diff --git a/AGENTS.md b/AGENTS.md index d56727a..13117e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -778,8 +778,17 @@ source CI can refresh. Four things to know about it: (`To Temper Thyself and Journey Far` vs `… Cycle 5`, and `Stygian Onslaught` vs `…: Battle of the Starburst`). In `merge.ts`, higher priority breaks ties when confidence is equal (0.85). If Fandom won those near-matches, the event IDs would change and readers' localStorage - completion marks would silently orphan. With Game8 prioritized: 0 IDs lost, 11 net-new events added, - 0 conflicts, and 5 corroborated events earning confidence bonuses. + completion marks would silently orphan. With Game8 prioritized: 0 IDs lost, 10 net-new events added, + 0 conflicts, and 6 corroborated events earning confidence bonuses. (This read 11 and 5 until + 2026-10-14: the eleventh "new" event was the battle pass, listed twice — next bullet.) +- **Game8 labels a battle pass; Fandom does not, and that put every pass on the calendar twice.** + Game8 heads it `Battle Pass - Moontrace`, Fandom lists `Moontrace` and files the category under + `Type(s)`, so the two minted different IDs and no merge rule could pair them — overlap scores 0.5, + and a one-word base is under `titleExtends`' floor. Reported by a reader on 2026-10-14; the pinned + fixtures show it from the day this source landed. `eventName` (`parsers/game8.ts`) now drops the + label so both sources mint one ID, and `src/client/state/retiredIds.ts` moves readers' marks off + the two IDs that changed. The general lesson: **a second source needs its whole overlap with the + first read row by row, not counted** — a duplicate looks exactly like a net-new event in a tally. - **The 336-day event raised the duration ceiling to 365 days.** Genshin's anniversary 5-star selection runs 336 days (`2025-10-22` → `2026-09-23`), correctly dated on this wiki. This motivated raising the sanity ceiling in `test/adapters/game8.test.ts` and `docs/INGESTION.md` from 180 to 365 days. diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index ad4997f..bf35bdd 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -116,7 +116,9 @@ server-side (the server never had the data). If the scheme must change, ship a c migration that reads the old keys and remaps them, and keep that migration for at least a year. **A parser fix can move IDs too, and gets the same treatment.** `src/client/state/retiredIds.ts` -holds an explicit table of IDs the feed stopped publishing and what each became. On every load and +holds an explicit table of IDs the feed stopped publishing and what each became — first used on +2026-10-14, when Game8's Genshin parser stopped keeping `Battle Pass -` in front of a pass's name +(the label had put every pass on the calendar twice beside Fandom's bare name). On every load and every import, `progress`, `daily` and `ignored` entries under a retired ID **move** to the new one: - **A move inside the same storage key, not a copy, and not a deleted key.** The mark survives under @@ -127,7 +129,7 @@ every import, `progress`, `daily` and `ignored` entries under a retired ID **mov held, because both rows were the reader's own. - **An unreadable entry is left where it is.** These run in `useState` initialisers, where a throw is a blank screen. -- **A table, never a pattern**, and each entry kept for at least a year. +- **A table, never a pattern**, and kept until at least 2027-10-14. The date suffix disambiguates reruns of the same event. Title is slugified from the *source's* title, so a wiki renaming an event creates a new ID — reconciliation detects this as a near-match diff --git a/docs/SOURCES.md b/docs/SOURCES.md index 1010bcb..f841d08 100644 --- a/docs/SOURCES.md +++ b/docs/SOURCES.md @@ -809,7 +809,7 @@ note is what stops the next person re-deriving the reason it does not. - **Title extraction:** Display text of the caption link beneath the banner image is taken. Link `title` (parent subpage) and `img alt` (uploaded image file name) are rejected to prevent corrupting event titles and IDs. - **Run-date suffixes:** Recurring event run dates (`2026-09-14`) in subpage names are stripped so dates aren't duplicated. - **Currency:** `latestBoundaryMs` is checked for day-precision boundaries so events aren't retired before all server regions roll. -- **Priority & IDs:** Game8 is assigned `priority: 10` while Fandom is default/0. Two live events differ slightly in naming (`To Temper Thyself and Journey Far` vs `… Cycle 5`, and `Stygian Onslaught` vs `…: Battle of the Starburst`). Game8's higher priority breaks near-match ties in `merge.ts`, ensuring existing localStorage event IDs survive and reader completion marks remain intact. Fandom contributes 11 net-new events Game8 missed, 0 date conflicts occur, and overlapping events gain corroboration bonuses. +- **Priority & IDs:** Game8 is assigned `priority: 10` while Fandom is default/0. Two live events differ slightly in naming (`To Temper Thyself and Journey Far` vs `… Cycle 5`, and `Stygian Onslaught` vs `…: Battle of the Starburst`). Game8's higher priority breaks near-match ties in `merge.ts`, ensuring existing localStorage event IDs survive and reader completion marks remain intact. Fandom contributes 10 net-new events Game8 missed, 0 date conflicts occur, and overlapping events gain corroboration bonuses. (Recorded as 11 until 2026-10-14: one was Game8's `Battle Pass - Frostfarer` beside Fandom's `Frostfarer`, the same event under two IDs. Game8's parser now drops that label; AGENTS.md § Fandom has the rest, including the anniversary selectors, still listed twice.) - **336-day event:** Genshin's anniversary 5-star selection runs 336 days (`2025-10-22` → `2026-09-23`), prompting the test duration ceiling to be raised from 180 to 365 days across the test suite and ingestion specs. ## 16. hololive Dreams — the wiki lapsed (2026-10-14); nothing else can be fetched diff --git a/fixtures/genshin/game8-events-2026-08-14.expected.json b/fixtures/genshin/game8-events-2026-08-14.expected.json index f5421c5..56ff6b4 100644 --- a/fixtures/genshin/game8-events-2026-08-14.expected.json +++ b/fixtures/genshin/game8-events-2026-08-14.expected.json @@ -21,14 +21,14 @@ "updatedAt": "2026-08-14T00:00:00.000Z" }, { - "id": "genshin:battle-pass-frostfarer:2026-08-12", + "id": "genshin:character-test-runs:2026-08-12", "game": "genshin", - "title": "Battle Pass - Frostfarer", - "type": "other", - "summary": "Participate in the free Sojourner's Battle Pass and the for-purchase Gnostic Hymn to receive prizes as you increase your BP Level.", + "title": "Character Test Runs", + "type": "challenge", + "summary": "The Character Test Run is available at the same time as the Character Banners in Phase 1 of Version 7.0 from August 12, 2026 to September 1, 2026!", "startsAt": "2026-08-12T00:00:00.000Z", "startPrecision": "day", - "endsAt": "2026-09-21T00:00:00.000Z", + "endsAt": "2026-09-01T00:00:00.000Z", "endPrecision": "day", "regionScoped": false, "regionEnds": null, @@ -42,14 +42,14 @@ "updatedAt": "2026-08-14T00:00:00.000Z" }, { - "id": "genshin:character-test-runs:2026-08-12", + "id": "genshin:frostfarer:2026-08-12", "game": "genshin", - "title": "Character Test Runs", - "type": "challenge", - "summary": "The Character Test Run is available at the same time as the Character Banners in Phase 1 of Version 7.0 from August 12, 2026 to September 1, 2026!", + "title": "Frostfarer", + "type": "other", + "summary": "Participate in the free Sojourner's Battle Pass and the for-purchase Gnostic Hymn to receive prizes as you increase your BP Level.", "startsAt": "2026-08-12T00:00:00.000Z", "startPrecision": "day", - "endsAt": "2026-09-01T00:00:00.000Z", + "endsAt": "2026-09-21T00:00:00.000Z", "endPrecision": "day", "regionScoped": false, "regionEnds": null, diff --git a/src/client/state/retiredIds.ts b/src/client/state/retiredIds.ts index 0311856..45b7969 100644 --- a/src/client/state/retiredIds.ts +++ b/src/client/state/retiredIds.ts @@ -10,14 +10,21 @@ export type CarryTable = Readonly>; * * An event id is a localStorage key (AGENTS.md § Event IDs are localStorage * keys), so an id that moves takes the reader's marks with it unless they are - * carried across. A change that moves ids — a parser fix that renames a title - * is the usual one — lands its entries here, in the same commit. + * carried across. These moved on 2026-10-14, when the Game8 parser stopped + * keeping `Battle Pass -` in front of Genshin's pass names: Fandom had always + * published the bare name, and the two ids put every pass on the calendar + * twice (docs/superpowers/specs/2026-10-14-genshin-duplicates-design.md). * - * An explicit table, never a pattern: a pattern would also catch an id nobody - * retired. Keep each entry for at least a year — a reader who has not opened - * the app since still has marks under the old id. + * An explicit table, never a pattern: the battle passes are the only two such + * ids that ever shipped (git history of snapshots/ and fixtures/), and a + * pattern would also catch an id nobody retired. Keep it until at least + * 2027-10-14 — a reader who has not opened the app since still has marks under + * the old ids. */ -export const RETIRED_IDS: CarryTable = {}; +export const RETIRED_IDS: CarryTable = { + "genshin:battle-pass-frostfarer:2026-08-12": "genshin:frostfarer:2026-08-12", + "genshin:battle-pass-moontrace:2026-09-23": "genshin:moontrace:2026-09-23", +}; /** * Move each entry under an old id in `table` to its replacement. diff --git a/src/ingest/parsers/game8.ts b/src/ingest/parsers/game8.ts index 15f7eb9..f77615f 100644 --- a/src/ingest/parsers/game8.ts +++ b/src/ingest/parsers/game8.ts @@ -452,6 +452,19 @@ export function inferType(title: string): EventType { return "other"; } +/** + * The event's name without a category label in front of it. + * + * Genshin's page heads its pass `Battle Pass - Moontrace`; the Fandom wiki + * lists the same event as `Moontrace` and files the category in a column. The + * label is not the name, and keeping it minted a second ID for one event, so + * the calendar showed every battle pass twice. Stripping it moved this + * source's battle-pass IDs, which is why `retiredIds.ts` exists. + */ +export function eventName(title: string): string { + return title.replace(/^battle pass\s*[-–—:]\s*(?=\S)/i, ""); +} + function toEvent(c: Candidate, ctx: ParseContext): GachaEvent { // Confidence reflects how much the source actually pinned down. Day precision // and unknown ends are legitimate and common, but they are weaker evidence @@ -461,11 +474,12 @@ function toEvent(c: Candidate, ctx: ParseContext): GachaEvent { if (c.end === null) confidence -= 0.15; else if (c.end.precision === "day") confidence -= 0.05; + const title = eventName(c.title); return { - id: eventId(ctx.game, c.title, c.start.iso), + id: eventId(ctx.game, title, c.start.iso), game: ctx.game, - title: c.title, - type: inferType(c.title), + title, + type: inferType(title), summary: c.summary, startsAt: c.start.iso, startPrecision: c.start.precision, diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts index 1998bf8..5fe0add 100644 --- a/test/adapters/game8.test.ts +++ b/test/adapters/game8.test.ts @@ -164,11 +164,23 @@ describe("genshin fixture specifics", () => { // Sourced from a one-cell "Availability Period" range rather than // Start/End rows — the other table shape on the same page. - expect(byTitle.get("Battle Pass - Frostfarer")?.endsAt).toBe( + expect(byTitle.get("Frostfarer")?.endsAt).toBe( "2026-09-21T00:00:00.000Z", ); }); + test("a leading Battle Pass label is not part of the name", async () => { + // Fandom lists the same pass as plain `Frostfarer` and puts the category in + // its Type(s) column. Keeping Game8's label minted a second ID for one + // event, and the calendar showed every battle pass twice. + const events = await runAdapter( + genshinGame8, + "fixtures/genshin/game8-events-2026-08-14", + ); + expect(events.some((e) => /^battle pass/i.test(e.title))).toBe(false); + expect(events.map((e) => e.id)).toContain("genshin:frostfarer:2026-08-12"); + }); + test("year-less summary rows produce no events", async () => { // The page's summary tables show "08/12 - 08/24" with no year. Those must // be skipped, not year-guessed — and they must not duplicate the detail @@ -1967,12 +1979,15 @@ describe("Genshin Impact wiki (the fifth Fandom template)", () => { expect(result.events.map((e) => e.id)).toContain(expected.id); } - // Fandom contributes net-new events Game8 did not list (total 20 merged events) - expect(result.events.length).toBe(20); + // Fandom contributes net-new events Game8 did not list (total 19 merged + // events). This read 20 until 2026-10-14, and the twentieth was the battle + // pass listed twice — `Battle Pass - Frostfarer` beside `Frostfarer`. + expect(result.events.length).toBe(19); + expect(result.events.filter((e) => /frostfarer/i.test(e.title))).toHaveLength(1); expect(result.conflicts).toHaveLength(0); // Corroboration bonus applies to overlapping events (0.85 -> 0.95) const corroborated = result.events.filter((e) => e.confidence > 0.85); - expect(corroborated.length).toBe(5); + expect(corroborated.length).toBe(6); }); }); diff --git a/test/retired-ids.test.ts b/test/retired-ids.test.ts index bdaed84..376759b 100644 --- a/test/retired-ids.test.ts +++ b/test/retired-ids.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { parseImportData } from "../src/client/state/export.ts"; import { + RETIRED_IDS, carryForward, carryDaily, carryMarks, @@ -94,3 +96,48 @@ describe("when the reader marked both rows", () => { expect(out).toEqual({ [NEW]: { at: "2026-09-24T00:00:00.000Z" } }); }); }); + +const PASS = "genshin:battle-pass-moontrace:2026-09-23"; +const BARE = "genshin:moontrace:2026-09-23"; + +describe("RETIRED_IDS", () => { + test("names the two battle passes Game8 minted with their label", () => { + expect(RETIRED_IDS[PASS]).toBe(BARE); + expect(RETIRED_IDS["genshin:battle-pass-frostfarer:2026-08-12"]).toBe( + "genshin:frostfarer:2026-08-12", + ); + }); + + test("never retires an id onto another retired id", () => { + for (const to of Object.values(RETIRED_IDS)) { + expect(RETIRED_IDS[to]).toBeUndefined(); + } + }); + + test("is the table a load uses when none is given", () => { + const out = carryMarks({ [PASS]: { at: "2026-09-24T00:00:00.000Z" } }); + expect(Object.keys(out)).toEqual([BARE]); + }); +}); + +describe("an export made before the ids moved", () => { + test("imports onto the new ids", () => { + const data = parseImportData({ + format: "gacha-tracker-export", + progress: { [PASS]: { status: "done", at: "2026-10-01T00:00:00.000Z" } }, + daily: { [PASS]: { days: ["2026-09-24"], at: "2026-09-24T00:00:00.000Z" } }, + ignored: { [PASS]: { at: "2026-09-24T00:00:00.000Z" } }, + }); + expect(Object.keys(data?.progress ?? {})).toEqual([BARE]); + expect(Object.keys(data?.daily ?? {})).toEqual([BARE]); + expect(Object.keys(data?.ignored ?? {})).toEqual([BARE]); + }); + + test("so does one from before progress existed", () => { + const data = parseImportData({ + format: "gacha-tracker-export", + completions: { [PASS]: { at: "2026-10-01T00:00:00.000Z" } }, + }); + expect(data?.progress?.[BARE]?.status).toBe("done"); + }); +});