From b8e8d44816222e2a505da9fe9b10391f5c00ce8f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 20 Oct 2026 03:33:21 +0200 Subject: [PATCH] feat(codes): mark a code dead once the owner confirms it in game Game8 still lists Y1INABQB6DEX, 2BJ64QRZ7RT8 and PLDXIB886Y1S as active Genshin codes after the game refused them. CODE_CHECKS records the owner's in-game check; build-codes stamps the matching code confirmedDead, and the Codes tab shows it expired: "Confirmed expired in game, 2026-10-20." Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 8 +++++ docs/DESIGN.md | 3 +- docs/INGESTION.md | 10 +++++++ scripts/build-codes.ts | 14 +++++++-- src/client/components/redesign/CodesPage.tsx | 16 +++++----- src/client/state/reader/codes.ts | 4 ++- src/ingest/codes/build.ts | 26 ++++++++++++++++ src/shared/codeChecks.ts | 25 ++++++++++++++++ src/shared/codes.ts | 9 ++++++ test/codes-ui.test.tsx | 29 ++++++++++++++++++ test/codes.test.ts | 31 +++++++++++++++++++- test/reader-codes-state.test.ts | 15 ++++++++++ 12 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 src/shared/codeChecks.ts diff --git a/AGENTS.md b/AGENTS.md index 5ddc676..ec341aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,6 +243,14 @@ These come from how gacha games actually schedule things, and they cause most bu `docs/INGESTION.md`, which is specified and **not built**. So the rule is enforced per adapter, by a test, against a pinned fixture: a source added without a fixture in that table is a source the rule does not cover. +- **A redeem code is dead when the owner says so, even while its source says active** (owner, + 2026-10-20). Game8 kept `Y1INABQB6DEX`, `2BJ64QRZ7RT8` and `PLDXIB886Y1S` in Genshin's active + table after the game refused them. The owner's in-game check is recorded in `CODE_CHECKS` + (`src/shared/codeChecks.ts`): game, code, date. `build-codes` stamps the matching code + `confirmedDead`, and the reader sees it expired with "Confirmed expired in game, YYYY-MM-DD." Only + "dead" exists; never add a check from a guess, a comment thread or a code's age, and never delete + the code instead, because the source still owns the code, its reward and its link. A check whose + code the source has dropped is logged at build and pruned when convenient. ## Working on parsers diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e520837..c58dd35 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -644,7 +644,8 @@ legend on the same baseline as "Next: …". date-only expiry counts as passed only once that date has ended everywhere (12:00 UTC the next day). Settings → Your games marks a followed game with "N new codes" until its Codes tab is opened (`codeSeen`); codes already expired or redeemed are not counted. Games the reader does not follow - get no marker. + get no marker. A code the owner confirmed dead in game (2026-10-20) is expired whatever the source + says, and reads "Confirmed expired in game, YYYY-MM-DD." in place of its expiry. - **The tour (2026-10-18, owner).** Every reader takes it once before using the app: new readers straight after setup, returning readers on their first visit, and readers who saw the earlier tour once more. The order is the owner's (2026-10-18): a welcome on its own over the dimmed Checklist, diff --git a/docs/INGESTION.md b/docs/INGESTION.md index aef3fa8..ce148f5 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -694,3 +694,13 @@ so it shows the in-game route only. **Refreshing.** These are game8 pages, so the CI caveat in AGENTS.md § Scraping conduct applies: if the runner is refused, they refresh by hand like the event pages. + +## Codes confirmed dead in game (2026-10-20) + +A source can keep a dead code in its active table: on 2026-10-20 Game8 still listed three Genshin +codes the game refused. `CODE_CHECKS` (`src/shared/codeChecks.ts`) records each code the owner +tried and found dead, with the date. `scripts/build-codes.ts` runs `applyCodeChecks` after parsing, +which sets `confirmedDead` on the matching code (by its `["game","CODE"]` id) and returns the checks +that matched nothing; the build logs those so they can be pruned. Nothing else about the code +changes, and the check is never used to add a code. The field is optional, so `schemaVersion` +stays 1. diff --git a/scripts/build-codes.ts b/scripts/build-codes.ts index b8134b0..349d4b2 100644 --- a/scripts/build-codes.ts +++ b/scripts/build-codes.ts @@ -1,6 +1,7 @@ import { mkdir, rename } from "node:fs/promises"; import { CODE_SOURCES } from "../src/ingest/codes/registry.ts"; -import { buildCodesFeed } from "../src/ingest/codes/build.ts"; +import { applyCodeChecks, buildCodesFeed } from "../src/ingest/codes/build.ts"; +import { CODE_CHECKS } from "../src/shared/codeChecks.ts"; import { CodesFeedSchema, type CodesFeed } from "../src/shared/codes.ts"; import { SnapshotStore, freshnessAt } from "../src/ingest/snapshots.ts"; import type { SourceHealth } from "../src/shared/feed.ts"; @@ -55,7 +56,16 @@ export async function buildCodes() { : false, }); } - const feed = CodesFeedSchema.parse({ schemaVersion: 1, codes, sources }); + const checked = applyCodeChecks(codes, CODE_CHECKS); + for (const c of checked.unmatched) + console.log( + `build-codes: check ${c.game} ${c.code} (${c.checked}) matches no code; prune it`, + ); + const feed = CodesFeedSchema.parse({ + schemaVersion: 1, + codes: checked.codes, + sources, + }); await mkdir(`${out}/data`, { recursive: true }); await Bun.write(`${out}/data/codes.v1.json`, JSON.stringify(feed)); await mkdir(root, { recursive: true }); diff --git a/src/client/components/redesign/CodesPage.tsx b/src/client/components/redesign/CodesPage.tsx index b832423..e7b8c43 100644 --- a/src/client/components/redesign/CodesPage.tsx +++ b/src/client/components/redesign/CodesPage.tsx @@ -69,13 +69,15 @@ export function CodesPage({ {c.reward &&

