Skip to content

fix(matchdoor): skip DeclareBlockersReq when zero legal blockers#217

Open
delebedev wants to merge 2 commits intomainfrom
fix/skip-empty-blockers
Open

fix(matchdoor): skip DeclareBlockersReq when zero legal blockers#217
delebedev wants to merge 2 commits intomainfrom
fix/skip-empty-blockers

Conversation

@delebedev
Copy link
Owner

Closes #188

Summary

  • When defending player has no creatures that can block, auto-submit empty blocker declaration and advance combat
  • Eliminates unnecessary "No Blocks" button click in Arena client
  • CombatHandler.sendDeclareBlockersReq now checks blockersCount == 0 before sending

Test plan

  • ZeroBlockersTest — new test: human has only lands, AI attacks, no DeclareBlockersReq sent
  • BlockerDeclarationTest — existing blocker tests still pass (human has creatures → normal flow)
  • CombatFlowTest — all combat tests pass

🤖 Generated with Claude Code

delebedev and others added 2 commits March 22, 2026 21:27
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the defending player has no creatures that can block, auto-submit
empty blocker declaration and advance combat instead of sending
DeclareBlockersReq to the client. Eliminates unnecessary "No Blocks"
button click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link

Test Results

102 files   -   125  102 suites   - 125   4m 12s ⏱️ + 1m 49s
773 tests  -   222  160 ✅  - 803  610 💤 +578  3 ❌ +3 
773 runs   - 1 003  160 ✅  - 817  610 💤  - 189  3 ❌ +3 

For more details on these failures, see this check.

Results for commit 3c109eb. ± Comparison against base commit c58f0b2.

This pull request removes 223 and adds 1 tests. Note that renamed tests count towards both.
leyline.account.AccountRoutesTest ‑ age gate stub returns false
leyline.account.AccountRoutesTest ‑ doorbell returns FdURI
leyline.account.AccountRoutesTest ‑ login with unknown email returns 401
leyline.account.AccountRoutesTest ‑ login with valid credentials returns 200 + tokens
leyline.account.AccountRoutesTest ‑ login with wrong password returns 401
leyline.account.AccountRoutesTest ‑ moderate stub returns 200
leyline.account.AccountRoutesTest ‑ profile rejects JWT-shaped bearer that only exposes sub without passing token validation
leyline.account.AccountRoutesTest ‑ profile with valid token returns account data
leyline.account.AccountRoutesTest ‑ profile without auth returns 401
leyline.account.AccountRoutesTest ‑ refresh token grant returns new tokens
…
leyline.conformance.ZeroBlockersTest ‑ zero blockers auto-advances without DeclareBlockersReq
This pull request skips 594 tests.
leyline.architecture.PackageLayeringTest ‑ bridge does not depend on game, match, or protocol
leyline.architecture.PackageLayeringTest ‑ config does not depend on any matchdoor package
leyline.architecture.PackageLayeringTest ‑ conformance does not depend on any matchdoor package
leyline.architecture.PackageLayeringTest ‑ game does not depend on match, protocol, or infra
leyline.architecture.PackageLayeringTest ‑ infra does not depend on match or game
leyline.architecture.PackageLayeringTest ‑ protocol does not depend on match
leyline.bridge.DeckConverterTest ‑ converts CardEntry list to deck text
leyline.bridge.DeckConverterTest ‑ empty sideboard omits header
leyline.bridge.DeckConverterTest ‑ includes sideboard section
leyline.bridge.DeckConverterTest ‑ skips unknown grpIds
…

@github-actions
Copy link

CI Report — Gate

Tests: 160/773 passed, 3 failed (610 skipped)

Failed tests
  • AiCombatAutoPassTest.AI combat auto-passes when human has castable instant (34.8s)

    java.lang.AssertionError: 32278 should be < 4000

  • AiTurnNoAarTest.turnInfo phase correct at AAR during AI combat (34.6s)

    java.lang.AssertionError: Stale phase during AI combat turn 3: Beginning_a549

  • TargetingFlowTest.cancel then recast (0.1s)

    java.lang.AssertionError: Expected value to not be null, but was null.

Slow tests (>3s): 7
  • AiTurnNoAarTest.AI turn sends no ActionsAvailableReq to client (34.6s)
  • DeclareBlockersDedupeTest.no duplicate blockers req (30.2s)
  • ActivatedAbilityPuzzleTest.Goblin Fireslinger tap-to-ping kills opponent (10.0s)
  • PvpBridgeEndToEndTest.two-player game: land, creature, turn rotation, per-seat visibility (7.1s)
  • BlockerDeclarationTest.human blocks AI attacker (5.6s)
  • BlockerDeclarationTest.human declines blocking takes damage (4.8s)
  • BlockerDeclarationTest.trade produces creature deaths (4.7s)

