From 34ebc401c785f7e54336d8d1ed06f4087271162d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 22 Oct 2026 17:11:06 +0200 Subject: [PATCH] feat(versions): a cited table of version timers, and the window holding now Co-Authored-By: Claude Opus 5.5 --- src/shared/period.ts | 11 ++++ src/shared/versions.ts | 122 ++++++++++++++++++++++++++++++++++++ test/versions.test.ts | 138 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 src/shared/versions.ts create mode 100644 test/versions.test.ts diff --git a/src/shared/period.ts b/src/shared/period.ts index 9028d8c..bcc16af 100644 --- a/src/shared/period.ts +++ b/src/shared/period.ts @@ -45,6 +45,17 @@ export const PeriodSchema = z.union([ ]); export type Period = z.infer; +/** + * 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; + const msOf = (key: string) => Date.parse(`${key}T00:00:00Z`); const keyOf = (ms: number) => new Date(ms).toISOString().slice(0, 10); diff --git a/src/shared/versions.ts b/src/shared/versions.ts new file mode 100644 index 0000000..b919a99 --- /dev/null +++ b/src/shared/versions.ts @@ -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> = { + // 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> = 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" : ""}`; +} diff --git a/test/versions.test.ts b/test/versions.test.ts new file mode 100644 index 0000000..f689e0b --- /dev/null +++ b/test/versions.test.ts @@ -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"); + }); +});