Files
netris-nestri/packages/core/src/trial/index.ts
T
Wanjohi 4627343e3c feat(trial): the free days are Saturday, Sunday and Monday
Monday in place of Friday: the window is Saturday 00:00 to Tuesday 00:00,
Helsinki time, and the refusal outside it says Saturday to Monday.
2026-10-11 13:22:22 +03:00

305 lines
10 KiB
TypeScript

import { and, count, eq, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { BoxTable } from '../box/box.sql.js';
import { Database } from '../db/index.js';
import { ErrorCodes, VisibleError } from '../error.js';
import { fn } from '../fn.js';
import { Identifier } from '../id.js';
import { MachineTable } from '../machine/machine.sql.js';
import { SessionTable } from '../session/session.sql.js';
import { TeamTable } from '../team/team.sql.js';
import { LinkedAccountTable } from '../user/linked-account.sql.js';
import { UserTable } from '../user/user.sql.js';
import { TrialClaimTable } from './trial.sql.js';
/**
* The free weekend: play on rented GPUs without a plan, a little, at weekends.
*
* Three rules and nothing else:
*
* - **When.** Saturday 00:00 to Tuesday 00:00 — Saturday, Sunday and
* Monday, Monday in place of Friday since 2026-10-11 — on the clock of the
* place the hardware is run from. Outside that, a trial run is refused, and one still
* going when it closes is stopped.
* - **How much.** {@link HOURS} of live time per window, across every run.
* Unlike a plan's allowance, this one stops a run that reaches it: the
* trial is the hardware lent, not a budget being drawn down.
* - **For how long.** Until {@link PAID_SEATS} teams pay for a plan. Then the
* hardware is theirs and the trial closes for everyone.
*
* Downloading a game is not playing it and is never refused here; only a run
* is.
*/
export namespace Trial {
export const HOURS = 2;
export const PAID_SEATS = 5;
export const ZONE = 'Europe/Helsinki';
const SECONDS = HOURS * 3600;
/**
* What time it is, for every rule here. Replaceable so a test can stand on
* a Saturday; nothing else has a reason to touch it.
*/
export let now = () => new Date();
export function useClock(clock: () => Date) {
now = clock;
}
/** A zone's UTC offset at one instant, in milliseconds. */
function offsetMs(at: Date): number {
const name = new Intl.DateTimeFormat('en-US', { timeZone: ZONE, timeZoneName: 'longOffset' })
.formatToParts(at)
.find((p) => p.type === 'timeZoneName')!.value;
const m = /GMT([+-])(\d{2}):?(\d{2})?/.exec(name);
if (!m) return 0;
const sign = m[1] === '-' ? -1 : 1;
return sign * (Number(m[2]) * 60 + Number(m[3] ?? 0)) * 60_000;
}
/** Midnight at the start of a calendar day in {@link ZONE}, as an instant. */
function midnight(year: number, month: number, day: number): Date {
const guess = Date.UTC(year, month - 1, day);
// Twice, because the offset at the guess and at the answer can differ by
// the hour a clock change moved.
const once = guess - offsetMs(new Date(guess));
return new Date(guess - offsetMs(new Date(once)));
}
/** The calendar day an instant falls on in {@link ZONE}, and its weekday (Mon 1 … Sun 7). */
function localDay(at: Date) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: ZONE,
year: 'numeric',
month: 'numeric',
day: 'numeric',
weekday: 'short'
})
.formatToParts(at)
.map((p) => [p.type, p.value])
);
const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].indexOf(parts.weekday!) + 1;
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
weekday
};
}
function addDays(d: { year: number; month: number; day: number }, n: number) {
const t = new Date(Date.UTC(d.year, d.month - 1, d.day + n));
return { year: t.getUTCFullYear(), month: t.getUTCMonth() + 1, day: t.getUTCDate() };
}
export interface Window {
/** Whether `at` is inside it. */
open: boolean;
/** The window `at` is in, or the next one. */
start: Date;
end: Date;
}
/** The weekend window an instant is in, or the next one if it is in none. */
export function window(at: Date = now()): Window {
const today = localDay(at);
const sinceSaturday = (today.weekday - 6 + 7) % 7; // Sat 0, Sun 1, Mon 2, Tue 3 …
const open = sinceSaturday <= 2;
const saturday = addDays(today, open ? -sinceSaturday : 7 - sinceSaturday);
const tuesday = addDays(saturday, 3);
return {
open,
start: midnight(saturday.year, saturday.month, saturday.day),
end: midnight(tuesday.year, tuesday.month, tuesday.day)
};
}
/** Whether the trial is still offered: fewer than {@link PAID_SEATS} teams pay. */
export async function offered(): Promise<boolean> {
const [row] = await Database.use((tx) =>
tx
.select({ paid: count() })
.from(TeamTable)
.where(and(eq(TeamTable.plan, 'paid'), isNull(TeamTable.timeDeleted)))
);
return (row?.paid ?? 0) < PAID_SEATS;
}
/** Live seconds of a person's trial runs inside `[start, end)`, up to `now`. */
export async function usedSeconds(userId: string, w: Window, at: Date = now()) {
const [row] = await Database.use((tx) =>
tx
.select({
seconds: sql<string>`coalesce(sum(greatest(0, extract(epoch from
least(coalesce(${SessionTable.timeStopped}, ${at.toISOString()}::timestamptz), ${w.end.toISOString()}::timestamptz)
- greatest(${SessionTable.timeStarted}, ${w.start.toISOString()}::timestamptz)))), 0)`
})
.from(SessionTable)
.innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id))
.where(
and(
eq(BoxTable.userId, userId),
eq(SessionTable.trial, true),
sql`${SessionTable.timeStarted} is not null`,
isNull(SessionTable.timeDeleted)
)
)
);
return Math.floor(Number(row?.seconds ?? 0));
}
function when(at: Date): string {
return new Intl.DateTimeFormat('en-GB', {
timeZone: ZONE,
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
}).format(at);
}
/**
* Refuse a trial run that may not start, or record the claim and let it.
*
* The order is the order a person can act on: closed for everyone first,
* then "not you" (somebody already used this address or account), then
* "not now" (outside the window, or this window's hours are spent).
*/
export const assertMayStart = fn(
z.object({
teamId: z.string(),
userId: z.string(),
linkedAccountId: z.string(),
now: z.date().optional()
}),
async (input) => {
const at = input.now ?? now();
if (!(await offered())) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.ACCOUNT_RESTRICTED,
'The free weekend has closed: Nestri GPUs are now taken by paying teams. A plan gets you on.'
);
}
await claim(input);
const w = window(at);
if (!w.open) {
throw new VisibleError(
'rate_limit',
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
`Free play on Nestri GPUs is Saturday to Monday. The next window opens ${when(w.start)}.`
);
}
if ((await usedSeconds(input.userId, w, at)) >= SECONDS) {
const next = window(w.end);
throw new VisibleError(
'rate_limit',
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
`Your ${HOURS} free hours this weekend are used. The next window opens ${when(next.start)}.`
);
}
}
);
/**
* The team's claim, made on its first trial run.
*
* Checked by reading, then held by the unique indexes: two first runs in
* the same instant both read "unclaimed", and the second insert is refused
* with the same answer the read would have given.
*/
async function claim(input: { teamId: string; userId: string; linkedAccountId: string }) {
const existing = await Database.use((tx) =>
tx.select().from(TrialClaimTable).where(eq(TrialClaimTable.teamId, input.teamId))
);
if (existing.length > 0) return;
const [person] = await Database.use((tx) =>
tx.select({ email: UserTable.email }).from(UserTable).where(eq(UserTable.id, input.userId))
);
const [linked] = await Database.use((tx) =>
tx
.select({ steamId: LinkedAccountTable.providerAccountId })
.from(LinkedAccountTable)
.where(eq(LinkedAccountTable.id, input.linkedAccountId))
);
const email = person?.email?.trim().toLowerCase();
if (!email || !linked) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.ACCOUNT_RESTRICTED,
'The free weekend needs a verified email address and a linked Steam account.'
);
}
const used = () =>
new VisibleError(
'forbidden',
ErrorCodes.Permission.ACCOUNT_RESTRICTED,
'This email address or Steam account has already had the free weekend. A plan gets you on.'
);
const taken = await Database.use((tx) =>
tx
.select({ id: TrialClaimTable.id })
.from(TrialClaimTable)
.where(
sql`lower(${TrialClaimTable.email}) = ${email} or ${TrialClaimTable.steamId} = ${linked.steamId}`
)
.limit(1)
);
if (taken.length > 0) throw used();
try {
await Database.use((tx) =>
tx.insert(TrialClaimTable).values({
id: Identifier.ascending('trialClaim'),
teamId: input.teamId,
userId: input.userId,
email,
steamId: linked.steamId
})
);
} catch (err) {
const e = err as { code?: string; cause?: { code?: string } };
if (e?.code === '23505' || e?.cause?.code === '23505') throw used();
throw err;
}
}
/**
* The trial runs on one machine that must be stopped now: live, and either
* outside the window or past the hours. Asked when the host polls for
* work, so a run is stopped within one poll of crossing the line.
*/
export async function overdue(machineId: string, at: Date = now()) {
const live = await Database.use((tx) =>
tx
.select({ sessionId: SessionTable.id, boxId: BoxTable.id, userId: BoxTable.userId })
.from(SessionTable)
.innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id))
.innerJoin(MachineTable, eq(BoxTable.machineId, MachineTable.id))
.where(
and(
eq(BoxTable.machineId, machineId),
eq(SessionTable.trial, true),
eq(SessionTable.state, 'live'),
isNull(SessionTable.timeDeleted)
)
)
);
const w = window(at);
const out: { sessionId: string; boxId: string; reason: 'window' | 'hours' }[] = [];
for (const run of live) {
if (!w.open) out.push({ sessionId: run.sessionId, boxId: run.boxId, reason: 'window' });
else if ((await usedSeconds(run.userId, w, at)) >= SECONDS) {
out.push({ sessionId: run.sessionId, boxId: run.boxId, reason: 'hours' });
}
}
return out;
}
}