diff --git a/src/shared/games.ts b/src/shared/games.ts index b012de9..26336ea 100644 --- a/src/shared/games.ts +++ b/src/shared/games.ts @@ -62,6 +62,8 @@ export interface GameMeta { } export const GAMES: Record = { + // EU reset at 04:00 UTC+1 confirmed in game by the owner on 2026-10-16: the + // Trounce Domains menu reads "every Monday 4AM GMT+1". genshin: { id: "genshin", name: "Genshin Impact", short: "Genshin", hue: "#4EA8DE" , studio: "HoYoverse", dailyTasks: "Commissions, resin" }, hsr: { id: "hsr", name: "Honkai: Star Rail", short: "Star Rail", hue: "#7B8CFF" , studio: "HoYoverse", dailyTasks: "Daily training, Trailblaze Power" }, zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" , studio: "HoYoverse", dailyTasks: "Daily missions, battery" }, diff --git a/src/shared/routines.ts b/src/shared/routines.ts index 9d41bf3..f80a6c4 100644 --- a/src/shared/routines.ts +++ b/src/shared/routines.ts @@ -14,6 +14,11 @@ import { MeasureSchema, type TaskShape } from "./task.ts"; * then visibly ten months old rather than presented as current. Nothing here is * inferred; add one only when a page states it. * + * The one exception is a figure the repository owner confirmed in the game + * when no page states it (owner, 2026-10-14). It still links the best page + * there is, and its `note` says `Confirmed in game, YYYY-MM-DD.` so the reader + * can tell which figure is ours rather than the page's. + * * Off unless the reader says yes — for every game (`prefs.showRoutines`) or for * one (`prefs.routineGames`, `prefs.routinesOff`; see `perGame.ts`). Its id is in * the reserved `routine:` space, and its ticks and numbers are stored under that @@ -37,6 +42,102 @@ export const RoutineSchema = z.object({ export type Routine = z.infer & TaskShape; export const ROUTINES: readonly Routine[] = [ + // Genshin Impact. Every weekly item rolls with the Monday reset, which each + // page states for itself; the monthly two name their own day. + { + id: "routine:genshin:weekly-bosses", + game: "genshin", + title: "Weekly Bosses", + period: { unit: "week", startsOn: 1 }, + // The three discounted claims; a fourth costs full resin. + measure: { kind: "amount", target: 3, unit: "runs" }, + source: "https://genshin-impact.fandom.com/wiki/Weekly_Boss", + asOf: "2026-08-21", + checked: "2026-10-14", + note: "Trounce Domains: three runs at half the Resin. Confirmed in game, 2026-10-16.", + }, + { + id: "routine:genshin:battle-pass-exp", + game: "genshin", + title: "Battle Pass weekly EXP", + period: { unit: "week", startsOn: 1 }, + measure: { kind: "amount", target: 10000, unit: "EXP" }, + source: "https://genshin-impact.fandom.com/wiki/Battle_Pass", + asOf: "2026-01-08", + checked: "2026-10-14", + note: "The cap on EXP from daily and weekly missions. Confirmed in game, 2026-10-14.", + }, + { + id: "routine:genshin:bounties", + game: "genshin", + title: "Reputation Bounties", + period: { unit: "week", startsOn: 1 }, + measure: { kind: "amount", target: 3, unit: "bounties" }, + source: "https://genshin-impact.fandom.com/wiki/Bounties", + asOf: "2026-06-13", + checked: "2026-10-14", + note: "Counted apart from Reputation Requests. Confirmed in game, 2026-10-16.", + }, + { + id: "routine:genshin:requests", + game: "genshin", + title: "Reputation Requests", + period: { unit: "week", startsOn: 1 }, + measure: { kind: "amount", target: 3, unit: "requests" }, + source: "https://genshin-impact.fandom.com/wiki/Request", + asOf: "2026-06-13", + checked: "2026-10-14", + note: "Three across all nations, not three each; apart from Bounties. Confirmed in game, 2026-10-16.", + }, + { + id: "routine:genshin:realm-depot", + game: "genshin", + title: "Realm Depot: Realm Treasures", + // The page says weekly and names no day; Monday is the owner's in-game + // restock timer. No one target either, so it is ticked once a week. + period: { unit: "week", startsOn: 1 }, + measure: { kind: "days", required: 1 }, + source: "https://genshin-impact.fandom.com/wiki/Serenitea_Pot/Realm_Depot", + asOf: "2026-10-12", + checked: "2026-10-16", + note: "Bought with Realm Currency; the whole stock costs 13,100. The wiki says weekly; the Monday is ours. Confirmed in game, 2026-10-16.", + }, + { + id: "routine:genshin:paimons-bargains", + game: "genshin", + title: "Paimon's Bargains: Intertwined Fates", + period: { unit: "month", startsOn: 1 }, + measure: { kind: "amount", target: 5, unit: "fates" }, + source: "https://genshin-impact.fandom.com/wiki/Paimon%27s_Bargains", + asOf: "2026-08-23", + checked: "2026-10-14", + note: "Bought with Masterless Stardust. Confirmed in game, 2026-10-16.", + }, + { + id: "routine:genshin:spiral-abyss", + game: "genshin", + title: "Spiral Abyss", + // The page names the reset and no target, so it is ticked once a cycle. + period: { unit: "month", startsOn: 16 }, + measure: { kind: "days", required: 1 }, + source: "https://genshin-impact.fandom.com/wiki/Spiral_Abyss", + asOf: "2026-08-29", + checked: "2026-10-14", + note: "Confirmed in game, 2026-10-16.", + }, + { + id: "routine:genshin:imaginarium-theater", + game: "genshin", + title: "Imaginarium Theater", + period: { unit: "month", startsOn: 1 }, + measure: { kind: "days", required: 1 }, + source: "https://genshin-impact.fandom.com/wiki/Imaginarium_Theater", + asOf: "2026-09-01", + checked: "2026-10-14", + note: "Confirmed in game, 2026-10-16.", + }, + + // Zenless Zone Zero. { id: "routine:zzz:ridu-weekly", game: "zzz", @@ -48,6 +149,8 @@ export const ROUTINES: readonly Routine[] = [ asOf: "2025-12-02", checked: "2026-10-13", }, + + // Arknights: Endfield. { id: "routine:endfield:weekly-routine", game: "endfield", diff --git a/test/controls.test.tsx b/test/controls.test.tsx index e507787..a8ff1ab 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -325,7 +325,8 @@ describe("Controls: built-in weekly and monthly tasks", () => { }); test("no switch when no game on the list has any", () => { - expect(render(PREFS)).not.toContain("built-in weekly and monthly tasks"); + // Arknights and Blue Archive have none. + expect(render(PREFS, 0, ["arknights", "ba"])).not.toContain("built-in weekly and monthly tasks"); }); }); diff --git a/test/routines.test.ts b/test/routines.test.ts index b67fb4c..dbbe69f 100644 --- a/test/routines.test.ts +++ b/test/routines.test.ts @@ -33,6 +33,15 @@ describe("ROUTINES", () => { } }); + test("a figure confirmed in game says so, with the day", () => { + // The one exception to "a page states it" (routines.ts): the reader must + // be able to tell our figure from the page's. + for (const r of ROUTINES) { + if (r.note?.toLowerCase().includes("in game") !== true) continue; + expect(r.note).toMatch(/Confirmed in game, \d{4}-\d{2}-\d{2}\./); + } + }); + test("a routine without a source is refused", () => { const { source: _s, ...bare } = ROUTINES[0]!; expect(RoutineSchema.safeParse(bare).success).toBe(false); @@ -44,7 +53,16 @@ describe("ROUTINES", () => { const shape = (game: string): unknown[] => routinesFor(game).map((r) => [r.title, r.period, r.measure]); const week = { unit: "week" as const, startsOn: 1 }; - expect(shape("genshin")).toEqual([]); + expect(shape("genshin")).toEqual([ + ["Weekly Bosses", week, { kind: "amount", target: 3, unit: "runs" }], + ["Battle Pass weekly EXP", week, { kind: "amount", target: 10000, unit: "EXP" }], + ["Reputation Bounties", week, { kind: "amount", target: 3, unit: "bounties" }], + ["Reputation Requests", week, { kind: "amount", target: 3, unit: "requests" }], + ["Realm Depot: Realm Treasures", week, { kind: "days", required: 1 }], + ["Paimon's Bargains: Intertwined Fates", { unit: "month", startsOn: 1 }, { kind: "amount", target: 5, unit: "fates" }], + ["Spiral Abyss", { unit: "month", startsOn: 16 }, { kind: "days", required: 1 }], + ["Imaginarium Theater", { unit: "month", startsOn: 1 }, { kind: "days", required: 1 }], + ]); expect(shape("hsr")).toEqual([]); expect(shape("zzz")).toEqual([ ["Ridu Weekly", week, { kind: "amount", target: 1300, unit: "points" }], @@ -131,10 +149,12 @@ describe("a reader's answers from before the global switch carry over", () => { describe("which game to ask about", () => { const off = { all: false }; test("the first game on screen with built-ins, not asked and not on", () => { - expect(routineToAsk(["genshin", "zzz", "endfield"], off, undefined, false)).toBe("zzz"); - expect(routineToAsk(["genshin", "zzz", "endfield"], off, ["zzz"], false)).toBe("endfield"); + // Arknights has none, so the first game asked about is the next one. + expect(routineToAsk(["arknights", "zzz", "endfield"], off, undefined, false)).toBe("zzz"); + expect(routineToAsk(["arknights", "zzz", "endfield"], off, ["zzz"], false)).toBe("endfield"); expect(routineToAsk(["zzz"], { all: false, on: ["zzz"] }, undefined, false)).toBeNull(); - expect(routineToAsk(["genshin"], off, undefined, false)).toBeNull(); + expect(routineToAsk(["arknights"], off, undefined, false)).toBeNull(); + expect(routineToAsk(["genshin"], off, undefined, false)).toBe("genshin"); }); test("never once the reader has answered the global switch", () => {