Add private admin console and disconnect handling
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})})
|
||||
})
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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})})
|
||||
})
|
||||
Reference in New Issue
Block a user