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:
Lucas WintherandClaude Opus 5.5 committed 2026-11-06 09:14:38 +01:00
1 parent be80e785ca
commit 327f6389cc
16 files changed
+242 -46

No files matched your search

+9 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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" : ""}`;
}