Implement Channel Points Twungeon MVP

This commit is contained in:
2026-08-17 07:16:13 -07:00
parent 21b845d16e
commit 222cf903f6
33 changed files with 4399 additions and 75 deletions
+13
View File
@@ -0,0 +1,13 @@
# Runtime
PORT=3000
PUBLIC_BASE_URL=http://localhost:3000
CHANNEL_POINTS_RESURRECTION_REWARD_ID=
# Twitch server-only configuration (required when TWITCH_ENABLED=true)
TWITCH_ENABLED=false
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
TWITCH_BROADCASTER_ID=
TWITCH_CHANNEL_LOGIN=
TWITCH_BOT_ACCESS_TOKEN=
TWITCH_EXTENSION_SECRET=
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
coverage/
.env
.env.*
!.env.example
*.pem
*.key
*.log
+7 -6
View File
@@ -6,9 +6,10 @@ This repository owns the public project page and development log for Twungeon.
- `.labyricorn/devlog/contents.lr` is the development-log index. - `.labyricorn/devlog/contents.lr` is the development-log index.
- Each directory below `.labyricorn/devlog/` is one chronological entry. - Each directory below `.labyricorn/devlog/` is one chronological entry.
The project is currently in pre-implementation planning. The PRD and MVP The project now has a runnable local proof of concept backed by an authoritative
definition are complete, the technical specification is approved, and a TypeScript game core, deterministic floor generation, production-shaped APIs,
traceable acceptance-test/build checklist now defines the route into and shared broadcast/controller views. Its Twitch boundary supports chat,
implementation. Runtime behavior and live Twitch integration are not yet follower checks, Extension identity, and a configured Channel Points
claimed complete. Future work should add dated devlog entries as the project resurrection reward. Automated checks cover the implemented local behavior;
moves through implementation, testing, release, and maintenance. live-channel, independent-operator, usability, and real-viewer acceptance remain
future evidence. Add dated devlog entries as those milestones occur.
+16 -13
View File
@@ -18,7 +18,7 @@ repository_url: https://git.labyricorn.com/Labyricorn/Twungeon
--- ---
default_branch: main default_branch: main
--- ---
tags: Twitch, Twitch Extension, RPGJS, Twurple, TypeScript, game design, pre-development, PRD, MVP, planning, architecture, acceptance testing, documentation tags: Twitch, Twitch Extension, Channel Points, RPGJS, Twurple, TypeScript, game development, proof of concept, MVP, architecture, acceptance testing, documentation
--- ---
body: body:
@@ -30,20 +30,22 @@ exit.
The MVP is designed to prove the complete interaction loop with real Twitch The MVP is designed to prove the complete interaction loop with real Twitch
viewers: eligibility, identity binding, spawning, movement, combat, healing, viewers: eligibility, identity binding, spawning, movement, combat, healing,
death, Bits resurrection, floor advancement, and total-party reset. It uses a death, Channel Points resurrection, floor advancement, and total-party reset.
deliberately small two-room floor with one Goblin Guard so the project can test It uses a deliberately small two-room floor with one Goblin Guard so the
the social game idea before investing in balance, polish, progression, or project can test the social game idea before investing in balance, polish,
production art. progression, or production art.
The stream-facing experience has three required regions: a compact The stream-facing experience has three required regions: a compact
control/status area, the shared game view, and a running action log. When no control/status area, the shared game view, and a running action log. When no
players are active, the current floor remains loaded in a dormant state and players are active, the current floor remains loaded in a dormant state and
invites an eligible follower to type `!spawn`. invites an eligible follower to type `!spawn`.
The initial technical direction is RPGJS for the prototype game environment, The runnable proof of concept uses an authoritative TypeScript game core,
Twurple for Twitch integration, and a Twitch Extension for player controls. deterministic two-room floor generation, HTTP and WebSocket boundaries, and a
The design keeps Twungeon's game rules separate from RPGJS where practical so shared broadcast/controller interface. Twurple supplies the production Twitch
the prototype can be replaced without discarding the validated concept. boundary for chat, follower verification, Channel Points redemptions, and a
Twitch Extension identity flow. The RPGJS boundary remains an authority-free
render adapter rather than a completed engine integration.
The product source of truth is the The product source of truth is the
[Twungeon MVP Product Requirements Document](https://git.labyricorn.com/Labyricorn/Twungeon/blob/main/Twungeon_MVP_PRD_Current.md). [Twungeon MVP Product Requirements Document](https://git.labyricorn.com/Labyricorn/Twungeon/blob/main/Twungeon_MVP_PRD_Current.md).
@@ -53,7 +55,8 @@ defines the authoritative architecture and game behavior. The
[acceptance-test and build checklist](https://git.labyricorn.com/Labyricorn/Twungeon/blob/main/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md) [acceptance-test and build checklist](https://git.labyricorn.com/Labyricorn/Twungeon/blob/main/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md)
sequences implementation and maps evidence to all 33 MVP success criteria. sequences implementation and maps evidence to all 33 MVP success criteria.
The project remains in pre-implementation planning. These documents establish The repository can run locally with synthetic viewer identities and has passing
the build baseline, but they do not claim that runtime behavior, Twitch lint, type-check, domain, integration, and production-build gates. This is a
integration, or viewer validation is complete. Future records will document proof of concept, not an accepted MVP: live Twitch credentials, independent
implementation, testing, release preparation, and maintenance as they occur. setup, first-time-viewer usability, and real-viewer concept validation have not
yet produced acceptance evidence.
+35 -7
View File
@@ -6,10 +6,37 @@ characters with a Twitch Extension.
## Project status ## Project status
Twungeon is in pre-implementation planning. The MVP product requirements and Twungeon now has a runnable MVP proof of concept. It includes an authoritative
technical specification are approved, and the acceptance-test/build checklist TypeScript game core, deterministic two-room dungeon generation, HTTP and
is ready to guide implementation. No runtime MVP behavior or live Twitch WebSocket APIs, a shared broadcast/Extension UI, synthetic local Twitch events,
integration is claimed complete yet. and a Twurple production boundary for chat, followers, Channel Points, and
Extension identity. Live Twitch acceptance still requires operator credentials, an
approved Extension configuration, and real-viewer evidence.
## Quick start
Requirements: Node.js 22 or newer and npm.
```bash
npm install
npm run dev
```
Open `http://localhost:3000`. Expand **Local viewer login**, then choose
**Spawn & bind** to exercise the complete local controller loop without Twitch.
Quality gates:
```bash
npm run lint
npm run typecheck
npm test
npm run test:integration
npm run build
```
Production startup uses `npm run build` followed by `npm start`. Configuration
is described in [.env.example](.env.example) and [docs/twitch-setup.md](docs/twitch-setup.md).
## Planning documents ## Planning documents
@@ -27,8 +54,9 @@ must be updated alongside any approved requirement change.
## Initial technical direction ## Initial technical direction
The MVP is planned around TypeScript, RPGJS, Twurple, and a Twitch Extension, The implementation uses TypeScript and Twurple, with game rules isolated from
with Twungeon's game rules isolated from framework and service adapters where framework and service adapters. The current RPGJS package is an authority-free
practical. render-model adapter; adopting RPGJS engine rendering remains a presentation
integration task and cannot move rules out of the domain package.
Public project and development-log records are maintained under `.labyricorn/`. Public project and development-log records are maintained under `.labyricorn/`.
@@ -100,7 +100,8 @@ Every implementation phase closes with:
- [ ] Confirm the repository package manager and TypeScript workspace tooling. - [ ] Confirm the repository package manager and TypeScript workspace tooling.
- [ ] Confirm access to a Twitch development channel, developer application, - [ ] Confirm access to a Twitch development channel, developer application,
Extension configuration, and test viewer identities. Extension configuration, and test viewer identities.
- [ ] Select a configurable Bits resurrection price for live testing. - [ ] Create a Channel Points resurrection reward and record its stable reward
ID for live testing.
- [ ] Record implementation-time Twitch/Twurple versions and verify their exact - [ ] Record implementation-time Twitch/Twurple versions and verify their exact
scopes, event types, authentication requirements, and transport setup against scopes, event types, authentication requirements, and transport setup against
official documentation. official documentation.
@@ -283,22 +284,23 @@ independent controls, and observe the same authoritative dungeon and log.
**Checkpoint P8:** A follower can spawn and control only their own character; **Checkpoint P8:** A follower can spawn and control only their own character;
non-followers and identity mismatches cannot acquire control. non-followers and identity mismatches cannot acquire control.
### Phase 9: Bits resurrection ### Phase 9: Channel Points resurrection
**Goal:** Complete the Twitch-backed death recovery branch. **Goal:** Complete the Twitch-backed death recovery branch.
- [ ] Configure the approved positive Bits price outside domain code. - [ ] Configure the approved Channel Points custom reward ID outside domain code.
- [ ] Subscribe to and normalize the selected Twitch Bits event. - [ ] Subscribe to and normalize that reward's redemption EventSub event.
- [ ] Deduplicate by stable external event ID. - [ ] Deduplicate by stable redemption ID.
- [ ] Map the event's stable Twitch user ID to the correct dead character. - [ ] Map the event's stable Twitch user ID to the correct dead character.
- [ ] Apply resurrection only when the configured amount/rule is satisfied. - [ ] Apply resurrection only when the configured reward ID matches.
- [ ] Reject duplicate, anonymous, insufficient, living-user, and unknown-user - [ ] Reject duplicate, wrong-reward, living-user, and unknown-user
events without mutating game state. events without mutating game state.
- [ ] Confirm resurrection position, HP, AP timing, and heal preservation. - [ ] Confirm resurrection position, HP, AP timing, and heal preservation.
- [ ] Log a successful resurrection and safe diagnostics for rejection. - [ ] Log a successful resurrection and safe diagnostics for rejection.
**Checkpoint P9:** A real Bits event resurrects exactly the intended dead player **Checkpoint P9:** A real Channel Points redemption resurrects exactly the
once, without permitting cross-user or duplicate resurrection. intended dead player once, without permitting cross-user or duplicate
resurrection.
### Phase 10: Operator documentation and acceptance campaign ### Phase 10: Operator documentation and acceptance campaign
@@ -312,7 +314,7 @@ without undocumented developer intervention.
scopes, environment values, URLs, HTTPS/public endpoints, and startup order. scopes, environment values, URLs, HTTPS/public endpoints, and startup order.
- [ ] Document safe secret separation and rotation. - [ ] Document safe secret separation and rotation.
- [ ] Document event-flow verification and common authentication, EventSub, - [ ] Document event-flow verification and common authentication, EventSub,
Extension, identity-binding, follower-check, and Bits failures. Extension, identity-binding, follower-check, and Channel Points failures.
- [ ] Have a second operator connect a clean checkout using only documentation. - [ ] Have a second operator connect a clean checkout using only documentation.
- [ ] Run all automated quality gates from that clean checkout. - [ ] Run all automated quality gates from that clean checkout.
- [ ] Execute AT-001 through AT-030 and capture evidence. - [ ] Execute AT-001 through AT-030 and capture evidence.
@@ -587,18 +589,18 @@ successful connection logs.
**Level:** Automated integration plus live spot check **Level:** Automated integration plus live spot check
**Pass evidence:** Rejection log and unchanged player record. **Pass evidence:** Rejection log and unchanged player record.
### AT-025 — Correct-player Bits resurrection ### AT-025 — Correct-player Channel Points resurrection
**PRD criterion:** 25 **PRD criterion:** 25
- [ ] With at least two players and one dead, send a qualifying live Bits event - [ ] With at least two players and one dead, redeem the configured Channel
from the dead viewer. Points reward from the dead viewer.
- [ ] Confirm exactly that character returns at spawn with full HP, preserved - [ ] Confirm exactly that character returns at spawn with full HP, preserved
heal availability, and AP beginning next Player Phase. heal availability, and AP beginning next Player Phase.
- [ ] Confirm duplicate, insufficient, living-user, and other-user events do not - [ ] Confirm duplicate, wrong-reward, living-user, and other-user events do not
mutate the dead character. mutate the dead character.
**Level:** Automated integration plus live Bits event **Level:** Automated integration plus live Channel Points redemption
**Pass evidence:** Redacted event, deduplication log, and before/after state. **Pass evidence:** Redacted event, deduplication log, and before/after state.
### AT-026 — Total-party wipe reset ### AT-026 — Total-party wipe reset
@@ -677,7 +679,8 @@ successful connection logs.
- [ ] Have a second operator use the documentation to diagnose controlled - [ ] Have a second operator use the documentation to diagnose controlled
failures in authentication, Twitch events/transport, Extension loading, failures in authentication, Twitch events/transport, Extension loading,
identity mismatch, configuration, follower verification, and Bits handling. identity mismatch, configuration, follower verification, and Channel Points
handling.
- [ ] Confirm each failure has a discoverable symptom, diagnostic, and remedy. - [ ] Confirm each failure has a discoverable symptom, diagnostic, and remedy.
**Level:** Independent documentation fault-injection review **Level:** Independent documentation fault-injection review
@@ -707,7 +710,7 @@ Run acceptance in increasing order of cost and external dependency:
3. Backend/API integration tests with fake Twitch adapters. 3. Backend/API integration tests with fake Twitch adapters.
4. Browser E2E tests with synthetic authenticated identities. 4. Browser E2E tests with synthetic authenticated identities.
5. Clean-checkout and independent-operator setup tests. 5. Clean-checkout and independent-operator setup tests.
6. Twitch development-channel tests for chat, followers, Extension, and Bits. 6. Twitch development-channel tests for chat, followers, Extension, and Channel Points.
7. Multi-viewer complete-loop and soak sessions. 7. Multi-viewer complete-loop and soak sessions.
8. First-time-viewer and concept-validation sessions. 8. First-time-viewer and concept-validation sessions.
+12 -10
View File
@@ -35,7 +35,7 @@ The MVP should demonstrate that multiple Twitch viewers can:
- encounter and fight a basic enemy, - encounter and fight a basic enemy,
- heal themselves, - heal themselves,
- die and remain inactive for the current floor, - die and remain inactive for the current floor,
- resurrect through Bits, - resurrect through a configured Channel Points reward,
- advance the entire party by reaching the exit, - advance the entire party by reaching the exit,
- revive dead players on floor advancement, - revive dead players on floor advancement,
- and reset the run when all participating players die. - and reset the run when all participating players die.
@@ -81,7 +81,7 @@ When a valid `!spawn` command is received:
Only one active character may exist per Twitch user. Only one active character may exist per Twitch user.
A follower who has died on the current floor cannot use `!spawn` to bypass death. That player must wait for either a Bits resurrection or advancement to the next floor. A follower who has died on the current floor cannot use `!spawn` to bypass death. That player must wait for either a Channel Points resurrection or advancement to the next floor.
### 3.4 Twitch Identity Binding ### 3.4 Twitch Identity Binding
@@ -153,7 +153,7 @@ It must provide clear confirmation of significant game actions and state changes
- AutoGuard blocks, - AutoGuard blocks,
- self-heal use, - self-heal use,
- player death, - player death,
- Bits resurrection, - Channel Points resurrection,
- Goblin aggro or return behavior when useful, - Goblin aggro or return behavior when useful,
- floor advancement, - floor advancement,
- dormant-state changes, - dormant-state changes,
@@ -489,22 +489,24 @@ There is no timed automatic resurrection.
A dead player returns to play only through: A dead player returns to play only through:
1. immediate Bits resurrection, or 1. immediate Channel Points resurrection, or
2. another living player reaching the next floor. 2. another living player reaching the next floor.
--- ---
## 17. Bits Resurrection ## 17. Channel Points Resurrection
A dead player may use the Twitch Bits mechanism to resurrect immediately during the current floor. A dead player may redeem the configured Twitch Channel Points custom reward to resurrect immediately during the current floor.
Upon successful Bits resurrection: Upon successful Channel Points redemption:
- the player's dead state is removed, - the player's dead state is removed,
- the player returns to active play on the current floor, - the player returns to active play on the current floor,
- and the player may participate in subsequent Player Phases. - and the player may participate in subsequent Player Phases.
The exact Bits price is not required to be finalized before implementation of the core MVP. The broadcaster configures the reward cost in Twitch. Twungeon identifies the reward by its stable reward ID and does not duplicate or override its cost in domain code.
Bits and Cheers are outside the MVP and must not trigger game-state changes.
--- ---
@@ -572,7 +574,7 @@ The setup documentation must cover at minimum:
- follower eligibility verification, - follower eligibility verification,
- receiving and processing `!spawn`, - receiving and processing `!spawn`,
- chat-to-Extension Twitch identity binding, - chat-to-Extension Twitch identity binding,
- Bits resurrection event setup, - Channel Points custom reward and redemption EventSub setup,
- separation and protection of secrets, - separation and protection of secrets,
- local/development testing, - local/development testing,
- live-channel testing, - live-channel testing,
@@ -671,7 +673,7 @@ The MVP is successful if it demonstrates that:
22. A surviving player can reach the exit without necessarily killing the Goblin and advance the entire group. 22. A surviving player can reach the exit without necessarily killing the Goblin and advance the entire group.
23. Dead players return when the floor advances. 23. Dead players return when the floor advances.
24. A player who died on the current floor cannot bypass death by using `!spawn`. 24. A player who died on the current floor cannot bypass death by using `!spawn`.
25. Bits can resurrect the correct dead player during the current floor. 25. The configured Channel Points reward can resurrect the correct dead player during the current floor.
26. A total-party wipe resets the run to Floor 1. 26. A total-party wipe resets the run to Floor 1.
27. Zero active players is distinguished from a total-party wipe. 27. Zero active players is distinguished from a total-party wipe.
28. An eligible follower who has not died on the current floor can `!spawn` into an otherwise empty current floor. 28. An eligible follower who has not died on the current floor can `!spawn` into an otherwise empty current floor.
+24 -22
View File
@@ -120,7 +120,7 @@ The Twitch adapter uses Twurple to:
- receive chat messages needed for `!spawn`; - receive chat messages needed for `!spawn`;
- extract the stable Twitch user ID and display data from the chat event; - extract the stable Twitch user ID and display data from the chat event;
- verify follower eligibility using the configured broadcaster identity; - verify follower eligibility using the configured broadcaster identity;
- receive the Bits event selected for resurrection; - receive the configured Channel Points custom reward redemption event;
- normalize Twitch events into internal messages; and - normalize Twitch events into internal messages; and
- reconnect without submitting the same external event more than once. - reconnect without submitting the same external event more than once.
@@ -537,12 +537,12 @@ When HP reaches 0:
The dead character remains associated with the Twitch user ID and cannot be The dead character remains associated with the Twitch user ID and cannot be
replaced with `!spawn`. replaced with `!spawn`.
### 10.4 Bits resurrection ### 10.4 Channel Points resurrection
The Bits price is a required positive configuration value and may change The Channel Points custom reward ID is required configuration and may change
without changing domain code. An accepted resurrection event must identify the without changing domain code. Its cost is owned by Twitch. An accepted
same Twitch user ID as the dead character, satisfy the configured transaction resurrection event must identify the configured reward, the same Twitch user ID
rule, and have a unique external event ID. as the dead character, and a unique redemption ID.
Resurrection: Resurrection:
@@ -553,8 +553,9 @@ Resurrection:
- grants 0 AP during the current phase; and - grants 0 AP during the current phase; and
- enables action beginning with the next Player Phase. - enables action beginning with the next Player Phase.
Extra, duplicate, anonymous, insufficient, or non-dead-user events do not alter Other-reward, duplicate, unknown-user, or non-dead-user events do not alter game
game state and must produce a diagnostic result without exposing secrets. state and must produce a diagnostic result without exposing secrets. Bits and
Cheers are outside the MVP and are not normalized into domain messages.
### 10.5 Refresh and reconnect ### 10.5 Refresh and reconnect
@@ -648,8 +649,9 @@ alive, the backend immediately:
9. logs the wipe and reset; and 9. logs the wipe and reset; and
10. begins a fresh Player Phase. 10. begins a fresh Player Phase.
Because the reset is immediate, a Bits event cannot interrupt a completed Because the reset is immediate, a Channel Points redemption cannot interrupt a
total-party wipe. Zero participating characters is explicitly not a wipe. completed total-party wipe. Zero participating characters is explicitly not a
wipe.
## 13. Randomness ## 13. Randomness
@@ -732,10 +734,10 @@ type TwitchMessage =
broadcasterId: string broadcasterId: string
} }
| { | {
type: 'bits-resurrection-received' type: 'channel-point-resurrection-redeemed'
externalEventId: string externalEventId: string
twitchUserId: string twitchUserId: string
amount: number rewardId: string
} }
``` ```
@@ -776,8 +778,8 @@ When phase is dormant, both applicable views prominently display exactly:
### 16.1 Twitch unavailable ### 16.1 Twitch unavailable
- Existing authenticated gameplay may continue if the backend remains healthy. - Existing authenticated gameplay may continue if the backend remains healthy.
- New spawn, new identity binding, and Bits events fail closed when they cannot - New spawn, new identity binding, and Channel Points redemptions fail closed
be verified. when they cannot be verified.
- The operator view and logs identify the unavailable integration. - The operator view and logs identify the unavailable integration.
- Reconnect uses bounded exponential backoff and does not duplicate events. - Reconnect uses bounded exponential backoff and does not duplicate events.
@@ -813,7 +815,7 @@ Structured operational logs must distinguish:
- accepted and rejected spawn attempts; - accepted and rejected spawn attempts;
- accepted and rejected controller commands; - accepted and rejected controller commands;
- phase transitions and timer expiry; - phase transitions and timer expiry;
- Bits-event deduplication and resurrection results; - Channel Points redemption deduplication and resurrection results;
- floor-generation seed and validation result; - floor-generation seed and validation result;
- reconnect and snapshot resynchronization; and - reconnect and snapshot resynchronization; and
- run reset or fatal errors. - run reset or fatal errors.
@@ -832,7 +834,7 @@ credentials and full authentication payloads.
|-- packages/ |-- packages/
| |-- domain/ # Framework-independent game state and rules | |-- domain/ # Framework-independent game state and rules
| |-- dungeon-generator/ # Seeded two-room generation and validation | |-- dungeon-generator/ # Seeded two-room generation and validation
| |-- twitch-adapter/ # Twurple chat, follower, and Bits integration | |-- twitch-adapter/ # Twurple chat, follower, and Channel Points integration
| |-- rpgjs-adapter/ # Mapping between domain state and RPGJS | |-- rpgjs-adapter/ # Mapping between domain state and RPGJS
| |-- contracts/ # Shared validated API/event schemas | |-- contracts/ # Shared validated API/event schemas
| `-- ui/ # Shared layout, status, controls, and log components | `-- ui/ # Shared layout, status, controls, and log components
@@ -894,7 +896,7 @@ Integration tests use test doubles before live Twitch testing and cover:
- two authenticated viewers controlling separate characters; - two authenticated viewers controlling separate characters;
- refresh/reconnect without duplication; - refresh/reconnect without duplication;
- Twitch event and command deduplication; - Twitch event and command deduplication;
- Bits event mapped to the correct dead character; - Channel Points redemption mapped to the correct dead character;
- backend snapshots, ordered events, and sequence-gap recovery; and - backend snapshots, ordered events, and sequence-gap recovery; and
- RPGJS rendering updates without domain authority leakage. - RPGJS rendering updates without domain authority leakage.
@@ -909,7 +911,7 @@ At minimum, the live test plan must demonstrate:
5. two or more simultaneous viewer characters; 5. two or more simultaneous viewer characters;
6. movement, two attacks, heal, pass, timer expiry, and AutoGuard; 6. movement, two attacks, heal, pass, timer expiry, and AutoGuard;
7. Goblin aggro, pursuit, attack, target death, and return; 7. Goblin aggro, pursuit, attack, target death, and return;
8. Bits resurrection of the correct dead viewer; 8. Channel Points resurrection of the correct dead viewer;
9. floor escape with the Goblin alive and dead-player revival; 9. floor escape with the Goblin alive and dead-player revival;
10. total-party wipe and Floor 1 reset; 10. total-party wipe and Floor 1 reset;
11. refresh and temporary disconnect recovery; and 11. refresh and temporary disconnect recovery; and
@@ -923,7 +925,7 @@ At minimum, the live test plan must demonstrate:
| 7-13 | Dormancy, spawn eligibility, duplicate prevention, and identity integration tests | | 7-13 | Dormancy, spawn eligibility, duplicate prevention, and identity integration tests |
| 14-19 | Controller, phase, timer, AP, combat, heal, death, Guard, and log tests | | 14-19 | Controller, phase, timer, AP, combat, heal, death, Guard, and log tests |
| 20-22 | Goblin AI, generator validation, and live-Goblin escape tests | | 20-22 | Goblin AI, generator validation, and live-Goblin escape tests |
| 23-29 | Floor revival, death lockout, Bits, wipe, zero-player, late spawn, and reconnect tests | | 23-29 | Floor revival, death lockout, Channel Points, wipe, zero-player, late spawn, and reconnect tests |
| 30-33 | Multi-floor soak test, first-time-viewer test, setup troubleshooting review, and viewer-feedback session | | 30-33 | Multi-floor soak test, first-time-viewer test, setup troubleshooting review, and viewer-feedback session |
The test implementation should reference individual PRD criterion numbers in The test implementation should reference individual PRD criterion numbers in
@@ -945,7 +947,7 @@ slices:
6. Add Twurple chat and follower verification for `!spawn`. 6. Add Twurple chat and follower verification for `!spawn`.
7. Add Extension authentication, identity binding, controller commands, and 7. Add Extension authentication, identity binding, controller commands, and
personalized state. personalized state.
8. Add the Twitch Bits adapter and configurable resurrection rule. 8. Add the Twitch Channel Points redemption adapter and configured reward rule.
9. Complete clean-checkout Twitch setup and troubleshooting documentation. 9. Complete clean-checkout Twitch setup and troubleshooting documentation.
10. Run automated, live-channel, multi-viewer, and repeated-floor acceptance 10. Run automated, live-channel, multi-viewer, and repeated-floor acceptance
tests against all 33 PRD criteria. tests against all 33 PRD criteria.
@@ -955,7 +957,7 @@ slices:
The following are intentionally configurable or version-dependent and do not The following are intentionally configurable or version-dependent and do not
change the domain design: change the domain design:
- exact Bits resurrection price; - Channel Points resurrection reward ID and broadcaster-managed cost;
- concrete Twitch developer application and Extension identifiers; - concrete Twitch developer application and Extension identifiers;
- exact Twitch scopes, EventSub event names, and Twurple configuration required - exact Twitch scopes, EventSub event names, and Twurple configuration required
by the versions selected at implementation time; by the versions selected at implementation time;
@@ -983,7 +985,7 @@ Resolved behavior choices:
3. The backend serializes commands by authoritative arrival order. 3. The backend serializes commands by authoritative arrival order.
4. The arrival timestamp at the backend gateway decides timer-boundary commands. 4. The arrival timestamp at the backend gateway decides timer-boundary commands.
5. Run state survives client reconnects but not backend process restarts. 5. Run state survives client reconnects but not backend process restarts.
6. Bits resurrection restores full HP at spawn and grants AP next Player Phase. 6. Channel Points resurrection restores full HP at spawn and grants AP next Player Phase.
7. Broadcast and Extension modes share presentation, but only the authenticated 7. Broadcast and Extension modes share presentation, but only the authenticated
Extension is interactive. Extension is interactive.
8. The backend domain core is the sole authority for every state transition. 8. The backend domain core is the sole authority for every state transition.
+10
View File
@@ -0,0 +1,10 @@
export interface Config { port:number; publicBaseUrl:string; resurrectionRewardId:string; twitchEnabled:boolean; broadcasterId:string; channelLogin:string; twitchClientId:string; twitchAccessToken:string; extensionSecret:string }
export function loadConfig(env:NodeJS.ProcessEnv=process.env):Config{
const port=Number(env.PORT??3000),twitchEnabled=env.TWITCH_ENABLED==='true',resurrectionRewardId=env.CHANNEL_POINTS_RESURRECTION_REWARD_ID??'local-resurrection'
const missing:string[]=[]
if(!Number.isInteger(port)||port<1||port>65535)throw new Error('PORT must be an integer from 1 to 65535')
if(!resurrectionRewardId.trim())throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required')
if(twitchEnabled)for(const key of ['TWITCH_CLIENT_ID','TWITCH_CLIENT_SECRET','TWITCH_BROADCASTER_ID','TWITCH_CHANNEL_LOGIN','TWITCH_BOT_ACCESS_TOKEN','TWITCH_EXTENSION_SECRET','CHANNEL_POINTS_RESURRECTION_REWARD_ID'])if(!env[key])missing.push(key)
if(missing.length)throw new Error(`Missing required Twitch configuration: ${missing.join(', ')}`)
return {port,publicBaseUrl:env.PUBLIC_BASE_URL??`http://localhost:${port}`,resurrectionRewardId,twitchEnabled,broadcasterId:env.TWITCH_BROADCASTER_ID??'local-broadcaster',channelLogin:env.TWITCH_CHANNEL_LOGIN??'local-channel',twitchClientId:env.TWITCH_CLIENT_ID??'',twitchAccessToken:env.TWITCH_BOT_ACCESS_TOKEN??'',extensionSecret:env.TWITCH_EXTENSION_SECRET??''}
}
+54
View File
@@ -0,0 +1,54 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { WebSocketServer, WebSocket } from 'ws'
import { CommandEnvelopeSchema } from '../../../packages/contracts/src/index.js'
import { Game } from '../../../packages/domain/src/index.js'
import { generateFloor } from '../../../packages/dungeon-generator/src/index.js'
import { LiveTwitchAdapter, SyntheticTwitchAdapter } from '../../../packages/twitch-adapter/src/index.js'
import { loadConfig } from './config.js'
const config=loadConfig(), startedAt=Date.now();let id=0
const game=new Game({clock:{now:()=>Date.now()},random:{next:()=>Math.random()},ids:{next:p=>`${p}-${++id}`},generateFloor,resurrectionRewardId:config.resurrectionRewardId})
const twitch=config.twitchEnabled?new LiveTwitchAdapter({clientId:config.twitchClientId,accessToken:config.twitchAccessToken,broadcasterId:config.broadcasterId,channelLogin:config.channelLogin,extensionSecret:config.extensionSecret,resurrectionRewardId:config.resurrectionRewardId}):new SyntheticTwitchAdapter(config.broadcasterId)
const sessions=new Map<string,{userId:string;expiresAt:number}>()
const clients=new Map<WebSocket,string|null>()
const publicDir=join(dirname(fileURLToPath(import.meta.url)),'../../stream-view/public')
function json(res:ServerResponse,status:number,body:unknown){const data=JSON.stringify(body);res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(data)}
async function body(req:IncomingMessage):Promise<unknown>{const chunks:Buffer[]=[];for await(const chunk of req)chunks.push(Buffer.from(chunk));if(chunks.reduce((n,b)=>n+b.length,0)>64_000)throw new Error('Request too large');return JSON.parse(Buffer.concat(chunks).toString('utf8')||'{}')}
function bearer(req:IncomingMessage):string|null{const h=req.headers.authorization;return h?.startsWith('Bearer ')?h.slice(7):null}
function sessionUser(token:string|null):string|null{if(!token)return null;const session=sessions.get(token);if(!session||session.expiresAt<=Date.now()){if(session)sessions.delete(token);return null}return session.userId}
function broadcast(){const sequence=game.snapshot().nextEventSequence-1;for(const [ws,userId] of clients)if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({type:'snapshot',sequence,state:userId?game.personalizedSnapshot(userId):game.snapshot()}))}
export const server=createServer(async(req,res)=>{
try{
const url=new URL(req.url??'/',config.publicBaseUrl)
if(req.method==='GET'&&url.pathname==='/health')return json(res,200,{status:'ok',ready:twitch.ready,uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchMode:config.twitchEnabled?'configured':'synthetic'})
if(req.method==='GET'&&url.pathname==='/api/state')return json(res,200,game.snapshot())
if(req.method==='POST'&&url.pathname==='/api/extension/session'){
const data=await body(req) as any,identity=await twitch.verifyExtensionToken(String(data.token??''))
if(!identity)return json(res,401,{error:'UNAUTHENTICATED'})
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId)
return json(res,200,{token,twitchUserId:identity.twitchUserId,state:game.personalizedSnapshot(identity.twitchUserId)})
}
if(req.method==='POST'&&url.pathname==='/api/commands'){
const token=bearer(req),userId=sessionUser(token),parsed=CommandEnvelopeSchema.safeParse(await body(req))
if(!parsed.success)return json(res,400,{error:'INVALID_COMMAND',issues:parsed.error.issues.map(i=>({path:i.path,message:i.message}))})
const result=game.command(userId,Boolean(userId),parsed.data);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)
}
if(req.method==='POST'&&url.pathname==='/api/dev/spawn'&&!config.twitchEnabled){const msg=await twitch.normalizeSpawn(await body(req));if(!msg)return json(res,400,{error:'INVALID_SPAWN'});const result=game.spawn(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
if(req.method==='POST'&&url.pathname==='/api/dev/redemption'&&!config.twitchEnabled){const msg=await twitch.normalizeRedemption(await body(req));if(!msg)return json(res,400,{error:'INVALID_REDEMPTION'});const result=game.resurrect(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
if(req.method==='GET'&&(url.pathname==='/'||url.pathname==='/extension')){const html=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8'});return res.end(html)}
if(req.method==='GET'&&url.pathname==='/app.js'){const js=await readFile(join(publicDir,'app.js'));res.writeHead(200,{'content-type':'text/javascript; charset=utf-8'});return res.end(js)}
if(req.method==='GET'&&url.pathname==='/styles.css'){const css=await readFile(join(publicDir,'styles.css'));res.writeHead(200,{'content-type':'text/css; charset=utf-8'});return res.end(css)}
json(res,404,{error:'NOT_FOUND'})
}catch(error){console.error(JSON.stringify({level:'error',message:error instanceof Error?error.message:'Unexpected error'}));json(res,500,{error:'INTERNAL_ERROR'})}
})
const wss=new WebSocketServer({noServer:true})
server.on('upgrade',(req,socket,head)=>{const url=new URL(req.url??'/',config.publicBaseUrl);if(url.pathname!=='/ws'){socket.destroy();return}wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws,req))})
wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null);clients.set(ws,userId);if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>clients.delete(ws))})
setInterval(()=>{const before=game.snapshot().nextEventSequence;game.tick();if(game.snapshot().nextEventSequence!==before)broadcast()},250).unref()
if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{game.spawn(message);broadcast()},onRedemption:message=>{game.resurrect(message);broadcast()},onConnection:ready=>console.log(JSON.stringify({level:'info',component:'twitch-adapter',ready}))}).catch(error=>console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Connection failed'})))
if(process.env.NODE_ENV!=='test')server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`))
+21
View File
@@ -0,0 +1,21 @@
let state=null,session=null,userId=null,lastSequence=0,activeSocket=null
const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s)
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
function disabledReason(){if(!userId)return 'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return 'Type !spawn in chat first.';if(p.lifeState==='dead')return 'Your character is dead.';if(state.phase.kind!=='player')return 'Wait for Player Phase.';if(!p.ap)return 'No AP remains this phase.';return ''}
function render(){if(!state)return;$('#floor').textContent=`Floor ${state.floorNumber}`;$('#phase').textContent=state.phase.kind;$('#banner').hidden=state.phase.kind!=='dormant'
const me=state.players.find(p=>p.twitchUserId===userId),seconds=state.phase.kind==='player'?Math.max(0,Math.ceil((state.phase.deadlineAt-Date.now())/1000)):'—'
$('#status').innerHTML=[['Players',state.players.filter(p=>p.lifeState==='alive').length],['Timer',seconds],['HP',me?`${me.hp}/3`:'—'],['AP',me?.ap??'—'],['Guard',me?.guard??'—'],['Heal',me?(me.healAvailable?'Ready':'Used'):'—']].map(([k,v])=>`<div class="stat">${k}<b>${v}</b></div>`).join('')
const map=$('#map');map.style.gridTemplateColumns=`repeat(${state.floor.width},auto)`;map.innerHTML=''
for(let y=0;y<state.floor.height;y++)for(let x=0;x<state.floor.width;x++){const el=document.createElement('div'),tile=state.floor.tiles[y][x];el.className=`tile ${tile}`;if(tile==='exit')el.textContent='▣';const p=state.players.find(p=>p.lifeState==='alive'&&p.position.x===x&&p.position.y===y);const gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob||p){const e=document.createElement('span');e.className=`entity ${gob?'goblin':'player'}`;e.textContent=gob?'◆':'●';e.title=gob?'Goblin':p.displayName;el.append(e)}map.append(el)}
$('#log').innerHTML=state.actionLog.slice(-40).map(e=>`<li><small>#${e.sequence}</small> ${escapeHtml(e.message)}</li>`).join('');$('#log').scrollTop=$('#log').scrollHeight
const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason))
}
async function api(path,options={}){const res=await fetch(path,{...options,headers:{'content-type':'application/json',...(session?{authorization:`Bearer ${session}`}:{})}});const data=await res.json();if(!res.ok)throw new Error(data.message||data.error);return data}
async function authorizeExtension(token){const auth=await api('/api/extension/session',{method:'POST',body:JSON.stringify({token})});session=auth.token;userId=auth.twitchUserId;state=auth.state;if(activeSocket?.readyState===WebSocket.OPEN)activeSocket.send(JSON.stringify({type:'authenticate',token:session}));render()}
async function spawn(){const id=$('#userId').value.trim(),name=$('#displayName').value.trim();await api('/api/dev/spawn',{method:'POST',body:JSON.stringify({command:'!spawn',externalEventId:crypto.randomUUID(),twitchUserId:id,displayName:name,followerVerified:true})}).catch(e=>{if(!String(e.message).includes('already'))throw e});const auth=await api('/api/extension/session',{method:'POST',body:JSON.stringify({token:`dev:${id}:${name}`})});session=auth.token;userId=id;state=auth.state;render()}
async function command(kind){if(!state||state.phase.kind!=='player')return;const commands={up:{type:'move',direction:'up'},down:{type:'move',direction:'down'},left:{type:'move',direction:'left'},right:{type:'move',direction:'right'},attack:{type:'attack',targetId:'goblin'},heal:{type:'heal-self'},pass:{type:'pass'}};try{await api('/api/commands',{method:'POST',body:JSON.stringify({requestId:crypto.randomUUID(),runId:state.runId,floorId:state.floor.floorId,phaseId:state.phase.phaseId,command:commands[kind]})})}catch(e){$('#disabled').textContent=e.message}}
$('#spawn').onclick=()=>spawn().catch(e=>$('#disabled').textContent=e.message);$$('[data-command]').forEach(b=>b.onclick=()=>command(b.dataset.command))
function connect(){const ws=activeSocket=new WebSocket(`${location.protocol==='https:'?'wss':'ws'}://${location.host}/ws`);ws.onopen=()=>{if(session)ws.send(JSON.stringify({type:'authenticate',token:session}));$('#connection').textContent='Live'};ws.onclose=()=>{$('#connection').textContent='Reconnecting…';setTimeout(connect,1000)};ws.onmessage=e=>{const msg=JSON.parse(e.data);if(msg.type!=='snapshot')return;if(lastSequence&&msg.sequence>lastSequence+1){fetch('/api/state').then(r=>r.json()).then(s=>{state=s;lastSequence=s.nextEventSequence-1;render()});return}lastSequence=msg.sequence;state=msg.state;render()}}
connect()
setInterval(()=>{if(state?.phase.kind==='player')render()},250)
if(window.Twitch?.ext)window.Twitch.ext.onAuthorized(auth=>authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message))
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Twungeon</title><link rel="stylesheet" href="/styles.css"></head>
<body><main class="shell">
<aside class="panel status"><div><p class="eyebrow">Twitch plays together</p><h1>TWUNG<span>EON</span></h1></div><div id="status"></div>
<section id="controller"><h2>Controller</h2><div class="dpad"><button data-command="up"></button><button data-command="left"></button><button data-command="down"></button><button data-command="right"></button></div><div class="actions"><button data-command="attack">Attack</button><button data-command="heal">Heal</button><button data-command="pass">Pass</button></div><p id="disabled"></p></section>
<details><summary>Local viewer login</summary><label>User ID <input id="userId" value="viewer-1"></label><label>Name <input id="displayName" value="Viewer One"></label><button id="spawn">Spawn & bind</button></details>
</aside>
<section class="panel game"><div class="game-head"><div><p class="eyebrow">Shared dungeon</p><h2 id="floor">Floor 1</h2></div><div id="phase" class="phase"></div></div><div id="banner" hidden>Type !spawn to spawn in the Twungeon!</div><div id="map" aria-label="Dungeon map"></div><div class="legend"><span>● Adventurer</span><span>◆ Goblin</span><span>▣ Exit</span></div></section>
<section class="panel log"><div class="log-head"><div><p class="eyebrow">Chronicle</p><h2>Action log</h2></div><span id="connection">Connecting…</span></div><ol id="log"></ol></section>
</main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js"></script></body></html>
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;--ink:#f5ecd8;--muted:#b8ad98;--gold:#e8b04b;--red:#db604c;--panel:#171a1d;--line:#34383b;--floor:#3a3832;--wall:#111315}*{box-sizing:border-box}body{margin:0;background:#0d0f10;color:var(--ink);font:15px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;background-image:radial-gradient(#252017 1px,transparent 1px);background-size:20px 20px}.shell{height:100vh;min-height:650px;padding:18px;display:grid;grid-template-columns:minmax(260px,28%) 1fr;grid-template-rows:minmax(0,1fr) 31%;gap:14px}.panel{background:linear-gradient(145deg,#191c1f,#111315);border:1px solid var(--line);box-shadow:0 18px 60px #0008,inset 0 1px #ffffff0d;border-radius:5px}.status{padding:22px;display:flex;flex-direction:column;gap:20px;overflow:auto}.game{padding:20px;display:flex;flex-direction:column;min-width:0;position:relative}.log{grid-column:1/-1;padding:15px 20px;overflow:hidden;display:flex;flex-direction:column}h1,h2,p{margin:0}h1{font:800 31px/1 system-ui;letter-spacing:.08em}h1 span{color:var(--gold)}h2{font:700 18px/1.2 system-ui}.eyebrow{text-transform:uppercase;color:var(--gold);font-size:11px;letter-spacing:.16em;margin-bottom:6px}.game-head,.log-head{display:flex;align-items:center;justify-content:space-between}.phase{padding:6px 10px;border:1px solid var(--gold);color:var(--gold);border-radius:2px;text-transform:uppercase;font-size:12px}#banner{position:absolute;z-index:3;inset:45% auto auto 50%;transform:translate(-50%,-50%);padding:16px 22px;background:#111e;border:1px solid var(--gold);box-shadow:0 0 40px #000;text-align:center;color:var(--gold);font-weight:bold;white-space:nowrap}#map{flex:1;display:grid;align-content:center;justify-content:center;margin:12px 0;min-height:0}.tile{width:min(3.6vw,42px);aspect-ratio:1;border:1px solid #222;display:grid;place-items:center;position:relative;font-size:min(1.6vw,18px)}.tile.wall{background:var(--wall);box-shadow:inset 0 0 0 2px #1b1f21}.tile.floor{background:var(--floor)}.tile.exit{background:#68491b;color:#ffd77e}.entity{position:absolute;inset:12%;border-radius:50%;display:grid;place-items:center;font-weight:bold;text-shadow:0 1px 2px #000}.entity.player{background:#4f8cc9;border:2px solid #b9deff}.entity.goblin{background:var(--red);border:2px solid #ffb2a6;border-radius:20%}.legend{display:flex;gap:18px;justify-content:center;color:var(--muted);font-size:12px}#status{display:grid;grid-template-columns:1fr 1fr;gap:8px}.stat{border:1px solid var(--line);padding:9px}.stat b{display:block;color:var(--gold);font-size:18px}.dpad{display:grid;grid-template-columns:repeat(3,42px);gap:5px;margin:10px 0}.dpad button:nth-child(1){grid-column:2}.dpad button:nth-child(2){grid-column:1}.actions{display:flex;gap:5px;flex-wrap:wrap}button,input{font:inherit}button{background:#292d2f;color:var(--ink);border:1px solid #53595c;padding:8px 10px;cursor:pointer}button:hover:not(:disabled){border-color:var(--gold);color:var(--gold)}button:disabled{opacity:.35;cursor:not-allowed}#disabled{color:var(--muted);font-size:12px;margin-top:8px}details{margin-top:auto;color:var(--muted)}details label{display:block;margin:8px 0}input{display:block;width:100%;background:#0d0f10;border:1px solid var(--line);color:var(--ink);padding:6px}#log{margin:8px 0 0;padding:0;list-style:none;overflow:auto;display:flex;flex-direction:column;gap:3px}#log li{padding:4px 8px;border-left:2px solid var(--line);color:var(--muted)}#log li:last-child{color:var(--ink);border-color:var(--gold)}#connection{font-size:12px;color:var(--muted)}@media(max-width:760px){.shell{height:auto;grid-template-columns:1fr;grid-template-rows:auto minmax(500px,70vh) 360px}.log{grid-column:1}.tile{width:min(5vw,28px)}#banner{white-space:normal;width:75%}}
+64
View File
@@ -0,0 +1,64 @@
# Testing Twungeon
## Automated gates
From a clean checkout:
```bash
npm install
npm run lint
npm run typecheck
npm test
npm run test:integration
npm run build
```
`npm test` covers deterministic domain rules and 500 generated floors.
`npm run test:integration` covers the HTTP/session boundary and independent
viewer authority. Tests use injected clocks, IDs, random rolls, and synthetic
Twitch identities; they do not require credentials.
## Local gameplay verification
1. Run `npm run dev` and open `http://localhost:3000`.
2. Confirm the exact dormant banner appears and no timer runs.
3. Use **Local viewer login** to spawn and bind a viewer.
4. Confirm 3 HP, 2 AP, no Guard, a ready heal, and a 25-second phase.
5. Exercise movement, invalid walls, Attack, Heal, and Pass. Invalid actions
must not consume AP.
6. Open a second browser/private window with a different ID. Confirm both
characters share the dungeon but each controller spends only its own AP.
7. Refresh during a phase. Confirm a snapshot restores the map/log and no
command replays.
8. Run `npm run build && npm start` and repeat the smoke test against the built
server.
The local-only endpoints `/api/dev/spawn` and `/api/dev/redemption` exist only while
`TWITCH_ENABLED` is false.
## Live-channel campaign
After completing `docs/twitch-setup.md`, use two follower accounts and one
non-follower account. Capture redacted evidence for:
- eligible, ineligible, and duplicate `!spawn` attempts;
- matching and mismatched Extension identities;
- independent commands from two viewers;
- phase expiry and AutoGuard while one viewer is disconnected;
- death and a configured Channel Points reward redemption from the same user;
- escape with the Goblin alive, floor revival, and a total-party wipe;
- refresh/reconnect during a phase and transition; and
- repeated floors and a short soak session.
Do not record tokens, JWTs, secrets, or complete authorization headers. Use the
acceptance matrix in `Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md` for
AT-001 through AT-033 evidence. Automated passing tests do not substitute for
the clean-operator, live Twitch, first-time-viewer, or concept-validation gates.
## Fault injection
Test each failure separately: expired Extension JWT, wrong channel claim,
unlinked identity, invalid broadcaster token, missing follower scope, Twitch
chat disconnect, wrong/duplicate Channel Points redemption, WebSocket interruption, and
stale run/floor/phase command. Expected behavior is a stable rejection or a
fresh snapshot without partial game-state mutation.
+109
View File
@@ -0,0 +1,109 @@
# Twitch setup
This guide connects the Twungeon proof of concept to a Twitch development
channel. Never commit real values: copy `.env.example` to an ignored `.env` or
provide the variables through your process manager.
## Prerequisites
- Node.js 22 or newer and npm.
- A Twitch developer application and a development channel.
- A Twitch Extension with identity sharing enabled. Twungeon requires the
numeric `user_id`; anonymous or opaque-only viewers fail closed.
- A broadcaster user access token that can read chat and check followers.
- The Extension shared secret, copied exactly as the base64 value supplied by
the Extension Manager.
The installed Twurple packages are locked in `package-lock.json`. Re-check
their release documentation and Twitch's current scope requirements before
rotating tokens or upgrading packages.
## Required environment
Set `TWITCH_ENABLED=true`, then provide:
| Variable | Purpose |
| --- | --- |
| `TWITCH_CLIENT_ID` | Developer application/Extension client ID |
| `TWITCH_CLIENT_SECRET` | Server-only application secret |
| `TWITCH_BROADCASTER_ID` | Numeric channel owner ID |
| `TWITCH_CHANNEL_LOGIN` | Channel login joined by Twurple chat |
| `TWITCH_BOT_ACCESS_TOKEN` | Broadcaster user access token |
| `TWITCH_EXTENSION_SECRET` | Base64 Extension shared secret |
| `CHANNEL_POINTS_RESURRECTION_REWARD_ID` | Stable ID of the resurrection custom reward |
| `PUBLIC_BASE_URL` | Public HTTPS backend origin |
The user token currently needs `chat:read`, `moderator:read:followers`, and
`channel:read:redemptions`. The follower and Channel Points subscriptions use
the broadcaster token, so its subject must match `TWITCH_BROADCASTER_ID`.
Twungeon subscribes only to the configured custom reward and uses Twitch's
stable redemption ID as the deduplication key. The broadcaster owns the reward
cost in Twitch; the backend does not duplicate it.
## Extension configuration
1. Host the built static files and backend at an HTTPS origin allowed by the
Extension configuration. Twitch embeds the UI in an iframe and supplies the
Extension Helper JWT through `onAuthorized`.
2. Point the viewer/mobile video component to the application root.
3. Enable identity sharing. A JWT without `user_id`, with the wrong
`channel_id`, an expired signature, or an `external` role is rejected.
4. Keep the Extension secret only in the backend environment. It must never be
included in the UI bundle or URL.
5. Start with `npm run build && npm start`. Confirm `/health` returns `ready:
true` and `twitchMode: "configured"` after chat connects.
The Extension JWT is exchanged for a random 15-minute Twungeon session. A
refresh obtains a new Twitch JWT, re-verifies it, and restores the existing
character; it does not create a player.
## Channel Points reward
1. In the broadcaster dashboard, create one custom reward for resurrection.
2. Choose its title and cost in Twitch. Disable viewer text input unless the
channel has a separate moderation reason to keep it.
3. Enable **Skip Reward Requests Queue** because Twungeon consumes the
redemption immediately and uses read-only EventSub access rather than
managing fulfillment status.
4. Retrieve the reward's stable ID through the Twitch API or Twitch CLI using
the broadcaster token, and set it as
`CHANNEL_POINTS_RESURRECTION_REWARD_ID`.
5. Do not reuse that reward for another action. Redemptions of every other
reward are ignored by the domain.
Bits, Cheers, and Bits-in-Extensions products are outside the MVP and have no
gameplay handler.
## Event flow
1. A follower types `!spawn` in chat.
2. Twurple supplies the stable chatter ID; the API checks that ID against the
broadcaster's followers.
3. The Extension sends its current Twitch JWT to
`POST /api/extension/session`.
4. The backend verifies signature, expiry, channel, role, and numeric user ID.
5. Controls enable only when the verified ID equals the chat-spawn owner ID.
6. A redemption of the configured Channel Points reward by the dead player
invokes resurrection exactly once.
## Troubleshooting and rotation
- **Chat disconnected:** check token validity, `chat:read`, channel login, and
outbound WebSocket access. Existing game state continues; new Twitch inputs
fail closed until reconnect.
- **Follower rejected:** ensure the token belongs to the broadcaster and has
`moderator:read:followers`; confirm the numeric broadcaster ID.
- **Extension returns `UNAUTHENTICATED`:** check the base64 secret, JWT expiry,
allowed channel, and identity-sharing capability. Never log the JWT.
- **`IDENTITY_NOT_BOUND`:** compare the chat and verified Extension numeric IDs;
display names are deliberately ignored for ownership.
- **Reward does not resurrect:** confirm the configured reward ID, the
`channel:read:redemptions` scope, the dead character owner ID, and that the
redemption was not already processed.
- **Public iframe fails:** confirm TLS, allowed origins, Extension asset paths,
and that WebSocket traffic reaches `/ws`.
To rotate credentials, stop new Twitch intake, replace the server environment,
restart, verify `/health`, test one follower check and Extension login, then
revoke the old token/secret. A full process restart creates a new in-memory run,
as required by the MVP persistence boundary.
+13
View File
@@ -0,0 +1,13 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist/**', 'coverage/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{ files: ['apps/stream-view/public/**/*.js'], languageOptions: { globals: {
document: 'readonly', fetch: 'readonly', crypto: 'readonly', WebSocket: 'readonly',
location: 'readonly', setTimeout: 'readonly', setInterval: 'readonly', window: 'readonly'
} } },
{ files: ['**/*.ts'], rules: { '@typescript-eslint/no-explicit-any': 'off' } }
)
+3443
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "twungeon",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "tsx apps/backend/src/server.ts",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --pool=threads --poolOptions.threads.singleThread=true --no-file-parallelism tests/domain",
"test:integration": "vitest run --pool=threads --poolOptions.threads.singleThread=true --no-file-parallelism tests/integration tests/e2e",
"build": "tsc && node scripts/copy-static.mjs",
"start": "node dist/apps/backend/src/server.js"
},
"dependencies": {
"@twurple/api": "^8.1.4",
"@twurple/auth": "^8.1.4",
"@twurple/chat": "^8.1.4",
"@twurple/eventsub-ws": "^8.1.4",
"jose": "^6.2.9",
"ws": "^8.18.3",
"zod": "^4.0.17"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@types/node": "^24.3.0",
"@types/ws": "^8.18.1",
"eslint": "^9.34.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"typescript-eslint": "^8.41.0",
"vitest": "^3.2.4"
}
}
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod'
export const DirectionSchema = z.enum(['up', 'down', 'left', 'right'])
export const PlayerCommandSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('move'), direction: DirectionSchema }),
z.object({ type: z.literal('attack'), targetId: z.string().min(1).max(128) }),
z.object({ type: z.literal('heal-self') }),
z.object({ type: z.literal('pass') })
])
export const CommandEnvelopeSchema = z.object({
requestId: z.string().min(1).max(128),
runId: z.string().min(1),
floorId: z.string().min(1),
phaseId: z.string().min(1),
command: PlayerCommandSchema
})
export const SpawnMessageSchema = z.object({
type: z.literal('spawn-requested'), externalEventId: z.string().min(1),
twitchUserId: z.string().min(1), displayName: z.string().min(1).max(40),
broadcasterId: z.string().min(1), followerVerified: z.boolean()
})
export const ChannelPointRedemptionSchema = z.object({
type: z.literal('channel-point-resurrection-redeemed'), externalEventId: z.string().min(1),
twitchUserId: z.string().min(1), rewardId: z.string().min(1)
})
export type PlayerCommand = z.infer<typeof PlayerCommandSchema>
export type CommandEnvelope = z.infer<typeof CommandEnvelopeSchema>
export type SpawnMessage = z.infer<typeof SpawnMessageSchema>
export type ChannelPointRedemption = z.infer<typeof ChannelPointRedemptionSchema>
export type RejectionReason = 'UNAUTHENTICATED' | 'IDENTITY_NOT_BOUND' | 'CHARACTER_NOT_FOUND' |
'CHARACTER_DEAD' | 'WRONG_PHASE' | 'STALE_RUN' | 'STALE_FLOOR' | 'STALE_PHASE' |
'DEADLINE_PASSED' | 'NO_AP' | 'INVALID_TARGET' | 'BLOCKED_TILE' | 'HEAL_USED' | 'DUPLICATE'
export interface CommandResult {
requestId: string
accepted: boolean
eventSequence: number
reason: RejectionReason | null
message: string
}
+198
View File
@@ -0,0 +1,198 @@
import type { ChannelPointRedemption, CommandEnvelope, CommandResult, RejectionReason, SpawnMessage } from '../../contracts/src/index.js'
import type { ActionLogEntry, Clock, FloorState, GoblinState, IdGenerator, PlayerState, RandomProvider, RunState, TilePosition } from './types.js'
export interface GameDependencies {
clock: Clock; random: RandomProvider; ids: IdGenerator; generateFloor(seed: string): FloorState
resurrectionRewardId: string
}
export interface SpawnResult { accepted: boolean; reason: 'NOT_FOLLOWER'|'DUPLICATE'|'DIED_THIS_FLOOR'|null; message: string }
export interface ResurrectionResult { accepted: boolean; reason: 'DUPLICATE'|'WRONG_REWARD'|'UNKNOWN_PLAYER'|'PLAYER_ALIVE'|null; message: string }
export interface GameSnapshot extends Omit<RunState, 'players'> { players: PlayerState[]; serverTime: number }
const copy = <T>(value: T): T => structuredClone(value)
const same = (a: TilePosition, b: TilePosition) => a.x === b.x && a.y === b.y
const adjacent = (a: TilePosition, b: TilePosition) => Math.abs(a.x-b.x)+Math.abs(a.y-b.y) === 1
const phaseId = (state: RunState) => state.phase.kind === 'player' || state.phase.kind === 'enemy' ? state.phase.phaseId : null
const directions = [{x:0,y:-1},{x:-1,y:0},{x:1,y:0},{x:0,y:1}]
export class Game {
private state: RunState
private readonly commandResults = new Map<string, CommandResult>()
private readonly externalEvents = new Set<string>()
constructor(private readonly deps: GameDependencies, seed = 'twungeon-1') {
if (!deps.resurrectionRewardId.trim()) throw new Error('resurrectionRewardId is required')
const floor = deps.generateFloor(seed)
this.state = {
runId: deps.ids.next('run'), floorNumber: 1, floor, phase: {kind:'dormant'}, players: {},
goblin: this.newGoblin(floor), actionLog: [], nextEventSequence: 1
}
this.log('floor-created', null, null, 'Floor 1 awaits its first adventurer.')
}
snapshot(): GameSnapshot {
return {...copy(this.state), players:Object.values(copy(this.state.players)), serverTime:this.deps.clock.now()}
}
personalizedSnapshot(twitchUserId: string): GameSnapshot & { viewer: PlayerState | null } {
const snapshot = this.snapshot()
return {...snapshot, viewer:snapshot.players.find(p=>p.twitchUserId===twitchUserId) ?? null}
}
bindExtension(twitchUserId: string, connected = true): boolean {
const player=this.state.players[twitchUserId]; if(!player) return false
player.extensionBound=true; player.connectionState=connected?'connected':'disconnected'; return true
}
disconnect(twitchUserId: string): void { const p=this.state.players[twitchUserId]; if(p) p.connectionState='disconnected' }
spawn(message: SpawnMessage): SpawnResult {
if(this.externalEvents.has(message.externalEventId)) return {accepted:false,reason:'DUPLICATE',message:'That spawn request was already handled.'}
this.externalEvents.add(message.externalEventId)
if(!message.followerVerified) return {accepted:false,reason:'NOT_FOLLOWER',message:'Follow the channel before using !spawn.'}
const existing=this.state.players[message.twitchUserId]
if(existing) return {accepted:false,reason:existing.diedOnFloor===this.state.floorNumber?'DIED_THIS_FLOOR':'DUPLICATE',message:existing.diedOnFloor===this.state.floorNumber?'You died on this floor; reach the next floor or resurrect to return.':'Your character is already in the Twungeon.'}
const p: PlayerState={twitchUserId:message.twitchUserId,displayName:safeName(message.displayName),followerVerified:true,characterCreated:true,extensionBound:false,connectionState:'disconnected',position:copy(this.state.floor.spawnTiles[0]!),hp:3,lifeState:'alive',ap:0,guard:0,healAvailable:true,participatingFloor:this.state.floorNumber,diedOnFloor:null,eligibleThisPhase:false}
this.state.players[p.twitchUserId]=p
this.log('player-spawned',p.twitchUserId,null,`${p.displayName} entered the Twungeon!`)
if(this.state.phase.kind==='dormant') this.startPlayerPhase()
return {accepted:true,reason:null,message:`${p.displayName} spawned.`}
}
resurrect(message: ChannelPointRedemption): ResurrectionResult {
if(this.externalEvents.has(message.externalEventId)) return {accepted:false,reason:'DUPLICATE',message:'That Channel Points redemption was already handled.'}
this.externalEvents.add(message.externalEventId)
if(message.rewardId!==this.deps.resurrectionRewardId) return {accepted:false,reason:'WRONG_REWARD',message:'That Channel Points reward does not resurrect characters.'}
const p=this.state.players[message.twitchUserId]
if(!p) return {accepted:false,reason:'UNKNOWN_PLAYER',message:'No character is bound to that viewer.'}
if(p.lifeState!=='dead') return {accepted:false,reason:'PLAYER_ALIVE',message:'That character is already alive.'}
p.lifeState='alive'; p.hp=3; p.ap=0; p.guard=0; p.eligibleThisPhase=false; p.diedOnFloor=null; p.position=copy(this.state.floor.spawnTiles[0]!)
this.log('player-resurrected',p.twitchUserId,null,`${p.displayName} rose again at the spawn!`)
return {accepted:true,reason:null,message:`${p.displayName} resurrected.`}
}
command(twitchUserId: string | null, bound: boolean, envelope: CommandEnvelope, arrivedAt = this.deps.clock.now()): CommandResult {
const requestKey=`${twitchUserId??'anonymous'}:${envelope.requestId}`
const cached=this.commandResults.get(requestKey)
if(cached) return {...cached,accepted:false,reason:'DUPLICATE',message:'Duplicate request; the original result was not applied again.'}
const before=copy(this.state)
let rejection: RejectionReason | null=null
if(!twitchUserId) rejection='UNAUTHENTICATED'
else if(!bound) rejection='IDENTITY_NOT_BOUND'
else if(envelope.runId!==this.state.runId) rejection='STALE_RUN'
else if(envelope.floorId!==this.state.floor.floorId) rejection='STALE_FLOOR'
else if(this.state.phase.kind!=='player') rejection='WRONG_PHASE'
else if(envelope.phaseId!==this.state.phase.phaseId) rejection='STALE_PHASE'
else if(arrivedAt>=this.state.phase.deadlineAt) rejection='DEADLINE_PASSED'
const player=twitchUserId?this.state.players[twitchUserId]:undefined
if(!rejection && !player) rejection='CHARACTER_NOT_FOUND'
else if(!rejection && player?.lifeState!=='alive') rejection='CHARACTER_DEAD'
else if(!rejection && (player?.ap ?? 0)<1) rejection='NO_AP'
if(!rejection && player) rejection=this.applyPlayerCommand(player,envelope)
if(rejection) this.state=before
else this.finishPlayerPhaseIfReady()
const result:CommandResult={requestId:envelope.requestId,accepted:rejection===null,eventSequence:this.state.nextEventSequence-1,reason:rejection,message:rejection?reasonMessage(rejection):'Command accepted.'}
this.commandResults.set(requestKey,result)
return result
}
tick(): void {
if(this.state.phase.kind==='player' && this.deps.clock.now()>=this.state.phase.deadlineAt) this.endPlayerPhase()
}
private applyPlayerCommand(player: PlayerState, envelope: CommandEnvelope): RejectionReason|null {
const c=envelope.command
if(c.type==='move'){
const delta={up:{x:0,y:-1},down:{x:0,y:1},left:{x:-1,y:0},right:{x:1,y:0}}[c.direction]
const next={x:player.position.x+delta.x,y:player.position.y+delta.y}
const tile=this.state.floor.tiles[next.y]?.[next.x]
if(!tile || tile==='wall' || (this.state.goblin.mode!=='dead' && same(next,this.state.goblin.position))) return 'BLOCKED_TILE'
player.position=next; player.ap--; this.log('player-moved',player.twitchUserId,null,`${player.displayName} moved ${c.direction}.`)
if(tile==='exit') this.advanceFloor()
return null
}
if(c.type==='attack'){
if(c.targetId!=='goblin' || this.state.goblin.mode==='dead' || !adjacent(player.position,this.state.goblin.position)) return 'INVALID_TARGET'
player.ap--; const hit=this.deps.random.next()<0.65
if(this.state.goblin.mode==='guarding'){this.state.goblin.mode='pursuing';this.state.goblin.targetPlayerId=player.twitchUserId;this.log('goblin-aggro',player.twitchUserId,'goblin',`The Goblin fixes its gaze on ${player.displayName}!`)}
if(hit){this.state.goblin.hp=Math.max(0,this.state.goblin.hp-1);this.log('player-hit',player.twitchUserId,'goblin',`${player.displayName} hits the Goblin for 1 damage.`);if(this.state.goblin.hp===0){this.state.goblin.mode='dead';this.state.goblin.targetPlayerId=null;this.log('goblin-died',player.twitchUserId,'goblin','The Goblin falls!')}}
else this.log('player-missed',player.twitchUserId,'goblin',`${player.displayName} misses the Goblin.`)
return null
}
if(c.type==='heal-self'){
if(!player.healAvailable) return 'HEAL_USED'
player.ap--;player.hp=3;player.healAvailable=false;this.log('player-healed',player.twitchUserId,player.twitchUserId,`${player.displayName} restores their HP.`);return null
}
player.ap--;this.log('player-passed',player.twitchUserId,null,`${player.displayName} waits and keeps watch.`);return null
}
private startPlayerPhase(): void {
const living=Object.values(this.state.players).filter(p=>p.lifeState==='alive')
if(living.length===0){this.state.phase={kind:'dormant'};return}
for(const p of Object.values(this.state.players)){p.guard=0;p.eligibleThisPhase=p.lifeState==='alive';p.ap=p.lifeState==='alive'?2:0}
const now=this.deps.clock.now(), duration=Math.min(living.length*25_000,120_000)
this.state.phase={kind:'player',phaseId:this.deps.ids.next('phase'),startedAt:now,deadlineAt:now+duration,initialEligiblePlayerIds:living.map(p=>p.twitchUserId)}
this.log('player-phase-started',null,null,`Player Phase begins: ${duration/1000} seconds.`)
}
private finishPlayerPhaseIfReady(): void {
if(this.state.phase.kind!=='player') return
const done=this.state.phase.initialEligiblePlayerIds.every(id=>{const p=this.state.players[id];return !p || p.lifeState==='dead' || p.ap===0})
if(done) this.endPlayerPhase()
}
private endPlayerPhase(): void {
if(this.state.phase.kind!=='player') return
for(const p of Object.values(this.state.players)){if(p.lifeState==='alive')p.guard=p.ap;p.ap=0;p.eligibleThisPhase=false}
this.state.phase={kind:'enemy',phaseId:this.deps.ids.next('phase')};this.log('enemy-phase-started',null,null,'Enemy Phase begins.')
this.runGoblin(); if(this.state.phase.kind==='enemy') this.startPlayerPhase()
}
private runGoblin(): void {
if(this.state.goblin.mode==='dead' || this.state.goblin.mode==='guarding') return
let ap=2
while(ap-->0){
const g=this.state.goblin
if(g.mode==='pursuing'){
const target=g.targetPlayerId?this.state.players[g.targetPlayerId]:undefined
if(!target || target.lifeState==='dead'){g.mode='returning';g.targetPlayerId=null;continue}
if(adjacent(g.position,target.position)){
const hit=this.deps.random.next()<0.5
if(hit && target.guard>0){target.guard--;this.log('guard-blocked','goblin',target.twitchUserId,`${target.displayName}'s Guard blocks the hit.`)}
else if(hit){target.hp--;this.log('goblin-hit','goblin',target.twitchUserId,`The Goblin hits ${target.displayName} for 1 damage.`);if(target.hp<=0)this.killPlayer(target)}
else this.log('goblin-missed','goblin',target.twitchUserId,`The Goblin misses ${target.displayName}.`)
} else { const step=this.nextPathStep(g.position,target.position);if(!step)break;g.position=step;this.log('goblin-moved','goblin',target.twitchUserId,'The Goblin closes in.') }
} else if(g.mode==='returning'){
if(same(g.position,g.guardPosition)){g.mode='guarding';break}
const step=this.nextPathStep(g.position,g.guardPosition);if(!step)break;g.position=step
this.log('goblin-returned','goblin',null,'The Goblin returns toward its post.')
if(same(g.position,g.guardPosition))g.mode='guarding'
} else break
}
}
private killPlayer(player: PlayerState): void {
player.hp=0;player.lifeState='dead';player.ap=0;player.guard=0;player.eligibleThisPhase=false;player.diedOnFloor=this.state.floorNumber
this.log('player-died','goblin',player.twitchUserId,`${player.displayName} has fallen.`)
if(this.state.goblin.targetPlayerId===player.twitchUserId){this.state.goblin.mode='returning';this.state.goblin.targetPlayerId=null}
const all=Object.values(this.state.players);if(all.length>0 && all.every(p=>p.lifeState==='dead'))this.resetRun()
}
private advanceFloor(): void {
this.state.phase={kind:'transition',reason:'floor-advance'};this.state.floorNumber++
const floor=this.deps.generateFloor(`${this.state.runId}-floor-${this.state.floorNumber}`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor)
this.restorePlayers();this.log('floor-advanced',null,null,`The party reaches Floor ${this.state.floorNumber}!`);this.startPlayerPhase()
}
private resetRun(): void {
this.state.phase={kind:'transition',reason:'party-wipe'};this.state.runId=this.deps.ids.next('run');this.state.floorNumber=1
const floor=this.deps.generateFloor(`${this.state.runId}-floor-1`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor)
this.restorePlayers();this.log('party-wipe',null,null,'The party has fallen. A new run begins on Floor 1.');this.startPlayerPhase()
}
private restorePlayers(): void { for(const p of Object.values(this.state.players)){p.hp=3;p.lifeState='alive';p.ap=0;p.guard=0;p.healAvailable=true;p.diedOnFloor=null;p.eligibleThisPhase=false;p.participatingFloor=this.state.floorNumber;p.position=copy(this.state.floor.spawnTiles[0]!)} }
private nextPathStep(start:TilePosition,goal:TilePosition):TilePosition|null {
const q:[TilePosition,TilePosition[]][]=[[start,[]]],seen=new Set([`${start.x},${start.y}`])
while(q.length){const [p,path]=q.shift()!;if(same(p,goal))return path[0]??null
for(const d of directions){const n={x:p.x+d.x,y:p.y+d.y},k=`${n.x},${n.y}`;if(seen.has(k)||this.state.floor.tiles[n.y]?.[n.x]==='wall')continue
if(Object.values(this.state.players).some(pl=>pl.lifeState==='alive'&&same(pl.position,n))&&!same(n,goal))continue
seen.add(k);q.push([n,[...path,n]])}}
return null
}
private newGoblin(floor:FloorState):GoblinState{return{hp:2,position:copy(floor.goblinGuardPosition),guardPosition:copy(floor.goblinGuardPosition),mode:'guarding',targetPlayerId:null}}
private log(type:string,actorId:string|null,targetId:string|null,message:string):ActionLogEntry {const entry={sequence:this.state.nextEventSequence++,runId:this.state.runId,floorNumber:this.state.floorNumber,phaseId:phaseId(this.state),type,actorId,targetId,message,occurredAt:new Date(this.deps.clock.now()).toISOString()};this.state.actionLog.push(entry);if(this.state.actionLog.length>200)this.state.actionLog.shift();return entry}
}
function safeName(name:string):string{return name.replace(/[<>]/g,'').slice(0,40)||'Adventurer'}
function reasonMessage(reason:RejectionReason):string{return ({UNAUTHENTICATED:'Sign in to control a character.',IDENTITY_NOT_BOUND:'This Extension identity is not bound to a character.',CHARACTER_NOT_FOUND:'Spawn through chat before using controls.',CHARACTER_DEAD:'Dead characters cannot act.',WRONG_PHASE:'Commands are only accepted during Player Phase.',STALE_RUN:'The run changed; refresh state.',STALE_FLOOR:'The floor changed; refresh state.',STALE_PHASE:'The phase changed; refresh state.',DEADLINE_PASSED:'The Player Phase deadline passed.',NO_AP:'No AP remains.',INVALID_TARGET:'That target is not valid or adjacent.',BLOCKED_TILE:'That tile cannot be entered.',HEAL_USED:'The self-heal was already used on this floor.',DUPLICATE:'That request was already handled.'})[reason]}
+2
View File
@@ -0,0 +1,2 @@
export * from './types.js'
export * from './game.js'
+38
View File
@@ -0,0 +1,38 @@
export interface TilePosition { x: number; y: number }
export type TileKind = 'wall' | 'floor' | 'exit'
export interface FloorState {
floorId: string; width: number; height: number; tiles: TileKind[][]
spawnTiles: TilePosition[]; exitPosition: TilePosition; goblinGuardPosition: TilePosition
generationSeed: string
}
export interface PlayerState {
twitchUserId: string; displayName: string; followerVerified: boolean
characterCreated: boolean; extensionBound: boolean
connectionState: 'connected' | 'disconnected'; position: TilePosition
hp: number; lifeState: 'alive' | 'dead'; ap: number; guard: number
healAvailable: boolean; participatingFloor: number; diedOnFloor: number | null
eligibleThisPhase: boolean
}
export interface GoblinState {
hp: number; position: TilePosition; guardPosition: TilePosition
mode: 'guarding' | 'pursuing' | 'returning' | 'dead'; targetPlayerId: string | null
}
export type PhaseState = { kind: 'dormant' } | {
kind: 'player'; phaseId: string; startedAt: number; deadlineAt: number
initialEligiblePlayerIds: string[]
} | { kind: 'enemy'; phaseId: string } | {
kind: 'transition'; reason: 'floor-advance' | 'party-wipe'
}
export interface ActionLogEntry {
sequence: number; runId: string; floorNumber: number; phaseId: string | null
type: string; actorId: string | null; targetId: string | null
message: string; occurredAt: string
}
export interface RunState {
runId: string; floorNumber: number; floor: FloorState; phase: PhaseState
players: Record<string, PlayerState>; goblin: GoblinState
actionLog: ActionLogEntry[]; nextEventSequence: number
}
export interface RandomProvider { next(): number }
export interface Clock { now(): number }
export interface IdGenerator { next(prefix: string): string }
+41
View File
@@ -0,0 +1,41 @@
import type { FloorState, TileKind, TilePosition } from '../../domain/src/types.js'
function hash(seed: string): number {
let value = 2166136261
for (const c of seed) { value ^= c.charCodeAt(0); value = Math.imul(value, 16777619) }
return value >>> 0
}
function rng(seed: string): () => number {
let state = hash(seed) || 1
return () => { state ^= state << 13; state ^= state >>> 17; state ^= state << 5; return (state >>> 0) / 4294967296 }
}
const key = (p: TilePosition) => `${p.x},${p.y}`
export function validateFloor(floor: FloorState): boolean {
if (floor.spawnTiles.length === 0 || floor.tiles.flat().filter(t => t === 'exit').length !== 1) return false
const seen = new Set<string>(), queue = [floor.spawnTiles[0]!]
while (queue.length) {
const p = queue.shift()!; if (seen.has(key(p))) continue; seen.add(key(p))
for (const n of [{x:p.x,y:p.y-1},{x:p.x-1,y:p.y},{x:p.x+1,y:p.y},{x:p.x,y:p.y+1}]) {
if (n.x >= 0 && n.y >= 0 && n.x < floor.width && n.y < floor.height && floor.tiles[n.y]?.[n.x] !== 'wall' && !seen.has(key(n))) queue.push(n)
}
}
return seen.has(key(floor.exitPosition)) && floor.tiles[0]?.every(t => t === 'wall') === true && floor.tiles.at(-1)?.every(t => t === 'wall') === true
}
export function generateFloor(seed: string): FloorState {
const random = rng(seed), width = 18 + Math.floor(random() * 5), height = 11 + Math.floor(random() * 4)
const tiles: TileKind[][] = Array.from({length: height}, () => Array<TileKind>(width).fill('wall'))
const spawnW = 4 + Math.floor(random()*3), spawnH = 4 + Math.floor(random()*2)
const exitW = 4 + Math.floor(random()*3), exitH = 4 + Math.floor(random()*2)
const sy = 2 + Math.floor(random() * Math.max(1, height-spawnH-3)), ey = 2 + Math.floor(random() * Math.max(1, height-exitH-3))
const carve = (x:number,y:number,w:number,h:number) => { for(let yy=y;yy<y+h;yy++) for(let xx=x;xx<x+w;xx++) tiles[yy]![xx]='floor' }
carve(1,sy,spawnW,spawnH); carve(width-exitW-1,ey,exitW,exitH)
const start={x:1+Math.floor(spawnW/2),y:sy+Math.floor(spawnH/2)}, end={x:width-exitW-1+Math.floor(exitW/2),y:ey+Math.floor(exitH/2)}
if(random()<0.5){ for(let x=start.x;x<=end.x;x++) tiles[start.y]![x]='floor'; for(let y=Math.min(start.y,end.y);y<=Math.max(start.y,end.y);y++) tiles[y]![end.x]='floor' }
else { for(let y=Math.min(start.y,end.y);y<=Math.max(start.y,end.y);y++) tiles[y]![start.x]='floor'; for(let x=start.x;x<=end.x;x++) tiles[end.y]![x]='floor' }
tiles[end.y]![end.x]='exit'
const floor: FloorState={floorId:`floor-${hash(seed).toString(16)}`,width,height,tiles,spawnTiles:[start],exitPosition:end,goblinGuardPosition:{x:end.x-1,y:end.y},generationSeed:seed}
if(!validateFloor(floor)) throw new Error(`Generated invalid floor for seed ${seed}`)
return floor
}
+9
View File
@@ -0,0 +1,9 @@
import type { GameSnapshot, TilePosition } from '../../domain/src/index.js'
export interface RenderEntity { id:string; kind:'player'|'goblin'|'exit'; position:TilePosition; label:string }
export function toRenderModel(snapshot:GameSnapshot):{width:number;height:number;tiles:string[][];entities:RenderEntity[]} {
const entities:RenderEntity[]=[{id:'exit',kind:'exit',position:snapshot.floor.exitPosition,label:'Exit'}]
for(const p of snapshot.players)if(p.lifeState==='alive')entities.push({id:p.twitchUserId,kind:'player',position:p.position,label:p.displayName})
if(snapshot.goblin.mode!=='dead')entities.push({id:'goblin',kind:'goblin',position:snapshot.goblin.position,label:'Goblin'})
return {width:snapshot.floor.width,height:snapshot.floor.height,tiles:snapshot.floor.tiles,entities}
}
+76
View File
@@ -0,0 +1,76 @@
import type { ChannelPointRedemption, SpawnMessage } from '../../contracts/src/index.js'
import { ApiClient } from '@twurple/api'
import { StaticAuthProvider } from '@twurple/auth'
import { ChatClient } from '@twurple/chat'
import { EventSubWsListener } from '@twurple/eventsub-ws'
import { jwtVerify } from 'jose'
export interface TwitchIdentity { twitchUserId: string; displayName: string }
export interface TwitchAdapter {
readonly ready: boolean
verifyExtensionToken(token: string): Promise<TwitchIdentity | null>
normalizeSpawn(input: unknown): Promise<SpawnMessage | null>
normalizeRedemption(input: unknown): Promise<ChannelPointRedemption | null>
}
/** Local acceptance adapter. Production Twurple wiring implements this boundary. */
export class SyntheticTwitchAdapter implements TwitchAdapter {
readonly ready = true
constructor(private readonly broadcasterId: string) {}
async verifyExtensionToken(token: string): Promise<TwitchIdentity|null> {
const match=/^dev:([^:]+):(.+)$/.exec(token)
return match ? {twitchUserId:match[1]!,displayName:match[2]!} : null
}
async normalizeSpawn(input: any): Promise<SpawnMessage|null> {
if(!input || input.command!=='!spawn' || typeof input.twitchUserId!=='string')return null
return {type:'spawn-requested',externalEventId:String(input.externalEventId),twitchUserId:input.twitchUserId,displayName:String(input.displayName??'Adventurer'),broadcasterId:this.broadcasterId,followerVerified:input.followerVerified===true}
}
async normalizeRedemption(input:any):Promise<ChannelPointRedemption|null>{
if(!input || typeof input.twitchUserId!=='string' || typeof input.rewardId!=='string')return null
return {type:'channel-point-resurrection-redeemed',externalEventId:String(input.externalEventId),twitchUserId:input.twitchUserId,rewardId:input.rewardId}
}
}
export interface LiveTwitchConfig { clientId:string; accessToken:string; broadcasterId:string; channelLogin:string; extensionSecret:string; resurrectionRewardId:string }
export interface TwitchHandlers { onSpawn(message:SpawnMessage):void; onRedemption(message:ChannelPointRedemption):void; onConnection(ready:boolean):void }
/** Twurple production adapter. All callbacks are normalized and safe for domain use. */
export class LiveTwitchAdapter implements TwitchAdapter {
private readonly api:ApiClient
private readonly chat:ChatClient
private readonly eventSub:EventSubWsListener
private connected=false
constructor(private readonly config:LiveTwitchConfig){
const authProvider=new StaticAuthProvider(config.clientId,config.accessToken,['chat:read','moderator:read:followers','channel:read:redemptions'])
this.api=new ApiClient({authProvider})
this.chat=new ChatClient({authProvider,channels:[config.channelLogin],readOnly:true,rejoinChannelsOnReconnect:true})
this.eventSub=new EventSubWsListener({apiClient:this.api})
}
get ready(){return this.connected}
async start(handlers:TwitchHandlers):Promise<void>{
this.chat.onConnect(()=>{this.connected=true;handlers.onConnection(true)})
this.chat.onDisconnect(()=>{this.connected=false;handlers.onConnection(false)})
this.chat.onMessage(async(_channel,_user,text,msg)=>{
try{
if(text.trim()==='!spawn'){
const follower=await this.api.channels.getChannelFollowers(this.config.broadcasterId,msg.userInfo.userId,{limit:1})
handlers.onSpawn({type:'spawn-requested',externalEventId:msg.id,twitchUserId:msg.userInfo.userId,displayName:msg.userInfo.displayName,broadcasterId:this.config.broadcasterId,followerVerified:follower.data.length===1})
}
}catch(error){console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Twitch event failed'}))}
})
this.eventSub.onChannelRedemptionAddForReward(this.config.broadcasterId,this.config.resurrectionRewardId,event=>handlers.onRedemption({type:'channel-point-resurrection-redeemed',externalEventId:event.id,twitchUserId:event.userId,rewardId:event.rewardId}))
this.eventSub.start()
await this.chat.connect()
}
async verifyExtensionToken(token:string):Promise<TwitchIdentity|null>{
try{
const secret=Buffer.from(this.config.extensionSecret,'base64')
const {payload}=await jwtVerify(token,secret,{algorithms:['HS256']})
if(payload.channel_id!==this.config.broadcasterId || typeof payload.user_id!=='string' || payload.role==='external')return null
const user=await this.api.users.getUserById(payload.user_id)
return {twitchUserId:payload.user_id,displayName:user?.displayName??payload.user_id}
}catch{return null}
}
async normalizeSpawn():Promise<SpawnMessage|null>{return null}
async normalizeRedemption():Promise<ChannelPointRedemption|null>{return null}
}
+12
View File
@@ -0,0 +1,12 @@
import type { GameSnapshot } from '../../domain/src/index.js'
export function controlDisabledReason(snapshot:GameSnapshot,twitchUserId:string|null):string|null{
if(!twitchUserId)return 'Sign in to the Extension.'
const p=snapshot.players.find(x=>x.twitchUserId===twitchUserId)
if(!p)return 'Type !spawn in chat first.'
if(!p.extensionBound)return 'Extension identity is not bound.'
if(p.lifeState==='dead')return 'Your character is dead.'
if(snapshot.phase.kind!=='player')return 'Wait for Player Phase.'
if(p.ap===0)return 'No AP remains this phase.'
return null
}
+4
View File
@@ -0,0 +1,4 @@
import { cp, mkdir } from 'node:fs/promises'
await mkdir('dist/apps/stream-view/public', { recursive: true })
await cp('apps/stream-view/public', 'dist/apps/stream-view/public', { recursive: true })
+7
View File
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
import { loadConfig } from '../../apps/backend/src/config.js'
describe('Channel Points configuration',()=>{
it('uses a safe local reward ID when Twitch is disabled',()=>expect(loadConfig({TWITCH_ENABLED:'false'}).resurrectionRewardId).toBe('local-resurrection'))
it('requires the reward ID when Twitch is enabled',()=>expect(()=>loadConfig({TWITCH_ENABLED:'true',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_BROADCASTER_ID:'broadcaster',TWITCH_CHANNEL_LOGIN:'channel',TWITCH_BOT_ACCESS_TOKEN:'token',TWITCH_EXTENSION_SECRET:'extension'})).toThrow(/CHANNEL_POINTS_RESURRECTION_REWARD_ID/))
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { Game, type Clock, type IdGenerator, type RandomProvider } from '../../packages/domain/src/index.js'
import { generateFloor } from '../../packages/dungeon-generator/src/index.js'
function harness(rolls=[0]){
let now=1_000,id=0,index=0
const clock:Clock={now:()=>now},random:RandomProvider={next:()=>rolls[index++]??0},ids:IdGenerator={next:p=>`${p}-${++id}`}
const game=new Game({clock,random,ids,generateFloor,resurrectionRewardId:'resurrection'},'test')
return {game,setTime:(n:number)=>now=n,advance:(n:number)=>now+=n}
}
function spawn(game:Game,id='u1',name='One',event=`spawn-${id}`){return game.spawn({type:'spawn-requested',externalEventId:event,twitchUserId:id,displayName:name,broadcasterId:'b',followerVerified:true})}
function envelope(game:Game,command:any,requestId='r1') {const s=game.snapshot();if(s.phase.kind!=='player')throw new Error('not player');return {requestId,runId:s.runId,floorId:s.floor.floorId,phaseId:s.phase.phaseId,command}}
describe('authoritative game core',()=>{
it('AT-007 stays dormant until an eligible follower spawns',()=>{const {game,advance}=harness();advance(200_000);game.tick();expect(game.snapshot().phase.kind).toBe('dormant');expect(game.spawn({type:'spawn-requested',externalEventId:'x',twitchUserId:'u',displayName:'Nope',broadcasterId:'b',followerVerified:false}).reason).toBe('NOT_FOLLOWER');expect(spawn(game).accepted).toBe(true);expect(game.snapshot().phase.kind).toBe('player')})
it.each([[1,25_000],[2,50_000],[3,75_000],[4,100_000],[5,120_000],[6,120_000]])('AT-016 gives %i living players a %i ms capped phase',(count,duration)=>{const {game}=harness();for(let i=1;i<=count;i++)spawn(game,`u${i}`,`P${i}`,`s${i}`);(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();const phase=game.snapshot().phase;expect(phase.kind).toBe('player');if(phase.kind==='player')expect(phase.deadlineAt-phase.startedAt).toBe(duration)})
it('AT-010 prevents duplicate characters',()=>{const {game}=harness();expect(spawn(game).accepted).toBe(true);expect(spawn(game,'u1','One','spawn-2').reason).toBe('DUPLICATE');expect(game.snapshot().players).toHaveLength(1)})
it('AT-014 spends AP once and deduplicates request IDs',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const env=envelope(game,{type:'pass'});expect(game.command('u1',true,env).accepted).toBe(true);expect(game.snapshot().players[0]?.ap).toBe(1);expect(game.command('u1',true,env).reason).toBe('DUPLICATE');expect(game.snapshot().players[0]?.ap).toBe(1)})
it('AT-017 ends early after the initial eligible set spends AP',()=>{const {game}=harness([1,1]);spawn(game);game.bindExtension('u1');game.command('u1',true,envelope(game,{type:'pass'},'p1'));game.command('u1',true,envelope(game,{type:'pass'},'p2'));expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players[0]?.ap).toBe(2)})
it('rejects stale and deadline-boundary commands without AP loss',()=>{const {game,setTime}=harness();spawn(game);game.bindExtension('u1');const env=envelope(game,{type:'pass'});const phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();setTime(phase.deadlineAt);expect(game.command('u1',true,env).reason).toBe('DEADLINE_PASSED');expect(game.snapshot().players[0]?.ap).toBe(2);expect(game.command('u1',true,{...env,requestId:'stale',runId:'old'}).reason).toBe('STALE_RUN')})
it('AT-018 heals once and rejects invalid repeat with no AP loss',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');(game as any).state.players.u1.hp=1;expect(game.command('u1',true,envelope(game,{type:'heal-self'},'h1')).accepted).toBe(true);expect(game.snapshot().players[0]).toMatchObject({hp:3,healAvailable:false,ap:1});expect(game.command('u1',true,envelope(game,{type:'heal-self'},'h2')).reason).toBe('HEAL_USED');expect(game.snapshot().players[0]?.ap).toBe(1)})
it('AT-018 attacks adjacent Goblin, aggroes on miss, and kills on hits',()=>{const {game}=harness([1,0,0]);spawn(game);game.bindExtension('u1');const internal=(game as any).state;internal.players.u1.position={x:internal.goblin.position.x-1,y:internal.goblin.position.y};expect(game.command('u1',true,envelope(game,{type:'attack',targetId:'goblin'},'a1')).accepted).toBe(true);expect(game.snapshot().goblin).toMatchObject({hp:2,mode:'pursuing',targetPlayerId:'u1'});expect(game.command('u1',true,envelope(game,{type:'attack',targetId:'goblin'},'a2')).accepted).toBe(true);expect(game.snapshot().goblin.hp).toBeLessThanOrEqual(1)})
it('AT-025 resurrects only the matching dead player once for the configured Channel Points reward',()=>{const {game}=harness();spawn(game);const p=(game as any).state.players.u1;p.lifeState='dead';p.hp=0;p.healAvailable=false;p.diedOnFloor=1;expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'redemption',twitchUserId:'u1',rewardId:'wrong'}).reason).toBe('WRONG_REWARD');expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'redemption-2',twitchUserId:'u1',rewardId:'resurrection'}).accepted).toBe(true);expect(game.snapshot().players[0]).toMatchObject({lifeState:'alive',hp:3,ap:0,healAvailable:false});expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'redemption-2',twitchUserId:'u1',rewardId:'resurrection'}).reason).toBe('DUPLICATE')})
it('escapes with a living Goblin and rejects the old floor envelope',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const old=envelope(game,{type:'pass'},'old');const internal=(game as any).state,exit=internal.floor.exitPosition;internal.players.u1.position={x:exit.x-1,y:exit.y};const direction=exit.x>internal.players.u1.position.x?'right':'left';expect(game.command('u1',true,envelope(game,{type:'move',direction},'exit')).accepted).toBe(true);expect(game.snapshot().floorNumber).toBe(2);expect(game.command('u1',true,{...old,requestId:'old2'}).reason).toBe('STALE_FLOOR')})
})
+7
View File
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
import { generateFloor, validateFloor } from '../../packages/dungeon-generator/src/index.js'
describe('AT-021 deterministic floor generation',()=>{
it('returns the same floor for the same seed',()=>expect(generateFloor('same')).toEqual(generateFloor('same')))
it('generates 500 valid connected floors with variation',()=>{const floors=Array.from({length:500},(_,i)=>generateFloor(`seed-${i}`));expect(floors.every(validateFloor)).toBe(true);expect(new Set(floors.map(f=>`${f.width}x${f.height}:${f.spawnTiles[0]?.y}:${f.exitPosition.y}`)).size).toBeGreaterThan(20);for(const f of floors){expect(f.tiles.flat().filter(t=>t==='exit')).toHaveLength(1);expect(f.tiles[f.goblinGuardPosition.y]?.[f.goblinGuardPosition.x]).not.toBe('wall')}})
})
+7
View File
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Game } from '../../packages/domain/src/index.js'
import { generateFloor } from '../../packages/dungeon-generator/src/index.js'
describe('AT-013 multi-viewer authority',()=>{
it('lets two verified identities spend only their own AP',()=>{let id=0;const game=new Game({clock:{now:()=>0},random:{next:()=>1},ids:{next:p=>`${p}-${++id}`},generateFloor,resurrectionRewardId:'resurrection'});for(const u of ['a','b']){game.spawn({type:'spawn-requested',externalEventId:u,twitchUserId:u,displayName:u,broadcasterId:'x',followerVerified:true});game.bindExtension(u)}const s=game.snapshot(),phase=s.phase;if(phase.kind!=='player')throw new Error();const env=(u:string)=>({requestId:u,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'} as const});expect(game.command('a',true,env('a')).accepted).toBe(true);expect(game.snapshot().players.find(p=>p.twitchUserId==='a')?.ap).toBe(1);expect(game.snapshot().players.find(p=>p.twitchUserId==='b')?.ap).toBe(0);expect(game.command('b',false,env('cross')).reason).toBe('IDENTITY_NOT_BOUND')})
})
+15
View File
@@ -0,0 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { server } from '../../apps/backend/src/server.js'
let base=''
beforeAll(async()=>{await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));const address=server.address();if(!address||typeof address==='string')throw new Error('no address');base=`http://127.0.0.1:${address.port}`})
afterAll(async()=>{await new Promise<void>((resolve,reject)=>server.close(e=>e?reject(e):resolve()))})
async function post(path:string,value:unknown,token?:string){return fetch(`${base}${path}`,{method:'POST',headers:{'content-type':'application/json',...(token?{authorization:`Bearer ${token}`}:{})},body:JSON.stringify(value)})}
describe('backend boundary',()=>{
it('AT-001 exposes a secret-free readiness response',async()=>{const data=await fetch(`${base}/health`).then(r=>r.json());expect(data).toMatchObject({status:'ok',ready:true,twitchMode:'synthetic'});expect(JSON.stringify(data)).not.toMatch(/secret|token/i)})
it('AT-005 establishes a session without creating a character',async()=>{const r=await post('/api/extension/session',{token:'dev:nobody:Nobody'});expect(r.status).toBe(200);const data=await r.json();expect(data.state.viewer).toBeNull()})
it('AT-011 binds the same stable chat and Extension identity',async()=>{const id=`viewer-${Date.now()}`;expect((await post('/api/dev/spawn',{command:'!spawn',externalEventId:`e-${id}`,twitchUserId:id,displayName:'Viewer',followerVerified:true})).status).toBe(200);const auth=await (await post('/api/extension/session',{token:`dev:${id}:Viewer`})).json();expect(auth.twitchUserId).toBe(id);expect(auth.state.viewer.extensionBound).toBe(true);const s=auth.state,phase=s.phase;expect(phase.kind).toBe('player');const command=await post('/api/commands',{requestId:`r-${id}`,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}},auth.token);expect(command.status).toBe(200)})
it('AT-012 rejects unauthenticated controls',async()=>{const s=await fetch(`${base}/api/state`).then(r=>r.json()),phase=s.phase;const r=await post('/api/commands',{requestId:'unauth',runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}});expect(r.status).toBe(409);expect((await r.json()).reason).toBe('UNAUTHENTICATED')})
it('AT-025 normalizes Channel Points redemptions and rejects unknown owners safely',async()=>{const r=await post('/api/dev/redemption',{externalEventId:'redemption-unknown',twitchUserId:'missing-viewer',rewardId:'local-resurrection'});expect(r.status).toBe(409);expect((await r.json()).reason).toBe('UNKNOWN_PLAYER')})
})
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": ".",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["apps/**/*.ts", "packages/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}