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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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" → "…
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user