diff --git a/fixtures/genshin/articles/event-table-2026-11-06.html b/fixtures/genshin/articles/event-table-2026-11-06.html new file mode 100644 index 0000000..bd42809 --- /dev/null +++ b/fixtures/genshin/articles/event-table-2026-11-06.html @@ -0,0 +1 @@ +{"parse":{"title":"Event","pageid":7573,"text":"
OverviewHistoryRewards
\n

\n

\n\n

Events are limited time activities in Genshin Impact that allows the player to earn rewards. Players must be Adventure Rank 5 to participate in Events, but most have additional requirements. Some Events include Quests that are not available after the Event duration has ended.\n

The Events Overview menu can be accessed from the Paimon Menu, from the Shortcut Wheel, from the \"Icon icon in the top right of screen, or pressing the F5 key on PC.\n

Some Events allow the player to skip quests requirements using the Quick Start feature.\n

\n

Contents

\n\n
\n\n

List of Events[]

\n

See Event/History for a complete list of Events.\n

\n

Current

EventDurationType(s)
\"To
To Temper Thyself and Journey Far Cycle 6
November 02, 2026 – January 25, 2027In-Game, To Temper Thyself and Journey Far
Test Run - Mitya, Furina, Valeriy, Charlotte, RosariaNovember 04, 2026 – November 24, 2026In-Game, Test Run
\"Battle
Sacred Spirit Chronicle
November 04, 2026 – December 14, 2026In-Game, Battle Pass
\"Chronicle
Chronicle of Polyphony
November 04, 2026 – December 14, 2026In-Game, Miliastra Pass, Miliastra Wonderland
\"The
The Forge Realm's Temper: Endless Swarm
November 04, 2026 – December 15, 2026In-Game, The Forge Realm's Temper
\"Encounter
Encounter: Interlude of the Long Journey
November 04, 2026 – December 15, 2026In-Game, Repertoire of Myriad Melodies Update
\"Hour
Hour of Ruin
November 04, 2026 – December 15, 2026In-Game, Archon Quest
\"The
The Grand Defense: Defy the Unjust Judgment
November 04, 2026 – December 16, 2026In-Game, Flagship
\"Raiment
Raiment Collection: Radiance Ablaze
November 04, 2026 – December 16, 2026In-Game, Miliastra Wonderland
\"Where
Where Kristall Roots Run Deep
November 04, 2026 – January 26, 2027In-Game, Exploration Mission
\"Across
Across the Frozen Wilds, Honing One's Edge
November 04, 2026 – TBAIn-Game, Anniversary 5-Star Character Selection

Upcoming

EventDurationType(s)
\"Stygian
Stygian Onslaught
November 11, 2026 – December 15, 2026In-Game, Stygian Onslaught
\"Stress
Stress Block Smashing Project
November 20, 2026 – November 30, 2026In-Game
\"Frames
Frames of Fierce Splendor
November 27, 2026 – December 07, 2026In-Game, Kamera
Starlight Voyage: Spontaneous CompositionNovember 30, 2026 – December 14, 2026In-Game, Miliastra Wonderland
\"Ley
Ley Line Overflow 2026-12-07
December 07, 2026 – December 14, 2026In-Game

Permanent

EventRelease DateType(s)
\"Fate
Fate Foretold
September 28, 2020In-Game, Indefinite
\"People's
Spiral Abyss Event - People's Choice
September 28, 2020In-Game, Spiral Abyss, Indefinite
\"Wings
Wings of Companionship
September 28, 2020In-Game, Mail, Indefinite
\"Baptism
Baptism of Song
November 11, 2020In-Game, Archon Quest, Indefinite
\"Stellar
Stellar Reunion
December 23, 2020In-Game, Indefinite, Login
\"HoYoLAB
HoYoLAB Community Daily Check-In
March 01, 2021Web, Indefinite, Login
\"Curtain
Curtain of Leaves - Spiral Abyss Challenge
September 28, 2022In-Game, Spiral Abyss, Indefinite
\"The
The Curtain Never Falls on Magic
August 16, 2023In-Game, Indefinite
\"Genshin
Genshin Impact and Discord Account Linking Now Available: Complete Discord Account Linking to Unlock More Game Info Display and One-Click Rewards
August 20, 2024Web, Indefinite, Login
\"Turbo
Turbo Twirly!
August 28, 2024In-Game, Archon Quest, Indefinite
\"The
The Clink Clank Whiz-Kid!
September 10, 2025In-Game, Archon Quest, Indefinite
\"Invitation
Invitation to Wonderland: A Curious Awakening
October 22, 2025In-Game, Miliastra Wonderland, Indefinite
\"Invitation
Invitation to Wonderland: In Search of Inspiration
December 03, 2025In-Game, Miliastra Wonderland, Indefinite
\"Witch's
Witch's Homework
December 03, 2025In-Game, World Quest, Indefinite
\"The
The Hunt Begins!
August 12, 2026In-Game, Archon Quest, Indefinite
\n

List of Event Types[]

\n

There are 26 Event Types:\n

\n\n

List of Recurring Events[]

\n

There are 65 Recurring Events:\n

\n\n

Navigation[]

\n
\n\n\n\n\n
"}} \ No newline at end of file diff --git a/fixtures/genshin/articles/hour-of-ruin-2026-11-07.html b/fixtures/genshin/articles/hour-of-ruin-2026-11-07.html new file mode 100644 index 0000000..ac39fbd --- /dev/null +++ b/fixtures/genshin/articles/hour-of-ruin-2026-11-07.html @@ -0,0 +1 @@ +{"parse":{"title":"Hour of Ruin (Event)","pageid":500845,"text":"
This article is about the Version 7.2 event. For the Archon Quest, see Hour of Ruin.
\n
\"Icon
This page is in need of information.
Can you help out? Click here to add more.
Unspecified reason

\n\n

\n

Asia
Ends in:
December 15, 2026 06:59:59 AM UTC
Europe
Ends in:
December 15, 2026 01:59:59 PM UTC
America
Ends in:
December 15, 2026 07:59:59 PM UTC
\n

Hour of Ruin is an Event in Version 7.2.\n

\n\n\n
"}} \ No newline at end of file diff --git a/fixtures/genshin/articles/raiment-collection-2026-11-04-2026-11-07.html b/fixtures/genshin/articles/raiment-collection-2026-11-04-2026-11-07.html new file mode 100644 index 0000000..9f1fb44 --- /dev/null +++ b/fixtures/genshin/articles/raiment-collection-2026-11-04-2026-11-07.html @@ -0,0 +1 @@ +{"parse":{"title":"Raiment Collection/2026-11-04","pageid":501128,"text":"
\"Icon
This page is in need of information.
Can you help out? Click here to add more.
Unspecified reason

\n\n

\n

Asia
Ends in:
December 15, 2026 09:59:59 PM UTC
Europe
Ends in:
December 15, 2026 09:59:59 PM UTC
America
Ends in:
December 15, 2026 09:59:59 PM UTC
\n

Raiment Collection: Radiance Ablaze is an Event for Miliastra Wonderland in Version 7.2.\n

\n\n\n
"}} \ No newline at end of file diff --git a/src/ingest/adapters/types.ts b/src/ingest/adapters/types.ts index 15842f1..76c3e09 100644 --- a/src/ingest/adapters/types.ts +++ b/src/ingest/adapters/types.ts @@ -1,4 +1,5 @@ import type { GachaEvent, GameId } from "../../shared/schema.ts"; +import type { StatedEnd } from "../articleEnds.ts"; export interface ParseContext { /** @@ -9,6 +10,11 @@ export interface ParseContext { sourceUrl: string; sourceId: string; game: GameId; + /** + * Ends stated by a row's own article, keyed by page title. Only ever decides + * whether a row is still live: the parser never writes one onto a row. + */ + articleEnds?: ReadonlyMap; } /** diff --git a/src/ingest/articleEnds.ts b/src/ingest/articleEnds.ts new file mode 100644 index 0000000..6ab0059 --- /dev/null +++ b/src/ingest/articleEnds.ts @@ -0,0 +1,137 @@ +import { Region, type GachaEvent } from "../shared/schema.ts"; +import { REGION_RESET_UTC_OFFSET } from "../shared/time.ts"; +import { text } from "./html.ts"; + +/** + * The end a Genshin event's own wiki article states. + * + * Fandom's `Event` table prints a date, and the same printed date can mean 03:59 + * server time, 14:59 server time or one instant worldwide. So the table cannot + * settle the time and the article's infobox can + * (docs/superpowers/specs/2026-11-07-exact-end-times-design.md). Pure and + * offline: fetching these is `scripts/refresh-articles.ts`'s job. + */ +export interface StatedEnd { + /** The calendar date as printed, `YYYY-MM-DD`, for comparing with the table. */ + day: string; + /** The instant, or the earliest region's: never promise more than a region gets. */ + endsAt: string; + regionEnds: Record | null; +} + +const MONTHS = [ + "January", "February", "March", "April", "May", "June", "July", + "August", "September", "October", "November", "December", +]; + +const END_CELL = /]*data-source="time_end"[^>]*>([\s\S]*?)<\/td>/i; +const PRINTED = + /^([A-Z][a-z]+) (\d{1,2}), (\d{4}) (\d{1,2}):(\d{2}) (\(Server Time\)|GMT\+8)$/; + +const HOUR_MS = 60 * 60 * 1000; + +/** `body` is the API's JSON response. Anything but a stated time is `null`. */ +export function parseArticleEnd(body: string): StatedEnd | null { + let rendered: unknown; + try { + rendered = (JSON.parse(body) as { parse?: { text?: unknown } } | null)?.parse?.text; + } catch { + return null; + } + if (typeof rendered !== "string") return null; + + const cell = END_CELL.exec(rendered)?.[1]; + if (cell === undefined) return null; + const printed = PRINTED.exec(text(cell)); + if (printed === null) return null; + + const [, monthName, dayText, yearText, hourText, minuteText, zone] = printed; + const month = MONTHS.indexOf(monthName ?? ""); + const day = Number(dayText); + const year = Number(yearText); + const hour = Number(hourText); + const minute = Number(minuteText); + if (month < 0 || hour > 23 || minute > 59) return null; + + const wall = Date.UTC(year, month, day, hour, minute); + // Date.UTC rolls 45 December into January rather than refusing it. + if (new Date(wall).getUTCDate() !== day) return null; + const printedDay = new Date(wall).toISOString().slice(0, 10); + const at = (offsetHours: number) => new Date(wall - offsetHours * HOUR_MS).toISOString(); + + if (zone === "GMT+8") return { day: printedDay, endsAt: at(8), regionEnds: null }; + + const regionEnds = { + asia: at(REGION_RESET_UTC_OFFSET.asia), + america: at(REGION_RESET_UTC_OFFSET.america), + europe: at(REGION_RESET_UTC_OFFSET.europe), + }; + const endsAt = Object.values(regionEnds).sort()[0] ?? at(REGION_RESET_UTC_OFFSET.asia); + return { day: printedDay, endsAt, regionEnds }; +} + +/** The decoded page title of a Genshin wiki article's URL, else `null`. */ +export function articleTitle(sourceUrl: string): string | null { + try { + const url = new URL(sourceUrl); + if (url.hostname !== "genshin-impact.fandom.com") return null; + if (!url.pathname.startsWith("/wiki/")) return null; + const title = decodeURIComponent(url.pathname.slice("/wiki/".length)); + return title.length === 0 ? null : title; + } catch { + return null; + } +} + +/** The instant a stated end has passed for every reader, whatever their region. */ +export function latestStatedMs(end: StatedEnd): number { + const all = end.regionEnds === null ? [end.endsAt] : Object.values(end.regionEnds); + return Math.max(...all.map((iso) => Date.parse(iso))); +} + +/** + * Write stated ends onto the merged rows. After the merge on purpose: it keeps + * the higher-confidence copy, which for several rows is Game8's day-precision one. + * + * `listed` is the Fandom parse before merging, the only place a row's article + * link and its table date survive. Only the four end fields ever change: `id` and + * `startsAt` are a reader's localStorage key. + */ +export function applyArticleEnds( + merged: GachaEvent[], + listed: GachaEvent[], + ends: ReadonlyMap, + warn: (line: string) => void, +): GachaEvent[] { + const byId = new Map(); + for (const row of listed) { + const title = articleTitle(row.sourceUrl); + const end = title === null ? undefined : ends.get(title); + if (end !== undefined) byId.set(row.id, { end, tableDay: row.endsAt?.slice(0, 10) }); + } + + return merged.map((event) => { + const found = [event.id, ...(event.mergedIds ?? [])] + .map((id) => byId.get(id)) + .find((f) => f !== undefined); + if (found === undefined) return event; + // Another source stated this end to the minute; an article does not outrank it. + if (event.endPrecision !== "day") return event; + + const { end, tableDay } = found; + if (tableDay !== end.day || event.endsAt?.slice(0, 10) !== end.day) { + warn( + `"${event.title}": article says ${end.day}, table ${tableDay ?? "none"}, ` + + `row ${event.endsAt?.slice(0, 10) ?? "none"}; keeping the row's date`, + ); + return event; + } + return { + ...event, + endsAt: end.endsAt, + endPrecision: "exact" as const, + regionScoped: end.regionEnds !== null, + regionEnds: end.regionEnds, + }; + }); +} diff --git a/src/ingest/parsers/fandom.ts b/src/ingest/parsers/fandom.ts index 08cdc5c..9bd148e 100644 --- a/src/ingest/parsers/fandom.ts +++ b/src/ingest/parsers/fandom.ts @@ -8,6 +8,7 @@ import { } from "../dates.ts"; import { decodeEntities, text } from "../html.ts"; import type { ParseContext } from "../adapters/types.ts"; +import { articleTitle, latestStatedMs } from "../articleEnds.ts"; import { inferType } from "./game8.ts"; import { classifySourceLabels } from "../../shared/classification.ts"; import type { SourceParser } from "./types.ts"; @@ -819,12 +820,19 @@ function parseGenshinEvents( // `Current` is maintained by hand and goes stale before anyone moves a // row, so currency is checked rather than taken on trust — on the clock // the countdown reads a day-precision end on, not on the UTC midnight - // placeholder stored for it. - if ( - latestBoundaryMs(range.end.iso, range.end.precision, ctx.game) < nowMs - ) { - continue; - } + // placeholder stored for it. An article that states a time on that same + // date is the better clock: a row ending 14:59 must not vanish at 04:00. + const href = ARTICLE_LINK.exec(titleCell)?.[1]; + const articleUrl = + href === undefined ? null : new URL(href, ctx.sourceUrl).toString(); + const pageTitle = articleUrl === null ? null : articleTitle(articleUrl); + const statedEnd = + pageTitle === null ? undefined : ctx.articleEnds?.get(pageTitle); + const lastMs = + statedEnd !== undefined && statedEnd.day === range.end.iso.slice(0, 10) + ? latestStatedMs(statedEnd) + : latestBoundaryMs(range.end.iso, range.end.precision, ctx.game); + if (lastMs < nowMs) continue; const id = eventId(ctx.game, title, range.start.iso); if (seen.has(id)) continue; @@ -832,7 +840,6 @@ function parseGenshinEvents( const stated = table.type === undefined ? "" : text(cells[table.type]?.html ?? ""); - const href = ARTICLE_LINK.exec(titleCell)?.[1]; out.push({ id, @@ -856,10 +863,7 @@ function parseGenshinEvents( // claiming a split the source never made. regionScoped: false, regionEnds: null, - sourceUrl: - href === undefined - ? ctx.sourceUrl - : new URL(href, ctx.sourceUrl).toString(), + sourceUrl: articleUrl ?? ctx.sourceUrl, sourceId: ctx.sourceId, status: "published", // Day precision on both sides, which is everything the page states. diff --git a/test/article-ends.test.ts b/test/article-ends.test.ts new file mode 100644 index 0000000..2c58164 --- /dev/null +++ b/test/article-ends.test.ts @@ -0,0 +1,212 @@ +import { expect, test } from "bun:test"; +import { ADAPTERS } from "../src/ingest/adapters/index.ts"; +import { + applyArticleEnds, + articleTitle, + latestStatedMs, + parseArticleEnd, + type StatedEnd, +} from "../src/ingest/articleEnds.ts"; +import { mergeEvents } from "../src/ingest/merge.ts"; +import type { GachaEvent } from "../src/shared/schema.ts"; + +const dir = "fixtures/genshin/articles"; +const hourOfRuin = await Bun.file(`${dir}/hour-of-ruin-2026-11-07.html`).text(); +const raiment = await Bun.file(`${dir}/raiment-collection-2026-11-04-2026-11-07.html`).text(); +const table = await Bun.file(`${dir}/event-table-2026-11-06.html`).text(); + +const withEnd = (value: string) => + hourOfRuin.replace("December 15, 2026 14:59 (Server Time)", value); + +const fandom = ADAPTERS.find((a) => a.id === "genshin-fandom-events")!; +const parseTable = (now: string, articleEnds?: ReadonlyMap) => + fandom.parse(table, { + now, + sourceUrl: fandom.url, + sourceId: fandom.id, + game: fandom.game, + ...(articleEnds ? { articleEnds } : {}), + }); + +const NOW = "2026-11-07T00:00:00.000Z"; +const listed = parseTable(NOW); +const row = (title: string): GachaEvent => listed.find((e) => e.title === title)!; +const HOUR_OF_RUIN_ID = "genshin:hour-of-ruin:2026-11-04"; +const ends = new Map([["Hour_of_Ruin_(Event)", parseArticleEnd(hourOfRuin)!]]); + +test("server time gives every region its own end", () => { + const end = parseArticleEnd(hourOfRuin)!; + expect(end.day).toBe("2026-12-15"); + expect(end.regionEnds).toEqual({ + asia: "2026-12-15T06:59:00.000Z", + europe: "2026-12-15T13:59:00.000Z", + america: "2026-12-15T19:59:00.000Z", + }); + // The earliest, as wikigg.ts does: never promise more than some region gets. + expect(end.endsAt).toBe("2026-12-15T06:59:00.000Z"); +}); + +test("GMT+8 is one instant", () => { + expect(parseArticleEnd(raiment)).toEqual({ + day: "2026-12-16", + endsAt: "2026-12-15T21:59:00.000Z", + regionEnds: null, + }); +}); + +test.each([ + "December 15, 2026", + "TBA", + "December 15, 2026 14:59 UTC", + "December 15, 2026 14:59 GMT+9", + "Smarch 15, 2026 14:59 (Server Time)", + "December 45, 2026 14:59 (Server Time)", + "December 15, 2026 25:59 (Server Time)", + "", +])("%p is no usable time", (value) => { + expect(parseArticleEnd(withEnd(value))).toBeNull(); +}); + +test("malformed JSON is no usable time", () => { + expect(parseArticleEnd("not the API")).toBeNull(); +}); + +test("a page with no infobox is no usable time", () => { + expect(parseArticleEnd(JSON.stringify({ parse: { text: "

Stub.

" } }))).toBeNull(); +}); + +test("the End header cell is not mistaken for the value", () => { + // The carries data-source="time_end" too, and precedes the value. + const noValue = JSON.stringify({ + parse: { text: '
End
' }, + }); + expect(parseArticleEnd(noValue)).toBeNull(); +}); + +test("markup inside the value cell is read as its text", () => { + expect( + parseArticleEnd(withEnd("December 15, 2026 14:59 (Server Time)"))?.day, + ).toBe("2026-12-15"); +}); + +test("the latest stated instant is the last region's", () => { + expect(latestStatedMs(parseArticleEnd(hourOfRuin)!)).toBe(Date.parse("2026-12-15T19:59:00.000Z")); + expect(latestStatedMs(parseArticleEnd(raiment)!)).toBe(Date.parse("2026-12-15T21:59:00.000Z")); +}); + +test("an article title is decoded from a wiki URL and nothing else", () => { + expect(articleTitle("https://genshin-impact.fandom.com/wiki/The_Forge_Realm%27s_Temper/2026-11-04")).toBe( + "The_Forge_Realm's_Temper/2026-11-04", + ); + expect(articleTitle(fandom.url)).toBeNull(); + expect(articleTitle("https://game8.co/wiki/Hour_of_Ruin")).toBeNull(); +}); + +test("the end lands on the row an article describes", () => { + const [applied] = applyArticleEnds([row("Hour of Ruin")], listed, ends, () => {}); + expect(applied?.endPrecision).toBe("exact"); + expect(applied?.regionScoped).toBe(true); + expect(applied?.regionEnds?.europe).toBe("2026-12-15T13:59:00.000Z"); + expect(applied?.endsAt).toBe("2026-12-15T06:59:00.000Z"); +}); + +test("a global end leaves the row unscoped", () => { + const raimentRow = row("Raiment Collection: Radiance Ablaze"); + const [applied] = applyArticleEnds( + [raimentRow], + listed, + new Map([["Raiment_Collection/2026-11-04", parseArticleEnd(raiment)!]]), + () => {}, + ); + expect(applied?.endPrecision).toBe("exact"); + expect(applied?.regionScoped).toBe(false); + expect(applied?.regionEnds).toBeNull(); + expect(applied?.endsAt).toBe("2026-12-15T21:59:00.000Z"); +}); + +test("a merged Game8 survivor takes the end through mergedIds", () => { + const survivor: GachaEvent = { + ...row("Hour of Ruin"), + id: "genshin:game8-title:2026-11-04", + sourceId: "genshin-game8-events", + sourceUrl: "https://game8.co/games/Genshin-Impact/archives/301601", + mergedIds: [HOUR_OF_RUIN_ID], + }; + const [applied] = applyArticleEnds([survivor], listed, ends, () => {}); + expect(applied?.endPrecision).toBe("exact"); + expect(applied?.regionEnds?.america).toBe("2026-12-15T19:59:00.000Z"); +}); + +test("a survivor of a real merge keeps its identity and takes the end", () => { + const game8Copy: GachaEvent = { + ...row("Hour of Ruin"), + sourceId: "genshin-game8-events", + sourceUrl: "https://game8.co/games/Genshin-Impact/archives/301601", + confidence: 0.95, + }; + const { events } = mergeEvents([[game8Copy], [row("Hour of Ruin")]]); + const [applied] = applyArticleEnds(events, listed, ends, () => {}); + expect(applied?.sourceId).toBe("genshin-game8-events"); + expect(applied?.endPrecision).toBe("exact"); +}); + +test("id, startsAt, sourceUrl, sourceId and classification never change", () => { + const before = { ...row("Hour of Ruin"), classification: { lane: "miliastra" as const } }; + const [after] = applyArticleEnds([before], listed, ends, () => {}); + expect(after?.id).toBe(before.id); + expect(after?.startsAt).toBe(before.startsAt); + expect(after?.sourceUrl).toBe(before.sourceUrl); + expect(after?.sourceId).toBe(before.sourceId); + expect(after?.classification).toEqual(before.classification); +}); + +test("an article whose date disagrees with the table keeps the row and warns", () => { + const stale = new Map([ + ["Hour_of_Ruin_(Event)", parseArticleEnd(withEnd("December 20, 2026 14:59 (Server Time)"))!], + ]); + const warnings: string[] = []; + const input = [row("Hour of Ruin")]; + expect(applyArticleEnds(input, listed, stale, (w) => warnings.push(w))).toEqual(input); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain("Hour of Ruin"); +}); + +test("a survivor whose date disagrees with the table keeps the row and warns", () => { + const survivor: GachaEvent = { ...row("Hour of Ruin"), endsAt: "2026-12-17T00:00:00.000Z" }; + const warnings: string[] = []; + expect(applyArticleEnds([survivor], listed, ends, (w) => warnings.push(w))).toEqual([survivor]); + expect(warnings.length).toBe(1); +}); + +test("a survivor already at exact precision is left alone without a warning", () => { + const survivor: GachaEvent = { + ...row("Hour of Ruin"), + endsAt: "2026-12-15T10:00:00.000Z", + endPrecision: "exact", + }; + const warnings: string[] = []; + expect(applyArticleEnds([survivor], listed, ends, (w) => warnings.push(w))).toEqual([survivor]); + expect(warnings).toEqual([]); +}); + +test("no articles means the same contents as merged", () => { + expect(applyArticleEnds(listed, listed, new Map(), () => {})).toEqual(listed); +}); + +test("a live row outlives the table's day on its stated end", () => { + // Past the last region's 04:00 on the printed day, before America's 14:59. + const lastDay = "2026-12-15T15:00:00.000Z"; + expect(parseTable(lastDay).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(false); + expect(parseTable(lastDay, ends).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(true); +}); + +test("a stated end also retires a row, once every region has passed it", () => { + expect(parseTable("2026-12-15T20:00:00.000Z", ends).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(false); +}); + +test("an article that disagrees on the date does not keep a row alive", () => { + const stale = new Map([ + ["Hour_of_Ruin_(Event)", parseArticleEnd(withEnd("December 20, 2026 14:59 (Server Time)"))!], + ]); + expect(parseTable("2026-12-15T15:00:00.000Z", stale).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(false); +});