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:
1 parent
7569aa6395
commit
b8e8d44816
12 files changed
+178
-12
No files matched your search
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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])),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
];
|
||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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}$/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user