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
@@ -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" : ""}`;
|
||||
}
|
||||
Reference in new issue
Block a user