feat: add Arknights: Endfield, and stop passing off requirements as summaries

Endfield was previously written off as undatable. That was wrong: a first
pass only inspected its Duration rows (all "Permanently Available") and its
year-less version grid, and missed an "Event | Schedule & Summary" table
whose cells read "Period: 08/09/26 - 08/30/26 During the event...". Two real
events, with a year.

Adds MM/DD/YY range parsing for that shape. Month-first ordering is not
assumed lightly — Endfield's own version grid reads 01/22, 04/17, 07/16 for
versions 1.0, 1.2 and 1.4, chronological only if the month comes first.

Also stops presenting unlock conditions as descriptions. Several templates
put "Reach Union Level 8" where a blurb would go; filling the summary slot
with text that never says what the event is is worse than leaving it empty.
Wuthering Waves correctly drops to zero summaries as a result.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 01:07:39 +02:00
co-authored by Claude Opus 5
parent 65f1660718
commit ad45b52c84
11 changed files with 237 additions and 19 deletions
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from "react";
import { readJson, writeJson } from "./storage.ts";
export interface Mark {
/** When the reader made this mark. */
at: string;
}
export type Marks = Record<string, Mark>;
/**
* A set of per-event marks, keyed by event ID and persisted locally.
*
* Completions ("I finished this") and ignores ("stop showing me this") are the
* same shape and want the same guarantees, so they share one implementation.
* They stay separate stores because they mean different things: an ignored
* event is hidden, a completed one is dimmed but still counted.
*/
export function useMarkSet(storageKey: string) {
const [marks, setMarks] = useState<Marks>(() =>
readJson<Marks>(storageKey, {}),
);
useEffect(() => {
writeJson(storageKey, marks);
}, [storageKey, marks]);
const toggle = useCallback((id: string) => {
setMarks((prev) => {
if (prev[id] !== undefined) {
const { [id]: _removed, ...rest } = prev;
return rest;
}
return { ...prev, [id]: { at: new Date().toISOString() } };
});
}, []);
const clear = useCallback(() => setMarks({}), []);
/**
* Union merge, keeping the earlier mark. Never removes: nothing else holds a
* copy of these, so a silent deletion would be unrecoverable.
*/
const merge = useCallback((incoming: Marks) => {
setMarks((prev) => {
const next = { ...prev };
for (const [id, value] of Object.entries(incoming)) {
const existing = next[id];
next[id] =
existing === undefined || value.at < existing.at ? value : existing;
}
return next;
});
}, []);
return { marks, toggle, merge, clear };
}
+6
View File
@@ -44,6 +44,12 @@ const SOURCES: SourceSpec[] = [
url: "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
parserId: "game8",
},
{
id: "endfield-game8-events",
game: "endfield",
url: "https://game8.co/games/Arknights-Endfield/archives/535443",
parserId: "game8",
},
{
id: "nte-game8-events",
game: "nte",
+35
View File
@@ -112,6 +112,41 @@ export function parseFullRange(
};
}
/**
* "08/09/26 - 08/30/26" → both instants. Also accepts a four-digit year.
*
* Month-first ordering is not assumed lightly: Game8 writes long dates
* US-style ("August 12, 2026"), and Endfield's own version grid reads 01/22,
* 04/17, 07/16 for versions 1.0, 1.2 and 1.4 — chronological only if the month
* comes first. A day-first reading would make 04/17 an invalid month.
*
* Two-digit years pivot at 70: 26 → 2026. The validator's sanity window
* (start within [now-2y, now+1y]) catches anything this gets wrong.
*/
export function parseShortSlashRange(
input: string,
): { start: ParsedInstant; end: ParsedInstant } | null {
const re =
/(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s*[-–—]\s*(\d{1,2})\/(\d{1,2})\/(\d{2,4})/;
const m = re.exec(input);
if (!m) return null;
const year = (raw: string) => {
const n = Number(raw);
return raw.length <= 2 ? (n < 70 ? 2000 + n : 1900 + n) : n;
};
const n = (i: number) => Number(m[i]);
const startIso = iso(year(m[3] ?? ""), n(1), n(2));
const endIso = iso(year(m[6] ?? ""), n(4), n(5));
if (startIso === null || endIso === null) return null;
return {
start: { iso: startIso, precision: "day" },
end: { iso: endIso, precision: "day" },
};
}
/**
* 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.
+57 -4
View File
@@ -8,6 +8,7 @@ import {
parseMonthDayRange,
parseMonthDayYear,
parseOpenRange,
parseShortSlashRange,
parseSlashDateTimeRange,
type ParsedInstant,
} from "../dates.ts";
@@ -63,7 +64,7 @@ const RANGE_LABEL = /^(availability period|event period|duration|period|dates)$/
/** Column-table header matchers. */
const COL_TITLE = /^(.*\b)?events?$/i;
const COL_RANGE =
/^(event |all )?(duration|dates?|event date|period|availability period|schedule)$/i;
/^(event |all )?(duration|dates?|event date|period|availability period|schedule)( ?& ?summary| and summary)?$/i;
const COL_START = /^start$/i;
const COL_END = /^end$/i;
const COL_SUMMARY = /^(event )?(details?|description|overview)$/i;
@@ -159,7 +160,7 @@ function summaryAfter(nodes: DocNode[], from: number): string | null {
if (node === undefined) break;
if (node.kind !== "p") break;
if (node.isButton || node.text.length === 0) continue;
return node.text.slice(0, 500);
return isRequirementOnly(node.text) ? null : node.text.slice(0, 500);
}
return null;
}
@@ -208,9 +209,15 @@ function readColumnTable(rows: string[][]): Candidate[] {
// year-less summary tables ("08/12 - 08/24") from producing events.
if (range === null) continue;
const summaryCell = summaryIdx === -1 ? undefined : row[summaryIdx];
// Some templates fold the schedule and the blurb into one cell
// ("Period: 08/09/26 - 08/30/26 During the event, gather..."). With no
// separate column, recover the prose from what follows the dates.
const summaryCell =
summaryIdx === -1 ? proseAfterDates(rangeCell) : row[summaryIdx];
const summary =
summaryCell && summaryCell.length > 0 ? summaryCell.slice(0, 500) : null;
summaryCell && summaryCell.length > 0 && !isRequirementOnly(summaryCell)
? summaryCell.slice(0, 500)
: null;
out.push({ title, summary, start: range.start, end: range.end });
}
@@ -262,12 +269,58 @@ function readStartEndTable(headers: string[], rows: string[][]): Candidate[] {
return out;
}
/**
* One date, in any shape this parser understands.
*/
const ONE_DATE =
String.raw`(?:\d{1,2}/\d{1,2}/\d{2,4}|[A-Za-z]+\.?\s+\d{1,2}(?:,\s*\d{4})?)`;
/**
* A leading range, whose end may be a date or a stated non-date such as
* "Permanent" or "End of 4.6". Those words are listed rather than matched
* loosely, so a real description is never mistaken for a range end.
*/
const RANGE_PREFIX = new RegExp(
String.raw`^\s*` +
ONE_DATE +
String.raw`(?:\s*[-\u2013\u2014]\s*(?:` +
ONE_DATE +
String.raw`|permanent|tbd|ongoing|end of [\d.]+))?\s*`,
"i",
);
/**
* Prose that only states how to qualify for an event, not what it is.
*
* Several templates put unlock conditions where a description would go
* ("Reach Union Level 8", "Unlocked by default"). Showing that as the summary
* fills the slot with something that never answers "what is this event?", so
* it is dropped in favour of no summary at all.
*/
function isRequirementOnly(text: string): boolean {
return /^(reach|unlock|unlocks|unlocked|require|requires|required|complete|completing|clear|clearing|obtain|available|becomes available|must |need |finish)\b/i.test(
text.trim(),
);
}
/** Strip a leading label and date range, leaving any description behind it. */
function proseAfterDates(cell: string): string | null {
const rest = cell
.replace(/^\s*(period|duration|schedule|dates?)\s*[:]\s*/i, "")
.replace(RANGE_PREFIX, "")
.trim();
// Too short to be a description — probably leftover punctuation.
if (rest.length < 12) return null;
return isRequirementOnly(rest) ? null : rest;
}
function parseRange(
value: string,
): { start: ParsedInstant; end: ParsedInstant | null } | null {
return (
parseSlashDateTimeRange(value) ??
parseFullRange(value) ??
parseShortSlashRange(value) ??
parseMonthDayRange(value) ??
parseOpenRange(value)
);