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.
|
- [ ] 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
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
|||||||
Reference in New Issue
Block a user