Implement Channel Points Twungeon MVP
This commit is contained in:
@@ -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=
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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??''}
|
||||||
|
}
|
||||||
@@ -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}`))
|
||||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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))
|
||||||
@@ -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>
|
||||||
@@ -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%}}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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' } }
|
||||||
|
)
|
||||||
Generated
+3443
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types.js'
|
||||||
|
export * from './game.js'
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
@@ -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/))
|
||||||
|
})
|
||||||
@@ -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')})
|
||||||
|
})
|
||||||
@@ -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')}})
|
||||||
|
})
|
||||||
@@ -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')})
|
||||||
|
})
|
||||||
@@ -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')})
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user