{c.reward}

} - {codeExpired(c, now) - ? "Expired" - : c.expiresAt - ? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}` - : c.expiresOn - ? `Expires on ${c.expiresOn} (time not stated)` - : "Expiry not announced"}{" "} + {c.confirmedDead + ? `Confirmed expired in game, ${c.confirmedDead}.` + : codeExpired(c, now) + ? "Expired" + : c.expiresAt + ? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}` + : c.expiresOn + ? `Expires on ${c.expiresOn} (time not stated)` + : "Expiry not announced"}{" "} ·{" "} Source diff --git a/src/client/state/reader/codes.ts b/src/client/state/reader/codes.ts index 54a96e8..a6ebc92 100644 --- a/src/client/state/reader/codes.ts +++ b/src/client/state/reader/codes.ts @@ -11,9 +11,11 @@ export function newCodes( * Whether a code's stated expiry has passed. A date with no time ("Expires on * 2026-10-20") counts as passed only once that date has ended everywhere, at * 12:00 UTC the next day, so a code is never shown expired while it may - * still redeem somewhere. + * still redeem somewhere. A code the owner confirmed dead in game is expired + * whatever it states. */ export function codeExpired(code: RedeemCode, now: number): boolean { + if (code.confirmedDead) return true; if (code.expiresAt) return Date.parse(code.expiresAt) <= now; if (code.expiresOn) return Date.parse(`${code.expiresOn}T00:00:00Z`) + 36 * 3600_000 <= now; diff --git a/src/ingest/codes/build.ts b/src/ingest/codes/build.ts index af9a832..43ffa47 100644 --- a/src/ingest/codes/build.ts +++ b/src/ingest/codes/build.ts @@ -1,4 +1,5 @@ import type { CodesFeed, RedeemCode } from "../../shared/codes.ts"; +import type { CodeCheck } from "../../shared/codeChecks.ts"; export function buildCodesFeed( previous: CodesFeed | null, parsed: readonly Omit[], @@ -14,3 +15,28 @@ export function buildCodesFeed( sources: previous?.sources ?? [], }; } +/** + * Stamps each code the owner confirmed dead (`codeChecks.ts`) and returns the + * checks that matched no code, for the build to report. + */ +export function applyCodeChecks( + codes: readonly RedeemCode[], + checks: readonly CodeCheck[], +): { codes: RedeemCode[]; unmatched: CodeCheck[] } { + const byId = new Map( + checks.map((c) => [JSON.stringify([c.game, c.code]), c]), + ); + const seen = new Set(); + const stamped = codes.map((c) => { + const check = byId.get(c.id); + if (!check) return c; + seen.add(c.id); + return { ...c, confirmedDead: check.checked }; + }); + return { + codes: stamped, + unmatched: checks.filter( + (c) => !seen.has(JSON.stringify([c.game, c.code])), + ), + }; +} diff --git a/src/shared/codeChecks.ts b/src/shared/codeChecks.ts new file mode 100644 index 0000000..ff30d2a --- /dev/null +++ b/src/shared/codeChecks.ts @@ -0,0 +1,25 @@ +import type { GameId } from "./schema.ts"; + +/** + * Codes the repository owner tried in game and found dead, while the source + * still lists them as active (owner, 2026-10-20). The in-game check is the + * evidence, as for a routine marked `Confirmed in game` (`routines.ts`): the + * reader sees "Confirmed expired in game, YYYY-MM-DD." on the code. + * + * Only "dead" is recorded; there is no "still works". An entry whose code the + * source has since dropped matches nothing and is logged by `build-codes`, so + * prune it whenever convenient. + */ +export interface CodeCheck { + game: GameId; + /** Exactly as the source spells it. */ + code: string; + /** The day it was tried and refused. */ + checked: string; +} + +export const CODE_CHECKS: readonly CodeCheck[] = [ + { game: "genshin", code: "Y1INABQB6DEX", checked: "2026-10-20" }, + { game: "genshin", code: "2BJ64QRZ7RT8", checked: "2026-10-20" }, + { game: "genshin", code: "PLDXIB886Y1S", checked: "2026-10-20" }, +]; diff --git a/src/shared/codes.ts b/src/shared/codes.ts index ce29721..cf72873 100644 --- a/src/shared/codes.ts +++ b/src/shared/codes.ts @@ -12,6 +12,15 @@ export const RedeemCodeSchema = z.object({ .regex(/^\d{4}-\d{2}-\d{2}$/) .nullable() .optional(), + /** + * The day the repository owner tried the code in game and it no longer + * worked (`codeChecks.ts`). Only ever "dead": the source still supplies the + * code, its reward and its link. + */ + confirmedDead: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), firstObservedAt: z.string().datetime(), sourceUrl: z.string().url(), }); diff --git a/test/codes-ui.test.tsx b/test/codes-ui.test.tsx index a9fcc6b..29e8f03 100644 --- a/test/codes-ui.test.tsx +++ b/test/codes-ui.test.tsx @@ -100,3 +100,32 @@ test("with one server, a code's checkbox does not name it", () => { expect(html).toContain("Redeemed"); expect(html).not.toContain("Redeemed · "); }); + +test("a code confirmed dead in game says so, in the redeemed-or-expired section", () => { + const at = "2026-10-20T12:00:00.000Z"; + const html = render( + "account:t")} + onAction={async () => ({ ok: true })} + feed={{ + schemaVersion: 1, + sources: [], + codes: [ + { + id: "dead", + game: "genshin", + code: "DEAD", + reward: null, + expiresAt: null, + confirmedDead: "2026-10-20", + firstObservedAt: at, + sourceUrl: "https://example.com", + }, + ], + }} + />, + ); + expect(html).toContain("Redeemed or expired"); + expect(html).toContain("Confirmed expired in game, 2026-10-20."); +}); diff --git a/test/codes.test.ts b/test/codes.test.ts index 42eef31..f792ec0 100644 --- a/test/codes.test.ts +++ b/test/codes.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; -import { buildCodesFeed } from "../src/ingest/codes/build.ts"; +import { applyCodeChecks, buildCodesFeed } from "../src/ingest/codes/build.ts"; import { parseGame8Codes } from "../src/ingest/codes/game8.ts"; +import { CODE_CHECKS } from "../src/shared/codeChecks.ts"; const at = "2026-10-16T12:00:00.000Z", ctx = { game: "genshin" as const, @@ -54,3 +55,31 @@ test("special-program codes are included while their stated expiry date is still expect(codes).toHaveLength(13); expect(codes.some((c) => c.code === "Rekviem")).toBe(true); }); +test("a code the owner confirmed dead is stamped, and a check matching nothing is reported", () => { + const live = (code: string) => ({ + id: JSON.stringify(["genshin", code]), + game: "genshin" as const, + code, + reward: null, + expiresAt: null, + firstObservedAt: at, + sourceUrl: ctx.sourceUrl, + }); + const { codes, unmatched } = applyCodeChecks( + [live("DEAD"), live("ALIVE")], + [ + { game: "genshin", code: "DEAD", checked: "2026-10-20" }, + { game: "genshin", code: "GONE", checked: "2026-10-20" }, + ], + ); + expect(codes.map((c) => c.confirmedDead ?? null)).toEqual([ + "2026-10-20", + null, + ]); + expect(unmatched.map((c) => c.code)).toEqual(["GONE"]); +}); +test("every code check names one code once, with a date", () => { + const ids = CODE_CHECKS.map((c) => JSON.stringify([c.game, c.code])); + expect(new Set(ids).size).toBe(ids.length); + for (const c of CODE_CHECKS) expect(c.checked).toMatch(/^\d{4}-\d{2}-\d{2}$/); +}); diff --git a/test/reader-codes-state.test.ts b/test/reader-codes-state.test.ts index 28a2414..4623ba6 100644 --- a/test/reader-codes-state.test.ts +++ b/test/reader-codes-state.test.ts @@ -69,3 +69,18 @@ test("the Settings marker counts unseen codes that can still be used", () => { ).map((c) => c.id); expect(ids).toEqual(["fresh"]); }); + +test("a code the owner confirmed dead is expired whatever its stated expiry", () => { + expect(codeExpired(code("a", { confirmedDead: "2026-10-20" }), now)).toBe( + true, + ); + expect( + codeExpired( + code("a", { + confirmedDead: "2026-10-20", + expiresAt: "2026-12-01T00:00:00.000Z", + }), + now, + ), + ).toBe(true); +});