feat(codes): redeem codes for eight more games, and where to redeem them

Game8 codes pages for Star Rail, ZZZ, Wuthering Waves, Endfield, NTE,
P5X, Chaos Zero Nightmare and Umamusume, read by one table parser told
each page's active-codes heading. Each page was fetched once and pinned
as a fixture. Infinity Nikki's page is stale (still titled July 2025)
and is left out.

The Codes tab links the publisher's own redeem page where one exists
(Genshin, Star Rail, ZZZ) and gives the in-game route for every game,
and names the server on a redeemed checkbox only when there are two.

Settings uses the width of a large screen: content up to 1320px and the
menu column running the full height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-18 04:20:45 +02:00
co-authored by Claude Opus 5.5
parent 6fb084350a
commit 254e78f4d1
38 changed files with 18707 additions and 15 deletions
+5 -4
View File
@@ -387,10 +387,11 @@ Sources are community wikis. Treat them as a guest would:
with `--only`: forcing twenty sources to re-ask a question they answered an hour ago is the
behaviour this bullet exists to prevent, whatever flag authorised it. The run names every source it
asked early, and a run that was due anyway is never reported as forced.
- **Space requests to one host**, honouring its `Crawl-delay` and defaulting to 2s. Nine of the
twenty sources are game8.co pages, so the per-source floor alone still permits one cycle to arrive
as nine back-to-back requests to a single site — which is the shape an edge network throttles, and
what a burst looks like from the far end regardless of our intent.
- **Space requests to one host**, honouring its `Crawl-delay` and defaulting to 2s. Nine event
sources and nine redeem-code sources (2026-10-18) are game8.co pages, so the per-source floor alone
still permits one cycle to arrive as eighteen back-to-back requests to a single site — which is
the shape an edge network throttles, and what a burst looks like from the far end regardless of
our intent.
- Send `If-None-Match` / `If-Modified-Since`; treat `304` as "skip, unchanged".
- Cache raw snapshots so re-parsing never re-fetches. **Iterate against fixtures, not the network.**
- Record `sourceUrl` on every event and surface attribution in the UI.
+11
View File
@@ -266,3 +266,14 @@ note only says where to start.
readers could reach the ~5 MB quota. Failures are reported, not silent.
- After a rollback, anything done in the new build lives only in v2 and isn't shown by the old app.
It's stranded, not lost.
- [ ] Get readers to try the layout choices, not just be told about them (owner, 2026-10-18).
- Relaxed vs Structured, and the Timeline's new vs classic look. Each is one switch in
Settings → Appearance today, which most readers will never open.
- Show the difference live, on the reader's own events: a "try it" that switches the page in place,
with an easy way back, rather than a description or a static picture.
- Aim this especially at returning readers, who arrive on the default (Structured, new Timeline)
without having chosen it.
- Offer it even to readers who have already done or skipped the returning-user tour (the tour
records `redesignTourSeen`; this needs its own "seen" so it isn't swallowed by that).
- Open questions: where it appears (a one-time card on the Checklist and Timeline? a tour step?), and
whether trying a look saves it or only previews it until confirmed.
+35
View File
@@ -659,3 +659,38 @@ Event adapter schemas are unchanged. scripts/build-codes.ts builds data/codes.v1
snapshots or fixtures and preserves observation timestamps in snapshots/codes-first-seen.json.
Code newness is per game; redemption is per local game/server account and never clears newness by
itself. No code row is synthesized into the event feed.
## Redeem codes for eight more games (2026-10-18)
Codes now come from Game8 for Star Rail (archives/410296), ZZZ (435683), Wuthering Waves (453149),
Endfield (571509), NTE (593718), P5X (531770), Chaos Zero Nightmare (557967) and Umamusume (536130),
beside Genshin.
- **Capture.** game8.co's robots.txt has no rule for our User-Agent or these paths. Each page was
fetched exactly once with the refresh User-Agent, 2s apart, all HTTP 200, and pinned as
`fixtures/<game>/game8-codes-2026-10-18.*`.
- **Parser.** The pages share Game8's copy-input markup but not their headings or columns.
`parseGame8CodeTable` reads the section under each page's own "active codes" heading
(`src/ingest/codes/registry.ts`), skips any table there that says its codes have expired
(Wuthering Waves keeps one at the top), and reads the reward from the other cells, or from the
code's own cell when they share one (Wuthering Waves, P5X).
- **Expiry stays null.** The pages print "Expires TBA" or a date range without a year, and a guessed
year would be a fabricated date.
- **Umamusume.** The page currently states "No Publicly Released Codes Yet", which is what lets its
empty read be stored (`statesNoEvents`). A changed page still fails the gate.
- **Spot-checked against the live pages:** Star Rail's six codes, WUTHERINGGIFT, Endfield's
ENDFIELDSTEAM / ENDFIELDGIFT / ENDFIELD4PC, CZN's NIGHTMARECARNIVAL and P5X's WEBSTORE1YEAR.
**Infinity Nikki is not added.** Game8 stopped updating its Nikki guides and the codes page is still
titled "All Infinity Nikki Codes (July 2025)", so its twelve "active" codes cannot be presented as
current. The other games (Arknights, Blue Archive, FGO, hololive Dreams, GFL2, Stella Sora, Nikke,
Honkai Impact 3rd, PGR, Reverse: 1999) have no Game8 codes page. Searches found only general blogs,
which are not assessed yet.
**Where to redeem** (`src/shared/redeem.ts`). Each game with a codes source shows its in-game route,
taken from its Game8 page's "How to redeem" section. An official web page gets a button only when the
page links one on the publisher's domain. Today that is Genshin, Star Rail and ZZZ on hoyoverse.com.
Chaos Zero Nightmare names an official coupon page without linking it, and no URL could be confirmed,
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.
+35
View File
@@ -995,3 +995,38 @@ Event adapter schemas are unchanged. scripts/build-codes.ts builds data/codes.v1
snapshots or fixtures and preserves observation timestamps in snapshots/codes-first-seen.json.
Code newness is per game; redemption is per local game/server account and never clears newness by
itself. No code row is synthesized into the event feed.
## Redeem codes for eight more games (2026-10-18)
Codes now come from Game8 for Star Rail (archives/410296), ZZZ (435683), Wuthering Waves (453149),
Endfield (571509), NTE (593718), P5X (531770), Chaos Zero Nightmare (557967) and Umamusume (536130),
beside Genshin.
- **Capture.** game8.co's robots.txt has no rule for our User-Agent or these paths. Each page was
fetched exactly once with the refresh User-Agent, 2s apart, all HTTP 200, and pinned as
`fixtures/<game>/game8-codes-2026-10-18.*`.
- **Parser.** The pages share Game8's copy-input markup but not their headings or columns.
`parseGame8CodeTable` reads the section under each page's own "active codes" heading
(`src/ingest/codes/registry.ts`), skips any table there that says its codes have expired
(Wuthering Waves keeps one at the top), and reads the reward from the other cells, or from the
code's own cell when they share one (Wuthering Waves, P5X).
- **Expiry stays null.** The pages print "Expires TBA" or a date range without a year, and a guessed
year would be a fabricated date.
- **Umamusume.** The page currently states "No Publicly Released Codes Yet", which is what lets its
empty read be stored (`statesNoEvents`). A changed page still fails the gate.
- **Spot-checked against the live pages:** Star Rail's six codes, WUTHERINGGIFT, Endfield's
ENDFIELDSTEAM / ENDFIELDGIFT / ENDFIELD4PC, CZN's NIGHTMARECARNIVAL and P5X's WEBSTORE1YEAR.
**Infinity Nikki is not added.** Game8 stopped updating its Nikki guides and the codes page is still
titled "All Infinity Nikki Codes (July 2025)", so its twelve "active" codes cannot be presented as
current. The other games (Arknights, Blue Archive, FGO, hololive Dreams, GFL2, Stella Sora, Nikke,
Honkai Impact 3rd, PGR, Reverse: 1999) have no Game8 codes page. Searches found only general blogs,
which are not assessed yet.
**Where to redeem** (`src/shared/redeem.ts`). Each game with a codes source shows its in-game route,
taken from its Game8 page's "How to redeem" section. An official web page gets a button only when the
page links one on the publisher's domain. Today that is Genshin, Star Rail and ZZZ on hoyoverse.com.
Chaos Zero Nightmare names an official coupon page without linking it, and no URL could be confirmed,
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.
@@ -924,3 +924,17 @@ committing:
The owner chose to commit and push, including `design/`, the redesign docs, `docs/design-reviews/` and
`.superpowers/`. The changelog entry and note follow in their own commit, because they must name the
redesign commit.
### After shipping: Settings width, redeem links, more code sources (2026-10-18)
- **Settings on large screens.** Content was capped at 900px (760px on game, backup and data pages),
and the menu's rule stopped partway down. Content now uses up to 1320px; the changelog keeps its
reading column. The menu runs the full height.
- **Codes tab.** It gains "Redeem on the official site ↗" where the publisher has a web page (Genshin,
Star Rail, ZZZ), and the in-game route for every game with codes (`src/shared/redeem.ts`).
A redeemed checkbox names the server only when there is more than one.
- **Code sources.** Eight more games come from Game8: `parseGame8CodeTable`, with fixtures, expected
files and tests. Infinity Nikki is skipped because its page is stale, and ten games have no Game8
page. See docs/SOURCES.md § Redeem codes for eight more games.
- **Owner's TODO:** let readers try Relaxed/Structured and the new/classic Timeline live, including
returning readers who've done the tour.
@@ -0,0 +1,42 @@
[
{
"id": "[\"czn\",\"NIGHTMARECARNIVAL\"]",
"game": "czn",
"code": "NIGHTMARECARNIVAL",
"reward": "Crystals x1,000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Chaos-Zero-Nightmare/archives/557967"
},
{
"id": "[\"czn\",\"CZNNEWPDGIFT3RD\"]",
"game": "czn",
"code": "CZNNEWPDGIFT3RD",
"reward": "Signal Amplification Anchor x10",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Chaos-Zero-Nightmare/archives/557967"
},
{
"id": "[\"czn\",\"CZNNEWPDGIFT2ND\"]",
"game": "czn",
"code": "CZNNEWPDGIFT2ND",
"reward": "Signal Amplification Anchor x10",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Chaos-Zero-Nightmare/archives/557967"
},
{
"id": "[\"czn\",\"CZNNEWPDGIFT1ST\"]",
"game": "czn",
"code": "CZNNEWPDGIFT1ST",
"reward": "Signal Amplification Anchor x10",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Chaos-Zero-Nightmare/archives/557967"
},
{
"id": "[\"czn\",\"epicwithczn\"]",
"game": "czn",
"code": "epicwithczn",
"reward": "Rescue Anchor x3 Unit x300,000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Chaos-Zero-Nightmare/archives/557967"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"sourceId": "czn-game8-codes",
"url": "https://game8.co/games/Chaos-Zero-Nightmare/archives/557967",
"contentHash": "4df2be0e25981e40e9b2d8c5efa0a87fc389ae3e03ec028dbf017b83fd7f375a",
"bytes": 258431,
"etag": "W/\"4df2be0e25981e40e9b2d8c5efa0a87f\"",
"lastModified": null,
"contentChangedAt": "2026-10-18T02:11:13.874Z",
"lastConfirmedAt": "2026-10-18T02:11:13.874Z",
"charset": "utf-8"
}
@@ -0,0 +1,34 @@
[
{
"id": "[\"endfield\",\"ENDFIELDSTEAM\"]",
"game": "endfield",
"code": "ENDFIELDSTEAM",
"reward": "T-Creds x8,000 Elementary Cognitive Carrier x2 Arms INSP Set x2 Heat-Driven Cog x1",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Arknights-Endfield/archives/571509"
},
{
"id": "[\"endfield\",\"ZAU2SYXHWX5L4ZH\"]",
"game": "endfield",
"code": "ZAU2SYXHWX5L4ZH",
"reward": "T-Creds x5000 Arms INSP Kit x10",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Arknights-Endfield/archives/571509"
},
{
"id": "[\"endfield\",\"ENDFIELDGIFT\"]",
"game": "endfield",
"code": "ENDFIELDGIFT",
"reward": "Oroberyl x150 T-Creds x10,000 Intermediate Combat Record x20 Arms INSP Kit x20",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Arknights-Endfield/archives/571509"
},
{
"id": "[\"endfield\",\"ENDFIELD4PC\"]",
"game": "endfield",
"code": "ENDFIELD4PC",
"reward": "T-Creds x13,000 Advanced Combat Record x2 Arms INSP Kit x2",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Arknights-Endfield/archives/571509"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"sourceId": "endfield-game8-codes",
"url": "https://game8.co/games/Arknights-Endfield/archives/571509",
"contentHash": "75c3ac825b82f46b51be36e5a22ddc99b37aca25574fac0b103c2f89934c9362",
"bytes": 359357,
"etag": "W/\"75c3ac825b82f46b51be36e5a22ddc99\"",
"lastModified": null,
"contentChangedAt": "2026-10-18T02:11:06.247Z",
"lastConfirmedAt": "2026-10-18T02:11:06.247Z",
"charset": "utf-8"
}
@@ -0,0 +1,50 @@
[
{
"id": "[\"hsr\",\"PEARLAPPRAISE\"]",
"game": "hsr",
"code": "PEARLAPPRAISE",
"reward": "Traveler's Guide x3 Cold Joke x2",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/410296"
},
{
"id": "[\"hsr\",\"OMEGA\"]",
"game": "hsr",
"code": "OMEGA",
"reward": "Stellar Jade x60 Fuel x1",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/410296"
},
{
"id": "[\"hsr\",\"CREATIONNYMPH\"]",
"game": "hsr",
"code": "CREATIONNYMPH",
"reward": "Stellar Jade x60 Fuel x1 Heroic Variable x1",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/410296"
},
{
"id": "[\"hsr\",\"FAREWELL\"]",
"game": "hsr",
"code": "FAREWELL",
"reward": "Stellar Jade x60 Fuel x1",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/410296"
},
{
"id": "[\"hsr\",\"IFYOUAREREADINGTHIS\"]",
"game": "hsr",
"code": "IFYOUAREREADINGTHIS",
"reward": "Stellar Jade x60 Fuel x1",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/410296"
},
{
"id": "[\"hsr\",\"STARRAILGIFT\"]",
"game": "hsr",
"code": "STARRAILGIFT",
"reward": "Stellar Jade x100 Traveler's Guide x4 Bottled Soda x5 Credit x50,000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/410296"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"sourceId": "hsr-game8-codes",
"url": "https://game8.co/games/Honkai-Star-Rail/archives/410296",
"contentHash": "be9e4c55c6e50d3393fc6fbbc85931e32f2d1fde24bc006488f239a903d54762",
"bytes": 259975,
"etag": "W/\"be9e4c55c6e50d3393fc6fbbc85931e3\"",
"lastModified": null,
"contentChangedAt": "2026-10-18T02:10:59.689Z",
"lastConfirmedAt": "2026-10-18T02:10:59.689Z",
"charset": "utf-8"
}
@@ -0,0 +1,98 @@
[
{
"id": "[\"nte\",\"WONDERLOGGIFT\"]",
"game": "nte",
"code": "WONDERLOGGIFT",
"reward": "Annulith x30, Fons x20,000, Senior Hunter Guide x3",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"BLACKBIRDINNTE\"]",
"game": "nte",
"code": "BLACKBIRDINNTE",
"reward": "Heterogeneous Unit x20, Fons x10,000, Beetle Coin x30,000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"BLACKBIRDHERE\"]",
"game": "nte",
"code": "BLACKBIRDHERE",
"reward": "Witch's Blessing Profile Frame x 1",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"COMEBACK\"]",
"game": "nte",
"code": "COMEBACK",
"reward": "Fabricated Dice x1",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"ZANKOU0819\"]",
"game": "nte",
"code": "ZANKOU0819",
"reward": "Annulith x30, Fons x50,000, Beetle Coin x30,000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"NTEMusou\"]",
"game": "nte",
"code": "NTEMusou",
"reward": "Fons x10,000, Beetle Coin x10,000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"NTEKokushi\"]",
"game": "nte",
"code": "NTEKokushi",
"reward": "Fons x30,000, Beetle Coin x30,000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"NTEGIFT\"]",
"game": "nte",
"code": "NTEGIFT",
"reward": "Annulith x50, Rising Hunter Guide x5, Light Dye x5",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"DREAMWALK0603\"]",
"game": "nte",
"code": "DREAMWALK0603",
"reward": "Annulith x100, Rising Hunter Guide x5, Light Dye x5, Beetle Coin x4000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"TOMATO100\"]",
"game": "nte",
"code": "TOMATO100",
"reward": "Annulith x100, Senior Hunter Guide x5, Colorless Dye x5, Beetle Coin x6000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"RACENOLIMIT\"]",
"game": "nte",
"code": "RACENOLIMIT",
"reward": "Annulith x100, Elite Hunter Guide x2, Chaotic Dye x2, Beetle Coin x12000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
},
{
"id": "[\"nte\",\"NTENENE\"]",
"game": "nte",
"code": "NTENENE",
"reward": "Fons x10,000, DynamiK Zero x10, Clicky Fries x10",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/593718"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"sourceId": "nte-game8-codes",
"url": "https://game8.co/games/Neverness-to-Everness/archives/593718",
"contentHash": "267dfd0bea264940784c505e3e3a32116a189c8c86d4d4118ab7269734ae3869",
"bytes": 340960,
"etag": "W/\"267dfd0bea264940784c505e3e3a3211\"",
"lastModified": null,
"contentChangedAt": "2026-10-18T02:11:08.413Z",
"lastConfirmedAt": "2026-10-18T02:11:08.413Z",
"charset": "utf-8"
}
@@ -0,0 +1,18 @@
[
{
"id": "[\"p5x\",\"WEBSTORE1YEAR\"]",
"game": "p5x",
"code": "WEBSTORE1YEAR",
"reward": "100 Marthym's Stamps (During Ver. 4.10 Marthym's Stamps)",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Persona-5-Phantom-X/archives/531770"
},
{
"id": "[\"p5x\",\"WONDER\"]",
"game": "p5x",
"code": "WONDER",
"reward": "Anniversary Profile Card",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Persona-5-Phantom-X/archives/531770"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"sourceId": "p5x-game8-codes",
"url": "https://game8.co/games/Persona-5-Phantom-X/archives/531770",
"contentHash": "c9f181f535fec6f537c1b0c9d23b62bd98108200f0e02e7445abc02588f17717",
"bytes": 264985,
"etag": "W/\"c9f181f535fec6f537c1b0c9d23b62bd\"",
"lastModified": null,
"contentChangedAt": "2026-10-18T02:11:11.142Z",
"lastConfirmedAt": "2026-10-18T02:11:11.142Z",
"charset": "utf-8"
}
@@ -0,0 +1 @@
[]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"sourceId": "uma-game8-codes",
"url": "https://game8.co/games/Umamusume-Pretty-Derby/archives/536130",
"contentHash": "725f227c7557e254c80225bee0ad4fc41565adf13cd1f07cbacfa7e614f7ac88",
"bytes": 233063,
"etag": "W/\"725f227c7557e254c80225bee0ad4fc4\"",
"lastModified": null,
"contentChangedAt": "2026-10-18T02:11:16.036Z",
"lastConfirmedAt": "2026-10-18T02:11:16.036Z",
"charset": "utf-8"
}
@@ -0,0 +1,10 @@
[
{
"id": "[\"wuwa\",\"WUTHERINGGIFT\"]",
"game": "wuwa",
"code": "WUTHERINGGIFT",
"reward": "Astrite x50 Premium Resonance Potion x2 Medium Revival Inhaler x2 Medium Energy Bag x2 Shell Credit x15,000",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453149"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"sourceId": "wuwa-game8-codes",
"url": "https://game8.co/games/Wuthering-Waves/archives/453149",
"contentHash": "fe94a2655b0d24f52f624bb5bd3a8ff52977ce232093316f6e0d508a4dc9ad52",
"bytes": 509899,
"etag": "W/\"fe94a2655b0d24f52f624bb5bd3a8ff5\"",
"lastModified": null,
"contentChangedAt": "2026-10-18T02:11:04.080Z",
"lastConfirmedAt": "2026-10-18T02:11:04.080Z",
"charset": "utf-8"
}
@@ -0,0 +1,34 @@
[
{
"id": "[\"zzz\",\"ZZZVOID32\"]",
"game": "zzz",
"code": "ZZZVOID32",
"reward": "Polychrome x20 Denny x2222",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/435683"
},
{
"id": "[\"zzz\",\"ZZZINK32\"]",
"game": "zzz",
"code": "ZZZINK32",
"reward": "Polychrome x20 Denny x2222",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/435683"
},
{
"id": "[\"zzz\",\"ZZZGRIND32\"]",
"game": "zzz",
"code": "ZZZGRIND32",
"reward": "Polychrome x20 Denny x2222",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/435683"
},
{
"id": "[\"zzz\",\"ZENLESSGIFT\"]",
"game": "zzz",
"code": "ZENLESSGIFT",
"reward": "Polychrome x50 Official Investigator Log x2 W-Engine Power Supply x3 Bangboo Algorithm Module x1",
"expiresAt": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/435683"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"sourceId": "zzz-game8-codes",
"url": "https://game8.co/games/Zenless-Zone-Zero/archives/435683",
"contentHash": "1d7c1e3ee7deb6b7e1f015528d1dcfe5d020181c766e9b27a057d19a306889d8",
"bytes": 342982,
"etag": "W/\"1d7c1e3ee7deb6b7e1f015528d1dcfe5\"",
"lastModified": null,
"contentChangedAt": "2026-10-18T02:11:01.919Z",
"lastConfirmedAt": "2026-10-18T02:11:01.919Z",
"charset": "utf-8"
}
+9 -8
View File
@@ -20,16 +20,14 @@ export async function buildCodes() {
const sources: SourceHealth[] = [];
for (const source of CODE_SOURCES) {
const snapshot = await store.read(source.id),
// Until the refresh stores a snapshot, each source builds from its own
// pinned page, dated by that capture.
at = snapshot
? freshnessAt(snapshot)
: ((
await Bun.file(
"fixtures/genshin/game8-codes-2026-10-16.meta.json",
).json()
).contentChangedAt as string),
: ((await Bun.file(`${source.fixture}.meta.json`).json())
.contentChangedAt as string),
html =
snapshot?.html ??
(await Bun.file("fixtures/genshin/game8-codes-2026-10-16.html").text());
snapshot?.html ?? (await Bun.file(`${source.fixture}.html`).text());
const parsed = source.parse(html, {
now: new Date().toISOString(),
sourceId: source.id,
@@ -51,7 +49,10 @@ export async function buildCodes() {
contentChangedAt: snapshot?.meta.contentChangedAt ?? at,
eventCount: parsed.length,
parsedCount: parsed.length,
statesNoEvents: false,
statesNoEvents:
parsed.length === 0 && "statesNoEvents" in source
? source.statesNoEvents(html)
: false,
});
}
const feed = CodesFeedSchema.parse({ schemaVersion: 1, codes, sources });
+35 -1
View File
@@ -8,5 +8,39 @@
"[\"genshin\",\"Y1INABQB6DEX\"]": "2026-10-16T11:48:08.968Z",
"[\"genshin\",\"2BJ64QRZ7RT8\"]": "2026-10-16T11:48:08.968Z",
"[\"genshin\",\"PLDXIB886Y1S\"]": "2026-10-16T11:48:08.968Z",
"[\"genshin\",\"GENSHINGIFT\"]": "2026-10-16T11:48:08.968Z"
"[\"genshin\",\"GENSHINGIFT\"]": "2026-10-16T11:48:08.968Z",
"[\"hsr\",\"PEARLAPPRAISE\"]": "2026-10-18T02:10:59.689Z",
"[\"hsr\",\"OMEGA\"]": "2026-10-18T02:10:59.689Z",
"[\"hsr\",\"CREATIONNYMPH\"]": "2026-10-18T02:10:59.689Z",
"[\"hsr\",\"FAREWELL\"]": "2026-10-18T02:10:59.689Z",
"[\"hsr\",\"IFYOUAREREADINGTHIS\"]": "2026-10-18T02:10:59.689Z",
"[\"hsr\",\"STARRAILGIFT\"]": "2026-10-18T02:10:59.689Z",
"[\"zzz\",\"ZZZVOID32\"]": "2026-10-18T02:11:01.919Z",
"[\"zzz\",\"ZZZINK32\"]": "2026-10-18T02:11:01.919Z",
"[\"zzz\",\"ZZZGRIND32\"]": "2026-10-18T02:11:01.919Z",
"[\"zzz\",\"ZENLESSGIFT\"]": "2026-10-18T02:11:01.919Z",
"[\"wuwa\",\"WUTHERINGGIFT\"]": "2026-10-18T02:11:04.080Z",
"[\"endfield\",\"ENDFIELDSTEAM\"]": "2026-10-18T02:11:06.247Z",
"[\"endfield\",\"ZAU2SYXHWX5L4ZH\"]": "2026-10-18T02:11:06.247Z",
"[\"endfield\",\"ENDFIELDGIFT\"]": "2026-10-18T02:11:06.247Z",
"[\"endfield\",\"ENDFIELD4PC\"]": "2026-10-18T02:11:06.247Z",
"[\"nte\",\"WONDERLOGGIFT\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"BLACKBIRDINNTE\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"BLACKBIRDHERE\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"COMEBACK\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"ZANKOU0819\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"NTEMusou\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"NTEKokushi\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"NTEGIFT\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"DREAMWALK0603\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"TOMATO100\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"RACENOLIMIT\"]": "2026-10-18T02:11:08.413Z",
"[\"nte\",\"NTENENE\"]": "2026-10-18T02:11:08.413Z",
"[\"p5x\",\"WEBSTORE1YEAR\"]": "2026-10-18T02:11:11.142Z",
"[\"p5x\",\"WONDER\"]": "2026-10-18T02:11:11.142Z",
"[\"czn\",\"NIGHTMARECARNIVAL\"]": "2026-10-18T02:11:13.874Z",
"[\"czn\",\"CZNNEWPDGIFT3RD\"]": "2026-10-18T02:11:13.874Z",
"[\"czn\",\"CZNNEWPDGIFT2ND\"]": "2026-10-18T02:11:13.874Z",
"[\"czn\",\"CZNNEWPDGIFT1ST\"]": "2026-10-18T02:11:13.874Z",
"[\"czn\",\"epicwithczn\"]": "2026-10-18T02:11:13.874Z"
}
+24 -1
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { freshness } from "../../../shared/feed.ts";
import type { CodesFeed } from "../../../shared/codes.ts";
import { REDEEM } from "../../../shared/redeem.ts";
import type { GameId } from "../../../shared/schema.ts";
import type { ReaderState } from "../../state/reader/schema.ts";
import { newCodes } from "../../state/reader/codes.ts";
import { actionMeta } from "../../state/reader/actions.ts";
@@ -22,6 +24,7 @@ export function CodesPage({
(a) => a.game === game && !a.retiredAt,
);
const codes = feed?.codes.filter((c) => c.game === game) ?? [];
const redeem = REDEEM[game as GameId];
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
useEffect(() => {
if (unseen.length)
@@ -40,6 +43,24 @@ export function CodesPage({
New-code notices are separate from redemption. Mark codes redeemed for
each account you play.
</p>
{redeem && (
<div className="redeem-where">
{redeem.url && (
<a
className="commit"
href={redeem.url}
target="_blank"
rel="noreferrer noopener"
>
Redeem on the official site ↗
</a>
)}
<p>
<span>{redeem.url ? "Or in game:" : "Redeem in game:"}</span>{" "}
{redeem.inGame}
</p>
</div>
)}
<p role="status">{message}</p>
{feed &&
freshness(
@@ -104,7 +125,9 @@ export function CodesPage({
})
}
/>
Redeemed · {a.region}
Redeemed
{/* The server only when there is more than one to tell apart. */}
{accounts.length > 1 && ` · ${a.region}`}
</label>
))}
</div>
+50
View File
@@ -5338,3 +5338,53 @@ button.mark {
font: 500 12px/1.4 var(--font-body);
color: var(--color-muted);
}
/* Settings on large screens: the content used to stop at 900px, leaving most
of a 1920px screen empty, and the menu's rule ended partway down. Forms and
card grids now use up to 1320px (paragraphs keep their 75ch measure), and
the menu column runs the full height of the page. */
.settings-layout {
min-height: calc(100vh - 130px);
}
.settings-layout > nav {
align-self: stretch;
}
.settings-content {
max-width: 1320px;
}
@media (max-width: 700px) {
.settings-layout {
min-height: 0;
}
}
/* The form pages fill the wider settings column too; the changelog keeps its
reading measure. */
.game-settings,
.backup-page,
.reset-row,
.sources-page .source-row,
.sources-page .list-options,
.sources-page .sources-heading {
max-width: none;
}
/* Where to redeem: the official page as a button when the publisher has one,
and the in-game route either way. */
.redeem-where {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
margin: 12px 0 16px;
}
.redeem-where .commit {
text-decoration: none;
}
.redeem-where p {
margin: 0;
font-size: 13px;
color: var(--color-ink);
}
.redeem-where p span {
color: var(--color-muted);
}
+74
View File
@@ -89,3 +89,77 @@ export function parseGame8Codes(
}
return [...codes.values()];
}
/**
* Game8's per-game codes pages share the copy-input markup but not their
* headings or columns, so this reads one page by the heading of its "active
* codes" section (an h2 runs to the next h2, an h3 to the next h2/h3).
*
* Within that section, a table counts only if it carries copy inputs and does
* not say its codes have expired — Wuthering Waves keeps an expired table at the
* top of its active section. A row's reward is its other cells, or for a single
* cell that holds both (Wuthering Waves, P5X) the cell's text after the code.
* Expiry is left null: these pages print "Expires TBA" or a range without a
* year, and a guessed year is a fabricated date.
*
* A missing heading throws (the page changed shape). A section with no code
* tables returns nothing; whether that is stored is `statesNoCodes`' call.
*/
export function parseGame8CodeTable(
html: string,
ctx: ParseContext,
heading: RegExp,
): Omit<RedeemCode, "firstObservedAt">[] {
const found = [...html.matchAll(/<h([23])\b[^>]*>([\s\S]*?)<\/h\1>/gi)].find(
(m) => heading.test(text(m[2]!)),
);
if (!found)
throw new Error("The current codes section could not be identified");
const after = html.slice(found.index! + found[0].length),
end = after.search(found[1] === "2" ? /<h2\b/i : /<h[23]\b/i),
section = end < 0 ? after : after.slice(0, end);
const codes = new Map<string, Omit<RedeemCode, "firstObservedAt">>();
for (const table of section.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
if (!/a-clipboard__textInput/.test(table[1]!)) continue;
if (/\bexpired\b/i.test(text(table[1]!))) continue;
for (const tr of table[1]!.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
const cells = [...tr[1]!.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map(
(c) => c[1]!,
);
const at = cells.findIndex((c) => /a-clipboard__textInput/.test(c));
if (at < 0) continue;
const input =
/<input\b(?=[^>]*\bclass=["'][^"']*a-clipboard__textInput)(?=[^>]*\bvalue=["']([^"']+)["'])[^>]*>/i.exec(
cells[at]!,
);
if (!input) throw new Error("A current-code row has no readable code");
const code = decodeEntities(input[1]!).trim();
if (!code || code.length > 100) throw new Error("Invalid code text");
const others = cells.filter((_, i) => i !== at);
// A date column (P5X's "Date Added") is not the reward.
const rewardCells = others.filter(
(c) => !/^\s*\d{2}\/\d{2}\/\d{4}\s*$/.test(text(c)),
);
const reward = rewardCells.length
? rewardCells.map((c) => text(c)).join(" ")
: text(cells[at]!)
.replace(/^\s*Copied\s*/i, "")
.trim();
// NTE and CZN list rewards as "・ A ・ B" / "• A"; read them as a list.
const tidy = reward
.replace(/\s*[・•]\s*/g, ", ")
.replace(/^,\s*/, "")
.trim();
const id = JSON.stringify([ctx.game, code]);
codes.set(id, {
id,
game: ctx.game,
code,
reward: tidy || null,
expiresAt: null,
sourceUrl: ctx.sourceUrl,
});
}
}
return [...codes.values()];
}
+80 -1
View File
@@ -1,5 +1,40 @@
import { SIX_HOURS_MS, type Adapter } from "../adapters/types.ts";
import { parseGame8Codes } from "./game8.ts";
import type { ParseContext } from "../adapters/types.ts";
import type { GameId } from "../../shared/schema.ts";
import { parseGame8Codes, parseGame8CodeTable } from "./game8.ts";
/**
* One Game8 codes page per game. Genshin's page has its own parser (it also
* reads livestream programmes); the others share `parseGame8CodeTable`, told
* which heading opens the page's active section. `fixture` is the pinned page a
* build uses until the refresh has stored a snapshot.
*
* Infinity Nikki is deliberately absent: Game8 stopped updating its guides and
* the codes page is still titled July 2025 (checked 2026-10-18), so its
* "active" list cannot be presented as current.
*/
function game8Table(
game: GameId,
url: string,
heading: RegExp,
statesNoCodes?: RegExp,
) {
return {
id: `${game}-game8-codes`,
game,
url,
parserId: "game8-code-table",
minIntervalMs: SIX_HOURS_MS,
priority: 0,
fixture: `fixtures/${game}/game8-codes-2026-10-18`,
parse: (html: string, ctx: ParseContext) =>
parseGame8CodeTable(html, ctx, heading),
...(statesNoCodes
? { statesNoEvents: (html: string) => statesNoCodes.test(html) }
: {}),
};
}
export const CODE_SOURCES = [
{
id: "genshin-game8-codes",
@@ -8,8 +43,52 @@ export const CODE_SOURCES = [
parserId: "game8-codes",
minIntervalMs: SIX_HOURS_MS,
priority: 0,
fixture: "fixtures/genshin/game8-codes-2026-10-16",
parse: parseGame8Codes,
},
game8Table(
"hsr",
"https://game8.co/games/Honkai-Star-Rail/archives/410296",
/^Active Redeem Codes for/i,
),
game8Table(
"zzz",
"https://game8.co/games/Zenless-Zone-Zero/archives/435683",
/^Active Zenless Zone Zero Code/i,
),
game8Table(
"wuwa",
"https://game8.co/games/Wuthering-Waves/archives/453149",
/^Wuthering Waves All Active Codes$/i,
),
game8Table(
"endfield",
"https://game8.co/games/Arknights-Endfield/archives/571509",
/^All Active Codes in/i,
),
game8Table(
"nte",
"https://game8.co/games/Neverness-to-Everness/archives/593718",
/^All Active Redeem Codes$/i,
),
game8Table(
"p5x",
"https://game8.co/games/Persona-5-Phantom-X/archives/531770",
/^List of All Codes$/i,
),
game8Table(
"czn",
"https://game8.co/games/Chaos-Zero-Nightmare/archives/557967",
/^All Active Coupons$/i,
),
// Global Umamusume has had no public codes yet; the page says so, and that
// statement (not an empty table) is what lets an empty read be stored.
game8Table(
"uma",
"https://game8.co/games/Umamusume-Pretty-Derby/archives/536130",
/^List of Codes$/i,
/No Publicly Released Codes Yet/i,
),
];
// Fetch policy only depends on source identity, interval and parse count. Event adapters keep
// their GachaEvent return contract; the scheduler can also count code records without faking events.
+72
View File
@@ -0,0 +1,72 @@
import type { GameId } from "./schema.ts";
/**
* Where a reader redeems a game's codes: the publisher's own web page when
* one exists, and the in-game path in the game's words. Only for games with a
* codes source; read from that source's own "How to redeem" section and dated
* by when it was read. A URL is listed only when it is on the publisher's
* domain and the source links it — Chaos Zero Nightmare names an official
* coupon page without linking it, so it gets the in-game path only.
*/
export interface Redeem {
/** The publisher's official redemption page. */
url?: string;
/** The in-game route, step by step. */
inGame: string;
/** Where the route and URL were read. */
source: string;
checked: string;
}
export const REDEEM: Partial<Record<GameId, Redeem>> = {
genshin: {
url: "https://genshin.hoyoverse.com/en/gift",
inGame:
"Paimon menu → Settings → Account → Redeem Code (Adventure Rank 10+)",
source: "https://game8.co/games/Genshin-Impact/archives/304759",
checked: "2026-10-18",
},
hsr: {
url: "https://hsr.hoyoverse.com/gift",
inGame: "Menu → ··· → Redemption Code",
source: "https://game8.co/games/Honkai-Star-Rail/archives/410296",
checked: "2026-10-18",
},
zzz: {
url: "https://zenless.hoyoverse.com/redemption",
inGame: "Menu → More → Redemption Code",
source: "https://game8.co/games/Zenless-Zone-Zero/archives/435683",
checked: "2026-10-18",
},
wuwa: {
inGame: "Settings → Other Settings → Redemption Code",
source: "https://game8.co/games/Wuthering-Waves/archives/453149",
checked: "2026-10-18",
},
endfield: {
inGame:
"Menu → Settings → Platform & Account → Exchange Code (after the tutorial unlocks Mail)",
source: "https://game8.co/games/Arknights-Endfield/archives/571509",
checked: "2026-10-18",
},
nte: {
inGame: "Menu → [...] → Redeem Code",
source: "https://game8.co/games/Neverness-to-Everness/archives/593718",
checked: "2026-10-18",
},
p5x: {
inGame: "Menu → Exchange Code",
source: "https://game8.co/games/Persona-5-Phantom-X/archives/531770",
checked: "2026-10-18",
},
czn: {
inGame: "Menu → Settings → Account Settings → Register Coupon",
source: "https://game8.co/games/Chaos-Zero-Nightmare/archives/557967",
checked: "2026-10-18",
},
uma: {
inGame: "Menu → Enter Code",
source: "https://game8.co/games/Umamusume-Pretty-Derby/archives/536130",
checked: "2026-10-18",
},
};
+78
View File
@@ -0,0 +1,78 @@
import { expect, test } from "bun:test";
import { CODE_SOURCES } from "../../src/ingest/codes/registry.ts";
import { parseGame8CodeTable } from "../../src/ingest/codes/game8.ts";
const now = "2026-10-18T12:00:00.000Z";
// Each page pinned 2026-10-18; expected files were spot-checked against the
// live pages (Star Rail's six codes, WUTHERINGGIFT, Endfield's ENDFIELDSTEAM /
// ENDFIELDGIFT / ENDFIELD4PC, CZN's NIGHTMARECARNIVAL, P5X's WEBSTORE1YEAR).
for (const source of CODE_SOURCES.filter((s) => s.game !== "genshin")) {
test(`${source.id} reads the page's active codes`, async () => {
const html = await Bun.file(`${source.fixture}.html`).text();
const expected = await Bun.file(`${source.fixture}.expected.json`).json();
expect(
source.parse(html, {
now,
sourceId: source.id,
sourceUrl: source.url,
game: source.game,
}),
).toEqual(expected);
});
}
test("an expired table inside the active section is not read as current", async () => {
const wuwa = CODE_SOURCES.find((s) => s.game === "wuwa")!;
const html = await Bun.file(`${wuwa.fixture}.html`).text();
const codes = wuwa.parse(html, {
now,
sourceId: wuwa.id,
sourceUrl: wuwa.url,
game: "wuwa",
});
expect(codes.map((c) => c.code)).toEqual(["WUTHERINGGIFT"]);
});
test("a page that says it has no codes is an empty read it may store; a changed page is not", async () => {
const uma = CODE_SOURCES.find((s) => s.game === "uma")!;
const html = await Bun.file(`${uma.fixture}.html`).text();
expect(
uma.parse(html, { now, sourceId: uma.id, sourceUrl: uma.url, game: "uma" }),
).toEqual([]);
expect("statesNoEvents" in uma && uma.statesNoEvents(html)).toBe(true);
expect(() =>
parseGame8CodeTable(
"<h2>Something else</h2>",
{
now,
sourceId: "x",
sourceUrl: "https://example.com",
game: "hsr",
},
/^Active Redeem Codes for/i,
),
).toThrow("could not be identified");
});
test("a reward shared with the code's cell, or listed with bullets, reads as text", () => {
const html =
"<h3>Codes</h3><table><tr><th>Date Added</th><th>Code &amp; Reward</th></tr>" +
"<tr><td>09/24/2026</td><td><input class='a-clipboard__textInput' value='ONE'> Copied 100 Stamps</td></tr>" +
"<tr><td><input class='a-clipboard__textInput' value='TWO'> Copied</td><td>・ Fons x10 ・ Coin x5</td></tr></table>";
const codes = parseGame8CodeTable(
html,
{
now,
sourceId: "x",
sourceUrl: "https://example.com",
game: "nte",
},
/^Codes$/,
);
expect(codes.map((c) => [c.code, c.reward])).toEqual([
["ONE", "100 Stamps"],
["TWO", "Fons x10, Coin x5"],
]);
expect(codes.every((c) => c.expiresAt === null)).toBe(true);
});
+61
View File
@@ -39,3 +39,64 @@ test("redemption controls identify each server and unknown expiry stays unknown"
expect(html).toContain("Expiry not announced");
expect(html).toContain('href="https://example.com"');
});
import { REDEEM } from "../src/shared/redeem.ts";
test("every game with a codes source says where to redeem, linking only official pages", async () => {
const { CODE_SOURCES } = await import("../src/ingest/codes/registry.ts");
for (const s of CODE_SOURCES) {
const r = REDEEM[s.game];
expect(r?.inGame.length ?? 0).toBeGreaterThan(0);
if (r?.url)
expect(new URL(r.url).hostname).toMatch(/(^|\.)hoyoverse\.com$/);
}
});
test("the Codes tab links the official redeem page, or gives the in-game route", () => {
const at = "2026-10-18T12:00:00.000Z";
const feed = { schemaVersion: 1 as const, codes: [], sources: [] };
const page = (game: string) =>
render(
<CodesPage
game={game}
state={migrateLegacy({}, [game as "genshin"], at, () => "account:t")}
onAction={async () => ({ ok: true })}
feed={feed}
/>,
);
const genshin = page("genshin");
expect(genshin).toContain('href="https://genshin.hoyoverse.com/en/gift"');
expect(genshin).toContain("Or in game:");
const wuwa = page("wuwa");
expect(wuwa).not.toContain("Redeem on the official site");
expect(wuwa).toContain("Redeem in game:");
expect(wuwa).toContain("Other Settings");
});
test("with one server, a code's checkbox does not name it", () => {
const at = "2026-10-18T12:00:00.000Z";
const html = render(
<CodesPage
game="hsr"
state={migrateLegacy({}, ["hsr"], at, () => "account:one")}
onAction={async () => ({ ok: true })}
feed={{
schemaVersion: 1,
sources: [],
codes: [
{
id: '["hsr","OMEGA"]',
game: "hsr",
code: "OMEGA",
reward: "Stellar Jade x60",
expiresAt: null,
firstObservedAt: at,
sourceUrl:
"https://game8.co/games/Honkai-Star-Rail/archives/410296",
},
],
}}
/>,
);
expect(html).toContain("Redeemed");
expect(html).not.toContain("Redeemed · ");
});