From af2ad942cde2ddde884da4dd721f3b1a9bc843a2 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 16 Oct 2026 03:00:52 +0200 Subject: [PATCH] feat(routines): let a reader add standard-banner pulls beside the premium ones Most shops sell both banners' pulls. A shop's premium line is offered like any built-in; its standard line (Acquaint Fates, Star Rail Pass, Master Tapes, Resonite Crystals) only when the reader switches on Include standard-banner pulls under the built-ins switch. A new pref, standardPulls, absent meaning off, so no install gains anything until it asks. Co-Authored-By: Claude Opus 5.5 --- src/client/App.tsx | 6 ++- src/client/components/Controls.tsx | 23 +++++++-- src/client/state/usePrefs.ts | 9 ++++ src/shared/routines.ts | 76 +++++++++++++++++++++++++++++- test/controls.test.tsx | 16 +++++++ test/routines.test.ts | 44 ++++++++++++++++- 6 files changed, 167 insertions(+), 7 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 7c8b5d6..0007b6f 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -53,6 +53,7 @@ import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts"; import { taskStatus, type TaskShape } from "../shared/task.ts"; import { activeRoutines, + offered, copyRoutineState, mineFrom, routinesFor, @@ -234,7 +235,8 @@ export function App() { // Every built-in the reader could track, for the lists that offer them; the // strip carries only those not stopped one by one. const routinesHidden = new Set(prefs.routinesHidden ?? []); - const routines = activeRoutines(routineRule(prefs), copiedFrom); + const standardPulls = prefs.standardPulls ?? false; + const routines = activeRoutines(routineRule(prefs), copiedFrom, new Set(), standardPulls); const stripTasks = [...tasks.tasks, ...routines.filter((r) => !routinesHidden.has(r.id))]; /** @@ -527,7 +529,7 @@ export function App() { // A copy the reader made stands in for its built-in, so the original is // not offered; the copy is theirs, in Your own. ...routinesFor(game) - .filter((r) => !copiedFrom.has(r.id)) + .filter((r) => !copiedFrom.has(r.id) && offered(r, standardPulls)) .map((r): StripStanding => ({ id: r.id, game, diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index 39f1a8b..7ae4101 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -9,7 +9,7 @@ import { choreRule, routineRule, type Prefs } from "../state/usePrefs.ts"; import { YourOwn } from "./YourOwn.tsx"; import { isCustomGameId } from "../../shared/custom.ts"; import { exceptionLine, setFor, shownFor, type PerGame } from "../../shared/perGame.ts"; -import { asOfLabel, routinesFor } from "../../shared/routines.ts"; +import { asOfLabel, offered, routinesFor } from "../../shared/routines.ts"; const REGIONS: Array<{ id: Region; label: string }> = ( ["america", "europe", "asia"] as const @@ -103,13 +103,16 @@ export function Controls({ // neither is offered — a chip for it would be a control that does nothing. const choreGames = games.filter((g) => !prefs.hiddenGames.includes(g) && !isCustomGameId(g)); const routineGames = games.filter((g) => routinesFor(g).length > 0); + const standardPulls = prefs.standardPulls ?? false; + // The standard-banner lines the switch below would add, named in its hint. + const standardLines = routineGames.flatMap((g) => routinesFor(g)).filter((r) => r.pulls === "standard"); // Which periods have a task at all — the reader's own, or a built-in they // turned on — so a switch is offered only with something behind it. const periods = [ ...(own.tasks ?? []).map((t) => t.period.unit), ...routineGames .filter((g) => shownFor(routines, g)) - .flatMap((g) => routinesFor(g).map((r) => r.period.unit)), + .flatMap((g) => routinesFor(g).filter((r) => offered(r, standardPulls)).map((r) => r.period.unit)), ]; const taskPeriods = { week: periods.includes("week"), month: periods.includes("month") }; @@ -287,7 +290,7 @@ export function Controls({ games={routineGames} chipsLabel="Built-in weekly and monthly tasks per game" titleFor={(game) => { - const list = routinesFor(game); + const list = routinesFor(game).filter((r) => offered(r, standardPulls)); // Several pages per game now: quote the oldest, so the // tooltip never makes a year-old figure look current. const oldest = list.map((r) => r.asOf).sort()[0]!; @@ -308,6 +311,20 @@ export function Controls({ /> )} + {/* Most shops sell both banners' pulls. The premium line comes with + the built-ins; the standard line beside it only on request, one + answer for every game (owner, 2026-10-16). */} + {standardLines.length > 0 && ( +
+ onUpdate({ standardPulls: value })} + label="Include standard-banner pulls" + hint={`Shops that sell premium pulls mostly sell standard ones too. Off, only the premium line is tracked; on, these join it: ${standardLines.map((r) => r.title).join(", ")}.`} + /> +
+ )} + {/* Where weekly and monthly tasks sit: their own row under the dailies. Only offered once there is a task of that period — a switch with nothing behind it is a control that does nothing. */} diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 0e46c8d..822ec6a 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -230,6 +230,12 @@ export interface Prefs { * numbers stay stored under the id. */ routinesHidden?: string[] | undefined; + /** + * Whether a shop's standard-banner pulls are tracked beside its premium ones + * (`Routine.pulls`, owner, 2026-10-16). Absent means no — premium only — so + * an install from before the standard lines existed gains nothing new. + */ + standardPulls?: boolean | undefined; /** * The newest changelog entry the reader dismissed (PRD F18). Absent means * unrecorded, not "seen nothing": first-run setup sets it, so an absent value @@ -441,6 +447,9 @@ export function restorePrefsValue(incoming: unknown): Prefs { if (result.showRoutines !== undefined && typeof result.showRoutines !== "boolean") { result.showRoutines = undefined; } + if (result.standardPulls !== undefined && typeof result.standardPulls !== "boolean") { + result.standardPulls = undefined; + } // focusGame if (result.focusGame !== null && typeof result.focusGame !== "string") { diff --git a/src/shared/routines.ts b/src/shared/routines.ts index fbe4961..eee1206 100644 --- a/src/shared/routines.ts +++ b/src/shared/routines.ts @@ -38,6 +38,13 @@ export const RoutineSchema = z.object({ 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(), + /** + * Gacha pulls a shop sells, by banner. Most shops stock both: the premium + * (limited) line is offered with the game's other built-ins, the standard + * line only when the reader also asks for standard pulls + * (`prefs.standardPulls`, owner, 2026-10-16). Absent on everything else. + */ + pulls: z.enum(["premium", "standard"]).optional(), }); export type Routine = z.infer & TaskShape; @@ -107,12 +114,25 @@ export const ROUTINES: readonly Routine[] = [ game: "genshin", title: "Paimon's Bargains: Intertwined Fates", period: { unit: "month", startsOn: 1 }, + pulls: "premium", 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:paimons-bargains-acquaint", + game: "genshin", + title: "Paimon's Bargains: Acquaint Fates", + period: { unit: "month", startsOn: 1 }, + pulls: "standard", + measure: { kind: "amount", target: 5, unit: "fates" }, + source: "https://genshin-impact.fandom.com/wiki/Paimon%27s_Bargains", + asOf: "2026-08-23", + checked: "2026-10-16", + note: "The standard-banner line beside the Intertwined Fates, bought with Masterless Stardust.", + }, { id: "routine:genshin:spiral-abyss", game: "genshin", @@ -168,12 +188,26 @@ export const ROUTINES: readonly Routine[] = [ // The wiki marks the stock as refreshing monthly; the 1st is the owner's // in-game countdown on 2026-10-14, which ends at the reset on 1 Nov 2026. period: { unit: "month", startsOn: 1 }, + pulls: "premium", measure: { kind: "amount", target: 5, unit: "passes" }, source: "https://honkai-star-rail.fandom.com/wiki/Embers_Exchange", asOf: "2026-07-12", checked: "2026-10-14", note: "Bought with Undying Embers. The wiki says monthly; the 1st is ours. Confirmed in game, 2026-10-14.", }, + { + id: "routine:hsr:embers-exchange-standard", + game: "hsr", + title: "Embers Exchange: Star Rail Pass", + // Resets with the Special Pass line beside it: the 1st. + period: { unit: "month", startsOn: 1 }, + pulls: "standard", + measure: { kind: "amount", target: 5, unit: "passes" }, + source: "https://honkai-star-rail.fandom.com/wiki/Embers_Exchange", + asOf: "2026-07-12", + checked: "2026-10-16", + note: "The standard-banner line beside the Special Pass, bought with Undying Embers.", + }, // Zenless Zone Zero. { @@ -232,12 +266,26 @@ export const ROUTINES: readonly Routine[] = [ // The wiki marks the Fading Signal stock as refreshing monthly; the 1st is // the owner's in-game timer on 2026-10-16, which ends at the reset on 1 Nov. period: { unit: "month", startsOn: 1 }, + pulls: "premium", measure: { kind: "amount", target: 5, unit: "tapes" }, source: "https://zenless-zone-zero.fandom.com/wiki/Signal_Shop", asOf: "2026-09-09", checked: "2026-10-16", note: "Bought with Fading Signal; 5 a month per the wiki. The monthly reset on the 1st is ours. Confirmed in game, 2026-10-16.", }, + { + id: "routine:zzz:signal-shop-master-tapes", + game: "zzz", + title: "Signal Shop: Master Tapes", + // Resets with the Encrypted Master Tape line beside it: the 1st. + period: { unit: "month", startsOn: 1 }, + pulls: "standard", + measure: { kind: "amount", target: 5, unit: "tapes" }, + source: "https://zenless-zone-zero.fandom.com/wiki/Signal_Shop", + asOf: "2026-09-09", + checked: "2026-10-16", + note: "The standard-banner line beside the Encrypted Master Tapes, bought with Fading Signal.", + }, { id: "routine:zzz:signal-shop-boopons", game: "zzz", @@ -245,6 +293,7 @@ export const ROUTINES: readonly Routine[] = [ // The Bangbuck section's monthly stock, which resets with the Fading Signal // one (the 1st, per the owner's in-game timer). period: { unit: "month", startsOn: 1 }, + pulls: "premium", measure: { kind: "amount", target: 5, unit: "Boopons" }, source: "https://zenless-zone-zero.fandom.com/wiki/Signal_Shop", asOf: "2026-09-09", @@ -346,12 +395,26 @@ export const ROUTINES: readonly Routine[] = [ // The wiki marks the Tranquility Droplet stock monthly; the 1st is the // owner's in-game timer on 2026-10-16, which ends at the reset on 1 Nov. period: { unit: "month", startsOn: 1 }, + pulls: "premium", measure: { kind: "amount", target: 5, unit: "crystals" }, source: "https://infinity-nikki.fandom.com/wiki/Tranquility_Droplet", asOf: "2025-12-26", checked: "2026-10-16", note: "Bought with Tranquility Droplets, 5 a month. The 1st is ours. Confirmed in game, 2026-10-16.", }, + { + id: "routine:nikki:resonance-resonite", + game: "nikki", + title: "Resonance Store: Resonite Crystals", + // Resets with the Revelation Crystal line beside it: the 1st. + period: { unit: "month", startsOn: 1 }, + pulls: "standard", + measure: { kind: "amount", target: 5, unit: "crystals" }, + source: "https://infinity-nikki.fandom.com/wiki/Tranquility_Droplet", + asOf: "2025-12-26", + checked: "2026-10-16", + note: "The standard-banner line beside the Revelation Crystals, bought with Tranquility Droplets.", + }, // Neverness to Everness. { @@ -431,12 +494,23 @@ export function activeRoutines( rule: PerGame, copiedFrom: ReadonlySet, hidden: ReadonlySet = new Set(), + standardPulls = false, ): Routine[] { return ROUTINES.filter( - (r) => shownFor(rule, r.game) && !copiedFrom.has(r.id) && !hidden.has(r.id), + (r) => + shownFor(rule, r.game) && offered(r, standardPulls) && !copiedFrom.has(r.id) && !hidden.has(r.id), ); } +/** + * Whether a built-in is on offer at all: a shop's standard-banner line only + * when the reader asked for standard pulls, everything else always. Below the + * game rule and per-item tracking, not instead of them. + */ +export function offered(routine: Routine, standardPulls: boolean): boolean { + return routine.pulls !== "standard" || standardPulls; +} + /** * `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 diff --git a/test/controls.test.tsx b/test/controls.test.tsx index 44f36f1..cda0924 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -381,6 +381,22 @@ describe("Controls: the Your own summary counts tasks", () => { }); }); +describe("Controls: standard-banner pulls", () => { + test("offered beside the built-ins switch, and reads its own pref", () => { + const off = render({ ...PREFS, routineGames: ["genshin"] }); + expect(off).toContain("Include standard-banner pulls"); + expect(off).toContain("Paimon's Bargains: Acquaint Fates"); + const on = render({ ...PREFS, routineGames: ["genshin"], standardPulls: true }); + expect(checkboxes(on).filter(Boolean).length).toBe(checkboxes(off).filter(Boolean).length + 1); + }); + + test("not offered when no game listed has a standard line", () => { + expect(render({ ...PREFS, routineGames: ["endfield"] }, 0, ["endfield"])).not.toContain( + "Include standard-banner pulls", + ); + }); +}); + describe("Controls: weekly and monthly tasks under the dailies", () => { test("built-ins switched on globally count as a weekly task", () => { expect(render({ ...PREFS, showRoutines: true }, 0, ["zzz"])).toContain("Show weekly tasks"); diff --git a/test/routines.test.ts b/test/routines.test.ts index 6a2975d..fb8d0b7 100644 --- a/test/routines.test.ts +++ b/test/routines.test.ts @@ -8,6 +8,7 @@ import { mineFrom, copyRoutineState, hiddenAfter, + offered, } from "../src/shared/routines.ts"; import { choreRule, defaults, restorePrefsValue, routineRule, trackRoutinePatch } from "../src/client/state/usePrefs.ts"; import { shownFor } from "../src/shared/perGame.ts"; @@ -60,6 +61,7 @@ describe("ROUTINES", () => { ["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" }], + ["Paimon's Bargains: Acquaint 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 }], ]); @@ -67,6 +69,7 @@ describe("ROUTINES", () => { ["Echo of War", week, { kind: "amount", target: 3, unit: "runs" }], ["Nameless Honor weekly EXP", week, { kind: "amount", target: 8000, unit: "EXP" }], ["Embers Exchange: Star Rail Special Pass", { unit: "month", startsOn: 1 }, { kind: "amount", target: 5, unit: "passes" }], + ["Embers Exchange: Star Rail Pass", { unit: "month", startsOn: 1 }, { kind: "amount", target: 5, unit: "passes" }], ]); expect(shape("zzz")).toEqual([ ["Ridu Weekly", week, { kind: "amount", target: 2100, unit: "points" }], @@ -74,6 +77,7 @@ describe("ROUTINES", () => { ["Notorious Hunt", week, { kind: "amount", target: 3, unit: "runs" }], ["New Eridu City Fund weekly EXP", week, { kind: "amount", target: 10000, unit: "EXP" }], ["Signal Shop: Encrypted Master Tapes", { unit: "month", startsOn: 1 }, { kind: "amount", target: 5, unit: "tapes" }], + ["Signal Shop: Master Tapes", { unit: "month", startsOn: 1 }, { kind: "amount", target: 5, unit: "tapes" }], ["Signal Shop: Boopons", { unit: "month", startsOn: 1 }, { kind: "amount", target: 5, unit: "Boopons" }], ]); expect(shape("endfield")).toEqual([ @@ -96,6 +100,7 @@ describe("ROUTINES", () => { ["Mira Journey weekly EXP", week, { kind: "amount", target: 8000, unit: "Journey EXP" }], ["Realm of Breakthrough", week, { kind: "amount", target: 2, unit: "trials" }], ["Resonance Store: Revelation Crystals", { unit: "month", startsOn: 1 }, { kind: "amount", target: 5, unit: "crystals" }], + ["Resonance Store: Resonite Crystals", { unit: "month", startsOn: 1 }, { kind: "amount", target: 5, unit: "crystals" }], ]); }); @@ -109,7 +114,8 @@ describe("ROUTINES", () => { }); }); -const OFFERED = ROUTINES; +// What is on offer with standard-banner pulls left off, the default. +const OFFERED = ROUTINES.filter((r) => r.pulls !== "standard"); const idsOf = (...games: string[]) => OFFERED.filter((r) => games.includes(r.game)).map((r) => r.id); const RIDU = "routine:zzz:ridu-weekly"; @@ -229,6 +235,42 @@ describe("tracking one built-in, or not", () => { }); }); +describe("standard-banner pulls", () => { + const standard = ROUTINES.filter((r) => r.pulls === "standard"); + + test("each shop's standard line sits beside its premium one, on the same reset", () => { + // The switch adds a second line to a shop the reader already tracks; a + // standard line with no premium twin would arrive alone. + expect(standard.length).toBeGreaterThan(0); + for (const r of standard) { + const twin = ROUTINES.find( + (p) => p.game === r.game && p.pulls === "premium" && JSON.stringify(p.period) === JSON.stringify(r.period), + ); + expect(twin).toBeDefined(); + } + }); + + test("are left out unless the reader asks for them", () => { + const all = { all: true, on: undefined, off: undefined }; + const ids = (on: boolean) => activeRoutines(all, new Set(), new Set(), on).map((r) => r.id); + for (const r of standard) { + expect(ids(false)).not.toContain(r.id); + expect(ids(true)).toContain(r.id); + } + // Everything else is the same either way. + expect(ids(true).filter((id) => !standard.some((r) => r.id === id))).toEqual(ids(false)); + expect(offered(standard[0]!, false)).toBe(false); + expect(offered(standard[0]!, true)).toBe(true); + expect(offered(routinesFor("zzz")[0]!, false)).toBe(true); + }); + + test("the switch survives a restore, and junk in it is dropped", () => { + expect(restorePrefsValue({ standardPulls: true }).standardPulls).toBe(true); + expect(restorePrefsValue({ standardPulls: "yes" }).standardPulls).toBeUndefined(); + expect(restorePrefsValue({}).standardPulls).toBeUndefined(); + }); +}); + describe("tracking a built-in from the strip", () => { const ridu = routinesFor("zzz")[0]!;