Skip to content

Commit a6cffd1

Browse files
committed
Magician (untested)
1 parent 8df90e2 commit a6cffd1

11 files changed

Lines changed: 674 additions & 88 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ enum class ActionDefinitionId(val actionName: String) {
4141
// Miracle Merchant Actions
4242
MIRACLE_MERCHANT_TRADE_GUARD("交易 (守衛守護)"),
4343

44+
// Magician Actions
45+
MAGICIAN_SWAP("交換"),
46+
4447
// Dream Weaver Actions
4548
DREAM_WEAVER_LINK("攝夢"),
4649

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import org.springframework.data.annotation.Transient
1111
enum class NightPhase {
1212
NIGHTMARE_ACTION,
1313
WOLF_YOUNGER_BROTHER_ACTION,
14+
MAGICIAN_ACTION,
1415
WEREWOLF_VOTING,
1516
ROLE_ACTIONS
1617
}
@@ -213,6 +214,10 @@ data class GameStateData(
213214
@Schema(description = "List of player IDs whose death events (last words, triggers) have been completed")
214215
var processedDeathPlayerIds: MutableList<Int> = mutableListOf()
215216

217+
/**
218+
* Whether the Ghost Rider's reflection ability has been triggered.
219+
* Derived from executed actions.
220+
*/
216221
/**
217222
* Whether the Ghost Rider's reflection ability has been triggered.
218223
* Derived from executed actions.
@@ -227,6 +232,48 @@ data class GameStateData(
227232
@get:BsonIgnore
228233
val detonatedThisDay: Boolean
229234
get() = submittedActions.any { it.actionDefinitionId == ActionDefinitionId.WOLF_DETONATE }
235+
236+
// --- Magician Swap Logic ---
237+
238+
/**
239+
* The swap pairing for the current night.
240+
*/
241+
@get:BsonIgnore
242+
val nightlySwap: Map<Int, Int>
243+
get() {
244+
val swapAction =
245+
submittedActions.find { it.actionDefinitionId == ActionDefinitionId.MAGICIAN_SWAP && it.status.executed }
246+
val targets = swapAction?.targets
247+
if (targets == null || targets.size != 2) return emptyMap()
248+
return mapOf(targets[0] to targets[1], targets[1] to targets[0])
249+
}
250+
251+
/**
252+
* Set of players who have been swapped by the Magician in previous nights (or tonight).
253+
* Used to enforce "each number can only be swapped once" rule.
254+
*/
255+
@get:BsonIgnore
256+
val magicianSwapTargets: Set<Int>
257+
get() {
258+
val history = executedActions.values.flatten()
259+
.filter { it.actionDefinitionId == ActionDefinitionId.MAGICIAN_SWAP }
260+
.flatMap { it.targets }
261+
.toSet()
262+
// current night's swap is also relevant for validation during the night
263+
val current =
264+
submittedActions.find { it.actionDefinitionId == ActionDefinitionId.MAGICIAN_SWAP }?.targets
265+
?: emptyList()
266+
return history + current
267+
}
268+
269+
/**
270+
* Returns the "Real Target" after applying Magician's swap.
271+
* If A and B are swapped, getRealTarget(A) -> B, getRealTarget(B) -> A.
272+
* Otherwise returns original targetId.
273+
*/
274+
fun getRealTarget(targetId: Int): Int {
275+
return nightlySwap[targetId] ?: targetId
276+
}
230277
}
231278

232279
/**
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package dev.robothanzo.werewolf.game.roles
2+
3+
import dev.robothanzo.werewolf.game.model.BaseRole
4+
import dev.robothanzo.werewolf.game.model.Camp
5+
import dev.robothanzo.werewolf.game.roles.actions.MagicianSwapAction
6+
import dev.robothanzo.werewolf.game.roles.actions.RoleAction
7+
import org.springframework.data.annotation.Transient
8+
import org.springframework.stereotype.Component
9+
10+
@Component
11+
class Magician(@Transient private val swapAction: MagicianSwapAction) : BaseRole("魔術師", Camp.GOD) {
12+
override fun getActions(): List<RoleAction> = listOf(swapAction)
13+
}

src/main/kotlin/dev/robothanzo/werewolf/game/roles/PredefinedRoles.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ object PredefinedRoles {
1212
const val POLICE_PRIORITY = 400
1313
const val DARK_MERCHANT_PRIORITY = 50
1414
const val DREAM_WEAVER_PRIORITY = 60 // Before wolves
15+
const val MAGICIAN_PRIORITY = 40 // Before Dream Weaver and Wolves
1516
const val NIGHTMARE_PRIORITY = 0 // First thing at night
1617

18+
// Action IDs
19+
1720
// Action IDs
1821
const val WEREWOLF_KILL = "WEREWOLF_KILL"
1922
const val WITCH_ANTIDOTE = "WITCH_ANTIDOTE"

src/main/kotlin/dev/robothanzo/werewolf/game/roles/Roles.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ class WolfKing(@Transient private val revengeAction: WolfKingRevengeAction) : Ba
4747
revengeAction.onDeath(context.session, context.actorPlayerId, deathCause ?: DeathCause.UNKNOWN)
4848
}
4949
}
50-
5150
@Component
5251
class DreamWeaver(
5352
@Transient private val linkAction: DreamWeaverLinkAction
@@ -71,6 +70,7 @@ class Nightmare(
7170
override fun getActions(): List<RoleAction> = listOf(fearAction, killAction)
7271
}
7372

73+
7474
@Component
7575
class GhostRider(@Transient private val killAction: WerewolfKillAction) : BaseRole("惡靈騎士", Camp.WEREWOLF) {
7676
override fun getActions(): List<RoleAction> = listOf(killAction)
@@ -143,3 +143,5 @@ class MiracleMerchant(
143143
) : BaseMerchant("奇蹟商人", Camp.GOD) {
144144
override fun getActions(): List<RoleAction> = listOf(tradeSeerAction, tradePoisonAction, tradeGuardAction)
145145
}
146+
147+

src/main/kotlin/dev/robothanzo/werewolf/game/roles/actions/Actions.kt

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ class WerewolfKillAction : BaseRoleAction(
2121
accumulatedState: ActionExecutionResult
2222
): ActionExecutionResult {
2323
if (action.targets.isEmpty()) return accumulatedState
24-
val targetId = action.targets.firstOrNull() ?: return accumulatedState
25-
if (targetId == SKIP_TARGET_ID) return accumulatedState
24+
val rawTargetId = action.targets.firstOrNull() ?: return accumulatedState
25+
if (rawTargetId == SKIP_TARGET_ID) return accumulatedState
26+
27+
val targetId = session.stateData.getRealTarget(rawTargetId)
2628

2729
// Nightmare Check: If any alive wolf is feared, the kill fails
2830
val fearedId = session.stateData.nightmareFearTargets[session.day]
@@ -133,7 +135,8 @@ class SeerCheckAction(
133135
): ActionExecutionResult {
134136
if (action.targets.isEmpty()) return accumulatedState
135137

136-
val targetId = action.targets[0]
138+
val rawTargetId = action.targets[0]
139+
val targetId = session.stateData.getRealTarget(rawTargetId)
137140
val target = session.getPlayer(targetId) ?: return accumulatedState
138141

139142
val isWolfBrotherAlive = session.alivePlayers().values.any { it.roles.contains("狼兄") }
@@ -169,7 +172,9 @@ class WitchAntidoteAction : BaseRoleAction(
169172
action: RoleActionInstance,
170173
accumulatedState: ActionExecutionResult
171174
): ActionExecutionResult {
172-
val targetId = action.targets.firstOrNull() ?: return accumulatedState
175+
val rawTargetId = action.targets.firstOrNull() ?: return accumulatedState
176+
val targetId = session.stateData.getRealTarget(rawTargetId)
177+
173178
val werewolfKillList = accumulatedState.deaths[DeathCause.WEREWOLF] ?: emptyList()
174179

175180
if (targetId !in werewolfKillList) return accumulatedState
@@ -203,7 +208,8 @@ class WitchPoisonAction : BaseRoleAction(
203208
action: RoleActionInstance,
204209
accumulatedState: ActionExecutionResult
205210
): ActionExecutionResult {
206-
val targetId = action.targets.firstOrNull() ?: return accumulatedState
211+
val rawTargetId = action.targets.firstOrNull() ?: return accumulatedState
212+
val targetId = session.stateData.getRealTarget(rawTargetId)
207213
accumulatedState.deaths.getOrPut(DeathCause.POISON) { mutableListOf() }.add(targetId)
208214
return accumulatedState
209215
}
@@ -283,7 +289,8 @@ abstract class AbstractRevengeAction(
283289
action: RoleActionInstance,
284290
accumulatedState: ActionExecutionResult
285291
): ActionExecutionResult {
286-
val targetId = action.targets.firstOrNull() ?: return accumulatedState
292+
val rawTargetId = action.targets.firstOrNull() ?: return accumulatedState
293+
val targetId = session.stateData.getRealTarget(rawTargetId)
287294
accumulatedState.deaths.getOrPut(deathCause) { mutableListOf() }.add(targetId)
288295

289296
// Consume the granted action
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package dev.robothanzo.werewolf.game.roles.actions
2+
3+
import dev.robothanzo.werewolf.database.documents.Session
4+
import dev.robothanzo.werewolf.game.model.ActionDefinitionId
5+
6+
import dev.robothanzo.werewolf.game.model.ActionTiming
7+
import dev.robothanzo.werewolf.game.model.RoleActionInstance
8+
import dev.robothanzo.werewolf.game.roles.PredefinedRoles
9+
import org.springframework.stereotype.Component
10+
11+
@Component
12+
class MagicianSwapAction : BaseRoleAction(
13+
actionId = ActionDefinitionId.MAGICIAN_SWAP,
14+
priority = PredefinedRoles.MAGICIAN_PRIORITY,
15+
timing = ActionTiming.NIGHT,
16+
targetCount = 2,
17+
isOptional = false
18+
) {
19+
override fun execute(
20+
session: Session,
21+
action: RoleActionInstance,
22+
accumulatedState: ActionExecutionResult
23+
): ActionExecutionResult {
24+
// The swap effect is handled via GameStateData.nightlySwap computed property
25+
// This execution just marks the action as done and adds it to history (via standard flow)
26+
return accumulatedState
27+
}
28+
29+
override fun eligibleTargets(
30+
session: Session,
31+
actor: Int,
32+
alivePlayers: List<Int>,
33+
accumulatedState: ActionExecutionResult
34+
): List<Int> {
35+
// Can swap any two players (including self, dead players? description says "exchange two players' numbers")
36+
// Usually Magician can swap anyone.
37+
// Constraint: "Each number can only be exchanged once."
38+
val usedTargets = session.stateData.magicianSwapTargets
39+
return session.players.keys.map { it.toInt() }.filter { it !in usedTargets }
40+
}
41+
42+
override fun validate(session: Session, actor: Int, targets: List<Int>): String? {
43+
val baseError = super.validate(session, actor, targets)
44+
if (baseError != null) return baseError
45+
46+
if (targets.size != 2) return "必須選擇兩名玩家"
47+
48+
val usedTargets = session.stateData.magicianSwapTargets
49+
for (t in targets) {
50+
if (t in usedTargets) {
51+
return "玩家 $t 的號碼已經被交換過,不能再次交換"
52+
}
53+
}
54+
return null
55+
}
56+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,9 @@ internal object NightSequence {
574574
NightmareStart,
575575
NightmareWait,
576576
NightmareCleanup,
577+
dev.robothanzo.werewolf.game.steps.tasks.MagicianStart,
578+
dev.robothanzo.werewolf.game.steps.tasks.MagicianWait,
579+
dev.robothanzo.werewolf.game.steps.tasks.MagicianCleanup,
577580
WolfYoungerBrotherStart,
578581
WolfYoungerBrotherWait,
579582
WolfYoungerBrotherCleanup,
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package dev.robothanzo.werewolf.game.steps.tasks
2+
3+
import dev.robothanzo.werewolf.database.documents.LogType
4+
import dev.robothanzo.werewolf.game.model.ActionDefinitionId
5+
import dev.robothanzo.werewolf.game.model.NightPhase
6+
import dev.robothanzo.werewolf.game.model.getAvailableActionsForPlayer
7+
import dev.robothanzo.werewolf.game.steps.NightStep
8+
import dev.robothanzo.werewolf.game.steps.NightTask
9+
10+
// 0.5. Magician (Runs after Nightmare/Brother, before Dream Weaver/Wolves)
11+
// Actually Nightmare is 0. Magician should be early.
12+
// Let's check NightSequence in NightStep.kt.
13+
// Nightmare -> WolfBrother -> WerewolfVoting -> RoleActions
14+
// Magician should be before WerewolfVoting.
15+
// And Magician might affect Dream Weaver? Dream Weaver is in RoleActions.
16+
// Magician swap affects "actions targeting".
17+
// Dream Weaver links 2 players. If Magician swaps A and B. Dream Weaver links A.
18+
// Does it link B? Yes.
19+
// So Magician should happen before any action that targets.
20+
// Nightmare targets? "Nightmare Fear".
21+
// If Magician swaps A and B. Nightmare fears A.
22+
// Should B be feared?
23+
// Description says: "Magician exchanges... logic... when Wolf kills 1... Witch sees 1... Seer checks 1..."
24+
// It doesn't explicitly mention Nightmare. But generally swap affects all target-based actions.
25+
// HOWEVER, Magician acts "every night".
26+
// Nightmare acts at start of night.
27+
// If Magician acts simultaneously or after?
28+
// Usually Magician acts very early.
29+
// Let's place Magician tasks here.
30+
31+
object MagicianStart : NightTask {
32+
override val phase = NightPhase.MAGICIAN_ACTION
33+
override suspend fun execute(step: NightStep, guildId: Long): Boolean {
34+
return step.gameSessionService.withLockedSession(guildId) { lockedSession ->
35+
val magician = lockedSession.players.values.find {
36+
it.roles.contains("魔術師") && it.alive
37+
}
38+
39+
if (magician != null) {
40+
val actions = lockedSession.getAvailableActionsForPlayer(magician.id, step.roleRegistry)
41+
val swapAction = actions.find { it.actionId == ActionDefinitionId.MAGICIAN_SWAP }
42+
43+
if (swapAction != null) {
44+
lockedSession.stateData.phaseType = NightPhase.MAGICIAN_ACTION
45+
val startTime = System.currentTimeMillis()
46+
lockedSession.stateData.phaseStartTime = startTime
47+
lockedSession.stateData.phaseEndTime = startTime + 60_000 // 60s
48+
49+
step.actionUIService.promptPlayerForAction(
50+
guildId,
51+
lockedSession,
52+
magician.id,
53+
listOf(swapAction),
54+
60
55+
)
56+
return@withLockedSession true
57+
}
58+
}
59+
false
60+
}
61+
}
62+
}
63+
64+
object MagicianWait : NightTask {
65+
override val phase = NightPhase.MAGICIAN_ACTION
66+
override suspend fun execute(step: NightStep, guildId: Long): Boolean {
67+
val finishedEarly = step.waitForCondition(guildId, 60) {
68+
val session = step.gameSessionService.getSession(guildId).orElse(null) ?: return@waitForCondition true
69+
if (session.stateData.phaseType != NightPhase.MAGICIAN_ACTION) return@waitForCondition true
70+
val swapAction = session.stateData.submittedActions.find { it.actorRole == "魔術師" }
71+
swapAction != null && swapAction.status.executed
72+
}
73+
return !finishedEarly
74+
}
75+
}
76+
77+
object MagicianCleanup : NightTask {
78+
override val phase = NightPhase.MAGICIAN_ACTION
79+
override val isSkippable = false
80+
override suspend fun execute(step: NightStep, guildId: Long): Boolean {
81+
step.gameSessionService.withLockedSession(guildId) { session ->
82+
step.actionUIService.cleanupExpiredPrompts(session)
83+
session.addLog(LogType.SYSTEM, "魔術師行動階段結束")
84+
session.stateData.phaseType = null
85+
}
86+
return false
87+
}
88+
}

0 commit comments

Comments
 (0)