Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ app/main/proto/messages.proto

!.claude/
.claude/CLAUDE.md
.claude/worktrees/

# Contains creds
deploy/services-proxy.conf
Expand Down
27 changes: 23 additions & 4 deletions matchdoor/src/main/kotlin/leyline/match/CombatHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}

/**
Expand Down
72 changes: 72 additions & 0 deletions matchdoor/src/test/kotlin/leyline/conformance/ZeroBlockersTest.kt
Original file line number Diff line number Diff line change
@@ -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()
}
})
Loading