Skip to content

Commit 8df90e2

Browse files
committed
death flow enhancements
1 parent 0b51787 commit 8df90e2

7 files changed

Lines changed: 272 additions & 109 deletions

File tree

src/main/kotlin/dev/robothanzo/werewolf/database/documents/Player.kt

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import dev.robothanzo.werewolf.game.model.RoleEventContext
1010
import dev.robothanzo.werewolf.game.model.RoleEventType
1111
import dev.robothanzo.werewolf.game.roles.actions.RoleAction
1212
import io.swagger.v3.oas.annotations.media.Schema
13-
import kotlinx.coroutines.delay
14-
import kotlinx.coroutines.suspendCancellableCoroutine
15-
import kotlinx.coroutines.withTimeout
13+
import kotlinx.coroutines.*
1614
import net.dv8tion.jda.api.EmbedBuilder
1715
import net.dv8tion.jda.api.entities.Member
1816
import net.dv8tion.jda.api.entities.Role
@@ -344,7 +342,7 @@ data class Player(
344342
delay(1000)
345343
}
346344
}
347-
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
345+
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
348346
// Timeout logic - force expire if needed
349347
WerewolfApplication.gameSessionService.withLockedSession(session.guildId) { lockedSession ->
350348
val p = lockedSession.getPlayer(id) ?: return@withLockedSession
@@ -400,6 +398,66 @@ data class Player(
400398
}
401399
}
402400

