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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9cf7649cc2
commit
af2ad942cd
6 files changed
+167
-7
No files matched your search
+4
-2
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
<div className="ml-6">
|
||||
<Check
|
||||
checked={standardPulls}
|
||||
onChange={(value) => 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(", ")}.`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
+75
-1
@@ -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<typeof RoutineSchema> & 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<string>,
|
||||
hidden: ReadonlySet<string> = 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
|
||||
|
||||
@@ -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");
|
||||
|
||||
+43
-1
@@ -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]!;
|
||||
|
||||
|
||||
Reference in new issue
Block a user