Files
gacha-event-tracker/src/shared/routines.ts
T
Lucas WintherandClaude Opus 5.5 af6a02cb34 feat(routines): Genshin's weekly and monthly built-ins
Weekly Bosses, Battle Pass weekly EXP, Reputation Bounties and Requests, the
Realm Depot, Paimon's Bargains (Intertwined Fates), Spiral Abyss and
Imaginarium Theater, each from a dated wiki page and confirmed in game by the
owner. Records the one exception to "a page states it": a figure the owner
confirmed in game says so in its note, with the day, and a test holds it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:00:25 +02:00

274 lines
9.9 KiB
TypeScript

import { z } from "zod";
import { PeriodSchema } from "./period.ts";
import { GameId } from "./schema.ts";
import { shownFor, type PerGame } from "./perGame.ts";
import { MeasureSchema, type TaskShape } from "./task.ts";
/**
* Weekly and monthly tasks the app ships for a game — the built-in counterpart of a
* reader's own `mytask:` (PRD F17).
*
* Each is a claim about a game we did not write, so it is held to the bar the
* standing chores in `games.ts` are: **a cited source, and the date that source
* gives**, both shown to the reader. A figure from a ten-month-old guide is
* 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
* id exactly as a reader's task's are.
*/
export const RoutineSchema = z.object({
id: z.string().regex(/^routine:[a-z0-9]+:[a-z0-9-]+$/),
game: GameId,
title: z.string().min(1).max(80),
period: PeriodSchema,
measure: MeasureSchema,
/** The page that states it. */
source: z.string().url().startsWith("https://"),
/** The date that page gives for itself — what the figure is as of. */
asOf: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
/** When we last read it. */
checked: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
/** Anything the reader needs to know before relying on it. */
note: z.string().max(140).optional(),
});
export type Routine = z.infer<typeof RoutineSchema> & 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",
title: "Ridu Weekly",
period: { unit: "week", startsOn: 1 },
// The last milestone; every reward is claimed by then.
measure: { kind: "amount", target: 1300, unit: "points" },
source: "https://game8.co/games/Zenless-Zone-Zero/archives/484723",
asOf: "2025-12-02",
checked: "2026-10-13",
},
// Arknights: Endfield.
{
id: "routine:endfield:weekly-routine",
game: "endfield",
title: "Weekly Routine",
period: { unit: "week", startsOn: 1 },
measure: { kind: "amount", target: 10, unit: "points" },
source: "https://endfield.wiki.gg/wiki/Weekly_Routine",
asOf: "2026-04-11",
checked: "2026-10-13",
note: "Unlocks at Authority Level 15.",
},
];
/** A game's built-ins, in the order listed. */
export function routinesFor(game: string): Routine[] {
return ROUTINES.filter((r) => r.game === game);
}
/**
* The built-ins on screen: those of games the rule shows, less any the reader
* made their own (`Task.from`) — the copy replaces the original while it
* exists, and deleting the copy brings the original back — and less any they
* stopped tracking one by one (`prefs.routinesHidden`). Leave `hidden` out to
* get every built-in the reader could track, which is what settings lists.
*/
export function activeRoutines(
rule: PerGame,
copiedFrom: ReadonlySet<string>,
hidden: ReadonlySet<string> = new Set(),
): Routine[] {
return ROUTINES.filter(
(r) => shownFor(rule, r.game) && !copiedFrom.has(r.id) && !hidden.has(r.id),
);
}
/**
* `prefs.routinesHidden` after the reader tracks one built-in or stops. Per
* item and never per game, so it moves nothing the game's switch says, and a
* built-in we ship later for that game still arrives. Absent when empty, like
* the other lists.
*/
export function hiddenAfter(
hidden: readonly string[] | undefined,
id: string,
track: boolean,
): string[] | undefined {
const rest = (hidden ?? []).filter((h) => h !== id);
const next = track ? rest : [...rest, id];
return next.length > 0 ? next : undefined;
}
/**
* The one game to ask "track its weekly tasks?" about, or null.
*
* Only a game on screen — asking about a game the reader switched off or is not
* focused on would be asking about something they are not looking at — and
* only once: an answer either way is recorded in `asked`. Never once the reader
* has `answered` the global switch: that answer covers every game at once.
*/
export function routineToAsk(
visible: readonly string[],
rule: PerGame,
asked: readonly string[] | undefined,
answered: boolean,
): string | null {
if (answered) return null;
for (const game of visible) {
if (shownFor(rule, game) || asked?.includes(game)) continue;
if (routinesFor(game).length > 0) return game;
}
return null;
}
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** `2025-12-02` → `2 Dec 2025`: the same in every locale, so it reads the same wherever it is quoted. */
export function asOfLabel(day: string): string {
const [y, m, d] = day.split("-").map(Number);
return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`;
}
/**
* A reader's own task copied from a built-in, remembering where it came from.
* The copy is theirs to edit; the original stays hidden while it exists.
*/
export function mineFrom(routine: Routine): {
game: string;
title: string;
period: Routine["period"];
measure: Routine["measure"];
from: string;
} {
return {
game: routine.game,
title: routine.title,
period: routine.period,
measure: routine.measure,
from: routine.id,
};
}
/**
* What *Make it mine* hands the day log and the amounts store: the built-in's
* record, filed under the copy's id. Pure, and only ever merged in — the
* originals stay under the routine's id, so deleting the copy shows the
* built-in exactly as it was.
*/
export function copyRoutineState<L, A>(
logs: Readonly<Record<string, L>>,
amounts: Readonly<Record<string, A>>,
fromId: string,
toId: string,
): { daily: Record<string, L>; amounts: Record<string, A> } {
const log = logs[fromId];
const numbers = amounts[fromId];
return {
daily: log === undefined ? {} : { [toId]: log },
amounts: numbers === undefined ? {} : { [toId]: numbers },
};
}