Return defeated parties to dormant state

This commit is contained in:
2026-08-17 20:49:04 -07:00
parent e24b1b4a3f
commit db19a89957
10 changed files with 67 additions and 66 deletions
@@ -231,14 +231,14 @@ target death, return, death, and escape with the Goblin alive.
- [ ] Generate the next floor and Goblin. - [ ] Generate the next floor and Goblin.
- [ ] Revive, heal, reposition, and refresh self-heal for all participants on - [ ] Revive, heal, reposition, and refresh self-heal for all participants on
floor advancement. floor advancement.
- [ ] Reset to a new Floor 1 run immediately after the last participant dies. - [ ] Return the current floor to dormancy immediately after the last participant dies.
- [ ] Distinguish zero participants from a total-party wipe. - [ ] Distinguish initial dormancy from defeated-party dormancy.
- [ ] Preserve character identity and state across simulated client reconnects. - [ ] Preserve character identity and state across simulated client reconnects.
- [ ] Verify expected new-run behavior after a simulated backend restart. - [ ] Verify expected new-run behavior after a simulated backend restart.
- [ ] Run a deterministic multi-floor soak test. - [ ] Run a deterministic multi-floor soak test.
**Checkpoint P6:** A local driver can repeat the entire gameplay loop through **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 ### 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. - [ ] 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 - [ ] 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 does not act, and the exact two-line `Activate the Extension to spawn your character in the Twungeon!`
shown. and `Must be a follower to spawn.` banner is shown.
**Level:** Automated domain/integration plus visual review **Level:** Automated domain/integration plus visual review
**Pass evidence:** State assertions, elapsed-time logs, and screenshot. **Pass evidence:** State assertions, elapsed-time logs, and screenshot.
@@ -603,25 +603,25 @@ successful connection logs.
**Level:** Automated integration plus live Channel Points redemption **Level:** Automated integration plus live Channel Points redemption
**Pass evidence:** Redacted event, deduplication log, and before/after state. **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 **PRD criterion:** 26
- [ ] Kill the final living participant before exit. - [ ] Kill the final living participant before exit.
- [ ] Confirm an immediate new run on Floor 1, new floor/Goblin, every participant - [ ] Confirm immediate dormancy on the current run and floor, no active timer,
alive at full HP, refreshed heals, and one wipe/reset log sequence. 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 **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 **PRD criterion:** 27
- [ ] Compare a new game with no participants to a run where the final player - [ ] Compare a new game with no participants to a run where the final player dies.
dies. - [ ] Confirm both are dormant, while only the defeated run preserves dead
- [ ] Confirm the first remains dormant on its current floor and the second participant records and a party-defeat log entry.
resets immediately with participants restored.
**Level:** Automated domain/integration **Level:** Automated domain/integration
**Pass evidence:** Side-by-side state-transition assertions. **Pass evidence:** Side-by-side state-transition assertions.
@@ -655,7 +655,7 @@ successful connection logs.
**PRD criterion:** 30 **PRD criterion:** 30
- [ ] Run a multi-floor soak containing spawns, concurrent actions, expiry, - [ ] 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 - [ ] Confirm integration, phase, player, Goblin, generator, and log invariants
remain valid throughout. remain valid throughout.
+16 -16
View File
@@ -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: 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. 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, - resurrect through a configured Channel Points reward,
- advance the entire party by reaching the exit, - advance the entire party by reaching the exit,
- revive dead players on floor advancement, - 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. 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, - Goblin aggro or return behavior when useful,
- floor advancement, - floor advancement,
- dormant-state changes, - dormant-state changes,
- and total-party wipe/run reset. - and total-party defeat/dormancy.
Example messages include: 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. 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. 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: 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. 1. The current run, floor, and Goblin remain loaded.
2. A new starting floor is generated. 2. All defeated characters remain dead and retain their current-floor death markers.
3. All participating players return alive. 3. No phase timer runs and the Goblin does not act.
4. Player HP returns to full. 4. A configured resurrection can revive a dead viewer and begin a fresh Player Phase.
5. Player self-heals refresh. 5. A different eligible viewer may spawn and begin a fresh Player Phase.
6. A new Goblin Guard is generated.
--- ---
@@ -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. 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. 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. 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`. 8. A Twitch follower can intentionally join with `!spawn`.
9. A non-follower cannot spawn a player character. 9. A non-follower cannot spawn a player character.
10. Duplicate spawning for the same Twitch user is prevented. 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. 23. Dead players return when the floor advances.
24. A player who died on the current floor cannot bypass death by using `!spawn`. 24. A player who died on the current floor cannot bypass death by using `!spawn`.
25. The configured Channel Points reward can resurrect the correct dead player during the current floor. 25. The configured Channel Points reward can resurrect the correct dead player during the current floor.
26. A total-party wipe resets the run to Floor 1. 26. A total-party defeat returns the current floor to dormancy without reviving players or resetting the run.
27. Zero active players is distinguished from a total-party wipe. 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. 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. 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. 30. The complete loop can repeat across multiple floors without breaking Twitch integration, turns, player state, Goblin state, floor generation, or the action log.
+23 -25
View File
@@ -49,7 +49,7 @@ The implementation must optimize for the following goals, in order:
- One character per Twitch user - One character per Twitch user
- Twitch Extension controls - Twitch Extension controls
- Shared stream-facing game view and chronological action log - 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 - AP, movement, combat, AutoGuard, self-heal, death, and resurrection
- One Goblin Guard with guarding, pursuit, and return behavior - One Goblin Guard with guarding, pursuit, and return behavior
- Varied, navigable two-room floors - Varied, navigable two-room floors
@@ -276,7 +276,7 @@ type PhaseState =
initialEligiblePlayerIds: string[] initialEligiblePlayerIds: string[]
} }
| { kind: 'enemy'; phaseId: 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 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 The first accepted `!spawn` creates a living character, removes the banner, and
starts a fresh Player Phase. starts a fresh Player Phase.
The MVP has no voluntary leave or character-removal command. Temporary Temporary disconnects do not create dormancy because disconnected living
disconnects therefore do not create dormancy. A total-party death triggers a characters use AutoGuard. A total-party death does create dormancy while
run reset rather than dormancy. preserving the run, floor, Goblin, and dead character records.
### 9.2 Starting Player Phase ### 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. until it has no AP or no valid action.
After the Goblin finishes, Guard is discarded and a new Player Phase begins, 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 ### 9.5 AutoGuard
@@ -633,25 +633,21 @@ When a living player enters the exit:
The previous Goblin and map are discarded. 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 After any death resolution, if participating characters exist and none is
alive, the backend immediately: alive, the backend immediately:
1. enters transition state; 1. enters Dormant state with no deadline;
2. creates a new run ID; 2. preserves the current run ID, floor, and Goblin;
3. sets the floor counter to 1; 3. preserves each dead character and current-floor death marker;
4. generates a new starting floor and Goblin; 4. stops Enemy Phase processing immediately; and
5. restores all participating players alive at 3 HP; 5. logs the party defeat.
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.
Because the reset is immediate, a Channel Points redemption cannot interrupt a A valid Channel Points resurrection revives its owner and starts a fresh Player
completed total-party wipe. Zero participating characters is explicitly not a Phase from dormancy. A newly eligible viewer may also spawn into the current
wipe. floor and resume play. Initial dormancy has no participants, while defeated-party
dormancy retains dead participant records.
## 13. Randomness ## 13. Randomness
@@ -771,7 +767,9 @@ starting an empty client-only log.
When phase is dormant, both applicable views prominently display exactly: 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 ## 16. Failure and Recovery Behavior
@@ -879,10 +877,10 @@ Deterministic tests must cover:
- AutoGuard conversion, hit blocking, misses, and reset; - AutoGuard conversion, hit blocking, misses, and reset;
- player and Goblin hit probabilities through injected rolls; - player and Goblin hit probabilities through injected rolls;
- heal availability and floor refresh; - 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; - Goblin guarding, aggro on hit or miss, pursuit, return, and death;
- exit entry with a living Goblin; - 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 - serial ordering and stale/duplicate command handling; and
- deterministic floor generation and connectivity validation. - 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; 7. Goblin aggro, pursuit, attack, target death, and return;
8. Channel Points resurrection of the correct dead viewer; 8. Channel Points resurrection of the correct dead viewer;
9. floor escape with the Goblin alive and dead-player revival; 9. floor escape with the Goblin alive 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 11. refresh and temporary disconnect recovery; and
12. repetition across multiple generated floors. 12. repetition across multiple generated floors.
@@ -941,7 +939,7 @@ slices:
2. Implement the deterministic floor generator and static stream rendering. 2. Implement the deterministic floor generator and static stream rendering.
3. Implement player/Goblin state, commands, turn phases, timer, and action log 3. Implement player/Goblin state, commands, turn phases, timer, and action log
using local test drivers. 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. dormancy, reconnect, and idempotency.
5. Add the RPGJS adapter without moving rules out of the domain package. 5. Add the RPGJS adapter without moving rules out of the domain package.
6. Add Twurple chat and follower verification for `!spawn`. 6. Add Twurple chat and follower verification for `!spawn`.
+1 -1
View File
@@ -5,6 +5,6 @@
<section id="controller"><h2>Controller</h2><button id="spawnExtension" hidden>Spawn character</button><div class="dpad"><button data-command="up" aria-label="Move up">▲</button><button data-command="left" aria-label="Move left">◀</button><button data-command="down" aria-label="Move down">▼</button><button data-command="right" aria-label="Move right">▶</button></div><div class="actions"><button data-command="attack">Attack</button><button data-command="heal">Heal</button><button data-command="pass">Pass</button></div><button id="shareIdentity" hidden>Share Twitch identity</button><p id="disabled" role="status"></p></section> <section id="controller"><h2>Controller</h2><button id="spawnExtension" hidden>Spawn character</button><div class="dpad"><button data-command="up" aria-label="Move up">▲</button><button data-command="left" aria-label="Move left">◀</button><button data-command="down" aria-label="Move down">▼</button><button data-command="right" aria-label="Move right">▶</button></div><div class="actions"><button data-command="attack">Attack</button><button data-command="heal">Heal</button><button data-command="pass">Pass</button></div><button id="shareIdentity" hidden>Share Twitch identity</button><p id="disabled" role="status"></p></section>
<details><summary>Local viewer login</summary><label>User ID <input id="userId" value="viewer-1"></label><label>Name <input id="displayName" value="Viewer One"></label><button id="spawn">Spawn & bind</button><p><a href="/privacy.html" target="_blank" rel="noopener">Privacy notice</a></p></details> <details><summary>Local viewer login</summary><label>User ID <input id="userId" value="viewer-1"></label><label>Name <input id="displayName" value="Viewer One"></label><button id="spawn">Spawn & bind</button><p><a href="/privacy.html" target="_blank" rel="noopener">Privacy notice</a></p></details>
</aside> </aside>
<section class="panel game"><div class="game-head"><div><p class="eyebrow">Shared dungeon</p><h2 id="floor">Floor 1</h2></div><div id="phase" class="phase"></div></div><div id="banner" hidden>Type !spawn to spawn in the Twungeon!</div><div id="map" aria-label="Dungeon map"></div><div class="legend"><span>● Adventurer</span><span>◆ Goblin</span><span>▣ Exit</span></div></section> <section class="panel game"><div class="game-head"><div><p class="eyebrow">Shared dungeon</p><h2 id="floor">Floor 1</h2></div><div id="phase" class="phase"></div></div><div id="banner" hidden>Activate the Extension to spawn your character in the Twungeon!<span class="banner-note">Must be a follower to spawn.</span></div><div id="map" aria-label="Dungeon map"></div><div class="legend"><span>● Adventurer</span><span>◆ Goblin</span><span>▣ Exit</span></div></section>
<section class="panel log"><div class="log-head"><div><p class="eyebrow">Chronicle</p><h2>Action log</h2></div><span id="connection">Connecting…</span></div><ol id="log"></ol></section> <section class="panel log"><div class="log-head"><div><p class="eyebrow">Chronicle</p><h2>Action log</h2></div><span id="connection">Connecting…</span></div><ol id="log"></ol></section>
</main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js?v=20260817-overlay"></script></body></html> </main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js?v=20260817-overlay"></script></body></html>
+1
View File
@@ -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; } .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; } .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 { 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; } #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 { 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; } .tile.wall { background: var(--wall); box-shadow: inset 0 0 0 2px #1b1f21; }
+1 -1
View File
@@ -77,7 +77,7 @@ non-follower account. Capture redacted evidence for:
- independent commands from two viewers; - independent commands from two viewers;
- phase expiry and AutoGuard while one viewer is disconnected; - phase expiry and AutoGuard while one viewer is disconnected;
- death and a configured Channel Points reward redemption from the same user; - death and a configured Channel Points reward redemption from the same user;
- escape with the Goblin alive, floor revival, and a total-party wipe; - escape with the Goblin alive, floor revival, and total-party defeat dormancy;
- refresh/reconnect during a phase and transition; and - refresh/reconnect during a phase and transition; and
- repeated floors and a short soak session. - repeated floors and a short soak session.
+7 -6
View File
@@ -61,7 +61,7 @@ export class Game {
return true return true
} }
forceEndPlayerPhase():boolean {if(this.state.phase.kind!=='player')return false;this.endPlayerPhase();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 { spawn(message: SpawnMessage): SpawnResult {
if(this.externalEvents.has(message.externalEventId)) return {accepted:false,reason:'DUPLICATE',message:'That spawn request was already handled.'} 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.'} 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]!) 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!`) 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.`} return {accepted:true,reason:null,message:`${p.displayName} resurrected.`}
} }
@@ -180,7 +181,7 @@ export class Game {
if(adjacent(g.position,target.position)){ if(adjacent(g.position,target.position)){
const hit=this.deps.random.next()<0.5 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.`)} 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 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 { 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'){ } 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 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.`) 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} 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 { private advanceFloor(): void {
this.state.phase={kind:'transition',reason:'floor-advance'};this.state.floorNumber++ this.state.phase={kind:'transition',reason:'floor-advance'};this.state.floorNumber++
const floor=this.deps.generateFloor(`${this.state.runId}-floor-${this.state.floorNumber}`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor) const floor=this.deps.generateFloor(`${this.state.runId}-floor-${this.state.floorNumber}`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor)
this.restorePlayers();this.log('floor-advanced',null,null,`The party reaches Floor ${this.state.floorNumber}!`);this.startPlayerPhase() this.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 { private resetRun(): void {
this.state.phase={kind:'transition',reason:'party-wipe'};this.state.runId=this.deps.ids.next('run');this.state.floorNumber=1 this.state.phase={kind:'transition',reason:'admin-reset'};this.state.runId=this.deps.ids.next('run');this.state.floorNumber=1
const floor=this.deps.generateFloor(`${this.state.runId}-floor-1`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor) 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 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 { private nextPathStep(start:TilePosition,goal:TilePosition):TilePosition|null {
+1 -1
View File
@@ -21,7 +21,7 @@ export type PhaseState = { kind: 'dormant' } | {
kind: 'player'; phaseId: string; startedAt: number; deadlineAt: number kind: 'player'; phaseId: string; startedAt: number; deadlineAt: number
initialEligiblePlayerIds: string[] initialEligiblePlayerIds: string[]
} | { kind: 'enemy'; phaseId: string } | { } | { kind: 'enemy'; phaseId: string } | {
kind: 'transition'; reason: 'floor-advance' | 'party-wipe' kind: 'transition'; reason: 'floor-advance' | 'admin-reset'
} }
export interface ActionLogEntry { export interface ActionLogEntry {
sequence: number; runId: string; floorNumber: number; phaseId: string | null sequence: number; runId: string; floorNumber: number; phaseId: string | null
+1
View File
@@ -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 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-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('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('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')})
}) })
+1 -1
View File
@@ -9,7 +9,7 @@ async function post(path:string,value:unknown,token?:string){return fetch(`${bas
describe('backend boundary',()=>{ describe('backend boundary',()=>{
it('AT-001 exposes a secret-free readiness response',async()=>{const data=await fetch(`${base}/health`).then(r=>r.json());expect(data).toMatchObject({status:'ok',ready:true,twitchMode:'synthetic'});expect(JSON.stringify(data)).not.toMatch(/secret|token/i)}) it('AT-001 exposes a secret-free readiness response',async()=>{const data=await fetch(`${base}/health`).then(r=>r.json());expect(data).toMatchObject({status:'ok',ready:true,twitchMode:'synthetic'});expect(JSON.stringify(data)).not.toMatch(/secret|token/i)})
it('reports OAuth readiness without exposing credentials',async()=>{const data=await fetch(`${base}/oauth/status`).then(r=>r.json());expect(data).toMatchObject({configured:false,authorized:false});expect(JSON.stringify(data)).not.toMatch(/clientId|clientSecret|accessToken|refreshToken/)}) it('reports OAuth readiness without exposing credentials',async()=>{const data=await fetch(`${base}/oauth/status`).then(r=>r.json());expect(data).toMatchObject({configured:false,authorized:false});expect(JSON.stringify(data)).not.toMatch(/clientId|clientSecret|accessToken|refreshToken/)})
it('serves a transparent responsive Twitch overlay controller',async()=>{const [pageResponse,rootResponse,scriptResponse,styleResponse]=await Promise.all([fetch(`${base}/extension`),fetch(`${base}/`),fetch(`${base}/app.js?v=20260817-overlay`),fetch(`${base}/styles.css`)]),[html,rootHtml,script,styles]=await Promise.all([pageResponse.text(),rootResponse.text(),scriptResponse.text(),styleResponse.text()]);expect(pageResponse.headers.get('cache-control')).toBe('no-store');expect(scriptResponse.headers.get('cache-control')).toBe('no-store');expect(styleResponse.headers.get('cache-control')).toBe('no-store');expect(html).toContain('<html lang="en" class="extension-mode">');expect(rootHtml).not.toContain('<html lang="en" class="extension-mode">');expect(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('<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 a public privacy notice for identity linking',async()=>{const response=await fetch(`${base}/privacy.html`),html=await response.text();expect(response.status).toBe(200);expect(html).toContain('Twungeon Privacy Notice');expect(html).toContain('numeric Twitch user ID')}) it('serves a public privacy notice for identity linking',async()=>{const response=await fetch(`${base}/privacy.html`),html=await response.text();expect(response.status).toBe(200);expect(html).toContain('Twungeon Privacy Notice');expect(html).toContain('numeric Twitch user ID')})
it('AT-005 establishes a session without creating a character',async()=>{const r=await post('/api/extension/session',{token:'dev:nobody:Nobody'});expect(r.status).toBe(200);const data=await r.json();expect(data.state.viewer).toBeNull()}) it('AT-005 establishes a session without creating a character',async()=>{const r=await post('/api/extension/session',{token:'dev:nobody:Nobody'});expect(r.status).toBe(200);const data=await r.json();expect(data.state.viewer).toBeNull()})
it('AT-011 binds the same stable chat and Extension identity',async()=>{const id=`viewer-${Date.now()}`;expect((await post('/api/dev/spawn',{command:'!spawn',externalEventId:`e-${id}`,twitchUserId:id,displayName:'Viewer',followerVerified:true})).status).toBe(200);const auth=await (await post('/api/extension/session',{token:`dev:${id}:Viewer`})).json();expect(auth.twitchUserId).toBe(id);expect(auth.state.viewer.extensionBound).toBe(true);const s=auth.state,phase=s.phase;expect(phase.kind).toBe('player');const command=await post('/api/commands',{requestId:`r-${id}`,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}},auth.token);expect(command.status).toBe(200)}) it('AT-011 binds the same stable chat and Extension identity',async()=>{const id=`viewer-${Date.now()}`;expect((await post('/api/dev/spawn',{command:'!spawn',externalEventId:`e-${id}`,twitchUserId:id,displayName:'Viewer',followerVerified:true})).status).toBe(200);const auth=await (await post('/api/extension/session',{token:`dev:${id}:Viewer`})).json();expect(auth.twitchUserId).toBe(id);expect(auth.state.viewer.extensionBound).toBe(true);const s=auth.state,phase=s.phase;expect(phase.kind).toBe('player');const command=await post('/api/commands',{requestId:`r-${id}`,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}},auth.token);expect(command.status).toBe(200)})