Skip to content

Stop the AI locking its own Room for no reason - #11447

Merged
Agetian merged 5 commits into
Card-Forge:masterfrom
omraj21:ai-unlockdoor-noop
Jul 31, 2026
Merged

Stop the AI locking its own Room for no reason#11447
Agetian merged 5 commits into
Card-Forge:masterfrom
omraj21:ai-unlockdoor-noop

Conversation

@omraj21

@omraj21 omraj21 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

UnlockDoor is mapped to AlwaysPlayAi, whose entire logic is return WillPlay — it never looks at the board. For Mode$ LockOrUnlock that is wrong in two separate ways.

1. It can only lock, so the AI shuts off its own Room

When every door of the targeted Room is already unlocked, UnlockDoorEffect takes the case 0 branch, whose only outcome is lockRoom:

case "LockOrUnlock":
    switch (c.getLockedRooms().size()) {
    case 0:
        // no locked, all unlocked, can only lock door
        List<CardState> unlockStates = c.getUnlockedRooms()...;
        CardState chosenUnlock = activator.getController().chooseSingleCardState(...);
        ...
        c.lockRoom(activator, chosenUnlock.getStateName());

Keys to the House pays {3}, taps and sacrifices itself for that ability:

A:AB$ UnlockDoor | Cost$ 3 T Sac<1/CARDNAME> | Mode$ LockOrUnlock | ValidTgts$ Room.YouCtrl | ...

so the AI was throwing away a permanent to make its own board worse. The added test shows Keys to the House going from one copy on the battlefield to zero under the old mapping.

2. With one door locked, the door it picked was arbitrary

In the case 1 branch the effect offers both doors and locks whichever one is still open. That choice went through SpellAbilityAi.chooseCardState, whose base implementation is:

public CardState chooseCardState(Player ai, SpellAbility sa, List<CardState> faces, Map<String,Object> params) {
    System.err.println("Warning: default (ie. inherited from base class) implementation of chooseCardState is used for " ...);
    return Iterables.getFirst(faces, null);
}

Since bothStates is always built in fixed [LeftSplit, RightSplit] order, whether the AI opened or closed a door came down to state ordering rather than a decision — and it printed a warning to stderr every time.

The fix

UnlockDoorAi:

  • declines when no reachable Room has a locked door, and
  • picks a locked door when asked to choose, so the ability always opens a Room instead of closing one.

Mode$ Unlock and Mode$ ThisDoor can only ever unlock, so they keep the previous always-play behaviour.

Affects Keys to the House and Marina Vendrell.

Verification

Two tests, both of which fail on the old mapping:

  • doesNotSacrificeItselfJustToLockItsOwnRoom — fails with Keys to the House should not have been sacrificed expected:<1> but was:<0>
  • unlocksARoomWhenADoorIsStillLocked — fails with AI should have unlocked the remaining door, not locked the open one

forge.ai.** test suite: 247 tests, 0 failures. Full mvn -U -B clean test across all 12 modules passes.


This PR was previously part of #11441, which bundled it with an unrelated BecomeMonarch fix. Split out so each can be reviewed on its own.

Written with the help of GitHub Copilot CLI; the commit carries a Co-authored-by trailer for it.

@Hanmac

Hanmac commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

@Agetian @tool4ever I think that is good for now?

There might be a small use-case for AI locking Doors that have an Unlock Trigger.

Also, AI should probably do a "Do i want to unlock this Door?" check:
Like checking if it wants to run a possible unlock trigger.

@Agetian

Agetian commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Agreed about checking for a trigger, otherwise looks good to me :)

Agetian
Agetian previously approved these changes Jul 29, 2026
@Hanmac

Hanmac commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Yeah, the logic about "should i lock/unlock my Room" per trigger can be extended later

omraj21 added a commit to omraj21/forge that referenced this pull request Jul 29, 2026
Follow-up to review feedback on Card-Forge#11447: check whether opening a particular door
would actually run its "when you unlock this door" trigger, rather than treating
every locked door as equally good.

