Add private admin console and disconnect handling

This commit is contained in:
2026-08-17 14:49:22 -07:00
parent 65e9917fd3
commit 8b1d14dcea
10 changed files with 201 additions and 9 deletions
+6
View File
@@ -12,3 +12,9 @@ TWITCH_CHANNEL_LOGIN=
TWITCH_EXTENSION_SECRET=
TWITCH_OAUTH_REDIRECT_URI=https://twungeon.example/oauth/callback
TWITCH_TOKEN_FILE=/var/lib/twungeon/twitch-token.json
# Private operator panel. The listener and request filter are restricted to 10.138.0.0/16.
ADMIN_ENABLED=false
ADMIN_HOST=10.138.4.44
ADMIN_PORT=3001
ADMIN_PASSWORD=
+2
View File
@@ -41,6 +41,8 @@ npm run build
Production startup uses `npm run build` followed by `npm start`. Configuration
is described in [.env.example](.env.example) and [docs/twitch-setup.md](docs/twitch-setup.md).
The optional private operator console is documented in
[docs/admin-panel.md](docs/admin-panel.md).
## Planning documents
+66
View File
@@ -0,0 +1,66 @@
import { timingSafeEqual } from 'node:crypto'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import type { Game } from '../../../packages/domain/src/index.js'
export interface AdminStatus {
uptimeSeconds:number
twitchReady:boolean
twitchMode:string
oauthConfigured:boolean
oauthAuthorized:boolean
}
export interface AdminServerDependencies {
game:Game
password:string
csrfToken:string
status():Promise<AdminStatus>
onStateChange():void
isAllowedAddress?(address:string|undefined):boolean
}
const escapeHtml=(value:unknown)=>String(value).replace(/[&<>"']/g,character=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]!))
const privateAddress=(address:string|undefined)=>Boolean(address?.replace(/^::ffff:/,'').startsWith('10.138.'))
const safeEqual=(left:string,right:string)=>{const a=Buffer.from(left),b=Buffer.from(right);return a.length===b.length&&timingSafeEqual(a,b)}
function authenticated(req:IncomingMessage,password:string):boolean {
const header=req.headers.authorization
if(!header?.startsWith('Basic '))return false
try{const [username,...parts]=Buffer.from(header.slice(6),'base64').toString('utf8').split(':');return username==='admin'&&safeEqual(parts.join(':'),password)}catch{return false}
}
async function formBody(req:IncomingMessage):Promise<URLSearchParams>{const chunks:Buffer[]=[];for await(const chunk of req){chunks.push(Buffer.from(chunk));if(chunks.reduce((size,item)=>size+item.length,0)>16_384)throw new Error('Request too large')}return new URLSearchParams(Buffer.concat(chunks).toString('utf8'))}
function headers(res:ServerResponse,status:number,type:string){res.writeHead(status,{'content-type':type,'cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",'x-content-type-options':'nosniff','x-frame-options':'DENY','referrer-policy':'no-referrer'})}
function redirect(res:ServerResponse,notice:string){res.writeHead(303,{location:`/?notice=${encodeURIComponent(notice)}`,'cache-control':'no-store'});res.end()}
export function createAdminServer(deps:AdminServerDependencies){
if(!deps.password)throw new Error('Admin password is required')
return createServer(async(req,res)=>{
try{
if(!(deps.isAllowedAddress??privateAddress)(req.socket.remoteAddress)){headers(res,403,'text/plain; charset=utf-8');return res.end('Private Twungeon network access required.')}
if(!authenticated(req,deps.password)){res.writeHead(401,{'www-authenticate':'Basic realm="Twungeon Admin", charset="UTF-8"','cache-control':'no-store'});return res.end('Authentication required.')}
const url=new URL(req.url??'/','http://admin.local')
if(req.method==='GET'&&url.pathname==='/health'){headers(res,200,'application/json');return res.end(JSON.stringify({status:'ok',...(await deps.status())}))}
if(req.method==='GET'&&url.pathname==='/'){headers(res,200,'text/html; charset=utf-8');return res.end(await render(deps,url.searchParams.get('notice')))}
if(req.method==='POST'&&url.pathname==='/action'){
const form=await formBody(req)
if(!safeEqual(form.get('_csrf')??'',deps.csrfToken)){headers(res,403,'text/plain; charset=utf-8');return res.end('Invalid form token.')}
const action=form.get('action'),userId=form.get('userId')??''
let notice='Unknown action.'
if(action==='disconnect')notice=deps.game.disconnect(userId)?'Player disconnected and AutoGuarded.':'Player was already disconnected or missing.'
else if(action==='remove')notice=deps.game.removePlayer(userId)?'Player removed.':'Player not found.'
else if(action==='force-phase')notice=deps.game.forceEndPlayerPhase()?'Player phase ended.':'There is no active player phase.'
else if(action==='reset-run'){deps.game.resetRunByAdmin();notice='Run reset.'}
else {headers(res,400,'text/plain; charset=utf-8');return res.end(notice)}
deps.onStateChange();return redirect(res,notice)
}
headers(res,404,'text/plain; charset=utf-8');res.end('Not found.')
}catch(error){console.error(JSON.stringify({level:'error',component:'admin',message:error instanceof Error?error.message:'Unexpected error'}));headers(res,500,'text/plain; charset=utf-8');res.end('Admin request failed.')}
})
}
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 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>`
}
+18 -1
View File
@@ -11,6 +11,10 @@ export interface Config {
twitchTokenFile: string
extensionSecret: string
oauthConfigured: boolean
adminEnabled: boolean
adminHost: string
adminPort: number
adminPassword: string
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
@@ -24,11 +28,20 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const twitchRedirectUri = env.TWITCH_OAUTH_REDIRECT_URI ?? `${publicBaseUrl}/oauth/callback`
const twitchTokenFile = env.TWITCH_TOKEN_FILE ?? '/var/lib/twungeon/twitch-token.json'
const oauthConfigured = Boolean(twitchClientId && twitchClientSecret && env.TWITCH_CHANNEL_LOGIN)
const adminEnabled = env.ADMIN_ENABLED === 'true'
const adminHost = env.ADMIN_HOST ?? '10.138.4.44'
const adminPort = Number(env.ADMIN_PORT ?? 3001)
const adminPassword = env.ADMIN_PASSWORD ?? ''
const missing: string[] = []
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be an integer from 1 to 65535')
}
if (!Number.isInteger(adminPort) || adminPort < 1 || adminPort > 65535 || adminPort===port) {
throw new Error('ADMIN_PORT must be an integer from 1 to 65535 and differ from PORT')
}
if(adminEnabled && !/^10\.138\.\d{1,3}\.\d{1,3}$/.test(adminHost))throw new Error('ADMIN_HOST must be an address on 10.138.0.0/16')
if(adminEnabled && adminPassword.length<16)throw new Error('ADMIN_PASSWORD must contain at least 16 characters when admin is enabled')
if (!resurrectionRewardId.trim()) {
throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required')
}
@@ -69,6 +82,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
twitchRedirectUri,
twitchTokenFile,
extensionSecret: env.TWITCH_EXTENSION_SECRET ?? '',
oauthConfigured
oauthConfigured,
adminEnabled,
adminHost,
adminPort,
adminPassword
}
}
+19 -3
View File
@@ -1,4 +1,5 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { randomBytes } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -9,6 +10,7 @@ import { generateFloor } from '../../../packages/dungeon-generator/src/index.js'
import { LiveTwitchAdapter, SyntheticTwitchAdapter } from '../../../packages/twitch-adapter/src/index.js'
import { loadConfig } from './config.js'
import { TwitchOAuthService } from './twitchOAuth.js'
import { createAdminServer } from './admin.js'
const config=loadConfig(), startedAt=Date.now();let id=0
const game=new Game({clock:{now:()=>Date.now()},random:{next:()=>Math.random()},ids:{next:p=>`${p}-${++id}`},generateFloor,resurrectionRewardId:config.resurrectionRewardId})
@@ -23,7 +25,18 @@ function html(res:ServerResponse,status:number,title:string,message:string){cons
async function body(req:IncomingMessage):Promise<unknown>{const chunks:Buffer[]=[];for await(const chunk of req)chunks.push(Buffer.from(chunk));if(chunks.reduce((n,b)=>n+b.length,0)>64_000)throw new Error('Request too large');return JSON.parse(Buffer.concat(chunks).toString('utf8')||'{}')}
function bearer(req:IncomingMessage):string|null{const h=req.headers.authorization;return h?.startsWith('Bearer ')?h.slice(7):null}
function sessionUser(token:string|null):string|null{if(!token)return null;const session=sessions.get(token);if(!session||session.expiresAt<=Date.now()){if(session)sessions.delete(token);return null}return session.userId}
function connectedClientCount(userId:string):number{let count=0;for(const clientUserId of clients.values())if(clientUserId===userId)count++;return count}
function authenticateClient(ws:WebSocket,userId:string|null):boolean{
const previousUserId=clients.get(ws)??null
if(previousUserId===userId)return false
clients.set(ws,userId)
let changed=false
if(previousUserId && connectedClientCount(previousUserId)===0)changed=game.disconnect(previousUserId)||changed
if(userId)changed=game.bindExtension(userId,true)||changed
return changed
}
function broadcast(){const sequence=game.snapshot().nextEventSequence-1;for(const [ws,userId] of clients)if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({type:'snapshot',sequence,state:userId?game.personalizedSnapshot(userId):game.snapshot()}))}
export const adminServer=config.adminEnabled?createAdminServer({game,password:config.adminPassword,csrfToken:randomBytes(32).toString('base64url'),onStateChange:broadcast,status:async()=>{const oauthStatus=oauth?await oauth.status():{configured:false,authorized:false};return{uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchReady:twitch.ready,twitchMode:config.twitchEnabled?'configured':'synthetic',oauthConfigured:oauthStatus.configured,oauthAuthorized:oauthStatus.authorized}}}):null
export const server=createServer(async(req,res)=>{
try{
@@ -53,7 +66,7 @@ export const server=createServer(async(req,res)=>{
if(req.method==='POST'&&url.pathname==='/api/extension/session'){
const data=await body(req) as any,identity=await twitch.verifyExtensionToken(String(data.token??''))
if(!identity)return json(res,401,{error:'UNAUTHENTICATED'})
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId)
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId,connectedClientCount(identity.twitchUserId)>0)
return json(res,200,{token,twitchUserId:identity.twitchUserId,state:game.personalizedSnapshot(identity.twitchUserId)})
}
if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){
@@ -78,7 +91,10 @@ export const server=createServer(async(req,res)=>{
})
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);clients.set(ws,userId);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',()=>clients.delete(ws))})
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(process.env.NODE_ENV!=='test')server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`))
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}`))
}
+38
View File
@@ -0,0 +1,38 @@
# Private admin panel
Twungeon can expose a separate server-rendered operator console on the private
`10.138.0.0/16` network. It is disabled by default and is never served from the
public application listener.
Configure the service environment:
```dotenv
ADMIN_ENABLED=true
ADMIN_HOST=10.138.4.44
ADMIN_PORT=3001
ADMIN_PASSWORD=use-a-unique-random-password-of-at-least-16-characters
```
Restart Twungeon, then open `http://10.138.4.44:3001` from a device on the
private network. Authenticate with username `admin` and the configured
password.
The process binds the panel to the configured `10.138.x.x` interface and also
rejects request source addresses outside `10.138.0.0/16`. Keep a host firewall
rule in place as a third boundary. For UFW, the intended policy is:
```bash
ufw allow from 10.138.0.0/16 to 10.138.4.44 port 3001 proto tcp
```
Do not publish or reverse-proxy this port. Basic authentication protects the
panel from other private-network users, while per-process form tokens protect
state-changing requests from cross-site submission. Because the listener uses
plain HTTP, use it only on the trusted private network; add internal TLS before
using it across an untrusted or shared network.
The panel displays service readiness, OAuth status, the current run and phase,
all players, and the latest 40 action-log entries. Operators can disconnect a
player into AutoGuard, remove a character, end the active player phase, or reset
the run. All mutations pass through the authoritative game core and broadcast
the resulting snapshot to connected viewers.
+31 -5
View File
@@ -42,7 +42,26 @@ export class Game {
const player=this.state.players[twitchUserId]; if(!player) return false
player.extensionBound=true; player.connectionState=connected?'connected':'disconnected'; return true
}
disconnect(twitchUserId: string): void { const p=this.state.players[twitchUserId]; if(p) p.connectionState='disconnected' }
disconnect(twitchUserId: string): boolean {
const player=this.state.players[twitchUserId]
if(!player || player.connectionState==='disconnected') return false
player.connectionState='disconnected'
if(this.state.phase.kind==='player' && player.lifeState==='alive' && player.ap>0) this.autoGuard(player)
this.finishPlayerPhaseIfReady()
return true
}
removePlayer(twitchUserId:string):boolean {
const player=this.state.players[twitchUserId]
if(!player)return false
delete this.state.players[twitchUserId]
if(this.state.goblin.targetPlayerId===twitchUserId){this.state.goblin.mode='returning';this.state.goblin.targetPlayerId=null}
this.log('player-removed',twitchUserId,null,`${player.displayName} was removed by an administrator.`)
if(Object.keys(this.state.players).length===0)this.state.phase={kind:'dormant'}
else this.finishPlayerPhaseIfReady()
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.')}
spawn(message: SpawnMessage): SpawnResult {
if(this.externalEvents.has(message.externalEventId)) return {accepted:false,reason:'DUPLICATE',message:'That spawn request was already handled.'}
@@ -127,9 +146,12 @@ 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}
for(const p of Object.values(this.state.players)){p.guard=0;p.eligibleThisPhase=p.lifeState==='alive';p.ap=p.lifeState==='alive'?2:0}
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)}
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.`)
}
private finishPlayerPhaseIfReady(): void {
@@ -139,10 +161,14 @@ export class Game {
}
private endPlayerPhase(): void {
if(this.state.phase.kind!=='player') return
for(const p of Object.values(this.state.players)){if(p.lifeState==='alive')p.guard=p.ap;p.ap=0;p.eligibleThisPhase=false}
for(const p of Object.values(this.state.players)){if(p.lifeState==='alive')p.guard+=p.ap;p.ap=0;p.eligibleThisPhase=false}
this.state.phase={kind:'enemy',phaseId:this.deps.ids.next('phase')};this.log('enemy-phase-started',null,null,'Enemy Phase begins.')
this.runGoblin(); if(this.state.phase.kind==='enemy') this.startPlayerPhase()
}
private autoGuard(player:PlayerState):void {
player.guard+=player.ap;player.ap=0;player.eligibleThisPhase=false
this.log('player-auto-guarded',player.twitchUserId,null,`${player.displayName} is disconnected and converts unused AP to Guard.`)
}
private runGoblin(): void {
if(this.state.goblin.mode==='dead' || this.state.goblin.mode==='guarding') return
let ap=2
@@ -176,10 +202,10 @@ export class Game {
const floor=this.deps.generateFloor(`${this.state.runId}-floor-${this.state.floorNumber}`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor)
this.restorePlayers();this.log('floor-advanced',null,null,`The party reaches Floor ${this.state.floorNumber}!`);this.startPlayerPhase()
}
private resetRun(): void {
private resetRun(logType='party-wipe',message='The party has fallen. A new run begins on Floor 1.'): void {
this.state.phase={kind:'transition',reason:'party-wipe'};this.state.runId=this.deps.ids.next('run');this.state.floorNumber=1
const floor=this.deps.generateFloor(`${this.state.runId}-floor-1`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor)
this.restorePlayers();this.log('party-wipe',null,null,'The party has fallen. A new run begins on Floor 1.');this.startPlayerPhase()
this.restorePlayers();this.log(logType,null,null,message);this.startPlayerPhase()
}
private restorePlayers(): void { for(const p of Object.values(this.state.players)){p.hp=3;p.lifeState='alive';p.ap=0;p.guard=0;p.healAvailable=true;p.diedOnFloor=null;p.eligibleThisPhase=false;p.participatingFloor=this.state.floorNumber;p.position=copy(this.state.floor.spawnTiles[0]!)} }
private nextPathStep(start:TilePosition,goal:TilePosition):TilePosition|null {
+1
View File
@@ -6,4 +6,5 @@ describe('Channel Points configuration',()=>{
it('allows OAuth setup before live Twitch mode is enabled',()=>expect(loadConfig({TWITCH_ENABLED:'false',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_CHANNEL_LOGIN:'labyricorn',PUBLIC_BASE_URL:'https://twungeon.example'})).toMatchObject({oauthConfigured:true,twitchRedirectUri:'https://twungeon.example/oauth/callback'}))
it('requires paired OAuth client credentials',()=>expect(()=>loadConfig({TWITCH_ENABLED:'false',TWITCH_CLIENT_ID:'id'})).toThrow(/configured together/))
it('requires the reward ID when Twitch is enabled',()=>expect(()=>loadConfig({TWITCH_ENABLED:'true',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_BROADCASTER_ID:'broadcaster',TWITCH_CHANNEL_LOGIN:'channel',TWITCH_EXTENSION_SECRET:'extension',CHANNEL_POINTS_RESURRECTION_REWARD_ID:''})).toThrow(/CHANNEL_POINTS_RESURRECTION_REWARD_ID/))
it('requires a private host and strong password for the admin listener',()=>{expect(()=>loadConfig({ADMIN_ENABLED:'true',ADMIN_PASSWORD:'short'})).toThrow(/ADMIN_PASSWORD/);expect(()=>loadConfig({ADMIN_ENABLED:'true',ADMIN_PASSWORD:'long-enough-password',ADMIN_HOST:'0.0.0.0'})).toThrow(/ADMIN_HOST/);expect(loadConfig({ADMIN_ENABLED:'true',ADMIN_PASSWORD:'long-enough-password'})).toMatchObject({adminEnabled:true,adminHost:'10.138.4.44',adminPort:3001})})
})
+3
View File
@@ -17,6 +17,9 @@ describe('authoritative game core',()=>{
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('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')})
it('rejects stale and deadline-boundary commands without AP loss',()=>{const {game,setTime}=harness();spawn(game);game.bindExtension('u1');const env=envelope(game,{type:'pass'});const phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();setTime(phase.deadlineAt);expect(game.command('u1',true,env).reason).toBe('DEADLINE_PASSED');expect(game.snapshot().players[0]?.ap).toBe(2);expect(game.command('u1',true,{...env,requestId:'stale',runId:'old'}).reason).toBe('STALE_RUN')})
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)})
+17
View File
@@ -0,0 +1,17 @@
import { once } from 'node:events'
import { afterEach, describe, expect, it } from 'vitest'
import { createAdminServer } from '../../apps/backend/src/admin.js'
import { Game, type Clock, type IdGenerator, type RandomProvider } from '../../packages/domain/src/index.js'
import { generateFloor } from '../../packages/dungeon-generator/src/index.js'
const servers:ReturnType<typeof createAdminServer>[]=[]
afterEach(async()=>{for(const server of servers.splice(0))if(server.listening){server.close();await once(server,'close')}})
function harness(allowed=true){let id=0;const clock:Clock={now:()=>1_000},random:RandomProvider={next:()=>0},ids:IdGenerator={next:prefix=>`${prefix}-${++id}`};const game=new Game({clock,random,ids,generateFloor,resurrectionRewardId:'resurrection'},'admin-test');game.spawn({type:'spawn-requested',externalEventId:'spawn',twitchUserId:'u1',displayName:'One',broadcasterId:'b',followerVerified:true});game.bindExtension('u1');const server=createAdminServer({game,password:'test-admin-password',csrfToken:'test-csrf-token',isAllowedAddress:()=>allowed,onStateChange:()=>{},status:async()=>({uptimeSeconds:10,twitchReady:true,twitchMode:'synthetic',oauthConfigured:false,oauthAuthorized:false})});servers.push(server);return{game,server}}
async function start(server:ReturnType<typeof createAdminServer>){server.listen(0,'127.0.0.1');await once(server,'listening');const address=server.address();if(!address||typeof address==='string')throw new Error('No admin address');return`http://127.0.0.1:${address.port}`}
const authorization=`Basic ${Buffer.from('admin:test-admin-password').toString('base64')}`
describe('private admin panel',()=>{
it('rejects non-private clients before authentication',async()=>{const {server}=harness(false),base=await start(server);expect((await fetch(base,{headers:{authorization}})).status).toBe(403)})
it('requires authentication and does not expose its password',async()=>{const {server}=harness(),base=await start(server);expect((await fetch(base)).status).toBe(401);const page=await (await fetch(base,{headers:{authorization}})).text();expect(page).toContain('Twungeon Admin');expect(page).toContain('One');expect(page).not.toContain('test-admin-password')})
it('requires CSRF tokens and applies administrative actions',async()=>{const {game,server}=harness(),base=await start(server);const invalid=await fetch(`${base}/action`,{method:'POST',headers:{authorization,'content-type':'application/x-www-form-urlencoded'},body:'action=disconnect&userId=u1'});expect(invalid.status).toBe(403);const valid=await fetch(`${base}/action`,{method:'POST',headers:{authorization,'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({_csrf:'test-csrf-token',action:'disconnect',userId:'u1'}),redirect:'manual'});expect(valid.status).toBe(303);expect(game.snapshot().players[0]).toMatchObject({connectionState:'disconnected',ap:0,guard:2})})
})