feat(p5x): track Persona 5: The Phantom X

Adds the game and its Game8 events page, the second of the games named in
the release thread. `parseAdjacentFullRange` reads the shape this page
uses: two full dates divided by an `<hr>` rather than a dash, so a
tag-stripping reader sees only whitespace between them. It is anchored at
both ends and needs a year on each half, because without that "August 12,
2026 Day 3 rewards" reads "Day 3" as an end.

Three of the four live rows publish. "Take Your Heart" ends 30 days after
each player makes an account, so it has no calendar date and no honest
place on a calendar. "Login Campaigns" names two candidate ends
("July 16/30, 2026") — it keeps its real start and takes no end, and the
leftover is not shown as a summary either, since a date the parser
refused to trust must not reappear dressed as information.

The page lists each live event twice: once in a bare Event|Duration table
and again under its own heading with Start Date / End Date rows and a
paragraph of prose. The second copy corroborates the dates, so deduping
now fills a missing summary from the copy it drops. Dates are still taken
wholesale from the better-dated copy and never blended.

Verified by re-extracting the page independently of the parser: 4 rows,
3 events, the fourth correctly skipped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-17 17:21:10 +02:00
co-authored by Claude Opus 5
parent 67951e81b4
commit d8b539bce1
9 changed files with 1324 additions and 6 deletions
+54
View File
@@ -28,6 +28,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
{ adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/game8-events-2026-08-14" },
{ adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" },
{ adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" },
{ adapter: adapter("p5x-game8-events"), fixture: "fixtures/p5x/game8-events-2026-08-17" },
];
async function runAdapter(adapter: Adapter, fixture: string) {
@@ -258,6 +259,59 @@ describe("nikki", () => {
});
});
describe("p5x", () => {
const fixture = "fixtures/p5x/game8-events-2026-08-17";
test("reads a range whose halves are separated by an <hr>", async () => {
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
const beach = events.find((e) =>
e.title.startsWith("Haunted Beach Shack Summer Event"),
);
expect(beach?.startsAt).toBe("2026-07-30T00:00:00.000Z");
expect(beach?.endsAt).toBe("2026-08-13T00:00:00.000Z");
});
test("keeps the finished-events back catalogue off the calendar", async () => {
// Fifty-odd past events sit in a table fenced off by nothing but an
// <h4>Finished Events</h4> inside a collapsed accordion. A reader blind to
// h4 sees one uninterrupted run of tables and publishes the lot.
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
expect(events).toHaveLength(3);
const titles = events.map((e) => e.title);
expect(titles).not.toContain("Tycoon Season 1"); // finished
expect(titles).not.toContain("New Year's Gifts"); // finished
});
test("skips a row whose duration states no date at all", async () => {
// "Take Your Heart" ends 30 days after each player makes an account, so it
// has no calendar date and no honest place on a calendar.
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
expect(events.map((e) => e.title)).not.toContain("Take Your Heart");
});
test("takes no end from an ambiguous one, and does not show it as prose", async () => {
// "June 25, 2026 July 16/30, 2026" names two candidate ends. Picking either
// would be a guess, and echoing the leftover into the summary would dress
// the same guess up as information.
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
const login = events.find((e) => e.title === "Login Campaigns");
expect(login?.startsAt).toBe("2026-06-25T00:00:00.000Z");
expect(login?.endsAt).toBeNull();
expect(login?.summary).toBeNull();
});
test("recovers the blurb from the event's own section", async () => {
// The event is listed twice: once in a bare Event|Duration table, and again
// under its own heading with a paragraph of prose. Deduping by ID must not
// throw the prose away.
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
const anniversary = events.find(
(e) => e.title === "1st Anniversary Celebration",
);
expect(anniversary?.summary).toContain("first anniversary");
});
});
describe("wiki.gg parser", () => {
test("reads exact per-region timers", async () => {
// The first source that states region-scoped ends. Asia and the Americas