fix(merge): pair Genshin character trials across sources

Game8's generic trial title and Fandom's changing character list now refer
to the same banner phase in refreshed snapshots. Recognize the registered
trial article family under the existing start-date guard, without widening
fuzzy matching or combining consecutive phases. Carry the absorbed ID so
completion, notes and daily progress follow the surviving row.
This commit is contained in:
Lucas Winther
2026-11-01 04:34:26 +01:00
parent 88783b0240
commit 0043735ef5
4 changed files with 99 additions and 2 deletions
+8 -2
View File
@@ -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
+9
View File
@@ -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).
+26
View File
@@ -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" → "…
+56
View File
@@ -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();
});