From db19a8995700732bad9d5efb02c1d10b76453a90 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Mon, 17 Aug 2026 20:49:04 -0700 Subject: [PATCH] Return defeated parties to dormant state --- ...MVP_Acceptance_Test_and_Build_Checklist.md | 30 ++++++------ Twungeon_MVP_PRD_Current.md | 32 ++++++------- Twungeon_MVP_Technical_Specification.md | 48 +++++++++---------- apps/stream-view/public/index.html | 2 +- apps/stream-view/public/styles.css | 1 + docs/testing.md | 2 +- packages/domain/src/game.ts | 13 ++--- packages/domain/src/types.ts | 2 +- tests/domain/game.test.ts | 1 + tests/integration/api.test.ts | 2 +- 10 files changed, 67 insertions(+), 66 deletions(-) diff --git a/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md b/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md index d7ae6c7..b6c9e6a 100644 --- a/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md +++ b/Twungeon_MVP_Acceptance_Test_and_Build_Checklist.md @@ -231,14 +231,14 @@ target death, return, death, and escape with the Goblin alive. - [ ] Generate the next floor and Goblin. - [ ] Revive, heal, reposition, and refresh self-heal for all participants on floor advancement. -- [ ] Reset to a new Floor 1 run immediately after the last participant dies. -- [ ] Distinguish zero participants from a total-party wipe. +- [ ] Return the current floor to dormancy immediately after the last participant dies. +- [ ] Distinguish initial dormancy from defeated-party dormancy. - [ ] Preserve character identity and state across simulated client reconnects. - [ ] Verify expected new-run behavior after a simulated backend restart. - [ ] Run a deterministic multi-floor soak test. **Checkpoint P6:** A local driver can repeat the entire gameplay loop through -spawn, action, death, resurrection or advancement, new floors, and wipe reset. +spawn, action, death, dormant defeat, resurrection or advancement, and new floors. ### Phase 7: Backend API, state stream, and UIs @@ -403,8 +403,8 @@ successful connection logs. - [ ] Start with zero participants and wait longer than a maximum Player Phase. - [ ] Confirm the floor remains loaded, no timer or Enemy Phase runs, the Goblin - does not act, and the exact `Type !spawn to spawn in the Twungeon!` banner is - shown. + does not act, and the exact two-line `Activate the Extension to spawn your character in the Twungeon!` + and `Must be a follower to spawn.` banner is shown. **Level:** Automated domain/integration plus visual review **Pass evidence:** State assertions, elapsed-time logs, and screenshot. @@ -603,25 +603,25 @@ successful connection logs. **Level:** Automated integration plus live Channel Points redemption **Pass evidence:** Redacted event, deduplication log, and before/after state. -### AT-026 — Total-party wipe reset +### AT-026 — Total-party defeat dormancy **PRD criterion:** 26 - [ ] Kill the final living participant before exit. -- [ ] Confirm an immediate new run on Floor 1, new floor/Goblin, every participant - alive at full HP, refreshed heals, and one wipe/reset log sequence. +- [ ] Confirm immediate dormancy on the current run and floor, no active timer, + every participant still dead, and one party-defeat log entry. +- [ ] Resurrect one participant and confirm a fresh Player Phase begins. **Level:** Automated E2E plus live scenario -**Pass evidence:** Run/floor transition and participant snapshots. +**Pass evidence:** Before/after phase, run, floor, participant, and log snapshots. -### AT-027 — Zero players differs from wipe +### AT-027 — Initial dormancy differs from defeated-party dormancy **PRD criterion:** 27 -- [ ] Compare a new game with no participants to a run where the final player - dies. -- [ ] Confirm the first remains dormant on its current floor and the second - resets immediately with participants restored. +- [ ] Compare a new game with no participants to a run where the final player dies. +- [ ] Confirm both are dormant, while only the defeated run preserves dead + participant records and a party-defeat log entry. **Level:** Automated domain/integration **Pass evidence:** Side-by-side state-transition assertions. @@ -655,7 +655,7 @@ successful connection logs. **PRD criterion:** 30 - [ ] Run a multi-floor soak containing spawns, concurrent actions, expiry, - combat, heal, death, resurrection, escape, advancement, and wipe reset. + combat, heal, death, defeated-party dormancy, resurrection, escape, and advancement. - [ ] Confirm integration, phase, player, Goblin, generator, and log invariants remain valid throughout. diff --git a/Twungeon_MVP_PRD_Current.md b/Twungeon_MVP_PRD_Current.md index f65dd8f..16f452b 100644 --- a/Twungeon_MVP_PRD_Current.md +++ b/Twungeon_MVP_PRD_Current.md @@ -18,7 +18,7 @@ tracks build sequencing and evidence without replacing these requirements. The MVP is intended to prove that the complete interaction loop works at a basic level: -**Twitch viewer -> follower eligibility -> `!spawn` -> character control -> turn-based dungeon play -> combat -> healing -> death -> resurrection or floor advance -> new floor -> run reset** +**Twitch viewer -> follower eligibility -> `!spawn` -> character control -> turn-based dungeon play -> combat -> healing -> death -> dormant defeat -> resurrection or a new adventurer -> floor advance** The MVP is not intended to prove game balance, long-term progression, production presentation, or anti-exploit systems. @@ -38,7 +38,7 @@ The MVP should demonstrate that multiple Twitch viewers can: - resurrect through a configured Channel Points reward, - advance the entire party by reaching the exit, - revive dead players on floor advancement, -- and reset the run when all participating players die. +- 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. @@ -157,7 +157,7 @@ It must provide clear confirmation of significant game actions and state changes - Goblin aggro or return behavior when useful, - floor advancement, - dormant-state changes, -- and total-party wipe/run reset. +- and total-party defeat/dormancy. Example messages include: @@ -202,7 +202,8 @@ When the first eligible player spawns: A player who already died on the current floor remains dead and cannot use `!spawn` to re-enter. -Zero active players is not considered a total-party wipe. +Zero active players with no participant history and a defeated party are both +dormant, but the latter preserves its dead character records and current floor. The MVP maintains only one active floor at a time. No previous-floor simulation or multi-floor concurrent state is required. @@ -510,20 +511,19 @@ Bits and Cheers are outside the MVP and must not trigger game-state changes. --- -## 18. Total Party Wipe +## 18. Total Party Defeat If every active participating player is dead before any player reaches the exit: -**The run resets.** +**The current floor returns to the Dormant state.** -On reset: +On defeat: -1. The floor counter returns to Floor 1. -2. A new starting floor is generated. -3. All participating players return alive. -4. Player HP returns to full. -5. Player self-heals refresh. -6. A new Goblin Guard is generated. +1. The current run, floor, and Goblin remain loaded. +2. All defeated characters remain dead and retain their current-floor death markers. +3. No phase timer runs and the Goblin does not act. +4. A configured resurrection can revive a dead viewer and begin a fresh Player Phase. +5. A different eligible viewer may spawn and begin a fresh Player Phase. --- @@ -655,7 +655,7 @@ The MVP is successful if it demonstrates that: 4. Twungeon successfully receives the Twitch events and chat activity required by the MVP. 5. The Twitch Extension loads and communicates with the Twungeon backend. 6. The stream-facing game view presents the three-section MVP layout: control/status area, main game area, and running action log. -7. With zero active players, the dungeon enters dormant mode, stops advancing turns, and displays the `Type !spawn to spawn in the Twungeon!` banner. +7. With zero active players, the dungeon enters dormant mode, stops advancing turns, and displays a two-line banner: `Activate the Extension to spawn your character in the Twungeon!` and `Must be a follower to spawn.` 8. A Twitch follower can intentionally join with `!spawn`. 9. A non-follower cannot spawn a player character. 10. Duplicate spawning for the same Twitch user is prevented. @@ -674,8 +674,8 @@ The MVP is successful if it demonstrates that: 23. Dead players return when the floor advances. 24. A player who died on the current floor cannot bypass death by using `!spawn`. 25. The configured Channel Points reward can resurrect the correct dead player during the current floor. -26. A total-party wipe resets the run to Floor 1. -27. Zero active players is distinguished from a total-party wipe. +26. A total-party defeat returns the current floor to dormancy without reviving players or resetting the run. +27. Initial dormancy is distinguished from defeated-party dormancy by preserved participant state and action-log history. 28. An eligible follower who has not died on the current floor can `!spawn` into an otherwise empty current floor. 29. Refreshes and temporary disconnects do not create duplicate characters or corrupt critical identity/game state. 30. The complete loop can repeat across multiple floors without breaking Twitch integration, turns, player state, Goblin state, floor generation, or the action log. diff --git a/Twungeon_MVP_Technical_Specification.md b/Twungeon_MVP_Technical_Specification.md index ef1dae2..7a15a46 100644 --- a/Twungeon_MVP_Technical_Specification.md +++ b/Twungeon_MVP_Technical_Specification.md @@ -49,7 +49,7 @@ The implementation must optimize for the following goals, in order: - One character per Twitch user - Twitch Extension controls - Shared stream-facing game view and chronological action log -- Dormant, Player Phase, Enemy Phase, floor transition, and run reset states +- Dormant, Player Phase, Enemy Phase, floor transition, and administrative run reset states - AP, movement, combat, AutoGuard, self-heal, death, and resurrection - One Goblin Guard with guarding, pursuit, and return behavior - Varied, navigable two-room floors @@ -276,7 +276,7 @@ type PhaseState = initialEligiblePlayerIds: string[] } | { kind: 'enemy'; phaseId: string } - | { kind: 'transition'; reason: 'floor-advance' | 'party-wipe' } + | { kind: 'transition'; reason: 'floor-advance' | 'admin-reset' } ``` The backend uses a monotonic clock for elapsed phase timing. Wall-clock values @@ -438,9 +438,9 @@ While no active character exists: The first accepted `!spawn` creates a living character, removes the banner, and starts a fresh Player Phase. -The MVP has no voluntary leave or character-removal command. Temporary -disconnects therefore do not create dormancy. A total-party death triggers a -run reset rather than dormancy. +Temporary disconnects do not create dormancy because disconnected living +characters use AutoGuard. A total-party death does create dormancy while +preserving the run, floor, Goblin, and dead character records. ### 9.2 Starting Player Phase @@ -485,7 +485,7 @@ The living Goblin receives 2 AP and repeatedly selects one deterministic action until it has no AP or no valid action. After the Goblin finishes, Guard is discarded and a new Player Phase begins, -unless the floor advanced or the run reset during the Enemy Phase. +unless the floor advanced or the party became dormant during the Enemy Phase. ### 9.5 AutoGuard @@ -633,25 +633,21 @@ When a living player enters the exit: The previous Goblin and map are discarded. -### 12.3 Total-party wipe +### 12.3 Total-party defeat After any death resolution, if participating characters exist and none is alive, the backend immediately: -1. enters transition state; -2. creates a new run ID; -3. sets the floor counter to 1; -4. generates a new starting floor and Goblin; -5. restores all participating players alive at 3 HP; -6. clears death markers, AP, and Guard; -7. refreshes all self-heals; -8. places all players at the spawn tile; -9. logs the wipe and reset; and -10. begins a fresh Player Phase. +1. enters Dormant state with no deadline; +2. preserves the current run ID, floor, and Goblin; +3. preserves each dead character and current-floor death marker; +4. stops Enemy Phase processing immediately; and +5. logs the party defeat. -Because the reset is immediate, a Channel Points redemption cannot interrupt a -completed total-party wipe. Zero participating characters is explicitly not a -wipe. +A valid Channel Points resurrection revives its owner and starts a fresh Player +Phase from dormancy. 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. ## 13. Randomness @@ -771,7 +767,9 @@ starting an empty client-only log. When phase is dormant, both applicable views prominently display exactly: -`Type !spawn to spawn in the Twungeon!` +`Activate the Extension to spawn your character in the Twungeon!` + +`Must be a follower to spawn.` ## 16. Failure and Recovery Behavior @@ -879,10 +877,10 @@ Deterministic tests must cover: - AutoGuard conversion, hit blocking, misses, and reset; - player and Goblin hit probabilities through injected rolls; - heal availability and floor refresh; -- death, spawn lockout, resurrection, advancement, and wipe reset; +- death, spawn lockout, resurrection, advancement, and defeated-party dormancy; - Goblin guarding, aggro on hit or miss, pursuit, return, and death; - exit entry with a living Goblin; -- dormant versus total-party-wipe conditions; +- initial versus defeated-party dormant conditions; - serial ordering and stale/duplicate command handling; and - deterministic floor generation and connectivity validation. @@ -913,7 +911,7 @@ At minimum, the live test plan must demonstrate: 7. Goblin aggro, pursuit, attack, target death, and return; 8. Channel Points resurrection of the correct dead viewer; 9. floor escape with the Goblin alive and dead-player revival; -10. total-party wipe and Floor 1 reset; +10. total-party defeat, dormant transition, and resurrection recovery; 11. refresh and temporary disconnect recovery; and 12. repetition across multiple generated floors. @@ -941,7 +939,7 @@ slices: 2. Implement the deterministic floor generator and static stream rendering. 3. Implement player/Goblin state, commands, turn phases, timer, and action log using local test drivers. -4. Implement death, resurrection messages, floor advancement, wipe reset, +4. Implement death, resurrection messages, floor advancement, defeat dormancy, dormancy, reconnect, and idempotency. 5. Add the RPGJS adapter without moving rules out of the domain package. 6. Add Twurple chat and follower verification for `!spawn`. diff --git a/apps/stream-view/public/index.html b/apps/stream-view/public/index.html index 368b5e7..98227ea 100644 --- a/apps/stream-view/public/index.html +++ b/apps/stream-view/public/index.html @@ -5,6 +5,6 @@

