Refine party, timer, and Extension mechanics
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user