@delebedev delebedev added the ralph Created by Ralph autonomous loop label Mar 22, 2026
@delebedev
Copy link
Owner Author

@claude please review this ralph PR

@claude
Copy link

claude bot commented Mar 22, 2026

Claude finished @delebedev's task in 3m 22s —— View job


PR Review — fix(matchdoor): skip DeclareBlockersReq when zero legal blockers

  • Read CombatHandler.kt changes
  • Read ZeroBlockersTest.kt
  • Analyze CI failures
  • Post review

Summary

The core fix is correct and clean. sendDeclareBlockersReq now returns Boolean and the call site in checkCombatPhase handles the skip path properly. The ZeroBlockersTest covers the new behavior well.

However, the PR breaks two existing tests that it needed to update but didn't, and the CI failures make this clear.


CI Failures

AiCombatAutoPassTest and AiTurnNoAarTest — broken by this PR

Both tests use puzzles where the human has only a Mountain on the battlefield (no creatures):

humanbattlefield=Mountain
aibattlefield=Raging Goblin|Attacking|Tapped;Mountain

Before this change: connectAndKeepPuzzleText would stop at DeclareBlockersReq, waiting for the human to respond. Both tests called h.declareNoBlockers() to advance.

After this change: checkCombatPhase detects zero legal blockers, auto-submits empty blockers, and returns Signal.SEND_STATE. The match has already advanced past declare-blockers when connectAndKeepPuzzleText returns. When h.declareNoBlockers() is then called, it sends a spurious SubmitBlockersReq to an engine that's no longer in the declare-blockers phase. The SubmitBlockersReq has gameStateId = 0 (from ProtoDsl.kt:153), so it bypasses the stale-check and submits a PlayerAction.DeclareBlockers to the wrong pending action — causing a state corruption and a 32-second timeout.

Fix needed: Remove h.declareNoBlockers() from both tests and adjust their flow — they should call h.passThroughCombat() directly since blockers are auto-resolved.

In AiCombatAutoPassTest, the elapsed-time guard (elapsed shouldBeLessThan 4000) is also now testing a slightly different thing — it should still be valid (the match should complete faster than ever), but the test structure needs updating.

Fix AiCombatAutoPassTest →

TargetingFlowTest.cancel then recast — likely pre-existing flaky test

This failure (Expected value to not be null, but was null at stReq.shouldNotBeNull()) is unrelated to blocker logic. The baseline comparison shows it was not failing before, but the test has no connection to this change. Likely a flaky test — worth monitoring but not a blocker for this PR.


Code Review

CombatHandler.kt:435-449sendDeclareBlockersReq

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
    }

    val result = BundleBuilder.declareBlockersBundle(game, bridge, ops.matchId, ops.seatId, ops.counter)
    ...
}

Minor inefficiency: RequestBuilder.buildDeclareBlockersReq is called here just to check blockersCount, and then BundleBuilder.declareBlockersBundle rebuilds it internally on the non-skip path. Not a bug, but an avoidable double-build on the common case. Could check blocker count more directly (e.g. enumerate player.getZone(Battlefield).cards.count { it.isCreature && CombatUtil.canBlock(it, combat) }), but the current approach is clear and readable — low priority.

CombatHandler.kt:340-348 — skip path in checkCombatPhase

if (skipBlockers) {
    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
}

The if (pending != null) guard is correct — bridge.awaitPriority() was already called before entering this block. However, if pending == null (which would be unexpected), the code silently returns Signal.SEND_STATE without submitting anything to the engine, potentially leaving it stuck. A log.warn here (matching the pattern elsewhere in CombatHandler) would help diagnose this if it ever happens.

ZeroBlockersTest.kt — good test, minor note

The test is well-structured and covers the right assertions. One small thing: h.isGameOver().shouldBeFalse() confirms the game hasn't ended, but doesn't assert that damage was dealt (which would give a positive signal that combat actually resolved). Not a required change, just an observation.


Verdict

Correct fix, wrong test state. The two declareNoBlockers() calls in AiCombatAutoPassTest and AiTurnNoAarTest need to be removed before merging — those are real regressions introduced by the new behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ralph Created by Ralph autonomous loop

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Combat: No Blocks prompt appears when player has zero blockers

1 participant