feat(nikki): track Infinity Nikki
Adds the game and its Game8 events page. One `SOURCES` entry against the existing game8 parser, plus one date shape that page needs: `parseLabelledStartEnd` reads a duration cell holding `Start: <date>` and `End: <date>` split by a `<br>`, which a tag-stripping reader flattens into one run of text. Four of the seven events say `End: Permanent`. That is the source telling us there is no deadline, so they publish with `endsAt: null` and `endPrecision: "unknown"` rather than a plausible date. The labelled cell is also structure end to end, so it never becomes a summary — stripping the leading half would leave "End: Permanent" standing where a description belongs. Verified by re-extracting the page with a throwaway script independent of the parser: 7 rows in, 7 events out, every date matching. Note the source itself is stale — its "Current Events" table still lists January-May 2025 and "Upcoming Events" says there are none. The adapter is right; the page looks abandoned, and Nikki coverage worth relying on needs a second source at a higher priority. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
243c7d70dd
commit
67951e81b4
@@ -0,0 +1,149 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "nikki:explosive-evasion:2025-01-24",
|
||||||
|
"game": "nikki",
|
||||||
|
"title": "Explosive Evasion",
|
||||||
|
"type": "other",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2025-01-24T00:00:00.000Z",
|
||||||
|
"startPrecision": "day",
|
||||||
|
"endsAt": null,
|
||||||
|
"endPrecision": "unknown",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445",
|
||||||
|
"sourceId": "nikki-game8-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.75,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nikki:fireworks-fiesta:2025-01-24",
|
||||||
|
"game": "nikki",
|
||||||
|
"title": "Fireworks Fiesta",
|
||||||
|
"type": "other",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2025-01-24T00:00:00.000Z",
|
||||||
|
"startPrecision": "day",
|
||||||
|
"endsAt": null,
|
||||||
|
"endPrecision": "unknown",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445",
|
||||||
|
"sourceId": "nikki-game8-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.75,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nikki:fireworks-styling-challenges:2025-01-24",
|
||||||
|
"game": "nikki",
|
||||||
|
"title": "Fireworks Styling Challenges",
|
||||||
|
"type": "other",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2025-01-24T00:00:00.000Z",
|
||||||
|
"startPrecision": "day",
|
||||||
|
"endsAt": null,
|
||||||
|
"endPrecision": "unknown",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445",
|
||||||
|
"sourceId": "nikki-game8-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.75,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nikki:balloon-hopper:2025-01-31",
|
||||||
|
"game": "nikki",
|
||||||
|
"title": "Balloon Hopper",
|
||||||
|
"type": "other",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2025-01-31T00:00:00.000Z",
|
||||||
|
"startPrecision": "day",
|
||||||
|
"endsAt": null,
|
||||||
|
"endPrecision": "unknown",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445",
|
||||||
|
"sourceId": "nikki-game8-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.75,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nikki:bubble-hopper:2025-04-28",
|
||||||
|
"game": "nikki",
|
||||||
|
"title": "Bubble Hopper",
|
||||||
|
"type": "other",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2025-04-28T00:00:00.000Z",
|
||||||
|
"startPrecision": "day",
|
||||||
|
"endsAt": null,
|
||||||
|
"endPrecision": "unknown",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445",
|
||||||
|
"sourceId": "nikki-game8-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.75,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nikki:bubble-season:2025-04-28",
|
||||||
|
"game": "nikki",
|
||||||
|
"title": "Bubble Season",
|
||||||
|
"type": "other",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2025-04-28T00:00:00.000Z",
|
||||||
|
"startPrecision": "day",
|
||||||
|
"endsAt": "2025-06-12T00:00:00.000Z",
|
||||||
|
"endPrecision": "day",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445",
|
||||||
|
"sourceId": "nikki-game8-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nikki:cozy-caretakers-protect-the-pandas:2025-05-20",
|
||||||
|
"game": "nikki",
|
||||||
|
"title": "Cozy Caretakers: Protect the Pandas",
|
||||||
|
"type": "other",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2025-05-20T00:00:00.000Z",
|
||||||
|
"startPrecision": "day",
|
||||||
|
"endsAt": "2025-06-12T00:00:00.000Z",
|
||||||
|
"endPrecision": "day",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445",
|
||||||
|
"sourceId": "nikki-game8-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
@@ -66,6 +66,12 @@ const SOURCES: SourceSpec[] = [
|
|||||||
url: "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
url: "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||||
parserId: "game8",
|
parserId: "game8",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "nikki-game8-events",
|
||||||
|
game: "nikki",
|
||||||
|
url: "https://game8.co/games/Infinity-Nikki/archives/487445",
|
||||||
|
parserId: "game8",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function toAdapter(spec: SourceSpec): Adapter {
|
function toAdapter(spec: SourceSpec): Adapter {
|
||||||
|
|||||||
@@ -147,6 +147,34 @@ export function parseShortSlashRange(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Start: January 24, 2025 End: Permanent" → the start, and whatever the end
|
||||||
|
* half turns out to be.
|
||||||
|
*
|
||||||
|
* A labelled cell rather than a range: the two halves are separated by a `<br>`
|
||||||
|
* and each carries its own word. The colons are required, which is what stops
|
||||||
|
* the word "end" inside a description from splitting a cell that is really
|
||||||
|
* prose.
|
||||||
|
*
|
||||||
|
* An end half that is not a date ("Permanent", "TBD", "After maintenance")
|
||||||
|
* yields a null end rather than an invented one — the same outcome as
|
||||||
|
* `parseOpenRange`, and for the same reason.
|
||||||
|
*/
|
||||||
|
export function parseLabelledStartEnd(
|
||||||
|
input: string,
|
||||||
|
): { start: ParsedInstant; end: ParsedInstant | null } | null {
|
||||||
|
const m = /^\s*start\s*[::]\s*(.+?)(?:\s+end\s*[::]\s*(.*?))?\s*$/i.exec(
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
const start = parseMonthDayYear(m[1] ?? "");
|
||||||
|
if (start === null) return null;
|
||||||
|
|
||||||
|
const endHalf = m[2];
|
||||||
|
return { start, end: endHalf === undefined ? null : parseMonthDayYear(endHalf) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A range whose start is a real date but whose end is not: "July 10, 2026 -
|
* A range whose start is a real date but whose end is not: "July 10, 2026 -
|
||||||
* Permanent", "Jul. 24, 2026 - End of 4.6", or a lone start date.
|
* Permanent", "Jul. 24, 2026 - End of 4.6", or a lone start date.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "../../shared/schema.ts";
|
} from "../../shared/schema.ts";
|
||||||
import {
|
import {
|
||||||
parseFullRange,
|
parseFullRange,
|
||||||
|
parseLabelledStartEnd,
|
||||||
parseMonthDayRange,
|
parseMonthDayRange,
|
||||||
parseMonthDayYear,
|
parseMonthDayYear,
|
||||||
parseOpenRange,
|
parseOpenRange,
|
||||||
@@ -313,6 +314,11 @@ function isRequirementOnly(text: string): boolean {
|
|||||||
|
|
||||||
/** Strip a leading label and date range, leaving any description behind it. */
|
/** Strip a leading label and date range, leaving any description behind it. */
|
||||||
function proseAfterDates(cell: string): string | null {
|
function proseAfterDates(cell: string): string | null {
|
||||||
|
// A fully labelled "Start: … End: …" cell is structure end to end. Stripping
|
||||||
|
// the leading half would leave "End: Permanent" standing where a description
|
||||||
|
// belongs — a date masquerading as a blurb.
|
||||||
|
if (parseLabelledStartEnd(cell) !== null) return null;
|
||||||
|
|
||||||
const rest = cell
|
const rest = cell
|
||||||
.replace(/^\s*(period|duration|schedule|dates?)\s*[::]\s*/i, "")
|
.replace(/^\s*(period|duration|schedule|dates?)\s*[::]\s*/i, "")
|
||||||
.replace(RANGE_PREFIX, "")
|
.replace(RANGE_PREFIX, "")
|
||||||
@@ -330,6 +336,7 @@ function parseRange(
|
|||||||
parseFullRange(value) ??
|
parseFullRange(value) ??
|
||||||
parseShortSlashRange(value) ??
|
parseShortSlashRange(value) ??
|
||||||
parseMonthDayRange(value) ??
|
parseMonthDayRange(value) ??
|
||||||
|
parseLabelledStartEnd(value) ??
|
||||||
parseOpenRange(value)
|
parseOpenRange(value)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const GAMES: Record<GameId, GameMeta> = {
|
|||||||
// and `america` already resolves to -5, so Europe is the only real override.
|
// and `america` already resolves to -5, so Europe is the only real override.
|
||||||
endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph", dailyTasks: "Daily missions", resetOffsets: { europe: -5 } },
|
endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph", dailyTasks: "Daily missions", resetOffsets: { europe: -5 } },
|
||||||
nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio", dailyTasks: "Daily tasks" },
|
nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio", dailyTasks: "Daily tasks" },
|
||||||
|
nikki: { id: "nikki", name: "Infinity Nikki", short: "Nikki", hue: "#F27BB0" , studio: "Infold Games", dailyTasks: "Daily tasks, Vital Energy" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
|
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const GameId = z.enum([
|
|||||||
"arknights",
|
"arknights",
|
||||||
"endfield",
|
"endfield",
|
||||||
"nte", // Neverness to Everness
|
"nte", // Neverness to Everness
|
||||||
|
"nikki", // Infinity Nikki
|
||||||
]);
|
]);
|
||||||
export type GameId = z.infer<typeof GameId>;
|
export type GameId = z.infer<typeof GameId>;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
|||||||
{ adapter: adapter("zzz-game8-events"), fixture: "fixtures/zzz/game8-events-2026-08-14" },
|
{ adapter: adapter("zzz-game8-events"), fixture: "fixtures/zzz/game8-events-2026-08-14" },
|
||||||
{ adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/game8-events-2026-08-14" },
|
{ adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/game8-events-2026-08-14" },
|
||||||
{ adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" },
|
{ adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" },
|
||||||
|
{ adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||||
@@ -226,6 +227,37 @@ describe("endfield", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("nikki", () => {
|
||||||
|
const fixture = "fixtures/nikki/game8-events-2026-08-17";
|
||||||
|
|
||||||
|
test("reads a labelled Start/End cell, and takes no end from 'Permanent'", async () => {
|
||||||
|
// The duration cell is "Start: January 24, 2025 End: Permanent" — two
|
||||||
|
// labelled halves separated by a <br>, which a tag-stripping reader sees as
|
||||||
|
// one run of text.
|
||||||
|
const events = await runAdapter(adapter("nikki-game8-events"), fixture);
|
||||||
|
expect(events).toHaveLength(7); // every row of the current-events table
|
||||||
|
|
||||||
|
const fiesta = events.find((e) => e.title === "Fireworks Fiesta");
|
||||||
|
expect(fiesta?.startsAt).toBe("2025-01-24T00:00:00.000Z");
|
||||||
|
// "Permanent" is not a date and does not become one.
|
||||||
|
expect(fiesta?.endsAt).toBeNull();
|
||||||
|
expect(fiesta?.endPrecision).toBe("unknown");
|
||||||
|
|
||||||
|
const bubble = events.find((e) => e.title === "Bubble Season");
|
||||||
|
expect(bubble?.startsAt).toBe("2025-04-28T00:00:00.000Z");
|
||||||
|
expect(bubble?.endsAt).toBe("2025-06-12T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never presents the labelled cell's own text as a summary", async () => {
|
||||||
|
// "End: Permanent" is structure, not a blurb. Leaving it in the summary
|
||||||
|
// slot would show a reader a date the parser deliberately refused to use.
|
||||||
|
const events = await runAdapter(adapter("nikki-game8-events"), fixture);
|
||||||
|
for (const e of events) {
|
||||||
|
expect(e.summary).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("wiki.gg parser", () => {
|
describe("wiki.gg parser", () => {
|
||||||
test("reads exact per-region timers", async () => {
|
test("reads exact per-region timers", async () => {
|
||||||
// The first source that states region-scoped ends. Asia and the Americas
|
// The first source that states region-scoped ends. Asia and the Americas
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
parseFullRange,
|
parseFullRange,
|
||||||
|
parseLabelledStartEnd,
|
||||||
parseMonthDayRange,
|
parseMonthDayRange,
|
||||||
parseMonthDayYear,
|
parseMonthDayYear,
|
||||||
parseOpenRange,
|
parseOpenRange,
|
||||||
@@ -127,3 +128,39 @@ describe("parseOpenRange", () => {
|
|||||||
expect(parseOpenRange("Releases in Version 3.6")).toBeNull();
|
expect(parseOpenRange("Releases in Version 3.6")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseLabelledStartEnd", () => {
|
||||||
|
test("reads a labelled cell", () => {
|
||||||
|
expect(
|
||||||
|
parseLabelledStartEnd("Start: April 28, 2025 End: June 12, 2025"),
|
||||||
|
).toEqual({
|
||||||
|
start: { iso: "2025-04-28T00:00:00.000Z", precision: "day" },
|
||||||
|
end: { iso: "2025-06-12T00:00:00.000Z", precision: "day" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports no end when the end half is not a date", () => {
|
||||||
|
// "Permanent" is the source telling us there is no deadline. Inventing one
|
||||||
|
// is the failure this codebase exists to prevent.
|
||||||
|
const permanent = parseLabelledStartEnd(
|
||||||
|
"Start: January 24, 2025 End: Permanent",
|
||||||
|
);
|
||||||
|
expect(permanent?.start.iso).toBe("2025-01-24T00:00:00.000Z");
|
||||||
|
expect(permanent?.end).toBeNull();
|
||||||
|
|
||||||
|
expect(parseLabelledStartEnd("Start: January 24, 2025 End: TBD")?.end).toBeNull();
|
||||||
|
expect(parseLabelledStartEnd("Start: January 24, 2025")?.end).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when the start half is not a date", () => {
|
||||||
|
expect(parseLabelledStartEnd("Start: After maintenance End: TBD")).toBeNull();
|
||||||
|
expect(parseLabelledStartEnd("Ends after 30 days of making your account.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires the colons, so prose containing 'end' does not split", () => {
|
||||||
|
// Without them, any sentence mentioning an end would look like a boundary.
|
||||||
|
expect(
|
||||||
|
parseLabelledStartEnd("Start the quest before August 12, 2026 to end the arc"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user