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
+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 {