Return defeated parties to dormant state
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+16
-16
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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>
|
||||
<details><summary>Local viewer login</summary><label>User ID <input id="userId" value="viewer-1"></label><label>Name <input id="displayName" value="Viewer One"></label><button id="spawn">Spawn & bind</button><p><a href="/privacy.html" target="_blank" rel="noopener">Privacy notice</a></p></details>
|
||||
</aside>
|
||||
<section class="panel game"><div class="game-head"><div><p class="eyebrow">Shared dungeon</p><h2 id="floor">Floor 1</h2></div><div id="phase" class="phase"></div></div><div id="banner" hidden>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>
|
||||
</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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')})
|
||||
})
|
||||
|
||||
@@ -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('<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('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)})
|
||||
|
||||
Reference in New Issue
Block a user