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
+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')})
})