diff --git a/.gitignore b/.gitignore index 6dfa1362..0f70be38 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ app/main/proto/messages.proto !.claude/ .claude/CLAUDE.md +.claude/worktrees/ # Contains creds deploy/services-proxy.conf diff --git a/matchdoor/src/main/kotlin/leyline/match/CombatHandler.kt b/matchdoor/src/main/kotlin/leyline/match/CombatHandler.kt index 6a975400..71a8d9c6 100644 --- a/matchdoor/src/main/kotlin/leyline/match/CombatHandler.kt +++ b/matchdoor/src/main/kotlin/leyline/match/CombatHandler.kt @@ -10,6 +10,7 @@ import leyline.bridge.SeatId import leyline.bridge.Target import leyline.game.BundleBuilder import leyline.game.GameBridge +import leyline.game.RequestBuilder import org.slf4j.LoggerFactory import wotc.mtgo.gre.external.messaging.Messages.* import kotlin.collections.iterator @@ -335,7 +336,17 @@ class CombatHandler(private val ops: SessionOps) { // captured AI actions between the last drain and now. drainPendingPlayback(bridge) ops.traceEvent(MatchEventType.COMBAT_PROMPT, game, "DeclareBlockers attackers=${combat.attackers.size}") - sendDeclareBlockersReq(bridge) + val skipBlockers = sendDeclareBlockersReq(bridge) + if (skipBlockers) { + // Zero legal blockers — submit empty declaration and advance + val seatBridge = bridge.seat(ops.seatId) + val pending = seatBridge.action.getPending() + if (pending != null) { + seatBridge.action.submitAction(pending.actionId, PlayerAction.DeclareBlockers(emptyMap())) + bridge.awaitPriority() + } + return Signal.SEND_STATE + } return Signal.STOP } else if (isHumanTurn && combat != null && combat.attackers.isNotEmpty()) { ops.traceEvent(MatchEventType.SEND_STATE, game, "AI blocking result") @@ -421,13 +432,21 @@ class CombatHandler(private val ops: SessionOps) { ops.sendBundledGRE(result.messages) } - private fun sendDeclareBlockersReq(bridge: GameBridge) { - val game = bridge.getGame() ?: return - val result = BundleBuilder.declareBlockersBundle(game, bridge, ops.matchId, ops.seatId, ops.counter) + private fun sendDeclareBlockersReq(bridge: GameBridge): Boolean { + val game = bridge.getGame() ?: return false + val req = RequestBuilder.buildDeclareBlockersReq(game, ops.seatId, bridge) + if (req.blockersCount == 0) { + log.info("CombatHandler: zero legal blockers — auto-submitting empty declaration") + pendingBlockersSent = true + return true // caller should auto-advance + } + + val result = BundleBuilder.declareBlockersBundle(game, bridge, ops.matchId, ops.seatId, ops.counter) pendingBlockersSent = true Tap.outboundTemplate("DeclareBlockersReq seat=${ops.seatId}") ops.sendBundledGRE(result.messages) + return false } /** diff --git a/matchdoor/src/test/kotlin/leyline/conformance/ZeroBlockersTest.kt b/matchdoor/src/test/kotlin/leyline/conformance/ZeroBlockersTest.kt new file mode 100644 index 00000000..99315b79 --- /dev/null +++ b/matchdoor/src/test/kotlin/leyline/conformance/ZeroBlockersTest.kt @@ -0,0 +1,72 @@ +package leyline.conformance + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.booleans.shouldBeFalse +import leyline.IntegrationTag + +/** + * Regression: when the defending player has zero legal blockers, the server + * should auto-advance through declare blockers instead of sending + * DeclareBlockersReq to the client (#188). + */ +class ZeroBlockersTest : + FunSpec({ + + tags(IntegrationTag) + + var harness: MatchFlowHarness? = null + + afterEach { + harness?.shutdown() + harness = null + } + + test("zero blockers auto-advances without DeclareBlockersReq") { + // Human has only lands, AI has haste attackers + val pzl = """ + [metadata] + Name:Zero Blockers AI Attack + Goal:Win + Turns:10 + Difficulty:Easy + Description:Human has no creatures. AI attacks — should skip blockers. + + [state] + ActivePlayer=Human + ActivePhase=Main1 + HumanLife=20 + AILife=20 + + humanbattlefield=Plains;Plains + humanlibrary=Plains;Plains;Plains;Plains;Plains + aibattlefield=Mountain;Mountain;Raging Goblin;Raging Goblin + ailibrary=Mountain;Mountain;Mountain;Mountain;Mountain + """.trimIndent() + + val h = MatchFlowHarness(seed = 42L, validating = false) + harness = h + h.connectAndKeepPuzzleText( + pzl, + aiScript = listOf( + ScriptedAction.Attack(listOf("Raging Goblin")), + ScriptedAction.PassPriority, + ), + ) + + val snap = h.messageSnapshot() + + // Pass through human turn into AI combat + h.passPriority() + + // Pass through combat — should auto-advance without blockers prompt + h.passThroughCombat() + + // No DeclareBlockersReq should have been sent + val msgs = h.messagesSince(snap) + val blockerReq = msgs.any { it.hasDeclareBlockersReq() } + blockerReq.shouldBeFalse() + + // Game should still be running (not stuck) + h.isGameOver().shouldBeFalse() + } + })