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 <noreply@anthropic.com>
This commit is contained in:
1 parent
be80e785ca
commit
327f6389cc
16 files changed
+242
-46
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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)) |
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,12 +53,19 @@ export type Period = z.infer<typeof PeriodSchema>;
|
||||
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<typeof RoutinePeriodSchema>;
|
||||
|
||||
/** 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<RoutinePeriod, { unit: "version" | "banner" }>;
|
||||
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);
|
||||
|
||||
+19
-4
@@ -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<Routine, "period">): boolean =>
|
||||
routine.period.unit !== "version";
|
||||
!isTimerPeriod(routine.period);
|
||||
|
||||
export function mineFrom(routine: Routine): {
|
||||
game: string;
|
||||
|
||||
+11
-12
@@ -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<Task, "measure"> & { 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"
|
||||
|
||||
+46
-2
@@ -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<Record<GameId, number>> = {
|
||||
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<Record<GameId, number>> = {
|
||||
// 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" : ""}`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+40
-5
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user