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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-13 14:06:34 +02:00
1 parent 641c6a52b9
commit 8aa820e2c0
3 files changed
+121 -2

No files matched your search

+65
View File
@@ -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<typeof RoutineSchema> & 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);
}
+8 -2
View File
@@ -60,9 +60,15 @@ export const TaskSchema = z.object({
});
export type Task = z.infer<typeof TaskSchema>;
/**
* 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<Task, "id" | "game" | "title" | "period" | "measure">;
/** Where the reader is with a task this period. Pure; `now` explicit. */
export function taskStatus(input: {
task: Task;
task: Pick<Task, "game" | "period" | "measure">;
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<Task, "period" | "measure">): string {
const when =
task.period.unit === "week"
? `every week from ${WEEKDAYS[task.period.startsOn - 1]}`
+48
View File
@@ -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 });
});
});