238 lines
18 KiB
TypeScript
238 lines
18 KiB
TypeScript
import type { ChannelPointRedemption, CommandEnvelope, CommandResult, RejectionReason, SpawnMessage } from '../../contracts/src/index.js'
|
|
import type { ActionLogEntry, Clock, FloorState, GoblinState, IdGenerator, PlayerState, RandomProvider, RunState, TilePosition } from './types.js'
|
|
|
|
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'|'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 }
|
|
|
|
const copy = <T>(value: T): T => structuredClone(value)
|
|
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
|
|
private readonly commandResults = new Map<string, CommandResult>()
|
|
private readonly externalEvents = new Set<string>()
|
|
|
|
constructor(private readonly deps: GameDependencies, seed = 'twungeon-1') {
|
|
if (!deps.resurrectionRewardId.trim()) throw new Error('resurrectionRewardId is required')
|
|
const floor = deps.generateFloor(seed)
|
|
this.state = {
|
|
runId: deps.ids.next('run'), floorNumber: 1, floor, phase: {kind:'dormant'}, players: {},
|
|
goblin: this.newGoblin(floor), actionLog: [], nextEventSequence: 1
|
|
}
|
|
this.log('floor-created', null, null, 'Floor 1 awaits its first adventurer.')
|
|
}
|
|
|
|
snapshot(): GameSnapshot {
|
|
return {...copy(this.state), players:Object.values(copy(this.state.players)), serverTime:this.deps.clock.now()}
|
|
}
|
|
personalizedSnapshot(twitchUserId: string): GameSnapshot & { viewer: PlayerState | null } {
|
|
const snapshot = this.snapshot()
|
|
return {...snapshot, viewer:snapshot.players.find(p=>p.twitchUserId===twitchUserId) ?? null}
|
|
}
|
|
|
|
bindExtension(twitchUserId: string, connected = true): boolean {
|
|
const player=this.state.players[twitchUserId]; if(!player) return false
|
|
player.extensionBound=true; player.connectionState=connected?'connected':'disconnected'; return true
|
|
}
|
|
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()}
|
|
|
|
spawn(message: SpawnMessage): SpawnResult {
|
|
if(this.externalEvents.has(message.externalEventId)) return {accepted:false,reason:'DUPLICATE',message:'That spawn request was already handled.'}
|
|
this.externalEvents.add(message.externalEventId)
|
|
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 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()
|
|
return {accepted:true,reason:null,message:`${p.displayName} spawned.`}
|
|
}
|
|
|
|
resurrect(message: ChannelPointRedemption): ResurrectionResult {
|
|
if(this.externalEvents.has(message.externalEventId)) return {accepted:false,reason:'DUPLICATE',message:'That Channel Points redemption was already handled.'}
|
|
this.externalEvents.add(message.externalEventId)
|
|
if(message.rewardId!==this.deps.resurrectionRewardId) return {accepted:false,reason:'WRONG_REWARD',message:'That Channel Points reward does not resurrect characters.'}
|
|
const p=this.state.players[message.twitchUserId]
|
|
if(!p) return {accepted:false,reason:'UNKNOWN_PLAYER',message:'No character is bound to that viewer.'}
|
|
if(p.lifeState!=='dead') return {accepted:false,reason:'PLAYER_ALIVE',message:'That character is already alive.'}
|
|
p.lifeState='alive'; p.hp=3; p.ap=0; p.guard=0; p.eligibleThisPhase=false; p.diedOnFloor=null; p.position=copy(this.state.floor.spawnTiles[0]!)
|
|
this.log('player-resurrected',p.twitchUserId,null,`${p.displayName} rose again at the spawn!`)
|
|
if(this.state.phase.kind==='dormant')this.startPlayerPhase()
|
|
return {accepted:true,reason:null,message:`${p.displayName} resurrected.`}
|
|
}
|
|
|
|
command(twitchUserId: string | null, bound: boolean, envelope: CommandEnvelope, arrivedAt = this.deps.clock.now()): CommandResult {
|
|
const requestKey=`${twitchUserId??'anonymous'}:${envelope.requestId}`
|
|
const cached=this.commandResults.get(requestKey)
|
|
if(cached) return {...cached,accepted:false,reason:'DUPLICATE',message:'Duplicate request; the original result was not applied again.'}
|
|
const before=copy(this.state)
|
|
let rejection: RejectionReason | null=null
|
|
if(!twitchUserId) rejection='UNAUTHENTICATED'
|
|
else if(!bound) rejection='IDENTITY_NOT_BOUND'
|
|
else if(envelope.runId!==this.state.runId) rejection='STALE_RUN'
|
|
else if(envelope.floorId!==this.state.floor.floorId) rejection='STALE_FLOOR'
|
|
else if(this.state.phase.kind!=='player') rejection='WRONG_PHASE'
|
|
else if(envelope.phaseId!==this.state.phase.phaseId) rejection='STALE_PHASE'
|
|
else if(arrivedAt>=this.state.phase.deadlineAt) rejection='DEADLINE_PASSED'
|
|
const player=twitchUserId?this.state.players[twitchUserId]:undefined
|
|
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 {
|
|
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
|
|
}
|
|
|
|
tick(): void {
|
|
if(this.state.phase.kind==='player' && this.deps.clock.now()>=this.state.phase.deadlineAt) this.endPlayerPhase()
|
|
}
|
|
|
|
private applyPlayerCommand(player: PlayerState, envelope: CommandEnvelope): RejectionReason|null {
|
|
const c=envelope.command
|
|
if(c.type==='move'){
|
|
const delta={up:{x:0,y:-1},down:{x:0,y:1},left:{x:-1,y:0},right:{x:1,y:0}}[c.direction]
|
|
const next={x:player.position.x+delta.x,y:player.position.y+delta.y}
|
|
const tile=this.state.floor.tiles[next.y]?.[next.x]
|
|
if(!tile || tile==='wall' || (this.state.goblin.mode!=='dead' && same(next,this.state.goblin.position))) return 'BLOCKED_TILE'
|
|
player.position=next; player.ap--; this.log('player-moved',player.twitchUserId,null,`${player.displayName} moved ${c.direction}.`)
|
|
if(tile==='exit') this.advanceFloor()
|
|
return null
|
|
}
|
|
if(c.type==='attack'){
|
|
if(c.targetId!=='goblin' || this.state.goblin.mode==='dead' || !adjacent(player.position,this.state.goblin.position)) return 'INVALID_TARGET'
|
|
player.ap--; const hit=this.deps.random.next()<0.65
|
|
if(this.state.goblin.mode==='guarding'){this.state.goblin.mode='pursuing';this.state.goblin.targetPlayerId=player.twitchUserId;this.log('goblin-aggro',player.twitchUserId,'goblin',`The Goblin fixes its gaze on ${player.displayName}!`)}
|
|
if(hit){this.state.goblin.hp=Math.max(0,this.state.goblin.hp-1);this.log('player-hit',player.twitchUserId,'goblin',`${player.displayName} hits the Goblin for 1 damage.`);if(this.state.goblin.hp===0){this.state.goblin.mode='dead';this.state.goblin.targetPlayerId=null;this.log('goblin-died',player.twitchUserId,'goblin','The Goblin falls!')}}
|
|
else this.log('player-missed',player.twitchUserId,'goblin',`${player.displayName} misses the Goblin.`)
|
|
return null
|
|
}
|
|
if(c.type==='heal-self'){
|
|
if(!player.healAvailable) return 'HEAL_USED'
|
|
player.ap--;player.hp=3;player.healAvailable=false;this.log('player-healed',player.twitchUserId,player.twitchUserId,`${player.displayName} restores their HP.`);return null
|
|
}
|
|
player.ap--;this.log('player-passed',player.twitchUserId,null,`${player.displayName} waits and keeps watch.`);return null
|
|
}
|
|
|
|
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()
|
|
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: 5 seconds.')
|
|
}
|
|
private finishPlayerPhaseIfReady(): void {
|
|
if(this.state.phase.kind!=='player') return
|
|
const done=this.state.phase.initialEligiblePlayerIds.every(id=>{const p=this.state.players[id];return !p || p.lifeState==='dead' || p.ap===0})
|
|
if(done) this.endPlayerPhase()
|
|
}
|
|
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}
|
|
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
|
|
while(ap-->0){
|
|
const g=this.state.goblin
|
|
if(g.mode==='pursuing'){
|
|
const target=g.targetPlayerId?this.state.players[g.targetPlayerId]:undefined
|
|
if(!target || target.lifeState==='dead'){g.mode='returning';g.targetPlayerId=null;continue}
|
|
if(adjacent(g.position,target.position)){
|
|
const hit=this.deps.random.next()<0.5
|
|
if(hit && target.guard>0){target.guard--;this.log('guard-blocked','goblin',target.twitchUserId,`${target.displayName}'s Guard blocks the hit.`)}
|
|
else if(hit){target.hp--;this.log('goblin-hit','goblin',target.twitchUserId,`The Goblin hits ${target.displayName} for 1 damage.`);if(target.hp<=0){this.killPlayer(target);if(this.state.phase.kind==='dormant')return}}
|
|
else this.log('goblin-missed','goblin',target.twitchUserId,`The Goblin misses ${target.displayName}.`)
|
|
} else { const step=this.nextPathStep(g.position,target.position);if(!step)break;g.position=step;this.log('goblin-moved','goblin',target.twitchUserId,'The Goblin closes in.') }
|
|
} else if(g.mode==='returning'){
|
|
if(same(g.position,g.guardPosition)){g.mode='guarding';break}
|
|
const step=this.nextPathStep(g.position,g.guardPosition);if(!step)break;g.position=step
|
|
this.log('goblin-returned','goblin',null,'The Goblin returns toward its post.')
|
|
if(same(g.position,g.guardPosition))g.mode='guarding'
|
|
} else break
|
|
}
|
|
}
|
|
private killPlayer(player: PlayerState): void {
|
|
player.hp=0;player.lifeState='dead';player.ap=0;player.guard=0;player.eligibleThisPhase=false;player.diedOnFloor=this.state.floorNumber
|
|
this.log('player-died','goblin',player.twitchUserId,`${player.displayName} has fallen.`)
|
|
if(this.state.goblin.targetPlayerId===player.twitchUserId){this.state.goblin.mode='returning';this.state.goblin.targetPlayerId=null}
|
|
const all=Object.values(this.state.players);if(all.length>0 && all.every(p=>p.lifeState==='dead')){this.state.phase={kind:'dormant'};this.log('party-defeated',null,null,'The party has fallen. The dungeon is dormant until an adventurer returns.')}
|
|
}
|
|
private advanceFloor(): void {
|
|
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.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
|
|
const floor=this.deps.generateFloor(`${this.state.runId}-floor-1`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor)
|
|
this.restorePlayers();this.log('admin-reset',null,null,'An administrator reset the run.');this.startPlayerPhase()
|
|
}
|
|
private restorePlayers(): void { for(const p of Object.values(this.state.players)){p.hp=3;p.lifeState='alive';p.ap=0;p.guard=0;p.healAvailable=true;p.diedOnFloor=null;p.eligibleThisPhase=false;p.participatingFloor=this.state.floorNumber;p.position=copy(this.state.floor.spawnTiles[0]!)} }
|
|
private nextPathStep(start:TilePosition,goal:TilePosition):TilePosition|null {
|
|
const q:[TilePosition,TilePosition[]][]=[[start,[]]],seen=new Set([`${start.x},${start.y}`])
|
|
while(q.length){const [p,path]=q.shift()!;if(same(p,goal))return path[0]??null
|
|
for(const d of directions){const n={x:p.x+d.x,y:p.y+d.y},k=`${n.x},${n.y}`;if(seen.has(k)||this.state.floor.tiles[n.y]?.[n.x]==='wall')continue
|
|
if(Object.values(this.state.players).some(pl=>pl.lifeState==='alive'&&same(pl.position,n))&&!same(n,goal))continue
|
|
seen.add(k);q.push([n,[...path,n]])}}
|
|
return null
|
|
}
|
|
private newGoblin(floor:FloorState):GoblinState{return{hp:2,position:copy(floor.goblinGuardPosition),guardPosition:copy(floor.goblinGuardPosition),mode:'guarding',targetPlayerId:null}}
|
|
private log(type:string,actorId:string|null,targetId:string|null,message:string):ActionLogEntry {const entry={sequence:this.state.nextEventSequence++,runId:this.state.runId,floorNumber:this.state.floorNumber,phaseId:phaseId(this.state),type,actorId,targetId,message,occurredAt:new Date(this.deps.clock.now()).toISOString()};this.state.actionLog.push(entry);if(this.state.actionLog.length>200)this.state.actionLog.shift();return entry}
|
|
}
|
|
|
|
function safeName(name:string):string{return name.replace(/[<>]/g,'').slice(0,40)||'Adventurer'}
|
|
function reasonMessage(reason:RejectionReason):string{return ({UNAUTHENTICATED:'Sign in to control a character.',IDENTITY_NOT_BOUND:'This Extension identity is not bound to a character.',CHARACTER_NOT_FOUND:'Spawn through chat before using controls.',CHARACTER_DEAD:'Dead characters cannot act.',WRONG_PHASE:'Commands are only accepted during Player Phase.',STALE_RUN:'The run changed; refresh state.',STALE_FLOOR:'The floor changed; refresh state.',STALE_PHASE:'The phase changed; refresh state.',DEADLINE_PASSED:'The Player Phase deadline passed.',NO_AP:'No AP remains.',INVALID_TARGET:'That target is not valid or adjacent.',BLOCKED_TILE:'That tile cannot be entered.',HEAL_USED:'The self-heal was already used on this floor.',DUPLICATE:'That request was already handled.'})[reason]}
|