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
@@ -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