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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-20 03:34:52 +02:00
1 parent 7569aa6395
commit b8e8d44816
12 files changed
+178 -12

No files matched your search

+8
View File
@@ -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, `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 by a test, against a pinned fixture: a source added without a fixture in that table is a source
the rule does not cover. 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 ## Working on parsers
+2 -1
View File
@@ -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 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 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 (`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 - **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 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, once more. The order is the owner's (2026-10-18): a welcome on its own over the dimmed Checklist,
+10
View File
@@ -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 **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. 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.
+12 -2
View File
@@ -1,6 +1,7 @@
import { mkdir, rename } from "node:fs/promises"; import { mkdir, rename } from "node:fs/promises";
import { CODE_SOURCES } from "../src/ingest/codes/registry.ts"; 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 { CodesFeedSchema, type CodesFeed } from "../src/shared/codes.ts";
import { SnapshotStore, freshnessAt } from "../src/ingest/snapshots.ts"; import { SnapshotStore, freshnessAt } from "../src/ingest/snapshots.ts";
import type { SourceHealth } from "../src/shared/feed.ts"; import type { SourceHealth } from "../src/shared/feed.ts";
@@ -55,7 +56,16 @@ export async function buildCodes() {
: false, : 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 mkdir(`${out}/data`, { recursive: true });
await Bun.write(`${out}/data/codes.v1.json`, JSON.stringify(feed)); await Bun.write(`${out}/data/codes.v1.json`, JSON.stringify(feed));
await mkdir(root, { recursive: true }); await mkdir(root, { recursive: true });
+9 -7
View File
@@ -69,13 +69,15 @@ export function CodesPage({
</button> </button>
{c.reward && <p>{c.reward}</p>} {c.reward && <p>{c.reward}</p>}
<small> <small>
{codeExpired(c, now) {c.confirmedDead
? "Expired" ? `Confirmed expired in game, ${c.confirmedDead}.`
: c.expiresAt : codeExpired(c, now)
? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}` ? "Expired"
: c.expiresOn : c.expiresAt
? `Expires on ${c.expiresOn} (time not stated)` ? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}`
: "Expiry not announced"}{" "} : c.expiresOn
? `Expires on ${c.expiresOn} (time not stated)`
: "Expiry not announced"}{" "}
·{" "} ·{" "}
<a href={c.sourceUrl} target="_blank" rel="noreferrer"> <a href={c.sourceUrl} target="_blank" rel="noreferrer">
Source Source
+3 -1
View File
@@ -11,9 +11,11 @@ export function newCodes(
* Whether a code's stated expiry has passed. A date with no time ("Expires on * 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 * 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 * 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 { export function codeExpired(code: RedeemCode, now: number): boolean {
if (code.confirmedDead) return true;
if (code.expiresAt) return Date.parse(code.expiresAt) <= now; if (code.expiresAt) return Date.parse(code.expiresAt) <= now;
if (code.expiresOn) if (code.expiresOn)
return Date.parse(`${code.expiresOn}T00:00:00Z`) + 36 * 3600_000 <= now; return Date.parse(`${code.expiresOn}T00:00:00Z`) + 36 * 3600_000 <= now;
+26
View File
@@ -1,4 +1,5 @@
import type { CodesFeed, RedeemCode } from "../../shared/codes.ts"; import type { CodesFeed, RedeemCode } from "../../shared/codes.ts";
import type { CodeCheck } from "../../shared/codeChecks.ts";
export function buildCodesFeed( export function buildCodesFeed(
previous: CodesFeed | null, previous: CodesFeed | null,
parsed: readonly Omit<RedeemCode, "firstObservedAt">[], parsed: readonly Omit<RedeemCode, "firstObservedAt">[],
@@ -14,3 +15,28 @@ export function buildCodesFeed(
sources: previous?.sources ?? [], 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<string>();
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])),
),
};
}
+25
View File
@@ -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" },
];
+9
View File
@@ -12,6 +12,15 @@ export const RedeemCodeSchema = z.object({
.regex(/^\d{4}-\d{2}-\d{2}$/) .regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable() .nullable()
.optional(), .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(), firstObservedAt: z.string().datetime(),
sourceUrl: z.string().url(), sourceUrl: z.string().url(),
}); });
+29
View File
@@ -100,3 +100,32 @@ test("with one server, a code's checkbox does not name it", () => {
expect(html).toContain("Redeemed"); expect(html).toContain("Redeemed");
expect(html).not.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(
<CodesPage
game="genshin"
state={migrateLegacy({}, ["genshin"], at, () => "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.");
});
+30 -1
View File
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"; 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 { parseGame8Codes } from "../src/ingest/codes/game8.ts";
import { CODE_CHECKS } from "../src/shared/codeChecks.ts";
const at = "2026-10-16T12:00:00.000Z", const at = "2026-10-16T12:00:00.000Z",
ctx = { ctx = {
game: "genshin" as const, 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).toHaveLength(13);
expect(codes.some((c) => c.code === "Rekviem")).toBe(true); 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}$/);
});
+15
View File
@@ -69,3 +69,18 @@ test("the Settings marker counts unseen codes that can still be used", () => {
).map((c) => c.id); ).map((c) => c.id);
expect(ids).toEqual(["fresh"]); 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);
});