diff --git a/src/shared/routines.ts b/src/shared/routines.ts new file mode 100644 index 0000000..2492d67 --- /dev/null +++ b/src/shared/routines.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { PeriodSchema } from "./period.ts"; +import { GameId } from "./schema.ts"; +import { MeasureSchema, type TaskShape } from "./task.ts"; + +/** + * Weekly 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. + * + * Off unless the reader says yes, per game (`prefs.routineGames`). 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 & TaskShape; + +export const ROUTINES: readonly Routine[] = [ + { + 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", + }, + { + 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); +} diff --git a/src/shared/task.ts b/src/shared/task.ts index 43c422c..76eda6a 100644 --- a/src/shared/task.ts +++ b/src/shared/task.ts @@ -60,9 +60,15 @@ export const TaskSchema = z.object({ }); export type Task = z.infer; +/** + * What the strip and `taskStatus` need from a task — a reader's own, or a + * built-in routine (routines.ts), which shares this shape under its own id. + */ +export type TaskShape = Pick; + /** Where the reader is with a task this period. Pure; `now` explicit. */ export function taskStatus(input: { - task: Task; + task: Pick; region: Region; now: number; logged: readonly string[]; @@ -105,7 +111,7 @@ function ordinal(n: number): string { } /** One line saying when it rolls and what it wants. */ -export function taskCaption(task: Task): string { +export function taskCaption(task: Pick): string { const when = task.period.unit === "week" ? `every week from ${WEEKDAYS[task.period.startsOn - 1]}` diff --git a/test/routines.test.ts b/test/routines.test.ts new file mode 100644 index 0000000..b9e632c --- /dev/null +++ b/test/routines.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { ROUTINES, RoutineSchema, routinesFor } from "../src/shared/routines.ts"; +import { taskStatus } from "../src/shared/task.ts"; + +/** + * Weekly tasks the app ships for a game. Each is a claim about a game we did + * not write, so each must say where it came from and as of when — the bar + * `dailyTasks` in games.ts is held to. + */ +describe("ROUTINES", () => { + test("every one parses, and ids are unique", () => { + for (const r of ROUTINES) expect(RoutineSchema.safeParse(r).success).toBe(true); + expect(new Set(ROUTINES.map((r) => r.id)).size).toBe(ROUTINES.length); + }); + + test("every one names its game in its id, and cites an https source with a date", () => { + for (const r of ROUTINES) { + expect(r.id.split(":")[1]).toBe(r.game); + expect(r.source.startsWith("https://")).toBe(true); + expect(r.asOf).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(r.checked).toMatch(/^\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); + }); + + test("the first two, as sourced", () => { + expect(routinesFor("zzz").map((r) => [r.title, r.measure])).toEqual([ + ["Ridu Weekly", { kind: "amount", target: 1300, unit: "points" }], + ]); + expect(routinesFor("endfield").map((r) => [r.title, r.measure])).toEqual([ + ["Weekly Routine", { kind: "amount", target: 10, unit: "points" }], + ]); + expect(routinesFor("genshin")).toEqual([]); + }); + + test("a routine has a status like any task", () => { + const s = taskStatus({ + task: routinesFor("zzz")[0]!, region: "europe", + now: Date.parse("2026-10-15T12:00:00Z"), logged: [], + amounts: { "2026-10-12": { value: 900, at: "2026-10-15T12:00:00.000Z" } }, + }); + expect(s).toMatchObject({ scope: "week", count: 900, required: 1300 }); + }); +});