feat(versions): a cited table of version timers, and the window holding now

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-22 17:11:06 +02:00
1 parent 71b5077846
commit 34ebc401c7
3 files changed
+271

No files matched your search

+11
View File
@@ -45,6 +45,17 @@ export const PeriodSchema = z.union([
]);
export type Period = z.infer<typeof PeriodSchema>;
/**
* A built-in's period: any of the above, or "until the game's next version"
* (`versions.ts`). Code only — never in `PeriodSchema`, so a stored task, an
* export and an older cached bundle never meet it (spec 2026-10-22).
*/
export const RoutinePeriodSchema = z.union([
PeriodSchema,
z.object({ unit: z.literal("version") }),
]);
export type RoutinePeriod = z.infer<typeof RoutinePeriodSchema>;
const msOf = (key: string) => Date.parse(`${key}T00:00:00Z`);
const keyOf = (ms: number) => new Date(ms).toISOString().slice(0, 10);
+122
View File
@@ -0,0 +1,122 @@
import type { GameId } from "./schema.ts";
import { DAY } from "./time.ts";
/**
* Version timers, per game — what a routine that "resets with the version"
* follows (spec 2026-10-22).
*
* Hand-kept and held to the bar `ROUTINES` is: a cited page and the day we
* read it, and a figure the repository owner read in game says so in `note`
* (`Confirmed in game, YYYY-MM-DD.`). No feed source dates these yet; one can
* replace entries later.
*
* A version ends at an **instant** — when the next version's stock appears,
* the same on every server — not at a daily reset, which on Asia would roll
* the shop about fourteen hours before it restocks.
*
* **Only a recorded `end` rolls anything.** Past the newest recorded end the
* window is the version that began there, open until someone records its end;
* the estimate from `VERSION_DAYS` is printed and never acted on. AGENTS.md:
* never act on an invented date.
*/
export interface GameVersion {
game: GameId;
/** A label, never parsed: 3.8 may be followed by 4.0. */
number: string;
/** ISO UTC instant the version went live. */
start: string;
/** ISO UTC instant the next version's stock appears; absent when unknown. */
end?: string;
/** The next version's number, when a page or the owner names it. */
next?: string;
source: string;
/** When we last read it. */
checked: string;
note?: string;
}
export const VERSIONS: readonly GameVersion[] = [
{
game: "wuwa",
number: "3.7",
// 11:00 UTC+8 on 30 September, after maintenance (FandomWire, Gematsu).
start: "2026-09-30T03:00:00Z",
// The owner's in-game shop timer: 19d 20h at about 14:30 UTC on
// 2026-10-22, and "11 Nov ~10–11 UTC" on 2026-10-16. Pages give the day only.
end: "2026-11-11T10:30:00Z",
next: "3.8",
source: "https://allthings.how/wuthering-waves-3-7-banners-schedule-hsin-and-suoming-release-dates/",
checked: "2026-10-22",
note: "The page gives 30 Sep to 11 Nov; the hour of the end is the shop's timer. Confirmed in game, 2026-10-22.",
},
];
/** A version's usual length in days, for the estimate only. */
export const VERSION_DAYS: Partial<Record<GameId, number>> = {
// 3.7 ran 42 days (allthings.how, above); AGENTS.md: patch cycles are ~6 weeks.
wuwa: 42,
};
export interface VersionWindow {
/** The version holding `now`; undefined when no page or reading names it. */
number?: string | undefined;
/** The start instant's UTC date: the period key amounts are stored under. */
key: string;
start: number;
/** Only when recorded. */
end?: number | undefined;
/** start + the usual length, when no end is recorded. Shown, never acted on. */
estimate?: number | undefined;
nextNumber?: string | undefined;
}
const keyOf = (ms: number) => new Date(ms).toISOString().slice(0, 10);
/** The version holding `now`, or undefined for a game with none listed (or before the first). */
export function versionWindow(
game: string,
now: number,
versions: readonly GameVersion[] = VERSIONS,
days: Partial<Record<string, number>> = VERSION_DAYS,
): VersionWindow | undefined {
const list = versions
.filter((v) => v.game === game)
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
const usual = days[game];
const open = (start: number, number: string | undefined, nextNumber: string | undefined): VersionWindow => ({
number,
key: keyOf(start),
start,
end: undefined,
estimate: usual === undefined ? undefined : start + usual * DAY,
nextNumber,
});
for (let i = list.length - 1; i >= 0; i--) {
const v = list[i]!;
const start = Date.parse(v.start);
if (now < start) continue;
if (v.end === undefined) return open(start, v.number, v.next);
const end = Date.parse(v.end);
if (now < end)
return { number: v.number, key: keyOf(start), start, end, estimate: undefined, nextNumber: v.next };
// Past a recorded end with no entry after it: the version that began there.
return open(end, v.next, undefined);
}
return undefined;
}
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** `11 Nov`, in UTC — the same wherever it is read, like `asOfLabel`. */
const dayLabel = (ms: number) => {
const d = new Date(ms);
return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
};
/** "resets with 3.8 · 11 Nov", or the next-version form with its estimate. */
export function versionCaption(w: VersionWindow, now: number): string {
if (w.end !== undefined)
return `resets with ${w.nextNumber ?? "next version"} · ${dayLabel(w.end)}`;
const head = `resets with next version${w.nextNumber ? ` (${w.nextNumber})` : ""}`;
if (w.estimate === undefined) return head;
return `${head} · expected ~${dayLabel(w.estimate)}${now > w.estimate ? ", not yet confirmed" : ""}`;
}
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, test } from "bun:test";
import { DAY } from "../src/shared/time.ts";
import {
VERSION_DAYS,
VERSIONS,
versionCaption,
versionWindow,
type GameVersion,
} from "../src/shared/versions.ts";
/**
* Version timers: a hand-kept, cited table (spec 2026-10-22). A routine resets
* only at an end the table records; an estimate is shown and never acted on.
*/
const at = (iso: string) => Date.parse(iso);
describe("VERSIONS", () => {
test("each game's versions are in order and meet end to start", () => {
const games = new Set(VERSIONS.map((v) => v.game));
for (const game of games) {
const list = VERSIONS.filter((v) => v.game === game);
list.forEach((v, i) => {
expect(Number.isFinite(at(v.start))).toBe(true);
if (v.end !== undefined) expect(at(v.end)).toBeGreaterThan(at(v.start));
const next = list[i + 1];
if (next !== undefined) {
expect(at(next.start)).toBeGreaterThan(at(v.start));
if (v.end !== undefined) expect(at(v.end)).toBe(at(next.start));
else expect(v.end).toBeDefined(); // only the newest may be open
}
});
}
});
test("every one cites an https page and the day it was read", () => {
for (const v of VERSIONS) {
expect(v.source.startsWith("https://")).toBe(true);
expect(v.checked).toMatch(/^\d{4}-\d{2}-\d{2}$/);
if (v.note?.toLowerCase().includes("in game") === true)
expect(v.note).toMatch(/Confirmed in game, \d{4}-\d{2}-\d{2}\./);
}
});
});
describe("versionWindow", () => {
test("inside 3.7: its number, its key, its recorded end", () => {
expect(versionWindow("wuwa", at("2026-10-22T14:30:00Z"))).toEqual({
number: "3.7",
key: "2026-09-30",
start: at("2026-09-30T03:00:00Z"),
end: at("2026-11-11T10:30:00Z"),
estimate: undefined,
nextNumber: "3.8",
});
});
test("rolls at the recorded instant, not a minute before", () => {
expect(versionWindow("wuwa", at("2026-11-11T10:29:00Z"))?.number).toBe("3.7");
expect(versionWindow("wuwa", at("2026-11-11T10:30:00Z"))).toEqual({
number: "3.8",
key: "2026-11-11",
start: at("2026-11-11T10:30:00Z"),
end: undefined,
estimate: at("2026-11-11T10:30:00Z") + 42 * DAY,
nextNumber: undefined,
});
});
test("never rolls on an estimate: far past it, still 3.8 under the same key", () => {
const w = versionWindow("wuwa", at("2027-02-01T00:00:00Z"));
expect(w?.number).toBe("3.8");
expect(w?.key).toBe("2026-11-11");
});
const open: GameVersion = {
game: "wuwa",
number: "4.0",
start: "2026-12-23T03:00:00Z",
next: "4.1",
source: "https://example.org/",
checked: "2026-10-22",
};
test("a newest entry with no end is open, estimated from its own start", () => {
const w = versionWindow("wuwa", at("2026-12-30T00:00:00Z"), [open]);
expect(w).toEqual({
number: "4.0",
key: "2026-12-23",
start: at(open.start),
end: undefined,
estimate: at(open.start) + 42 * DAY,
nextNumber: "4.1",
});
});
test("no usual length, no estimate", () => {
expect(versionWindow("wuwa", at("2026-12-30T00:00:00Z"), [open], {})?.estimate).toBeUndefined();
});
test("a game with no versions has no window", () => {
expect(versionWindow("genshin", at("2026-10-22T00:00:00Z"))).toBeUndefined();
});
test("WuWa's usual length is six weeks", () => {
expect(VERSION_DAYS.wuwa).toBe(42);
});
});
describe("versionCaption", () => {
const base = { key: "2026-11-11", start: at("2026-11-11T10:30:00Z") };
const estimate = at("2026-12-23T10:30:00Z");
test("a recorded end names the next version and the day", () => {
const w = versionWindow("wuwa", at("2026-10-22T14:30:00Z"))!;
expect(versionCaption(w, at("2026-10-22T14:30:00Z"))).toBe("resets with 3.8 · 11 Nov");
});
test("no recorded end: the next version, if named, and the estimate", () => {
const now = at("2026-11-20T00:00:00Z");
expect(versionCaption({ ...base, number: "3.8", nextNumber: "3.9", estimate }, now)).toBe(
"resets with next version (3.9) · expected ~23 Dec",
);
expect(versionCaption({ ...base, number: "3.8", estimate }, now)).toBe(
"resets with next version · expected ~23 Dec",
);
});
test("past the estimate, it says the end is not yet confirmed", () => {
expect(versionCaption({ ...base, estimate }, at("2027-01-02T00:00:00Z"))).toBe(
"resets with next version · expected ~23 Dec, not yet confirmed",
);
});
test("no estimate at all", () => {
expect(versionCaption(base, at("2026-11-20T00:00:00Z"))).toBe("resets with next version");
});
});