From 327f6389cc27afc4aea77de233d0f9d7c0ef4055 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 6 Nov 2026 09:06:47 +0100 Subject: [PATCH] feat(endfield): track AIC Quota Exchange permits by banner rotation The shop's five permits restock when the character and weapon banners change together, which no week, month or fixed cycle can express: rotations ran 21 days and then 19. A rotation is not a game version, so it gets its own table and caption, and follows the version rule that only a recorded end resets the count. The dates and reset rule are the owner's in-game reading, as no page states them. Co-Authored-By: Claude Opus 5.5 --- TODO.md | 5 ++ docs/DATA-MODEL.md | 6 +++ docs/PRD.md | 4 +- .../2026-10-14-built-in-routines-design.md | 1 + src/client/components/Dailies.tsx | 6 +-- .../components/redesign/RoutineDrawer.tsx | 18 +++---- .../components/redesign/TimelinePage.tsx | 16 ++++--- src/shared/period.ts | 11 ++++- src/shared/routines.ts | 23 +++++++-- src/shared/task.ts | 23 +++++---- src/shared/versions.ts | 48 ++++++++++++++++++- test/reader-routines.test.ts | 26 +++++++++- test/routines.test.ts | 13 +++++ test/task.test.ts | 31 ++++++++++++ test/versions.test.ts | 45 +++++++++++++++-- test/views.test.tsx | 12 +++++ 16 files changed, 242 insertions(+), 46 deletions(-) diff --git a/TODO.md b/TODO.md index 6d724b9..431ffab 100644 --- a/TODO.md +++ b/TODO.md @@ -150,6 +150,11 @@ note only says where to start. Star Rail's Nameless Honor and ZZZ's New Eridu City Fund from their Fandom history tables, and Infinity Nikki's Mira Journey from its version, on the owner's in-game confirmation. Genshin's Battle Pass and Miliastra Pass were already in the feed. +- [ ] Each Endfield banner rotation: add the next entry to `BANNERS` in `src/shared/versions.ts` + from the weapon banner's in-game details, with its `start` equal to the last entry's `end`, or + `routine:endfield:aic-quota-permits` keeps its count and only shows an estimate. Next due by + 2026-11-24 22:00 UTC. At that restock, also check the shop's item name against the routine's + title: "Limited-Time HH Permits" is Game8's wording, not read off the shop screen. - [ ] Endfield's *Protocol Pass* window. The wiki says the pass runs "until the end of this version", but on 2026-10-16 no stable page dated the current version: `/wiki/Version` stopped at Dreamscape (ended 2026-10-14), the main page gave only Asia's start, and the per-version page diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 5b9d9c9..c9dace5 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -427,6 +427,12 @@ recorded, and `VERSION_DAYS` gives an estimate that is printed and never acted o `amounts` under the version's start date in UTC (`2026-09-30` for WuWa 3.7), a key that adding the next entry never moves. It groups, tallies and switches with the cycles (`cadenceOf`). +A built-in may instead **reset with its game's banners** (`{ unit: "banner" }`, 2026-11-06), for a +shop that restocks when the character and weapon banners change together. Same rule, same code path +and same storage (Endfield's AIC Quota Exchange counts under `2026-11-05`), read from the `BANNERS` +table in the same file. It has its own table because a rotation is not a version: `VERSIONS` feeds +the version line in *Your games*, and Endfield's real versions must be able to join it later. + **Not every game has a server per region.** `GameMeta.resetOffsets` records the regions where a game's server clock differs from `REGION_RESET_UTC_OFFSET`. Endfield is the case this exists for: it has two server groups rather than three, and Europe is served off the Americas machine on a fixed diff --git a/docs/PRD.md b/docs/PRD.md index 49e6acd..66d9cee 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -554,7 +554,9 @@ that account's logs; custom routines remain editable. Catch up & recorded histor their original reset revisions. A built-in can also **reset with its game's version** (WuWa's Oscillated Coral pulls, seven of each per version): it counts down to the end the version table records, or says *resets with next version (3.8) · expected ~23 Dec* when no end is recorded yet, -and never resets on that estimate. It sits with the cycles and is not offered as Make it mine. The +and never resets on that estimate. It sits with the cycles and is not offered as Make it mine. A +built-in can reset with its game's **banners** by the same rule (Endfield's five AIC Quota Exchange +permits: *resets with next banner · 24 Nov*). The game's About tab in *Your games* shows the version and when it ends. A separate Timeline reset strip sits directly after its game's events; with strips off, Ending soonest remains a single cross-game deadline queue. diff --git a/docs/superpowers/specs/2026-10-14-built-in-routines-design.md b/docs/superpowers/specs/2026-10-14-built-in-routines-design.md index ffc1b4c..d24dbc8 100644 --- a/docs/superpowers/specs/2026-10-14-built-in-routines-design.md +++ b/docs/superpowers/specs/2026-10-14-built-in-routines-design.md @@ -248,6 +248,7 @@ Looked for on 2026-10-14 and not shipped. Each is a candidate the owner may know | Star Rail | Divergent Universe / Currency Wars points | **Shipped 2026-10-16 as a weekly built-in**: the pool has been weekly since 4.2, and the "two weeks" was left over from 3.7 ([cycle routines spec](2026-10-16-cycle-routines-design.md), decision 7). Originally read as a two-week period, not a week → sub-project 2. Owner, in game, 2026-10-14: the two modes share one cap of 18,000 points per period, and the current period ends Monday 2026-10-19 at Star Rail's EU reset (05:00 CEST) | | Endfield | Stock Redistribution | "Some items may also refresh weekly" — no figure | | Endfield | Protocol Pass weekly missions | Missions change every week; no fixed target or EXP cap stated, and the owner found none in game (2026-10-14) | +| Endfield | AIC Quota Exchange | Shipped 2026-11-06, as a banner-period routine rather than a week: five permits, restocking when the character and weapon banners change together (owner, in game). `BANNERS` in `versions.ts` holds the dates | | Endfield | Algorithmic Memories, Credit Store, Arsenal Exchange | No figure; Beta-era; daily store; a shop rotation, not a chore | | Nikki | Merit Arena, Pinnacle Contest | No wiki page (2026-10-16); Merit Arena's weekly caps change per version and only a fan account gives numbers; Pinnacle Contest runs in half-month periods → sub-project 2 | | ZZZ | Shiyu Defense, Deadly Assault | Multi-week cycles → sub-project 2; the owner's in-game dates are in § Scope. **Shipped 2026-10-16** as two-week cycle built-ins ([cycle routines spec](2026-10-16-cycle-routines-design.md)) | diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index 2b4f3ad..e2c94c2 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -12,7 +12,7 @@ import { isCustomGameId, type DisplayEvent, type LaneId } from "../../shared/cus import type { GameMeta } from "../../shared/games.ts"; import type { GameId, Region } from "../../shared/schema.ts"; import { goalCount, goalLabel, type GoalStatus } from "../../shared/goal.ts"; -import { addDays, cadenceOf, periodOf, type Period } from "../../shared/period.ts"; +import { addDays, cadenceOf, isTimerPeriod, periodOf, type Period } from "../../shared/period.ts"; import type { TaskShape } from "../../shared/task.ts"; import { asOfLabel } from "../../shared/routines.ts"; import { AmountEditor } from "./AmountEditor.tsx"; @@ -207,8 +207,8 @@ export function dailyGroups( notBefore: null, goal: taskStatusFor(task), cadence: cadenceOf(task.period), - // A version has no period before it to catch up on (spec 2026-10-22). - period: task.period.unit === "version" ? undefined : task.period, + // A timer has no period before it to catch up on (spec 2026-10-22). + period: isTimerPeriod(task.period) ? undefined : task.period, unit: task.measure.kind === "amount" ? task.measure.unit : undefined, }); } diff --git a/src/client/components/redesign/RoutineDrawer.tsx b/src/client/components/redesign/RoutineDrawer.tsx index a92d83d..684ce03 100644 --- a/src/client/components/redesign/RoutineDrawer.tsx +++ b/src/client/components/redesign/RoutineDrawer.tsx @@ -22,8 +22,8 @@ import { type Routine, } from "../../../shared/routines.ts"; import { asOfLabel, TaskSchema, type Task } from "../../../shared/task.ts"; -import { periodOf, type Period } from "../../../shared/period.ts"; -import { versionCaption, versionWindow } from "../../../shared/versions.ts"; +import { isTimerPeriod, periodOf, type Period } from "../../../shared/period.ts"; +import { timerWindow, versionCaption } from "../../../shared/versions.ts"; const capitalise = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); import { AmountTarget } from "../../../shared/goal.ts"; @@ -379,16 +379,16 @@ function Library({ const rows = rowsOf(id), first = rows[0]!, def = builtins.find((b) => b.id === id); - const version = - def?.period.unit === "version" - ? versionWindow(def.game, now) + const timer = + def && isTimerPeriod(def.period) + ? timerWindow(def.period, def.game, now) : undefined; const line = first.event ? ["Battle pass", "Uses its sourced window", "Mark complete when finished"] : def ? [ - def.period.unit === "version" - ? version - ? capitalise(versionCaption(version, now)) - : "Resets with the version" + isTimerPeriod(def.period) + ? timer + ? capitalise(versionCaption(timer, now)) + : `Resets with the ${def.period.unit}` : scheduleWords( def.period, first.clock.resetHour, diff --git a/src/client/components/redesign/TimelinePage.tsx b/src/client/components/redesign/TimelinePage.tsx index a212a1c..b076308 100644 --- a/src/client/components/redesign/TimelinePage.tsx +++ b/src/client/components/redesign/TimelinePage.tsx @@ -21,8 +21,8 @@ import { routineRows, type RoutineRow } from "../../state/reader/routines.ts"; import { actionMeta } from "../../state/reader/actions.ts"; import { useGameMeta } from "../../state/gameMeta.tsx"; import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts"; -import { periodOf } from "../../../shared/period.ts"; -import { versionCaption, versionWindow } from "../../../shared/versions.ts"; +import { isTimerPeriod, periodOf } from "../../../shared/period.ts"; +import { timerWindow, versionCaption } from "../../../shared/versions.ts"; import { routinesFor } from "../../../shared/routines.ts"; import { canStep, snapDayWidth, stepDayWidth } from "../../state/zoom.ts"; import { orderGames } from "../../state/gameOrder.ts"; @@ -57,12 +57,12 @@ export function routinePeriod( if (row.window) return { ...row.window, estimated: false }; const key = readerDayKey(now, row.clock), task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id); - if (task?.period.unit === "version") { - const w = versionWindow(row.game, now); + if (task && isTimerPeriod(task.period)) { + const w = timerWindow(task.period, row.game, now); if (w?.end !== undefined) return { start: w.start, end: w.end, estimated: false }; if (w) return { start: w.start, end: Math.max(w.estimate ?? 0, now + DAY), estimated: true }; } - const period = task && task.period.unit !== "version" + const period = task && !isTimerPeriod(task.period) ? periodOf(task.period, key) : { start: key, days: [key] }; const shift = resetShiftMs(row.clock.region, row.game, row.clock); @@ -72,8 +72,10 @@ export function routinePeriod( /** What to print instead of a countdown when `routinePeriod` is estimated. */ export const estimatedReset = (row: RoutineRow, now: number): string => { - const w = versionWindow(row.game, now); - return w ? versionCaption(w, now) : "resets with next version"; + const task = routinesFor(row.game).find((r) => r.id === row.id), + period = task && isTimerPeriod(task.period) ? task.period : ({ unit: "version" } as const), + w = timerWindow(period, row.game, now); + return w ? versionCaption(w, now) : `resets with next ${period.unit}`; }; export function TimelinePage({ state, diff --git a/src/shared/period.ts b/src/shared/period.ts index a62c282..f010a90 100644 --- a/src/shared/period.ts +++ b/src/shared/period.ts @@ -53,12 +53,19 @@ export type Period = z.infer; export const RoutinePeriodSchema = z.union([ PeriodSchema, z.object({ unit: z.literal("version") }), + /** "Until the game's next banners": the same rule, on `BANNERS`. Code only too. */ + z.object({ unit: z.literal("banner") }), ]); export type RoutinePeriod = z.infer; -/** Where a period is grouped and tallied: a version shows with the cycles. */ +/** A period that ends at a recorded instant in a hand-kept table, not on a day key. */ +export type TimerPeriod = Extract; +export const isTimerPeriod = (period: RoutinePeriod): period is TimerPeriod => + period.unit === "version" || period.unit === "banner"; + +/** Where a period is grouped and tallied: a timer shows with the cycles. */ export const cadenceOf = (period: RoutinePeriod): Period["unit"] => - period.unit === "version" ? "cycle" : period.unit; + isTimerPeriod(period) ? "cycle" : period.unit; const msOf = (key: string) => Date.parse(`${key}T00:00:00Z`); const keyOf = (ms: number) => new Date(ms).toISOString().slice(0, 10); diff --git a/src/shared/routines.ts b/src/shared/routines.ts index de40fc4..62b5705 100644 --- a/src/shared/routines.ts +++ b/src/shared/routines.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { RoutinePeriodSchema } from "./period.ts"; +import { isTimerPeriod, RoutinePeriodSchema } from "./period.ts"; import { GameId, Region } from "./schema.ts"; import { shownFor, type PerGame } from "./perGame.ts"; import { MeasureSchema, type TaskShape } from "./task.ts"; @@ -30,7 +30,7 @@ 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), - /** A built-in may also reset with its game's version (`versions.ts`). */ + /** A built-in may also reset with its game's version or banners (`versions.ts`). */ period: RoutinePeriodSchema, measure: MeasureSchema, /** The page that states it. */ @@ -408,6 +408,20 @@ export const ROUTINES: readonly Routine[] = [ checked: "2026-10-14", note: "Unlocks after Aberrant Exploration III.", }, + { + id: "routine:endfield:aic-quota-permits", + game: "endfield", + title: "AIC Quota Exchange: Limited-Time HH Permits", + // Restocks when the character and weapon banners change, which they do + // together (owner, 2026-11-06); no page states this, so it is the owner's. + period: { unit: "banner" }, + pulls: "premium", + measure: { kind: "amount", target: 5, unit: "permits" }, + source: "https://game8.co/games/Arknights-Endfield/archives/575906", + asOf: "2026-08-24", + checked: "2026-11-06", + note: "Under Quota Exchange. Restocks with the next character and weapon banners. Confirmed in game, 2026-11-06.", + }, // Wuthering Waves. Game8 is the fresher source here: the wiki's per-season // pages stop in early 2026. @@ -750,10 +764,11 @@ export function routineToAsk( */ /** * Whether *Make it mine* is offered: not for a routine that resets with the - * version, since a reader's task cannot hold that period (spec 2026-10-22). + * version or the banner, since a reader's task cannot hold those periods + * (spec 2026-10-22). */ export const ownable = (routine: Pick): boolean => - routine.period.unit !== "version"; + !isTimerPeriod(routine.period); export function mineFrom(routine: Routine): { game: string; diff --git a/src/shared/task.ts b/src/shared/task.ts index db8bf7e..f53fe62 100644 --- a/src/shared/task.ts +++ b/src/shared/task.ts @@ -3,8 +3,8 @@ import { z } from "zod"; import type { LaneId } from "./custom.ts"; import { dayKey } from "./daily.ts"; import { AmountTarget, type AmountLog, type GoalStatus } from "./goal.ts"; -import { periodOf, PeriodSchema, type RoutinePeriod } from "./period.ts"; -import { versionCaption, versionWindow } from "./versions.ts"; +import { isTimerPeriod, periodOf, PeriodSchema, type RoutinePeriod } from "./period.ts"; +import { timerWindow, versionCaption, type VersionWindow } from "./versions.ts"; import { DAY } from "./time.ts"; import type { Region } from "./schema.ts"; @@ -94,7 +94,7 @@ export function taskStatus(input: { timing?: ResetTiming; }): GoalStatus { const { task, region, now, logged, amounts } = input; - if (task.period.unit === "version") return versionStatus(task.game, task.measure, now, amounts); + if (isTimerPeriod(task.period)) return timerStatus(timerWindow(task.period, task.game, now), task.measure, now, amounts); const today = dayKey(now, task.clock ?? region, task.game as LaneId, task.clock === undefined ? input.timing : undefined); const { start, days } = periodOf(task.period, today); const scope = task.period.unit; @@ -124,13 +124,12 @@ export function taskStatus(input: { } /** - * A built-in that resets with its game's version (spec 2026-10-22): the number - * under the version's key, and whole days to a **recorded** end — none when - * only an estimate exists, since an estimate is never acted on. Amounts only - * (routines.test.ts holds every version routine to that); shown as a cycle. + * A built-in that resets with its game's version or banners (spec 2026-10-22): + * the number under the window's key, and whole days to a **recorded** end — none + * when only an estimate exists, since an estimate is never acted on. Amounts only + * (routines.test.ts holds every such routine to that); shown as a cycle. */ -function versionStatus(game: string, measure: Measure, now: number, amounts: AmountLog | undefined): GoalStatus { - const w = versionWindow(game, now); +function timerStatus(w: VersionWindow | undefined, measure: Measure, now: number, amounts: AmountLog | undefined): GoalStatus { const key = w?.key ?? ""; const count = amounts?.[key]?.value ?? 0; const required = measure.kind === "amount" ? measure.target : measure.required; @@ -163,10 +162,10 @@ export function taskCaption( task: Pick & { period: RoutinePeriod; game?: string }, now: number = Date.now(), ): string { - const version = task.period.unit === "version" && task.game !== undefined ? versionWindow(task.game, now) : undefined; + const timer = isTimerPeriod(task.period) && task.game !== undefined ? timerWindow(task.period, task.game, now) : undefined; const when = - task.period.unit === "version" - ? version === undefined ? "resets with the version" : versionCaption(version, now) + isTimerPeriod(task.period) + ? timer === undefined ? `resets with the ${task.period.unit}` : versionCaption(timer, now) : task.period.unit === "day" ? "every day" : task.period.unit === "week" diff --git a/src/shared/versions.ts b/src/shared/versions.ts index b919a99..44c5fcc 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,3 +1,4 @@ +import type { TimerPeriod } from "./period.ts"; import type { GameId } from "./schema.ts"; import { DAY } from "./time.ts"; @@ -57,6 +58,36 @@ export const VERSION_DAYS: Partial> = { wuwa: 42, }; +/** + * Banner rotations, for a shop that restocks when a game's character and weapon + * banners change together rather than with its version. Same shape and same rule + * as `VERSIONS`: only a recorded `end` rolls anything, and each new entry's `start` + * is the last one's `end`, so amounts already stored stay where they are. It gets + * a table of its own so that a rotation is never reported as a game version, and + * so Endfield's real versions can be added to `VERSIONS` later without colliding. + * `number` here is a label and is not shown. + */ +export const BANNERS: readonly GameVersion[] = [ + { + game: "endfield", + number: "Argent Flow", + // The day Pocket Tactics gives, at the hour the wiki opens this rotation's events. + start: "2026-11-05T04:00:00Z", + // The weapon banner's in-game details: "Ending at 11-24 17:00 (UTC-5)". The + // AIC Quota Exchange's timer read 18d alongside it; it shows days only. + end: "2026-11-24T22:00:00Z", + source: "https://www.pockettactics.com/arknights-endfield-banner", + checked: "2026-11-06", + note: "The page gives 5 to 25 Nov; the instant is the banner's own. Confirmed in game, 2026-11-06.", + }, +]; + +/** A rotation's usual length in days, for the estimate only. */ +export const BANNER_DAYS: Partial> = { + // 15 Oct to 5 Nov was 21 and 5 to 24 Nov is 19 (Pocket Tactics, above; the game). + endfield: 20, +}; + export interface VersionWindow { /** The version holding `now`; undefined when no page or reading names it. */ number?: string | undefined; @@ -68,6 +99,8 @@ export interface VersionWindow { /** start + the usual length, when no end is recorded. Shown, never acted on. */ estimate?: number | undefined; nextNumber?: string | undefined; + /** What the caption says resets it. Absent means a version. */ + noun?: "banner"; } const keyOf = (ms: number) => new Date(ms).toISOString().slice(0, 10); @@ -105,6 +138,16 @@ export function versionWindow( return undefined; } +/** The banner rotation holding `now`, or undefined for a game with none listed. */ +export function bannerWindow(game: string, now: number): VersionWindow | undefined { + const w = versionWindow(game, now, BANNERS, BANNER_DAYS); + return w && { ...w, noun: "banner" }; +} + +/** The window a timer period is counting down, whichever table it reads. */ +export const timerWindow = (period: TimerPeriod, game: string, now: number): VersionWindow | undefined => + period.unit === "banner" ? bannerWindow(game, now) : versionWindow(game, now); + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; /** `11 Nov`, in UTC — the same wherever it is read, like `asOfLabel`. */ const dayLabel = (ms: number) => { @@ -114,9 +157,10 @@ const dayLabel = (ms: number) => { /** "resets with 3.8 · 11 Nov", or the next-version form with its estimate. */ export function versionCaption(w: VersionWindow, now: number): string { + const next = `next ${w.noun ?? "version"}`; if (w.end !== undefined) - return `resets with ${w.nextNumber ?? "next version"} · ${dayLabel(w.end)}`; - const head = `resets with next version${w.nextNumber ? ` (${w.nextNumber})` : ""}`; + return `resets with ${w.nextNumber ?? next} · ${dayLabel(w.end)}`; + const head = `resets with ${next}${w.nextNumber ? ` (${w.nextNumber})` : ""}`; if (w.estimate === undefined) return head; return `${head} · expected ~${dayLabel(w.estimate)}${now > w.estimate ? ", not yet confirmed" : ""}`; } diff --git a/test/reader-routines.test.ts b/test/reader-routines.test.ts index 959e45a..0d6e4ae 100644 --- a/test/reader-routines.test.ts +++ b/test/reader-routines.test.ts @@ -60,7 +60,7 @@ test("an event the reader marked daily remains a directly tickable routine", () }); // Spec 2026-10-22: WuWa's shop pulls reset with the version and show with the cycles. -import { routinePeriod } from "../src/client/components/redesign/TimelinePage.tsx"; +import { estimatedReset, routinePeriod } from "../src/client/components/redesign/TimelinePage.tsx"; test("a routine that resets with the version is a cycle row, banded to its recorded end", () => { const now = "2026-10-22T14:30:00.000Z"; const s = migrateLegacy({}, ["wuwa"], now, () => "account:one"); @@ -83,3 +83,27 @@ test("a routine that resets with the version is a cycle row, banded to its recor Date.parse("2026-12-23T10:30:00Z"), ); }); + +test("a routine that resets with the banner is a cycle row, and says banner when no end is recorded", () => { + const now = "2026-11-06T12:00:00.000Z"; + const s = migrateLegacy({}, ["endfield"], now, () => "account:one"); + s.data["account:one"]!.amounts[s.accounts["account:one"]!.clocks.at(-1)!.id] = { + "routine:endfield:aic-quota-permits": { "2026-11-05": { value: 2, at: now } }, + }; + const permits = routineRows(s, "account:one", Date.parse(now)) + .find((r) => r.id === "routine:endfield:aic-quota-permits")!; + expect(permits.cadence).toBe("cycle"); + expect(permits.status).toMatchObject({ count: 2, required: 5, periodStart: "2026-11-05" }); + expect(routinePeriod(permits, Date.parse(now))).toEqual({ + start: Date.parse("2026-11-05T04:00:00Z"), + end: Date.parse("2026-11-24T22:00:00Z"), + estimated: false, + }); + const later = Date.parse("2026-11-25T00:00:00Z"); + expect(routinePeriod(permits, later)).toEqual({ + start: Date.parse("2026-11-24T22:00:00Z"), + end: Date.parse("2026-12-14T22:00:00Z"), + estimated: true, + }); + expect(estimatedReset(permits, later)).toBe("resets with next banner · expected ~14 Dec"); +}); diff --git a/test/routines.test.ts b/test/routines.test.ts index 559a694..edd8bcd 100644 --- a/test/routines.test.ts +++ b/test/routines.test.ts @@ -6,6 +6,7 @@ import { routinesFor, routineToAsk, mineFrom, + ownable, copyRoutineState, hiddenAfter, offered, @@ -50,6 +51,14 @@ describe("ROUTINES", () => { if (r.period.unit === "version") expect(r.measure.kind).toBe("amount"); }); + test("a built-in may reset with the banner, then counts an amount and stays the app's", () => { + const { period: _p, ...rest } = ROUTINES[0]!; + expect(RoutineSchema.safeParse({ ...rest, period: { unit: "banner" } }).success).toBe(true); + for (const r of ROUTINES) + if (r.period.unit === "banner") expect(r.measure.kind).toBe("amount"); + expect(ownable({ period: { unit: "banner" } })).toBe(false); + }); + test("a routine without a source is refused", () => { const { source: _s, ...bare } = ROUTINES[0]!; expect(RoutineSchema.safeParse(bare).success).toBe(false); @@ -96,6 +105,10 @@ describe("ROUTINES", () => { expect(shape("endfield")).toEqual([ ["Weekly Routine", week, { kind: "amount", target: 10, unit: "points" }], ["Etchspace Salvage: Focus Commissions", week, { kind: "amount", target: 3, unit: "commissions" }], + ["AIC Quota Exchange: Limited-Time HH Permits", { unit: "banner" }, { kind: "amount", target: 5, unit: "permits" }], + ]); + expect(routinesFor("endfield").filter((r) => r.pulls !== undefined).map((r) => [r.id, r.pulls])).toEqual([ + ["routine:endfield:aic-quota-permits", "premium"], ]); expect(shape("wuwa")).toEqual([ ["Weekly Challenge", week, { kind: "amount", target: 3, unit: "claims" }], diff --git a/test/task.test.ts b/test/task.test.ts index 88e53fb..6ba71e8 100644 --- a/test/task.test.ts +++ b/test/task.test.ts @@ -233,3 +233,34 @@ describe("a task that resets with the version", () => { expect(TaskSchema.safeParse(task({ period: { unit: "version" } as never })).success).toBe(false); }); }); + +// A built-in that follows the banner table rather than the version table. +describe("a task that resets with the banner", () => { + const permits = { + game: "endfield", + period: { unit: "banner" as const }, + measure: { kind: "amount" as const, target: 5, unit: "permits" }, + }; + const amounts = { "2026-11-05": { value: 2, at: AT } }; + const status = (iso: string) => + taskStatus({ task: permits, region: "europe", now: Date.parse(iso), logged: [], amounts }); + + test("counts under the rotation's key until the recorded end, then starts over", () => { + expect(status("2026-11-24T21:59:00Z")).toMatchObject({ + scope: "cycle", periodStart: "2026-11-05", count: 2, required: 5, daysLeft: 1, + }); + expect(status("2026-11-24T22:00:00Z")).toMatchObject({ + periodStart: "2026-11-24", count: 0, daysLeft: null, + }); + }); + + test("its caption names the banner", () => { + expect(taskCaption(permits, Date.parse("2026-11-06T12:00:00Z"))).toBe( + "resets with next banner · 24 Nov · reach 5 permits", + ); + }); + + test("a reader's task cannot hold it", () => { + expect(TaskSchema.safeParse(task({ period: { unit: "banner" } as never })).success).toBe(false); + }); +}); diff --git a/test/versions.test.ts b/test/versions.test.ts index f689e0b..3b36912 100644 --- a/test/versions.test.ts +++ b/test/versions.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { DAY } from "../src/shared/time.ts"; import { + BANNERS, + bannerWindow, VERSION_DAYS, VERSIONS, versionCaption, @@ -15,11 +17,11 @@ import { const at = (iso: string) => Date.parse(iso); -describe("VERSIONS", () => { - test("each game's versions are in order and meet end to start", () => { - const games = new Set(VERSIONS.map((v) => v.game)); +describe.each([["VERSIONS", VERSIONS], ["BANNERS", BANNERS]] as const)("%s", (_name, table) => { + test("each game's entries are in order and meet end to start", () => { + const games = new Set(table.map((v) => v.game)); for (const game of games) { - const list = VERSIONS.filter((v) => v.game === game); + const list = table.filter((v) => v.game === game); list.forEach((v, i) => { expect(Number.isFinite(at(v.start))).toBe(true); if (v.end !== undefined) expect(at(v.end)).toBeGreaterThan(at(v.start)); @@ -34,7 +36,7 @@ describe("VERSIONS", () => { }); test("every one cites an https page and the day it was read", () => { - for (const v of VERSIONS) { + for (const v of table) { expect(v.source.startsWith("https://")).toBe(true); expect(v.checked).toMatch(/^\d{4}-\d{2}-\d{2}$/); if (v.note?.toLowerCase().includes("in game") === true) @@ -136,3 +138,36 @@ describe("versionCaption", () => { expect(versionCaption(base, at("2026-11-20T00:00:00Z"))).toBe("resets with next version"); }); }); + +// A shop that restocks when the character and weapon banners change together: +// the same rule as a version, on a table of its own (Endfield, owner, 2026-11-06). +describe("bannerWindow", () => { + test("holds the rotation up to the end the game states", () => { + // In game: "Ending at 11-24 17:00 (UTC-5)", read on 2026-11-06. + expect(bannerWindow("endfield", at("2026-11-24T21:59:00Z"))).toMatchObject({ + key: "2026-11-05", + end: at("2026-11-24T22:00:00Z"), + noun: "banner", + }); + }); + + test("past the recorded end it opens the next rotation and only estimates its end", () => { + const w = bannerWindow("endfield", at("2026-11-24T22:00:00Z"))!; + expect(w.key).toBe("2026-11-24"); + expect(w.end).toBeUndefined(); + expect(w.estimate).toBe(at("2026-12-14T22:00:00Z")); + }); + + test("a banner table says nothing about the game's version", () => { + expect(versionWindow("endfield", at("2026-11-06T12:00:00Z"))).toBeUndefined(); + expect(bannerWindow("wuwa", at("2026-11-06T12:00:00Z"))).toBeUndefined(); + }); + + test("its caption says banner, not version", () => { + const held = at("2026-11-06T12:00:00Z"), open = at("2026-11-25T00:00:00Z"); + expect(versionCaption(bannerWindow("endfield", held)!, held)).toBe("resets with next banner · 24 Nov"); + expect(versionCaption(bannerWindow("endfield", open)!, open)).toBe( + "resets with next banner · expected ~14 Dec", + ); + }); +}); diff --git a/test/views.test.tsx b/test/views.test.tsx index c0f6f54..e86b1f8 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -1457,6 +1457,18 @@ describe("weekly and monthly tasks in the dailies strip", () => { expect(tasksShown([shop], { weekly: true, monthly: true, cycle: false })).toEqual([]); }); + test("a banner task sits with the cycles and has no period to catch up on", () => { + const permits: StripTask = { + id: "routine:endfield:aic-quota-permits", game: "endfield", title: "AIC Quota Exchange", + period: { unit: "banner" }, measure: { kind: "amount", target: 5, unit: "permits" }, + }; + const [group] = dailyGroups(["endfield"], [], NOW, "europe", meta, startOf, false, undefined, + [permits], () => cycleStatus); + const item = group!.items.find((i) => i.key === permits.id)!; + expect(item.cadence).toBe("cycle"); + expect(item.period).toBeUndefined(); + }); + test("cycle tasks sit in the lower row, are tallied apart and switched apart", () => { const [group] = dailyGroups(["zzz"], [], NOW, "europe", meta, startOf, true, undefined, [points, shiyu], anyStatus);