Compare commits
2
Commits
db19a89957
...
d3dd64291e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3dd64291e | ||
|
|
879cfd4fd0 |
@@ -0,0 +1,47 @@
|
||||
_model: devlog-entry
|
||||
---
|
||||
schema_version: 1
|
||||
---
|
||||
title: Four-player parties reform each floor with rolling turns
|
||||
---
|
||||
date: 2026-08-17
|
||||
---
|
||||
author: Codex and Christopher Chambers
|
||||
---
|
||||
summary: Twungeon now enforces four fixed party slots, reforms the party between floors, uses a five-second rolling player-phase timer, and presents consistent player colors in responsive desktop and mobile Extension layouts.
|
||||
---
|
||||
tags: implementation, multiplayer, party system, turn timer, Twitch Extension, mobile, responsive UI, testing
|
||||
---
|
||||
source_commit: 879cfd4fd071a240b79c25b5054a195578855130
|
||||
---
|
||||
body:
|
||||
|
||||
Twungeon's party and turn rules have been tightened around a predictable
|
||||
four-player multiplayer loop. Each character now occupies one of four fixed
|
||||
slots with a stable color: blue, green, red, or yellow. A fifth spawn is
|
||||
rejected as party-full, and defeated characters continue to hold their slot
|
||||
until the floor concludes.
|
||||
|
||||
Completing a floor now clears the active party and returns the game to its
|
||||
dormant state with the next dungeon ready. Connected and authenticated viewers
|
||||
remain eligible to spawn again, so every floor begins with a fresh, first-come
|
||||
party formation instead of carrying character state forward.
|
||||
|
||||
The player phase now runs on a rolling five-second deadline. The first accepted
|
||||
command from each active player restarts that deadline from the command's
|
||||
arrival time. Repeat commands, rejected actions, and duplicate submissions do
|
||||
not extend the phase, keeping turns responsive without allowing one player to
|
||||
stall the game indefinitely.
|
||||
|
||||
The Twitch Extension now uses the same slot colors across dungeon markers,
|
||||
names, party status, action feedback, and administrative views. Its single
|
||||
frontend adapts to desktop and mobile contexts: mobile viewers receive larger
|
||||
movement and action controls plus an at-a-glance identity, color, health,
|
||||
action-point, and healing display.
|
||||
|
||||
Documentation and acceptance coverage were updated alongside the mechanics.
|
||||
The completed change passed the domain and integration suites, linting, type
|
||||
checking, production build, and desktop and mobile browser layout checks.
|
||||
|
||||
The implementation is recorded in
|
||||
[commit `879cfd4fd071a240b79c25b5054a195578855130`](https://git.labyricorn.com/Labyricorn/Twungeon/commit/879cfd4fd071a240b79c25b5054a195578855130).
|
||||
@@ -13,6 +13,13 @@ and a Twurple production boundary for chat, followers, Channel Points, and
|
||||
Extension identity. Live Twitch acceptance still requires operator credentials, an
|
||||
approved Extension configuration, and real-viewer evidence.
|
||||
|
||||
Each floor has four first-come, first-served party slots with fixed Blue, Green,
|
||||
Red, and Yellow identities. Reaching the exit clears the party and opens the
|
||||
next floor for a complete reparty. Player phases use a rolling five-second
|
||||
deadline that each living player may reset once with their first accepted
|
||||
action. The same Twitch Extension provides compact desktop controls and a
|
||||
touch-oriented mobile layout backed by the same authoritative game instance.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements: Node.js 22 or newer and npm.
|
||||
|
||||
@@ -166,6 +166,9 @@ events without importing Twitch, RPGJS, HTTP, WebSocket, or browser code.
|
||||
- [ ] Implement the broadcast-mode three-section layout.
|
||||
- [ ] Render players, Goblin, walls, paths, exit, shared status, and action log
|
||||
with temporary but distinguishable visuals.
|
||||
- [ ] Render deterministic Blue, Green, Red, and Yellow party-slot markers and names.
|
||||
- [ ] Provide desktop and mobile Extension layouts with mobile controls at least
|
||||
44 pixels and essential viewer-specific status kept visible.
|
||||
- [ ] Render the exact dormant banner text.
|
||||
|
||||
**Checkpoint P3:** A local read-only stream view can cycle through many valid,
|
||||
@@ -177,7 +180,8 @@ visibly varied floors without a Twitch connection.
|
||||
|
||||
- [ ] Implement dormant, Player Phase, Enemy Phase, and transition states.
|
||||
- [ ] Start Player Phase with 2 AP and zero Guard for each living participant.
|
||||
- [ ] Implement 25 seconds per living player with a 120-second cap.
|
||||
- [ ] Implement a rolling five-second deadline with one reset per living
|
||||
player's first accepted command in each phase.
|
||||
- [ ] End Player Phase early when its initial eligible set is finished or dead.
|
||||
- [ ] Decide deadline acceptance using gateway arrival before the monotonic
|
||||
deadline; reject arrivals at or after the deadline.
|
||||
@@ -229,8 +233,9 @@ target death, return, death, and escape with the Goblin alive.
|
||||
- [ ] Advance immediately when a living player enters the exit.
|
||||
- [ ] Reject queued commands for the previous floor.
|
||||
- [ ] Generate the next floor and Goblin.
|
||||
- [ ] Revive, heal, reposition, and refresh self-heal for all participants on
|
||||
floor advancement.
|
||||
- [ ] Clear all previous party records and enter dormancy on floor advancement.
|
||||
- [ ] Reassign all four slots and colors first come, first served with no
|
||||
previous-player reservation.
|
||||
- [ ] 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.
|
||||
@@ -415,6 +420,8 @@ successful connection logs.
|
||||
|
||||
- [ ] As a verified follower with no character or same-floor death, issue
|
||||
`!spawn` and confirm one character appears in the current spawn room.
|
||||
- [ ] Fill all four party slots, confirm the fifth eligible viewer receives a
|
||||
clear `PARTY_FULL` result, and confirm a dead member still occupies a slot.
|
||||
|
||||
**Level:** Live integration
|
||||
**Pass evidence:** Chat capture, follower result, spawn event, and stream capture.
|
||||
@@ -492,12 +499,14 @@ successful connection logs.
|
||||
**Level:** Automated domain/integration
|
||||
**Pass evidence:** State-transition assertions and ordered event log.
|
||||
|
||||
### AT-016 — Scaled timer and cap
|
||||
### AT-016 — Rolling five-second timer
|
||||
|
||||
**PRD criterion:** 16
|
||||
|
||||
- [ ] Verify durations of 25, 50, 75, 100, and 120 seconds for one through five
|
||||
living players and 120 seconds for more than five.
|
||||
- [ ] Confirm phase start sets a five-second deadline.
|
||||
- [ ] Confirm Player A's first accepted command resets it to five seconds,
|
||||
Player A's second does not, and Player B's first accepted command does.
|
||||
- [ ] Confirm rejected and duplicate commands do not reset the deadline.
|
||||
|
||||
**Level:** Automated fake-clock domain test plus one live timing check
|
||||
**Pass evidence:** Parameterized test output and live timer recording.
|
||||
@@ -564,17 +573,21 @@ successful connection logs.
|
||||
**PRD criterion:** 22
|
||||
|
||||
- [ ] Aggro but do not kill the Goblin, move a living player onto the exit, and
|
||||
confirm immediate group advancement and a new Goblin on the next floor.
|
||||
confirm immediate floor advancement, a new Goblin, an empty party, and
|
||||
Dormant state on the next floor.
|
||||
|
||||
**Level:** Automated E2E plus live scenario
|
||||
**Pass evidence:** Before/after snapshots and stream recording.
|
||||
|
||||
### AT-023 — Dead players revive on advancement
|
||||
### AT-023 — Full next-floor reparty
|
||||
|
||||
**PRD criterion:** 23
|
||||
|
||||
- [ ] Kill one player, advance with another, and confirm the dead player returns
|
||||
at spawn with full HP and refreshed self-heal on the next floor.
|
||||
- [ ] Fill four slots, kill one player, and advance with another.
|
||||
- [ ] Confirm every previous character is removed, all four slots are open, and
|
||||
no previous player has a reservation.
|
||||
- [ ] Spawn a new viewer before a previous party member and confirm they receive
|
||||
Slot 1/Blue while the previous member receives the next available slot/color.
|
||||
|
||||
**Level:** Automated E2E plus live scenario
|
||||
**Pass evidence:** Before/after player and floor state.
|
||||
|
||||
+41
-34
@@ -37,7 +37,7 @@ The MVP should demonstrate that multiple Twitch viewers can:
|
||||
- die and remain inactive for the current floor,
|
||||
- resurrect through a configured Channel Points reward,
|
||||
- advance the entire party by reaching the exit,
|
||||
- revive dead players on floor advancement,
|
||||
- reform the party from scratch on floor advancement,
|
||||
- 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.
|
||||
@@ -74,13 +74,18 @@ When a valid `!spawn` command is received:
|
||||
2. The backend verifies that the viewer follows the channel.
|
||||
3. The backend verifies that the viewer does not already have an active character.
|
||||
4. The backend verifies that the viewer has not died on the current floor.
|
||||
5. A character is created in the current floor's spawn room.
|
||||
6. The game records the Twitch user ID as the authoritative identity for that character.
|
||||
7. The player's Twitch Extension identity must resolve to the same Twitch user ID before game controls are enabled.
|
||||
8. The player's Twitch Extension game controls become active.
|
||||
5. The backend verifies that one of four party slots remains available.
|
||||
6. A character is created in the current floor's spawn room and assigned the lowest available party slot.
|
||||
7. The game records the Twitch user ID as the authoritative identity for that character.
|
||||
8. The player's Twitch Extension identity must resolve to the same Twitch user ID before game controls are enabled.
|
||||
9. The player's Twitch Extension game controls become active.
|
||||
|
||||
Only one active character may exist per Twitch user.
|
||||
|
||||
Each floor has exactly four first-come, first-served party slots. Dead players
|
||||
continue occupying their slot until the floor ends. A fifth eligible viewer
|
||||
remains a spectator and receives a clear party-full result.
|
||||
|
||||
A follower who has died on the current floor cannot use `!spawn` to bypass death. That player must wait for either a Channel Points resurrection or advancement to the next floor.
|
||||
|
||||
### 3.4 Twitch Identity Binding
|
||||
@@ -118,6 +123,12 @@ The shared Twitch stream is the primary game display.
|
||||
|
||||
Players do not require a separate conventional game client.
|
||||
|
||||
The same Extension adapts to desktop and mobile without creating another game
|
||||
instance. Desktop retains the information-rich controller. Mobile reflows the
|
||||
controller around touch targets of approximately 44 pixels or larger and keeps
|
||||
identity/color, HP, AP, and heal availability visible without shrinking the
|
||||
desktop layout proportionally.
|
||||
|
||||
### 4.1 Three-Section MVP Layout
|
||||
|
||||
The MVP game view must establish the intended structure of the final Twungeon experience even if the graphics and styling remain temporary.
|
||||
@@ -252,21 +263,11 @@ Players do not need to act in a fixed sequential order. Valid player commands ma
|
||||
|
||||
### 7.1 Player Phase Timer
|
||||
|
||||
The Player Phase timer scales with the number of living players:
|
||||
|
||||
**25 seconds per living player**
|
||||
|
||||
The timer is capped at:
|
||||
|
||||
**120 seconds maximum**
|
||||
|
||||
Examples:
|
||||
|
||||
- 1 living player: 25 seconds
|
||||
- 2 living players: 50 seconds
|
||||
- 3 living players: 75 seconds
|
||||
- 4 living players: 100 seconds
|
||||
- 5 or more living players: 120 seconds
|
||||
The Player Phase uses a rolling **5-second** timer. It begins at five seconds.
|
||||
Each living player's first accepted command during that phase resets the
|
||||
deadline to five seconds after that command arrived. Further commands from the
|
||||
same player do not reset it again. A different player's first accepted command
|
||||
may reset it once, so one player cannot extend the phase indefinitely.
|
||||
|
||||
The Player Phase ends when either:
|
||||
|
||||
@@ -464,12 +465,16 @@ When any living player enters the exit:
|
||||
|
||||
1. The current floor ends immediately.
|
||||
2. The floor counter increases.
|
||||
3. A new two-room floor is generated.
|
||||
4. A new Goblin Guard is created.
|
||||
5. All participating players are placed in the new spawn room.
|
||||
6. All players are restored to full HP.
|
||||
7. All dead players are resurrected.
|
||||
8. Every player's self-heal is refreshed.
|
||||
3. Every previous party slot and character is cleared, alive or dead.
|
||||
4. A new two-room floor is generated.
|
||||
5. A new Goblin Guard is created.
|
||||
6. The new floor enters Dormant state.
|
||||
7. Eligible followers claim the four new party slots first come, first served.
|
||||
|
||||
Previous party members receive no reservation and must spawn again. Twitch
|
||||
identity and Extension sessions may persist for authentication, but character
|
||||
HP, AP, Guard, heal use, death state, position, slot, and color do not carry
|
||||
into the new party.
|
||||
|
||||
The dungeon may continue generating floors indefinitely for the MVP.
|
||||
|
||||
@@ -488,10 +493,9 @@ While dead:
|
||||
|
||||
There is no timed automatic resurrection.
|
||||
|
||||
A dead player returns to play only through:
|
||||
|
||||
1. immediate Channel Points resurrection, or
|
||||
2. another living player reaching the next floor.
|
||||
A dead player returns during the current floor only through immediate Channel
|
||||
Points resurrection. When another player reaches the exit, the entire party is
|
||||
cleared; that viewer may compete to spawn a fresh character on the next floor.
|
||||
|
||||
---
|
||||
|
||||
@@ -523,7 +527,9 @@ On defeat:
|
||||
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.
|
||||
5. If fewer than four party slots were claimed, a different eligible viewer may
|
||||
claim an open slot and begin a fresh Player Phase; a defeated full party
|
||||
requires resurrection to resume the current floor.
|
||||
|
||||
---
|
||||
|
||||
@@ -537,6 +543,7 @@ The MVP must maintain enough authoritative player state to support:
|
||||
- verified chat-to-Extension identity binding,
|
||||
- follower eligibility,
|
||||
- one character per Twitch user,
|
||||
- current-floor party slot and its fixed Blue, Green, Red, or Yellow color,
|
||||
- current HP,
|
||||
- alive/dead state,
|
||||
- current AP,
|
||||
@@ -664,14 +671,14 @@ The MVP is successful if it demonstrates that:
|
||||
13. Multiple viewers can control separate characters in the same dungeon.
|
||||
14. Twitch Extension commands reliably control movement and actions.
|
||||
15. The Player Phase / Enemy Phase cycle functions correctly.
|
||||
16. The Player Phase timer scales at 25 seconds per living player and caps at 120 seconds.
|
||||
16. The Player Phase uses a rolling 5-second timer that each living player can reset only once with their first accepted command.
|
||||
17. The Player Phase ends early when all living players finish.
|
||||
18. AP, AutoGuard, attacks, healing, HP, and death interact correctly.
|
||||
19. The action log accurately reports significant game actions and state changes.
|
||||
20. The Goblin can guard, aggro, pursue, attack, return, and remain behind on floor transition.
|
||||
21. Two-room floors can be generated repeatedly with varying room sizes, positions, and navigable connections.
|
||||
22. A surviving player can reach the exit without necessarily killing the Goblin and advance the entire group.
|
||||
23. Dead players return when the floor advances.
|
||||
22. A surviving player can reach the exit without necessarily killing the Goblin, clear the current party, and open the next floor for a new party.
|
||||
23. Each floor admits at most four party members, assigns deterministic slot colors, and repartees first come, first served without reservations.
|
||||
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 defeat returns the current floor to dormancy without reviving players or resetting the run.
|
||||
|
||||
@@ -246,6 +246,8 @@ interface PlayerState {
|
||||
followerVerified: boolean
|
||||
characterCreated: boolean
|
||||
extensionBound: boolean
|
||||
partySlot: 1 | 2 | 3 | 4
|
||||
color: 'blue' | 'green' | 'red' | 'yellow'
|
||||
connectionState: 'connected' | 'disconnected'
|
||||
position: TilePosition
|
||||
hp: 0 | 1 | 2 | 3
|
||||
@@ -274,6 +276,7 @@ type PhaseState =
|
||||
startedAt: number
|
||||
deadlineAt: number
|
||||
initialEligiblePlayerIds: string[]
|
||||
timerResetPlayerIds: string[]
|
||||
}
|
||||
| { kind: 'enemy'; phaseId: string }
|
||||
| { kind: 'transition'; reason: 'floor-advance' | 'admin-reset' }
|
||||
@@ -352,6 +355,12 @@ New characters spawn on the first spawn tile chosen by deterministic ordering.
|
||||
Players may share that tile. A late join never changes the current phase timer
|
||||
or the set of players required to finish the phase.
|
||||
|
||||
The authoritative player record is also the current-floor party roster. At
|
||||
most four records may exist. Spawn assigns the lowest open slot and its fixed
|
||||
color: Slot 1 Blue, Slot 2 Green, Slot 3 Red, and Slot 4 Yellow. Dead records
|
||||
remain in the roster and occupy their slots. A fifth spawn is rejected as
|
||||
`PARTY_FULL` without creating a character.
|
||||
|
||||
### 7.3 Attack range and targeting
|
||||
|
||||
- Player and Goblin attacks target an orthogonally adjacent tile.
|
||||
@@ -451,14 +460,12 @@ For every living participant present when the phase starts:
|
||||
3. set `eligibleThisPhase` to true; and
|
||||
4. include the player in `initialEligiblePlayerIds`.
|
||||
|
||||
The duration is:
|
||||
|
||||
```text
|
||||
min(living-player-count * 25 seconds, 120 seconds)
|
||||
```
|
||||
|
||||
The count includes disconnected living characters, matching the PRD's AFK
|
||||
AutoGuard behavior.
|
||||
The initial deadline is five seconds after phase start. The phase begins with
|
||||
an empty `timerResetPlayerIds` list. After each player's first accepted command,
|
||||
the backend adds that Twitch user ID and moves `deadlineAt` to five seconds
|
||||
after the gateway arrival time. Later commands from that player do not move the
|
||||
deadline. Rejected, duplicate, disconnected, and AutoGuard events do not reset
|
||||
the timer.
|
||||
|
||||
Players who spawn or resurrect after the phase starts receive 0 AP, are not
|
||||
added to the phase completion set, and may act beginning with the next Player
|
||||
@@ -506,7 +513,8 @@ An accepted spawn requires:
|
||||
- a stable Twitch chat user ID;
|
||||
- verified follower status;
|
||||
- no existing character for that ID; and
|
||||
- no death marker for that ID on the current floor.
|
||||
- no death marker for that ID on the current floor; and
|
||||
- one of four party slots to be open.
|
||||
|
||||
The backend creates one character at full HP with heal available. If the game is
|
||||
already active, the player has 0 AP until the next Player Phase.
|
||||
@@ -514,6 +522,9 @@ already active, the player has 0 AP until the next Player Phase.
|
||||
Repeated `!spawn` attempts for an existing character return an informative chat
|
||||
response or log result and never create another character.
|
||||
|
||||
The roster is first come, first served for one floor. Twitch identity sessions
|
||||
remain outside the domain roster so clearing a party does not log viewers out.
|
||||
|
||||
### 10.2 Heal
|
||||
|
||||
A valid self-heal:
|
||||
@@ -624,14 +635,15 @@ When a living player enters the exit:
|
||||
1. enter transition state and reject remaining old-floor commands;
|
||||
2. increment the floor counter;
|
||||
3. generate and validate a new floor and Goblin;
|
||||
4. place every participating player on the new spawn tile;
|
||||
5. restore every player to 3 HP and alive;
|
||||
6. clear death markers, AP, and Guard;
|
||||
7. refresh every self-heal;
|
||||
8. log the new floor; and
|
||||
9. begin a fresh Player Phase.
|
||||
4. clear every current party record and release all four slots;
|
||||
5. discard all previous character HP, AP, Guard, heal, death, position, slot,
|
||||
and color state;
|
||||
6. log the new floor; and
|
||||
7. enter Dormant state for a new first-come, first-served party.
|
||||
|
||||
The previous Goblin and map are discarded.
|
||||
The previous Goblin and map are discarded. Existing authenticated Twitch
|
||||
sessions and sockets remain valid, but previous players have no slot
|
||||
reservation or priority.
|
||||
|
||||
### 12.3 Total-party defeat
|
||||
|
||||
@@ -645,9 +657,10 @@ alive, the backend immediately:
|
||||
5. logs the party defeat.
|
||||
|
||||
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.
|
||||
Phase from dormancy. If the defeated party claimed fewer than four slots, a
|
||||
newly eligible viewer may claim an open slot and resume play. A defeated full
|
||||
party requires resurrection. Initial dormancy has no participants, while
|
||||
defeated-party dormancy retains dead participant records.
|
||||
|
||||
## 13. Randomness
|
||||
|
||||
@@ -747,14 +760,22 @@ The default wide layout reserves:
|
||||
- the largest upper-right area for the map; and
|
||||
- a full-width bottom area for the action log.
|
||||
|
||||
Temporary shapes, colors, labels, and sprites are acceptable. Every required
|
||||
entity, wall, traversable path, and exit must remain distinguishable.
|
||||
Temporary shapes, labels, and sprites are acceptable. Every required entity,
|
||||
wall, traversable path, and exit must remain distinguishable. Player markers,
|
||||
visible name labels, personalized status, and adapter render data use the
|
||||
current party-slot color consistently.
|
||||
|
||||
### 15.2 Player status
|
||||
|
||||
The personalized Extension status includes at least identity/binding status,
|
||||
HP, AP, Guard, heal availability, alive/dead status, current phase, and a clear
|
||||
disabled reason when controls are unavailable.
|
||||
party slot/color, HP, AP, Guard, heal availability, alive/dead status, current
|
||||
phase, and a clear disabled reason when controls are unavailable.
|
||||
|
||||
Desktop preserves the compact information-rich overlay. Twitch context
|
||||
platform data adds an `extension-mobile` presentation flag when available, and
|
||||
a narrow-viewport media query provides the fallback. Mobile keeps identity and
|
||||
color, HP, AP, and heal availability visible, reflows the controller instead of
|
||||
scaling the desktop layout, and uses touch targets of at least 44 pixels.
|
||||
|
||||
### 15.3 Action log
|
||||
|
||||
@@ -872,7 +893,7 @@ Deterministic tests must cover:
|
||||
|
||||
- all accepted and rejected command conditions;
|
||||
- AP spending and early phase completion;
|
||||
- timer duration and 120-second cap;
|
||||
- rolling five-second deadline and one reset per living player;
|
||||
- deadline boundary behavior;
|
||||
- AutoGuard conversion, hit blocking, misses, and reset;
|
||||
- player and Goblin hit probabilities through injected rolls;
|
||||
@@ -910,7 +931,7 @@ At minimum, the live test plan must demonstrate:
|
||||
6. movement, two attacks, heal, pass, timer expiry, and AutoGuard;
|
||||
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;
|
||||
9. floor escape with the Goblin alive, party clearing, and next-floor reparty;
|
||||
10. total-party defeat, dormant transition, and resurrection recovery;
|
||||
11. refresh and temporary disconnect recovery; and
|
||||
12. repetition across multiple generated floors.
|
||||
|
||||
@@ -60,7 +60,7 @@ export function createAdminServer(deps:AdminServerDependencies){
|
||||
async function render(deps:AdminServerDependencies,notice:string|null):Promise<string>{
|
||||
const state=deps.game.snapshot(),status=await deps.status(),phase=state.phase.kind==='player'?`${state.phase.kind} · ${Math.max(0,Math.ceil((state.phase.deadlineAt-state.serverTime)/1000))}s remaining`:state.phase.kind
|
||||
const token=escapeHtml(deps.csrfToken)
|
||||
const playerRows=state.players.map(player=>`<tr><td>${escapeHtml(player.displayName)}</td><td><code>${escapeHtml(player.twitchUserId)}</code></td><td><span class="${player.connectionState}">${player.connectionState}</span></td><td>${player.lifeState}</td><td>${player.hp}</td><td>${player.ap}</td><td>${player.guard}</td><td><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><input type="hidden" name="userId" value="${escapeHtml(player.twitchUserId)}"><button name="action" value="disconnect">Disconnect</button> <button class="danger" name="action" value="remove">Remove</button></form></td></tr>`).join('')||'<tr><td colspan="8">No players are currently in the dungeon.</td></tr>'
|
||||
const playerRows=state.players.map(player=>`<tr><td>${player.partySlot}</td><td><span class="slot-color" style="--slot-color:${({blue:'#3f8cff',green:'#38c976',red:'#ef5350',yellow:'#f2c94c'} as const)[player.color]}"></span>${escapeHtml(player.displayName)}</td><td><code>${escapeHtml(player.twitchUserId)}</code></td><td><span class="${player.connectionState}">${player.connectionState}</span></td><td>${player.lifeState}</td><td>${player.hp}</td><td>${player.ap}</td><td>${player.guard}</td><td><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><input type="hidden" name="userId" value="${escapeHtml(player.twitchUserId)}"><button name="action" value="disconnect">Disconnect</button> <button class="danger" name="action" value="remove">Remove</button></form></td></tr>`).join('')||'<tr><td colspan="9">No players are currently in the dungeon.</td></tr>'
|
||||
const logs=state.actionLog.slice(-40).reverse().map(entry=>`<tr><td>${escapeHtml(entry.occurredAt)}</td><td>${escapeHtml(entry.type)}</td><td>${escapeHtml(entry.message)}</td></tr>`).join('')
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta http-equiv="refresh" content="15"><title>Twungeon Admin</title><style>:root{color-scheme:dark;font:15px system-ui;background:#0d0d12;color:#eee}body{max-width:1200px;margin:auto;padding:24px}h1{margin-bottom:4px}.sub{color:#aaa;margin-top:0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}.card,section{background:#181820;border:1px solid #343442;border-radius:10px;padding:16px}section{margin-top:16px;overflow:auto}.label{color:#aaa;font-size:12px;text-transform:uppercase}.value{font-size:20px;margin-top:5px}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:9px;border-bottom:1px solid #30303a;white-space:nowrap}button{border:0;border-radius:6px;padding:7px 11px;background:#7047eb;color:white;font-weight:650;cursor:pointer}.danger{background:#b42d46}.connected{color:#62d493}.disconnected{color:#e8b95b}.notice{background:#193b2c;border:1px solid #2d8056;padding:10px;border-radius:8px}.actions{display:flex;gap:10px;flex-wrap:wrap}code{font-size:12px}</style></head><body><h1>Twungeon Admin</h1><p class="sub">Private operator console · refreshes every 15 seconds</p>${notice?`<p class="notice">${escapeHtml(notice)}</p>`:''}<div class="cards"><div class="card"><div class="label">Run</div><div class="value">Floor ${state.floorNumber}</div></div><div class="card"><div class="label">Phase</div><div class="value">${escapeHtml(phase)}</div></div><div class="card"><div class="label">Players</div><div class="value">${state.players.length}</div></div><div class="card"><div class="label">Twitch</div><div class="value">${status.twitchReady?'Ready':'Not ready'}</div></div><div class="card"><div class="label">OAuth</div><div class="value">${status.oauthAuthorized?'Authorized':status.oauthConfigured?'Not authorized':'Not configured'}</div></div><div class="card"><div class="label">Uptime</div><div class="value">${Math.floor(status.uptimeSeconds/60)}m</div></div></div><section><h2>Controls</h2><div class="actions"><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><button name="action" value="force-phase">End player phase</button></form><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><button class="danger" name="action" value="reset-run">Reset run</button></form></div></section><section><h2>Players</h2><table><thead><tr><th>Name</th><th>Twitch ID</th><th>Connection</th><th>Life</th><th>HP</th><th>AP</th><th>Guard</th><th>Actions</th></tr></thead><tbody>${playerRows}</tbody></table></section><section><h2>Recent action log</h2><table><thead><tr><th>Time</th><th>Event</th><th>Message</th></tr></thead><tbody>${logs}</tbody></table></section></body></html>`
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta http-equiv="refresh" content="15"><title>Twungeon Admin</title><style>:root{color-scheme:dark;font:15px system-ui;background:#0d0d12;color:#eee}body{max-width:1200px;margin:auto;padding:24px}h1{margin-bottom:4px}.sub{color:#aaa;margin-top:0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}.card,section{background:#181820;border:1px solid #343442;border-radius:10px;padding:16px}section{margin-top:16px;overflow:auto}.label{color:#aaa;font-size:12px;text-transform:uppercase}.value{font-size:20px;margin-top:5px}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:9px;border-bottom:1px solid #30303a;white-space:nowrap}button{border:0;border-radius:6px;padding:7px 11px;background:#7047eb;color:white;font-weight:650;cursor:pointer}.danger{background:#b42d46}.connected{color:#62d493}.disconnected{color:#e8b95b}.slot-color{display:inline-block;width:10px;height:10px;margin-right:6px;border-radius:50%;background:var(--slot-color);border:1px solid #fff}.notice{background:#193b2c;border:1px solid #2d8056;padding:10px;border-radius:8px}.actions{display:flex;gap:10px;flex-wrap:wrap}code{font-size:12px}</style></head><body><h1>Twungeon Admin</h1><p class="sub">Private operator console · refreshes every 15 seconds</p>${notice?`<p class="notice">${escapeHtml(notice)}</p>`:''}<div class="cards"><div class="card"><div class="label">Run</div><div class="value">Floor ${state.floorNumber}</div></div><div class="card"><div class="label">Phase</div><div class="value">${escapeHtml(phase)}</div></div><div class="card"><div class="label">Party</div><div class="value">${state.players.length}/4</div></div><div class="card"><div class="label">Twitch</div><div class="value">${status.twitchReady?'Ready':'Not ready'}</div></div><div class="card"><div class="label">OAuth</div><div class="value">${status.oauthAuthorized?'Authorized':status.oauthConfigured?'Not authorized':'Not configured'}</div></div><div class="card"><div class="label">Uptime</div><div class="value">${Math.floor(status.uptimeSeconds/60)}m</div></div></div><section><h2>Controls</h2><div class="actions"><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><button name="action" value="force-phase">End player phase</button></form><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><button class="danger" name="action" value="reset-run">Reset run</button></form></div></section><section><h2>Players</h2><table><thead><tr><th>Slot</th><th>Name</th><th>Twitch ID</th><th>Connection</th><th>Life</th><th>HP</th><th>AP</th><th>Guard</th><th>Actions</th></tr></thead><tbody>${playerRows}</tbody></table></section><section><h2>Recent action log</h2><table><thead><tr><th>Time</th><th>Event</th><th>Message</th></tr></thead><tbody>${logs}</tbody></table></section></body></html>`
|
||||
}
|
||||
|
||||
@@ -72,14 +72,14 @@ export const server=createServer(async(req,res)=>{
|
||||
if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){
|
||||
const userId=sessionUser(bearer(req));if(!userId)return json(res,401,{error:'UNAUTHENTICATED'})
|
||||
const message=await twitch.createSpawn(userId,`extension-spawn-${crypto.randomUUID()}`);if(!message)return json(res,503,{error:'TWITCH_UNAVAILABLE'})
|
||||
const result=game.spawn(message);if(result.accepted)broadcast();return json(res,result.accepted?200:409,{...result,state:game.personalizedSnapshot(userId)})
|
||||
const result=game.spawn(message);if(result.accepted){game.bindExtension(userId,connectedClientCount(userId)>0);broadcast()}return json(res,result.accepted?200:409,{...result,state:game.personalizedSnapshot(userId)})
|
||||
}
|
||||
if(req.method==='POST'&&url.pathname==='/api/commands'){
|
||||
const token=bearer(req),userId=sessionUser(token),parsed=CommandEnvelopeSchema.safeParse(await body(req))
|
||||
if(!parsed.success)return json(res,400,{error:'INVALID_COMMAND',issues:parsed.error.issues.map(i=>({path:i.path,message:i.message}))})
|
||||
const result=game.command(userId,Boolean(userId),parsed.data);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)
|
||||
}
|
||||
if(req.method==='POST'&&url.pathname==='/api/dev/spawn'&&!config.twitchEnabled){const msg=await twitch.normalizeSpawn(await body(req));if(!msg)return json(res,400,{error:'INVALID_SPAWN'});const result=game.spawn(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
|
||||
if(req.method==='POST'&&url.pathname==='/api/dev/spawn'&&!config.twitchEnabled){const msg=await twitch.normalizeSpawn(await body(req));if(!msg)return json(res,400,{error:'INVALID_SPAWN'});const result=game.spawn(msg);if(result.accepted){game.bindExtension(msg.twitchUserId,connectedClientCount(msg.twitchUserId)>0);broadcast()}return json(res,result.accepted?200:409,result)}
|
||||
if(req.method==='POST'&&url.pathname==='/api/dev/redemption'&&!config.twitchEnabled){const msg=await twitch.normalizeRedemption(await body(req));if(!msg)return json(res,400,{error:'INVALID_REDEMPTION'});const result=game.resurrect(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
|
||||
if(req.method==='GET'&&url.pathname==='/'){const page=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(page)}
|
||||
if(req.method==='GET'&&url.pathname==='/extension'){const page=(await readFile(join(publicDir,'index.html'),'utf8')).replace('<html lang="en">','<html lang="en" class="extension-mode">');res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(page)}
|
||||
@@ -93,7 +93,7 @@ const wss=new WebSocketServer({noServer:true})
|
||||
server.on('upgrade',(req,socket,head)=>{const url=new URL(req.url??'/',config.publicBaseUrl);if(url.pathname!=='/ws'){socket.destroy();return}wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws,req))})
|
||||
wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null),changed=authenticateClient(ws,userId);if(changed)broadcast();else if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>{const userId=clients.get(ws)??null;clients.delete(ws);if(userId && connectedClientCount(userId)===0 && game.disconnect(userId))broadcast()})})
|
||||
setInterval(()=>{const before=game.snapshot().nextEventSequence;game.tick();if(game.snapshot().nextEventSequence!==before)broadcast()},250).unref()
|
||||
if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{game.spawn(message);broadcast()},onRedemption:message=>{game.resurrect(message);broadcast()},onConnection:ready=>console.log(JSON.stringify({level:'info',component:'twitch-adapter',ready}))}).catch(error=>console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Connection failed'})))
|
||||
if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{const result=game.spawn(message);if(result.accepted)game.bindExtension(message.twitchUserId,connectedClientCount(message.twitchUserId)>0);broadcast()},onRedemption:message=>{game.resurrect(message);broadcast()},onConnection:ready=>console.log(JSON.stringify({level:'info',component:'twitch-adapter',ready}))}).catch(error=>console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Connection failed'})))
|
||||
if(process.env.NODE_ENV!=='test'){
|
||||
server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`))
|
||||
adminServer?.listen(config.adminPort,config.adminHost,()=>console.log(`Twungeon admin listening on http://${config.adminHost}:${config.adminPort}`))
|
||||
|
||||
@@ -2,14 +2,18 @@ let state=null,session=null,userId=null,lastSequence=0,activeSocket=null,extensi
|
||||
const extensionMode=location.pathname==='/extension',overlayParams=new window.URLSearchParams(location.search),localOverlayHost=['localhost','127.0.0.1','[::1]'].includes(location.hostname),overlayDevMode=extensionMode&&localOverlayHost&&overlayParams.get('dev')==='1';document.documentElement.classList.toggle('extension-mode',extensionMode);document.documentElement.classList.toggle('overlay-debug',extensionMode&&localOverlayHost&&overlayParams.get('debug')==='1');document.documentElement.classList.toggle('overlay-dev',overlayDevMode)
|
||||
const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s)
|
||||
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))
|
||||
function disabledReason(){if(extensionIdentityPending)return 'Share your Twitch identity to enable controls.';if(!userId)return extensionMode?'Waiting for Twitch authorization.':'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return extensionMode?'Click Spawn character to join.':'Type !spawn in chat first.';if(p.lifeState==='dead')return 'Your character is dead.';if(state.phase.kind!=='player')return 'Wait for Player Phase.';if(!p.ap)return 'No AP remains this phase.';return ''}
|
||||
const playerColors={blue:'#3f8cff',green:'#38c976',red:'#ef5350',yellow:'#f2c94c'},colorNames={blue:'Blue',green:'Green',red:'Red',yellow:'Yellow'}
|
||||
function setMobileLayout(context={}){const platform=String(context.platform??context.mode??'').toLowerCase(),mobile=platform.includes('mobile')||window.matchMedia('(max-width: 600px)').matches;document.documentElement.classList.toggle('extension-mobile',extensionMode&&mobile)}
|
||||
function disabledReason(){if(extensionIdentityPending)return 'Share your Twitch identity to enable controls.';if(!userId)return extensionMode?'Waiting for Twitch authorization.':'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return state?.players.length>=4?'Party full for this level. Spectating until the next level.':extensionMode?'Click Spawn character to join.':'Activate the Extension to spawn.';if(p.lifeState==='dead')return 'Your character is dead.';if(state.phase.kind!=='player')return 'Wait for Player Phase.';if(!p.ap)return 'No AP remains this phase.';return ''}
|
||||
function render(){if(!state)return;$('#floor').textContent=`Floor ${state.floorNumber}`;$('#phase').textContent=state.phase.kind;$('#banner').hidden=state.phase.kind!=='dormant'
|
||||
const me=state.players.find(p=>p.twitchUserId===userId),seconds=state.phase.kind==='player'?Math.max(0,Math.ceil((state.phase.deadlineAt-Date.now())/1000)):'—'
|
||||
$('#status').innerHTML=[['Players',state.players.filter(p=>p.lifeState==='alive').length],['Timer',seconds],['HP',me?`${me.hp}/3`:'—'],['AP',me?.ap??'—'],['Guard',me?.guard??'—'],['Heal',me?(me.healAvailable?'Ready':'Used'):'—']].map(([k,v])=>`<div class="stat">${k}<b>${v}</b></div>`).join('')
|
||||
$('#status').innerHTML=[['Party',`${state.players.length}/4`],['Living',state.players.filter(p=>p.lifeState==='alive').length],['Timer',seconds],['HP',me?`${me.hp}/3`:'—'],['AP',me?.ap??'—'],['Guard',me?.guard??'—'],['Heal',me?(me.healAvailable?'Ready':'Used'):'—']].map(([k,v])=>`<div class="stat">${k}<b>${v}</b></div>`).join('')
|
||||
const viewerStatus=$('#viewerStatus');viewerStatus.hidden=!extensionMode||!me;viewerStatus.innerHTML=me?`<div class="viewer-identity"><span class="color-dot" style="--player-color:${playerColors[me.color]}"></span><b>${escapeHtml(me.displayName)}</b><span>Slot ${me.partySlot} · ${colorNames[me.color]}</span></div><div><span>HP</span><b>${me.hp}/3</b></div><div><span>AP</span><b>${me.ap}</b></div><div><span>Heal</span><b>${me.healAvailable?'Ready':'Used'}</b></div>`:''
|
||||
$('#partyLegend').innerHTML=state.players.map(player=>`<span class="party-name" style="--player-color:${playerColors[player.color]}">● ${escapeHtml(player.displayName)}</span>`).join(' ')
|
||||
const map=$('#map');map.style.gridTemplateColumns=`repeat(${state.floor.width},auto)`;map.innerHTML=''
|
||||
for(let y=0;y<state.floor.height;y++)for(let x=0;x<state.floor.width;x++){const el=document.createElement('div'),tile=state.floor.tiles[y][x];el.className=`tile ${tile}`;if(tile==='exit')el.textContent='▣';const p=state.players.find(p=>p.lifeState==='alive'&&p.position.x===x&&p.position.y===y);const gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob||p){const e=document.createElement('span');e.className=`entity ${gob?'goblin':'player'}`;e.textContent=gob?'◆':'●';e.title=gob?'Goblin':p.displayName;el.append(e)}map.append(el)}
|
||||
$('#log').innerHTML=state.actionLog.slice(-40).map(e=>`<li><small>#${e.sequence}</small> ${escapeHtml(e.message)}</li>`).join('');$('#log').scrollTop=$('#log').scrollHeight
|
||||
const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode||overlayDevMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character'
|
||||
for(let y=0;y<state.floor.height;y++)for(let x=0;x<state.floor.width;x++){const el=document.createElement('div'),tile=state.floor.tiles[y][x];el.className=`tile ${tile}`;if(tile==='exit')el.textContent='▣';const players=state.players.filter(p=>p.lifeState==='alive'&&p.position.x===x&&p.position.y===y),gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob){const entity=document.createElement('span');entity.className='entity goblin';entity.textContent='◆';entity.title='Goblin';el.append(entity)}for(const player of players){const entity=document.createElement('span');entity.className=`entity player${players.length>1?` stacked slot-${player.partySlot}`:''}`;entity.textContent='●';entity.title=player.displayName;entity.style.setProperty('--player-color',playerColors[player.color]);const label=document.createElement('span');label.className='entity-name';label.textContent=player.displayName;entity.append(label);el.append(entity)}map.append(el)}
|
||||
$('#log').innerHTML=state.actionLog.slice(-40).map(e=>{const actor=state.players.find(player=>player.twitchUserId===e.actorId),style=actor?` style="--player-color:${playerColors[actor.color]}"`:'';return`<li${style}><small>#${e.sequence}</small> ${escapeHtml(e.message)}</li>`}).join('');$('#log').scrollTop=$('#log').scrollHeight
|
||||
const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode||overlayDevMode;spawnButton.disabled=!session||Boolean(me)||state.players.length>=4;spawnButton.textContent=me?'Character spawned':state.players.length>=4?'Party full':'Spawn character'
|
||||
}
|
||||
async function api(path,options={}){const res=await fetch(path,{...options,headers:{'content-type':'application/json',...(session?{authorization:`Bearer ${session}`}:{})}});const data=await res.json();if(!res.ok)throw new Error(data.message||data.error||data.reason||'Request failed');return data}
|
||||
async function authorizeExtension(token){const auth=await api('/api/extension/session',{method:'POST',body:JSON.stringify({token})});session=auth.token;userId=auth.twitchUserId;state=auth.state;if(activeSocket?.readyState===WebSocket.OPEN)activeSocket.send(JSON.stringify({type:'authenticate',token:session}));render()}
|
||||
@@ -20,4 +24,5 @@ $('#spawn').onclick=()=>spawn().catch(e=>$('#disabled').textContent=e.message);$
|
||||
function connect(){const ws=activeSocket=new WebSocket(`${location.protocol==='https:'?'wss':'ws'}://${location.host}/ws`);ws.onopen=()=>{if(session)ws.send(JSON.stringify({type:'authenticate',token:session}));$('#connection').textContent='Live'};ws.onclose=()=>{$('#connection').textContent='Reconnecting…';setTimeout(connect,1000)};ws.onmessage=e=>{const msg=JSON.parse(e.data);if(msg.type!=='snapshot')return;if(lastSequence&&msg.sequence>lastSequence+1){fetch('/api/state').then(r=>r.json()).then(s=>{state=s;lastSequence=s.nextEventSequence-1;render()});return}lastSequence=msg.sequence;state=msg.state;render()}}
|
||||
connect()
|
||||
setInterval(()=>{if(state?.phase.kind==='player')render()},250)
|
||||
if(window.Twitch?.ext)window.Twitch.ext.onAuthorized(auth=>{const linked=Boolean(window.Twitch.ext.viewer?.isLinked);extensionIdentityPending=!linked;$('#shareIdentity').hidden=linked;if(!linked){render();return}authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message)})
|
||||
setMobileLayout();window.matchMedia('(max-width: 600px)').addEventListener?.('change',()=>setMobileLayout())
|
||||
if(window.Twitch?.ext){window.Twitch.ext.onContext?.(context=>setMobileLayout(context));window.Twitch.ext.onAuthorized(auth=>{const linked=Boolean(window.Twitch.ext.viewer?.isLinked);extensionIdentityPending=!linked;$('#shareIdentity').hidden=linked;if(!linked){render();return}authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message)})}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Twungeon</title><link rel="stylesheet" href="/styles.css"></head>
|
||||
<body><main class="shell">
|
||||
<aside class="panel status" id="controlRegion"><div class="branding"><p class="eyebrow">Twitch plays together</p><h1>TWUNG<span>EON</span></h1></div><div id="status"></div>
|
||||
<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><div id="viewerStatus" hidden></div><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>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 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 id="partyLegend"></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>
|
||||
|
||||
@@ -40,9 +40,16 @@ h2 { font: 700 18px/1.2 system-ui; }
|
||||
.tile.floor { background: var(--floor); }
|
||||
.tile.exit { background: #68491b; color: #ffd77e; }
|
||||
.entity { position: absolute; inset: 12%; border-radius: 50%; display: grid; place-items: center; font-weight: bold; text-shadow: 0 1px 2px #000; }
|
||||
.entity.player { background: #4f8cc9; border: 2px solid #b9deff; }
|
||||
.entity.player { background: var(--player-color, #3f8cff); border: 2px solid #fff; color: #111; }
|
||||
.entity.player.stacked { inset: auto; width: 40%; height: 40%; }
|
||||
.entity.player.stacked.slot-1 { left: 6%; top: 6%; }
|
||||
.entity.player.stacked.slot-2 { right: 6%; top: 6%; }
|
||||
.entity.player.stacked.slot-3 { left: 6%; bottom: 6%; }
|
||||
.entity.player.stacked.slot-4 { right: 6%; bottom: 6%; }
|
||||
.entity-name { position: absolute; top: 100%; left: 50%; z-index: 2; transform: translate(-50%, 2px); padding: 1px 3px; border-radius: 2px; background: #000c; color: var(--player-color, #fff); font-size: 9px; line-height: 1.1; white-space: nowrap; text-shadow: 0 1px 2px #000; }
|
||||
.entity.goblin { background: var(--red); border: 2px solid #ffb2a6; border-radius: 20%; }
|
||||
.legend { display: flex; gap: 18px; justify-content: center; color: var(--muted); font-size: 12px; }
|
||||
.party-name { color: var(--player-color); white-space: nowrap; }
|
||||
#status { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.stat { border: 1px solid var(--line); padding: 9px; }
|
||||
.stat b { display: block; color: var(--gold); font-size: 18px; }
|
||||
@@ -60,7 +67,7 @@ details { margin-top: auto; color: var(--muted); }
|
||||
details label { display: block; margin: 8px 0; }
|
||||
input { display: block; width: 100%; background: #0d0f10; border: 1px solid var(--line); color: var(--ink); padding: 6px; }
|
||||
#log { margin: 8px 0 0; padding: 0; list-style: none; overflow: auto; display: flex; flex-direction: column; gap: 3px; }
|
||||
#log li { padding: 4px 8px; border-left: 2px solid var(--line); color: var(--muted); }
|
||||
#log li { padding: 4px 8px; border-left: 2px solid var(--player-color, var(--line)); color: var(--muted); }
|
||||
#log li:last-child { color: var(--ink); border-color: var(--gold); }
|
||||
#connection { font-size: 12px; color: var(--muted); }
|
||||
|
||||
@@ -87,6 +94,13 @@ html.extension-mode .status {
|
||||
}
|
||||
html.extension-mode #controller { width: min(100%, 18rem); pointer-events: none; }
|
||||
html.extension-mode #controller h2 { font-size: clamp(11px, 5cqw, 18px); margin-bottom: clamp(4px, 2cqh, 12px); text-shadow: 0 1px 3px #000; }
|
||||
html.extension-mode #viewerStatus { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(3px, 1.4cqw, 7px); margin-bottom: clamp(5px, 2cqh, 10px); color: var(--ink); text-shadow: 0 1px 3px #000; }
|
||||
html.extension-mode #viewerStatus[hidden] { display: none; }
|
||||
html.extension-mode #viewerStatus > div { padding: clamp(3px, 1cqw, 6px); background: #080a0bd9; border: 1px solid #ffffff2b; font-size: clamp(8px, 3.3cqw, 12px); }
|
||||
html.extension-mode #viewerStatus > div > span, html.extension-mode #viewerStatus > div > b { display: block; }
|
||||
html.extension-mode #viewerStatus .viewer-identity { grid-column: 1/-1; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 6px; }
|
||||
html.extension-mode #viewerStatus .viewer-identity > span, html.extension-mode #viewerStatus .viewer-identity > b { display: initial; }
|
||||
.color-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--player-color); border: 1px solid #fff; }
|
||||
html.extension-mode #controller button { pointer-events: auto; touch-action: manipulation; }
|
||||
html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(28px, 1fr)); gap: clamp(3px, 1.8cqw, 7px); margin: 0 0 clamp(6px, 2.5cqh, 14px); }
|
||||
html.extension-mode .dpad button { aspect-ratio: 1; padding: 0; font-size: clamp(13px, 6cqw, 24px); }
|
||||
@@ -108,6 +122,25 @@ html.extension-mode.overlay-dev .status { place-items: start center; overflow: a
|
||||
html:not(.extension-mode) #banner { white-space: normal; width: 75%; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
html.extension-mode .status { left: 3%; top: 3%; width: 94%; height: 94%; padding: 12px; place-items: center; overflow: auto; }
|
||||
html.extension-mode #controller { width: min(100%, 24rem); }
|
||||
html.extension-mode #controller h2 { font-size: 17px; }
|
||||
html.extension-mode #viewerStatus { gap: 7px; margin-bottom: 10px; }
|
||||
html.extension-mode #viewerStatus > div { min-height: 44px; padding: 7px 9px; font-size: 13px; }
|
||||
html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
html.extension-mode .dpad button { min-height: 56px; font-size: 24px; }
|
||||
html.extension-mode .actions { gap: 8px; }
|
||||
html.extension-mode .actions button, html.extension-mode #spawnExtension, html.extension-mode #shareIdentity { min-height: 52px; padding: 10px 6px; font-size: 15px; }
|
||||
html.extension-mode #disabled { font-size: 13px; }
|
||||
}
|
||||
|
||||
html.extension-mode.extension-mobile .status { left: 3%; top: 3%; width: 94%; height: 94%; padding: 12px; place-items: center; overflow: auto; }
|
||||
html.extension-mode.extension-mobile #controller { width: min(100%, 24rem); }
|
||||
html.extension-mode.extension-mobile .dpad { grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 8px; }
|
||||
html.extension-mode.extension-mobile .dpad button { min-height: 56px; font-size: 24px; }
|
||||
html.extension-mode.extension-mobile .actions button, html.extension-mode.extension-mobile #spawnExtension, html.extension-mode.extension-mobile #shareIdentity { min-height: 52px; font-size: 15px; }
|
||||
|
||||
@media (max-aspect-ratio: 4/3) {
|
||||
html.extension-mode { --controls-width: 31%; --controls-height: 62%; }
|
||||
}
|
||||
|
||||
+15
-9
@@ -23,14 +23,16 @@ Twitch identities; they do not require credentials.
|
||||
1. Run `npm run dev` and open `http://localhost:3000`.
|
||||
2. Confirm the exact dormant banner appears and no timer runs.
|
||||
3. Use **Local viewer login** to spawn and bind a viewer.
|
||||
4. Confirm 3 HP, 2 AP, no Guard, a ready heal, and a 25-second phase.
|
||||
5. Exercise movement, invalid walls, Attack, Heal, and Pass. Invalid actions
|
||||
4. Confirm 3 HP, 2 AP, no Guard, a ready heal, and a five-second phase.
|
||||
5. Confirm the viewer's first accepted action resets the timer to five seconds
|
||||
and their second accepted action does not reset it again.
|
||||
6. Exercise movement, invalid walls, Attack, Heal, and Pass. Invalid actions
|
||||
must not consume AP.
|
||||
6. Open a second browser/private window with a different ID. Confirm both
|
||||
7. Open a second browser/private window with a different ID. Confirm both
|
||||
characters share the dungeon but each controller spends only its own AP.
|
||||
7. Refresh during a phase. Confirm a snapshot restores the map/log and no
|
||||
8. Refresh during a phase. Confirm a snapshot restores the map/log and no
|
||||
command replays.
|
||||
8. Run `npm run build && npm start` and repeat the smoke test against the built
|
||||
9. Run `npm run build && npm start` and repeat the smoke test against the built
|
||||
server.
|
||||
|
||||
The local-only endpoints `/api/dev/spawn` and `/api/dev/redemption` exist only while
|
||||
@@ -52,7 +54,8 @@ cyan control-region boundary and a pink controller boundary. Both switches are
|
||||
accepted only on `localhost`, `127.0.0.1`, or `[::1]`, so the diagnostics cannot
|
||||
be enabled on the deployed Extension origin.
|
||||
|
||||
Verify at 1920×1080, 1280×720, and a narrower or 4:3 viewport:
|
||||
Verify at 1920×1080, 1280×720, a narrower or 4:3 viewport, and a mobile-width
|
||||
viewport:
|
||||
|
||||
- the document and empty overlay canvas remain transparent;
|
||||
- controls stay within the upper-left debug boundary and remain readable;
|
||||
@@ -61,7 +64,9 @@ Verify at 1920×1080, 1280×720, and a narrower or 4:3 viewport:
|
||||
- Up, Down, Left, Right, Attack, Heal, and Pass reach the same backend routes;
|
||||
- missing character, dead character, non-player phase, and zero-AP states still
|
||||
disable the controls; and
|
||||
- loading `/` still presents the full local game and controller.
|
||||
- loading `/` still presents the full local game and controller;
|
||||
- mobile identity/color, HP, AP, and heal status remains visible; and
|
||||
- mobile directional buttons are at least 56px and action buttons at least 52px.
|
||||
|
||||
Remove the query string for the production-shaped local view. Twitch-hosted
|
||||
testing still must cover identity sharing, player ownership, theater mode,
|
||||
@@ -69,15 +74,16 @@ fullscreen, embeds, ads/pauses, and Twitch player-control safe zones.
|
||||
|
||||
## Live-channel campaign
|
||||
|
||||
After completing `docs/twitch-setup.md`, use two follower accounts and one
|
||||
After completing `docs/twitch-setup.md`, use five follower accounts and one
|
||||
non-follower account. Capture redacted evidence for:
|
||||
|
||||
- eligible, ineligible, and duplicate `!spawn` attempts;
|
||||
- matching and mismatched Extension identities;
|
||||
- independent commands from two viewers;
|
||||
- four deterministic party slots/colors and a rejected fifth viewer;
|
||||
- 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 total-party defeat dormancy;
|
||||
- escape with the Goblin alive, complete next-floor reparty, and total-party defeat dormancy;
|
||||
- refresh/reconnect during a phase and transition; and
|
||||
- repeated floors and a short soak session.
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ repository has no manifest that can change an Extension version in Twitch.
|
||||
6. Test the overlay while live in normal, theater, fullscreen, and a narrow
|
||||
player. The video player and stream supply the game frame; `/extension`
|
||||
should show only the upper-left controls on a transparent canvas.
|
||||
The same viewer path handles mobile: Twitch context selects the mobile
|
||||
layout when available and responsive CSS is the fallback. Do not configure
|
||||
a second Extension, stream, or backend instance for mobile viewers.
|
||||
7. A JWT without `user_id`, with the wrong
|
||||
`channel_id`, an expired signature, or an `external` role is rejected.
|
||||
8. Keep the Extension secret only in the backend environment. It must never be
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface GameDependencies {
|
||||
clock: Clock; random: RandomProvider; ids: IdGenerator; generateFloor(seed: string): FloorState
|
||||
resurrectionRewardId: string
|
||||
}
|
||||
export interface SpawnResult { accepted: boolean; reason: 'NOT_FOLLOWER'|'DUPLICATE'|'DIED_THIS_FLOOR'|null; message: string }
|
||||
export interface SpawnResult { accepted: boolean; reason: 'NOT_FOLLOWER'|'DUPLICATE'|'DIED_THIS_FLOOR'|'PARTY_FULL'|null; message: string }
|
||||
export interface ResurrectionResult { accepted: boolean; reason: 'DUPLICATE'|'WRONG_REWARD'|'UNKNOWN_PLAYER'|'PLAYER_ALIVE'|null; message: string }
|
||||
export interface GameSnapshot extends Omit<RunState, 'players'> { players: PlayerState[]; serverTime: number }
|
||||
|
||||
@@ -14,6 +14,8 @@ const same = (a: TilePosition, b: TilePosition) => a.x === b.x && a.y === b.y
|
||||
const adjacent = (a: TilePosition, b: TilePosition) => Math.abs(a.x-b.x)+Math.abs(a.y-b.y) === 1
|
||||
const phaseId = (state: RunState) => state.phase.kind === 'player' || state.phase.kind === 'enemy' ? state.phase.phaseId : null
|
||||
const directions = [{x:0,y:-1},{x:-1,y:0},{x:1,y:0},{x:0,y:1}]
|
||||
const partyColors = ['blue','green','red','yellow'] as const
|
||||
const playerPhaseDuration = 5_000
|
||||
|
||||
export class Game {
|
||||
private state: RunState
|
||||
@@ -69,7 +71,10 @@ export class Game {
|
||||
if(!message.followerVerified) return {accepted:false,reason:'NOT_FOLLOWER',message:'Follow the channel before using !spawn.'}
|
||||
const existing=this.state.players[message.twitchUserId]
|
||||
if(existing) return {accepted:false,reason:existing.diedOnFloor===this.state.floorNumber?'DIED_THIS_FLOOR':'DUPLICATE',message:existing.diedOnFloor===this.state.floorNumber?'You died on this floor; reach the next floor or resurrect to return.':'Your character is already in the Twungeon.'}
|
||||
const p: PlayerState={twitchUserId:message.twitchUserId,displayName:safeName(message.displayName),followerVerified:true,characterCreated:true,extensionBound:false,connectionState:'disconnected',position:copy(this.state.floor.spawnTiles[0]!),hp:3,lifeState:'alive',ap:0,guard:0,healAvailable:true,participatingFloor:this.state.floorNumber,diedOnFloor:null,eligibleThisPhase:false}
|
||||
const occupiedSlots=new Set(Object.values(this.state.players).map(player=>player.partySlot)),partySlot=partyColors.findIndex((_,index)=>!occupiedSlots.has((index+1) as PlayerState['partySlot']))+1
|
||||
if(partySlot<1)return {accepted:false,reason:'PARTY_FULL',message:'The current party is full. You can try again on the next level.'}
|
||||
const slot=partySlot as PlayerState['partySlot']
|
||||
const p: PlayerState={twitchUserId:message.twitchUserId,displayName:safeName(message.displayName),followerVerified:true,characterCreated:true,extensionBound:false,partySlot:slot,color:partyColors[slot-1]!,connectionState:'disconnected',position:copy(this.state.floor.spawnTiles[0]!),hp:3,lifeState:'alive',ap:0,guard:0,healAvailable:true,participatingFloor:this.state.floorNumber,diedOnFloor:null,eligibleThisPhase:false}
|
||||
this.state.players[p.twitchUserId]=p
|
||||
this.log('player-spawned',p.twitchUserId,null,`${p.displayName} entered the Twungeon!`)
|
||||
if(this.state.phase.kind==='dormant') this.startPlayerPhase()
|
||||
@@ -106,9 +111,16 @@ export class Game {
|
||||
if(!rejection && !player) rejection='CHARACTER_NOT_FOUND'
|
||||
else if(!rejection && player?.lifeState!=='alive') rejection='CHARACTER_DEAD'
|
||||
else if(!rejection && (player?.ap ?? 0)<1) rejection='NO_AP'
|
||||
const commandPhase=this.state.phase.kind==='player'?this.state.phase:null
|
||||
if(!rejection && player) rejection=this.applyPlayerCommand(player,envelope)
|
||||
if(rejection) this.state=before
|
||||
else this.finishPlayerPhaseIfReady()
|
||||
else {
|
||||
if(player && commandPhase && this.state.phase.kind==='player' && this.state.phase.phaseId===commandPhase.phaseId && !this.state.phase.timerResetPlayerIds.includes(player.twitchUserId)){
|
||||
this.state.phase.timerResetPlayerIds.push(player.twitchUserId);this.state.phase.deadlineAt=arrivedAt+playerPhaseDuration
|
||||
this.log('phase-timer-reset',player.twitchUserId,null,`${player.displayName}'s first action resets the Player Phase timer to 5 seconds.`)
|
||||
}
|
||||
this.finishPlayerPhaseIfReady()
|
||||
}
|
||||
const result:CommandResult={requestId:envelope.requestId,accepted:rejection===null,eventSequence:this.state.nextEventSequence-1,reason:rejection,message:rejection?reasonMessage(rejection):'Command accepted.'}
|
||||
this.commandResults.set(requestKey,result)
|
||||
return result
|
||||
@@ -147,13 +159,13 @@ export class Game {
|
||||
private startPlayerPhase(): void {
|
||||
const living=Object.values(this.state.players).filter(p=>p.lifeState==='alive')
|
||||
if(living.length===0){this.state.phase={kind:'dormant'};return}
|
||||
const now=this.deps.clock.now(), duration=Math.min(living.length*25_000,120_000)
|
||||
this.state.phase={kind:'player',phaseId:this.deps.ids.next('phase'),startedAt:now,deadlineAt:now+duration,initialEligiblePlayerIds:living.map(p=>p.twitchUserId)}
|
||||
const now=this.deps.clock.now()
|
||||
this.state.phase={kind:'player',phaseId:this.deps.ids.next('phase'),startedAt:now,deadlineAt:now+playerPhaseDuration,initialEligiblePlayerIds:living.map(p=>p.twitchUserId),timerResetPlayerIds:[]}
|
||||
for(const p of Object.values(this.state.players)){
|
||||
p.guard=0;p.eligibleThisPhase=p.lifeState==='alive';p.ap=p.lifeState==='alive'?2:0
|
||||
if(p.lifeState==='alive' && p.extensionBound && p.connectionState==='disconnected') this.autoGuard(p)
|
||||
}
|
||||
this.log('player-phase-started',null,null,`Player Phase begins: ${duration/1000} seconds.`)
|
||||
this.log('player-phase-started',null,null,'Player Phase begins: 5 seconds.')
|
||||
}
|
||||
private finishPlayerPhaseIfReady(): void {
|
||||
if(this.state.phase.kind!=='player') return
|
||||
@@ -201,7 +213,7 @@ export class Game {
|
||||
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()
|
||||
this.state.players={};this.log('floor-advanced',null,null,`Floor ${this.state.floorNumber} awaits a new party.`);this.state.phase={kind:'dormant'}
|
||||
}
|
||||
private resetRun(): void {
|
||||
this.state.phase={kind:'transition',reason:'admin-reset'};this.state.runId=this.deps.ids.next('run');this.state.floorNumber=1
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface FloorState {
|
||||
export interface PlayerState {
|
||||
twitchUserId: string; displayName: string; followerVerified: boolean
|
||||
characterCreated: boolean; extensionBound: boolean
|
||||
partySlot: 1 | 2 | 3 | 4; color: 'blue' | 'green' | 'red' | 'yellow'
|
||||
connectionState: 'connected' | 'disconnected'; position: TilePosition
|
||||
hp: number; lifeState: 'alive' | 'dead'; ap: number; guard: number
|
||||
healAvailable: boolean; participatingFloor: number; diedOnFloor: number | null
|
||||
@@ -19,7 +20,7 @@ export interface GoblinState {
|
||||
}
|
||||
export type PhaseState = { kind: 'dormant' } | {
|
||||
kind: 'player'; phaseId: string; startedAt: number; deadlineAt: number
|
||||
initialEligiblePlayerIds: string[]
|
||||
initialEligiblePlayerIds: string[]; timerResetPlayerIds: string[]
|
||||
} | { kind: 'enemy'; phaseId: string } | {
|
||||
kind: 'transition'; reason: 'floor-advance' | 'admin-reset'
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { GameSnapshot, TilePosition } from '../../domain/src/index.js'
|
||||
|
||||
export interface RenderEntity { id:string; kind:'player'|'goblin'|'exit'; position:TilePosition; label:string }
|
||||
export interface RenderEntity { id:string; kind:'player'|'goblin'|'exit'; position:TilePosition; label:string; color?:string }
|
||||
export function toRenderModel(snapshot:GameSnapshot):{width:number;height:number;tiles:string[][];entities:RenderEntity[]} {
|
||||
const entities:RenderEntity[]=[{id:'exit',kind:'exit',position:snapshot.floor.exitPosition,label:'Exit'}]
|
||||
for(const p of snapshot.players)if(p.lifeState==='alive')entities.push({id:p.twitchUserId,kind:'player',position:p.position,label:p.displayName})
|
||||
for(const p of snapshot.players)if(p.lifeState==='alive')entities.push({id:p.twitchUserId,kind:'player',position:p.position,label:p.displayName,color:p.color})
|
||||
if(snapshot.goblin.mode!=='dead')entities.push({id:'goblin',kind:'goblin',position:snapshot.goblin.position,label:'Goblin'})
|
||||
return {width:snapshot.floor.width,height:snapshot.floor.height,tiles:snapshot.floor.tiles,entities}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ function envelope(game:Game,command:any,requestId='r1') {const s=game.snapshot()
|
||||
|
||||
describe('authoritative game core',()=>{
|
||||
it('AT-007 stays dormant until an eligible follower spawns',()=>{const {game,advance}=harness();advance(200_000);game.tick();expect(game.snapshot().phase.kind).toBe('dormant');expect(game.spawn({type:'spawn-requested',externalEventId:'x',twitchUserId:'u',displayName:'Nope',broadcasterId:'b',followerVerified:false}).reason).toBe('NOT_FOLLOWER');expect(spawn(game).accepted).toBe(true);expect(game.snapshot().phase.kind).toBe('player')})
|
||||
it.each([[1,25_000],[2,50_000],[3,75_000],[4,100_000],[5,120_000],[6,120_000]])('AT-016 gives %i living players a %i ms capped phase',(count,duration)=>{const {game}=harness();for(let i=1;i<=count;i++)spawn(game,`u${i}`,`P${i}`,`s${i}`);(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();const phase=game.snapshot().phase;expect(phase.kind).toBe('player');if(phase.kind==='player')expect(phase.deadlineAt-phase.startedAt).toBe(duration)})
|
||||
it.each([1,2,3,4])('AT-016 gives %i living players a five-second phase',(count)=>{const {game}=harness();for(let i=1;i<=count;i++)spawn(game,`u${i}`,`P${i}`,`s${i}`);(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();const phase=game.snapshot().phase;expect(phase.kind).toBe('player');if(phase.kind==='player'){expect(phase.deadlineAt-phase.startedAt).toBe(5_000);expect(phase.timerResetPlayerIds).toEqual([])}})
|
||||
it('caps a level at four deterministic party slots and dead players retain their slot',()=>{const {game}=harness();for(let i=1;i<=4;i++)expect(spawn(game,`u${i}`,`P${i}`,`s${i}`).accepted).toBe(true);expect(game.snapshot().players.map(p=>[p.partySlot,p.color])).toEqual([[1,'blue'],[2,'green'],[3,'red'],[4,'yellow']]);(game as any).killPlayer((game as any).state.players.u2);expect(spawn(game,'u5','Five','s5')).toMatchObject({accepted:false,reason:'PARTY_FULL'});expect(game.snapshot().players).toHaveLength(4)})
|
||||
it('AT-010 prevents duplicate characters',()=>{const {game}=harness();expect(spawn(game).accepted).toBe(true);expect(spawn(game,'u1','One','spawn-2').reason).toBe('DUPLICATE');expect(game.snapshot().players).toHaveLength(1)})
|
||||
it('AT-014 spends AP once and deduplicates request IDs',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const env=envelope(game,{type:'pass'});expect(game.command('u1',true,env).accepted).toBe(true);expect(game.snapshot().players[0]?.ap).toBe(1);expect(game.command('u1',true,env).reason).toBe('DUPLICATE');expect(game.snapshot().players[0]?.ap).toBe(1)})
|
||||
it('AT-017 ends early after the initial eligible set spends AP',()=>{const {game}=harness([1,1]);spawn(game);game.bindExtension('u1');game.command('u1',true,envelope(game,{type:'pass'},'p1'));game.command('u1',true,envelope(game,{type:'pass'},'p2'));expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players[0]?.ap).toBe(2)})
|
||||
it('resets the rolling timer only for each player first valid action and ends when all finish',()=>{const {game,advance}=harness([1,1]);spawn(game,'u1','One');spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();const firstPhase=game.snapshot().phase;if(firstPhase.kind!=='player')throw new Error();advance(1_000);game.command('u1',true,envelope(game,{type:'pass'},'a1'));let phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();expect(phase).toMatchObject({deadlineAt:7_000,timerResetPlayerIds:['u1']});advance(1_000);game.command('u1',true,envelope(game,{type:'pass'},'a2'));phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();expect(phase.deadlineAt).toBe(7_000);advance(1_000);game.command('u2',true,envelope(game,{type:'pass'},'b1'));phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();expect(phase).toMatchObject({deadlineAt:9_000,timerResetPlayerIds:['u1','u2']});game.command('u2',true,envelope(game,{type:'pass'},'b2'));phase=game.snapshot().phase;expect(phase.kind).toBe('player');if(phase.kind==='player')expect(phase.phaseId).not.toBe(firstPhase.phaseId)})
|
||||
it('expires after five seconds and converts unused AP into AutoGuard for the Enemy Phase',()=>{const {game,advance}=harness([0,0]);spawn(game);game.bindExtension('u1');const internal=(game as any).state;internal.players.u1.position={x:internal.goblin.position.x-1,y:internal.goblin.position.y};internal.goblin.mode='pursuing';internal.goblin.targetPlayerId='u1';advance(5_000);game.tick();expect(game.snapshot().players[0]).toMatchObject({hp:3,ap:2,guard:0});expect(game.snapshot().actionLog.filter(entry=>entry.type==='guard-blocked')).toHaveLength(2)})
|
||||
it('AutoGuards a player immediately when their last connection closes',()=>{const {game}=harness();spawn(game);spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();expect(game.disconnect('u2')).toBe(true);expect(game.snapshot().players.find(p=>p.twitchUserId==='u2')).toMatchObject({connectionState:'disconnected',ap:0,guard:2,eligibleThisPhase:false});expect(game.snapshot().phase.kind).toBe('player')})
|
||||
it('does not wait for a disconnected player in later phases',()=>{const {game}=harness([1,1]);spawn(game);spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');game.disconnect('u2');game.command('u1',true,envelope(game,{type:'pass'},'p1'));game.command('u1',true,envelope(game,{type:'pass'},'p2'));expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players.find(p=>p.twitchUserId==='u1')).toMatchObject({ap:2,connectionState:'connected'});expect(game.snapshot().players.find(p=>p.twitchUserId==='u2')).toMatchObject({ap:0,guard:2,connectionState:'disconnected'})})
|
||||
it('supports administrative player removal, phase completion, and run reset',()=>{const {game}=harness();spawn(game);spawn(game,'u2','Two');const firstRun=game.snapshot().runId;expect(game.removePlayer('u2')).toBe(true);expect(game.removePlayer('missing')).toBe(false);expect(game.snapshot().players.map(p=>p.twitchUserId)).toEqual(['u1']);expect(game.forceEndPlayerPhase()).toBe(true);game.resetRunByAdmin();expect(game.snapshot().runId).not.toBe(firstRun);expect(game.snapshot().actionLog.at(-2)?.type).toBe('admin-reset')})
|
||||
@@ -26,4 +29,5 @@ describe('authoritative game core',()=>{
|
||||
it('AT-025 resurrects only the matching dead player once for the configured Channel Points reward',()=>{const {game}=harness();spawn(game);const p=(game as any).state.players.u1;p.lifeState='dead';p.hp=0;p.healAvailable=false;p.diedOnFloor=1;expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'redemption',twitchUserId:'u1',rewardId:'wrong'}).reason).toBe('WRONG_REWARD');expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'redemption-2',twitchUserId:'u1',rewardId:'resurrection'}).accepted).toBe(true);expect(game.snapshot().players[0]).toMatchObject({lifeState:'alive',hp:3,ap:0,healAvailable:false});expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'redemption-2',twitchUserId:'u1',rewardId:'resurrection'}).reason).toBe('DUPLICATE')})
|
||||
it('returns to dormant when the final living player dies and resumes on resurrection',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const before=game.snapshot();(game as any).killPlayer((game as any).state.players.u1);expect(game.snapshot()).toMatchObject({runId:before.runId,floorNumber:before.floorNumber,phase:{kind:'dormant'},players:[{lifeState:'dead',hp:0}]});expect(game.snapshot().actionLog.at(-1)?.type).toBe('party-defeated');expect(game.resurrect({type:'channel-point-resurrection-redeemed',externalEventId:'return',twitchUserId:'u1',rewardId:'resurrection'}).accepted).toBe(true);expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players[0]).toMatchObject({lifeState:'alive',ap:2})})
|
||||
it('escapes with a living Goblin and rejects the old floor envelope',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const old=envelope(game,{type:'pass'},'old');const internal=(game as any).state,exit=internal.floor.exitPosition;internal.players.u1.position={x:exit.x-1,y:exit.y};const direction=exit.x>internal.players.u1.position.x?'right':'left';expect(game.command('u1',true,envelope(game,{type:'move',direction},'exit')).accepted).toBe(true);expect(game.snapshot().floorNumber).toBe(2);expect(game.command('u1',true,{...old,requestId:'old2'}).reason).toBe('STALE_FLOOR')})
|
||||
it('clears the party at the exit and assigns next-level slots first come first served',()=>{const {game}=harness();for(let i=1;i<=4;i++){spawn(game,`u${i}`,`P${i}`,`s${i}`);game.bindExtension(`u${i}`)}const internal=(game as any).state,exit=internal.floor.exitPosition;internal.players.u1.position={x:exit.x-1,y:exit.y};const direction=exit.x>internal.players.u1.position.x?'right':'left';expect(game.command('u1',true,envelope(game,{type:'move',direction},'exit')).accepted).toBe(true);expect(game.snapshot()).toMatchObject({floorNumber:2,phase:{kind:'dormant'},players:[]});expect(game.personalizedSnapshot('u1').viewer).toBeNull();expect(spawn(game,'u5','Five','next-5').accepted).toBe(true);expect(spawn(game,'u1','One','next-1').accepted).toBe(true);expect(game.snapshot().players.map(p=>[p.twitchUserId,p.partySlot,p.color])).toEqual([['u5',1,'blue'],['u1',2,'green']]);expect(game.personalizedSnapshot('u1').viewer).toMatchObject({twitchUserId:'u1',partySlot:2,color:'green'})})
|
||||
})
|
||||
|
||||
@@ -9,11 +9,12 @@ async function post(path:string,value:unknown,token?:string){return fetch(`${bas
|
||||
describe('backend boundary',()=>{
|
||||
it('AT-001 exposes a secret-free readiness response',async()=>{const data=await fetch(`${base}/health`).then(r=>r.json());expect(data).toMatchObject({status:'ok',ready:true,twitchMode:'synthetic'});expect(JSON.stringify(data)).not.toMatch(/secret|token/i)})
|
||||
it('reports OAuth readiness without exposing credentials',async()=>{const data=await fetch(`${base}/oauth/status`).then(r=>r.json());expect(data).toMatchObject({configured:false,authorized:false});expect(JSON.stringify(data)).not.toMatch(/clientId|clientSecret|accessToken|refreshToken/)})
|
||||
it('serves a transparent responsive Twitch overlay controller',async()=>{const [pageResponse,rootResponse,scriptResponse,styleResponse]=await Promise.all([fetch(`${base}/extension`),fetch(`${base}/`),fetch(`${base}/app.js?v=20260817-overlay`),fetch(`${base}/styles.css`)]),[html,rootHtml,script,styles]=await Promise.all([pageResponse.text(),rootResponse.text(),scriptResponse.text(),styleResponse.text()]);expect(pageResponse.headers.get('cache-control')).toBe('no-store');expect(scriptResponse.headers.get('cache-control')).toBe('no-store');expect(styleResponse.headers.get('cache-control')).toBe('no-store');expect(html).toContain('<html lang="en" class="extension-mode">');expect(rootHtml).not.toContain('<html lang="en" class="extension-mode">');expect(rootHtml).toContain('Activate the Extension to spawn your character in the Twungeon!');expect(rootHtml).toContain('<span class="banner-note">Must be a follower to spawn.</span>');expect(html).toContain('/app.js?v=20260817-overlay');expect(html).toContain('id="controlRegion"');expect(html).toContain('id="spawnExtension"');expect(html).toContain('id="shareIdentity"');expect(script).toContain("location.pathname==='/extension'");expect(script).toContain("['localhost','127.0.0.1','[::1]']");expect(script).toContain('/api/extension/spawn');expect(script).toContain('/api/commands');expect(script).toContain('viewer?.isLinked');expect(script).toContain('actions.requestIdShare()');expect(styles).toContain('--controls-left:');expect(styles).toContain('html.extension-mode { color-scheme: normal; }');expect(styles).toContain('background: transparent');expect(styles).toContain('pointer-events: none');expect(styles).toContain('#controller button { pointer-events: auto')})
|
||||
it('serves responsive desktop and mobile Twitch controls',async()=>{const [pageResponse,rootResponse,scriptResponse,styleResponse]=await Promise.all([fetch(`${base}/extension`),fetch(`${base}/`),fetch(`${base}/app.js?v=20260817-overlay`),fetch(`${base}/styles.css`)]),[html,rootHtml,script,styles]=await Promise.all([pageResponse.text(),rootResponse.text(),scriptResponse.text(),styleResponse.text()]);expect(pageResponse.headers.get('cache-control')).toBe('no-store');expect(scriptResponse.headers.get('cache-control')).toBe('no-store');expect(styleResponse.headers.get('cache-control')).toBe('no-store');expect(html).toContain('<html lang="en" class="extension-mode">');expect(rootHtml).not.toContain('<html lang="en" class="extension-mode">');expect(rootHtml).toContain('Activate the Extension to spawn your character in the Twungeon!');expect(rootHtml).toContain('<span class="banner-note">Must be a follower to spawn.</span>');expect(html).toContain('/app.js?v=20260817-overlay');expect(html).toContain('id="controlRegion"');expect(html).toContain('id="viewerStatus"');expect(html).toContain('id="partyLegend"');expect(html).toContain('id="spawnExtension"');expect(html).toContain('id="shareIdentity"');expect(script).toContain("location.pathname==='/extension'");expect(script).toContain("['localhost','127.0.0.1','[::1]']");expect(script).toContain('onContext');expect(script).toContain('extension-mobile');expect(script).toContain('partySlot');expect(script).toContain("blue:'#3f8cff',green:'#38c976',red:'#ef5350',yellow:'#f2c94c'");expect(script).toContain('/api/extension/spawn');expect(script).toContain('/api/commands');expect(script).toContain('viewer?.isLinked');expect(script).toContain('actions.requestIdShare()');expect(styles).toContain('--controls-left:');expect(styles).toContain('html.extension-mode { color-scheme: normal; }');expect(styles).toContain('background: transparent');expect(styles).toContain('pointer-events: none');expect(styles).toContain('#controller button { pointer-events: auto');expect(styles).toContain('.entity.player.stacked.slot-4');expect(styles).toContain('min-height: 56px');expect(styles).toContain('min-height: 52px')})
|
||||
it('serves a public privacy notice for identity linking',async()=>{const response=await fetch(`${base}/privacy.html`),html=await response.text();expect(response.status).toBe(200);expect(html).toContain('Twungeon Privacy Notice');expect(html).toContain('numeric Twitch user ID')})
|
||||
it('AT-005 establishes a session without creating a character',async()=>{const r=await post('/api/extension/session',{token:'dev:nobody:Nobody'});expect(r.status).toBe(200);const data=await r.json();expect(data.state.viewer).toBeNull()})
|
||||
it('AT-011 binds the same stable chat and Extension identity',async()=>{const id=`viewer-${Date.now()}`;expect((await post('/api/dev/spawn',{command:'!spawn',externalEventId:`e-${id}`,twitchUserId:id,displayName:'Viewer',followerVerified:true})).status).toBe(200);const auth=await (await post('/api/extension/session',{token:`dev:${id}:Viewer`})).json();expect(auth.twitchUserId).toBe(id);expect(auth.state.viewer.extensionBound).toBe(true);const s=auth.state,phase=s.phase;expect(phase.kind).toBe('player');const command=await post('/api/commands',{requestId:`r-${id}`,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}},auth.token);expect(command.status).toBe(200)})
|
||||
it('spawns from an authenticated Extension identity',async()=>{const id=`button-${Date.now()}`,auth=await (await post('/api/extension/session',{token:`dev:${id}:Button Viewer`})).json();expect((await post('/api/extension/spawn',{},auth.token)).status).toBe(200);const state=await fetch(`${base}/api/state`).then(r=>r.json());expect(state.players.some((player:any)=>player.twitchUserId===id)).toBe(true);expect((await post('/api/extension/spawn',{})).status).toBe(401)})
|
||||
it('rejects the fifth Extension spawn with a clear full-party result',async()=>{let state=await fetch(`${base}/api/state`).then(r=>r.json());for(let i=state.players.length;i<4;i++){const id=`fill-${Date.now()}-${i}`,auth=await (await post('/api/extension/session',{token:`dev:${id}:Fill ${i}`})).json();expect((await post('/api/extension/spawn',{},auth.token)).status).toBe(200)}state=await fetch(`${base}/api/state`).then(r=>r.json());expect(state.players).toHaveLength(4);const spectator=`spectator-${Date.now()}`,auth=await (await post('/api/extension/session',{token:`dev:${spectator}:Spectator`})).json(),response=await post('/api/extension/spawn',{},auth.token),result=await response.json();expect(response.status).toBe(409);expect(result).toMatchObject({accepted:false,reason:'PARTY_FULL',message:'The current party is full. You can try again on the next level.'});expect(result.state.viewer).toBeNull()})
|
||||
it('AT-012 rejects unauthenticated controls',async()=>{const s=await fetch(`${base}/api/state`).then(r=>r.json()),phase=s.phase;const r=await post('/api/commands',{requestId:'unauth',runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}});expect(r.status).toBe(409);expect((await r.json()).reason).toBe('UNAUTHENTICATED')})
|
||||
it('AT-025 normalizes Channel Points redemptions and rejects unknown owners safely',async()=>{const r=await post('/api/dev/redemption',{externalEventId:'redemption-unknown',twitchUserId:'missing-viewer',rewardId:'local-resurrection'});expect(r.status).toBe(409);expect((await r.json()).reason).toBe('UNKNOWN_PLAYER')})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user