Stop the AI locking its own Room for no reason - #11447
Conversation
|
@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: |
|
Agreed about checking for a trigger, otherwise looks good to me :) |
|
Yeah, the logic about "should i lock/unlock my Room" per trigger can be extended later |
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>
1baca8d to
87a7fac
Compare
|
Thanks both. I've pushed the unlock-trigger check as a second commit. Those triggers are declared with 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:
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.
|
|
@omraj21 example of trigger check i mean from ClassLevelAi: forge/forge-ai/src/main/java/forge/ai/ability/ClassLevelUpAi.java Lines 30 to 33 in 0acfa8f this |
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>
87a7fac to
7055aa7
Compare
|
Good call, that's better than what I had - swapped it over to 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 249 tests, 0 failures. Rebased on master. |
|
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>
yes it was a tricky bug but I caught it, would appreciate a re-approval and another CI run |
|
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 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. |
UnlockDooris mapped toAlwaysPlayAi, whose entire logic isreturn WillPlay— it never looks at the board. ForMode$ LockOrUnlockthat 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,
UnlockDoorEffecttakes thecase 0branch, whose only outcome islockRoom: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. 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 1branch the effect offers both doors and locks whichever one is still open. That choice went throughSpellAbilityAi.chooseCardState, whose base implementation is:Since
bothStatesis 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:Mode$ UnlockandMode$ ThisDoorcan 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 withKeys to the House should not have been sacrificed expected:<1> but was:<0>unlocksARoomWhenADoorIsStillLocked— fails withAI should have unlocked the remaining door, not locked the open oneforge.ai.**test suite: 247 tests, 0 failures. Fullmvn -U -B clean testacross all 12 modules passes.This PR was previously part of #11441, which bundled it with an unrelated
BecomeMonarchfix. Split out so each can be reviewed on its own.Written with the help of GitHub Copilot CLI; the commit carries a
Co-authored-bytrailer for it.