Most Rooms put a trigger on each face, declared with ThisDoor$ True so it only
fires for the face it sits on (TriggerUnlockDoor.performTest compares the
trigger's own state name against the state being unlocked). Matching on that
lets the AI tell the two halves apart.

Two places now use it:

- chooseCardState prefers a locked door whose face carries an unlock trigger,
  falling back to any locked door when neither pays out. Previously it took the
  first locked door it found, so with both doors locked which half opened came
  down to state ordering.
- when the ability targets, pick a Room that has such a door over one that would
  only swap which half is active.

Bottomless Pool // Locker Room is the case in the added test: the Bottomless
Pool face bounces a creature when unlocked, while Locker Room's trigger is a
combat damage one that does nothing at the moment it opens.

Not included: deliberately locking a door in order to unlock it again later for
the trigger. That needs a value model for whether the re-unlock is reachable,
and getting it wrong reintroduces exactly the "AI closes its own Room" behaviour
this PR set out to fix.

forge.ai.** test suite: 248 tests, 0 failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@omraj21
omraj21 force-pushed the ai-unlockdoor-noop branch from 1baca8d to 87a7fac Compare July 29, 2026 14:15
@omraj21

omraj21 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks both. I've pushed the unlock-trigger check as a second commit.

Those triggers are declared with ThisDoor$ True, and TriggerUnlockDoor.performTest compares the trigger's own state name against the state being unlocked, so matching on that is enough to tell the two halves apart:

if (hasParam("ThisDoor")) {
    CardState state = (CardState) runParams.get(AbilityKey.CardState);
    if (!getHostCard().equals(state.getCard())) return false;
    if (!getCardStateName().equals(state.getStateName())) return false;
}

Two places use it now:

  • chooseCardState prefers a locked door whose face carries an unlock trigger, falling back to any locked door when neither pays out. Before this it just took the first locked door, so with both doors locked which half opened came down to state ordering.
  • when the ability targets, it picks a Room that has such a door over one that would only swap which half is active.

Test case is Bottomless Pool // Locker Room with both doors locked: the Bottomless Pool face bounces a creature when unlocked, while Locker Room's trigger is a combat damage one that pays out nothing at the moment it opens. I checked the test actually catches a wrong choice by stubbing the preference out and confirming it fails.

On @Hanmac's other point — deliberately locking a door so it can be unlocked again later for the trigger — I've left that out on purpose. It needs a value model for whether the re-unlock is actually reachable (mana, another Keys effect, sorcery timing), and if that model is wrong it reintroduces exactly the "AI closes its own Room" behaviour this PR is fixing. Happy to look at it separately if you'd like it.

forge.ai.**: 248 tests, 0 failures. Rebased on current master.

@Hanmac
Hanmac requested a review from Agetian July 29, 2026 14:16
@Hanmac

Hanmac commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

@omraj21 example of trigger check i mean from ClassLevelAi:

SpellAbility effect = t.ensureAbility();
if (!SpellApiToAi.Converter.get(effect).doTrigger(aiPlayer, effect, false)) {
return new AiAbilityDecision(0, AiPlayDecision.CantPlayAi);
}

this doTrigger check is for looking if AI would choose this trigger if it wasn't mandatory

omraj21 and others added 3 commits July 29, 2026 10:45
UnlockDoor was mapped to AlwaysPlayAi, which accepts every activation without
looking at the board. For Mode$ LockOrUnlock that is wrong in two ways.

When every door of the targeted Room is already unlocked, UnlockDoorEffect's
case 0 branch can only call lockRoom, so the ability shuts off one of the AI's
own Rooms. Keys to the House pays {3}, taps and sacrifices itself for that
ability, so the AI was throwing away a permanent to make its own board worse.
With the old mapping the test here shows Keys to the House going from one copy
on the battlefield to zero.

When exactly one door is locked, the effect offers both doors and locks the one
that is still open. The choice went through SpellAbilityAi.chooseCardState,
whose base implementation prints a "default implementation is used" warning and
returns the first element, so which door the AI got was down to state ordering
rather than a decision.

Add UnlockDoorAi: decline when no reachable Room has a locked door, and pick a
locked door when asked to choose, so the ability always opens a Room instead of
closing one. Mode$ Unlock and Mode$ ThisDoor can only ever unlock, so they keep
the previous always-play behaviour.

Affects Keys to the House and Marina Vendrell.

forge.ai.** test suite: 247 tests, 0 failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-up to review feedback on Card-Forge#11447: check whether opening a particular door
would actually run its "when you unlock this door" trigger, rather than treating
every locked door as equally good.

Most Rooms put a trigger on each face, declared with ThisDoor$ True so it only
fires for the face it sits on (TriggerUnlockDoor.performTest compares the
trigger's own state name against the state being unlocked). Matching on that
lets the AI tell the two halves apart.

Two places now use it:

- chooseCardState prefers a locked door whose face carries an unlock trigger,
  falling back to any locked door when neither pays out. Previously it took the
  first locked door it found, so with both doors locked which half opened came
  down to state ordering.
- when the ability targets, pick a Room that has such a door over one that would
  only swap which half is active.

Bottomless Pool // Locker Room is the case in the added test: the Bottomless
Pool face bounces a creature when unlocked, while Locker Room's trigger is a
combat damage one that does nothing at the moment it opens.

Not included: deliberately locking a door in order to unlock it again later for
the trigger. That needs a value model for whether the re-unlock is reachable,
and getting it wrong reintroduces exactly the "AI closes its own Room" behaviour
this PR set out to fix.

forge.ai.** test suite: 248 tests, 0 failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-up to review feedback: checking only that a door has a "when you unlock
this door" trigger is not enough, because plenty of those triggers are useless
or actively harmful depending on the board.

Use the ClassLevelUpAi pattern instead and ask the effect's own AI via doTrigger
whether it would choose to run the trigger if it were not mandatory. Measured
against the two cases that motivated this:

  Cramped Vents, no opposing creatures      doTrigger false, was true
  Cramped Vents, opponent has a creature    doTrigger true
  Derelict Attic at 20 life                 doTrigger true
  Derelict Attic at 2 life                  doTrigger false, was true

Derelict Attic is the one that mattered. Its trigger draws two cards and loses 2
life, so at 2 life the previous check steered the AI into opening the door that
killed it. The added test covers that and fails with the old presence check.

Cramped Vents is the blank case: 6 damage to a creature an opponent controls
does nothing when the opponent has no creatures.

Note that a trigger's ability has no activating player until it actually fires,
so it has to be set before calling doTrigger, otherwise DamageDealAi's targeting
and AiCostDecision both dereference null.

forge.ai.** test suite: 249 tests, 0 failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@omraj21
omraj21 force-pushed the ai-unlockdoor-noop branch from 87a7fac to 7055aa7 Compare July 29, 2026 14:46
@omraj21

omraj21 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Good call, that's better than what I had - swapped it over to doTrigger.

Two Rooms show why just checking the trigger exists isn't enough. Cramped Vents (6 damage to a creature an opponent controls) comes back false when they have no creatures, and Derelict Attic (draw two, lose 2 life) comes back false at 2 life - my old check would cheerfully have opened that door and killed the AI. Added a test for the Derelict Attic case; it fails with the presence-only check.

One gotcha in case you hit it elsewhere: the trigger's ability has no activating player until it actually fires, so doTrigger NPEs in DamageDealAi's targeting and in AiCostDecision unless you set it first. ClassLevelUpAi doesn't set it either, so it may have the same issue for any class trigger that targets or has a cost - happy to look at that separately.

249 tests, 0 failures. Rebased on master.

Hanmac
Hanmac previously approved these changes Jul 29, 2026
@Agetian

Agetian commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

It looks like the test is failing on a test run?

CI caught this: the previous commit's door choice was not reproducible. When no
locked door had a trigger the AI wanted, it fell back to the first locked door it
saw, and Card.getLockedRooms builds its result with Sets.newHashSet, so for enum
constants the iteration order depends on identity hash codes and varies between
JVM runs. The same board picked Derelict Attic on one run and Widow's Walk on the
next.

The ordering only exposed the real problem, which is that "has a trigger the AI
does not want" was being treated as equivalent to "has no trigger at all". Those
are not the same: Derelict Attic's trigger draws two cards and loses 2 life, so
at 2 life opening that door kills the AI, while its Widow's Walk half has no
unlock trigger and is simply harmless.

Rank each locked door instead:

  0  has an unlock trigger the AI wants to run now
  1  has no unlock trigger, so opening it just turns the other half on
  2  has an unlock trigger the AI does not want

and take the lowest. A door that would hurt now loses to a door that does
nothing, rather than depending on which one iteration happens to reach first.

Also always set the activating player on the trigger's ability rather than only
when it is null, so a stale activator can never decide the answer.

The test now asserts which door was opened rather than only which one was not,
so it fails if the choice regresses in either direction.

Full mvn -U -B clean test: 12 modules, 292 tests, 0 failures, run three times to
confirm the choice is now stable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@omraj21

omraj21 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

It looks like the test is failing on a test run?

yes it was a tricky bug but I caught it, would appreciate a re-approval and another CI run

@omraj21

omraj21 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

That was my doing - the details are in the comment just above, but short version: my door choice fell back to the first locked door, and Card.getLockedRooms builds a HashSet, so the iteration order changed between JVM runs. It passed locally and failed on CI for that reason.

Fixed by ranking the doors rather than relying on ordering, and it turned out to be hiding a real bug: I'd been treating "has a trigger the AI doesn't want" the same as "has no trigger at all", which for Derelict Attic at 2 life meant picking the door that kills you.

CI is green now on both Java 17 and 21.

@Agetian
Agetian merged commit cf68a4f into Card-Forge:master Jul 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI General AI tag Game Mechanics

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants