diff --git a/README.md b/README.md index a8ab8da..01eb833 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md b/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md index b6c9e6a..3721e5f 100644 --- a/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md +++ b/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md @@ -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. diff --git a/Twungeon_MVP_PRD_Current.md b/Twungeon_MVP_PRD_Current.md index 16f452b..f8c2731 100644 --- a/Twungeon_MVP_PRD_Current.md +++ b/Twungeon_MVP_PRD_Current.md @@ -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. diff --git a/Twungeon_MVP_Technical_Specification.md b/Twungeon_MVP_Technical_Specification.md index 7a15a46..8e54952 100644 --- a/Twungeon_MVP_Technical_Specification.md +++ b/Twungeon_MVP_Technical_Specification.md @@ -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. diff --git a/apps/backend/src/admin.ts b/apps/backend/src/admin.ts index 77a9e7b..61abe73 100644 --- a/apps/backend/src/admin.ts +++ b/apps/backend/src/admin.ts @@ -60,7 +60,7 @@ export function createAdminServer(deps:AdminServerDependencies){ async function render(deps:AdminServerDependencies,notice:string|null):Promise{ 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=>`${escapeHtml(player.displayName)}${escapeHtml(player.twitchUserId)}${player.connectionState}${player.lifeState}${player.hp}${player.ap}${player.guard}
`).join('')||'No players are currently in the dungeon.' + const playerRows=state.players.map(player=>`${player.partySlot}${escapeHtml(player.displayName)}${escapeHtml(player.twitchUserId)}${player.connectionState}${player.lifeState}${player.hp}${player.ap}${player.guard}
`).join('')||'No players are currently in the dungeon.' const logs=state.actionLog.slice(-40).reverse().map(entry=>`${escapeHtml(entry.occurredAt)}${escapeHtml(entry.type)}${escapeHtml(entry.message)}`).join('') - return `Twungeon Admin

Twungeon Admin

Private operator console · refreshes every 15 seconds

${notice?`

${escapeHtml(notice)}

`:''}
Run
Floor ${state.floorNumber}
Phase
${escapeHtml(phase)}
Players
${state.players.length}
Twitch
${status.twitchReady?'Ready':'Not ready'}
OAuth
${status.oauthAuthorized?'Authorized':status.oauthConfigured?'Not authorized':'Not configured'}
Uptime
${Math.floor(status.uptimeSeconds/60)}m

Controls

Players

${playerRows}
NameTwitch IDConnectionLifeHPAPGuardActions

Recent action log

${logs}
TimeEventMessage
` + return `Twungeon Admin

Twungeon Admin

Private operator console · refreshes every 15 seconds

${notice?`

${escapeHtml(notice)}

`:''}
Run
Floor ${state.floorNumber}
Phase
${escapeHtml(phase)}
Party
${state.players.length}/4
Twitch
${status.twitchReady?'Ready':'Not ready'}
OAuth
${status.oauthAuthorized?'Authorized':status.oauthConfigured?'Not authorized':'Not configured'}
Uptime
${Math.floor(status.uptimeSeconds/60)}m

Controls

Players

${playerRows}
SlotNameTwitch IDConnectionLifeHPAPGuardActions

Recent action log

${logs}
TimeEventMessage
` } diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index 63c3967..74d769f 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -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('','');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}`)) diff --git a/apps/stream-view/public/app.js b/apps/stream-view/public/app.js index 0b8add3..29a5841 100644 --- a/apps/stream-view/public/app.js +++ b/apps/stream-view/public/app.js @@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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])=>`
${k}${v}
`).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])=>`
${k}${v}
`).join('') + const viewerStatus=$('#viewerStatus');viewerStatus.hidden=!extensionMode||!me;viewerStatus.innerHTML=me?`
${escapeHtml(me.displayName)}Slot ${me.partySlot} · ${colorNames[me.color]}
HP${me.hp}/3
AP${me.ap}
Heal${me.healAvailable?'Ready':'Used'}
`:'' + $('#partyLegend').innerHTML=state.players.map(player=>`● ${escapeHtml(player.displayName)}`).join(' ') const map=$('#map');map.style.gridTemplateColumns=`repeat(${state.floor.width},auto)`;map.innerHTML='' - for(let y=0;yp.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=>`
  • #${e.sequence} ${escapeHtml(e.message)}
  • `).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;yp.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`#${e.sequence} ${escapeHtml(e.message)}`}).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)})} diff --git a/apps/stream-view/public/index.html b/apps/stream-view/public/index.html index 98227ea..a96d043 100644 --- a/apps/stream-view/public/index.html +++ b/apps/stream-view/public/index.html @@ -2,9 +2,9 @@ Twungeon
    -

    Shared dungeon

    Floor 1

    ● Adventurer◆ Goblin▣ Exit
    +

    Shared dungeon

    Floor 1

    ◆ Goblin▣ Exit

    Chronicle

    Action log

    Connecting…
      diff --git a/apps/stream-view/public/styles.css b/apps/stream-view/public/styles.css index a57b110..ef7a0e0 100644 --- a/apps/stream-view/public/styles.css +++ b/apps/stream-view/public/styles.css @@ -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%; } } diff --git a/docs/testing.md b/docs/testing.md index 6a2ea3f..6640e7a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/docs/twitch-setup.md b/docs/twitch-setup.md index bc3fcb0..70ba184 100644 --- a/docs/twitch-setup.md +++ b/docs/twitch-setup.md @@ -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 diff --git a/packages/domain/src/game.ts b/packages/domain/src/game.ts index 01ddbe6..465356b 100644 --- a/packages/domain/src/game.ts +++ b/packages/domain/src/game.ts @@ -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 { 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 diff --git a/packages/domain/src/types.ts b/packages/domain/src/types.ts index a234ab9..fbbd7e1 100644 --- a/packages/domain/src/types.ts +++ b/packages/domain/src/types.ts @@ -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' } diff --git a/packages/rpgjs-adapter/src/index.ts b/packages/rpgjs-adapter/src/index.ts index 28f75ac..fafc6fc 100644 --- a/packages/rpgjs-adapter/src/index.ts +++ b/packages/rpgjs-adapter/src/index.ts @@ -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} } diff --git a/tests/domain/game.test.ts b/tests/domain/game.test.ts index 11dc0a5..b13dc96 100644 --- a/tests/domain/game.test.ts +++ b/tests/domain/game.test.ts @@ -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'})}) }) diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index 18e20fe..6b91402 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -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('');expect(rootHtml).not.toContain('');expect(rootHtml).toContain('Activate the Extension to spawn your character in the Twungeon!');expect(rootHtml).toContain('');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('');expect(rootHtml).not.toContain('');expect(rootHtml).toContain('Activate the Extension to spawn your character in the Twungeon!');expect(rootHtml).toContain('');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')}) })