fix(game8): drop the Battle Pass label Fandom does not print

Game8 heads Genshin's pass "Battle Pass - Moontrace"; Fandom lists "Moontrace" and files the category in a column. The label minted a second id for one event, so every battle pass since the Fandom source landed has been on the calendar twice, and a reader reported it.

Both sources now mint the same id and merge fuses them by id. The two Game8 ids that moved, Frostfarer and Moontrace (the only ones git history shows ever shipped), go in RETIRED_IDS so readers' marks follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-14 17:47:28 +02:00
1 parent 7c8fa7febc
commit 59403ca2cc
8 files changed
+122 -28

No files matched your search

+11 -2
View File
@@ -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.
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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,
+13 -6
View File
@@ -10,14 +10,21 @@ export type CarryTable = Readonly<Record<string, string>>;
*
* 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.
+17 -3
View File
@@ -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,
+19 -4
View File
@@ -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);
});
});
+47
View File
@@ -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");
});
});