Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3dd64291e | ||
|
|
879cfd4fd0 | ||
|
|
db19a89957 | ||
|
|
e24b1b4a3f | ||
|
|
8b1d14dcea | ||
|
|
65e9917fd3 | ||
|
|
2bcd6aa1f6 | ||
|
|
a8a86852c0 | ||
|
|
b27a66b1cd | ||
|
|
9ebd1ea283 | ||
|
|
df984690ce | ||
|
|
5bc70dd9b2 | ||
|
|
d4c063ffce |
+8
-1
@@ -9,5 +9,12 @@ TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
TWITCH_BROADCASTER_ID=
|
||||
TWITCH_CHANNEL_LOGIN=
|
||||
TWITCH_BOT_ACCESS_TOKEN=
|
||||
TWITCH_EXTENSION_SECRET=
|
||||
TWITCH_OAUTH_REDIRECT_URI=https://twungeon.example/oauth/callback
|
||||
TWITCH_TOKEN_FILE=/var/lib/twungeon/twitch-token.json
|
||||
|
||||
# Private operator panel. The listener and request filter are restricted to 10.138.0.0/16.
|
||||
ADMIN_ENABLED=false
|
||||
ADMIN_HOST=10.138.4.44
|
||||
ADMIN_PORT=3001
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
_model: devlog-entry
|
||||
---
|
||||
schema_version: 1
|
||||
---
|
||||
title: Four-player parties reform each floor with rolling turns
|
||||
---
|
||||
date: 2026-08-17
|
||||
---
|
||||
author: Codex and Christopher Chambers
|
||||
---
|
||||
summary: Twungeon now enforces four fixed party slots, reforms the party between floors, uses a five-second rolling player-phase timer, and presents consistent player colors in responsive desktop and mobile Extension layouts.
|
||||
---
|
||||
tags: implementation, multiplayer, party system, turn timer, Twitch Extension, mobile, responsive UI, testing
|
||||
---
|
||||
source_commit: 879cfd4fd071a240b79c25b5054a195578855130
|
||||
---
|
||||
body:
|
||||
|
||||
Twungeon's party and turn rules have been tightened around a predictable
|
||||
four-player multiplayer loop. Each character now occupies one of four fixed
|
||||
slots with a stable color: blue, green, red, or yellow. A fifth spawn is
|
||||
rejected as party-full, and defeated characters continue to hold their slot
|
||||
until the floor concludes.
|
||||
|
||||
Completing a floor now clears the active party and returns the game to its
|
||||
dormant state with the next dungeon ready. Connected and authenticated viewers
|
||||
remain eligible to spawn again, so every floor begins with a fresh, first-come
|
||||
party formation instead of carrying character state forward.
|
||||
|
||||
The player phase now runs on a rolling five-second deadline. The first accepted
|
||||
command from each active player restarts that deadline from the command's
|
||||
arrival time. Repeat commands, rejected actions, and duplicate submissions do
|
||||
not extend the phase, keeping turns responsive without allowing one player to
|
||||
stall the game indefinitely.
|
||||
|
||||
The Twitch Extension now uses the same slot colors across dungeon markers,
|
||||
names, party status, action feedback, and administrative views. Its single
|
||||
frontend adapts to desktop and mobile contexts: mobile viewers receive larger
|
||||
movement and action controls plus an at-a-glance identity, color, health,
|
||||
action-point, and healing display.
|
||||
|
||||
Documentation and acceptance coverage were updated alongside the mechanics.
|
||||
The completed change passed the domain and integration suites, linting, type
|
||||
checking, production build, and desktop and mobile browser layout checks.
|
||||
|
||||
The implementation is recorded in
|
||||
[commit `879cfd4fd071a240b79c25b5054a195578855130`](https://git.labyricorn.com/Labyricorn/Twungeon/commit/879cfd4fd071a240b79c25b5054a195578855130).
|
||||
@@ -0,0 +1,70 @@
|
||||
_model: devlog-entry
|
||||
---
|
||||
schema_version: 1
|
||||
---
|
||||
title: Twungeon reaches a live Twitch concept milestone
|
||||
---
|
||||
date: 2026-08-17
|
||||
---
|
||||
author: Codex and Christopher Chambers
|
||||
---
|
||||
summary: Twungeon moved from a local proof of concept to a live Twitch-connected deployment with broadcaster OAuth, Extension identity sharing, Channel Points resurrection, a focused viewer control panel, and an authenticated spawn button.
|
||||
---
|
||||
tags: implementation, concept validation, deployment, Twitch, Twitch Extension, OAuth, Channel Points, Twurple, Cloudflare, testing
|
||||
---
|
||||
source_commit: df984690cedf84fc58d3eb6e2000d2df567a8d5d
|
||||
---
|
||||
body:
|
||||
|
||||
Twungeon is now running as a live Twitch-connected concept on the
|
||||
[Labyricorn Twitch channel](https://www.twitch.tv/labyricorn). The deployed
|
||||
service connects Twurple chat and EventSub to the broadcaster account, verifies
|
||||
Twitch Extension identity on the server, and serves the Extension through the
|
||||
public Cloudflare tunnel at `https://twungeon.labyricorn.com`.
|
||||
|
||||
The authentication path required more than supplying a static token. Twungeon
|
||||
now provides a confidential OAuth authorization-code flow with state
|
||||
validation, checks that Twitch returned the configured broadcaster and required
|
||||
scopes, stores access and refresh credentials outside the repository with
|
||||
restricted permissions, and persists refreshed credentials atomically. The
|
||||
Extension exchanges its signed Twitch JWT for a short-lived Twungeon session;
|
||||
the shared Extension secret remains server-side.
|
||||
|
||||
The first setup attempt exposed several integration mismatches. The original
|
||||
Twitch application had been registered as a public client before the callback
|
||||
endpoint existed, so live server authorization required a new confidential
|
||||
application and the exact HTTPS `/oauth/callback` redirect. Enabling identity
|
||||
linking also made a privacy notice mandatory before the Extension version could
|
||||
advance. Both requirements are now reflected in the implementation and operator
|
||||
documentation.
|
||||
|
||||
The first live Extension view rendered the complete broadcast game instead of
|
||||
a compact control surface. The shared frontend now switches to a controller-only
|
||||
layout at `/extension`, while `/` remains the full game view used by the stream.
|
||||
Cloudflare also continued serving an older JavaScript bundle during testing;
|
||||
static responses now use `Cache-Control: no-store`, and the Extension loads a
|
||||
versioned script URL to force the current controller code.
|
||||
|
||||
Spawning revealed one final usability gap. Chat `!spawn` was not sufficient for
|
||||
the broadcaster because a channel owner cannot follow their own channel, and
|
||||
the Extension offered no direct alternative. The control panel now includes a
|
||||
**Spawn character** button backed by an authenticated server endpoint. It uses
|
||||
the verified Twitch viewer ID, applies the normal follower rule, and treats the
|
||||
configured broadcaster as eligible. The same eligibility correction also
|
||||
allows broadcaster chat spawning.
|
||||
|
||||
The current deployment reports healthy with the Twitch adapter ready. Viewers
|
||||
can authorize their identity, spawn from the Extension, and use the movement,
|
||||
attack, heal, and pass controls for their own character. The configured custom
|
||||
Channel Points reward drives resurrection through EventSub. The final automated
|
||||
run passed linting, strict type checking, 24 domain tests, 10 integration and
|
||||
multi-viewer tests, and the production build.
|
||||
|
||||
This establishes the live interaction concept, but it is not a claim that every
|
||||
MVP acceptance gate is complete. Broader real-viewer, multi-viewer, usability,
|
||||
fault-injection, soak, and independent-operator evidence remains to be recorded.
|
||||
Game state is still intentionally in memory, so restarting the service starts a
|
||||
new run.
|
||||
|
||||
The live Twitch integration and spawn-control milestone are recorded in
|
||||
[commit `df984690cedf84fc58d3eb6e2000d2df567a8d5d`](https://git.labyricorn.com/Labyricorn/Twungeon/commit/df984690cedf84fc58d3eb6e2000d2df567a8d5d).
|
||||
@@ -0,0 +1,62 @@
|
||||
_model: devlog-entry
|
||||
---
|
||||
schema_version: 1
|
||||
---
|
||||
title: Private operations arrive with graceful viewer disconnects
|
||||
---
|
||||
date: 2026-08-17
|
||||
---
|
||||
author: Codex and Christopher Chambers
|
||||
---
|
||||
summary: Twungeon gained a private, authenticated operator console and last-socket disconnect handling that sends absent viewers directly into AutoGuard instead of delaying every player phase.
|
||||
---
|
||||
tags: implementation, deployment, operations, admin panel, networking, security, multiplayer, AutoGuard, testing
|
||||
---
|
||||
source_commit: 8b1d14dceac8f9645f70cab106a045ed64bcd71c
|
||||
---
|
||||
body:
|
||||
|
||||
Live multi-viewer testing revealed an awkward failure mode: when a viewer left
|
||||
the channel, their character remained in the dungeon with unspent action points.
|
||||
Keeping the character was intentional because a refresh or temporary network
|
||||
failure should not destroy a viewer's progress, but waiting for its action-point
|
||||
deadline made each later player phase appear stuck.
|
||||
|
||||
Twungeon now tracks authenticated WebSocket connections per Twitch user. A
|
||||
viewer is marked disconnected only after their final socket closes, so a second
|
||||
tab remains valid and closing one of several connections does not incorrectly
|
||||
end the turn. On the last close, unused action points immediately become Guard.
|
||||
Extension-bound characters that remain disconnected also enter AutoGuard as
|
||||
each later phase starts. Their characters stay on the board for a safe
|
||||
reconnection while connected viewers can complete the phase normally.
|
||||
|
||||
The same milestone adds a private operator console for live troubleshooting.
|
||||
It runs on a separate listener rather than the public Twungeon origin. The
|
||||
production listener binds specifically to `10.138.4.44:3001`, and the
|
||||
application rejects source addresses outside `10.138.0.0/16`. Basic
|
||||
authentication, a unique generated deployment password, per-process form
|
||||
tokens, no-store responses, a restrictive Content Security Policy, and frame
|
||||
denial provide additional boundaries. The port is not exposed through the
|
||||
public web listener or reverse proxy.
|
||||
|
||||
The server-rendered console shows the current run, floor, phase, players,
|
||||
Twitch readiness, OAuth authorization, uptime, and the latest 40 action-log
|
||||
entries. An operator can disconnect a viewer into AutoGuard, remove a character,
|
||||
end the active player phase, or reset the run. These operations call the same
|
||||
authoritative game core used by viewer commands and broadcast the resulting
|
||||
snapshot to connected clients.
|
||||
|
||||
Configuration remains opt-in. The admin listener will not start unless it is
|
||||
explicitly enabled with a `10.138.x.x` bind address and a password of at least
|
||||
16 characters. Operator documentation records the environment settings,
|
||||
network boundary, authentication flow, and the requirement not to publish or
|
||||
proxy the private port.
|
||||
|
||||
Validation covered linting, strict type checking, a production build, 28 domain
|
||||
tests, and 13 integration and end-to-end tests. Deployment checks confirmed
|
||||
that the public service and Twitch adapter were ready, authenticated admin
|
||||
health succeeded on the private address, unauthenticated access was rejected,
|
||||
and the admin port was not listening on loopback.
|
||||
|
||||
The private operations and graceful-disconnect implementation is recorded in
|
||||
[commit `8b1d14dceac8f9645f70cab106a045ed64bcd71c`](https://git.labyricorn.com/Labyricorn/Twungeon/commit/8b1d14dceac8f9645f70cab106a045ed64bcd71c).
|
||||
@@ -0,0 +1,63 @@
|
||||
_model: devlog-entry
|
||||
---
|
||||
schema_version: 1
|
||||
---
|
||||
title: Twungeon controls move into the Twitch video overlay
|
||||
---
|
||||
date: 2026-08-17
|
||||
---
|
||||
author: Codex and Christopher Chambers
|
||||
---
|
||||
summary: Twungeon replaced its component-style viewer popup with a transparent, responsive Twitch video overlay that reuses the authenticated controller while leaving the streamed dungeon visible.
|
||||
---
|
||||
tags: implementation, deployment, Twitch, Twitch Extension, video overlay, responsive UI, accessibility, testing
|
||||
---
|
||||
source_commit: a8a86852c0e06e518d816ea35400ef9878a365f3
|
||||
---
|
||||
body:
|
||||
|
||||
Twungeon's viewer controls now sit directly over the control area reserved in
|
||||
the stream instead of opening as a large Twitch Video Component popup. The
|
||||
production viewer uses Twitch's Video - Fullscreen placement with `extension`
|
||||
as its relative viewer path. The broadcast continues to provide the dungeon,
|
||||
players, Goblin, exit, status frame, and action log underneath the Extension.
|
||||
|
||||
The change deliberately reuses the existing controller and authoritative
|
||||
backend flow. Twitch identity sharing, short-lived Twungeon sessions, character
|
||||
ownership, movement, attack, self-heal, pass, action points, phase checks, and
|
||||
dead-character checks still use the same APIs and server-side rules. The root
|
||||
page remains the complete local and broadcast view, while `/extension` receives
|
||||
an overlay class before first paint and hides every noninteractive game surface.
|
||||
|
||||
Overlay placement is expressed through centralized percentage-based CSS
|
||||
variables for the control region rather than resolution-specific coordinates.
|
||||
Container-relative sizing keeps the directional pad and action buttons usable
|
||||
across normal, theater, fullscreen, narrow, and 4:3 players. The transparent
|
||||
document and empty overlay canvas reject pointer events; only the actual buttons
|
||||
restore them, so the rest of the video is not presented as a large clickable
|
||||
surface.
|
||||
|
||||
Development remains practical without Twitch. The loopback-only
|
||||
`/extension?dev=1&debug=1` mode exposes the existing synthetic viewer login and
|
||||
draws control-region boundaries. The flags are ignored on the deployed host.
|
||||
Operator documentation now identifies the Twitch dashboard setting as
|
||||
**Video - Fullscreen**, records the `extension` viewer path, and separates the
|
||||
manual dashboard change from repository deployment.
|
||||
|
||||
Live testing exposed a subtle transparency failure after the correct viewer
|
||||
path was saved. Although the document, body, and control region all computed to
|
||||
transparent backgrounds, the root `color-scheme: dark` declaration caused
|
||||
Chrome to paint the iframe canvas black. Dark color scheme now applies only to
|
||||
the full game page; overlay mode explicitly uses the normal transparent canvas.
|
||||
The stylesheet fix was deployed without restarting the Twungeon service, so the
|
||||
active in-memory run was preserved.
|
||||
|
||||
Validation covered linting, strict type checking, 24 domain tests, 10
|
||||
integration and multi-viewer tests, and a production build. Browser checks at
|
||||
1920×1080, 1280×720, and 1024×768 confirmed normalized placement, hidden
|
||||
noninteractive surfaces, button-only pointer handling, and transparent computed
|
||||
backgrounds. The deployed public stylesheet and viewer endpoint were verified
|
||||
after the final transparency correction.
|
||||
|
||||
The Twitch video-overlay implementation is recorded in
|
||||
[commit `a8a86852c0e06e518d816ea35400ef9878a365f3`](https://git.labyricorn.com/Labyricorn/Twungeon/commit/a8a86852c0e06e518d816ea35400ef9878a365f3).
|
||||
@@ -13,6 +13,13 @@ 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.
|
||||
|
||||
Each floor has four first-come, first-served party slots with fixed Blue, Green,
|
||||
Red, and Yellow identities. Reaching the exit clears the party and opens the
|
||||
next floor for a complete reparty. Player phases use a rolling five-second
|
||||
deadline that each living player may reset once with their first accepted
|
||||
action. The same Twitch Extension provides compact desktop controls and a
|
||||
touch-oriented mobile layout backed by the same authoritative game instance.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements: Node.js 22 or newer and npm.
|
||||
@@ -24,6 +31,10 @@ npm run dev
|
||||
|
||||
Open `http://localhost:3000`. Expand **Local viewer login**, then choose
|
||||
**Spawn & bind** to exercise the complete local controller loop without Twitch.
|
||||
The production Twitch viewer is the transparent Video Overlay at
|
||||
`http://localhost:3000/extension`; use
|
||||
`http://localhost:3000/extension?dev=1&debug=1` to exercise its shared controls
|
||||
and view alignment boundaries locally.
|
||||
|
||||
Quality gates:
|
||||
|
||||
@@ -37,6 +48,8 @@ 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).
|
||||
The optional private operator console is documented in
|
||||
[docs/admin-panel.md](docs/admin-panel.md).
|
||||
|
||||
## Planning documents
|
||||
|
||||
|
||||
@@ -166,6 +166,9 @@ events without importing Twitch, RPGJS, HTTP, WebSocket, or browser code.
|
||||
- [ ] Implement the broadcast-mode three-section layout.
|
||||
- [ ] Render players, Goblin, walls, paths, exit, shared status, and action log
|
||||
with temporary but distinguishable visuals.
|
||||
- [ ] Render deterministic Blue, Green, Red, and Yellow party-slot markers and names.
|
||||
- [ ] Provide desktop and mobile Extension layouts with mobile controls at least
|
||||
44 pixels and essential viewer-specific status kept visible.
|
||||
- [ ] Render the exact dormant banner text.
|
||||
|
||||
**Checkpoint P3:** A local read-only stream view can cycle through many valid,
|
||||
@@ -177,7 +180,8 @@ visibly varied floors without a Twitch connection.
|
||||
|
||||
- [ ] Implement dormant, Player Phase, Enemy Phase, and transition states.
|
||||
- [ ] Start Player Phase with 2 AP and zero Guard for each living participant.
|
||||
- [ ] Implement 25 seconds per living player with a 120-second cap.
|
||||
- [ ] Implement a rolling five-second deadline with one reset per living
|
||||
player's first accepted command in each phase.
|
||||
- [ ] End Player Phase early when its initial eligible set is finished or dead.
|
||||
- [ ] Decide deadline acceptance using gateway arrival before the monotonic
|
||||
deadline; reject arrivals at or after the deadline.
|
||||
@@ -229,16 +233,17 @@ target death, return, death, and escape with the Goblin alive.
|
||||
- [ ] Advance immediately when a living player enters the exit.
|
||||
- [ ] Reject queued commands for the previous floor.
|
||||
- [ ] Generate the next floor and Goblin.
|
||||
- [ ] Revive, heal, reposition, and refresh self-heal for all participants on
|
||||
floor advancement.
|
||||
- [ ] Reset to a new Floor 1 run immediately after the last participant dies.
|
||||
- [ ] Distinguish zero participants from a total-party wipe.
|
||||
- [ ] Clear all previous party records and enter dormancy on floor advancement.
|
||||
- [ ] Reassign all four slots and colors first come, first served with no
|
||||
previous-player reservation.
|
||||
- [ ] Return the current floor to dormancy immediately after the last participant dies.
|
||||
- [ ] Distinguish initial dormancy from defeated-party dormancy.
|
||||
- [ ] Preserve character identity and state across simulated client reconnects.
|
||||
- [ ] Verify expected new-run behavior after a simulated backend restart.
|
||||
- [ ] Run a deterministic multi-floor soak test.
|
||||
|
||||
**Checkpoint P6:** A local driver can repeat the entire gameplay loop through
|
||||
spawn, action, death, resurrection or advancement, new floors, and wipe reset.
|
||||
spawn, action, death, dormant defeat, resurrection or advancement, and new floors.
|
||||
|
||||
### Phase 7: Backend API, state stream, and UIs
|
||||
|
||||
@@ -403,8 +408,8 @@ successful connection logs.
|
||||
|
||||
- [ ] Start with zero participants and wait longer than a maximum Player Phase.
|
||||
- [ ] Confirm the floor remains loaded, no timer or Enemy Phase runs, the Goblin
|
||||
does not act, and the exact `Type !spawn to spawn in the Twungeon!` banner is
|
||||
shown.
|
||||
does not act, and the exact two-line `Activate the Extension to spawn your character in the Twungeon!`
|
||||
and `Must be a follower to spawn.` banner is shown.
|
||||
|
||||
**Level:** Automated domain/integration plus visual review
|
||||
**Pass evidence:** State assertions, elapsed-time logs, and screenshot.
|
||||
@@ -415,6 +420,8 @@ successful connection logs.
|
||||
|
||||
- [ ] As a verified follower with no character or same-floor death, issue
|
||||
`!spawn` and confirm one character appears in the current spawn room.
|
||||
- [ ] Fill all four party slots, confirm the fifth eligible viewer receives a
|
||||
clear `PARTY_FULL` result, and confirm a dead member still occupies a slot.
|
||||
|
||||
**Level:** Live integration
|
||||
**Pass evidence:** Chat capture, follower result, spawn event, and stream capture.
|
||||
@@ -492,12 +499,14 @@ successful connection logs.
|
||||
**Level:** Automated domain/integration
|
||||
**Pass evidence:** State-transition assertions and ordered event log.
|
||||
|
||||
### AT-016 — Scaled timer and cap
|
||||
### AT-016 — Rolling five-second timer
|
||||
|
||||
**PRD criterion:** 16
|
||||
|
||||
- [ ] Verify durations of 25, 50, 75, 100, and 120 seconds for one through five
|
||||
living players and 120 seconds for more than five.
|
||||
- [ ] Confirm phase start sets a five-second deadline.
|
||||
- [ ] Confirm Player A's first accepted command resets it to five seconds,
|
||||
Player A's second does not, and Player B's first accepted command does.
|
||||
- [ ] Confirm rejected and duplicate commands do not reset the deadline.
|
||||
|
||||
**Level:** Automated fake-clock domain test plus one live timing check
|
||||
**Pass evidence:** Parameterized test output and live timer recording.
|
||||
@@ -564,17 +573,21 @@ successful connection logs.
|
||||
**PRD criterion:** 22
|
||||
|
||||
- [ ] Aggro but do not kill the Goblin, move a living player onto the exit, and
|
||||
confirm immediate group advancement and a new Goblin on the next floor.
|
||||
confirm immediate floor advancement, a new Goblin, an empty party, and
|
||||
Dormant state on the next floor.
|
||||
|
||||
**Level:** Automated E2E plus live scenario
|
||||
**Pass evidence:** Before/after snapshots and stream recording.
|
||||
|
||||
### AT-023 — Dead players revive on advancement
|
||||
### AT-023 — Full next-floor reparty
|
||||
|
||||
**PRD criterion:** 23
|
||||
|
||||
- [ ] Kill one player, advance with another, and confirm the dead player returns
|
||||
at spawn with full HP and refreshed self-heal on the next floor.
|
||||
- [ ] Fill four slots, kill one player, and advance with another.
|
||||
- [ ] Confirm every previous character is removed, all four slots are open, and
|
||||
no previous player has a reservation.
|
||||
- [ ] Spawn a new viewer before a previous party member and confirm they receive
|
||||
Slot 1/Blue while the previous member receives the next available slot/color.
|
||||
|
||||
**Level:** Automated E2E plus live scenario
|
||||
**Pass evidence:** Before/after player and floor state.
|
||||
@@ -603,25 +616,25 @@ successful connection logs.
|
||||
**Level:** Automated integration plus live Channel Points redemption
|
||||
**Pass evidence:** Redacted event, deduplication log, and before/after state.
|
||||
|
||||
### AT-026 — Total-party wipe reset
|
||||
### AT-026 — Total-party defeat dormancy
|
||||
|
||||
**PRD criterion:** 26
|
||||
|
||||
- [ ] Kill the final living participant before exit.
|
||||
- [ ] Confirm an immediate new run on Floor 1, new floor/Goblin, every participant
|
||||
alive at full HP, refreshed heals, and one wipe/reset log sequence.
|
||||
- [ ] Confirm immediate dormancy on the current run and floor, no active timer,
|
||||
every participant still dead, and one party-defeat log entry.
|
||||
- [ ] Resurrect one participant and confirm a fresh Player Phase begins.
|
||||
|
||||
**Level:** Automated E2E plus live scenario
|
||||
**Pass evidence:** Run/floor transition and participant snapshots.
|
||||
**Pass evidence:** Before/after phase, run, floor, participant, and log snapshots.
|
||||
|
||||
### AT-027 — Zero players differs from wipe
|
||||
### AT-027 — Initial dormancy differs from defeated-party dormancy
|
||||
|
||||
**PRD criterion:** 27
|
||||
|
||||
- [ ] Compare a new game with no participants to a run where the final player
|
||||
dies.
|
||||
- [ ] Confirm the first remains dormant on its current floor and the second
|
||||
resets immediately with participants restored.
|
||||
- [ ] Compare a new game with no participants to a run where the final player dies.
|
||||
- [ ] Confirm both are dormant, while only the defeated run preserves dead
|
||||
participant records and a party-defeat log entry.
|
||||
|
||||
**Level:** Automated domain/integration
|
||||
**Pass evidence:** Side-by-side state-transition assertions.
|
||||
@@ -655,7 +668,7 @@ successful connection logs.
|
||||
**PRD criterion:** 30
|
||||
|
||||
- [ ] Run a multi-floor soak containing spawns, concurrent actions, expiry,
|
||||
combat, heal, death, resurrection, escape, advancement, and wipe reset.
|
||||
combat, heal, death, defeated-party dormancy, resurrection, escape, and advancement.
|
||||
- [ ] Confirm integration, phase, player, Goblin, generator, and log invariants
|
||||
remain valid throughout.
|
||||
|
||||
|
||||
+56
-49
@@ -18,7 +18,7 @@ tracks build sequencing and evidence without replacing these requirements.
|
||||
|
||||
The MVP is intended to prove that the complete interaction loop works at a basic level:
|
||||
|
||||
**Twitch viewer -> follower eligibility -> `!spawn` -> character control -> turn-based dungeon play -> combat -> healing -> death -> resurrection or floor advance -> new floor -> run reset**
|
||||
**Twitch viewer -> follower eligibility -> `!spawn` -> character control -> turn-based dungeon play -> combat -> healing -> death -> dormant defeat -> resurrection or a new adventurer -> floor advance**
|
||||
|
||||
The MVP is not intended to prove game balance, long-term progression, production presentation, or anti-exploit systems.
|
||||
|
||||
@@ -37,8 +37,8 @@ The MVP should demonstrate that multiple Twitch viewers can:
|
||||
- die and remain inactive for the current floor,
|
||||
- resurrect through a configured Channel Points reward,
|
||||
- advance the entire party by reaching the exit,
|
||||
- revive dead players on floor advancement,
|
||||
- and reset the run when all participating players die.
|
||||
- reform the party from scratch on floor advancement,
|
||||
- and return the current floor to dormancy when all participating players die.
|
||||
|
||||
The MVP succeeds if these systems work together reliably enough to test the core concept with real Twitch viewers.
|
||||
|
||||
@@ -74,13 +74,18 @@ When a valid `!spawn` command is received:
|
||||
2. The backend verifies that the viewer follows the channel.
|
||||
3. The backend verifies that the viewer does not already have an active character.
|
||||
4. The backend verifies that the viewer has not died on the current floor.
|
||||
5. A character is created in the current floor's spawn room.
|
||||
6. The game records the Twitch user ID as the authoritative identity for that character.
|
||||
7. The player's Twitch Extension identity must resolve to the same Twitch user ID before game controls are enabled.
|
||||
8. The player's Twitch Extension game controls become active.
|
||||
5. The backend verifies that one of four party slots remains available.
|
||||
6. A character is created in the current floor's spawn room and assigned the lowest available party slot.
|
||||
7. The game records the Twitch user ID as the authoritative identity for that character.
|
||||
8. The player's Twitch Extension identity must resolve to the same Twitch user ID before game controls are enabled.
|
||||
9. The player's Twitch Extension game controls become active.
|
||||
|
||||
Only one active character may exist per Twitch user.
|
||||
|
||||
Each floor has exactly four first-come, first-served party slots. Dead players
|
||||
continue occupying their slot until the floor ends. A fifth eligible viewer
|
||||
remains a spectator and receives a clear party-full result.
|
||||
|
||||
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
|
||||
@@ -118,6 +123,12 @@ The shared Twitch stream is the primary game display.
|
||||
|
||||
Players do not require a separate conventional game client.
|
||||
|
||||
The same Extension adapts to desktop and mobile without creating another game
|
||||
instance. Desktop retains the information-rich controller. Mobile reflows the
|
||||
controller around touch targets of approximately 44 pixels or larger and keeps
|
||||
identity/color, HP, AP, and heal availability visible without shrinking the
|
||||
desktop layout proportionally.
|
||||
|
||||
### 4.1 Three-Section MVP Layout
|
||||
|
||||
The MVP game view must establish the intended structure of the final Twungeon experience even if the graphics and styling remain temporary.
|
||||
@@ -157,7 +168,7 @@ It must provide clear confirmation of significant game actions and state changes
|
||||
- Goblin aggro or return behavior when useful,
|
||||
- floor advancement,
|
||||
- dormant-state changes,
|
||||
- and total-party wipe/run reset.
|
||||
- and total-party defeat/dormancy.
|
||||
|
||||
Example messages include:
|
||||
|
||||
@@ -202,7 +213,8 @@ When the first eligible player spawns:
|
||||
|
||||
A player who already died on the current floor remains dead and cannot use `!spawn` to re-enter.
|
||||
|
||||
Zero active players is not considered a total-party wipe.
|
||||
Zero active players with no participant history and a defeated party are both
|
||||
dormant, but the latter preserves its dead character records and current floor.
|
||||
|
||||
The MVP maintains only one active floor at a time. No previous-floor simulation or multi-floor concurrent state is required.
|
||||
|
||||
@@ -251,21 +263,11 @@ Players do not need to act in a fixed sequential order. Valid player commands ma
|
||||
|
||||
### 7.1 Player Phase Timer
|
||||
|
||||
The Player Phase timer scales with the number of living players:
|
||||
|
||||
**25 seconds per living player**
|
||||
|
||||
The timer is capped at:
|
||||
|
||||
**120 seconds maximum**
|
||||
|
||||
Examples:
|
||||
|
||||
- 1 living player: 25 seconds
|
||||
- 2 living players: 50 seconds
|
||||
- 3 living players: 75 seconds
|
||||
- 4 living players: 100 seconds
|
||||
- 5 or more living players: 120 seconds
|
||||
The Player Phase uses a rolling **5-second** timer. It begins at five seconds.
|
||||
Each living player's first accepted command during that phase resets the
|
||||
deadline to five seconds after that command arrived. Further commands from the
|
||||
same player do not reset it again. A different player's first accepted command
|
||||
may reset it once, so one player cannot extend the phase indefinitely.
|
||||
|
||||
The Player Phase ends when either:
|
||||
|
||||
@@ -463,12 +465,16 @@ When any living player enters the exit:
|
||||
|
||||
1. The current floor ends immediately.
|
||||
2. The floor counter increases.
|
||||
3. A new two-room floor is generated.
|
||||
4. A new Goblin Guard is created.
|
||||
5. All participating players are placed in the new spawn room.
|
||||
6. All players are restored to full HP.
|
||||
7. All dead players are resurrected.
|
||||
8. Every player's self-heal is refreshed.
|
||||
3. Every previous party slot and character is cleared, alive or dead.
|
||||
4. A new two-room floor is generated.
|
||||
5. A new Goblin Guard is created.
|
||||
6. The new floor enters Dormant state.
|
||||
7. Eligible followers claim the four new party slots first come, first served.
|
||||
|
||||
Previous party members receive no reservation and must spawn again. Twitch
|
||||
identity and Extension sessions may persist for authentication, but character
|
||||
HP, AP, Guard, heal use, death state, position, slot, and color do not carry
|
||||
into the new party.
|
||||
|
||||
The dungeon may continue generating floors indefinitely for the MVP.
|
||||
|
||||
@@ -487,10 +493,9 @@ While dead:
|
||||
|
||||
There is no timed automatic resurrection.
|
||||
|
||||
A dead player returns to play only through:
|
||||
|
||||
1. immediate Channel Points resurrection, or
|
||||
2. another living player reaching the next floor.
|
||||
A dead player returns during the current floor only through immediate Channel
|
||||
Points resurrection. When another player reaches the exit, the entire party is
|
||||
cleared; that viewer may compete to spawn a fresh character on the next floor.
|
||||
|
||||
---
|
||||
|
||||
@@ -510,20 +515,21 @@ Bits and Cheers are outside the MVP and must not trigger game-state changes.
|
||||
|
||||
---
|
||||
|
||||
## 18. Total Party Wipe
|
||||
## 18. Total Party Defeat
|
||||
|
||||
If every active participating player is dead before any player reaches the exit:
|
||||
|
||||
**The run resets.**
|
||||
**The current floor returns to the Dormant state.**
|
||||
|
||||
On reset:
|
||||
On defeat:
|
||||
|
||||
1. The floor counter returns to Floor 1.
|
||||
2. A new starting floor is generated.
|
||||
3. All participating players return alive.
|
||||
4. Player HP returns to full.
|
||||
5. Player self-heals refresh.
|
||||
6. A new Goblin Guard is generated.
|
||||
1. The current run, floor, and Goblin remain loaded.
|
||||
2. All defeated characters remain dead and retain their current-floor death markers.
|
||||
3. No phase timer runs and the Goblin does not act.
|
||||
4. A configured resurrection can revive a dead viewer and begin a fresh Player Phase.
|
||||
5. If fewer than four party slots were claimed, a different eligible viewer may
|
||||
claim an open slot and begin a fresh Player Phase; a defeated full party
|
||||
requires resurrection to resume the current floor.
|
||||
|
||||
---
|
||||
|
||||
@@ -537,6 +543,7 @@ The MVP must maintain enough authoritative player state to support:
|
||||
- verified chat-to-Extension identity binding,
|
||||
- follower eligibility,
|
||||
- one character per Twitch user,
|
||||
- current-floor party slot and its fixed Blue, Green, Red, or Yellow color,
|
||||
- current HP,
|
||||
- alive/dead state,
|
||||
- current AP,
|
||||
@@ -655,7 +662,7 @@ The MVP is successful if it demonstrates that:
|
||||
4. Twungeon successfully receives the Twitch events and chat activity required by the MVP.
|
||||
5. The Twitch Extension loads and communicates with the Twungeon backend.
|
||||
6. The stream-facing game view presents the three-section MVP layout: control/status area, main game area, and running action log.
|
||||
7. With zero active players, the dungeon enters dormant mode, stops advancing turns, and displays the `Type !spawn to spawn in the Twungeon!` banner.
|
||||
7. With zero active players, the dungeon enters dormant mode, stops advancing turns, and displays a two-line banner: `Activate the Extension to spawn your character in the Twungeon!` and `Must be a follower to spawn.`
|
||||
8. A Twitch follower can intentionally join with `!spawn`.
|
||||
9. A non-follower cannot spawn a player character.
|
||||
10. Duplicate spawning for the same Twitch user is prevented.
|
||||
@@ -664,18 +671,18 @@ The MVP is successful if it demonstrates that:
|
||||
13. Multiple viewers can control separate characters in the same dungeon.
|
||||
14. Twitch Extension commands reliably control movement and actions.
|
||||
15. The Player Phase / Enemy Phase cycle functions correctly.
|
||||
16. The Player Phase timer scales at 25 seconds per living player and caps at 120 seconds.
|
||||
16. The Player Phase uses a rolling 5-second timer that each living player can reset only once with their first accepted command.
|
||||
17. The Player Phase ends early when all living players finish.
|
||||
18. AP, AutoGuard, attacks, healing, HP, and death interact correctly.
|
||||
19. The action log accurately reports significant game actions and state changes.
|
||||
20. The Goblin can guard, aggro, pursue, attack, return, and remain behind on floor transition.
|
||||
21. Two-room floors can be generated repeatedly with varying room sizes, positions, and navigable connections.
|
||||
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.
|
||||
22. A surviving player can reach the exit without necessarily killing the Goblin, clear the current party, and open the next floor for a new party.
|
||||
23. Each floor admits at most four party members, assigns deterministic slot colors, and repartees first come, first served without reservations.
|
||||
24. A player who died on the current floor cannot bypass death by using `!spawn`.
|
||||
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.
|
||||
27. Zero active players is distinguished from a total-party wipe.
|
||||
26. A total-party defeat returns the current floor to dormancy without reviving players or resetting the run.
|
||||
27. Initial dormancy is distinguished from defeated-party dormancy by preserved participant state and action-log history.
|
||||
28. An eligible follower who has not died on the current floor can `!spawn` into an otherwise empty current floor.
|
||||
29. Refreshes and temporary disconnects do not create duplicate characters or corrupt critical identity/game state.
|
||||
30. The complete loop can repeat across multiple floors without breaking Twitch integration, turns, player state, Goblin state, floor generation, or the action log.
|
||||
|
||||
@@ -49,7 +49,7 @@ The implementation must optimize for the following goals, in order:
|
||||
- One character per Twitch user
|
||||
- Twitch Extension controls
|
||||
- Shared stream-facing game view and chronological action log
|
||||
- Dormant, Player Phase, Enemy Phase, floor transition, and run reset states
|
||||
- Dormant, Player Phase, Enemy Phase, floor transition, and administrative run reset states
|
||||
- AP, movement, combat, AutoGuard, self-heal, death, and resurrection
|
||||
- One Goblin Guard with guarding, pursuit, and return behavior
|
||||
- Varied, navigable two-room floors
|
||||
@@ -246,6 +246,8 @@ interface PlayerState {
|
||||
followerVerified: boolean
|
||||
characterCreated: boolean
|
||||
extensionBound: boolean
|
||||
partySlot: 1 | 2 | 3 | 4
|
||||
color: 'blue' | 'green' | 'red' | 'yellow'
|
||||
connectionState: 'connected' | 'disconnected'
|
||||
position: TilePosition
|
||||
hp: 0 | 1 | 2 | 3
|
||||
@@ -274,9 +276,10 @@ type PhaseState =
|
||||
startedAt: number
|
||||
deadlineAt: number
|
||||
initialEligiblePlayerIds: string[]
|
||||
timerResetPlayerIds: string[]
|
||||
}
|
||||
| { kind: 'enemy'; phaseId: string }
|
||||
| { kind: 'transition'; reason: 'floor-advance' | 'party-wipe' }
|
||||
| { kind: 'transition'; reason: 'floor-advance' | 'admin-reset' }
|
||||
```
|
||||
|
||||
The backend uses a monotonic clock for elapsed phase timing. Wall-clock values
|
||||
@@ -352,6 +355,12 @@ New characters spawn on the first spawn tile chosen by deterministic ordering.
|
||||
Players may share that tile. A late join never changes the current phase timer
|
||||
or the set of players required to finish the phase.
|
||||
|
||||
The authoritative player record is also the current-floor party roster. At
|
||||
most four records may exist. Spawn assigns the lowest open slot and its fixed
|
||||
color: Slot 1 Blue, Slot 2 Green, Slot 3 Red, and Slot 4 Yellow. Dead records
|
||||
remain in the roster and occupy their slots. A fifth spawn is rejected as
|
||||
`PARTY_FULL` without creating a character.
|
||||
|
||||
### 7.3 Attack range and targeting
|
||||
|
||||
- Player and Goblin attacks target an orthogonally adjacent tile.
|
||||
@@ -438,9 +447,9 @@ While no active character exists:
|
||||
The first accepted `!spawn` creates a living character, removes the banner, and
|
||||
starts a fresh Player Phase.
|
||||
|
||||
The MVP has no voluntary leave or character-removal command. Temporary
|
||||
disconnects therefore do not create dormancy. A total-party death triggers a
|
||||
run reset rather than dormancy.
|
||||
Temporary disconnects do not create dormancy because disconnected living
|
||||
characters use AutoGuard. A total-party death does create dormancy while
|
||||
preserving the run, floor, Goblin, and dead character records.
|
||||
|
||||
### 9.2 Starting Player Phase
|
||||
|
||||
@@ -451,14 +460,12 @@ For every living participant present when the phase starts:
|
||||
3. set `eligibleThisPhase` to true; and
|
||||
4. include the player in `initialEligiblePlayerIds`.
|
||||
|
||||
The duration is:
|
||||
|
||||
```text
|
||||
min(living-player-count * 25 seconds, 120 seconds)
|
||||
```
|
||||
|
||||
The count includes disconnected living characters, matching the PRD's AFK
|
||||
AutoGuard behavior.
|
||||
The initial deadline is five seconds after phase start. The phase begins with
|
||||
an empty `timerResetPlayerIds` list. After each player's first accepted command,
|
||||
the backend adds that Twitch user ID and moves `deadlineAt` to five seconds
|
||||
after the gateway arrival time. Later commands from that player do not move the
|
||||
deadline. Rejected, duplicate, disconnected, and AutoGuard events do not reset
|
||||
the timer.
|
||||
|
||||
Players who spawn or resurrect after the phase starts receive 0 AP, are not
|
||||
added to the phase completion set, and may act beginning with the next Player
|
||||
@@ -485,7 +492,7 @@ The living Goblin receives 2 AP and repeatedly selects one deterministic action
|
||||
until it has no AP or no valid action.
|
||||
|
||||
After the Goblin finishes, Guard is discarded and a new Player Phase begins,
|
||||
unless the floor advanced or the run reset during the Enemy Phase.
|
||||
unless the floor advanced or the party became dormant during the Enemy Phase.
|
||||
|
||||
### 9.5 AutoGuard
|
||||
|
||||
@@ -506,7 +513,8 @@ An accepted spawn requires:
|
||||
- a stable Twitch chat user ID;
|
||||
- verified follower status;
|
||||
- no existing character for that ID; and
|
||||
- no death marker for that ID on the current floor.
|
||||
- no death marker for that ID on the current floor; and
|
||||
- one of four party slots to be open.
|
||||
|
||||
The backend creates one character at full HP with heal available. If the game is
|
||||
already active, the player has 0 AP until the next Player Phase.
|
||||
@@ -514,6 +522,9 @@ already active, the player has 0 AP until the next Player Phase.
|
||||
Repeated `!spawn` attempts for an existing character return an informative chat
|
||||
response or log result and never create another character.
|
||||
|
||||
The roster is first come, first served for one floor. Twitch identity sessions
|
||||
remain outside the domain roster so clearing a party does not log viewers out.
|
||||
|
||||
### 10.2 Heal
|
||||
|
||||
A valid self-heal:
|
||||
@@ -624,34 +635,32 @@ When a living player enters the exit:
|
||||
1. enter transition state and reject remaining old-floor commands;
|
||||
2. increment the floor counter;
|
||||
3. generate and validate a new floor and Goblin;
|
||||
4. place every participating player on the new spawn tile;
|
||||
5. restore every player to 3 HP and alive;
|
||||
6. clear death markers, AP, and Guard;
|
||||
7. refresh every self-heal;
|
||||
8. log the new floor; and
|
||||
9. begin a fresh Player Phase.
|
||||
4. clear every current party record and release all four slots;
|
||||
5. discard all previous character HP, AP, Guard, heal, death, position, slot,
|
||||
and color state;
|
||||
6. log the new floor; and
|
||||
7. enter Dormant state for a new first-come, first-served party.
|
||||
|
||||
The previous Goblin and map are discarded.
|
||||
The previous Goblin and map are discarded. Existing authenticated Twitch
|
||||
sessions and sockets remain valid, but previous players have no slot
|
||||
reservation or priority.
|
||||
|
||||
### 12.3 Total-party wipe
|
||||
### 12.3 Total-party defeat
|
||||
|
||||
After any death resolution, if participating characters exist and none is
|
||||
alive, the backend immediately:
|
||||
|
||||
1. enters transition state;
|
||||
2. creates a new run ID;
|
||||
3. sets the floor counter to 1;
|
||||
4. generates a new starting floor and Goblin;
|
||||
5. restores all participating players alive at 3 HP;
|
||||
6. clears death markers, AP, and Guard;
|
||||
7. refreshes all self-heals;
|
||||
8. places all players at the spawn tile;
|
||||
9. logs the wipe and reset; and
|
||||
10. begins a fresh Player Phase.
|
||||
1. enters Dormant state with no deadline;
|
||||
2. preserves the current run ID, floor, and Goblin;
|
||||
3. preserves each dead character and current-floor death marker;
|
||||
4. stops Enemy Phase processing immediately; and
|
||||
5. logs the party defeat.
|
||||
|
||||
Because the reset is immediate, a Channel Points redemption cannot interrupt a
|
||||
completed total-party wipe. Zero participating characters is explicitly not a
|
||||
wipe.
|
||||
A valid Channel Points resurrection revives its owner and starts a fresh Player
|
||||
Phase from dormancy. If the defeated party claimed fewer than four slots, a
|
||||
newly eligible viewer may claim an open slot and resume play. A defeated full
|
||||
party requires resurrection. Initial dormancy has no participants, while
|
||||
defeated-party dormancy retains dead participant records.
|
||||
|
||||
## 13. Randomness
|
||||
|
||||
@@ -751,14 +760,22 @@ The default wide layout reserves:
|
||||
- the largest upper-right area for the map; and
|
||||
- a full-width bottom area for the action log.
|
||||
|
||||
Temporary shapes, colors, labels, and sprites are acceptable. Every required
|
||||
entity, wall, traversable path, and exit must remain distinguishable.
|
||||
Temporary shapes, labels, and sprites are acceptable. Every required entity,
|
||||
wall, traversable path, and exit must remain distinguishable. Player markers,
|
||||
visible name labels, personalized status, and adapter render data use the
|
||||
current party-slot color consistently.
|
||||
|
||||
### 15.2 Player status
|
||||
|
||||
The personalized Extension status includes at least identity/binding status,
|
||||
HP, AP, Guard, heal availability, alive/dead status, current phase, and a clear
|
||||
disabled reason when controls are unavailable.
|
||||
party slot/color, HP, AP, Guard, heal availability, alive/dead status, current
|
||||
phase, and a clear disabled reason when controls are unavailable.
|
||||
|
||||
Desktop preserves the compact information-rich overlay. Twitch context
|
||||
platform data adds an `extension-mobile` presentation flag when available, and
|
||||
a narrow-viewport media query provides the fallback. Mobile keeps identity and
|
||||
color, HP, AP, and heal availability visible, reflows the controller instead of
|
||||
scaling the desktop layout, and uses touch targets of at least 44 pixels.
|
||||
|
||||
### 15.3 Action log
|
||||
|
||||
@@ -771,7 +788,9 @@ starting an empty client-only log.
|
||||
|
||||
When phase is dormant, both applicable views prominently display exactly:
|
||||
|
||||
`Type !spawn to spawn in the Twungeon!`
|
||||
`Activate the Extension to spawn your character in the Twungeon!`
|
||||
|
||||
`Must be a follower to spawn.`
|
||||
|
||||
## 16. Failure and Recovery Behavior
|
||||
|
||||
@@ -874,15 +893,15 @@ Deterministic tests must cover:
|
||||
|
||||
- all accepted and rejected command conditions;
|
||||
- AP spending and early phase completion;
|
||||
- timer duration and 120-second cap;
|
||||
- rolling five-second deadline and one reset per living player;
|
||||
- deadline boundary behavior;
|
||||
- AutoGuard conversion, hit blocking, misses, and reset;
|
||||
- player and Goblin hit probabilities through injected rolls;
|
||||
- heal availability and floor refresh;
|
||||
- death, spawn lockout, resurrection, advancement, and wipe reset;
|
||||
- death, spawn lockout, resurrection, advancement, and defeated-party dormancy;
|
||||
- Goblin guarding, aggro on hit or miss, pursuit, return, and death;
|
||||
- exit entry with a living Goblin;
|
||||
- dormant versus total-party-wipe conditions;
|
||||
- initial versus defeated-party dormant conditions;
|
||||
- serial ordering and stale/duplicate command handling; and
|
||||
- deterministic floor generation and connectivity validation.
|
||||
|
||||
@@ -912,8 +931,8 @@ At minimum, the live test plan must demonstrate:
|
||||
6. movement, two attacks, heal, pass, timer expiry, and AutoGuard;
|
||||
7. Goblin aggro, pursuit, attack, target death, and return;
|
||||
8. Channel Points resurrection of the correct dead viewer;
|
||||
9. floor escape with the Goblin alive and dead-player revival;
|
||||
10. total-party wipe and Floor 1 reset;
|
||||
9. floor escape with the Goblin alive, party clearing, and next-floor reparty;
|
||||
10. total-party defeat, dormant transition, and resurrection recovery;
|
||||
11. refresh and temporary disconnect recovery; and
|
||||
12. repetition across multiple generated floors.
|
||||
|
||||
@@ -941,7 +960,7 @@ slices:
|
||||
2. Implement the deterministic floor generator and static stream rendering.
|
||||
3. Implement player/Goblin state, commands, turn phases, timer, and action log
|
||||
using local test drivers.
|
||||
4. Implement death, resurrection messages, floor advancement, wipe reset,
|
||||
4. Implement death, resurrection messages, floor advancement, defeat dormancy,
|
||||
dormancy, reconnect, and idempotency.
|
||||
5. Add the RPGJS adapter without moving rules out of the domain package.
|
||||
6. Add Twurple chat and follower verification for `!spawn`.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import type { Game } from '../../../packages/domain/src/index.js'
|
||||
|
||||
export interface AdminStatus {
|
||||
uptimeSeconds:number
|
||||
twitchReady:boolean
|
||||
twitchMode:string
|
||||
oauthConfigured:boolean
|
||||
oauthAuthorized:boolean
|
||||
}
|
||||
|
||||
export interface AdminServerDependencies {
|
||||
game:Game
|
||||
password:string
|
||||
csrfToken:string
|
||||
status():Promise<AdminStatus>
|
||||
onStateChange():void
|
||||
isAllowedAddress?(address:string|undefined):boolean
|
||||
}
|
||||
|
||||
const escapeHtml=(value:unknown)=>String(value).replace(/[&<>"']/g,character=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]!))
|
||||
const privateAddress=(address:string|undefined)=>Boolean(address?.replace(/^::ffff:/,'').startsWith('10.138.'))
|
||||
const safeEqual=(left:string,right:string)=>{const a=Buffer.from(left),b=Buffer.from(right);return a.length===b.length&&timingSafeEqual(a,b)}
|
||||
function authenticated(req:IncomingMessage,password:string):boolean {
|
||||
const header=req.headers.authorization
|
||||
if(!header?.startsWith('Basic '))return false
|
||||
try{const [username,...parts]=Buffer.from(header.slice(6),'base64').toString('utf8').split(':');return username==='admin'&&safeEqual(parts.join(':'),password)}catch{return false}
|
||||
}
|
||||
async function formBody(req:IncomingMessage):Promise<URLSearchParams>{const chunks:Buffer[]=[];for await(const chunk of req){chunks.push(Buffer.from(chunk));if(chunks.reduce((size,item)=>size+item.length,0)>16_384)throw new Error('Request too large')}return new URLSearchParams(Buffer.concat(chunks).toString('utf8'))}
|
||||
function headers(res:ServerResponse,status:number,type:string){res.writeHead(status,{'content-type':type,'cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",'x-content-type-options':'nosniff','x-frame-options':'DENY','referrer-policy':'no-referrer'})}
|
||||
function redirect(res:ServerResponse,notice:string){res.writeHead(303,{location:`/?notice=${encodeURIComponent(notice)}`,'cache-control':'no-store'});res.end()}
|
||||
|
||||
export function createAdminServer(deps:AdminServerDependencies){
|
||||
if(!deps.password)throw new Error('Admin password is required')
|
||||
return createServer(async(req,res)=>{
|
||||
try{
|
||||
if(!(deps.isAllowedAddress??privateAddress)(req.socket.remoteAddress)){headers(res,403,'text/plain; charset=utf-8');return res.end('Private Twungeon network access required.')}
|
||||
if(!authenticated(req,deps.password)){res.writeHead(401,{'www-authenticate':'Basic realm="Twungeon Admin", charset="UTF-8"','cache-control':'no-store'});return res.end('Authentication required.')}
|
||||
const url=new URL(req.url??'/','http://admin.local')
|
||||
if(req.method==='GET'&&url.pathname==='/health'){headers(res,200,'application/json');return res.end(JSON.stringify({status:'ok',...(await deps.status())}))}
|
||||
if(req.method==='GET'&&url.pathname==='/'){headers(res,200,'text/html; charset=utf-8');return res.end(await render(deps,url.searchParams.get('notice')))}
|
||||
if(req.method==='POST'&&url.pathname==='/action'){
|
||||
const form=await formBody(req)
|
||||
if(!safeEqual(form.get('_csrf')??'',deps.csrfToken)){headers(res,403,'text/plain; charset=utf-8');return res.end('Invalid form token.')}
|
||||
const action=form.get('action'),userId=form.get('userId')??''
|
||||
let notice='Unknown action.'
|
||||
if(action==='disconnect')notice=deps.game.disconnect(userId)?'Player disconnected and AutoGuarded.':'Player was already disconnected or missing.'
|
||||
else if(action==='remove')notice=deps.game.removePlayer(userId)?'Player removed.':'Player not found.'
|
||||
else if(action==='force-phase')notice=deps.game.forceEndPlayerPhase()?'Player phase ended.':'There is no active player phase.'
|
||||
else if(action==='reset-run'){deps.game.resetRunByAdmin();notice='Run reset.'}
|
||||
else {headers(res,400,'text/plain; charset=utf-8');return res.end(notice)}
|
||||
deps.onStateChange();return redirect(res,notice)
|
||||
}
|
||||
headers(res,404,'text/plain; charset=utf-8');res.end('Not found.')
|
||||
}catch(error){console.error(JSON.stringify({level:'error',component:'admin',message:error instanceof Error?error.message:'Unexpected error'}));headers(res,500,'text/plain; charset=utf-8');res.end('Admin request failed.')}
|
||||
})
|
||||
}
|
||||
|
||||
async function render(deps:AdminServerDependencies,notice:string|null):Promise<string>{
|
||||
const state=deps.game.snapshot(),status=await deps.status(),phase=state.phase.kind==='player'?`${state.phase.kind} · ${Math.max(0,Math.ceil((state.phase.deadlineAt-state.serverTime)/1000))}s remaining`:state.phase.kind
|
||||
const token=escapeHtml(deps.csrfToken)
|
||||
const playerRows=state.players.map(player=>`<tr><td>${player.partySlot}</td><td><span class="slot-color" style="--slot-color:${({blue:'#3f8cff',green:'#38c976',red:'#ef5350',yellow:'#f2c94c'} as const)[player.color]}"></span>${escapeHtml(player.displayName)}</td><td><code>${escapeHtml(player.twitchUserId)}</code></td><td><span class="${player.connectionState}">${player.connectionState}</span></td><td>${player.lifeState}</td><td>${player.hp}</td><td>${player.ap}</td><td>${player.guard}</td><td><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><input type="hidden" name="userId" value="${escapeHtml(player.twitchUserId)}"><button name="action" value="disconnect">Disconnect</button> <button class="danger" name="action" value="remove">Remove</button></form></td></tr>`).join('')||'<tr><td colspan="9">No players are currently in the dungeon.</td></tr>'
|
||||
const logs=state.actionLog.slice(-40).reverse().map(entry=>`<tr><td>${escapeHtml(entry.occurredAt)}</td><td>${escapeHtml(entry.type)}</td><td>${escapeHtml(entry.message)}</td></tr>`).join('')
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta http-equiv="refresh" content="15"><title>Twungeon Admin</title><style>:root{color-scheme:dark;font:15px system-ui;background:#0d0d12;color:#eee}body{max-width:1200px;margin:auto;padding:24px}h1{margin-bottom:4px}.sub{color:#aaa;margin-top:0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}.card,section{background:#181820;border:1px solid #343442;border-radius:10px;padding:16px}section{margin-top:16px;overflow:auto}.label{color:#aaa;font-size:12px;text-transform:uppercase}.value{font-size:20px;margin-top:5px}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:9px;border-bottom:1px solid #30303a;white-space:nowrap}button{border:0;border-radius:6px;padding:7px 11px;background:#7047eb;color:white;font-weight:650;cursor:pointer}.danger{background:#b42d46}.connected{color:#62d493}.disconnected{color:#e8b95b}.slot-color{display:inline-block;width:10px;height:10px;margin-right:6px;border-radius:50%;background:var(--slot-color);border:1px solid #fff}.notice{background:#193b2c;border:1px solid #2d8056;padding:10px;border-radius:8px}.actions{display:flex;gap:10px;flex-wrap:wrap}code{font-size:12px}</style></head><body><h1>Twungeon Admin</h1><p class="sub">Private operator console · refreshes every 15 seconds</p>${notice?`<p class="notice">${escapeHtml(notice)}</p>`:''}<div class="cards"><div class="card"><div class="label">Run</div><div class="value">Floor ${state.floorNumber}</div></div><div class="card"><div class="label">Phase</div><div class="value">${escapeHtml(phase)}</div></div><div class="card"><div class="label">Party</div><div class="value">${state.players.length}/4</div></div><div class="card"><div class="label">Twitch</div><div class="value">${status.twitchReady?'Ready':'Not ready'}</div></div><div class="card"><div class="label">OAuth</div><div class="value">${status.oauthAuthorized?'Authorized':status.oauthConfigured?'Not authorized':'Not configured'}</div></div><div class="card"><div class="label">Uptime</div><div class="value">${Math.floor(status.uptimeSeconds/60)}m</div></div></div><section><h2>Controls</h2><div class="actions"><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><button name="action" value="force-phase">End player phase</button></form><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><button class="danger" name="action" value="reset-run">Reset run</button></form></div></section><section><h2>Players</h2><table><thead><tr><th>Slot</th><th>Name</th><th>Twitch ID</th><th>Connection</th><th>Life</th><th>HP</th><th>AP</th><th>Guard</th><th>Actions</th></tr></thead><tbody>${playerRows}</tbody></table></section><section><h2>Recent action log</h2><table><thead><tr><th>Time</th><th>Event</th><th>Message</th></tr></thead><tbody>${logs}</tbody></table></section></body></html>`
|
||||
}
|
||||
@@ -1,10 +1,91 @@
|
||||
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??''}
|
||||
export interface Config {
|
||||
port: number
|
||||
publicBaseUrl: string
|
||||
resurrectionRewardId: string
|
||||
twitchEnabled: boolean
|
||||
broadcasterId: string
|
||||
channelLogin: string
|
||||
twitchClientId: string
|
||||
twitchClientSecret: string
|
||||
twitchRedirectUri: string
|
||||
twitchTokenFile: string
|
||||
extensionSecret: string
|
||||
oauthConfigured: boolean
|
||||
adminEnabled: boolean
|
||||
adminHost: string
|
||||
adminPort: number
|
||||
adminPassword: string
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const port = Number(env.PORT ?? 3000)
|
||||
const publicBaseUrl = env.PUBLIC_BASE_URL ?? `http://localhost:${port}`
|
||||
const twitchEnabled = env.TWITCH_ENABLED === 'true'
|
||||
const resurrectionRewardId = env.CHANNEL_POINTS_RESURRECTION_REWARD_ID ?? 'local-resurrection'
|
||||
const twitchClientId = env.TWITCH_CLIENT_ID ?? ''
|
||||
const twitchClientSecret = env.TWITCH_CLIENT_SECRET ?? ''
|
||||
const channelLogin = env.TWITCH_CHANNEL_LOGIN ?? 'local-channel'
|
||||
const twitchRedirectUri = env.TWITCH_OAUTH_REDIRECT_URI ?? `${publicBaseUrl}/oauth/callback`
|
||||
const twitchTokenFile = env.TWITCH_TOKEN_FILE ?? '/var/lib/twungeon/twitch-token.json'
|
||||
const oauthConfigured = Boolean(twitchClientId && twitchClientSecret && env.TWITCH_CHANNEL_LOGIN)
|
||||
const adminEnabled = env.ADMIN_ENABLED === 'true'
|
||||
const adminHost = env.ADMIN_HOST ?? '10.138.4.44'
|
||||
const adminPort = Number(env.ADMIN_PORT ?? 3001)
|
||||
const adminPassword = env.ADMIN_PASSWORD ?? ''
|
||||
const missing: string[] = []
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('PORT must be an integer from 1 to 65535')
|
||||
}
|
||||
if (!Number.isInteger(adminPort) || adminPort < 1 || adminPort > 65535 || adminPort===port) {
|
||||
throw new Error('ADMIN_PORT must be an integer from 1 to 65535 and differ from PORT')
|
||||
}
|
||||
if(adminEnabled && !/^10\.138\.\d{1,3}\.\d{1,3}$/.test(adminHost))throw new Error('ADMIN_HOST must be an address on 10.138.0.0/16')
|
||||
if(adminEnabled && adminPassword.length<16)throw new Error('ADMIN_PASSWORD must contain at least 16 characters when admin is enabled')
|
||||
if (!resurrectionRewardId.trim()) {
|
||||
throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required')
|
||||
}
|
||||
try {
|
||||
new URL(publicBaseUrl)
|
||||
new URL(twitchRedirectUri)
|
||||
} catch {
|
||||
throw new Error('PUBLIC_BASE_URL and TWITCH_OAUTH_REDIRECT_URI must be valid absolute URLs')
|
||||
}
|
||||
if (Boolean(twitchClientId) !== Boolean(twitchClientSecret)) {
|
||||
throw new Error('TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET must be configured together')
|
||||
}
|
||||
if (twitchEnabled) {
|
||||
for (const key of [
|
||||
'TWITCH_CLIENT_ID',
|
||||
'TWITCH_CLIENT_SECRET',
|
||||
'TWITCH_BROADCASTER_ID',
|
||||
'TWITCH_CHANNEL_LOGIN',
|
||||
'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,
|
||||
resurrectionRewardId,
|
||||
twitchEnabled,
|
||||
broadcasterId: env.TWITCH_BROADCASTER_ID ?? 'local-broadcaster',
|
||||
channelLogin,
|
||||
twitchClientId,
|
||||
twitchClientSecret,
|
||||
twitchRedirectUri,
|
||||
twitchTokenFile,
|
||||
extensionSecret: env.TWITCH_EXTENSION_SECRET ?? '',
|
||||
oauthConfigured,
|
||||
adminEnabled,
|
||||
adminHost,
|
||||
adminPort,
|
||||
adminPassword
|
||||
}
|
||||
}
|
||||
|
||||
+56
-10
@@ -1,4 +1,5 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -8,47 +9,92 @@ 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'
|
||||
import { TwitchOAuthService } from './twitchOAuth.js'
|
||||
import { createAdminServer } from './admin.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 oauth=config.oauthConfigured?new TwitchOAuthService({clientId:config.twitchClientId,clientSecret:config.twitchClientSecret,redirectUri:config.twitchRedirectUri,expectedLogin:config.channelLogin,tokenFile:config.twitchTokenFile}):null
|
||||
const twitch=config.twitchEnabled?new LiveTwitchAdapter({clientId:config.twitchClientId,clientSecret:config.twitchClientSecret,tokenFile:config.twitchTokenFile,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)}
|
||||
function html(res:ServerResponse,status:number,title:string,message:string){const escape=(value:string)=>value.replace(/[&<>"']/g,character=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]!));res.writeHead(status,{'content-type':'text/html; charset=utf-8','cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",'x-content-type-options':'nosniff'});res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(title)}</title><body style="font:16px system-ui;max-width:48rem;margin:4rem auto;padding:0 1rem;background:#111;color:#eee"><h1>${escape(title)}</h1><p>${escape(message)}</p></body></html>`)}
|
||||
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 connectedClientCount(userId:string):number{let count=0;for(const clientUserId of clients.values())if(clientUserId===userId)count++;return count}
|
||||
function authenticateClient(ws:WebSocket,userId:string|null):boolean{
|
||||
const previousUserId=clients.get(ws)??null
|
||||
if(previousUserId===userId)return false
|
||||
clients.set(ws,userId)
|
||||
let changed=false
|
||||
if(previousUserId && connectedClientCount(previousUserId)===0)changed=game.disconnect(previousUserId)||changed
|
||||
if(userId)changed=game.bindExtension(userId,true)||changed
|
||||
return changed
|
||||
}
|
||||
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 adminServer=config.adminEnabled?createAdminServer({game,password:config.adminPassword,csrfToken:randomBytes(32).toString('base64url'),onStateChange:broadcast,status:async()=>{const oauthStatus=oauth?await oauth.status():{configured:false,authorized:false};return{uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchReady:twitch.ready,twitchMode:config.twitchEnabled?'configured':'synthetic',oauthConfigured:oauthStatus.configured,oauthAuthorized:oauthStatus.authorized}}}):null
|
||||
|
||||
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==='/health')return json(res,200,{status:'ok',ready:twitch.ready,uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchMode:config.twitchEnabled?'configured':'synthetic',oauthConfigured:config.oauthConfigured})
|
||||
if(req.method==='GET'&&url.pathname==='/oauth/status')return json(res,200,oauth?await oauth.status():{configured:false,authorized:false,redirectUri:config.twitchRedirectUri})
|
||||
if(req.method==='GET'&&url.pathname==='/oauth/login'){
|
||||
if(!oauth)return html(res,503,'Twitch OAuth is not configured','Configure the Twitch Client ID, Client Secret, and channel login on the server first.')
|
||||
try{res.writeHead(302,{location:oauth.startAuthorization(),'cache-control':'no-store'});return res.end()}
|
||||
catch(error){return html(res,429,'Twitch OAuth is temporarily unavailable',error instanceof Error?error.message:'Try again later.')}
|
||||
}
|
||||
if(req.method==='GET'&&url.pathname==='/oauth/callback'){
|
||||
if(!oauth)return html(res,503,'Twitch OAuth is not configured','Configure Twitch OAuth on the server and try again.')
|
||||
const oauthError=url.searchParams.get('error_description')??url.searchParams.get('error')
|
||||
if(oauthError)return html(res,400,'Twitch authorization was declined',oauthError)
|
||||
try{
|
||||
const result=await oauth.completeAuthorization(url.searchParams.get('code')??'',url.searchParams.get('state')??'')
|
||||
console.log(JSON.stringify({level:'info',component:'twitch-oauth',message:'Broadcaster authorization stored',userId:result.userId,login:result.login,scopes:result.scopes}))
|
||||
return html(res,200,'Twungeon is authorized',`Authorized Twitch broadcaster ${result.login} (${result.userId}). You may close this page and continue setup.`)
|
||||
}catch(error){
|
||||
const message=error instanceof Error?error.message:'Twitch authorization failed.'
|
||||
console.error(JSON.stringify({level:'error',component:'twitch-oauth',message}))
|
||||
return html(res,400,'Twitch authorization failed',message)
|
||||
}
|
||||
}
|
||||
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)
|
||||
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId,connectedClientCount(identity.twitchUserId)>0)
|
||||
return json(res,200,{token,twitchUserId:identity.twitchUserId,state:game.personalizedSnapshot(identity.twitchUserId)})
|
||||
}
|
||||
if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){
|
||||
const userId=sessionUser(bearer(req));if(!userId)return json(res,401,{error:'UNAUTHENTICATED'})
|
||||
const message=await twitch.createSpawn(userId,`extension-spawn-${crypto.randomUUID()}`);if(!message)return json(res,503,{error:'TWITCH_UNAVAILABLE'})
|
||||
const result=game.spawn(message);if(result.accepted){game.bindExtension(userId,connectedClientCount(userId)>0);broadcast()}return json(res,result.accepted?200:409,{...result,state:game.personalizedSnapshot(userId)})
|
||||
}
|
||||
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/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){game.bindExtension(msg.twitchUserId,connectedClientCount(msg.twitchUserId)>0);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)}
|
||||
if(req.method==='GET'&&url.pathname==='/'){const page=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(page)}
|
||||
if(req.method==='GET'&&url.pathname==='/extension'){const page=(await readFile(join(publicDir,'index.html'),'utf8')).replace('<html lang="en">','<html lang="en" class="extension-mode">');res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(page)}
|
||||
if(req.method==='GET'&&url.pathname==='/privacy.html'){const html=await readFile(join(publicDir,'privacy.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});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','cache-control':'no-store'});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','cache-control':'no-store'});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))})
|
||||
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),changed=authenticateClient(ws,userId);if(changed)broadcast();else 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',()=>{const userId=clients.get(ws)??null;clients.delete(ws);if(userId && connectedClientCount(userId)===0 && game.disconnect(userId))broadcast()})})
|
||||
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}`))
|
||||
if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{const result=game.spawn(message);if(result.accepted)game.bindExtension(message.twitchUserId,connectedClientCount(message.twitchUserId)>0);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}`))
|
||||
adminServer?.listen(config.adminPort,config.adminHost,()=>console.log(`Twungeon admin listening on http://${config.adminHost}:${config.adminPort}`))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
REQUIRED_TWITCH_SCOPES,
|
||||
storedTwitchTokenExists,
|
||||
writeStoredTwitchToken
|
||||
} from '../../../packages/twitch-adapter/src/tokenStore.js'
|
||||
|
||||
const TokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1),
|
||||
expires_in: z.number().int().nonnegative(),
|
||||
scope: z.array(z.string()),
|
||||
token_type: z.string()
|
||||
})
|
||||
|
||||
const ValidationResponseSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
login: z.string().min(1),
|
||||
scopes: z.array(z.string()),
|
||||
user_id: z.string().min(1),
|
||||
expires_in: z.number().int().nonnegative()
|
||||
})
|
||||
|
||||
export interface TwitchOAuthConfig {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
expectedLogin: string
|
||||
tokenFile: string
|
||||
}
|
||||
|
||||
export interface TwitchOAuthResult {
|
||||
login: string
|
||||
userId: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
export interface TwitchOAuthStatus {
|
||||
configured: boolean
|
||||
authorized: boolean
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
export class TwitchOAuthService {
|
||||
private readonly states = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
private readonly config: TwitchOAuthConfig,
|
||||
private readonly fetchImplementation: typeof fetch = fetch,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
startAuthorization(): string {
|
||||
this.removeExpiredStates()
|
||||
if (this.states.size >= 100) {
|
||||
throw new Error('Too many OAuth attempts are pending. Wait ten minutes and try again.')
|
||||
}
|
||||
const state = randomBytes(32).toString('base64url')
|
||||
this.states.set(state, this.now() + 10 * 60_000)
|
||||
const url = new URL('https://id.twitch.tv/oauth2/authorize')
|
||||
url.searchParams.set('client_id', this.config.clientId)
|
||||
url.searchParams.set('redirect_uri', this.config.redirectUri)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', REQUIRED_TWITCH_SCOPES.join(' '))
|
||||
url.searchParams.set('state', state)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async completeAuthorization(code: string, state: string): Promise<TwitchOAuthResult> {
|
||||
this.removeExpiredStates()
|
||||
const expiresAt = this.states.get(state)
|
||||
this.states.delete(state)
|
||||
if (!expiresAt || expiresAt <= this.now()) {
|
||||
throw new Error('The OAuth state is missing or expired. Start authorization again.')
|
||||
}
|
||||
if (!code) throw new Error('Twitch did not return an authorization code.')
|
||||
|
||||
const tokenResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: this.config.redirectUri
|
||||
})
|
||||
})
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Twitch token exchange failed with HTTP ${tokenResponse.status}.`)
|
||||
}
|
||||
const token = TokenResponseSchema.parse(await tokenResponse.json())
|
||||
|
||||
const validationResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/validate', {
|
||||
headers: { authorization: `OAuth ${token.access_token}` }
|
||||
})
|
||||
if (!validationResponse.ok) {
|
||||
throw new Error(`Twitch token validation failed with HTTP ${validationResponse.status}.`)
|
||||
}
|
||||
const validation = ValidationResponseSchema.parse(await validationResponse.json())
|
||||
if (validation.client_id !== this.config.clientId) {
|
||||
throw new Error('Twitch returned a token for a different application.')
|
||||
}
|
||||
if (validation.login.toLowerCase() !== this.config.expectedLogin.toLowerCase()) {
|
||||
throw new Error(`Authorize with the configured broadcaster account: ${this.config.expectedLogin}.`)
|
||||
}
|
||||
const missingScopes = REQUIRED_TWITCH_SCOPES.filter(scope => !validation.scopes.includes(scope))
|
||||
if (missingScopes.length) {
|
||||
throw new Error(`The Twitch token is missing required scopes: ${missingScopes.join(', ')}.`)
|
||||
}
|
||||
|
||||
await writeStoredTwitchToken(this.config.tokenFile, {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
scope: validation.scopes,
|
||||
expiresIn: token.expires_in,
|
||||
obtainmentTimestamp: this.now(),
|
||||
userId: validation.user_id,
|
||||
login: validation.login,
|
||||
clientId: validation.client_id
|
||||
})
|
||||
|
||||
return { login: validation.login, userId: validation.user_id, scopes: validation.scopes }
|
||||
}
|
||||
|
||||
async status(): Promise<TwitchOAuthStatus> {
|
||||
return {
|
||||
configured: true,
|
||||
authorized: await storedTwitchTokenExists(this.config.tokenFile),
|
||||
redirectUri: this.config.redirectUri
|
||||
}
|
||||
}
|
||||
|
||||
private removeExpiredStates(): void {
|
||||
const now = this.now()
|
||||
for (const [state, expiresAt] of this.states) {
|
||||
if (expiresAt <= now) this.states.delete(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
let state=null,session=null,userId=null,lastSequence=0,activeSocket=null
|
||||
let state=null,session=null,userId=null,lastSequence=0,activeSocket=null,extensionIdentityPending=false
|
||||
const extensionMode=location.pathname==='/extension',overlayParams=new window.URLSearchParams(location.search),localOverlayHost=['localhost','127.0.0.1','[::1]'].includes(location.hostname),overlayDevMode=extensionMode&&localOverlayHost&&overlayParams.get('dev')==='1';document.documentElement.classList.toggle('extension-mode',extensionMode);document.documentElement.classList.toggle('overlay-debug',extensionMode&&localOverlayHost&&overlayParams.get('debug')==='1');document.documentElement.classList.toggle('overlay-dev',overlayDevMode)
|
||||
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 ''}
|
||||
const playerColors={blue:'#3f8cff',green:'#38c976',red:'#ef5350',yellow:'#f2c94c'},colorNames={blue:'Blue',green:'Green',red:'Red',yellow:'Yellow'}
|
||||
function setMobileLayout(context={}){const platform=String(context.platform??context.mode??'').toLowerCase(),mobile=platform.includes('mobile')||window.matchMedia('(max-width: 600px)').matches;document.documentElement.classList.toggle('extension-mobile',extensionMode&&mobile)}
|
||||
function disabledReason(){if(extensionIdentityPending)return 'Share your Twitch identity to enable controls.';if(!userId)return extensionMode?'Waiting for Twitch authorization.':'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return state?.players.length>=4?'Party full for this level. Spectating until the next level.':extensionMode?'Click Spawn character to join.':'Activate the Extension to spawn.';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('')
|
||||
$('#status').innerHTML=[['Party',`${state.players.length}/4`],['Living',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 viewerStatus=$('#viewerStatus');viewerStatus.hidden=!extensionMode||!me;viewerStatus.innerHTML=me?`<div class="viewer-identity"><span class="color-dot" style="--player-color:${playerColors[me.color]}"></span><b>${escapeHtml(me.displayName)}</b><span>Slot ${me.partySlot} · ${colorNames[me.color]}</span></div><div><span>HP</span><b>${me.hp}/3</b></div><div><span>AP</span><b>${me.ap}</b></div><div><span>Heal</span><b>${me.healAvailable?'Ready':'Used'}</b></div>`:''
|
||||
$('#partyLegend').innerHTML=state.players.map(player=>`<span class="party-name" style="--player-color:${playerColors[player.color]}">● ${escapeHtml(player.displayName)}</span>`).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))
|
||||
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 players=state.players.filter(p=>p.lifeState==='alive'&&p.position.x===x&&p.position.y===y),gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob){const entity=document.createElement('span');entity.className='entity goblin';entity.textContent='◆';entity.title='Goblin';el.append(entity)}for(const player of players){const entity=document.createElement('span');entity.className=`entity player${players.length>1?` stacked slot-${player.partySlot}`:''}`;entity.textContent='●';entity.title=player.displayName;entity.style.setProperty('--player-color',playerColors[player.color]);const label=document.createElement('span');label.className='entity-name';label.textContent=player.displayName;entity.append(label);el.append(entity)}map.append(el)}
|
||||
$('#log').innerHTML=state.actionLog.slice(-40).map(e=>{const actor=state.players.find(player=>player.twitchUserId===e.actorId),style=actor?` style="--player-color:${playerColors[actor.color]}"`:'';return`<li${style}><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));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode||overlayDevMode;spawnButton.disabled=!session||Boolean(me)||state.players.length>=4;spawnButton.textContent=me?'Character spawned':state.players.length>=4?'Party full':'Spawn character'
|
||||
}
|
||||
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 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||data.reason||'Request failed');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 spawnFromExtension(){const result=await api('/api/extension/spawn',{method:'POST',body:'{}'});state=result.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))
|
||||
$('#spawn').onclick=()=>spawn().catch(e=>$('#disabled').textContent=e.message);$('#spawnExtension').onclick=()=>spawnFromExtension().catch(e=>$('#disabled').textContent=e.message);$$('[data-command]').forEach(b=>b.onclick=()=>command(b.dataset.command));$('#shareIdentity').onclick=()=>window.Twitch?.ext?.actions.requestIdShare()
|
||||
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))
|
||||
setMobileLayout();window.matchMedia('(max-width: 600px)').addEventListener?.('change',()=>setMobileLayout())
|
||||
if(window.Twitch?.ext){window.Twitch.ext.onContext?.(context=>setMobileLayout(context));window.Twitch.ext.onAuthorized(auth=>{const linked=Boolean(window.Twitch.ext.viewer?.isLinked);extensionIdentityPending=!linked;$('#shareIdentity').hidden=linked;if(!linked){render();return}authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message)})}
|
||||
|
||||
@@ -1,10 +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 class="panel status" id="controlRegion"><div class="branding"><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 id="viewerStatus" hidden></div><button id="spawnExtension" hidden>Spawn character</button><div class="dpad"><button data-command="up" aria-label="Move up">▲</button><button data-command="left" aria-label="Move left">◀</button><button data-command="down" aria-label="Move down">▼</button><button data-command="right" aria-label="Move 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><button id="shareIdentity" hidden>Share Twitch identity</button><p id="disabled" role="status"></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><p><a href="/privacy.html" target="_blank" rel="noopener">Privacy notice</a></p></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 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>Activate the Extension to spawn your character in the Twungeon!<span class="banner-note">Must be a follower to spawn.</span></div><div id="map" aria-label="Dungeon map"></div><div class="legend"><span id="partyLegend"></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>
|
||||
</main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js?v=20260817-overlay"></script></body></html>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Twungeon Privacy Notice</title>
|
||||
<style>
|
||||
:root{color-scheme:dark}body{max-width:760px;margin:0 auto;padding:32px 20px;background:#0d0f10;color:#f5ecd8;font:16px/1.6 system-ui,sans-serif}h1,h2{line-height:1.2}h2{margin-top:28px;color:#e8b04b}a{color:#e8b04b}small{color:#b8ad98}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Twungeon Privacy Notice</h1>
|
||||
<small>Effective August 17, 2026</small>
|
||||
|
||||
<p>Twungeon is an interactive Twitch Extension operated for the Labyricorn channel. This notice explains how Twungeon handles information when viewers participate in the shared dungeon.</p>
|
||||
|
||||
<h2>Information Twungeon processes</h2>
|
||||
<p>If you choose to share your Twitch identity, Twungeon receives your numeric Twitch user ID, display name, the channel where the Extension is running, and the authorization claims Twitch supplies. During play, Twungeon also processes chat commands, follower-verification results, game actions, and relevant Channel Points redemption identifiers.</p>
|
||||
|
||||
<h2>How the information is used</h2>
|
||||
<p>The information is used only to connect your chat character to your Extension controls, enforce game ownership and eligibility rules, prevent duplicate actions or redemptions, display the shared game state, and protect the service from unauthorized requests.</p>
|
||||
|
||||
<h2>Storage and retention</h2>
|
||||
<p>Viewer identities, game state, actions, and redemption deduplication records are held in server memory for the active Twungeon run and are cleared when the service restarts. Twungeon does not place tracking cookies in the Extension and does not use viewer information for advertising or profiling. The broadcaster's OAuth credentials are stored separately and are not viewer data.</p>
|
||||
|
||||
<h2>Sharing</h2>
|
||||
<p>Twungeon does not sell viewer information. Information is not disclosed to third parties except as necessary to operate the Extension through Twitch and its hosting or network providers, to protect the service, or when required by law. Twitch independently processes information under its own privacy notice.</p>
|
||||
|
||||
<h2>Your choices</h2>
|
||||
<p>Identity sharing is optional. Without it, Twungeon cannot safely bind Extension controls to a chat character. You can decline Twitch's identity prompt or manage your Extension permissions through Twitch. To ask about this notice or request removal from the current active run, contact the operator through the <a href="https://www.twitch.tv/labyricorn" rel="noopener">Labyricorn Twitch channel</a>.</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>This notice may be updated when Twungeon's data practices change. The effective date above identifies the current version.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +1,146 @@
|
||||
: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%}}
|
||||
:root {
|
||||
--ink: #f5ecd8;
|
||||
--muted: #b8ad98;
|
||||
--gold: #e8b04b;
|
||||
--red: #db604c;
|
||||
--panel: #171a1d;
|
||||
--line: #34383b;
|
||||
--floor: #3a3832;
|
||||
--wall: #111315;
|
||||
|
||||
/* Normalized to the matching control panel in the 16:9 broadcast layout. */
|
||||
--controls-left: 0.95%;
|
||||
--controls-top: 1.7%;
|
||||
--controls-width: 27.1%;
|
||||
--controls-height: 65.8%;
|
||||
--controls-padding: clamp(8px, 1.15vw, 22px);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html:not(.extension-mode) { color-scheme: dark; }
|
||||
html.extension-mode { color-scheme: normal; }
|
||||
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; }
|
||||
#banner .banner-note { display: block; margin-top: 6px; color: #fff; font-size: .78em; font-weight: 600; }
|
||||
#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: var(--player-color, #3f8cff); border: 2px solid #fff; color: #111; }
|
||||
.entity.player.stacked { inset: auto; width: 40%; height: 40%; }
|
||||
.entity.player.stacked.slot-1 { left: 6%; top: 6%; }
|
||||
.entity.player.stacked.slot-2 { right: 6%; top: 6%; }
|
||||
.entity.player.stacked.slot-3 { left: 6%; bottom: 6%; }
|
||||
.entity.player.stacked.slot-4 { right: 6%; bottom: 6%; }
|
||||
.entity-name { position: absolute; top: 100%; left: 50%; z-index: 2; transform: translate(-50%, 2px); padding: 1px 3px; border-radius: 2px; background: #000c; color: var(--player-color, #fff); font-size: 9px; line-height: 1.1; white-space: nowrap; text-shadow: 0 1px 2px #000; }
|
||||
.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; }
|
||||
.party-name { color: var(--player-color); white-space: nowrap; }
|
||||
#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:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
||||
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(--player-color, var(--line)); color: var(--muted); }
|
||||
#log li:last-child { color: var(--ink); border-color: var(--gold); }
|
||||
#connection { font-size: 12px; color: var(--muted); }
|
||||
|
||||
/* Twitch Video Overlay mode: the stream supplies every noninteractive surface. */
|
||||
html.extension-mode, html.extension-mode body { width: 100%; height: 100%; overflow: hidden; background: transparent; background-image: none; pointer-events: none; }
|
||||
html.extension-mode .shell { position: fixed; inset: 0; display: block; min-height: 0; padding: 0; pointer-events: none; }
|
||||
html.extension-mode .game, html.extension-mode .log, html.extension-mode .branding, html.extension-mode #status, html.extension-mode details { display: none; }
|
||||
html.extension-mode .status {
|
||||
position: absolute;
|
||||
left: var(--controls-left);
|
||||
top: var(--controls-top);
|
||||
width: var(--controls-width);
|
||||
height: var(--controls-height);
|
||||
padding: var(--controls-padding);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
container: overlay-controls / size;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
html.extension-mode #controller { width: min(100%, 18rem); pointer-events: none; }
|
||||
html.extension-mode #controller h2 { font-size: clamp(11px, 5cqw, 18px); margin-bottom: clamp(4px, 2cqh, 12px); text-shadow: 0 1px 3px #000; }
|
||||
html.extension-mode #viewerStatus { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(3px, 1.4cqw, 7px); margin-bottom: clamp(5px, 2cqh, 10px); color: var(--ink); text-shadow: 0 1px 3px #000; }
|
||||
html.extension-mode #viewerStatus[hidden] { display: none; }
|
||||
html.extension-mode #viewerStatus > div { padding: clamp(3px, 1cqw, 6px); background: #080a0bd9; border: 1px solid #ffffff2b; font-size: clamp(8px, 3.3cqw, 12px); }
|
||||
html.extension-mode #viewerStatus > div > span, html.extension-mode #viewerStatus > div > b { display: block; }
|
||||
html.extension-mode #viewerStatus .viewer-identity { grid-column: 1/-1; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 6px; }
|
||||
html.extension-mode #viewerStatus .viewer-identity > span, html.extension-mode #viewerStatus .viewer-identity > b { display: initial; }
|
||||
.color-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--player-color); border: 1px solid #fff; }
|
||||
html.extension-mode #controller button { pointer-events: auto; touch-action: manipulation; }
|
||||
html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(28px, 1fr)); gap: clamp(3px, 1.8cqw, 7px); margin: 0 0 clamp(6px, 2.5cqh, 14px); }
|
||||
html.extension-mode .dpad button { aspect-ratio: 1; padding: 0; font-size: clamp(13px, 6cqw, 24px); }
|
||||
html.extension-mode .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(3px, 1.8cqw, 7px); }
|
||||
html.extension-mode .actions button { padding: clamp(5px, 1.7cqh, 10px) clamp(2px, 1cqw, 8px); font-size: clamp(9px, 4.3cqw, 15px); }
|
||||
html.extension-mode #spawnExtension, html.extension-mode #shareIdentity { width: 100%; margin-bottom: clamp(5px, 2cqh, 12px); font-size: clamp(9px, 4.3cqw, 15px); }
|
||||
html.extension-mode #disabled { min-height: 2.8em; margin-top: clamp(5px, 2cqh, 10px); color: var(--ink); font-size: clamp(9px, 3.7cqw, 13px); text-shadow: 0 1px 3px #000, 0 0 8px #000; }
|
||||
|
||||
/* Opt-in local diagnostics. These classes are never enabled by production URLs. */
|
||||
html.extension-mode.overlay-debug .status { outline: 2px dashed #00e5ff; outline-offset: -2px; background: #00e5ff0d; }
|
||||
html.extension-mode.overlay-debug #controller { outline: 1px dashed #ff4fd8; }
|
||||
html.extension-mode.overlay-dev details { display: block; width: 100%; margin-top: clamp(8px, 3cqh, 16px); pointer-events: auto; font-size: clamp(9px, 3.7cqw, 13px); }
|
||||
html.extension-mode.overlay-dev .status { place-items: start center; overflow: auto; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
html:not(.extension-mode) .shell { height: auto; grid-template-columns: 1fr; grid-template-rows: auto minmax(500px, 70vh) 360px; }
|
||||
html:not(.extension-mode) .log { grid-column: 1; }
|
||||
html:not(.extension-mode) .tile { width: min(5vw, 28px); }
|
||||
html:not(.extension-mode) #banner { white-space: normal; width: 75%; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
html.extension-mode .status { left: 3%; top: 3%; width: 94%; height: 94%; padding: 12px; place-items: center; overflow: auto; }
|
||||
html.extension-mode #controller { width: min(100%, 24rem); }
|
||||
html.extension-mode #controller h2 { font-size: 17px; }
|
||||
html.extension-mode #viewerStatus { gap: 7px; margin-bottom: 10px; }
|
||||
html.extension-mode #viewerStatus > div { min-height: 44px; padding: 7px 9px; font-size: 13px; }
|
||||
html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
html.extension-mode .dpad button { min-height: 56px; font-size: 24px; }
|
||||
html.extension-mode .actions { gap: 8px; }
|
||||
html.extension-mode .actions button, html.extension-mode #spawnExtension, html.extension-mode #shareIdentity { min-height: 52px; padding: 10px 6px; font-size: 15px; }
|
||||
html.extension-mode #disabled { font-size: 13px; }
|
||||
}
|
||||
|
||||
html.extension-mode.extension-mobile .status { left: 3%; top: 3%; width: 94%; height: 94%; padding: 12px; place-items: center; overflow: auto; }
|
||||
html.extension-mode.extension-mobile #controller { width: min(100%, 24rem); }
|
||||
html.extension-mode.extension-mobile .dpad { grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 8px; }
|
||||
html.extension-mode.extension-mobile .dpad button { min-height: 56px; font-size: 24px; }
|
||||
html.extension-mode.extension-mobile .actions button, html.extension-mode.extension-mobile #spawnExtension, html.extension-mode.extension-mobile #shareIdentity { min-height: 52px; font-size: 15px; }
|
||||
|
||||
@media (max-aspect-ratio: 4/3) {
|
||||
html.extension-mode { --controls-width: 31%; --controls-height: 62%; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Private admin panel
|
||||
|
||||
Twungeon can expose a separate server-rendered operator console on the private
|
||||
`10.138.0.0/16` network. It is disabled by default and is never served from the
|
||||
public application listener.
|
||||
|
||||
Configure the service environment:
|
||||
|
||||
```dotenv
|
||||
ADMIN_ENABLED=true
|
||||
ADMIN_HOST=10.138.4.44
|
||||
ADMIN_PORT=3001
|
||||
ADMIN_PASSWORD=use-a-unique-random-password-of-at-least-16-characters
|
||||
```
|
||||
|
||||
Restart Twungeon, then open `http://10.138.4.44:3001` from a device on the
|
||||
private network. Authenticate with username `admin` and the configured
|
||||
password.
|
||||
|
||||
The process binds the panel to the configured `10.138.x.x` interface and also
|
||||
rejects request source addresses outside `10.138.0.0/16`. Keep a host firewall
|
||||
rule in place as a third boundary. For UFW, the intended policy is:
|
||||
|
||||
```bash
|
||||
ufw allow from 10.138.0.0/16 to 10.138.4.44 port 3001 proto tcp
|
||||
```
|
||||
|
||||
Do not publish or reverse-proxy this port. Basic authentication protects the
|
||||
panel from other private-network users, while per-process form tokens protect
|
||||
state-changing requests from cross-site submission. Because the listener uses
|
||||
plain HTTP, use it only on the trusted private network; add internal TLS before
|
||||
using it across an untrusted or shared network.
|
||||
|
||||
The panel displays service readiness, OAuth status, the current run and phase,
|
||||
all players, and the latest 40 action-log entries. Operators can disconnect a
|
||||
player into AutoGuard, remove a character, end the active player phase, or reset
|
||||
the run. All mutations pass through the authoritative game core and broadcast
|
||||
the resulting snapshot to connected viewers.
|
||||
+44
-7
@@ -23,30 +23,67 @@ Twitch identities; they do not require credentials.
|
||||
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
|
||||
4. Confirm 3 HP, 2 AP, no Guard, a ready heal, and a five-second phase.
|
||||
5. Confirm the viewer's first accepted action resets the timer to five seconds
|
||||
and their second accepted action does not reset it again.
|
||||
6. 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
|
||||
7. 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
|
||||
8. 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
|
||||
9. 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.
|
||||
|
||||
## Local video-overlay verification
|
||||
|
||||
The production viewer path is `/extension`. It is safe to open directly, but it
|
||||
waits for Twitch authorization when it is not inside Twitch. While running the
|
||||
synthetic local backend, open:
|
||||
|
||||
```text
|
||||
http://localhost:3000/extension?dev=1&debug=1
|
||||
```
|
||||
|
||||
`dev=1` exposes the existing **Local viewer login** inside the overlay so the
|
||||
same controller and API flow can be exercised without Twitch. `debug=1` draws a
|
||||
cyan control-region boundary and a pink controller boundary. Both switches are
|
||||
accepted only on `localhost`, `127.0.0.1`, or `[::1]`, so the diagnostics cannot
|
||||
be enabled on the deployed Extension origin.
|
||||
|
||||
Verify at 1920×1080, 1280×720, a narrower or 4:3 viewport, and a mobile-width
|
||||
viewport:
|
||||
|
||||
- the document and empty overlay canvas remain transparent;
|
||||
- controls stay within the upper-left debug boundary and remain readable;
|
||||
- the dungeon, log, branding, and status tiles are absent from `/extension`;
|
||||
- only buttons (and the local-only login form) accept pointer events;
|
||||
- Up, Down, Left, Right, Attack, Heal, and Pass reach the same backend routes;
|
||||
- missing character, dead character, non-player phase, and zero-AP states still
|
||||
disable the controls; and
|
||||
- loading `/` still presents the full local game and controller;
|
||||
- mobile identity/color, HP, AP, and heal status remains visible; and
|
||||
- mobile directional buttons are at least 56px and action buttons at least 52px.
|
||||
|
||||
Remove the query string for the production-shaped local view. Twitch-hosted
|
||||
testing still must cover identity sharing, player ownership, theater mode,
|
||||
fullscreen, embeds, ads/pauses, and Twitch player-control safe zones.
|
||||
|
||||
## Live-channel campaign
|
||||
|
||||
After completing `docs/twitch-setup.md`, use two follower accounts and one
|
||||
After completing `docs/twitch-setup.md`, use five 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;
|
||||
- four deterministic party slots/colors and a rejected fifth viewer;
|
||||
- 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;
|
||||
- escape with the Goblin alive, complete next-floor reparty, and total-party defeat dormancy;
|
||||
- refresh/reconnect during a phase and transition; and
|
||||
- repeated floors and a short soak session.
|
||||
|
||||
|
||||
+64
-11
@@ -10,7 +10,8 @@ provide the variables through your process manager.
|
||||
- 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.
|
||||
- A confidential Twitch developer application whose OAuth callback is the
|
||||
public Twungeon `/oauth/callback` URL.
|
||||
- The Extension shared secret, copied exactly as the base64 value supplied by
|
||||
the Extension Manager.
|
||||
|
||||
@@ -20,16 +21,19 @@ rotating tokens or upgrading packages.
|
||||
|
||||
## Required environment
|
||||
|
||||
Set `TWITCH_ENABLED=true`, then provide:
|
||||
Provide the following values first. Keep `TWITCH_ENABLED=false` until OAuth,
|
||||
Extension, and Channel Points setup are complete; then switch it to `true` for
|
||||
live operation.
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `TWITCH_CLIENT_ID` | Developer application/Extension client ID |
|
||||
| `TWITCH_CLIENT_ID` | Confidential OAuth application 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 |
|
||||
| `TWITCH_OAUTH_REDIRECT_URI` | Exact HTTPS OAuth callback registered with Twitch |
|
||||
| `TWITCH_TOKEN_FILE` | Restricted file used for access and refresh tokens |
|
||||
| `CHANNEL_POINTS_RESURRECTION_REWARD_ID` | Stable ID of the resurrection custom reward |
|
||||
| `PUBLIC_BASE_URL` | Public HTTPS backend origin |
|
||||
|
||||
@@ -40,17 +44,66 @@ 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.
|
||||
|
||||
## Broadcaster OAuth authorization
|
||||
|
||||
1. Register a **Confidential** Twitch application with an exact HTTPS redirect
|
||||
such as `https://twungeon.example/oauth/callback`.
|
||||
2. Configure `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET`,
|
||||
`TWITCH_CHANNEL_LOGIN`, `TWITCH_OAUTH_REDIRECT_URI`, and
|
||||
`TWITCH_TOKEN_FILE` while leaving `TWITCH_ENABLED=false`.
|
||||
3. Restart Twungeon and confirm `/oauth/status` reports `configured: true` and
|
||||
`authorized: false`.
|
||||
4. Open `/oauth/login` in a browser and authorize using the configured
|
||||
broadcaster account. Twungeon requests only `chat:read`,
|
||||
`moderator:read:followers`, and `channel:read:redemptions`.
|
||||
5. Confirm the callback reports success and `/oauth/status` reports
|
||||
`authorized: true`. The callback validates the client ID, broadcaster login,
|
||||
and required scopes before storing the token.
|
||||
6. Record the numeric broadcaster ID shown by the callback as
|
||||
`TWITCH_BROADCASTER_ID`.
|
||||
|
||||
The access and refresh tokens are stored atomically at `TWITCH_TOKEN_FILE` with
|
||||
owner-only permissions. Twurple refreshes the access token when necessary and
|
||||
Twungeon replaces the stored token without printing either token. The token
|
||||
directory must be writable only by the Twungeon service account. Never place the
|
||||
token file inside the repository or a web-served directory.
|
||||
|
||||
## 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
|
||||
Twungeon's production viewer is a **Video Overlay Extension**. Twitch's current
|
||||
Extension Manager labels this asset type **Video - Fullscreen** and its path
|
||||
field **Video - Fullscreen View Path**. This is a manual dashboard setting; the
|
||||
repository has no manifest that can change an Extension version in Twitch.
|
||||
|
||||
1. Create a Twitch Extension separately from the confidential OAuth
|
||||
application, or create a new test version of the existing Twungeon
|
||||
Extension.
|
||||
2. On **Asset Hosting**, set **Testing Base URI** to the public HTTPS Twungeon
|
||||
origin with a trailing `/`.
|
||||
3. Under **Type of Extension**, enable **Video - Fullscreen** (the video-overlay
|
||||
placement) and set **Video - Fullscreen View Path** to `extension`. Do not
|
||||
use **Video - Component** for the production viewer. If a component entry is
|
||||
retained temporarily for development, it may point at the same shared page,
|
||||
but the overlay placement is the supported viewer experience.
|
||||
4. On **Capabilities**, enable **Request Identity Link**. The viewer must click
|
||||
**Share Twitch identity** in the overlay; the Extension Helper then invokes
|
||||
`requestIdShare()` from that user gesture and supplies a new JWT through
|
||||
`onAuthorized`.
|
||||
5. Put the version in Local Test or Hosted Test and activate it in the channel's
|
||||
video-overlay slot. Changing an already released version may require a new
|
||||
Extension version and Twitch review; the repository cannot submit or
|
||||
activate that version automatically.
|
||||
6. Test the overlay while live in normal, theater, fullscreen, and a narrow
|
||||
player. The video player and stream supply the game frame; `/extension`
|
||||
should show only the upper-left controls on a transparent canvas.
|
||||
The same viewer path handles mobile: Twitch context selects the mobile
|
||||
layout when available and responsive CSS is the fallback. Do not configure
|
||||
a second Extension, stream, or backend instance for mobile viewers.
|
||||
7. 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
|
||||
8. 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:
|
||||
9. 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
|
||||
|
||||
+53
-14
@@ -5,7 +5,7 @@ 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 SpawnResult { accepted: boolean; reason: 'NOT_FOLLOWER'|'DUPLICATE'|'DIED_THIS_FLOOR'|'PARTY_FULL'|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 }
|
||||
|
||||
@@ -14,6 +14,8 @@ 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}]
|
||||
const partyColors = ['blue','green','red','yellow'] as const
|
||||
const playerPhaseDuration = 5_000
|
||||
|
||||
export class Game {
|
||||
private state: RunState
|
||||
@@ -42,7 +44,26 @@ export class Game {
|
||||
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' }
|
||||
disconnect(twitchUserId: string): boolean {
|
||||
const player=this.state.players[twitchUserId]
|
||||
if(!player || player.connectionState==='disconnected') return false
|
||||
player.connectionState='disconnected'
|
||||
if(this.state.phase.kind==='player' && player.lifeState==='alive' && player.ap>0) this.autoGuard(player)
|
||||
this.finishPlayerPhaseIfReady()
|
||||
return true
|
||||
}
|
||||
removePlayer(twitchUserId:string):boolean {
|
||||
const player=this.state.players[twitchUserId]
|
||||
if(!player)return false
|
||||
delete this.state.players[twitchUserId]
|
||||
if(this.state.goblin.targetPlayerId===twitchUserId){this.state.goblin.mode='returning';this.state.goblin.targetPlayerId=null}
|
||||
this.log('player-removed',twitchUserId,null,`${player.displayName} was removed by an administrator.`)
|
||||
if(Object.keys(this.state.players).length===0)this.state.phase={kind:'dormant'}
|
||||
else this.finishPlayerPhaseIfReady()
|
||||
return true
|
||||
}
|
||||
forceEndPlayerPhase():boolean {if(this.state.phase.kind!=='player')return false;this.endPlayerPhase();return true}
|
||||
resetRunByAdmin():void {this.resetRun()}
|
||||
|
||||
spawn(message: SpawnMessage): SpawnResult {
|
||||
if(this.externalEvents.has(message.externalEventId)) return {accepted:false,reason:'DUPLICATE',message:'That spawn request was already handled.'}
|
||||
@@ -50,7 +71,10 @@ export class Game {
|
||||
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}
|
||||
const occupiedSlots=new Set(Object.values(this.state.players).map(player=>player.partySlot)),partySlot=partyColors.findIndex((_,index)=>!occupiedSlots.has((index+1) as PlayerState['partySlot']))+1
|
||||
if(partySlot<1)return {accepted:false,reason:'PARTY_FULL',message:'The current party is full. You can try again on the next level.'}
|
||||
const slot=partySlot as PlayerState['partySlot']
|
||||
const p: PlayerState={twitchUserId:message.twitchUserId,displayName:safeName(message.displayName),followerVerified:true,characterCreated:true,extensionBound:false,partySlot:slot,color:partyColors[slot-1]!,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()
|
||||
@@ -66,6 +90,7 @@ export class Game {
|
||||
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!`)
|
||||
if(this.state.phase.kind==='dormant')this.startPlayerPhase()
|
||||
return {accepted:true,reason:null,message:`${p.displayName} resurrected.`}
|
||||
}
|
||||
|
||||
@@ -86,9 +111,16 @@ export class Game {
|
||||
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'
|
||||
const commandPhase=this.state.phase.kind==='player'?this.state.phase:null
|
||||
if(!rejection && player) rejection=this.applyPlayerCommand(player,envelope)
|
||||
if(rejection) this.state=before
|
||||
else this.finishPlayerPhaseIfReady()
|
||||
else {
|
||||
if(player && commandPhase && this.state.phase.kind==='player' && this.state.phase.phaseId===commandPhase.phaseId && !this.state.phase.timerResetPlayerIds.includes(player.twitchUserId)){
|
||||
this.state.phase.timerResetPlayerIds.push(player.twitchUserId);this.state.phase.deadlineAt=arrivedAt+playerPhaseDuration
|
||||
this.log('phase-timer-reset',player.twitchUserId,null,`${player.displayName}'s first action resets the Player Phase timer to 5 seconds.`)
|
||||
}
|
||||
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
|
||||
@@ -127,10 +159,13 @@ export class Game {
|
||||
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.`)
|
||||
const now=this.deps.clock.now()
|
||||
this.state.phase={kind:'player',phaseId:this.deps.ids.next('phase'),startedAt:now,deadlineAt:now+playerPhaseDuration,initialEligiblePlayerIds:living.map(p=>p.twitchUserId),timerResetPlayerIds:[]}
|
||||
for(const p of Object.values(this.state.players)){
|
||||
p.guard=0;p.eligibleThisPhase=p.lifeState==='alive';p.ap=p.lifeState==='alive'?2:0
|
||||
if(p.lifeState==='alive' && p.extensionBound && p.connectionState==='disconnected') this.autoGuard(p)
|
||||
}
|
||||
this.log('player-phase-started',null,null,'Player Phase begins: 5 seconds.')
|
||||
}
|
||||
private finishPlayerPhaseIfReady(): void {
|
||||
if(this.state.phase.kind!=='player') return
|
||||
@@ -139,10 +174,14 @@ export class Game {
|
||||
}
|
||||
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}
|
||||
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 autoGuard(player:PlayerState):void {
|
||||
player.guard+=player.ap;player.ap=0;player.eligibleThisPhase=false
|
||||
this.log('player-auto-guarded',player.twitchUserId,null,`${player.displayName} is disconnected and converts unused AP to Guard.`)
|
||||
}
|
||||
private runGoblin(): void {
|
||||
if(this.state.goblin.mode==='dead' || this.state.goblin.mode==='guarding') return
|
||||
let ap=2
|
||||
@@ -154,7 +193,7 @@ export class Game {
|
||||
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 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);if(this.state.phase.kind==='dormant')return}}
|
||||
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'){
|
||||
@@ -169,17 +208,17 @@ export class Game {
|
||||
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()
|
||||
const all=Object.values(this.state.players);if(all.length>0 && all.every(p=>p.lifeState==='dead')){this.state.phase={kind:'dormant'};this.log('party-defeated',null,null,'The party has fallen. The dungeon is dormant until an adventurer returns.')}
|
||||
}
|
||||
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()
|
||||
this.state.players={};this.log('floor-advanced',null,null,`Floor ${this.state.floorNumber} awaits a new party.`);this.state.phase={kind:'dormant'}
|
||||
}
|
||||
private resetRun(): void {
|
||||
this.state.phase={kind:'transition',reason:'party-wipe'};this.state.runId=this.deps.ids.next('run');this.state.floorNumber=1
|
||||
this.state.phase={kind:'transition',reason:'admin-reset'};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()
|
||||
this.restorePlayers();this.log('admin-reset',null,null,'An administrator reset the run.');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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface FloorState {
|
||||
export interface PlayerState {
|
||||
twitchUserId: string; displayName: string; followerVerified: boolean
|
||||
characterCreated: boolean; extensionBound: boolean
|
||||
partySlot: 1 | 2 | 3 | 4; color: 'blue' | 'green' | 'red' | 'yellow'
|
||||
connectionState: 'connected' | 'disconnected'; position: TilePosition
|
||||
hp: number; lifeState: 'alive' | 'dead'; ap: number; guard: number
|
||||
healAvailable: boolean; participatingFloor: number; diedOnFloor: number | null
|
||||
@@ -19,9 +20,9 @@ export interface GoblinState {
|
||||
}
|
||||
export type PhaseState = { kind: 'dormant' } | {
|
||||
kind: 'player'; phaseId: string; startedAt: number; deadlineAt: number
|
||||
initialEligiblePlayerIds: string[]
|
||||
initialEligiblePlayerIds: string[]; timerResetPlayerIds: string[]
|
||||
} | { kind: 'enemy'; phaseId: string } | {
|
||||
kind: 'transition'; reason: 'floor-advance' | 'party-wipe'
|
||||
kind: 'transition'; reason: 'floor-advance' | 'admin-reset'
|
||||
}
|
||||
export interface ActionLogEntry {
|
||||
sequence: number; runId: string; floorNumber: number; phaseId: string | null
|
||||
|
||||
@@ -1,9 +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 interface RenderEntity { id:string; kind:'player'|'goblin'|'exit'; position:TilePosition; label:string; color?: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})
|
||||
for(const p of snapshot.players)if(p.lifeState==='alive')entities.push({id:p.twitchUserId,kind:'player',position:p.position,label:p.displayName,color:p.color})
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import type { ChannelPointRedemption, SpawnMessage } from '../../contracts/src/index.js'
|
||||
import { ApiClient } from '@twurple/api'
|
||||
import { StaticAuthProvider } from '@twurple/auth'
|
||||
import { RefreshingAuthProvider } from '@twurple/auth'
|
||||
import { ChatClient } from '@twurple/chat'
|
||||
import { EventSubWsListener } from '@twurple/eventsub-ws'
|
||||
import { jwtVerify } from 'jose'
|
||||
import { readStoredTwitchToken, writeStoredTwitchToken } from './tokenStore.js'
|
||||
|
||||
export { REQUIRED_TWITCH_SCOPES } from './tokenStore.js'
|
||||
|
||||
export interface TwitchIdentity { twitchUserId: string; displayName: string }
|
||||
export interface TwitchAdapter {
|
||||
readonly ready: boolean
|
||||
verifyExtensionToken(token: string): Promise<TwitchIdentity | null>
|
||||
createSpawn(twitchUserId: string, externalEventId: string): Promise<SpawnMessage | null>
|
||||
normalizeSpawn(input: unknown): Promise<SpawnMessage | null>
|
||||
normalizeRedemption(input: unknown): Promise<ChannelPointRedemption | null>
|
||||
}
|
||||
@@ -21,6 +25,7 @@ export class SyntheticTwitchAdapter implements TwitchAdapter {
|
||||
const match=/^dev:([^:]+):(.+)$/.exec(token)
|
||||
return match ? {twitchUserId:match[1]!,displayName:match[2]!} : null
|
||||
}
|
||||
async createSpawn(twitchUserId:string,externalEventId:string):Promise<SpawnMessage>{return {type:'spawn-requested',externalEventId,twitchUserId,displayName:twitchUserId,broadcasterId:this.broadcasterId,followerVerified:true}}
|
||||
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}
|
||||
@@ -31,46 +36,98 @@ export class SyntheticTwitchAdapter implements TwitchAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
export interface LiveTwitchConfig { clientId:string; accessToken:string; broadcasterId:string; channelLogin:string; extensionSecret:string; resurrectionRewardId:string }
|
||||
export interface LiveTwitchConfig {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
tokenFile: 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 api: ApiClient | null = null
|
||||
private chat: ChatClient | null = null
|
||||
private eventSub: EventSubWsListener | null = null
|
||||
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})
|
||||
}
|
||||
constructor(private readonly config:LiveTwitchConfig) {}
|
||||
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)=>{
|
||||
const storedToken = await readStoredTwitchToken(this.config.tokenFile)
|
||||
if (storedToken.clientId !== this.config.clientId) {
|
||||
throw new Error('The stored Twitch token belongs to a different application.')
|
||||
}
|
||||
if (storedToken.userId !== this.config.broadcasterId) {
|
||||
throw new Error('The stored Twitch token does not belong to TWITCH_BROADCASTER_ID.')
|
||||
}
|
||||
|
||||
const authProvider = new RefreshingAuthProvider({
|
||||
clientId: this.config.clientId,
|
||||
clientSecret: this.config.clientSecret
|
||||
})
|
||||
authProvider.onRefresh((userId, token) => {
|
||||
void writeStoredTwitchToken(this.config.tokenFile, {
|
||||
...token,
|
||||
refreshToken: token.refreshToken ?? storedToken.refreshToken,
|
||||
userId,
|
||||
login: storedToken.login,
|
||||
clientId: this.config.clientId
|
||||
}).catch(error => console.error(JSON.stringify({
|
||||
level: 'error',
|
||||
component: 'twitch-token-store',
|
||||
message: error instanceof Error ? error.message : 'Token persistence failed'
|
||||
})))
|
||||
})
|
||||
authProvider.addUser(storedToken.userId, storedToken, ['chat'])
|
||||
|
||||
const api = this.api = new ApiClient({ authProvider })
|
||||
const chat = this.chat = new ChatClient({
|
||||
authProvider,
|
||||
channels: [this.config.channelLogin],
|
||||
readOnly: true,
|
||||
rejoinChannelsOnReconnect: true
|
||||
})
|
||||
const eventSub = this.eventSub = new EventSubWsListener({ apiClient: api })
|
||||
|
||||
chat.onConnect(()=>{this.connected=true;handlers.onConnection(true)})
|
||||
chat.onDisconnect(()=>{this.connected=false;handlers.onConnection(false)})
|
||||
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})
|
||||
const followerVerified=await this.isEligibleFollower(msg.userInfo.userId)
|
||||
handlers.onSpawn({type:'spawn-requested',externalEventId:msg.id,twitchUserId:msg.userInfo.userId,displayName:msg.userInfo.displayName,broadcasterId:this.config.broadcasterId,followerVerified})
|
||||
}
|
||||
}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()
|
||||
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}))
|
||||
eventSub.start()
|
||||
await chat.connect()
|
||||
}
|
||||
async verifyExtensionToken(token:string):Promise<TwitchIdentity|null>{
|
||||
try{
|
||||
if (!this.api) return null
|
||||
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
|
||||
if(payload.channel_id!==this.config.broadcasterId || typeof payload.user_id!=='string' || typeof payload.exp!=='number' || 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 createSpawn(twitchUserId:string,externalEventId:string):Promise<SpawnMessage|null>{
|
||||
if(!this.api)return null
|
||||
const user=await this.api.users.getUserById(twitchUserId)
|
||||
if(!user)return null
|
||||
return {type:'spawn-requested',externalEventId,twitchUserId,displayName:user.displayName,broadcasterId:this.config.broadcasterId,followerVerified:await this.isEligibleFollower(twitchUserId)}
|
||||
}
|
||||
private async isEligibleFollower(twitchUserId:string):Promise<boolean>{
|
||||
if(twitchUserId===this.config.broadcasterId)return true
|
||||
if(!this.api)return false
|
||||
const follower=await this.api.channels.getChannelFollowers(this.config.broadcasterId,twitchUserId,{limit:1})
|
||||
return follower.data.length===1
|
||||
}
|
||||
async normalizeSpawn():Promise<SpawnMessage|null>{return null}
|
||||
async normalizeRedemption():Promise<ChannelPointRedemption|null>{return null}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const REQUIRED_TWITCH_SCOPES = [
|
||||
'chat:read',
|
||||
'moderator:read:followers',
|
||||
'channel:read:redemptions'
|
||||
] as const
|
||||
|
||||
const StoredTwitchTokenSchema = z.object({
|
||||
accessToken: z.string().min(1),
|
||||
refreshToken: z.string().min(1),
|
||||
scope: z.array(z.string()),
|
||||
expiresIn: z.number().int().nonnegative().nullable(),
|
||||
obtainmentTimestamp: z.number().int().nonnegative(),
|
||||
userId: z.string().min(1),
|
||||
login: z.string().min(1),
|
||||
clientId: z.string().min(1)
|
||||
})
|
||||
|
||||
export type StoredTwitchToken = z.infer<typeof StoredTwitchTokenSchema>
|
||||
|
||||
export async function readStoredTwitchToken(path: string): Promise<StoredTwitchToken> {
|
||||
const value: unknown = JSON.parse(await readFile(path, 'utf8'))
|
||||
return StoredTwitchTokenSchema.parse(value)
|
||||
}
|
||||
|
||||
export async function storedTwitchTokenExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await readStoredTwitchToken(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeStoredTwitchToken(path: string, token: StoredTwitchToken): Promise<void> {
|
||||
const validated = StoredTwitchTokenSchema.parse(token)
|
||||
const directory = dirname(path)
|
||||
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
await writeFile(temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx'
|
||||
})
|
||||
await rename(temporaryPath, path)
|
||||
await chmod(path, 0o600)
|
||||
}
|
||||
@@ -3,5 +3,8 @@ 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/))
|
||||
it('allows OAuth setup before live Twitch mode is enabled',()=>expect(loadConfig({TWITCH_ENABLED:'false',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_CHANNEL_LOGIN:'labyricorn',PUBLIC_BASE_URL:'https://twungeon.example'})).toMatchObject({oauthConfigured:true,twitchRedirectUri:'https://twungeon.example/oauth/callback'}))
|
||||
it('requires paired OAuth client credentials',()=>expect(()=>loadConfig({TWITCH_ENABLED:'false',TWITCH_CLIENT_ID:'id'})).toThrow(/configured together/))
|
||||
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_EXTENSION_SECRET:'extension',CHANNEL_POINTS_RESURRECTION_REWARD_ID:''})).toThrow(/CHANNEL_POINTS_RESURRECTION_REWARD_ID/))
|
||||
it('requires a private host and strong password for the admin listener',()=>{expect(()=>loadConfig({ADMIN_ENABLED:'true',ADMIN_PASSWORD:'short'})).toThrow(/ADMIN_PASSWORD/);expect(()=>loadConfig({ADMIN_ENABLED:'true',ADMIN_PASSWORD:'long-enough-password',ADMIN_HOST:'0.0.0.0'})).toThrow(/ADMIN_HOST/);expect(loadConfig({ADMIN_ENABLED:'true',ADMIN_PASSWORD:'long-enough-password'})).toMatchObject({adminEnabled:true,adminHost:'10.138.4.44',adminPort:3001})})
|
||||
})
|
||||
|
||||
@@ -13,13 +13,21 @@ function envelope(game:Game,command:any,requestId='r1') {const s=game.snapshot()
|
||||
|
||||
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.each([1,2,3,4])('AT-016 gives %i living players a five-second phase',(count)=>{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(5_000);expect(phase.timerResetPlayerIds).toEqual([])}})
|
||||
it('caps a level at four deterministic party slots and dead players retain their slot',()=>{const {game}=harness();for(let i=1;i<=4;i++)expect(spawn(game,`u${i}`,`P${i}`,`s${i}`).accepted).toBe(true);expect(game.snapshot().players.map(p=>[p.partySlot,p.color])).toEqual([[1,'blue'],[2,'green'],[3,'red'],[4,'yellow']]);(game as any).killPlayer((game as any).state.players.u2);expect(spawn(game,'u5','Five','s5')).toMatchObject({accepted:false,reason:'PARTY_FULL'});expect(game.snapshot().players).toHaveLength(4)})
|
||||
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('resets the rolling timer only for each player first valid action and ends when all finish',()=>{const {game,advance}=harness([1,1]);spawn(game,'u1','One');spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();const firstPhase=game.snapshot().phase;if(firstPhase.kind!=='player')throw new Error();advance(1_000);game.command('u1',true,envelope(game,{type:'pass'},'a1'));let phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();expect(phase).toMatchObject({deadlineAt:7_000,timerResetPlayerIds:['u1']});advance(1_000);game.command('u1',true,envelope(game,{type:'pass'},'a2'));phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();expect(phase.deadlineAt).toBe(7_000);advance(1_000);game.command('u2',true,envelope(game,{type:'pass'},'b1'));phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();expect(phase).toMatchObject({deadlineAt:9_000,timerResetPlayerIds:['u1','u2']});game.command('u2',true,envelope(game,{type:'pass'},'b2'));phase=game.snapshot().phase;expect(phase.kind).toBe('player');if(phase.kind==='player')expect(phase.phaseId).not.toBe(firstPhase.phaseId)})
|
||||
it('expires after five seconds and converts unused AP into AutoGuard for the Enemy Phase',()=>{const {game,advance}=harness([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};internal.goblin.mode='pursuing';internal.goblin.targetPlayerId='u1';advance(5_000);game.tick();expect(game.snapshot().players[0]).toMatchObject({hp:3,ap:2,guard:0});expect(game.snapshot().actionLog.filter(entry=>entry.type==='guard-blocked')).toHaveLength(2)})
|
||||
it('AutoGuards a player immediately when their last connection closes',()=>{const {game}=harness();spawn(game);spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();expect(game.disconnect('u2')).toBe(true);expect(game.snapshot().players.find(p=>p.twitchUserId==='u2')).toMatchObject({connectionState:'disconnected',ap:0,guard:2,eligibleThisPhase:false});expect(game.snapshot().phase.kind).toBe('player')})
|
||||
it('does not wait for a disconnected player in later phases',()=>{const {game}=harness([1,1]);spawn(game);spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');game.disconnect('u2');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.find(p=>p.twitchUserId==='u1')).toMatchObject({ap:2,connectionState:'connected'});expect(game.snapshot().players.find(p=>p.twitchUserId==='u2')).toMatchObject({ap:0,guard:2,connectionState:'disconnected'})})
|
||||
it('supports administrative player removal, phase completion, and run reset',()=>{const {game}=harness();spawn(game);spawn(game,'u2','Two');const firstRun=game.snapshot().runId;expect(game.removePlayer('u2')).toBe(true);expect(game.removePlayer('missing')).toBe(false);expect(game.snapshot().players.map(p=>p.twitchUserId)).toEqual(['u1']);expect(game.forceEndPlayerPhase()).toBe(true);game.resetRunByAdmin();expect(game.snapshot().runId).not.toBe(firstRun);expect(game.snapshot().actionLog.at(-2)?.type).toBe('admin-reset')})
|
||||
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('returns to dormant when the final living player dies and resumes on resurrection',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const before=game.snapshot();(game as any).killPlayer((game as any).state.players.u1);expect(game.snapshot()).toMatchObject({runId:before.runId,floorNumber:before.floorNumber,phase:{kind:'dormant'},players:[{lifeState:'dead',hp:0}]});expect(game.snapshot().actionLog.at(-1)?.type).toBe('party-defeated');expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'return',twitchUserId:'u1',rewardId:'resurrection'}).accepted).toBe(true);expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players[0]).toMatchObject({lifeState:'alive',ap:2})})
|
||||
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')})
|
||||
it('clears the party at the exit and assigns next-level slots first come first served',()=>{const {game}=harness();for(let i=1;i<=4;i++){spawn(game,`u${i}`,`P${i}`,`s${i}`);game.bindExtension(`u${i}`)}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()).toMatchObject({floorNumber:2,phase:{kind:'dormant'},players:[]});expect(game.personalizedSnapshot('u1').viewer).toBeNull();expect(spawn(game,'u5','Five','next-5').accepted).toBe(true);expect(spawn(game,'u1','One','next-1').accepted).toBe(true);expect(game.snapshot().players.map(p=>[p.twitchUserId,p.partySlot,p.color])).toEqual([['u5',1,'blue'],['u1',2,'green']]);expect(game.personalizedSnapshot('u1').viewer).toMatchObject({twitchUserId:'u1',partySlot:2,color:'green'})})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TwitchOAuthService } from '../../apps/backend/src/twitchOAuth.js'
|
||||
import { readStoredTwitchToken } from '../../packages/twitch-adapter/src/tokenStore.js'
|
||||
|
||||
const directories: string[] = []
|
||||
afterEach(async()=>{await Promise.all(directories.splice(0).map(path=>rm(path,{recursive:true,force:true})))})
|
||||
|
||||
async function harness(scopes=['chat:read','moderator:read:followers','channel:read:redemptions']) {
|
||||
const directory=await mkdtemp(join(tmpdir(),'twungeon-oauth-'));directories.push(directory)
|
||||
const tokenFile=join(directory,'token.json')
|
||||
const fetchMock=vi.fn(async(input:string|URL|Request)=>{
|
||||
const url=String(input)
|
||||
if(url.endsWith('/token'))return new Response(JSON.stringify({access_token:'access',refresh_token:'refresh',expires_in:3600,scope:scopes,token_type:'bearer'}),{status:200,headers:{'content-type':'application/json'}})
|
||||
if(url.endsWith('/validate'))return new Response(JSON.stringify({client_id:'client',login:'labyricorn',scopes,user_id:'1234',expires_in:3500}),{status:200,headers:{'content-type':'application/json'}})
|
||||
return new Response(null,{status:404})
|
||||
}) as typeof fetch
|
||||
const service=new TwitchOAuthService({clientId:'client',clientSecret:'secret',redirectUri:'https://twungeon.example/oauth/callback',expectedLogin:'labyricorn',tokenFile},fetchMock,()=>1_000)
|
||||
return {service,tokenFile,fetchMock}
|
||||
}
|
||||
|
||||
describe('Twitch OAuth',()=>{
|
||||
it('uses state, validates the broadcaster, and stores a refreshable token',async()=>{
|
||||
const {service,tokenFile,fetchMock}=await harness()
|
||||
const authorization=new URL(service.startAuthorization()),state=authorization.searchParams.get('state')!
|
||||
expect(authorization.searchParams.get('redirect_uri')).toBe('https://twungeon.example/oauth/callback')
|
||||
expect(authorization.searchParams.get('scope')).toContain('moderator:read:followers')
|
||||
await expect(service.completeAuthorization('code',state)).resolves.toMatchObject({login:'labyricorn',userId:'1234'})
|
||||
await expect(readStoredTwitchToken(tokenFile)).resolves.toMatchObject({accessToken:'access',refreshToken:'refresh',userId:'1234',clientId:'client'})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await expect(service.status()).resolves.toMatchObject({configured:true,authorized:true})
|
||||
})
|
||||
|
||||
it('rejects missing state before exchanging a code',async()=>{
|
||||
const {service,fetchMock}=await harness()
|
||||
await expect(service.completeAuthorization('code','unknown')).rejects.toThrow(/state is missing or expired/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a token missing a required scope',async()=>{
|
||||
const {service,tokenFile}=await harness(['chat:read'])
|
||||
const state=new URL(service.startAuthorization()).searchParams.get('state')!
|
||||
await expect(service.completeAuthorization('code',state)).rejects.toThrow(/missing required scopes/)
|
||||
await expect(readStoredTwitchToken(tokenFile)).rejects.toMatchObject({code:'ENOENT'})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { once } from 'node:events'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createAdminServer } from '../../apps/backend/src/admin.js'
|
||||
import { Game, type Clock, type IdGenerator, type RandomProvider } from '../../packages/domain/src/index.js'
|
||||
import { generateFloor } from '../../packages/dungeon-generator/src/index.js'
|
||||
|
||||
const servers:ReturnType<typeof createAdminServer>[]=[]
|
||||
afterEach(async()=>{for(const server of servers.splice(0))if(server.listening){server.close();await once(server,'close')}})
|
||||
function harness(allowed=true){let id=0;const clock:Clock={now:()=>1_000},random:RandomProvider={next:()=>0},ids:IdGenerator={next:prefix=>`${prefix}-${++id}`};const game=new Game({clock,random,ids,generateFloor,resurrectionRewardId:'resurrection'},'admin-test');game.spawn({type:'spawn-requested',externalEventId:'spawn',twitchUserId:'u1',displayName:'One',broadcasterId:'b',followerVerified:true});game.bindExtension('u1');const server=createAdminServer({game,password:'test-admin-password',csrfToken:'test-csrf-token',isAllowedAddress:()=>allowed,onStateChange:()=>{},status:async()=>({uptimeSeconds:10,twitchReady:true,twitchMode:'synthetic',oauthConfigured:false,oauthAuthorized:false})});servers.push(server);return{game,server}}
|
||||
async function start(server:ReturnType<typeof createAdminServer>){server.listen(0,'127.0.0.1');await once(server,'listening');const address=server.address();if(!address||typeof address==='string')throw new Error('No admin address');return`http://127.0.0.1:${address.port}`}
|
||||
const authorization=`Basic ${Buffer.from('admin:test-admin-password').toString('base64')}`
|
||||
|
||||
describe('private admin panel',()=>{
|
||||
it('rejects non-private clients before authentication',async()=>{const {server}=harness(false),base=await start(server);expect((await fetch(base,{headers:{authorization}})).status).toBe(403)})
|
||||
it('requires authentication and does not expose its password',async()=>{const {server}=harness(),base=await start(server);expect((await fetch(base)).status).toBe(401);const page=await (await fetch(base,{headers:{authorization}})).text();expect(page).toContain('Twungeon Admin');expect(page).toContain('One');expect(page).not.toContain('test-admin-password')})
|
||||
it('requires CSRF tokens and applies administrative actions',async()=>{const {game,server}=harness(),base=await start(server);const invalid=await fetch(`${base}/action`,{method:'POST',headers:{authorization,'content-type':'application/x-www-form-urlencoded'},body:'action=disconnect&userId=u1'});expect(invalid.status).toBe(403);const valid=await fetch(`${base}/action`,{method:'POST',headers:{authorization,'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({_csrf:'test-csrf-token',action:'disconnect',userId:'u1'}),redirect:'manual'});expect(valid.status).toBe(303);expect(game.snapshot().players[0]).toMatchObject({connectionState:'disconnected',ap:0,guard:2})})
|
||||
})
|
||||
@@ -8,8 +8,13 @@ async function post(path:string,value:unknown,token?:string){return fetch(`${bas
|
||||
|
||||
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('reports OAuth readiness without exposing credentials',async()=>{const data=await fetch(`${base}/oauth/status`).then(r=>r.json());expect(data).toMatchObject({configured:false,authorized:false});expect(JSON.stringify(data)).not.toMatch(/clientId|clientSecret|accessToken|refreshToken/)})
|
||||
it('serves responsive desktop and mobile Twitch controls',async()=>{const [pageResponse,rootResponse,scriptResponse,styleResponse]=await Promise.all([fetch(`${base}/extension`),fetch(`${base}/`),fetch(`${base}/app.js?v=20260817-overlay`),fetch(`${base}/styles.css`)]),[html,rootHtml,script,styles]=await Promise.all([pageResponse.text(),rootResponse.text(),scriptResponse.text(),styleResponse.text()]);expect(pageResponse.headers.get('cache-control')).toBe('no-store');expect(scriptResponse.headers.get('cache-control')).toBe('no-store');expect(styleResponse.headers.get('cache-control')).toBe('no-store');expect(html).toContain('<html lang="en" class="extension-mode">');expect(rootHtml).not.toContain('<html lang="en" class="extension-mode">');expect(rootHtml).toContain('Activate the Extension to spawn your character in the Twungeon!');expect(rootHtml).toContain('<span class="banner-note">Must be a follower to spawn.</span>');expect(html).toContain('/app.js?v=20260817-overlay');expect(html).toContain('id="controlRegion"');expect(html).toContain('id="viewerStatus"');expect(html).toContain('id="partyLegend"');expect(html).toContain('id="spawnExtension"');expect(html).toContain('id="shareIdentity"');expect(script).toContain("location.pathname==='/extension'");expect(script).toContain("['localhost','127.0.0.1','[::1]']");expect(script).toContain('onContext');expect(script).toContain('extension-mobile');expect(script).toContain('partySlot');expect(script).toContain("blue:'#3f8cff',green:'#38c976',red:'#ef5350',yellow:'#f2c94c'");expect(script).toContain('/api/extension/spawn');expect(script).toContain('/api/commands');expect(script).toContain('viewer?.isLinked');expect(script).toContain('actions.requestIdShare()');expect(styles).toContain('--controls-left:');expect(styles).toContain('html.extension-mode { color-scheme: normal; }');expect(styles).toContain('background: transparent');expect(styles).toContain('pointer-events: none');expect(styles).toContain('#controller button { pointer-events: auto');expect(styles).toContain('.entity.player.stacked.slot-4');expect(styles).toContain('min-height: 56px');expect(styles).toContain('min-height: 52px')})
|
||||
it('serves a public privacy notice for identity linking',async()=>{const response=await fetch(`${base}/privacy.html`),html=await response.text();expect(response.status).toBe(200);expect(html).toContain('Twungeon Privacy Notice');expect(html).toContain('numeric Twitch user ID')})
|
||||
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('spawns from an authenticated Extension identity',async()=>{const id=`button-${Date.now()}`,auth=await (await post('/api/extension/session',{token:`dev:${id}:Button Viewer`})).json();expect((await post('/api/extension/spawn',{},auth.token)).status).toBe(200);const state=await fetch(`${base}/api/state`).then(r=>r.json());expect(state.players.some((player:any)=>player.twitchUserId===id)).toBe(true);expect((await post('/api/extension/spawn',{})).status).toBe(401)})
|
||||
it('rejects the fifth Extension spawn with a clear full-party result',async()=>{let state=await fetch(`${base}/api/state`).then(r=>r.json());for(let i=state.players.length;i<4;i++){const id=`fill-${Date.now()}-${i}`,auth=await (await post('/api/extension/session',{token:`dev:${id}:Fill ${i}`})).json();expect((await post('/api/extension/spawn',{},auth.token)).status).toBe(200)}state=await fetch(`${base}/api/state`).then(r=>r.json());expect(state.players).toHaveLength(4);const spectator=`spectator-${Date.now()}`,auth=await (await post('/api/extension/session',{token:`dev:${spectator}:Spectator`})).json(),response=await post('/api/extension/spawn',{},auth.token),result=await response.json();expect(response.status).toBe(409);expect(result).toMatchObject({accepted:false,reason:'PARTY_FULL',message:'The current party is full. You can try again on the next level.'});expect(result.state.viewer).toBeNull()})
|
||||
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')})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user