diff --git a/docs/2026-11-01-genshin-event-audit.md b/docs/2026-11-01-genshin-event-audit.md index 932e455..9fa8cbd 100644 --- a/docs/2026-11-01-genshin-event-audit.md +++ b/docs/2026-11-01-genshin-event-audit.md @@ -65,8 +65,14 @@ Battle Pass prefix was addressed by the previous parser change. See This walkthrough established no additional duplicate pair requiring a new merge rule. It identified content classification and potentially stale display issues: Miliastra content under Genshin, pass placement, and older event rows. The older -Character Test Runs row covers a different banner phase from the current Skirk / -Escoffier trial, so those two runs must not be fused. +Character Test Runs row in the initially inspected snapshot covered a different +banner phase from the current Skirk / Escoffier trial, so those runs must not be +fused. After the automated refreshes through `4faf3c9`, Game8's row instead +starts 2026-10-13 and ends 2026-11-03, matching Fandom's named Skirk / Escoffier +phase. The owner reported both on the live server after the classification +release. This is now a confirmed same-phase duplicate: merge matches the two +source-identified trial families under its existing start guard and preserves +the absorbed ID through `mergedIds`. Different-phase fixture rows stay separate. Next session should inspect how the current client handles expiration, existing cycle/pass windows, and the consequences of a separate Miliastra lane. Game and diff --git a/docs/INGESTION.md b/docs/INGESTION.md index ace6c02..b27025e 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -503,6 +503,15 @@ Only meaningful when a game has more than one source; a single-source game passe **Dates alone never pair two events**: patch day puts about twenty different Genshin events on one window. + **Genshin character trials have a source-identified family match** (2026-11-01). + Game8's exact `Character Test Runs` title and Fandom's dated + `Test_Run_-_Character_Trial_Event/YYYY-MM-DD` article describe the same trial + phase when their starts satisfy the existing 24h guard. The article date must + match its parsed start; unrelated challenge pages do not qualify. Character + names are never hardcoded, consecutive banner phases stay separate, and an end + disagreement still produces a conflict. The survivor records the other ID in + `mergedIds`, carrying its saved progress as usual. + **The survivor lists what it absorbed** in `mergedIds`, and the client moves readers' marks off those ids when the feed arrives — so a rule that starts fusing two published ids costs nobody a tick, with no table to edit (`docs/DATA-MODEL.md` § ID stability). diff --git a/src/ingest/merge.ts b/src/ingest/merge.ts index a354ab3..24a907f 100644 --- a/src/ingest/merge.ts +++ b/src/ingest/merge.ts @@ -179,6 +179,8 @@ function isSameEvent( // to be close before treating two entries as one event. if (hoursBetween(a.startsAt, b.startsAt) > toleranceHours) return false; + if (sameGenshinTrial(a, b)) return true; + const titled = (x: GachaEvent, y: GachaEvent) => titleSimilarity(x.title, y.title) >= titleThreshold || titleExtends(x.title, y.title); @@ -201,6 +203,30 @@ function isSameEvent( return !twinIn(a, b.sourceId, b) && !twinIn(b, a.sourceId, a); } +/** + * Game8 names the trial family; Fandom names its featured characters. Their + * shared start identifies a phase (guarded above), while Fandom's dated article + * path identifies the activity independently of its changing character list. + * Confirmed against both snapshots and the owner's in-game list, 2026-11-01. + * End disagreements still go through findConflict; dates are never averaged. + */ +function sameGenshinTrial(a: GachaEvent, b: GachaEvent): boolean { + if (a.game !== "genshin" || b.game !== "genshin") return false; + const generic = (e: GachaEvent) => + e.sourceId === "genshin-game8-events" && /^Character Test Runs?$/i.test(e.title); + const named = (e: GachaEvent) => { + if (e.sourceId !== "genshin-fandom-events") return false; + try { + const url = new URL(e.sourceUrl); + return url.hostname === "genshin-impact.fandom.com" && + decodeURIComponent(url.pathname) === `/wiki/Test_Run_-_Character_Trial_Event/${e.startsAt.slice(0, 10)}`; + } catch { + return false; + } + }; + return (generic(a) && named(b)) || (named(a) && generic(b)); +} + /** * True when one title is the other with words added at either end — the shape * a source qualifier actually takes. Appended: "Bedazzling Dawnstar" → "… diff --git a/test/genshin-trial-merge.test.ts b/test/genshin-trial-merge.test.ts new file mode 100644 index 0000000..98a5e17 --- /dev/null +++ b/test/genshin-trial-merge.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { mergeEvents } from "../src/ingest/merge.ts"; +import { aliasesFrom } from "../src/client/state/retiredIds.ts"; +import { applyReaderAliases } from "../src/client/state/reader/aliases.ts"; +import { migrateLegacy } from "../src/client/state/reader/migration.ts"; +import { sampleEvent } from "./helpers/reader.ts"; + +const at = "2026-11-01T12:00:00.000Z"; +const generic = { + ...sampleEvent, + id: "genshin:character-test-runs:2026-10-13", + title: "Character Test Runs", + startsAt: "2026-10-13T00:00:00.000Z", + endsAt: "2026-11-03T00:00:00.000Z", + sourceId: "genshin-game8-events", + sourceUrl: "https://game8.co/games/Genshin-Impact/archives/301601", + confidence: 0.85, +}; +const named = { + ...generic, + id: "genshin:test-run-skirk-escoffier-dahlia-candace-mika:2026-10-13", + title: "Test Run - Skirk, Escoffier, Dahlia, Candace, Mika", + sourceId: "genshin-fandom-events", + sourceUrl: "https://genshin-impact.fandom.com/wiki/Test_Run_-_Character_Trial_Event/2026-10-13", +}; +test("generic and character-named copies of the same Genshin trial phase merge", () => { + const result = mergeEvents([[generic], [named]]); + expect(result.events).toHaveLength(1); + expect(result.events[0]?.id).toBe(generic.id); + expect(result.events[0]?.mergedIds).toEqual([named.id]); + expect(result.conflicts).toHaveLength(0); +}); +test("separate banner phases and unrelated events remain separate", () => { + const previous = { ...generic, id: "genshin:character-test-runs:2026-09-23", startsAt: "2026-09-23T00:00:00.000Z", endsAt: generic.startsAt }; + expect(mergeEvents([[previous], [named]]).events).toHaveLength(2); + expect(mergeEvents([[generic], [{ ...named, sourceUrl: "https://genshin-impact.fandom.com/wiki/Some_Other_Challenge" }]]).events).toHaveLength(2); + expect(mergeEvents([[generic], [{ ...named, game: "hsr" }]]).events).toHaveLength(2); +}); +test("trial identity works for future character names and flags conflicting ends", () => { + const next = { ...named, title: "Test Run - Different Characters", id: "genshin:test-run-different-characters:2026-10-13", endsAt: "2026-11-06T00:00:00.000Z" }; + const result = mergeEvents([[generic], [next]]); + expect(result.events).toHaveLength(1); + expect(result.conflicts).toHaveLength(1); + expect(result.events[0]?.endsAt).toBe(generic.endsAt); +}); +test("progress under either trial copy follows the surviving ID", () => { + const result = mergeEvents([[generic], [named]]); + const s = migrateLegacy({}, ["genshin"], at, () => "account:trial"); + s.data["account:trial"]!.progress[named.id] = { status: "done", note: "collected", at }; + s.data["account:trial"]!.daily.initial = { [named.id]: { days: ["2026-11-01"], at } }; + const next = applyReaderAliases(s, aliasesFrom(result.events)); + expect(next.data["account:trial"]!.progress[generic.id]).toHaveProperty("status", "done"); + expect(next.data["account:trial"]!.progress[generic.id]).toHaveProperty("note", "collected"); + expect(next.data["account:trial"]!.daily.initial?.[generic.id]).toHaveProperty("days", ["2026-11-01"]); + expect(next.data["account:trial"]!.progress[named.id]).toBeUndefined(); +});