From 8aa820e2c0d9076913c847130b26f266f6b81d57 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 13 Oct 2026 14:06:34 +0200 Subject: [PATCH] feat(routines): ship sourced weekly tasks for ZZZ and Endfield Ridu Weekly (1,300 points) and the Weekly Routine (10 points), each with the page that states it and that page's own date, so a figure from an old guide shows its age. Routines share the task shape, so they draw and count like a reader's own. Co-Authored-By: Claude Opus 5.5 --- src/shared/routines.ts | 65 ++++++++++++++++++++++++++++++++++++++++++ src/shared/task.ts | 10 +++++-- test/routines.test.ts | 48 +++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 src/shared/routines.ts create mode 100644 test/routines.test.ts 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 }); + }); +});