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:
co-authored by
Claude Opus 5
parent
65f1660718
commit
ad45b52c84
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user