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

+25 -1
View File
@@ -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");
});
+13
View File
@@ -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" }],
+31
View File
@@ -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
View File
@@ -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",
);
});
});
+12
View File
@@ -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);