Refine party, timer, and Extension mechanics

This commit is contained in:
2026-08-17 21:07:20 -07:00
parent db19a89957
commit 879cfd4fd0
16 changed files with 218 additions and 105 deletions
+7
View File
@@ -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 Extension identity. Live Twitch acceptance still requires operator credentials, an
approved Extension configuration, and real-viewer evidence. 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 ## Quick start
Requirements: Node.js 22 or newer and npm. Requirements: Node.js 22 or newer and npm.
@@ -166,6 +166,9 @@ events without importing Twitch, RPGJS, HTTP, WebSocket, or browser code.
- [ ] Implement the broadcast-mode three-section layout. - [ ] Implement the broadcast-mode three-section layout.
- [ ] Render players, Goblin, walls, paths, exit, shared status, and action log - [ ] Render players, Goblin, walls, paths, exit, shared status, and action log
with temporary but distinguishable visuals. 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. - [ ] Render the exact dormant banner text.
**Checkpoint P3:** A local read-only stream view can cycle through many valid, **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. - [ ] Implement dormant, Player Phase, Enemy Phase, and transition states.
- [ ] Start Player Phase with 2 AP and zero Guard for each living participant. - [ ] 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. - [ ] End Player Phase early when its initial eligible set is finished or dead.
- [ ] Decide deadline acceptance using gateway arrival before the monotonic - [ ] Decide deadline acceptance using gateway arrival before the monotonic
deadline; reject arrivals at or after the deadline. deadline; reject arrivals at or after the deadline.
@@ -229,8 +233,9 @@ target death, return, death, and escape with the Goblin alive.
- [ ] Advance immediately when a living player enters the exit. - [ ] Advance immediately when a living player enters the exit.
- [ ] Reject queued commands for the previous floor. - [ ] Reject queued commands for the previous floor.
- [ ] Generate the next floor and Goblin. - [ ] Generate the next floor and Goblin.
- [ ] Revive, heal, reposition, and refresh self-heal for all participants on - [ ] Clear all previous party records and enter dormancy on floor advancement.
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. - [ ] Return the current floor to dormancy immediately after the last participant dies.
- [ ] Distinguish initial dormancy from defeated-party dormancy. - [ ] Distinguish initial dormancy from defeated-party dormancy.
- [ ] Preserve character identity and state across simulated client reconnects. - [ ] Preserve character identity and state across simulated client reconnects.
@@ -415,6 +420,8 @@ successful connection logs.
- [ ] As a verified follower with no character or same-floor death, issue - [ ] As a verified follower with no character or same-floor death, issue
`!spawn` and confirm one character appears in the current spawn room. `!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 **Level:** Live integration
**Pass evidence:** Chat capture, follower result, spawn event, and stream capture. **Pass evidence:** Chat capture, follower result, spawn event, and stream capture.
@@ -492,12 +499,14 @@ successful connection logs.
**Level:** Automated domain/integration **Level:** Automated domain/integration
**Pass evidence:** State-transition assertions and ordered event log. **Pass evidence:** State-transition assertions and ordered event log.
### AT-016 — Scaled timer and cap ### AT-016 — Rolling five-second timer
**PRD criterion:** 16 **PRD criterion:** 16
- [ ] Verify durations of 25, 50, 75, 100, and 120 seconds for one through five - [ ] Confirm phase start sets a five-second deadline.
living players and 120 seconds for more than five. - [ ] 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 **Level:** Automated fake-clock domain test plus one live timing check
**Pass evidence:** Parameterized test output and live timer recording. **Pass evidence:** Parameterized test output and live timer recording.
@@ -564,17 +573,21 @@ successful connection logs.
**PRD criterion:** 22 **PRD criterion:** 22
- [ ] Aggro but do not kill the Goblin, move a living player onto the exit, and - [ ] 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 **Level:** Automated E2E plus live scenario
**Pass evidence:** Before/after snapshots and stream recording. **Pass evidence:** Before/after snapshots and stream recording.
### AT-023 — Dead players revive on advancement ### AT-023 — Full next-floor reparty
**PRD criterion:** 23 **PRD criterion:** 23
- [ ] Kill one player, advance with another, and confirm the dead player returns - [ ] Fill four slots, kill one player, and advance with another.
at spawn with full HP and refreshed self-heal on the next floor. - [ ] 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 **Level:** Automated E2E plus live scenario
**Pass evidence:** Before/after player and floor state. **Pass evidence:** Before/after player and floor state.
+41 -34
View File
@@ -37,7 +37,7 @@ The MVP should demonstrate that multiple Twitch viewers can:
- die and remain inactive for the current floor, - die and remain inactive for the current floor,
- resurrect through a configured Channel Points reward, - resurrect through a configured Channel Points reward,
- advance the entire party by reaching the exit, - advance the entire party by reaching the exit,
- revive dead players on floor advancement, - reform the party from scratch on floor advancement,
- and return the current floor to dormancy when all participating players die. - 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. 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. 2. The backend verifies that the viewer follows the channel.
3. The backend verifies that the viewer does not already have an active character. 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. 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. 5. The backend verifies that one of four party slots remains available.
6. The game records the Twitch user ID as the authoritative identity for that character. 6. A character is created in the current floor's spawn room and assigned the lowest available party slot.
7. The player's Twitch Extension identity must resolve to the same Twitch user ID before game controls are enabled. 7. The game records the Twitch user ID as the authoritative identity for that character.
8. The player's Twitch Extension game controls become active. 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. 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. A follower who has died on the current floor cannot use `!spawn` to bypass death. That player must wait for either a Channel Points resurrection or advancement to the next floor.
### 3.4 Twitch Identity Binding ### 3.4 Twitch Identity Binding
@@ -118,6 +123,12 @@ The shared Twitch stream is the primary game display.
Players do not require a separate conventional game client. 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 ### 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. The MVP game view must establish the intended structure of the final Twungeon experience even if the graphics and styling remain temporary.
@@ -252,21 +263,11 @@ Players do not need to act in a fixed sequential order. Valid player commands ma
### 7.1 Player Phase Timer ### 7.1 Player Phase Timer
The Player Phase timer scales with the number of living players: 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
**25 seconds per living player** 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
The timer is capped at: may reset it once, so one player cannot extend the phase indefinitely.
**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 ends when either: The Player Phase ends when either:
@@ -464,12 +465,16 @@ When any living player enters the exit:
1. The current floor ends immediately. 1. The current floor ends immediately.
2. The floor counter increases. 2. The floor counter increases.
3. A new two-room floor is generated. 3. Every previous party slot and character is cleared, alive or dead.
4. A new Goblin Guard is created. 4. A new two-room floor is generated.
5. All participating players are placed in the new spawn room. 5. A new Goblin Guard is created.
6. All players are restored to full HP. 6. The new floor enters Dormant state.
7. All dead players are resurrected. 7. Eligible followers claim the four new party slots first come, first served.
8. Every player's self-heal is refreshed.
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. The dungeon may continue generating floors indefinitely for the MVP.
@@ -488,10 +493,9 @@ While dead:
There is no timed automatic resurrection. There is no timed automatic resurrection.
A dead player returns to play only through: A dead player returns during the current floor only through immediate Channel
Points resurrection. When another player reaches the exit, the entire party is
1. immediate Channel Points resurrection, or cleared; that viewer may compete to spawn a fresh character on the next floor.
2. another living player reaching the next floor.
--- ---
@@ -523,7 +527,9 @@ On defeat:
2. All defeated characters remain dead and retain their current-floor death markers. 2. All defeated characters remain dead and retain their current-floor death markers.
3. No phase timer runs and the Goblin does not act. 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. 4. A configured resurrection can revive a dead viewer and begin a fresh Player Phase.
5. A different eligible viewer may spawn 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, - verified chat-to-Extension identity binding,
- follower eligibility, - follower eligibility,
- one character per Twitch user, - one character per Twitch user,
- current-floor party slot and its fixed Blue, Green, Red, or Yellow color,
- current HP, - current HP,
- alive/dead state, - alive/dead state,
- current AP, - current AP,
@@ -664,14 +671,14 @@ The MVP is successful if it demonstrates that:
13. Multiple viewers can control separate characters in the same dungeon. 13. Multiple viewers can control separate characters in the same dungeon.
14. Twitch Extension commands reliably control movement and actions. 14. Twitch Extension commands reliably control movement and actions.
15. The Player Phase / Enemy Phase cycle functions correctly. 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. 17. The Player Phase ends early when all living players finish.
18. AP, AutoGuard, attacks, healing, HP, and death interact correctly. 18. AP, AutoGuard, attacks, healing, HP, and death interact correctly.
19. The action log accurately reports significant game actions and state changes. 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. 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. 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. 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. Dead players return when the floor advances. 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`. 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. 25. The configured Channel Points reward can resurrect the correct dead player during the current floor.
26. A total-party defeat returns the current floor to dormancy without reviving players or resetting the run. 26. A total-party defeat returns the current floor to dormancy without reviving players or resetting the run.
+46 -25
View File
@@ -246,6 +246,8 @@ interface PlayerState {
followerVerified: boolean followerVerified: boolean
characterCreated: boolean characterCreated: boolean
extensionBound: boolean extensionBound: boolean
partySlot: 1 | 2 | 3 | 4
color: 'blue' | 'green' | 'red' | 'yellow'
connectionState: 'connected' | 'disconnected' connectionState: 'connected' | 'disconnected'
position: TilePosition position: TilePosition
hp: 0 | 1 | 2 | 3 hp: 0 | 1 | 2 | 3
@@ -274,6 +276,7 @@ type PhaseState =
startedAt: number startedAt: number
deadlineAt: number deadlineAt: number
initialEligiblePlayerIds: string[] initialEligiblePlayerIds: string[]
timerResetPlayerIds: string[]
} }
| { kind: 'enemy'; phaseId: string } | { kind: 'enemy'; phaseId: string }
| { kind: 'transition'; reason: 'floor-advance' | 'admin-reset' } | { kind: 'transition'; reason: 'floor-advance' | 'admin-reset' }
@@ -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 Players may share that tile. A late join never changes the current phase timer
or the set of players required to finish the phase. 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 ### 7.3 Attack range and targeting
- Player and Goblin attacks target an orthogonally adjacent tile. - Player and Goblin attacks target an orthogonally adjacent tile.
@@ -451,14 +460,12 @@ For every living participant present when the phase starts:
3. set `eligibleThisPhase` to true; and 3. set `eligibleThisPhase` to true; and
4. include the player in `initialEligiblePlayerIds`. 4. include the player in `initialEligiblePlayerIds`.
The duration is: The initial deadline is five seconds after phase start. The phase begins with
an empty `timerResetPlayerIds` list. After each player's first accepted command,
```text the backend adds that Twitch user ID and moves `deadlineAt` to five seconds
min(living-player-count * 25 seconds, 120 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.
The count includes disconnected living characters, matching the PRD's AFK
AutoGuard behavior.
Players who spawn or resurrect after the phase starts receive 0 AP, are not 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 added to the phase completion set, and may act beginning with the next Player
@@ -506,7 +513,8 @@ An accepted spawn requires:
- a stable Twitch chat user ID; - a stable Twitch chat user ID;
- verified follower status; - verified follower status;
- no existing character for that ID; and - 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 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. 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 Repeated `!spawn` attempts for an existing character return an informative chat
response or log result and never create another character. 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 ### 10.2 Heal
A valid self-heal: A valid self-heal:
@@ -624,14 +635,15 @@ When a living player enters the exit:
1. enter transition state and reject remaining old-floor commands; 1. enter transition state and reject remaining old-floor commands;
2. increment the floor counter; 2. increment the floor counter;
3. generate and validate a new floor and Goblin; 3. generate and validate a new floor and Goblin;
4. place every participating player on the new spawn tile; 4. clear every current party record and release all four slots;
5. restore every player to 3 HP and alive; 5. discard all previous character HP, AP, Guard, heal, death, position, slot,
6. clear death markers, AP, and Guard; and color state;
7. refresh every self-heal; 6. log the new floor; and
8. log the new floor; and 7. enter Dormant state for a new first-come, first-served party.
9. begin a fresh Player Phase.
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 defeat ### 12.3 Total-party defeat
@@ -645,9 +657,10 @@ alive, the backend immediately:
5. logs the party defeat. 5. logs the party defeat.
A valid Channel Points resurrection revives its owner and starts a fresh Player A valid Channel Points resurrection revives its owner and starts a fresh Player
Phase from dormancy. A newly eligible viewer may also spawn into the current Phase from dormancy. If the defeated party claimed fewer than four slots, a
floor and resume play. Initial dormancy has no participants, while defeated-party newly eligible viewer may claim an open slot and resume play. A defeated full
dormancy retains dead participant records. party requires resurrection. Initial dormancy has no participants, while
defeated-party dormancy retains dead participant records.
## 13. Randomness ## 13. Randomness
@@ -747,14 +760,22 @@ The default wide layout reserves:
- the largest upper-right area for the map; and - the largest upper-right area for the map; and
- a full-width bottom area for the action log. - a full-width bottom area for the action log.
Temporary shapes, colors, labels, and sprites are acceptable. Every required Temporary shapes, labels, and sprites are acceptable. Every required entity,
entity, wall, traversable path, and exit must remain distinguishable. 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 ### 15.2 Player status
The personalized Extension status includes at least identity/binding status, The personalized Extension status includes at least identity/binding status,
HP, AP, Guard, heal availability, alive/dead status, current phase, and a clear party slot/color, HP, AP, Guard, heal availability, alive/dead status, current
disabled reason when controls are unavailable. 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 ### 15.3 Action log
@@ -872,7 +893,7 @@ Deterministic tests must cover:
- all accepted and rejected command conditions; - all accepted and rejected command conditions;
- AP spending and early phase completion; - 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; - deadline boundary behavior;
- AutoGuard conversion, hit blocking, misses, and reset; - AutoGuard conversion, hit blocking, misses, and reset;
- player and Goblin hit probabilities through injected rolls; - player and Goblin hit probabilities through injected rolls;
@@ -910,7 +931,7 @@ At minimum, the live test plan must demonstrate:
6. movement, two attacks, heal, pass, timer expiry, and AutoGuard; 6. movement, two attacks, heal, pass, timer expiry, and AutoGuard;
7. Goblin aggro, pursuit, attack, target death, and return; 7. Goblin aggro, pursuit, attack, target death, and return;
8. Channel Points resurrection of the correct dead viewer; 8. Channel Points resurrection of the correct dead viewer;
9. floor escape with the Goblin alive and dead-player revival; 9. floor escape with the Goblin alive, party clearing, and next-floor reparty;
10. total-party defeat, dormant transition, and resurrection recovery; 10. total-party defeat, dormant transition, and resurrection recovery;
11. refresh and temporary disconnect recovery; and 11. refresh and temporary disconnect recovery; and
12. repetition across multiple generated floors. 12. repetition across multiple generated floors.
+2 -2
View File
@@ -60,7 +60,7 @@ export function createAdminServer(deps:AdminServerDependencies){
async function render(deps:AdminServerDependencies,notice:string|null):Promise<string>{ 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 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 token=escapeHtml(deps.csrfToken)
const playerRows=state.players.map(player=>`<tr><td>${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="8">No players are currently in the dungeon.</td></tr>' 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('') 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}.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">Players</div><div class="value">${state.players.length}</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>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>` 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>`
} }
+3 -3
View File
@@ -72,14 +72,14 @@ export const server=createServer(async(req,res)=>{
if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){ if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){
const userId=sessionUser(bearer(req));if(!userId)return json(res,401,{error:'UNAUTHENTICATED'}) 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 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)broadcast();return json(res,result.accepted?200:409,{...result,state:game.personalizedSnapshot(userId)}) 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'){ if(req.method==='POST'&&url.pathname==='/api/commands'){
const token=bearer(req),userId=sessionUser(token),parsed=CommandEnvelopeSchema.safeParse(await body(req)) 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}))}) 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) 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==='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==='/'){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==='/'){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==='/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)}
@@ -93,7 +93,7 @@ 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))}) 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),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()})}) 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() 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(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'){ if(process.env.NODE_ENV!=='test'){
server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`)) 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}`)) adminServer?.listen(config.adminPort,config.adminHost,()=>console.log(`Twungeon admin listening on http://${config.adminHost}:${config.adminPort}`))
+11 -6
View File
@@ -2,14 +2,18 @@ let state=null,session=null,userId=null,lastSequence=0,activeSocket=null,extensi
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 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 $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s)
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])) const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
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 extensionMode?'Click Spawn character to join.':'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' 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)):'—' 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='' 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)} 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=>`<li><small>#${e.sequence}</small> ${escapeHtml(e.message)}</li>`).join('');$('#log').scrollTop=$('#log').scrollHeight $('#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);spawnButton.textContent=me?'Character spawned':'Spawn character' 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||data.reason||'Request failed');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 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()}
@@ -20,4 +24,5 @@ $('#spawn').onclick=()=>spawn().catch(e=>$('#disabled').textContent=e.message);$
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()}} 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() connect()
setInterval(()=>{if(state?.phase.kind==='player')render()},250) setInterval(()=>{if(state?.phase.kind==='player')render()},250)
if(window.Twitch?.ext)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)}) 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)})}
+2 -2
View File
@@ -2,9 +2,9 @@
<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> <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"> <body><main class="shell">
<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> <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><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> <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> <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> </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>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>● 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> <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?v=20260817-overlay"></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>
+35 -2
View File
@@ -40,9 +40,16 @@ h2 { font: 700 18px/1.2 system-ui; }
.tile.floor { background: var(--floor); } .tile.floor { background: var(--floor); }
.tile.exit { background: #68491b; color: #ffd77e; } .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 { 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.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%; } .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; } .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; } #status { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.stat { border: 1px solid var(--line); padding: 9px; } .stat { border: 1px solid var(--line); padding: 9px; }
.stat b { display: block; color: var(--gold); font-size: 18px; } .stat b { display: block; color: var(--gold); font-size: 18px; }
@@ -60,7 +67,7 @@ details { margin-top: auto; color: var(--muted); }
details label { display: block; margin: 8px 0; } details label { display: block; margin: 8px 0; }
input { display: block; width: 100%; background: #0d0f10; border: 1px solid var(--line); color: var(--ink); padding: 6px; } 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 { 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 { 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); } #log li:last-child { color: var(--ink); border-color: var(--gold); }
#connection { font-size: 12px; color: var(--muted); } #connection { font-size: 12px; color: var(--muted); }
@@ -87,6 +94,13 @@ html.extension-mode .status {
} }
html.extension-mode #controller { width: min(100%, 18rem); 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 #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 #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 { 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 .dpad button { aspect-ratio: 1; padding: 0; font-size: clamp(13px, 6cqw, 24px); }
@@ -108,6 +122,25 @@ html.extension-mode.overlay-dev .status { place-items: start center; overflow: a
html:not(.extension-mode) #banner { white-space: normal; width: 75%; } 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) { @media (max-aspect-ratio: 4/3) {
html.extension-mode { --controls-width: 31%; --controls-height: 62%; } html.extension-mode { --controls-width: 31%; --controls-height: 62%; }
} }
+15 -9
View File
@@ -23,14 +23,16 @@ Twitch identities; they do not require credentials.
1. Run `npm run dev` and open `http://localhost:3000`. 1. Run `npm run dev` and open `http://localhost:3000`.
2. Confirm the exact dormant banner appears and no timer runs. 2. Confirm the exact dormant banner appears and no timer runs.
3. Use **Local viewer login** to spawn and bind a viewer. 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. 4. Confirm 3 HP, 2 AP, no Guard, a ready heal, and a five-second phase.
5. Exercise movement, invalid walls, Attack, Heal, and Pass. Invalid actions 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. 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. 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. 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. server.
The local-only endpoints `/api/dev/spawn` and `/api/dev/redemption` exist only while The local-only endpoints `/api/dev/spawn` and `/api/dev/redemption` exist only while
@@ -52,7 +54,8 @@ 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 accepted only on `localhost`, `127.0.0.1`, or `[::1]`, so the diagnostics cannot
be enabled on the deployed Extension origin. be enabled on the deployed Extension origin.
Verify at 1920×1080, 1280×720, and a narrower or 4:3 viewport: 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; - the document and empty overlay canvas remain transparent;
- controls stay within the upper-left debug boundary and remain readable; - controls stay within the upper-left debug boundary and remain readable;
@@ -61,7 +64,9 @@ Verify at 1920×1080, 1280×720, and a narrower or 4:3 viewport:
- Up, Down, Left, Right, Attack, Heal, and Pass reach the same backend routes; - 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 - missing character, dead character, non-player phase, and zero-AP states still
disable the controls; and disable the controls; and
- loading `/` still presents the full local game and controller. - 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 Remove the query string for the production-shaped local view. Twitch-hosted
testing still must cover identity sharing, player ownership, theater mode, testing still must cover identity sharing, player ownership, theater mode,
@@ -69,15 +74,16 @@ fullscreen, embeds, ads/pauses, and Twitch player-control safe zones.
## Live-channel campaign ## 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: non-follower account. Capture redacted evidence for:
- eligible, ineligible, and duplicate `!spawn` attempts; - eligible, ineligible, and duplicate `!spawn` attempts;
- matching and mismatched Extension identities; - matching and mismatched Extension identities;
- independent commands from two viewers; - independent commands from two viewers;
- four deterministic party slots/colors and a rejected fifth viewer;
- phase expiry and AutoGuard while one viewer is disconnected; - phase expiry and AutoGuard while one viewer is disconnected;
- death and a configured Channel Points reward redemption from the same user; - death and a configured Channel Points reward redemption from the same user;
- escape with the Goblin alive, floor revival, and total-party defeat dormancy; - escape with the Goblin alive, complete next-floor reparty, and total-party defeat dormancy;
- refresh/reconnect during a phase and transition; and - refresh/reconnect during a phase and transition; and
- repeated floors and a short soak session. - repeated floors and a short soak session.
+3
View File
@@ -96,6 +96,9 @@ repository has no manifest that can change an Extension version in Twitch.
6. Test the overlay while live in normal, theater, fullscreen, and a narrow 6. Test the overlay while live in normal, theater, fullscreen, and a narrow
player. The video player and stream supply the game frame; `/extension` player. The video player and stream supply the game frame; `/extension`
should show only the upper-left controls on a transparent canvas. 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 7. A JWT without `user_id`, with the wrong
`channel_id`, an expired signature, or an `external` role is rejected. `channel_id`, an expired signature, or an `external` role is rejected.
8. 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
+19 -7
View File
@@ -5,7 +5,7 @@ export interface GameDependencies {
clock: Clock; random: RandomProvider; ids: IdGenerator; generateFloor(seed: string): FloorState clock: Clock; random: RandomProvider; ids: IdGenerator; generateFloor(seed: string): FloorState
resurrectionRewardId: string 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 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 } 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 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 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 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 { export class Game {
private state: RunState private state: RunState
@@ -69,7 +71,10 @@ export class Game {
if(!message.followerVerified) return {accepted:false,reason:'NOT_FOLLOWER',message:'Follow the channel before using !spawn.'} if(!message.followerVerified) return {accepted:false,reason:'NOT_FOLLOWER',message:'Follow the channel before using !spawn.'}
const existing=this.state.players[message.twitchUserId] 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.'} 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.state.players[p.twitchUserId]=p
this.log('player-spawned',p.twitchUserId,null,`${p.displayName} entered the Twungeon!`) this.log('player-spawned',p.twitchUserId,null,`${p.displayName} entered the Twungeon!`)
if(this.state.phase.kind==='dormant') this.startPlayerPhase() if(this.state.phase.kind==='dormant') this.startPlayerPhase()
@@ -106,9 +111,16 @@ export class Game {
if(!rejection && !player) rejection='CHARACTER_NOT_FOUND' if(!rejection && !player) rejection='CHARACTER_NOT_FOUND'
else if(!rejection && player?.lifeState!=='alive') rejection='CHARACTER_DEAD' else if(!rejection && player?.lifeState!=='alive') rejection='CHARACTER_DEAD'
else if(!rejection && (player?.ap ?? 0)<1) rejection='NO_AP' 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 && player) rejection=this.applyPlayerCommand(player,envelope)
if(rejection) this.state=before 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.'} 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) this.commandResults.set(requestKey,result)
return result return result
@@ -147,13 +159,13 @@ export class Game {
private startPlayerPhase(): void { private startPlayerPhase(): void {
const living=Object.values(this.state.players).filter(p=>p.lifeState==='alive') const living=Object.values(this.state.players).filter(p=>p.lifeState==='alive')
if(living.length===0){this.state.phase={kind:'dormant'};return} if(living.length===0){this.state.phase={kind:'dormant'};return}
const now=this.deps.clock.now(), duration=Math.min(living.length*25_000,120_000) const now=this.deps.clock.now()
this.state.phase={kind:'player',phaseId:this.deps.ids.next('phase'),startedAt:now,deadlineAt:now+duration,initialEligiblePlayerIds:living.map(p=>p.twitchUserId)} 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)){ for(const p of Object.values(this.state.players)){
p.guard=0;p.eligibleThisPhase=p.lifeState==='alive';p.ap=p.lifeState==='alive'?2:0 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) if(p.lifeState==='alive' && p.extensionBound && p.connectionState==='disconnected') this.autoGuard(p)
} }
this.log('player-phase-started',null,null,`Player Phase begins: ${duration/1000} seconds.`) this.log('player-phase-started',null,null,'Player Phase begins: 5 seconds.')
} }
private finishPlayerPhaseIfReady(): void { private finishPlayerPhaseIfReady(): void {
if(this.state.phase.kind!=='player') return if(this.state.phase.kind!=='player') return
@@ -201,7 +213,7 @@ export class Game {
private advanceFloor(): void { private advanceFloor(): void {
this.state.phase={kind:'transition',reason:'floor-advance'};this.state.floorNumber++ 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) 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 { private resetRun(): void {
this.state.phase={kind:'transition',reason:'admin-reset'};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
+2 -1
View File
@@ -8,6 +8,7 @@ export interface FloorState {
export interface PlayerState { export interface PlayerState {
twitchUserId: string; displayName: string; followerVerified: boolean twitchUserId: string; displayName: string; followerVerified: boolean
characterCreated: boolean; extensionBound: boolean characterCreated: boolean; extensionBound: boolean
partySlot: 1 | 2 | 3 | 4; color: 'blue' | 'green' | 'red' | 'yellow'
connectionState: 'connected' | 'disconnected'; position: TilePosition connectionState: 'connected' | 'disconnected'; position: TilePosition
hp: number; lifeState: 'alive' | 'dead'; ap: number; guard: number hp: number; lifeState: 'alive' | 'dead'; ap: number; guard: number
healAvailable: boolean; participatingFloor: number; diedOnFloor: number | null healAvailable: boolean; participatingFloor: number; diedOnFloor: number | null
@@ -19,7 +20,7 @@ export interface GoblinState {
} }
export type PhaseState = { kind: 'dormant' } | { export type PhaseState = { kind: 'dormant' } | {
kind: 'player'; phaseId: string; startedAt: number; deadlineAt: number kind: 'player'; phaseId: string; startedAt: number; deadlineAt: number
initialEligiblePlayerIds: string[] initialEligiblePlayerIds: string[]; timerResetPlayerIds: string[]
} | { kind: 'enemy'; phaseId: string } | { } | { kind: 'enemy'; phaseId: string } | {
kind: 'transition'; reason: 'floor-advance' | 'admin-reset' kind: 'transition'; reason: 'floor-advance' | 'admin-reset'
} }
+2 -2
View File
@@ -1,9 +1,9 @@
import type { GameSnapshot, TilePosition } from '../../domain/src/index.js' 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[]} { 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'}] 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'}) 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} return {width:snapshot.floor.width,height:snapshot.floor.height,tiles:snapshot.floor.tiles,entities}
} }
+5 -1
View File
@@ -13,10 +13,13 @@ function envelope(game:Game,command:any,requestId='r1') {const s=game.snapshot()
describe('authoritative game core',()=>{ 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('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-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-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('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('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('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('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')})
@@ -26,4 +29,5 @@ describe('authoritative game core',()=>{
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('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('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('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'})})
}) })
+2 -1
View File
@@ -9,11 +9,12 @@ async function post(path:string,value:unknown,token?:string){return fetch(`${bas
describe('backend boundary',()=>{ describe('backend boundary',()=>{
it('AT-001 exposes a secret-free readiness response',async()=>{const data=await fetch(`${base}/health`).then(r=>r.json());expect(data).toMatchObject({status:'ok',ready:true,twitchMode:'synthetic'});expect(JSON.stringify(data)).not.toMatch(/secret|token/i)}) it('AT-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('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 a transparent responsive Twitch overlay controller',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="spawnExtension"');expect(html).toContain('id="shareIdentity"');expect(script).toContain("location.pathname==='/extension'");expect(script).toContain("['localhost','127.0.0.1','[::1]']");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')}) 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('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-005 establishes a session without creating a character',async()=>{const r=await post('/api/extension/session',{token:'dev:nobody:Nobody'});expect(r.status).toBe(200);const data=await r.json();expect(data.state.viewer).toBeNull()})
it('AT-011 binds the same stable chat and Extension identity',async()=>{const id=`viewer-${Date.now()}`;expect((await post('/api/dev/spawn',{command:'!spawn',externalEventId:`e-${id}`,twitchUserId:id,displayName:'Viewer',followerVerified:true})).status).toBe(200);const auth=await (await post('/api/extension/session',{token:`dev:${id}:Viewer`})).json();expect(auth.twitchUserId).toBe(id);expect(auth.state.viewer.extensionBound).toBe(true);const s=auth.state,phase=s.phase;expect(phase.kind).toBe('player');const command=await post('/api/commands',{requestId:`r-${id}`,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}},auth.token);expect(command.status).toBe(200)}) it('AT-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('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-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')}) 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')})
}) })