feat(ingest): preserve source content classifications
Keep the sources' Miliastra, battle-pass and TCG distinctions so rotating names do not need a new title table every patch. Classification survives cross-source merges while event IDs, dates and source identities stay fixed. Conflicting classifications are reported instead of selecting a destination.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import type { DisplayEvent } from "./custom.ts";
|
||||
import type { EventClassification } from "./schema.ts";
|
||||
|
||||
/** The source's category vocabulary, rather than similarity between titles. */
|
||||
export function classifySourceLabels(labels: string): EventClassification {
|
||||
const result: EventClassification = {};
|
||||
if (/\bMiliastra Wonderland\b/i.test(labels)) result.lane = "miliastra";
|
||||
if (/\bMiliastra Pass\b/i.test(labels)) {
|
||||
result.lane = "miliastra";
|
||||
result.activity = "battle-pass";
|
||||
result.family = "miliastra-pass";
|
||||
} else if (/\bBattle Pass\b/i.test(labels)) {
|
||||
result.activity = "battle-pass";
|
||||
result.family = "battle-pass";
|
||||
}
|
||||
// Named TCG families in Genshin Event's type glossary. Forge Realm was also
|
||||
// confirmed in game by the owner, 2026-11-01 (docs event audit).
|
||||
if (/Genius Invokation TCG|The Forge Realm['’]s Temper|Heated Battle Mode/i.test(labels)) result.tags = ["tcg"];
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compatibility for source-backed snapshots/feeds predating the metadata. */
|
||||
export function classificationFor(event: DisplayEvent): EventClassification {
|
||||
if (event.id.startsWith("myevent:") || event.sourceUrl === null) return {};
|
||||
if (event.classification?.unresolved) return {};
|
||||
let known: EventClassification = {};
|
||||
if (event.game === "genshin" || event.game === "miliastra") {
|
||||
let path = "";
|
||||
try {
|
||||
const url = new URL(event.sourceUrl);
|
||||
if (url.hostname === "genshin-impact.fandom.com") path = decodeURIComponent(url.pathname).replace(/_/g, " ");
|
||||
} catch { /* Unknown links provide no evidence. */ }
|
||||
if (/^\/wiki\/(Miliastra Pass|Starlit Gala|Starlight Voyage|Raiment Collection)(?:[/:]|$)/i.test(path)) known.lane = "miliastra";
|
||||
if (/^\/wiki\/Miliastra Pass\//i.test(path)) known = { ...known, activity: "battle-pass", family: "miliastra-pass" };
|
||||
if (/^\/wiki\/Battle Pass\//i.test(path)) known = { activity: "battle-pass", family: "battle-pass" };
|
||||
if (/^\/wiki\/(The Forge Realm['’]s Temper|Heated Battle Mode)(?:[/:]|$)/i.test(path)) known.tags = ["tcg"];
|
||||
// Game8's overview link does not identify the article. These two already
|
||||
// published pass names are pinned fixtures; future passes use source labels.
|
||||
if (event.sourceId === "genshin-game8-events" && /^(?:Battle Pass - )?(Moontrace|Frostfarer)$/.test(event.title)) known = { activity: "battle-pass", family: "battle-pass" };
|
||||
}
|
||||
const families: Record<string, string> = {
|
||||
"hsr-fandom-nameless-honor": "nameless-honor",
|
||||
"zzz-fandom-city-fund": "city-fund",
|
||||
"nikki-fandom-mira-journey": "mira-journey",
|
||||
};
|
||||
const family = families[event.sourceId];
|
||||
if (family) known = { activity: "battle-pass", family };
|
||||
return { ...known, ...event.classification };
|
||||
}
|
||||
|
||||
export const displayGameFor = (event: DisplayEvent): string => classificationFor(event).lane ?? event.game;
|
||||
export const passFamilyFor = (event: DisplayEvent): string | null => {
|
||||
const c = classificationFor(event);
|
||||
return c.activity === "battle-pass" ? c.family ?? "battle-pass" : null;
|
||||
};
|
||||
export const passPlacementId = (event: DisplayEvent): string | null => {
|
||||
const family = passFamilyFor(event);
|
||||
return family === null ? null : `feed-cycle:${displayGameFor(event)}:${family}`;
|
||||
};
|
||||
export const isTcgEvent = (event: DisplayEvent): boolean => classificationFor(event).tags?.includes("tcg") === true;
|
||||
export const tracksEventCategory = (event: DisplayEvent, prefs: Record<string, unknown>): boolean => !(displayGameFor(event) === "genshin" && prefs.genshinTcg === false && isTcgEvent(event));
|
||||
export function classifiedEvent(event: DisplayEvent): DisplayEvent {
|
||||
const game = displayGameFor(event);
|
||||
return game === event.game ? event : { ...event, game, classification: classificationFor(event) };
|
||||
}
|
||||
/** Miliastra consumes the same source bytes, freshness and attribution. */
|
||||
export const sourceGameFor = (game: string): string => game === "miliastra" ? "genshin" : game;
|
||||
@@ -62,6 +62,7 @@ export interface GameMeta {
|
||||
}
|
||||
|
||||
export const GAMES: Record<GameId, GameMeta> = {
|
||||
miliastra: { id: "miliastra", name: "Miliastra Wonderland", short: "Miliastra", hue: "#9C83D6", studio: "HoYoverse", dailyTasks: "" },
|
||||
// EU reset at 04:00 UTC+1 confirmed in game by the owner on 2026-10-16: the
|
||||
// Trounce Domains menu reads "every Monday 4AM GMT+1".
|
||||
genshin: { id: "genshin", name: "Genshin Impact", short: "Genshin", hue: "#4EA8DE" , studio: "HoYoverse", dailyTasks: "Commissions, resin" },
|
||||
|
||||
@@ -23,6 +23,7 @@ export const GameId = z.enum([
|
||||
"nikke", // Goddess of Victory: Nikke
|
||||
"hi3", // Honkai Impact 3rd
|
||||
"pgr", // Punishing: Gray Raven
|
||||
"miliastra", // Logical lane; source identities remain Genshin.
|
||||
]);
|
||||
export type GameId = z.infer<typeof GameId>;
|
||||
|
||||
@@ -50,6 +51,15 @@ export type Region = z.infer<typeof Region>;
|
||||
export const Precision = z.enum(["exact", "day", "unknown"]);
|
||||
export type Precision = z.infer<typeof Precision>;
|
||||
|
||||
export const EventClassification = z.object({
|
||||
unresolved: z.boolean().optional(),
|
||||
lane: z.literal("miliastra").optional(),
|
||||
activity: z.literal("battle-pass").optional(),
|
||||
family: z.string().min(1).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
export type EventClassification = z.infer<typeof EventClassification>;
|
||||
|
||||
export const GachaEvent = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -85,6 +95,7 @@ export const GachaEvent = z
|
||||
* client's non-strict parse drops it unread.
|
||||
*/
|
||||
mergedIds: z.array(z.string()).optional(),
|
||||
classification: EventClassification.optional(),
|
||||
})
|
||||
// These invariants are cheap here and prevent a whole class of wrong-date bugs
|
||||
// reaching the feed. See docs/INGESTION.md § Stage 4.
|
||||
|
||||
Reference in New Issue
Block a user