docs: design and plan exact end times for Genshin and Miliastra
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
52dbad820e
commit
74c91a7143
@@ -0,0 +1,190 @@
|
||||
# Exact End Times Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** A live Genshin or Miliastra row shows the end its Fandom article states, to the minute, and otherwise behaves as today.
|
||||
|
||||
**Architecture:** The refresh stores the lead section of each listed event's article beside the other snapshots. The feed build parses those offline: `fandom.ts` uses them only to decide currency, and a pure step after `mergeEvents` writes the stated end onto the merged survivor.
|
||||
|
||||
**Tech Stack:** Bun 1.3, TypeScript strict, `bun test`. No new dependency.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-11-07-exact-end-times-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `id`, `startsAt`, `sourceUrl`, `sourceId` and `classification` are never changed by this work.
|
||||
- Only `genshin-fandom-events` rows are read. Other games are out of scope.
|
||||
- The build and all tests never touch the network. Tests never need build output.
|
||||
- No time is invented: only the `data-source="time_end"` infobox cell, in one of two printed shapes.
|
||||
- Stored times are as printed (14:59, not 15:00).
|
||||
- Article failures never affect a source's failure streak, health or broken-source count.
|
||||
- Stage by path; a commit is its own command, never chained with an edit (AGENTS.md).
|
||||
|
||||
## Review Focus
|
||||
|
||||
- A percent-encoded title (`The_Forge_Realm%27s_Temper/2026-11-04`) must reach the API decoded and keep one stable file name. Test in Task 2.
|
||||
- Two table rows linking the same article make one request per cycle. Test in Task 2.
|
||||
- A row with no article link, whose `sourceUrl` is the API URL, makes no request and stays on fallback. Test in Task 2.
|
||||
- An empty or unparsable source snapshot deletes no stored articles and throws nothing. Test in Task 2.
|
||||
- A build with no `snapshots/articles` directory, as on a clean checkout, works and changes nothing. Test in Task 1.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Read and apply a stated end, and keep a live row live
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ingest/articleEnds.ts`
|
||||
- Create: `fixtures/genshin/articles/hour-of-ruin-2026-11-07.html` (server time), `raiment-collection-2026-11-04-2026-11-07.html` (GMT+8): `section=0` API responses, captured once
|
||||
- Modify: `src/ingest/adapters/types.ts` (`ParseContext`), `src/ingest/parsers/fandom.ts` (`parseGenshinEvents` currency check)
|
||||
- Test: `test/article-ends.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces, from `src/ingest/articleEnds.ts`:
|
||||
- `interface StatedEnd { day: string /* YYYY-MM-DD as printed */; endsAt: string; regionEnds: Record<Region, string> | null }`
|
||||
- `parseArticleEnd(body: string): StatedEnd | null`: `body` is the API's JSON; anything malformed is `null`
|
||||
- `articleTitle(sourceUrl: string): string | null`: decoded page title for a `genshin-impact.fandom.com/wiki/…` URL, else `null`
|
||||
- `latestStatedMs(end: StatedEnd): number`
|
||||
- `applyArticleEnds(merged: GachaEvent[], listed: GachaEvent[], ends: ReadonlyMap<string, StatedEnd>, warn: (line: string) => void): GachaEvent[]`, where `listed` is the Fandom parse before merging and `ends` is keyed by `articleTitle`
|
||||
- Produces on `ParseContext`: optional `articleEnds?: ReadonlyMap<string, StatedEnd>`
|
||||
|
||||
- [ ] **Step 1: Capture the two fixtures** with one request each, using the `section=0` URL from spec decision 5.
|
||||
|
||||
- [ ] **Step 2: Write the failing tests** in `test/article-ends.test.ts`
|
||||
|
||||
```ts
|
||||
test("server time gives every region its own end", () => {
|
||||
const end = parseArticleEnd(hourOfRuinFixture)!;
|
||||
expect(end.day).toBe("2026-12-15");
|
||||
expect(end.regionEnds).toEqual({
|
||||
asia: "2026-12-15T06:59:00.000Z",
|
||||
europe: "2026-12-15T13:59:00.000Z",
|
||||
america: "2026-12-15T19:59:00.000Z",
|
||||
});
|
||||
expect(end.endsAt).toBe("2026-12-15T06:59:00.000Z"); // earliest, as wikigg.ts does
|
||||
});
|
||||
test("GMT+8 is one instant", () => {
|
||||
const end = parseArticleEnd(raimentFixture)!;
|
||||
expect(end).toEqual({ day: "2026-12-16", endsAt: "2026-12-15T21:59:00.000Z", regionEnds: null });
|
||||
});
|
||||
test.each(["December 15, 2026", "TBA", "December 15, 2026 14:59 UTC", "December 15, 2026 14:59 GMT+9", ""])(
|
||||
"%s is no usable time", /* fixture with the cell text replaced → null */);
|
||||
test("malformed JSON and a page with no infobox are null");
|
||||
test("the End header cell is not mistaken for the value"); // the <th> carries data-source too
|
||||
test("a merged Game8 survivor takes the end through mergedIds");
|
||||
// survivor id differs, mergedIds holds the Fandom id; regionScoped true, endPrecision "exact"
|
||||
test("id, startsAt, sourceUrl, sourceId and classification never change");
|
||||
test("a table date that disagrees keeps the row and warns");
|
||||
test("a survivor date that disagrees with the table keeps the row and warns");
|
||||
test("a survivor already at exact precision is left alone");
|
||||
test("no articles means the same array contents as merged");
|
||||
test("a live row outlives the table's day on its stated end", () => {
|
||||
// parse the Fandom fixture row for an event at now = just past latestBoundaryMs of its date:
|
||||
// with articleEnds holding a later stated end it is emitted; without, it is not.
|
||||
});
|
||||
```
|
||||
|
||||
Use `toEqual` against the input for the no-articles and unchanged-fields cases. The disagreeing-date cases use the real fixture with the End cell's date replaced.
|
||||
|
||||
- [ ] **Step 3: Run:** `bun test test/article-ends.test.ts`. Expected: FAIL, module not found.
|
||||
|
||||
- [ ] **Step 4: Implement `src/ingest/articleEnds.ts`.** Read `parse.text`, take `<td …data-source="time_end"…>…</td>`, flatten with `html.ts`'s `text`, then match only:
|
||||
|
||||
```ts
|
||||
/^([A-Z][a-z]+ \d{1,2}, \d{4}) (\d{1,2}):(\d{2}) (\(Server Time\)|GMT\+8)$/
|
||||
```
|
||||
|
||||
`(Server Time)` offsets come from `REGION_RESET_UTC_OFFSET`, not from `resetShiftMs`, whose game overrides concern resets. `applyArticleEnds` skips a survivor unless it has `endPrecision === "day"` (agent's call: an end another source stated exactly is not overridden) and both `listed.endsAt` and the survivor's `endsAt` start with `end.day`. It sets `endPrecision: "exact"`, `endsAt`, and `regionScoped`/`regionEnds` from the `StatedEnd`.
|
||||
|
||||
- [ ] **Step 5: Add `articleEnds?` to `ParseContext`, and in `parseGenshinEvents`** compute the row's retirement on `latestStatedMs` when `ctx.articleEnds?.get(articleTitle(href))` exists and its `day` equals the table's date, else `latestBoundaryMs` as now. Emit the same row either way; the parser never writes the stated end.
|
||||
|
||||
- [ ] **Step 6: Run:** `bun test test/article-ends.test.ts test/adapters/game8.test.ts`. Expected: PASS, including unchanged `.expected.json` rows.
|
||||
|
||||
- [ ] **Step 7: Commit** `src/ingest/articleEnds.ts`, `src/ingest/adapters/types.ts`, `src/ingest/parsers/fandom.ts`, `test/article-ends.test.ts`, `fixtures/genshin/articles/` as `feat(ingest): read a stated end from a Genshin article`.
|
||||
|
||||
### Task 2: Keep the articles fresh during a refresh
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ingest/articleStore.ts`, `scripts/refresh-articles.ts`
|
||||
- Modify: `src/ingest/snapshots.ts` (export `writeAtomic`), `scripts/refresh-sources.ts` (`RefreshOptions`, `runRefresh`, `main`), `.gitignore`, `snapshots/README.md`
|
||||
- Test: `test/article-refresh.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `articleTitle`, `parseArticleEnd` (Task 1).
|
||||
- Produces, from `src/ingest/articleStore.ts`:
|
||||
- `interface ArticleMeta { title: string; lastAttemptAt: string; lastReadAt: string | null; statesTime: boolean }`
|
||||
- `class ArticleStore { constructor(root: string) /* <root>/articles/genshin-fandom */; readMeta(title); readBody(title); saveRead(title, body, at); recordAttempt(title, at); remove(title); listed(): Promise<string[]>; readEnds(): Promise<Map<string, StatedEnd>> }`
|
||||
- `articleFileName(title: string): string`: unsafe characters as `_` plus the first 8 hex of the title's hash
|
||||
- `articleDue(meta: ArticleMeta | null, nowMs: number): boolean`
|
||||
- Produces, from `scripts/refresh-articles.ts`:
|
||||
- `refreshArticles(input: { titles: string[]; store: ArticleStore; robots: RobotsGate; fetchImpl: FetchLike; userAgent: string; now: () => Date; sleep: (ms: number) => Promise<void>; dryRun: boolean; timeoutMs: number; log: (line: string) => void; requestedHosts: Set<string> }): Promise<{ changed: number; warnings: string[] }>`
|
||||
- Produces on `RefreshOptions`: optional `articleSourceId?: string`; on `RefreshSummary`: `articlesChanged: number`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** in `test/article-refresh.test.ts`, driving `refreshArticles` with a stub fetch and a temp root:
|
||||
|
||||
```ts
|
||||
test("due: never read"); // articleDue(null, now) === true
|
||||
test("due: no time found, only after 6 hours");
|
||||
test("due: time found, only 7 days after the last read that worked");
|
||||
test("due: a failed attempt inside 6 hours is not due, even past 7 days");
|
||||
test.each([500, 403])("HTTP %i keeps the body and the time, and warns");
|
||||
test("a network error keeps the body and the time, and warns");
|
||||
test("robots refusal makes no request, keeps the body, and warns");
|
||||
test("404 removes the stored article and warns");
|
||||
test("a 200 with a missingtitle API error removes the stored article and warns");
|
||||
test("a 200 with no usable End overwrites the body, sets statesTime false, and logs without warning");
|
||||
test("lastReadAt moves only on a successful read; lastAttemptAt on every attempt");
|
||||
test("the requested URL carries the decoded title, section=0, and the runner User-Agent");
|
||||
test("the same title twice is one request");
|
||||
test("per-host spacing applies when the host was already asked this cycle");
|
||||
test("a dry run requests and writes nothing");
|
||||
test("a stored article no longer listed is removed");
|
||||
```
|
||||
|
||||
And driving `runRefresh`:
|
||||
|
||||
```ts
|
||||
test("article reads run only with articleSourceId set; existing callers are unaffected");
|
||||
test("a row with no article link makes no article request");
|
||||
test("an empty source snapshot removes nothing and does not throw");
|
||||
test("article failures leave broken, hardFailure and the source's streak untouched");
|
||||
test("a changed article triggers the rebuild even when no source page changed");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run:** `bun test test/article-refresh.test.ts`. Expected: FAIL, module not found.
|
||||
|
||||
- [ ] **Step 3: Implement `articleStore.ts`.** `articleDue` is true when `meta` is null, or when at least 6h have passed since `lastAttemptAt` and either `statesTime` is false or at least 7 days have passed since `lastReadAt`. Body before meta, each through `writeAtomic`. `readEnds` skips a file `parseArticleEnd` returns null for, and returns an empty map when the directory does not exist.
|
||||
|
||||
- [ ] **Step 4: Implement `refresh-articles.ts`** to spec decision 12's table. Sends no conditional headers: the API does not offer validators worth the state. Uses the `api.php` URL from spec decision 5 and the same 200-only, read-body-in-a-try discipline as `refreshOne`. Removes stored articles not in `titles` only when `titles` is non-empty.
|
||||
|
||||
- [ ] **Step 5: Wire `runRefresh`.** After the source loop and before the hard-failure returns, when `articleSourceId` is among `selected`, parse that source's stored snapshot with the adapter at `now`, map rows through `articleTitle`, drop nulls, and call `refreshArticles`, sharing `cycle.requestedHosts`. Wrap the whole step in `try`, turning a throw into one warning. Push its warnings to `summary.warnings`. Rebuild when `summary.changed > 0 || summary.articlesChanged > 0`. `main` passes `articleSourceId: "genshin-fandom-events"`. `--force` is not forwarded. A dry run lists what is due.
|
||||
|
||||
- [ ] **Step 6: Add `snapshots/articles/**/*.tmp-*` to `.gitignore`** and document the directory in `snapshots/README.md`, including why it holds no `.state.json`.
|
||||
|
||||
- [ ] **Step 7: Run:** `bun test test/article-refresh.test.ts test/refresh.test.ts test/snapshots.test.ts`. Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit** those paths as `feat(refresh): keep Genshin event articles beside the snapshots`.
|
||||
|
||||
### Task 3: Build it into the feed and record it
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/build-feed.ts`, `src/shared/changelog.ts`, `AGENTS.md`, `docs/INGESTION.md`, `docs/PRD.md`, `docs/2026-11-01-genshin-event-audit.md`, the spec's status line
|
||||
- Test: `test/article-feed.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ArticleStore.readEnds` (Task 2), `applyArticleEnds` and `ParseContext.articleEnds` (Task 1).
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `test/article-feed.test.ts`: run `scripts/build-feed.ts` as a subprocess against a temp `SNAPSHOT_DIR` holding `genshin-fandom-events.html` and `.meta.json`, copied from a pinned `fixtures/genshin/articles/event-table-2026-11-06.html`, plus an `articles/genshin-fandom` copy of the Hour of Ruin fixture. Add two environment overrides to `build-feed.ts`, each defaulting to today's behaviour: `EVENT_FEED_OUT` for the output path, and `EVENT_FEED_NOW` pinned to `2026-11-07T00:00:00.000Z`, without which this test would fail once the fixture's events end. Assert Hour of Ruin's `regionEnds.europe` is `2026-12-15T13:59:00.000Z`, its `id` is `genshin:hour-of-ruin:2026-11-04`, and the feed still passes `EventFeed.parse`.
|
||||
|
||||
- [ ] **Step 2: Run:** `bun test test/article-feed.test.ts`. Expected: FAIL, row still at day precision.
|
||||
|
||||
- [ ] **Step 3: Wire `build-feed.ts`.** Read `ArticleStore.readEnds` once. Pass `articleEnds` in the context for `genshin-fandom-events` only. Keep that source's parsed rows. After `mergeEvents` for its game, apply `applyArticleEnds`, logging warnings as ` ! article: …`. Sort after applying.
|
||||
|
||||
- [ ] **Step 4: Run:** `bun test && bun run typecheck`. Expected: all pass.
|
||||
|
||||
- [ ] **Step 5: Check it for real, without touching the repo's data.** Copy `snapshots/` to a scratch directory, run `bun run refresh --snapshots <scratch> --only genshin-fandom-events`, then build from it with `SNAPSHOT_DIR=<scratch>` from an export. Expected: Test Run 17:59 server, Hour of Ruin 14:59 server, Raiment 05:59 GMT+8, passes 03:59 server. In a real browser on an HTTP test hostname: same countdowns on Europe, Raiment 38d 21h-style, and the detail sheet's "gave a date but no time" note gone from those rows but still shown for a Game8-only row.
|
||||
|
||||
- [ ] **Step 6: Update docs:** `AGENTS.md` § Domain rules (a Fandom article may state the end) and the Genshin § Fandom bullets; `docs/INGESTION.md` (article step, decision 12's table); PRD § Quality bar; the audit doc gets the Forge Realm reading and marks open item 1 done; the spec's status.
|
||||
|
||||
- [ ] **Step 7: Review:** run `schema-guardian` and fix what it finds.
|
||||
|
||||
- [ ] **Step 8: Commit** the feature paths as `feat(feed): show the end a Genshin article states`, then the changelog separately as `chore(changelog): note exact Genshin end times (<hash>)`, after `bun run scripts/check-changelog.ts`.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Exact end times for Genshin and Miliastra — design
|
||||
|
||||
Date: 2026-11-07. Status: **approved** by the owner 2026-11-07; plan at `../plans/2026-11-07-exact-end-times.md`.
|
||||
|
||||
## Why
|
||||
|
||||
The owner trusts a countdown right up to the last hour before something closes.
|
||||
Fandom's `Event` table prints dates only, so `clockFor` reads each end as that
|
||||
game-day's reset. That is right for most events and hours wrong for the rest. It is
|
||||
this product's named worst failure: a confidently wrong end date (PRD § Quality bar
|
||||
for dates). The evidence is in `../../2026-11-01-genshin-event-audit.md` § Follow-up.
|
||||
|
||||
Every live row's article, read 2026-11-07, against what the app shows on Europe:
|
||||
|
||||
| Article's end | Rows | App today |
|
||||
|---|---|---|
|
||||
| 03:59 server time | 9: To Temper Cycle 6, Sacred Spirit Chronicle, Chronicle of Polyphony, Grand Defense, Stygian Onslaught, Stress Block, Frames of Fierce Splendor, Ley Line Overflow, Starlight Voyage | Correct |
|
||||
| 05:59 GMT+8 | Raiment Collection, Forge Realm's Temper | 5h late on the article's reading |
|
||||
| 14:59 server time | Encounter: Interlude, Hour of Ruin, Where Kristall Roots Run Deep | 11h early |
|
||||
| 17:59 server time | Test Run | 14h early |
|
||||
|
||||
In-game readings by the owner agree with the articles on Sacred Spirit Chronicle,
|
||||
Chronicle of Polyphony, Raiment Collection and Test Run. Encounter has indirect
|
||||
support: `Wonderland Treasures - Polyphonic Harmony` read 38d 13h 47m, landing on
|
||||
the same 14:59. **One disagrees:** the Cat's Tail timer for Forge Realm's Temper read
|
||||
38d 22h at about 00:30 UTC on 2026-11-07, implying 15 Dec about 22:30–23:30 UTC. The
|
||||
article implies 14 Dec 21:59 UTC. See § Accepted exception.
|
||||
|
||||
The miss is not a pattern a rule can fix: the same printed date can mean any of three
|
||||
times, so each event's own stated time has to be read.
|
||||
|
||||
## Goals
|
||||
|
||||
- A live Genshin or Miliastra row shows the end its Fandom article states, to the minute.
|
||||
- Reading the wiki stays polite: small requests, not repeated for what is already known.
|
||||
- With no usable article time, a row behaves exactly as it does today.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Other games. Each has its own article layout; measure one when it is shown wrong.
|
||||
- Start times. **`startsAt` must not change: it is half of the event ID.**
|
||||
- The undated selector row, Wonderland Treasures, banners, and lane labels on Game8
|
||||
rows (audit open items 2–4).
|
||||
- New interface. Countdowns and printed end times become more accurate; nothing is added.
|
||||
|
||||
## Anti-goals: a failure even if it works
|
||||
|
||||
- An event ID changes, or a reader's marks are orphaned.
|
||||
- A time is invented or inferred, including from an owner's rounded in-game reading.
|
||||
- A row disappears from the feed while its stated end is still ahead.
|
||||
- A refresh's failure on an article blocks or fails the page sources.
|
||||
- The build touches the network.
|
||||
|
||||
## Constraints
|
||||
|
||||
No LLM, no new runtime dependency, no HTML parsing library. Deterministic parsing only,
|
||||
per AGENTS.md § Three constraints and § Scraping conduct.
|
||||
|
||||
## Decisions
|
||||
|
||||
Approved with this document unless marked as the agent's call.
|
||||
|
||||
1. **Scope:** Genshin and Miliastra only (owner, 2026-11-07). Both come from
|
||||
`genshin-fandom-events`, so this one source's rows are what gets read.
|
||||
2. **Read an article when a row first appears, and keep what it said**, rather than
|
||||
re-reading every article each refresh (owner, 2026-11-07, as a stated guess not
|
||||
corrected).
|
||||
3. **No usable time falls back to today's behaviour** (owner, same).
|
||||
4. **Forge Realm's Temper is accepted as an exception,** not special-cased (owner,
|
||||
2026-11-07, see below).
|
||||
5. **Article reads:** `api.php?action=parse&page=<title>&prop=text§ion=0&formatversion=2&format=json`.
|
||||
Agent's call. `section=0` holds the infobox: 8 KB rather than about 18 KB, verified
|
||||
on `Hour_of_Ruin_(Event)`. Same host, same `/api.php?action=` path the robots note
|
||||
on `genshin-fandom-events` already clears, same runner User-Agent, robots gate and
|
||||
per-host spacing.
|
||||
6. **When to read.** Agent's call. Only rows the current table lists and that have not
|
||||
ended. An article is due when it has never been read; when the last read found no
|
||||
time, once the 6-hour floor has passed; or when the last read found a time at least
|
||||
7 days ago, because wiki editors correct these and a kept wrong time would be
|
||||
permanent. `--force` does not apply to articles.
|
||||
7. **Storage: `snapshots/articles/genshin-fandom/<article-slug>.html` plus `.meta.json`,**
|
||||
committed with the other snapshots. Agent's call. Raw bytes as served, written like
|
||||
every other snapshot, so re-parsing never re-fetches and a changed time is a
|
||||
reviewable diff. Rows no longer listed have their files deleted by the refresh.
|
||||
8. **Read the End value of the infobox only:** the cell with `data-source="time_end"`.
|
||||
Not the "Ends in" lines, the body prose, or the image names. The value has one of two
|
||||
usable shapes:
|
||||
|
||||
| Printed | Stored | Precision |
|
||||
|---|---|---|
|
||||
| `December 15, 2026 14:59 (Server Time)` | `regionScoped: true`, `regionEnds` for each region at that server's clock: Asia UTC+8, Europe UTC+1, America UTC−5 | exact |
|
||||
| `December 16, 2026 05:59 GMT+8` | one instant in `endsAt`, `regionScoped: false` | exact |
|
||||
|
||||
Anything else, including a date with no time or another zone, is no usable time.
|
||||
Stored times are as printed, 14:59 and not 15:00.
|
||||
9. **The article must agree on the date.** Its printed calendar date must equal the
|
||||
date the table printed. If not, the time is ignored, the row keeps the table's date,
|
||||
and the build logs a warning. A stale or wrongly linked article must not move an
|
||||
event by days. This also keeps a wiki-side disagreement from silently shifting a row.
|
||||
Agent's call: on a merged survivor the survivor's own end date must match as well. If
|
||||
Game8 and Fandom disagree on the date, the row stays as merged, at day precision, with
|
||||
a warning: `mergeEvents` flags such a disagreement for a person rather than resolving
|
||||
it, and an article must not settle it unseen. No live row is in this state today.
|
||||
10. **Apply after merge, in the feed build.** `mergeEvents` keeps the higher-confidence
|
||||
copy, so Game8's day-precision row wins for Grand Defense, Stress Block, Frames and
|
||||
Ley Line, and it would discard an end set on the Fandom copy. So the exact end is
|
||||
applied to the merged survivor, matched by event ID or `mergedIds`. The merge's end
|
||||
comparison keeps seeing table dates, so no new conflicts appear. Applying it changes
|
||||
only `endsAt`, `endPrecision`, `regionScoped` and `regionEnds`; never `id`,
|
||||
`startsAt`, `sourceUrl`, `sourceId` or `classification`. The application is a pure
|
||||
function a test can reach, as `parseGame` keeps fetching out of parsing.
|
||||
11. **Currency must follow the stated end.** `fandom.ts` retires a row on
|
||||
`latestBoundaryMs` of the table's date. For Hour of Ruin that is 09:00Z while the
|
||||
stated end is 19:59Z in the Americas, which would delete a live row for 11 hours on
|
||||
its last day (an anti-goal). A row with a usable article time is retired on the
|
||||
latest of those stated instants instead. How the parser receives this is left to the
|
||||
builder; the parser stays offline and pure.
|
||||
12. **Article failures are warnings, and what happens to a kept time depends on what the
|
||||
wiki said.** Agent's call. None of these contributes to `genshin-fandom-events'`
|
||||
failure streak, health or broken-source count.
|
||||
|
||||
| Result of a read | Stored article | Row shows |
|
||||
|---|---|---|
|
||||
| Network error, 5xx, refused, or robots-blocked: the wiki told us nothing about the article | Kept untouched | The last good time, if any. Due again after the 6-hour floor |
|
||||
| 404 or a missing-page API error: the article is gone or renamed | Deleted | Fallback |
|
||||
| `200` whose End value is absent or unusable: the article no longer states a time | Overwritten | Fallback. Due again under the no-time rule |
|
||||
|
||||
`.meta.json` records the last attempt separately from the last successful read, so the
|
||||
7-day rule counts from a read that worked.
|
||||
|
||||
## Accepted exception
|
||||
|
||||
Forge Realm's Temper would go from about 20h early to about 25h early against the
|
||||
Cat's Tail reading. It may be that the timer covers something beyond the event, or that
|
||||
the article is wrong; neither is established. Both errors are on the safe side, so the
|
||||
reader leaves the event done before it closes. It is one reading, so no special case.
|
||||
Recheck when the next `The Forge Realm's Temper` run starts. Record the reading in the
|
||||
audit doc in the same change.
|
||||
|
||||
Raiment Collection is the only confirmed-late row today, and it is corrected by this.
|
||||
|
||||
## Left to the builder
|
||||
|
||||
- Where the due-check and fetch sit in `scripts/refresh-sources.ts`, and the budget of
|
||||
reads per run if one is needed. A new version adds about 15.
|
||||
- Which form the region offsets take; reuse what `daily.ts`/`time.ts` already hold.
|
||||
- What `endsAt` holds on a region-scoped row. Follow the existing wiki.gg precedent
|
||||
and check `EventDetail.tsx`'s read of it.
|
||||
- Whether a stale `firstSeenAt` or a bumped `version` applies; no change is the default.
|
||||
|
||||
## Verification
|
||||
|
||||
- Fixtures in `fixtures/genshin/` for one article of each shape, one stub with no time,
|
||||
and one with a different date. Captured once, then tests stay offline.
|
||||
- Tests: both shapes; rejected shapes; date disagreement, with the table and with a
|
||||
merged survivor; each row of decision 12's table; application to a merged Game8
|
||||
survivor via `mergedIds`; IDs and `startsAt` unchanged; a row still live on its last
|
||||
day past the table's boundary; an article failure leaving source health untouched; the
|
||||
due rule.
|
||||
- `schema-guardian` reviews the change, since it writes `regionEnds` for this source.
|
||||
- After a real refresh: on Europe, the table above reproduces at Test Run 17:59,
|
||||
Hour of Ruin 14:59, Raiment 05:59 GMT+8 and passes at 03:59, in a real browser.
|
||||
- `bun test`, `bun run typecheck`, and the docs changed with it: `AGENTS.md` § Domain
|
||||
rules and § Fandom, `docs/INGESTION.md`, PRD § Quality bar, `snapshots/README.md`.
|
||||
- A reader sees this, so it gets a changelog entry per AGENTS.md.
|
||||
Reference in New Issue
Block a user