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
approved Extension configuration, and real-viewer evidence.
Each floor has four first-come, first-served party slots with fixed Blue, Green,
Red, and Yellow identities. Reaching the exit clears the party and opens the
next floor for a complete reparty. Player phases use a rolling five-second
deadline that each living player may reset once with their first accepted
action. The same Twitch Extension provides compact desktop controls and a
touch-oriented mobile layout backed by the same authoritative game instance.
## Quick start
Requirements: Node.js 22 or newer and npm.
@@ -166,6 +166,9 @@ events without importing Twitch, RPGJS, HTTP, WebSocket, or browser code.
- [ ] Implement the broadcast-mode three-section layout.
- [ ] Render players, Goblin, walls, paths, exit, shared status, and action log
with temporary but distinguishable visuals.
- [ ] Render deterministic Blue, Green, Red, and Yellow party-slot markers and names.
- [ ] Provide desktop and mobile Extension layouts with mobile controls at least
44 pixels and essential viewer-specific status kept visible.
- [ ] Render the exact dormant banner text.
**Checkpoint P3:** A local read-only stream view can cycle through many valid,
@@ -177,7 +180,8 @@ visibly varied floors without a Twitch connection.
- [ ] Implement dormant, Player Phase, Enemy Phase, and transition states.
- [ ] Start Player Phase with 2 AP and zero Guard for each living participant.
- [ ] Implement 25 seconds per living player with a 120-second cap.
- [ ] Implement a rolling five-second deadline with one reset per living
player's first accepted command in each phase.
- [ ] End Player Phase early when its initial eligible set is finished or dead.
- [ ] Decide deadline acceptance using gateway arrival before the monotonic
deadline; reject arrivals at or after the deadline.
@@ -229,8 +233,9 @@ target death, return, death, and escape with the Goblin alive.
- [ ] Advance immediately when a living player enters the exit.
- [ ] Reject queued commands for the previous floor.
- [ ] Generate the next floor and Goblin.
- [ ] Revive, heal, reposition, and refresh self-heal for all participants on
floor advancement.
- [ ] Clear all previous party records and enter dormancy on floor advancement.
- [ ] Reassign all four slots and colors first come, first served with no
previous-player reservation.
- [ ] Return the current floor to dormancy immediately after the last participant dies.
- [ ] Distinguish initial dormancy from defeated-party dormancy.
- [ ] Preserve character identity and state across simulated client reconnects.
@@ -415,6 +420,8 @@ successful connection logs.
- [ ] As a verified follower with no character or same-floor death, issue
`!spawn` and confirm one character appears in the current spawn room.
- [ ] Fill all four party slots, confirm the fifth eligible viewer receives a
clear `PARTY_FULL` result, and confirm a dead member still occupies a slot.
**Level:** Live integration
**Pass evidence:** Chat capture, follower result, spawn event, and stream capture.
@@ -492,12 +499,14 @@ successful connection logs.
**Level:** Automated domain/integration
**Pass evidence:** State-transition assertions and ordered event log.
### AT-016 — Scaled timer and cap
### AT-016 — Rolling five-second timer
**PRD criterion:** 16
- [ ] Verify durations of 25, 50, 75, 100, and 120 seconds for one through five
living players and 120 seconds for more than five.
- [ ] Confirm phase start sets a five-second deadline.
- [ ] Confirm Player A's first accepted command resets it to five seconds,
Player A's second does not, and Player B's first accepted command does.
- [ ] Confirm rejected and duplicate commands do not reset the deadline.
**Level:** Automated fake-clock domain test plus one live timing check
**Pass evidence:** Parameterized test output and live timer recording.
@@ -564,17 +573,21 @@ successful connection logs.
**PRD criterion:** 22
- [ ] Aggro but do not kill the Goblin, move a living player onto the exit, and
confirm immediate group advancement and a new Goblin on the next floor.
confirm immediate floor advancement, a new Goblin, an empty party, and
Dormant state on the next floor.
**Level:** Automated E2E plus live scenario
**Pass evidence:** Before/after snapshots and stream recording.
### AT-023 — Dead players revive on advancement
### AT-023 — Full next-floor reparty
**PRD criterion:** 23
- [ ] Kill one player, advance with another, and confirm the dead player returns
at spawn with full HP and refreshed self-heal on the next floor.
- [ ] Fill four slots, kill one player, and advance with another.
- [ ] Confirm every previous character is removed, all four slots are open, and
no previous player has a reservation.
- [ ] Spawn a new viewer before a previous party member and confirm they receive
Slot 1/Blue while the previous member receives the next available slot/color.
**Level:** Automated E2E plus live scenario
**Pass evidence:** Before/after player and floor state.
+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,
- resurrect through a configured Channel Points reward,
- 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.
The MVP succeeds if these systems work together reliably enough to test the core concept with real Twitch viewers.
@@ -74,13 +74,18 @@ When a valid `!spawn` command is received:
2. The backend verifies that the viewer follows the channel.
3. The backend verifies that the viewer does not already have an active character.
4. The backend verifies that the viewer has not died on the current floor.
5. A character is created in the current floor's spawn room.
6. The game records the Twitch user ID as the authoritative identity for that character.
7. The player's Twitch Extension identity must resolve to the same Twitch user ID before game controls are enabled.
8. The player's Twitch Extension game controls become active.
5. The backend verifies that one of four party slots remains available.
6. A character is created in the current floor's spawn room and assigned the lowest available party slot.
7. The game records the Twitch user ID as the authoritative identity for that character.
8. The player's Twitch Extension identity must resolve to the same Twitch user ID before game controls are enabled.
9. The player's Twitch Extension game controls become active.
Only one active character may exist per Twitch user.
Each floor has exactly four first-come, first-served party slots. Dead players
continue occupying their slot until the floor ends. A fifth eligible viewer
remains a spectator and receives a clear party-full result.
A follower who has died on the current floor cannot use `!spawn` to bypass death. That player must wait for either a Channel Points resurrection or advancement to the next floor.
### 3.4 Twitch Identity Binding
@@ -118,6 +123,12 @@ The shared Twitch stream is the primary game display.
Players do not require a separate conventional game client.
The same Extension adapts to desktop and mobile without creating another game
instance. Desktop retains the information-rich controller. Mobile reflows the
controller around touch targets of approximately 44 pixels or larger and keeps
identity/color, HP, AP, and heal availability visible without shrinking the
desktop layout proportionally.
### 4.1 Three-Section MVP Layout
The MVP game view must establish the intended structure of the final Twungeon experience even if the graphics and styling remain temporary.
@@ -252,21 +263,11 @@ Players do not need to act in a fixed sequential order. Valid player commands ma
### 7.1 Player Phase Timer
The Player Phase timer scales with the number of living players:
**25 seconds per living player**
The timer is capped at:
**120 seconds maximum**
Examples:
- 1 living player: 25 seconds
- 2 living players: 50 seconds
- 3 living players: 75 seconds
- 4 living players: 100 seconds
- 5 or more living players: 120 seconds
The Player Phase uses a rolling **5-second** timer. It begins at five seconds.
Each living player's first accepted command during that phase resets the
deadline to five seconds after that command arrived. Further commands from the
same player do not reset it again. A different player's first accepted command
may reset it once, so one player cannot extend the phase indefinitely.
The Player Phase ends when either:
@@ -464,12 +465,16 @@ When any living player enters the exit:
1. The current floor ends immediately.
2. The floor counter increases.
3. A new two-room floor is generated.
4. A new Goblin Guard is created.
5. All participating players are placed in the new spawn room.
6. All players are restored to full HP.
7. All dead players are resurrected.
8. Every player's self-heal is refreshed.
3. Every previous party slot and character is cleared, alive or dead.
4. A new two-room floor is generated.
5. A new Goblin Guard is created.
6. The new floor enters Dormant state.
7. Eligible followers claim the four new party slots first come, first served.
Previous party members receive no reservation and must spawn again. Twitch
identity and Extension sessions may persist for authentication, but character
HP, AP, Guard, heal use, death state, position, slot, and color do not carry
into the new party.
The dungeon may continue generating floors indefinitely for the MVP.
@@ -488,10 +493,9 @@ While dead:
There is no timed automatic resurrection.
A dead player returns to play only through:
1. immediate Channel Points resurrection, or
2. another living player reaching the next floor.
A dead player returns during the current floor only through immediate Channel
Points resurrection. When another player reaches the exit, the entire party is
cleared; that viewer may compete to spawn a fresh character on the next floor.
---
@@ -523,7 +527,9 @@ On defeat:
2. All defeated characters remain dead and retain their current-floor death markers.
3. No phase timer runs and the Goblin does not act.
4. A configured resurrection can revive a dead viewer and begin a fresh Player Phase.
5. 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,
- follower eligibility,
- one character per Twitch user,
- current-floor party slot and its fixed Blue, Green, Red, or Yellow color,
- current HP,
- alive/dead state,
- current AP,
@@ -664,14 +671,14 @@ The MVP is successful if it demonstrates that:
13. Multiple viewers can control separate characters in the same dungeon.
14. Twitch Extension commands reliably control movement and actions.
15. The Player Phase / Enemy Phase cycle functions correctly.
16. The Player Phase timer scales at 25 seconds per living player and caps at 120 seconds.
16. The Player Phase uses a rolling 5-second timer that each living player can reset only once with their first accepted command.
17. The Player Phase ends early when all living players finish.
18. AP, AutoGuard, attacks, healing, HP, and death interact correctly.
19. The action log accurately reports significant game actions and state changes.
20. The Goblin can guard, aggro, pursue, attack, return, and remain behind on floor transition.
21. Two-room floors can be generated repeatedly with varying room sizes, positions, and navigable connections.
22. A surviving player can reach the exit without necessarily killing the Goblin and advance the entire group.
23. Dead players return when the floor advances.
22. A surviving player can reach the exit without necessarily killing the Goblin, clear the current party, and open the next floor for a new party.
23. Each floor admits at most four party members, assigns deterministic slot colors, and repartees first come, first served without reservations.
24. A player who died on the current floor cannot bypass death by using `!spawn`.
25. The configured Channel Points reward can resurrect the correct dead player during the current floor.
26. A total-party 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
characterCreated: boolean
extensionBound: boolean
partySlot: 1 | 2 | 3 | 4
color: 'blue' | 'green' | 'red' | 'yellow'
connectionState: 'connected' | 'disconnected'
position: TilePosition
hp: 0 | 1 | 2 | 3
@@ -274,6 +276,7 @@ type PhaseState =
startedAt: number
deadlineAt: number
initialEligiblePlayerIds: string[]
timerResetPlayerIds: string[]
}
| { kind: 'enemy'; phaseId: string }
| { 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
or the set of players required to finish the phase.
The authoritative player record is also the current-floor party roster. At
most four records may exist. Spawn assigns the lowest open slot and its fixed
color: Slot 1 Blue, Slot 2 Green, Slot 3 Red, and Slot 4 Yellow. Dead records
remain in the roster and occupy their slots. A fifth spawn is rejected as
`PARTY_FULL` without creating a character.
### 7.3 Attack range and targeting
- Player and Goblin attacks target an orthogonally adjacent tile.
@@ -451,14 +460,12 @@ For every living participant present when the phase starts:
3. set `eligibleThisPhase` to true; and
4. include the player in `initialEligiblePlayerIds`.
The duration is:
```text
min(living-player-count * 25 seconds, 120 seconds)
```
The count includes disconnected living characters, matching the PRD's AFK
AutoGuard behavior.
The initial deadline is five seconds after phase start. The phase begins with
an empty `timerResetPlayerIds` list. After each player's first accepted command,
the backend adds that Twitch user ID and moves `deadlineAt` to five seconds
after the gateway arrival time. Later commands from that player do not move the
deadline. Rejected, duplicate, disconnected, and AutoGuard events do not reset
the timer.
Players who spawn or resurrect after the phase starts receive 0 AP, are not
added to the phase completion set, and may act beginning with the next Player
@@ -506,7 +513,8 @@ An accepted spawn requires:
- a stable Twitch chat user ID;
- verified follower status;
- no existing character for that ID; and
- no death marker for that ID on the current floor.
- no death marker for that ID on the current floor; and
- one of four party slots to be open.
The backend creates one character at full HP with heal available. If the game is
already active, the player has 0 AP until the next Player Phase.
@@ -514,6 +522,9 @@ already active, the player has 0 AP until the next Player Phase.
Repeated `!spawn` attempts for an existing character return an informative chat
response or log result and never create another character.
The roster is first come, first served for one floor. Twitch identity sessions
remain outside the domain roster so clearing a party does not log viewers out.
### 10.2 Heal
A valid self-heal:
@@ -624,14 +635,15 @@ When a living player enters the exit:
1. enter transition state and reject remaining old-floor commands;
2. increment the floor counter;
3. generate and validate a new floor and Goblin;
4. place every participating player on the new spawn tile;
5. restore every player to 3 HP and alive;
6. clear death markers, AP, and Guard;
7. refresh every self-heal;
8. log the new floor; and
9. begin a fresh Player Phase.
4. clear every current party record and release all four slots;
5. discard all previous character HP, AP, Guard, heal, death, position, slot,
and color state;
6. log the new floor; and
7. enter Dormant state for a new first-come, first-served party.
The previous Goblin and map are discarded.
The previous Goblin and map are discarded. Existing authenticated Twitch
sessions and sockets remain valid, but previous players have no slot
reservation or priority.
### 12.3 Total-party defeat
@@ -645,9 +657,10 @@ alive, the backend immediately:
5. logs the party defeat.
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
floor and resume play. Initial dormancy has no participants, while defeated-party
dormancy retains dead participant records.
Phase from dormancy. If the defeated party claimed fewer than four slots, a
newly eligible viewer may claim an open slot and resume play. A defeated full
party requires resurrection. Initial dormancy has no participants, while
defeated-party dormancy retains dead participant records.
## 13. Randomness
@@ -747,14 +760,22 @@ The default wide layout reserves:
- the largest upper-right area for the map; and
- a full-width bottom area for the action log.
Temporary shapes, colors, labels, and sprites are acceptable. Every required
entity, wall, traversable path, and exit must remain distinguishable.
Temporary shapes, labels, and sprites are acceptable. Every required entity,
wall, traversable path, and exit must remain distinguishable. Player markers,
visible name labels, personalized status, and adapter render data use the
current party-slot color consistently.
### 15.2 Player status
The personalized Extension status includes at least identity/binding status,
HP, AP, Guard, heal availability, alive/dead status, current phase, and a clear
disabled reason when controls are unavailable.
party slot/color, HP, AP, Guard, heal availability, alive/dead status, current
phase, and a clear disabled reason when controls are unavailable.
Desktop preserves the compact information-rich overlay. Twitch context
platform data adds an `extension-mobile` presentation flag when available, and
a narrow-viewport media query provides the fallback. Mobile keeps identity and
color, HP, AP, and heal availability visible, reflows the controller instead of
scaling the desktop layout, and uses touch targets of at least 44 pixels.
### 15.3 Action log
@@ -872,7 +893,7 @@ Deterministic tests must cover:
- all accepted and rejected command conditions;
- AP spending and early phase completion;
- timer duration and 120-second cap;
- rolling five-second deadline and one reset per living player;
- deadline boundary behavior;
- AutoGuard conversion, hit blocking, misses, and reset;
- player and Goblin hit probabilities through injected rolls;
@@ -910,7 +931,7 @@ At minimum, the live test plan must demonstrate:
6. movement, two attacks, heal, pass, timer expiry, and AutoGuard;
7. Goblin aggro, pursuit, attack, target death, and return;
8. Channel Points resurrection of the correct dead viewer;
9. floor escape with the Goblin alive and dead-player revival;
9. floor escape with the Goblin alive, party clearing, and next-floor reparty;
10. total-party defeat, dormant transition, and resurrection recovery;
11. refresh and temporary disconnect recovery; and
12. repetition across multiple generated floors.
+2 -2
View File
@@ -60,7 +60,7 @@ export function createAdminServer(deps:AdminServerDependencies){
async function render(deps:AdminServerDependencies,notice:string|null):Promise<string>{
const state=deps.game.snapshot(),status=await deps.status(),phase=state.phase.kind==='player'?`${state.phase.kind} · ${Math.max(0,Math.ceil((state.phase.deadlineAt-state.serverTime)/1000))}s remaining`:state.phase.kind
const token=escapeHtml(deps.csrfToken)
const playerRows=state.players.map(player=>`<tr><td>${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('')
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'){
const userId=sessionUser(bearer(req));if(!userId)return json(res,401,{error:'UNAUTHENTICATED'})
const message=await twitch.createSpawn(userId,`extension-spawn-${crypto.randomUUID()}`);if(!message)return json(res,503,{error:'TWITCH_UNAVAILABLE'})
const result=game.spawn(message);if(result.accepted)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'){
const token=bearer(req),userId=sessionUser(token),parsed=CommandEnvelopeSchema.safeParse(await body(req))
if(!parsed.success)return json(res,400,{error:'INVALID_COMMAND',issues:parsed.error.issues.map(i=>({path:i.path,message:i.message}))})
const result=game.command(userId,Boolean(userId),parsed.data);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)
}
if(req.method==='POST'&&url.pathname==='/api/dev/spawn'&&!config.twitchEnabled){const msg=await twitch.normalizeSpawn(await body(req));if(!msg)return json(res,400,{error:'INVALID_SPAWN'});const result=game.spawn(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
if(req.method==='POST'&&url.pathname==='/api/dev/spawn'&&!config.twitchEnabled){const msg=await twitch.normalizeSpawn(await body(req));if(!msg)return json(res,400,{error:'INVALID_SPAWN'});const result=game.spawn(msg);if(result.accepted){game.bindExtension(msg.twitchUserId,connectedClientCount(msg.twitchUserId)>0);broadcast()}return json(res,result.accepted?200:409,result)}
if(req.method==='POST'&&url.pathname==='/api/dev/redemption'&&!config.twitchEnabled){const msg=await twitch.normalizeRedemption(await body(req));if(!msg)return json(res,400,{error:'INVALID_REDEMPTION'});const result=game.resurrect(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
if(req.method==='GET'&&url.pathname==='/'){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)}
@@ -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))})
wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null),changed=authenticateClient(ws,userId);if(changed)broadcast();else if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>{const userId=clients.get(ws)??null;clients.delete(ws);if(userId && connectedClientCount(userId)===0 && game.disconnect(userId))broadcast()})})
setInterval(()=>{const before=game.snapshot().nextEventSequence;game.tick();if(game.snapshot().nextEventSequence!==before)broadcast()},250).unref()
if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{game.spawn(message);broadcast()},onRedemption:message=>{game.resurrect(message);broadcast()},onConnection:ready=>console.log(JSON.stringify({level:'info',component:'twitch-adapter',ready}))}).catch(error=>console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Connection failed'})))
if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{const result=game.spawn(message);if(result.accepted)game.bindExtension(message.twitchUserId,connectedClientCount(message.twitchUserId)>0);broadcast()},onRedemption:message=>{game.resurrect(message);broadcast()},onConnection:ready=>console.log(JSON.stringify({level:'info',component:'twitch-adapter',ready}))}).catch(error=>console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Connection failed'})))
if(process.env.NODE_ENV!=='test'){
server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`))
adminServer?.listen(config.adminPort,config.adminHost,()=>console.log(`Twungeon admin listening on http://${config.adminHost}:${config.adminPort}`))
+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 $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s)
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
function disabledReason(){if(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'
const me=state.players.find(p=>p.twitchUserId===userId),seconds=state.phase.kind==='player'?Math.max(0,Math.ceil((state.phase.deadlineAt-Date.now())/1000)):'—'
$('#status').innerHTML=[['Players',state.players.filter(p=>p.lifeState==='alive').length],['Timer',seconds],['HP',me?`${me.hp}/3`:'—'],['AP',me?.ap??'—'],['Guard',me?.guard??'—'],['Heal',me?(me.healAvailable?'Ready':'Used'):'—']].map(([k,v])=>`<div class="stat">${k}<b>${v}</b></div>`).join('')
$('#status').innerHTML=[['Party',`${state.players.length}/4`],['Living',state.players.filter(p=>p.lifeState==='alive').length],['Timer',seconds],['HP',me?`${me.hp}/3`:'—'],['AP',me?.ap??'—'],['Guard',me?.guard??'—'],['Heal',me?(me.healAvailable?'Ready':'Used'):'—']].map(([k,v])=>`<div class="stat">${k}<b>${v}</b></div>`).join('')
const viewerStatus=$('#viewerStatus');viewerStatus.hidden=!extensionMode||!me;viewerStatus.innerHTML=me?`<div class="viewer-identity"><span class="color-dot" style="--player-color:${playerColors[me.color]}"></span><b>${escapeHtml(me.displayName)}</b><span>Slot ${me.partySlot} · ${colorNames[me.color]}</span></div><div><span>HP</span><b>${me.hp}/3</b></div><div><span>AP</span><b>${me.ap}</b></div><div><span>Heal</span><b>${me.healAvailable?'Ready':'Used'}</b></div>`:''
$('#partyLegend').innerHTML=state.players.map(player=>`<span class="party-name" style="--player-color:${playerColors[player.color]}">● ${escapeHtml(player.displayName)}</span>`).join(' ')
const map=$('#map');map.style.gridTemplateColumns=`repeat(${state.floor.width},auto)`;map.innerHTML=''
for(let y=0;y<state.floor.height;y++)for(let x=0;x<state.floor.width;x++){const el=document.createElement('div'),tile=state.floor.tiles[y][x];el.className=`tile ${tile}`;if(tile==='exit')el.textContent='▣';const p=state.players.find(p=>p.lifeState==='alive'&&p.position.x===x&&p.position.y===y);const gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob||p){const e=document.createElement('span');e.className=`entity ${gob?'goblin':'player'}`;e.textContent=gob?'◆':'●';e.title=gob?'Goblin':p.displayName;el.append(e)}map.append(el)}
$('#log').innerHTML=state.actionLog.slice(-40).map(e=>`<li><small>#${e.sequence}</small> ${escapeHtml(e.message)}</li>`).join('');$('#log').scrollTop=$('#log').scrollHeight
const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode||overlayDevMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character'
for(let y=0;y<state.floor.height;y++)for(let x=0;x<state.floor.width;x++){const el=document.createElement('div'),tile=state.floor.tiles[y][x];el.className=`tile ${tile}`;if(tile==='exit')el.textContent='▣';const players=state.players.filter(p=>p.lifeState==='alive'&&p.position.x===x&&p.position.y===y),gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob){const entity=document.createElement('span');entity.className='entity goblin';entity.textContent='◆';entity.title='Goblin';el.append(entity)}for(const player of players){const entity=document.createElement('span');entity.className=`entity player${players.length>1?` stacked slot-${player.partySlot}`:''}`;entity.textContent='●';entity.title=player.displayName;entity.style.setProperty('--player-color',playerColors[player.color]);const label=document.createElement('span');label.className='entity-name';label.textContent=player.displayName;entity.append(label);el.append(entity)}map.append(el)}
$('#log').innerHTML=state.actionLog.slice(-40).map(e=>{const actor=state.players.find(player=>player.twitchUserId===e.actorId),style=actor?` style="--player-color:${playerColors[actor.color]}"`:'';return`<li${style}><small>#${e.sequence}</small> ${escapeHtml(e.message)}</li>`}).join('');$('#log').scrollTop=$('#log').scrollHeight
const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode||overlayDevMode;spawnButton.disabled=!session||Boolean(me)||state.players.length>=4;spawnButton.textContent=me?'Character spawned':state.players.length>=4?'Party full':'Spawn character'
}
async function api(path,options={}){const res=await fetch(path,{...options,headers:{'content-type':'application/json',...(session?{authorization:`Bearer ${session}`}:{})}});const data=await res.json();if(!res.ok)throw new Error(data.message||data.error||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()}
@@ -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()}}
connect()
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>
<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>
<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>
</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>
</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.exit { background: #68491b; color: #ffd77e; }
.entity { position: absolute; inset: 12%; border-radius: 50%; display: grid; place-items: center; font-weight: bold; text-shadow: 0 1px 2px #000; }
.entity.player { background: #4f8cc9; border: 2px solid #b9deff; }
.entity.player { background: var(--player-color, #3f8cff); border: 2px solid #fff; color: #111; }
.entity.player.stacked { inset: auto; width: 40%; height: 40%; }
.entity.player.stacked.slot-1 { left: 6%; top: 6%; }
.entity.player.stacked.slot-2 { right: 6%; top: 6%; }
.entity.player.stacked.slot-3 { left: 6%; bottom: 6%; }
.entity.player.stacked.slot-4 { right: 6%; bottom: 6%; }
.entity-name { position: absolute; top: 100%; left: 50%; z-index: 2; transform: translate(-50%, 2px); padding: 1px 3px; border-radius: 2px; background: #000c; color: var(--player-color, #fff); font-size: 9px; line-height: 1.1; white-space: nowrap; text-shadow: 0 1px 2px #000; }
.entity.goblin { background: var(--red); border: 2px solid #ffb2a6; border-radius: 20%; }
.legend { display: flex; gap: 18px; justify-content: center; color: var(--muted); font-size: 12px; }
.party-name { color: var(--player-color); white-space: nowrap; }
#status { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.stat { border: 1px solid var(--line); padding: 9px; }
.stat b { display: block; color: var(--gold); font-size: 18px; }
@@ -60,7 +67,7 @@ details { margin-top: auto; color: var(--muted); }
details label { display: block; margin: 8px 0; }
input { display: block; width: 100%; background: #0d0f10; border: 1px solid var(--line); color: var(--ink); padding: 6px; }
#log { margin: 8px 0 0; padding: 0; list-style: none; overflow: auto; display: flex; flex-direction: column; gap: 3px; }
#log li { padding: 4px 8px; border-left: 2px solid var(--line); color: var(--muted); }
#log li { padding: 4px 8px; border-left: 2px solid var(--player-color, var(--line)); color: var(--muted); }
#log li:last-child { color: var(--ink); border-color: var(--gold); }
#connection { font-size: 12px; color: var(--muted); }
@@ -87,6 +94,13 @@ html.extension-mode .status {
}
html.extension-mode #controller { width: min(100%, 18rem); pointer-events: none; }
html.extension-mode #controller h2 { font-size: clamp(11px, 5cqw, 18px); margin-bottom: clamp(4px, 2cqh, 12px); text-shadow: 0 1px 3px #000; }
html.extension-mode #viewerStatus { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(3px, 1.4cqw, 7px); margin-bottom: clamp(5px, 2cqh, 10px); color: var(--ink); text-shadow: 0 1px 3px #000; }
html.extension-mode #viewerStatus[hidden] { display: none; }
html.extension-mode #viewerStatus > div { padding: clamp(3px, 1cqw, 6px); background: #080a0bd9; border: 1px solid #ffffff2b; font-size: clamp(8px, 3.3cqw, 12px); }
html.extension-mode #viewerStatus > div > span, html.extension-mode #viewerStatus > div > b { display: block; }
html.extension-mode #viewerStatus .viewer-identity { grid-column: 1/-1; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 6px; }
html.extension-mode #viewerStatus .viewer-identity > span, html.extension-mode #viewerStatus .viewer-identity > b { display: initial; }
.color-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--player-color); border: 1px solid #fff; }
html.extension-mode #controller button { pointer-events: auto; touch-action: manipulation; }
html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(28px, 1fr)); gap: clamp(3px, 1.8cqw, 7px); margin: 0 0 clamp(6px, 2.5cqh, 14px); }
html.extension-mode .dpad button { aspect-ratio: 1; padding: 0; font-size: clamp(13px, 6cqw, 24px); }
@@ -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%; }
}
@media (max-width: 600px) {
html.extension-mode .status { left: 3%; top: 3%; width: 94%; height: 94%; padding: 12px; place-items: center; overflow: auto; }
html.extension-mode #controller { width: min(100%, 24rem); }
html.extension-mode #controller h2 { font-size: 17px; }
html.extension-mode #viewerStatus { gap: 7px; margin-bottom: 10px; }
html.extension-mode #viewerStatus > div { min-height: 44px; padding: 7px 9px; font-size: 13px; }
html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 8px; margin-bottom: 10px; }
html.extension-mode .dpad button { min-height: 56px; font-size: 24px; }
html.extension-mode .actions { gap: 8px; }
html.extension-mode .actions button, html.extension-mode #spawnExtension, html.extension-mode #shareIdentity { min-height: 52px; padding: 10px 6px; font-size: 15px; }
html.extension-mode #disabled { font-size: 13px; }
}
html.extension-mode.extension-mobile .status { left: 3%; top: 3%; width: 94%; height: 94%; padding: 12px; place-items: center; overflow: auto; }
html.extension-mode.extension-mobile #controller { width: min(100%, 24rem); }
html.extension-mode.extension-mobile .dpad { grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 8px; }
html.extension-mode.extension-mobile .dpad button { min-height: 56px; font-size: 24px; }
html.extension-mode.extension-mobile .actions button, html.extension-mode.extension-mobile #spawnExtension, html.extension-mode.extension-mobile #shareIdentity { min-height: 52px; font-size: 15px; }
@media (max-aspect-ratio: 4/3) {
html.extension-mode { --controls-width: 31%; --controls-height: 62%; }
}
+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`.
2. Confirm the exact dormant banner appears and no timer runs.
3. Use **Local viewer login** to spawn and bind a viewer.
4. Confirm 3 HP, 2 AP, no Guard, a ready heal, and a 25-second phase.
5. Exercise movement, invalid walls, Attack, Heal, and Pass. Invalid actions
4. Confirm 3 HP, 2 AP, no Guard, a ready heal, and a five-second phase.
5. Confirm the viewer's first accepted action resets the timer to five seconds
and their second accepted action does not reset it again.
6. Exercise movement, invalid walls, Attack, Heal, and Pass. Invalid actions
must not consume AP.
6. Open a second browser/private window with a different ID. Confirm both
7. Open a second browser/private window with a different ID. Confirm both
characters share the dungeon but each controller spends only its own AP.
7. Refresh during a phase. Confirm a snapshot restores the map/log and no
8. Refresh during a phase. Confirm a snapshot restores the map/log and no
command replays.
8. Run `npm run build && npm start` and repeat the smoke test against the built
9. Run `npm run build && npm start` and repeat the smoke test against the built
server.
The local-only endpoints `/api/dev/spawn` and `/api/dev/redemption` exist only while
@@ -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
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;
- 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;
- missing character, dead character, non-player phase, and zero-AP states still
disable the controls; and
- loading `/` still presents the full local game and controller.
- loading `/` still presents the full local game and controller;
- mobile identity/color, HP, AP, and heal status remains visible; and
- mobile directional buttons are at least 56px and action buttons at least 52px.
Remove the query string for the production-shaped local view. Twitch-hosted
testing still must cover identity sharing, player ownership, theater mode,
@@ -69,15 +74,16 @@ fullscreen, embeds, ads/pauses, and Twitch player-control safe zones.
## Live-channel campaign
After completing `docs/twitch-setup.md`, use two follower accounts and one
After completing `docs/twitch-setup.md`, use five follower accounts and one
non-follower account. Capture redacted evidence for:
- eligible, ineligible, and duplicate `!spawn` attempts;
- matching and mismatched Extension identities;
- independent commands from two viewers;
- four deterministic party slots/colors and a rejected fifth viewer;
- phase expiry and AutoGuard while one viewer is disconnected;
- death and a configured Channel Points reward redemption from the same user;
- escape with the Goblin alive, floor revival, and 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
- 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
player. The video player and stream supply the game frame; `/extension`
should show only the upper-left controls on a transparent canvas.
The same viewer path handles mobile: Twitch context selects the mobile
layout when available and responsive CSS is the fallback. Do not configure
a second Extension, stream, or backend instance for mobile viewers.
7. A JWT without `user_id`, with the wrong
`channel_id`, an expired signature, or an `external` role is rejected.
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
resurrectionRewardId: string
}
export interface SpawnResult { accepted: boolean; reason: 'NOT_FOLLOWER'|'DUPLICATE'|'DIED_THIS_FLOOR'|null; message: string }
export interface SpawnResult { accepted: boolean; reason: 'NOT_FOLLOWER'|'DUPLICATE'|'DIED_THIS_FLOOR'|'PARTY_FULL'|null; message: string }
export interface ResurrectionResult { accepted: boolean; reason: 'DUPLICATE'|'WRONG_REWARD'|'UNKNOWN_PLAYER'|'PLAYER_ALIVE'|null; message: string }
export interface GameSnapshot extends Omit<RunState, 'players'> { players: PlayerState[]; serverTime: number }
@@ -14,6 +14,8 @@ const same = (a: TilePosition, b: TilePosition) => a.x === b.x && a.y === b.y
const adjacent = (a: TilePosition, b: TilePosition) => Math.abs(a.x-b.x)+Math.abs(a.y-b.y) === 1
const phaseId = (state: RunState) => state.phase.kind === 'player' || state.phase.kind === 'enemy' ? state.phase.phaseId : null
const directions = [{x:0,y:-1},{x:-1,y:0},{x:1,y:0},{x:0,y:1}]
const partyColors = ['blue','green','red','yellow'] as const
const playerPhaseDuration = 5_000
export class Game {
private state: RunState
@@ -69,7 +71,10 @@ export class Game {
if(!message.followerVerified) return {accepted:false,reason:'NOT_FOLLOWER',message:'Follow the channel before using !spawn.'}
const existing=this.state.players[message.twitchUserId]
if(existing) return {accepted:false,reason:existing.diedOnFloor===this.state.floorNumber?'DIED_THIS_FLOOR':'DUPLICATE',message:existing.diedOnFloor===this.state.floorNumber?'You died on this floor; reach the next floor or resurrect to return.':'Your character is already in the Twungeon.'}
const p: PlayerState={twitchUserId:message.twitchUserId,displayName:safeName(message.displayName),followerVerified:true,characterCreated:true,extensionBound:false,connectionState:'disconnected',position:copy(this.state.floor.spawnTiles[0]!),hp:3,lifeState:'alive',ap:0,guard:0,healAvailable:true,participatingFloor:this.state.floorNumber,diedOnFloor:null,eligibleThisPhase:false}
const occupiedSlots=new Set(Object.values(this.state.players).map(player=>player.partySlot)),partySlot=partyColors.findIndex((_,index)=>!occupiedSlots.has((index+1) as PlayerState['partySlot']))+1
if(partySlot<1)return {accepted:false,reason:'PARTY_FULL',message:'The current party is full. You can try again on the next level.'}
const slot=partySlot as PlayerState['partySlot']
const p: PlayerState={twitchUserId:message.twitchUserId,displayName:safeName(message.displayName),followerVerified:true,characterCreated:true,extensionBound:false,partySlot:slot,color:partyColors[slot-1]!,connectionState:'disconnected',position:copy(this.state.floor.spawnTiles[0]!),hp:3,lifeState:'alive',ap:0,guard:0,healAvailable:true,participatingFloor:this.state.floorNumber,diedOnFloor:null,eligibleThisPhase:false}
this.state.players[p.twitchUserId]=p
this.log('player-spawned',p.twitchUserId,null,`${p.displayName} entered the Twungeon!`)
if(this.state.phase.kind==='dormant') this.startPlayerPhase()
@@ -106,9 +111,16 @@ export class Game {
if(!rejection && !player) rejection='CHARACTER_NOT_FOUND'
else if(!rejection && player?.lifeState!=='alive') rejection='CHARACTER_DEAD'
else if(!rejection && (player?.ap ?? 0)<1) rejection='NO_AP'
const commandPhase=this.state.phase.kind==='player'?this.state.phase:null
if(!rejection && player) rejection=this.applyPlayerCommand(player,envelope)
if(rejection) this.state=before
else this.finishPlayerPhaseIfReady()
else {
if(player && commandPhase && this.state.phase.kind==='player' && this.state.phase.phaseId===commandPhase.phaseId && !this.state.phase.timerResetPlayerIds.includes(player.twitchUserId)){
this.state.phase.timerResetPlayerIds.push(player.twitchUserId);this.state.phase.deadlineAt=arrivedAt+playerPhaseDuration
this.log('phase-timer-reset',player.twitchUserId,null,`${player.displayName}'s first action resets the Player Phase timer to 5 seconds.`)
}
this.finishPlayerPhaseIfReady()
}
const result:CommandResult={requestId:envelope.requestId,accepted:rejection===null,eventSequence:this.state.nextEventSequence-1,reason:rejection,message:rejection?reasonMessage(rejection):'Command accepted.'}
this.commandResults.set(requestKey,result)
return result
@@ -147,13 +159,13 @@ export class Game {
private startPlayerPhase(): void {
const living=Object.values(this.state.players).filter(p=>p.lifeState==='alive')
if(living.length===0){this.state.phase={kind:'dormant'};return}
const now=this.deps.clock.now(), duration=Math.min(living.length*25_000,120_000)
this.state.phase={kind:'player',phaseId:this.deps.ids.next('phase'),startedAt:now,deadlineAt:now+duration,initialEligiblePlayerIds:living.map(p=>p.twitchUserId)}
const now=this.deps.clock.now()
this.state.phase={kind:'player',phaseId:this.deps.ids.next('phase'),startedAt:now,deadlineAt:now+playerPhaseDuration,initialEligiblePlayerIds:living.map(p=>p.twitchUserId),timerResetPlayerIds:[]}
for(const p of Object.values(this.state.players)){
p.guard=0;p.eligibleThisPhase=p.lifeState==='alive';p.ap=p.lifeState==='alive'?2:0
if(p.lifeState==='alive' && p.extensionBound && p.connectionState==='disconnected') this.autoGuard(p)
}
this.log('player-phase-started',null,null,`Player Phase begins: ${duration/1000} seconds.`)
this.log('player-phase-started',null,null,'Player Phase begins: 5 seconds.')
}
private finishPlayerPhaseIfReady(): void {
if(this.state.phase.kind!=='player') return
@@ -201,7 +213,7 @@ export class Game {
private advanceFloor(): void {
this.state.phase={kind:'transition',reason:'floor-advance'};this.state.floorNumber++
const floor=this.deps.generateFloor(`${this.state.runId}-floor-${this.state.floorNumber}`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor)
this.restorePlayers();this.log('floor-advanced',null,null,`The party reaches Floor ${this.state.floorNumber}!`);this.startPlayerPhase()
this.state.players={};this.log('floor-advanced',null,null,`Floor ${this.state.floorNumber} awaits a new party.`);this.state.phase={kind:'dormant'}
}
private resetRun(): void {
this.state.phase={kind:'transition',reason:'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 {
twitchUserId: string; displayName: string; followerVerified: boolean
characterCreated: boolean; extensionBound: boolean
partySlot: 1 | 2 | 3 | 4; color: 'blue' | 'green' | 'red' | 'yellow'
connectionState: 'connected' | 'disconnected'; position: TilePosition
hp: number; lifeState: 'alive' | 'dead'; ap: number; guard: number
healAvailable: boolean; participatingFloor: number; diedOnFloor: number | null
@@ -19,7 +20,7 @@ export interface GoblinState {
}
export type PhaseState = { kind: 'dormant' } | {
kind: 'player'; phaseId: string; startedAt: number; deadlineAt: number
initialEligiblePlayerIds: string[]
initialEligiblePlayerIds: string[]; timerResetPlayerIds: string[]
} | { kind: 'enemy'; phaseId: string } | {
kind: 'transition'; reason: 'floor-advance' | 'admin-reset'
}
+2 -2
View File
@@ -1,9 +1,9 @@
import type { GameSnapshot, TilePosition } from '../../domain/src/index.js'
export interface RenderEntity { id:string; kind:'player'|'goblin'|'exit'; position:TilePosition; label:string }
export interface RenderEntity { id:string; kind:'player'|'goblin'|'exit'; position:TilePosition; label:string; color?:string }
export function toRenderModel(snapshot:GameSnapshot):{width:number;height:number;tiles:string[][];entities:RenderEntity[]} {
const entities:RenderEntity[]=[{id:'exit',kind:'exit',position:snapshot.floor.exitPosition,label:'Exit'}]
for(const p of snapshot.players)if(p.lifeState==='alive')entities.push({id:p.twitchUserId,kind:'player',position:p.position,label:p.displayName})
for(const p of snapshot.players)if(p.lifeState==='alive')entities.push({id:p.twitchUserId,kind:'player',position:p.position,label:p.displayName,color:p.color})
if(snapshot.goblin.mode!=='dead')entities.push({id:'goblin',kind:'goblin',position:snapshot.goblin.position,label:'Goblin'})
return {width:snapshot.floor.width,height:snapshot.floor.height,tiles:snapshot.floor.tiles,entities}
}
+5 -1
View File
@@ -13,10 +13,13 @@ function envelope(game:Game,command:any,requestId='r1') {const s=game.snapshot()
describe('authoritative game core',()=>{
it('AT-007 stays dormant until an eligible follower spawns',()=>{const {game,advance}=harness();advance(200_000);game.tick();expect(game.snapshot().phase.kind).toBe('dormant');expect(game.spawn({type:'spawn-requested',externalEventId:'x',twitchUserId:'u',displayName:'Nope',broadcasterId:'b',followerVerified:false}).reason).toBe('NOT_FOLLOWER');expect(spawn(game).accepted).toBe(true);expect(game.snapshot().phase.kind).toBe('player')})
it.each([[1,25_000],[2,50_000],[3,75_000],[4,100_000],[5,120_000],[6,120_000]])('AT-016 gives %i living players a %i ms capped phase',(count,duration)=>{const {game}=harness();for(let i=1;i<=count;i++)spawn(game,`u${i}`,`P${i}`,`s${i}`);(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();const phase=game.snapshot().phase;expect(phase.kind).toBe('player');if(phase.kind==='player')expect(phase.deadlineAt-phase.startedAt).toBe(duration)})
it.each([1,2,3,4])('AT-016 gives %i living players a five-second phase',(count)=>{const {game}=harness();for(let i=1;i<=count;i++)spawn(game,`u${i}`,`P${i}`,`s${i}`);(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();const phase=game.snapshot().phase;expect(phase.kind).toBe('player');if(phase.kind==='player'){expect(phase.deadlineAt-phase.startedAt).toBe(5_000);expect(phase.timerResetPlayerIds).toEqual([])}})
it('caps a level at four deterministic party slots and dead players retain their slot',()=>{const {game}=harness();for(let i=1;i<=4;i++)expect(spawn(game,`u${i}`,`P${i}`,`s${i}`).accepted).toBe(true);expect(game.snapshot().players.map(p=>[p.partySlot,p.color])).toEqual([[1,'blue'],[2,'green'],[3,'red'],[4,'yellow']]);(game as any).killPlayer((game as any).state.players.u2);expect(spawn(game,'u5','Five','s5')).toMatchObject({accepted:false,reason:'PARTY_FULL'});expect(game.snapshot().players).toHaveLength(4)})
it('AT-010 prevents duplicate characters',()=>{const {game}=harness();expect(spawn(game).accepted).toBe(true);expect(spawn(game,'u1','One','spawn-2').reason).toBe('DUPLICATE');expect(game.snapshot().players).toHaveLength(1)})
it('AT-014 spends AP once and deduplicates request IDs',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const env=envelope(game,{type:'pass'});expect(game.command('u1',true,env).accepted).toBe(true);expect(game.snapshot().players[0]?.ap).toBe(1);expect(game.command('u1',true,env).reason).toBe('DUPLICATE');expect(game.snapshot().players[0]?.ap).toBe(1)})
it('AT-017 ends early after the initial eligible set spends AP',()=>{const {game}=harness([1,1]);spawn(game);game.bindExtension('u1');game.command('u1',true,envelope(game,{type:'pass'},'p1'));game.command('u1',true,envelope(game,{type:'pass'},'p2'));expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players[0]?.ap).toBe(2)})
it('resets the rolling timer only for each player first valid action and ends when all finish',()=>{const {game,advance}=harness([1,1]);spawn(game,'u1','One');spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();const firstPhase=game.snapshot().phase;if(firstPhase.kind!=='player')throw new Error();advance(1_000);game.command('u1',true,envelope(game,{type:'pass'},'a1'));let phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();expect(phase).toMatchObject({deadlineAt:7_000,timerResetPlayerIds:['u1']});advance(1_000);game.command('u1',true,envelope(game,{type:'pass'},'a2'));phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();expect(phase.deadlineAt).toBe(7_000);advance(1_000);game.command('u2',true,envelope(game,{type:'pass'},'b1'));phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();expect(phase).toMatchObject({deadlineAt:9_000,timerResetPlayerIds:['u1','u2']});game.command('u2',true,envelope(game,{type:'pass'},'b2'));phase=game.snapshot().phase;expect(phase.kind).toBe('player');if(phase.kind==='player')expect(phase.phaseId).not.toBe(firstPhase.phaseId)})
it('expires after five seconds and converts unused AP into AutoGuard for the Enemy Phase',()=>{const {game,advance}=harness([0,0]);spawn(game);game.bindExtension('u1');const internal=(game as any).state;internal.players.u1.position={x:internal.goblin.position.x-1,y:internal.goblin.position.y};internal.goblin.mode='pursuing';internal.goblin.targetPlayerId='u1';advance(5_000);game.tick();expect(game.snapshot().players[0]).toMatchObject({hp:3,ap:2,guard:0});expect(game.snapshot().actionLog.filter(entry=>entry.type==='guard-blocked')).toHaveLength(2)})
it('AutoGuards a player immediately when their last connection closes',()=>{const {game}=harness();spawn(game);spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();expect(game.disconnect('u2')).toBe(true);expect(game.snapshot().players.find(p=>p.twitchUserId==='u2')).toMatchObject({connectionState:'disconnected',ap:0,guard:2,eligibleThisPhase:false});expect(game.snapshot().phase.kind).toBe('player')})
it('does not wait for a disconnected player in later phases',()=>{const {game}=harness([1,1]);spawn(game);spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');game.disconnect('u2');game.command('u1',true,envelope(game,{type:'pass'},'p1'));game.command('u1',true,envelope(game,{type:'pass'},'p2'));expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players.find(p=>p.twitchUserId==='u1')).toMatchObject({ap:2,connectionState:'connected'});expect(game.snapshot().players.find(p=>p.twitchUserId==='u2')).toMatchObject({ap:0,guard:2,connectionState:'disconnected'})})
it('supports administrative player removal, phase completion, and run reset',()=>{const {game}=harness();spawn(game);spawn(game,'u2','Two');const firstRun=game.snapshot().runId;expect(game.removePlayer('u2')).toBe(true);expect(game.removePlayer('missing')).toBe(false);expect(game.snapshot().players.map(p=>p.twitchUserId)).toEqual(['u1']);expect(game.forceEndPlayerPhase()).toBe(true);game.resetRunByAdmin();expect(game.snapshot().runId).not.toBe(firstRun);expect(game.snapshot().actionLog.at(-2)?.type).toBe('admin-reset')})
@@ -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('returns to dormant when the final living player dies and resumes on resurrection',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const before=game.snapshot();(game as any).killPlayer((game as any).state.players.u1);expect(game.snapshot()).toMatchObject({runId:before.runId,floorNumber:before.floorNumber,phase:{kind:'dormant'},players:[{lifeState:'dead',hp:0}]});expect(game.snapshot().actionLog.at(-1)?.type).toBe('party-defeated');expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'return',twitchUserId:'u1',rewardId:'resurrection'}).accepted).toBe(true);expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players[0]).toMatchObject({lifeState:'alive',ap:2})})
it('escapes with a living Goblin and rejects the old floor envelope',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const old=envelope(game,{type:'pass'},'old');const internal=(game as any).state,exit=internal.floor.exitPosition;internal.players.u1.position={x:exit.x-1,y:exit.y};const direction=exit.x>internal.players.u1.position.x?'right':'left';expect(game.command('u1',true,envelope(game,{type:'move',direction},'exit')).accepted).toBe(true);expect(game.snapshot().floorNumber).toBe(2);expect(game.command('u1',true,{...old,requestId:'old2'}).reason).toBe('STALE_FLOOR')})
it('clears the party at the exit and assigns next-level slots first come first served',()=>{const {game}=harness();for(let i=1;i<=4;i++){spawn(game,`u${i}`,`P${i}`,`s${i}`);game.bindExtension(`u${i}`)}const internal=(game as any).state,exit=internal.floor.exitPosition;internal.players.u1.position={x:exit.x-1,y:exit.y};const direction=exit.x>internal.players.u1.position.x?'right':'left';expect(game.command('u1',true,envelope(game,{type:'move',direction},'exit')).accepted).toBe(true);expect(game.snapshot()).toMatchObject({floorNumber:2,phase:{kind:'dormant'},players:[]});expect(game.personalizedSnapshot('u1').viewer).toBeNull();expect(spawn(game,'u5','Five','next-5').accepted).toBe(true);expect(spawn(game,'u1','One','next-1').accepted).toBe(true);expect(game.snapshot().players.map(p=>[p.twitchUserId,p.partySlot,p.color])).toEqual([['u5',1,'blue'],['u1',2,'green']]);expect(game.personalizedSnapshot('u1').viewer).toMatchObject({twitchUserId:'u1',partySlot:2,color:'green'})})
})
+2 -1
View File
@@ -9,11 +9,12 @@ async function post(path:string,value:unknown,token?:string){return fetch(`${bas
describe('backend boundary',()=>{
it('AT-001 exposes a secret-free readiness response',async()=>{const data=await fetch(`${base}/health`).then(r=>r.json());expect(data).toMatchObject({status:'ok',ready:true,twitchMode:'synthetic'});expect(JSON.stringify(data)).not.toMatch(/secret|token/i)})
it('reports OAuth readiness without exposing credentials',async()=>{const data=await fetch(`${base}/oauth/status`).then(r=>r.json());expect(data).toMatchObject({configured:false,authorized:false});expect(JSON.stringify(data)).not.toMatch(/clientId|clientSecret|accessToken|refreshToken/)})
it('serves 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('AT-005 establishes a session without creating a character',async()=>{const r=await post('/api/extension/session',{token:'dev:nobody:Nobody'});expect(r.status).toBe(200);const data=await r.json();expect(data.state.viewer).toBeNull()})
it('AT-011 binds the same stable chat and Extension identity',async()=>{const id=`viewer-${Date.now()}`;expect((await post('/api/dev/spawn',{command:'!spawn',externalEventId:`e-${id}`,twitchUserId:id,displayName:'Viewer',followerVerified:true})).status).toBe(200);const auth=await (await post('/api/extension/session',{token:`dev:${id}:Viewer`})).json();expect(auth.twitchUserId).toBe(id);expect(auth.state.viewer.extensionBound).toBe(true);const s=auth.state,phase=s.phase;expect(phase.kind).toBe('player');const command=await post('/api/commands',{requestId:`r-${id}`,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}},auth.token);expect(command.status).toBe(200)})
it('spawns from an authenticated Extension identity',async()=>{const id=`button-${Date.now()}`,auth=await (await post('/api/extension/session',{token:`dev:${id}:Button Viewer`})).json();expect((await post('/api/extension/spawn',{},auth.token)).status).toBe(200);const state=await fetch(`${base}/api/state`).then(r=>r.json());expect(state.players.some((player:any)=>player.twitchUserId===id)).toBe(true);expect((await post('/api/extension/spawn',{})).status).toBe(401)})
it('rejects the fifth Extension spawn with a clear full-party result',async()=>{let state=await fetch(`${base}/api/state`).then(r=>r.json());for(let i=state.players.length;i<4;i++){const id=`fill-${Date.now()}-${i}`,auth=await (await post('/api/extension/session',{token:`dev:${id}:Fill ${i}`})).json();expect((await post('/api/extension/spawn',{},auth.token)).status).toBe(200)}state=await fetch(`${base}/api/state`).then(r=>r.json());expect(state.players).toHaveLength(4);const spectator=`spectator-${Date.now()}`,auth=await (await post('/api/extension/session',{token:`dev:${spectator}:Spectator`})).json(),response=await post('/api/extension/spawn',{},auth.token),result=await response.json();expect(response.status).toBe(409);expect(result).toMatchObject({accepted:false,reason:'PARTY_FULL',message:'The current party is full. You can try again on the next level.'});expect(result.state.viewer).toBeNull()})
it('AT-012 rejects unauthenticated controls',async()=>{const s=await fetch(`${base}/api/state`).then(r=>r.json()),phase=s.phase;const r=await post('/api/commands',{requestId:'unauth',runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}});expect(r.status).toBe(409);expect((await r.json()).reason).toBe('UNAUTHENTICATED')})
it('AT-025 normalizes Channel Points redemptions and rejects unknown owners safely',async()=>{const r=await post('/api/dev/redemption',{externalEventId:'redemption-unknown',twitchUserId:'missing-viewer',rewardId:'local-resurrection'});expect(r.status).toBe(409);expect((await r.json()).reason).toBe('UNKNOWN_PLAYER')})
})