Controller

Local viewer login

Privacy notice

-

Shared dungeon

Floor 1

● Adventurer◆ Goblin▣ Exit
+

Shared dungeon

Floor 1

● Adventurer◆ Goblin▣ Exit

Chronicle

Action log

Connecting…
    diff --git a/apps/stream-view/public/styles.css b/apps/stream-view/public/styles.css index bc6beb1..a57b110 100644 --- a/apps/stream-view/public/styles.css +++ b/apps/stream-view/public/styles.css @@ -33,6 +33,7 @@ h2 { font: 700 18px/1.2 system-ui; } .game-head, .log-head { display: flex; align-items: center; justify-content: space-between; } .phase { padding: 6px 10px; border: 1px solid var(--gold); color: var(--gold); border-radius: 2px; text-transform: uppercase; font-size: 12px; } #banner { position: absolute; z-index: 3; inset: 45% auto auto 50%; transform: translate(-50%, -50%); padding: 16px 22px; background: #111e; border: 1px solid var(--gold); box-shadow: 0 0 40px #000; text-align: center; color: var(--gold); font-weight: bold; white-space: nowrap; } +#banner .banner-note { display: block; margin-top: 6px; color: #fff; font-size: .78em; font-weight: 600; } #map { flex: 1; display: grid; align-content: center; justify-content: center; margin: 12px 0; min-height: 0; } .tile { width: min(3.6vw, 42px); aspect-ratio: 1; border: 1px solid #222; display: grid; place-items: center; position: relative; font-size: min(1.6vw, 18px); } .tile.wall { background: var(--wall); box-shadow: inset 0 0 0 2px #1b1f21; } diff --git a/docs/testing.md b/docs/testing.md index b1b5d56..6a2ea3f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -77,7 +77,7 @@ non-follower account. Capture redacted evidence for: - independent commands from two viewers; - phase expiry and AutoGuard while one viewer is disconnected; - death and a configured Channel Points reward redemption from the same user; -- escape with the Goblin alive, floor revival, and a total-party wipe; +- escape with the Goblin alive, floor revival, and total-party defeat dormancy; - refresh/reconnect during a phase and transition; and - repeated floors and a short soak session. diff --git a/packages/domain/src/game.ts b/packages/domain/src/game.ts index fa0796d..01ddbe6 100644 --- a/packages/domain/src/game.ts +++ b/packages/domain/src/game.ts @@ -61,7 +61,7 @@ export class Game { return true } forceEndPlayerPhase():boolean {if(this.state.phase.kind!=='player')return false;this.endPlayerPhase();return true} - resetRunByAdmin():void {this.resetRun('admin-reset','An administrator reset the run.')} + resetRunByAdmin():void {this.resetRun()} spawn(message: SpawnMessage): SpawnResult { if(this.externalEvents.has(message.externalEventId)) return {accepted:false,reason:'DUPLICATE',message:'That spawn request was already handled.'} @@ -85,6 +85,7 @@ export class Game { if(p.lifeState!=='dead') return {accepted:false,reason:'PLAYER_ALIVE',message:'That character is already alive.'} p.lifeState='alive'; p.hp=3; p.ap=0; p.guard=0; p.eligibleThisPhase=false; p.diedOnFloor=null; p.position=copy(this.state.floor.spawnTiles[0]!) this.log('player-resurrected',p.twitchUserId,null,`${p.displayName} rose again at the spawn!`) + if(this.state.phase.kind==='dormant')this.startPlayerPhase() return {accepted:true,reason:null,message:`${p.displayName} resurrected.`} } @@ -180,7 +181,7 @@ export class Game { if(adjacent(g.position,target.position)){ const hit=this.deps.random.next()<0.5 if(hit && target.guard>0){target.guard--;this.log('guard-blocked','goblin',target.twitchUserId,`${target.displayName}'s Guard blocks the hit.`)} - else if(hit){target.hp--;this.log('goblin-hit','goblin',target.twitchUserId,`The Goblin hits ${target.displayName} for 1 damage.`);if(target.hp<=0)this.killPlayer(target)} + else if(hit){target.hp--;this.log('goblin-hit','goblin',target.twitchUserId,`The Goblin hits ${target.displayName} for 1 damage.`);if(target.hp<=0){this.killPlayer(target);if(this.state.phase.kind==='dormant')return}} else this.log('goblin-missed','goblin',target.twitchUserId,`The Goblin misses ${target.displayName}.`) } else { const step=this.nextPathStep(g.position,target.position);if(!step)break;g.position=step;this.log('goblin-moved','goblin',target.twitchUserId,'The Goblin closes in.') } } else if(g.mode==='returning'){ @@ -195,17 +196,17 @@ export class Game { player.hp=0;player.lifeState='dead';player.ap=0;player.guard=0;player.eligibleThisPhase=false;player.diedOnFloor=this.state.floorNumber this.log('player-died','goblin',player.twitchUserId,`${player.displayName} has fallen.`) if(this.state.goblin.targetPlayerId===player.twitchUserId){this.state.goblin.mode='returning';this.state.goblin.targetPlayerId=null} - const all=Object.values(this.state.players);if(all.length>0 && all.every(p=>p.lifeState==='dead'))this.resetRun() + const all=Object.values(this.state.players);if(all.length>0 && all.every(p=>p.lifeState==='dead')){this.state.phase={kind:'dormant'};this.log('party-defeated',null,null,'The party has fallen. The dungeon is dormant until an adventurer returns.')} } private advanceFloor(): void { this.state.phase={kind:'transition',reason:'floor-advance'};this.state.floorNumber++ const floor=this.deps.generateFloor(`${this.state.runId}-floor-${this.state.floorNumber}`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor) this.restorePlayers();this.log('floor-advanced',null,null,`The party reaches Floor ${this.state.floorNumber}!`);this.startPlayerPhase() } - private resetRun(logType='party-wipe',message='The party has fallen. A new run begins on Floor 1.'): void { - this.state.phase={kind:'transition',reason:'party-wipe'};this.state.runId=this.deps.ids.next('run');this.state.floorNumber=1 + private resetRun(): void { + this.state.phase={kind:'transition',reason:'admin-reset'};this.state.runId=this.deps.ids.next('run');this.state.floorNumber=1 const floor=this.deps.generateFloor(`${this.state.runId}-floor-1`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor) - this.restorePlayers();this.log(logType,null,null,message);this.startPlayerPhase() + this.restorePlayers();this.log('admin-reset',null,null,'An administrator reset the run.');this.startPlayerPhase() } private restorePlayers(): void { for(const p of Object.values(this.state.players)){p.hp=3;p.lifeState='alive';p.ap=0;p.guard=0;p.healAvailable=true;p.diedOnFloor=null;p.eligibleThisPhase=false;p.participatingFloor=this.state.floorNumber;p.position=copy(this.state.floor.spawnTiles[0]!)} } private nextPathStep(start:TilePosition,goal:TilePosition):TilePosition|null { diff --git a/packages/domain/src/types.ts b/packages/domain/src/types.ts index d6ed670..a234ab9 100644 --- a/packages/domain/src/types.ts +++ b/packages/domain/src/types.ts @@ -21,7 +21,7 @@ export type PhaseState = { kind: 'dormant' } | { kind: 'player'; phaseId: string; startedAt: number; deadlineAt: number initialEligiblePlayerIds: string[] } | { kind: 'enemy'; phaseId: string } | { - kind: 'transition'; reason: 'floor-advance' | 'party-wipe' + kind: 'transition'; reason: 'floor-advance' | 'admin-reset' } export interface ActionLogEntry { sequence: number; runId: string; floorNumber: number; phaseId: string | null diff --git a/tests/domain/game.test.ts b/tests/domain/game.test.ts index 4576172..11dc0a5 100644 --- a/tests/domain/game.test.ts +++ b/tests/domain/game.test.ts @@ -24,5 +24,6 @@ describe('authoritative game core',()=>{ it('AT-018 heals once and rejects invalid repeat with no AP loss',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');(game as any).state.players.u1.hp=1;expect(game.command('u1',true,envelope(game,{type:'heal-self'},'h1')).accepted).toBe(true);expect(game.snapshot().players[0]).toMatchObject({hp:3,healAvailable:false,ap:1});expect(game.command('u1',true,envelope(game,{type:'heal-self'},'h2')).reason).toBe('HEAL_USED');expect(game.snapshot().players[0]?.ap).toBe(1)}) it('AT-018 attacks adjacent Goblin, aggroes on miss, and kills on hits',()=>{const {game}=harness([1,0,0]);spawn(game);game.bindExtension('u1');const internal=(game as any).state;internal.players.u1.position={x:internal.goblin.position.x-1,y:internal.goblin.position.y};expect(game.command('u1',true,envelope(game,{type:'attack',targetId:'goblin'},'a1')).accepted).toBe(true);expect(game.snapshot().goblin).toMatchObject({hp:2,mode:'pursuing',targetPlayerId:'u1'});expect(game.command('u1',true,envelope(game,{type:'attack',targetId:'goblin'},'a2')).accepted).toBe(true);expect(game.snapshot().goblin.hp).toBeLessThanOrEqual(1)}) it('AT-025 resurrects only the matching dead player once for the configured Channel Points reward',()=>{const {game}=harness();spawn(game);const p=(game as any).state.players.u1;p.lifeState='dead';p.hp=0;p.healAvailable=false;p.diedOnFloor=1;expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'redemption',twitchUserId:'u1',rewardId:'wrong'}).reason).toBe('WRONG_REWARD');expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'redemption-2',twitchUserId:'u1',rewardId:'resurrection'}).accepted).toBe(true);expect(game.snapshot().players[0]).toMatchObject({lifeState:'alive',hp:3,ap:0,healAvailable:false});expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'redemption-2',twitchUserId:'u1',rewardId:'resurrection'}).reason).toBe('DUPLICATE')}) + it('returns to dormant when the final living player dies and resumes on resurrection',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const before=game.snapshot();(game as any).killPlayer((game as any).state.players.u1);expect(game.snapshot()).toMatchObject({runId:before.runId,floorNumber:before.floorNumber,phase:{kind:'dormant'},players:[{lifeState:'dead',hp:0}]});expect(game.snapshot().actionLog.at(-1)?.type).toBe('party-defeated');expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'return',twitchUserId:'u1',rewardId:'resurrection'}).accepted).toBe(true);expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players[0]).toMatchObject({lifeState:'alive',ap:2})}) it('escapes with a living Goblin and rejects the old floor envelope',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const old=envelope(game,{type:'pass'},'old');const internal=(game as any).state,exit=internal.floor.exitPosition;internal.players.u1.position={x:exit.x-1,y:exit.y};const direction=exit.x>internal.players.u1.position.x?'right':'left';expect(game.command('u1',true,envelope(game,{type:'move',direction},'exit')).accepted).toBe(true);expect(game.snapshot().floorNumber).toBe(2);expect(game.command('u1',true,{...old,requestId:'old2'}).reason).toBe('STALE_FLOOR')}) }) diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index eedb053..18e20fe 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -9,7 +9,7 @@ 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(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 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 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)})