feat(holodori): track hololive Dreams from its own wiki
New holodoriwiki parser plus the holodori-holodoriwiki-events source, giving the game its two live and upcoming events at exact precision. holodori.wiki is Miraheze, so this is the same call bawiki makes and for the same reason: robots.txt disallows /w/ and /*?action=, which closes the API route and leaves the rendered /wiki/Events page as the surface * is allowed. It serves our own User-Agent a 200, sets no Content-Signal and no Crawl-delay for us, and the wiki is CC BY-SA 4.0. What makes this source unusually good is that it states its timezone on every cell — 08/17/2026 8:00PM (JST) — so it is the only wiki source here publishing exact precision on both boundaries without a per-region timer. parseSlashClockZone requires that zone rather than defaulting to UTC: a row that ever loses it drops out instead of silently landing nine hours off, and an abbreviation absent from the table is refused rather than guessed, which is why the table holds only JST and not the plausible-looking candidates that are not fixed offsets. The 12-hour clock gets its own handling and its own tests, because 12PM and 12AM are the two readings a naive parser gets wrong while still producing a valid-looking instant. Inclusion is fenced by the Current Events <h2> and bounded by the next one. That matters more than it looks: Past Events sits directly below with identical columns, so a reader taking every wikitable would put the back catalogue on the calendar with nothing to mark it. Rows are still checked against ctx.now on top of the heading, because Current is maintained by hand and goes stale before anyone moves a row down. Two rows are deliberately missing. Beginner Mission runs Game Launch → Unknown, and no start means no event ID — a permanent tutorial chore is not what a calendar of deadlines is for. An Unknown end is kept as endsAt null, and unlike bawiki this parser does not drop a started-but-undated row: the heading has already said the event is running, which is the fact bawiki lacks when reading an archive. Every event title is still a red link — the wiki has no article for any of them yet — so each points at ?action=edit&redlink=1, a create-page form on a path this robots.txt disallows. Refusing a href with a query keeps those out and falls back to the events page; the articles get linked when they exist, with no change here. holodori gets resetOffsets of UTC+9 across all three regions, evidenced rather than assumed the way Arknights' is: the game launched worldwide simultaneously on one service, every boundary is stated in JST, and Training Support Missions ends at 3:59AM JST, one minute before a 04:00 local reset. Only the offset is overridden; the hour is the default. Setting it now is free, because no reader has a day key for a game the app has never shipped — the same override a year from now would re-label ticks already logged. Both boundaries and the exclusions are asserted against an independent extraction off the fixture, computed through a timezone library rather than by the parser's own arithmetic. Several counts in AGENTS.md and PRD.md were already stale before this — the Fate/Grand Order source did not update them — and are corrected to what the tree now holds rather than to what it held yesterday. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f3d0df9fda
commit
7ab801f409
@@ -112,6 +112,15 @@ const SOURCES: SourceSpec[] = [
|
||||
url: "https://fategrandorder.fandom.com/api.php?action=parse&page=Event_List&prop=text&formatversion=2&format=json",
|
||||
parserId: "fandom",
|
||||
},
|
||||
{
|
||||
id: "holodori-holodoriwiki-events",
|
||||
game: "holodori",
|
||||
// The rendered page, the same call as `ba-bawiki-events` above and for the
|
||||
// same reason: holodori.wiki is Miraheze, so `/w/` and `?action=` are the
|
||||
// closed routes here and `/wiki/` is the one `*` is allowed.
|
||||
url: "https://holodori.wiki/wiki/Events",
|
||||
parserId: "holodoriwiki",
|
||||
},
|
||||
];
|
||||
|
||||
function toAdapter(spec: SourceSpec): Adapter {
|
||||
|
||||
@@ -410,3 +410,72 @@ export function parseSlashDateTimeRange(
|
||||
end: { iso: endIso, precision: "exact" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Named timezone abbreviations this file will convert from, in milliseconds.
|
||||
*
|
||||
* Deliberately only the ones a source actually states, and deliberately only
|
||||
* ones with a fixed offset. An abbreviation absent here parses to null, which
|
||||
* is the same answer this file gives every other missing fact — and the reason
|
||||
* the map is not pre-populated with the obvious candidates is that half of them
|
||||
* are not fixed: `CST` names three different zones and `PT` shifts by an hour
|
||||
* twice a year, so a plausible-looking entry is a wrong date waiting for the
|
||||
* source to use it.
|
||||
*/
|
||||
const NAMED_ZONE_OFFSET_MS: Record<string, number> = {
|
||||
// Japan observes no daylight saving, so UTC+9 holds all year.
|
||||
jst: 9 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* "08/17/2026 8:00PM (JST)" → 2026-08-17T11:00:00.000Z, exact precision.
|
||||
*
|
||||
* One boundary, not a range: the hololive Dreams wiki gives Start Date and End
|
||||
* Date their own columns, so there is nothing to split and the whole cell is
|
||||
* anchored at both ends.
|
||||
*
|
||||
* Three things separate this from the readers above:
|
||||
*
|
||||
* - **A 12-hour clock**, which is the detail most likely to be got wrong
|
||||
* silently. `12:00PM` is noon and `12:00AM` is midnight — the hour is not
|
||||
* `12 + 12` in the first case and not `12` in the second — and a naive
|
||||
* reading puts an event's start twelve hours out without ever failing.
|
||||
* - **The zone is named, not offset.** `parseOrdinalDateTimeRange` reads a
|
||||
* stated `(UTC-5)`; here the source writes `(JST)`, so the abbreviation is
|
||||
* resolved through `NAMED_ZONE_OFFSET_MS` and an unrecognised one returns
|
||||
* null rather than being read as UTC.
|
||||
* - **The zone is required.** A cell stating a wall clock and no zone is a
|
||||
* missing fact, and this page always states one — so the day a row loses it,
|
||||
* that row should vanish rather than silently land nine hours off.
|
||||
*
|
||||
* Month-first, like `parseShortSlashRange`: the source is written for an
|
||||
* English-speaking audience and its own rows settle it — `08/17/2026` and
|
||||
* `08/27/2026` are both readable either way, but `08/30/2026` is not a day-first
|
||||
* date at all.
|
||||
*/
|
||||
export function parseSlashClockZone(input: string): ParsedInstant | null {
|
||||
const re =
|
||||
/^\s*(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})\s*([AP])M\s*\(([A-Za-z]+)\)\s*$/i;
|
||||
const m = re.exec(input);
|
||||
if (!m) return null;
|
||||
|
||||
const offsetMs = NAMED_ZONE_OFFSET_MS[(m[7] ?? "").toLowerCase()];
|
||||
if (offsetMs === undefined) return null;
|
||||
|
||||
const hour12 = Number(m[4]);
|
||||
if (hour12 < 1 || hour12 > 12) return null;
|
||||
const pm = (m[6] ?? "").toUpperCase() === "P";
|
||||
// Noon and midnight are the two readings a 12-hour clock gets wrong: 12PM is
|
||||
// hour 12 and 12AM is hour 0, so the wrap happens before the PM shift.
|
||||
const hour = (hour12 % 12) + (pm ? 12 : 0);
|
||||
|
||||
const value = offsetIso(
|
||||
Number(m[3]),
|
||||
Number(m[1]),
|
||||
Number(m[2]),
|
||||
hour,
|
||||
Number(m[5]),
|
||||
offsetMs,
|
||||
);
|
||||
return value === null ? null : { iso: value, precision: "exact" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { eventId, type EventType, type GachaEvent } from "../../shared/schema.ts";
|
||||
import { parseSlashClockZone, type ParsedInstant } from "../dates.ts";
|
||||
import { text } from "../html.ts";
|
||||
import type { ParseContext } from "../adapters/types.ts";
|
||||
import { inferType } from "./game8.ts";
|
||||
import type { SourceParser } from "./types.ts";
|
||||
|
||||
/**
|
||||
* The hololive Dreams wiki's event schedule (`holodori.wiki`, a Miraheze wiki).
|
||||
*
|
||||
* The rendered page, not the API — the same call `bawiki.ts` makes and for the
|
||||
* same reason: this is Miraheze, whose `robots.txt` disallows `/w/` and
|
||||
* `/*?action=` while allowing `/wiki/`, so the route `fandom.ts` takes is the
|
||||
* one that is closed here. `/wiki/Events` answers our own User-Agent with a
|
||||
* `200`, carries no `Content-Signal`, and the wiki is CC BY-SA 4.0.
|
||||
*
|
||||
* The page is two `wikitable`s under two `<h2>`s:
|
||||
*
|
||||
* <h2>Current Events</h2>
|
||||
* <table class="wikitable">
|
||||
* <tr><th>Logo<th>Event<th>Type<th>Start Date<th>End Date<th>Featured Members
|
||||
* <tr><td><img><td>Ultimate Summer For Me?<td>Spotlight
|
||||
* <td>08/17/2026 8:00PM (JST)<td>08/27/2026 7:59PM (JST)<td>…
|
||||
* <h2>Past Events</h2>
|
||||
* <table class="wikitable"> … same columns …
|
||||
*
|
||||
* Four things about it decide the code below.
|
||||
*
|
||||
* **The page states its timezone, on every single cell.** All eight boundaries
|
||||
* in the captured fixture end `(JST)`, which is why this is the only wiki source
|
||||
* here publishing `exact` precision on both sides without a per-region timer.
|
||||
* It is worth guarding rather than assuming: `parseSlashClockZone` requires the
|
||||
* zone, so a row that ever loses it drops out instead of quietly landing nine
|
||||
* hours off.
|
||||
*
|
||||
* **A section is fenced by its `<h2>`, and only `Current Events` is ours.**
|
||||
* Unlike `bluearchive.wiki`, this page says outright which rows it considers
|
||||
* live, and the two tables are otherwise identical — a reader that took every
|
||||
* `wikitable` would put the whole back catalogue on the calendar. Rows are still
|
||||
* checked against `ctx.now` on top of that, because the heading is maintained by
|
||||
* hand and "Current" goes stale a few days before someone moves the row.
|
||||
*
|
||||
* **Not every row is datable, and that is the page being honest.** `Beginner
|
||||
* Mission` runs from `Game Launch` to `Unknown` — a permanent tutorial chore
|
||||
* with no schedule. Its start is what decides it: an event ID is built from a
|
||||
* start date, so a row without one cannot be published at all, and inventing
|
||||
* launch day to fill the hole would put a permanent fixture on a calendar of
|
||||
* deadlines. An `Unknown` *end* is different and is kept — that is `endsAt:
|
||||
* null`, the honest answer this codebase exists to preserve.
|
||||
*
|
||||
* **The Type column is a controlled vocabulary, so it beats the title.**
|
||||
* `Spotlight` is this game's word for a rate-up banner — the row carries a
|
||||
* `Featured Members` column to prove it — and no amount of reading
|
||||
* "Ultimate Summer For Me?" would ever produce that. See `TYPES` below.
|
||||
*/
|
||||
|
||||
const HEADING = /<h2\b[^>]*>([\s\S]*?)<\/h2>/gi;
|
||||
const TABLE = /<table\b[^>]*>([\s\S]*?)<\/table>/i;
|
||||
const ROW = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||
const CELL = /<t([dh])\b[^>]*>([\s\S]*?)<\/t\1>/gi;
|
||||
/** Non-global deliberately: `.exec` on `ROW` would advance a shared `lastIndex`. */
|
||||
const FIRST_ROW = /<tr\b[^>]*>([\s\S]*?)<\/tr>/i;
|
||||
/**
|
||||
* The event's own article, when it has one.
|
||||
*
|
||||
* `Special:` is excluded because Miraheze's robots.txt disallows it, exactly as
|
||||
* in `bawiki.ts`. Excluding a query string matters more here than it does there,
|
||||
* though, and not as a nicety: **every title on this page is currently a red
|
||||
* link.** The wiki has no article for any of these events yet, so it writes
|
||||
* `/wiki/Ultimate_Summer_For_Me%3F?action=edit&redlink=1` — a *create this
|
||||
* page* form, which is both the wrong place to send a reader and a `?action=`
|
||||
* URL that this wiki's robots.txt disallows outright. Refusing a href with a
|
||||
* query is what keeps one out of the feed, and the fallback to the events page
|
||||
* is the right answer until the articles exist.
|
||||
*/
|
||||
const ARTICLE_LINK = /<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"/i;
|
||||
|
||||
/**
|
||||
* The site's own event vocabulary, mapped onto our `EventType`.
|
||||
*
|
||||
* A fixed list rather than word-matching, because this column is a controlled
|
||||
* vocabulary: the wiki writes one of these four words and nothing else. Two of
|
||||
* them are not derivable from the title in any form —
|
||||
*
|
||||
* - **`Spotlight`** is the gacha. Rate-up banners are what the `Featured
|
||||
* Members` column on the same row lists, and "spotlight" is the game's term
|
||||
* for the window they run in.
|
||||
* - **`Point Rally`** and **`Score Challenge`** are the game's two ranked
|
||||
* scoring formats — play the event's Set Pieces for points against a
|
||||
* leaderboard. `challenge` is the bucket for a scored cycle you grind, and
|
||||
* putting the two formats in different buckets would be describing a
|
||||
* difference the game does not draw.
|
||||
*
|
||||
* `Mission` maps to nothing better than `other`, which is stated here rather
|
||||
* than left to the fallback so that a *new* word in this column is the only
|
||||
* thing that ever reaches `inferType` — that is the case worth noticing, and
|
||||
* the fallback is what keeps it from being a crash.
|
||||
*/
|
||||
const TYPES: Record<string, EventType> = {
|
||||
spotlight: "banner",
|
||||
"point rally": "challenge",
|
||||
"score challenge": "challenge",
|
||||
mission: "other",
|
||||
};
|
||||
|
||||
interface Columns {
|
||||
title: number;
|
||||
type: number | undefined;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each column sits, read off the header row rather than counted.
|
||||
*
|
||||
* Counting is how a silent drop starts here: the first column is a logo and the
|
||||
* last is a cast list, so a table that gains or loses one of those shifts every
|
||||
* date one place and every row fails to parse — an empty lane with no error
|
||||
* anywhere. Returning null is also what tells a `wikitable` that is not a
|
||||
* schedule from one that is.
|
||||
*/
|
||||
function columns(headers: string[]): Columns | null {
|
||||
const at = (name: string) => {
|
||||
const i = headers.indexOf(name);
|
||||
return i < 0 ? undefined : i;
|
||||
};
|
||||
|
||||
const title = at("event");
|
||||
const start = at("start date");
|
||||
const end = at("end date");
|
||||
if (title === undefined || start === undefined || end === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { title, start, end, type: at("type") };
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule table under the `Current Events` heading, and where its columns
|
||||
* sit.
|
||||
*
|
||||
* Bounded by the next `<h2>`, which is what keeps `Past Events` — an identically
|
||||
* shaped table directly below — off the calendar. Matching the heading by its
|
||||
* text rather than by position is the same choice `bawiki.ts` makes about tabs:
|
||||
* position works today and starts reading the archive the day the page is
|
||||
* reordered.
|
||||
*/
|
||||
function currentSchedule(html: string): { cols: Columns; body: string } | null {
|
||||
let section: string | null = null;
|
||||
for (const m of html.matchAll(HEADING)) {
|
||||
if (text(m[1] ?? "").toLowerCase() !== "current events") continue;
|
||||
const from = m.index + m[0].length;
|
||||
const next = html.slice(from).search(/<h2\b/i);
|
||||
section = next < 0 ? html.slice(from) : html.slice(from, from + next);
|
||||
break;
|
||||
}
|
||||
if (section === null) return null;
|
||||
|
||||
const body = TABLE.exec(section)?.[1];
|
||||
if (body === undefined) return null;
|
||||
|
||||
// The header row only, rather than every `<th>` in the table. A row header
|
||||
// appearing further down would otherwise join the list and shift every
|
||||
// resolved index by one — which is the silent drop that reading columns by
|
||||
// name is supposed to rule out, arriving by a different door.
|
||||
const first = FIRST_ROW.exec(body)?.[1] ?? "";
|
||||
const headers = [...first.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)].map((h) =>
|
||||
text(h[1] ?? "").toLowerCase(),
|
||||
);
|
||||
const cols = columns(headers);
|
||||
return cols === null ? null : { cols, body };
|
||||
}
|
||||
|
||||
export function parseHolodoriWikiEventsPage(
|
||||
html: string,
|
||||
ctx: ParseContext,
|
||||
): GachaEvent[] {
|
||||
const schedule = currentSchedule(html.replace(/\s+/g, " "));
|
||||
if (schedule === null) return [];
|
||||
|
||||
const { cols } = schedule;
|
||||
const nowMs = Date.parse(ctx.now);
|
||||
const out: GachaEvent[] = [];
|
||||
|
||||
for (const row of schedule.body.matchAll(ROW)) {
|
||||
const cells = [...(row[1] ?? "").matchAll(CELL)].map((c) => ({
|
||||
tag: c[1] ?? "",
|
||||
html: c[2] ?? "",
|
||||
}));
|
||||
if (cells.length === 0 || cells.some((c) => c.tag === "h")) continue;
|
||||
|
||||
const titleCell = cells[cols.title]?.html ?? "";
|
||||
const title = text(titleCell);
|
||||
if (title.length === 0) continue;
|
||||
|
||||
// No start, no event. `Game Launch` is the real value this drops, and it is
|
||||
// not a date the page failed to write down — it is a permanent tutorial
|
||||
// chore, which is not what a calendar of deadlines is for.
|
||||
const start = parseSlashClockZone(text(cells[cols.start]?.html ?? ""));
|
||||
if (start === null) continue;
|
||||
|
||||
// `Unknown` lands here, and is kept rather than dropped. It is the whole
|
||||
// point of `endPrecision: "unknown"`: the heading has already said this
|
||||
// event is running, so the honest report is that it is live and its end has
|
||||
// not been announced. That differs from `bawiki.ts`, which drops a
|
||||
// started-but-undated row — it has to, because it is reading an archive
|
||||
// with no heading to tell a live row from a finished one.
|
||||
const end: ParsedInstant | null = parseSlashClockZone(
|
||||
text(cells[cols.end]?.html ?? ""),
|
||||
);
|
||||
|
||||
if (end !== null) {
|
||||
if (end.iso <= start.iso) continue;
|
||||
// "Current Events" is maintained by hand and goes stale a few days before
|
||||
// anyone moves a row down to "Past Events". The dates are the fact; the
|
||||
// heading is someone's housekeeping.
|
||||
if (Date.parse(end.iso) < nowMs) continue;
|
||||
}
|
||||
|
||||
const type = cols.type === undefined
|
||||
? ""
|
||||
: text(cells[cols.type]?.html ?? "");
|
||||
|
||||
const href = ARTICLE_LINK.exec(titleCell)?.[1];
|
||||
|
||||
// Both boundaries are stated to the minute in a named zone, so there is no
|
||||
// day-precision penalty here — only the one an unannounced end carries.
|
||||
const confidence = end === null ? 0.95 - 0.15 : 0.95;
|
||||
|
||||
out.push({
|
||||
id: eventId(ctx.game, title, start.iso),
|
||||
game: ctx.game,
|
||||
title,
|
||||
type: TYPES[type.toLowerCase()] ?? inferType(`${title} ${type}`),
|
||||
summary: type.length > 0 ? type : null,
|
||||
startsAt: start.iso,
|
||||
startPrecision: start.precision,
|
||||
endsAt: end === null ? null : end.iso,
|
||||
endPrecision: end === null ? "unknown" : end.precision,
|
||||
// hololive Dreams launched worldwide simultaneously on one service, and
|
||||
// every boundary on this page is stated once, in JST, with no per-region
|
||||
// column anywhere. So there is one global end instant and nothing
|
||||
// region-scoped to report — see `games.ts` for the server clock itself.
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl:
|
||||
href === undefined
|
||||
? ctx.sourceUrl
|
||||
: new URL(href, ctx.sourceUrl).toString(),
|
||||
sourceId: ctx.sourceId,
|
||||
status: "published",
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
extractionMethod: "parser",
|
||||
version: 1,
|
||||
firstSeenAt: ctx.now,
|
||||
updatedAt: ctx.now,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export const holodoriWikiParser: SourceParser = {
|
||||
id: "holodoriwiki",
|
||||
label: "hololive Dreams Wiki (holodori.wiki)",
|
||||
|
||||
/**
|
||||
* Asserts the two things the parser navigates by: the `Current Events`
|
||||
* heading, and a table under it headed with the columns it reads. A renamed
|
||||
* heading or a renamed column fails the run loudly rather than emptying the
|
||||
* lane, which is the whole reason `canParse` is checked separately from a
|
||||
* zero-event parse.
|
||||
*/
|
||||
canParse(html: string): boolean {
|
||||
return currentSchedule(html.replace(/\s+/g, " ")) !== null;
|
||||
},
|
||||
|
||||
parse: parseHolodoriWikiEventsPage,
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { arknightsWikiParser } from "./akwiki.ts";
|
||||
import { blueArchiveWikiParser } from "./bawiki.ts";
|
||||
import { fandomParser } from "./fandom.ts";
|
||||
import { game8Parser } from "./game8.ts";
|
||||
import { holodoriWikiParser } from "./holodori.ts";
|
||||
import { wikiGgParser } from "./wikigg.ts";
|
||||
import type { SourceParser } from "./types.ts";
|
||||
|
||||
@@ -16,6 +17,7 @@ export const PARSERS: SourceParser[] = [
|
||||
arknightsWikiParser,
|
||||
fandomParser,
|
||||
blueArchiveWikiParser,
|
||||
holodoriWikiParser,
|
||||
];
|
||||
|
||||
export function parserById(id: string): SourceParser | undefined {
|
||||
@@ -27,4 +29,5 @@ export { arknightsWikiParser } from "./akwiki.ts";
|
||||
export { blueArchiveWikiParser } from "./bawiki.ts";
|
||||
export { fandomParser } from "./fandom.ts";
|
||||
export { game8Parser } from "./game8.ts";
|
||||
export { holodoriWikiParser } from "./holodori.ts";
|
||||
export { wikiGgParser } from "./wikigg.ts";
|
||||
|
||||
@@ -95,6 +95,16 @@ export const GAMES: Record<GameId, GameMeta> = {
|
||||
// readers' day keys, and a wrong guess ticks the wrong box every night.
|
||||
ba: { id: "ba", name: "Blue Archive", short: "Blue Archive", hue: "#3FCBDD" , studio: "Nexon Games", dailyTasks: "Daily missions, AP" },
|
||||
fgo: { id: "fgo", name: "Fate/Grand Order", short: "FGO", hue: "#1D3A8F", studio: "Lasengle", dailyTasks: "Daily missions, AP" },
|
||||
// hololive Dreams runs a single worldwide service on a Japanese clock, and
|
||||
// both halves of that come from the source rather than from habit: every
|
||||
// boundary on holodori.wiki is stated once, in `(JST)`, with no per-region
|
||||
// column anywhere, and the game launched worldwide simultaneously. The reset
|
||||
// hour is evidenced the way Arknights' is — `Training Support Missions` ends
|
||||
// at 3:59AM JST, one minute before a 04:00 local reset — so the default hour
|
||||
// stands and only the offset is overridden. Setting it now costs nothing: no
|
||||
// reader has a day key for a game the app has never shipped, whereas adding
|
||||
// the same override later would re-label ticks they had already logged.
|
||||
holodori: { id: "holodori", name: "hololive Dreams", short: "holodori", hue: "#5FD3F3", studio: "QualiArts / COVER", dailyTasks: "Daily missions, stamina", resetOffsets: { asia: 9, america: 9, europe: 9 } },
|
||||
};
|
||||
|
||||
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
|
||||
|
||||
@@ -13,6 +13,7 @@ export const GameId = z.enum([
|
||||
"r1999", // Reverse: 1999
|
||||
"ba", // Blue Archive
|
||||
"fgo", // Fate/Grand Order
|
||||
"holodori", // hololive Dreams
|
||||
]);
|
||||
export type GameId = z.infer<typeof GameId>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user