401+
/**
402+
* Orchestrates the entire death sequence, including this player and any subsequent deaths
403+
* triggered by death events (e.g. Hunter shooting).
404+
*
405+
* @param onFinished Callback executed when ALL cascading deaths are resolved.
406+
*/
407+
@OptIn(DelicateCoroutinesApi::class)
408+
fun processCascadingDeaths(onFinished: () -> Unit) {
409+
val session = this.session ?: return
410+
val guildId = session.guildId
411+
412+
GlobalScope.launch {
413+
try {
414+
WerewolfApplication.gameSessionService.withLockedSession(guildId) { session ->
415+
session.stateData.deathProcessingInProgress = true
416+
}
417+
418+
// 1. Process THIS player first
419+
// On Day 1, everyone gets Last Words. On later days, usually only the first night death or similar.
420+
// For simplicity, we respect the allowLastWords flag passed to runDeathEvents,
421+
// but since this is the *entry point* for a death chain, we assume this player SHOULD get them
422+
// or specific logic inside runDeathEvents/call sites handles it.
423+
// For EXPEL/HUNTER cases on Day 1, they should get it.
424+
val isDayOne = session.day <= 1
425+
runDeathEvents(isDayOne)
426+
427+
// 2. Loop for others (Cascading Deaths)
428+
while (true) {
429+
// Refresh session to get latest state
430+
val currentSession =
431+
WerewolfApplication.gameSessionService.getSession(guildId).orElse(null) ?: break
432+
433+
// Find next unprocessed dead player
434+
// distinct from any specific logic, just "dead but not processed"
435+
val nextVictim = currentSession.players.values.firstOrNull {
436+
!it.alive && !currentSession.stateData.processedDeathPlayerIds.contains(it.id)
437+
} ?: break
438+
439+
try {
440+
// All cascading victims on Day 1 get last words (Request: "players killed by hunter's skill on the first day")
441+
nextVictim.runDeathEvents(isDayOne)
442+
} catch (e: Exception) {
443+
e.printStackTrace()
444+
// Failsafe: mark processed so we don't loop forever
445+
WerewolfApplication.gameSessionService.withLockedSession(guildId) { s ->
446+
s.stateData.processedDeathPlayerIds.add(nextVictim.id)
447+
}
448+
}
449+
}
450+
} catch (e: Exception) {
451+
e.printStackTrace()
452+
} finally {
453+
WerewolfApplication.gameSessionService.withLockedSession(guildId) { session ->
454+
session.stateData.deathProcessingInProgress = false
455+
}
456+
onFinished()
457+
}
458+
}
459+
}
460+
403461
companion object {
404462
val ID_FORMAT = DecimalFormat("00")
405463

src/main/kotlin/dev/robothanzo/werewolf/game/model/GameStateData.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,10 @@ data class GameStateData(
167167
var pendingNextStep: String? = null,
168168

169169
@Schema(description = "The reason for game end, if detected")
170-
var gameEndReason: String? = null
170+
var gameEndReason: String? = null,
171+
172+
@Schema(description = "Flag indicating if asynchronous death processing is currently active")
173+
var deathProcessingInProgress: Boolean = false
171174
) {
172175
// --- Transient fields for UI state synchronization ---
173176
@Transient

src/main/kotlin/dev/robothanzo/werewolf/game/steps/DeathAnnouncementStep.kt

Lines changed: 18 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ import dev.robothanzo.werewolf.game.roles.actions.RoleActionExecutor
1414
import dev.robothanzo.werewolf.service.GameSessionService
1515
import dev.robothanzo.werewolf.service.GameStateService
1616
import dev.robothanzo.werewolf.utils.CmdUtils
17-
import kotlinx.coroutines.DelicateCoroutinesApi
18-
import kotlinx.coroutines.GlobalScope
19-
import kotlinx.coroutines.launch
2017
import org.slf4j.LoggerFactory
2118
import org.springframework.context.annotation.Lazy
2219
import org.springframework.stereotype.Component
@@ -97,82 +94,27 @@ class DeathAnnouncementStep(
9794
gameSessionService.broadcastSessionUpdate(lockedSession)
9895
}
9996

100-
// Launch async death processing
101-
@OptIn(DelicateCoroutinesApi::class)
102-
GlobalScope.launch {
103-
while (true) {
104-
// Fetch fresh session to check for new deaths
105-
val currentSession = gameSessionService.getSession(guildId).orElse(null) ?: break
106-
107-
// Find players who are dead but haven't been processed yet
108-
val toProcess = currentSession.players.values
109-
.filter { !it.alive && !currentSession.stateData.processedDeathPlayerIds.contains(it.id) }
110-
.sortedBy { it.id } // Consistent order
111-
112-
if (toProcess.isEmpty()) {
113-
// Check if there are any pending death triggers (someone still choosing their shot)
114-
val hasPendingTriggers = currentSession.stateData.submittedActions.any { instance ->
115-
val isUnprocessed =
116-
instance.status != ActionStatus.PROCESSED && instance.status != ActionStatus.SKIPPED
117-
val action = instance.actionDefinitionId?.let { roleRegistry.getAction(it) }
118-
action?.timing == ActionTiming.DEATH_TRIGGER && isUnprocessed
119-
}
120-
121-
if (!hasPendingTriggers) break // No more deaths and no more pending shots
122-
123-
kotlinx.coroutines.delay(1000) // Wait for user to take action
124-
continue
125-
}
126-
127-
val player = toProcess.first()
128-
129-
// Only allow last words for first day
130-
val allowLastWords = currentSession.day == 1
131-
try {
132-
player.runDeathEvents(allowLastWords)
133-
134-
// Mark as processed after everything (last words, triggers, etc) is done
135-
gameSessionService.withLockedSession(guildId) { lockedSession ->
136-
if (!lockedSession.stateData.processedDeathPlayerIds.contains(player.id)) {
137-
lockedSession.stateData.processedDeathPlayerIds.add(player.id)
138-
139-
// Also ensure they are in the announced deadPlayers list for UI
140-
if (!lockedSession.stateData.deadPlayers.contains(player.id)) {
141-
val currentDead = lockedSession.stateData.deadPlayers.toMutableList()
142-
currentDead.add(player.id)
143-
lockedSession.stateData.deadPlayers = currentDead
144-
}
145-
}
146-
}
147-
} catch (e: Exception) {
148-
log.error("Error running death events for player ${player.id}", e)
149-
// Still mark as processed to avoid infinite loop on error
150-
gameSessionService.withLockedSession(guildId) { it.stateData.processedDeathPlayerIds.add(player.id) }
151-
}
152-
}
153-
154-
// Once all events are done, check if we can advance
155-
gameSessionService.withLockedSession(guildId) { lockedSession ->
156-
checkAdvance(lockedSession, service)
157-
}
97+
// Delegate death processing to the first available dead player (if any)
98+
// The processCascadingDeaths function will handle ALL unprocessed deaths in the session
99+
val currentSession = gameSessionService.getSession(guildId).orElse(null)
100+
val firstDead = currentSession?.players?.values?.sortedBy { it.id }?.firstOrNull {
101+
!it.alive && !currentSession.stateData.processedDeathPlayerIds.contains(it.id)
158102
}
159103

160-
// Schedule tasks for potential advancement (one-shot fallbacks in case async hangs or something)
161-
// Task 1: Minimum display time (10s as requested)
162-
CmdUtils.schedule({
163-
gameSessionService.withLockedSession(guildId) { currentSession ->
164-
checkAdvance(currentSession, service)
104+
if (firstDead != null) {
105+
log.info("Starting death processing chain from player ${firstDead.id}")
106+
firstDead.processCascadingDeaths {
107+
log.info("All death events processed. Advancing step.")
108+
service.nextStep(currentSession)
165109
}
166-
}, 10000)
167-
168-
// Task 2: Maximum duration (backup timeout)
169-
val duration = getDurationSeconds(session)
170-
if (duration > 10) {
110+
} else {
111+
// No deaths to process, just wait for minimum duration then advance
112+
log.info("No deaths to process in DeathAnnouncementStep. Scheduling advance.")
171113
CmdUtils.schedule({
172-
gameSessionService.withLockedSession(guildId) { currentSession ->
173-
checkAdvance(currentSession, service)
114+
gameSessionService.withLockedSession(guildId) { s ->
115+
service.nextStep(s)
174116
}
175-
}, duration * 1000L + 5000) // Small buffer
117+
}, 10000)
176118
}
177119
}
178120

@@ -211,8 +153,8 @@ class DeathAnnouncementStep(
211153
log.info("DeathAnnouncementStep timeout for guild ${session.guildId}. Advancing.")
212154
shouldAdvance = true
213155
} else if (now >= minTime) {
214-
if (!hasActiveTriggers && !hasUnprocessedDeaths && !hasOngoingEvents) {
215-
log.info("DeathAnnouncementStep early exit for guild ${session.guildId} (no active triggers/events). Advancing.")
156+
if (!hasActiveTriggers && !hasUnprocessedDeaths && !hasOngoingEvents && !session.stateData.deathProcessingInProgress) {
157+
log.info("DeathAnnouncementStep early exit for guild ${session.guildId} (no active triggers/events/processing). Advancing.")
216158
shouldAdvance = true
217159
}
218160
}

src/main/kotlin/dev/robothanzo/werewolf/game/steps/NightStep.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class NightStep(
5151

5252
private val log = LoggerFactory.getLogger(NightStep::class.java)
5353
internal val nightScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
54+
private val orchestrationJobs = java.util.concurrent.ConcurrentHashMap<Long, Job>()
5455

5556
override fun onStart(session: Session, service: GameStateService) {
5657
val guildId = session.guildId
@@ -100,11 +101,16 @@ class NightStep(
100101
}
101102

102103
// Orchestrate the night phases
103-
nightScope.launch {
104+
orchestrationJobs[guildId]?.cancel()
105+
orchestrationJobs[guildId] = nightScope.launch {
104106
try {
105107
processNightPhases(guildId, service)
108+
} catch (e: CancellationException) {
109+
log.info("Night orchestration for guild $guildId was cancelled")
106110
} catch (e: Exception) {
107111
log.error("Error during night orchestration for guild $guildId", e)
112+
} finally {
113+
orchestrationJobs.remove(guildId, coroutineContext[Job])
108114
}
109115
}
110116
}
@@ -175,12 +181,31 @@ class NightStep(
175181
}
176182

177183
override fun onEnd(session: Session, service: GameStateService) {
184+
val guildId = session.guildId
185+
log.info("Ending NightStep for guild $guildId. Cleaning up state.")
186+
187+
// 1. Cancel orchestration coroutine
188+
orchestrationJobs.remove(guildId)?.cancel()
189+
190+
// 2. Clear phase signals
191+
phaseSignals.remove(guildId)
192+
193+
// 3. Cleanup UI prompts
178194
actionUIService.cleanupExpiredPrompts(session)
179195

196+
// 4. Clear transient night state
180197
session.stateData.phaseType = null
181198
session.stateData.phaseStartTime = 0
182199
session.stateData.phaseEndTime = 0
183200
session.stateData.wolfBrotherAwakenedPlayerId = null
201+
202+
// 5. Clear any PENDING or ACTING actions (Keep only SUBMITTED/SKIPPED/PROCESSED for resolution)
203+
session.stateData.submittedActions.removeIf { !it.status.executed }
204+
205+
// 6. Clear wolf data
206+
session.stateData.wolfStates.clear()
207+
session.stateData.werewolfMessages.clear()
208+
184209
gameSessionService.saveSession(session)
185210
}
186211

src/main/kotlin/dev/robothanzo/werewolf/model/ExpelPoll.kt

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ import dev.robothanzo.werewolf.audio.Audio
55
import dev.robothanzo.werewolf.audio.Audio.play
66
import dev.robothanzo.werewolf.database.documents.Session
77
import dev.robothanzo.werewolf.game.model.DeathCause
8-
import kotlinx.coroutines.DelicateCoroutinesApi
9-
import kotlinx.coroutines.GlobalScope
10-
import kotlinx.coroutines.launch
118
import net.dv8tion.jda.api.EmbedBuilder
129
import net.dv8tion.jda.api.entities.Message
1310
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel
@@ -45,34 +42,14 @@ class ExpelPoll(
4542
message: Message?,
4643
session: Session
4744
): Boolean {
48-
// Announce
4945
message?.reply("投票已結束,正在放逐玩家 <@!" + winner.player.user?.idLong + ">")?.queue()
50-
51-
// Sync mark death
5246
winner.player.markDead(DeathCause.EXPEL)
53-
54-
// Async process death events
55-
@OptIn(DelicateCoroutinesApi::class)
56-
GlobalScope.launch {
57-
try {
58-
// Must fetch fresh player session context or ensure it is valid
59-
// Since this runs after sync markDead, session state is updated.
60-
// We restart a coroutine flow for interaction
61-
winner.player.runDeathEvents(true)
62-
} catch (e: Exception) {
63-
e.printStackTrace()
64-
} finally {
65-
// Execute the poll finished callback (advancing step etc)
66-
// We must be careful about thread safety if callback touches session
67-
// finishedCallback usually calls service.nextStep which locks session.
68-
finishedCallback?.invoke()
69-
}
47+
winner.player.processCascadingDeaths {
48+
finishedCallback?.invoke()
7049
}
71-
72-
// Finally remove poll registration
7350
WerewolfApplication.expelService.removePoll(guildId)
7451

75-
return true // We handle the callback
52+
return true
7653
}
7754

7855
override fun onPKTie(winners: List<Candidate>, channel: GuildMessageChannel, message: Message?, session: Session) {

0 commit comments

Comments
 (0)