Visitar URL original
Refresh: fullpod onto master-2026-10-01 (conflicts resolved) by narfman0 · Pull Request #2 · narfman0/mage · GitHub
Skip to content

Refresh: fullpod onto master-2026-10-01 (conflicts resolved) - #2

Closed
narfman0 wants to merge 87 commits into
fullpodfrom
refresh/master-2026-10-01
Closed

narfman0 wants to merge 87 commits into
fullpodfrom
refresh/master-2026-10-01

Conversation

@narfman0

@narfman0 narfman0 commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Rebase of fullpod (2ea9603) onto 7c517e0f88b7981b58225a2cb8aa185c6adc1931 (master-2026-10-01), conflicts resolved by the refresh workflow's agent; seat harness on the result: failure.
A build-only engine-pin.yml run was dispatched for 0174d259931dda165596253a2174b765a535a73c (harness and golden games; nothing lands).
This branch is fullpod rewritten on top of the target, so the merge button cannot land it (its commits are the twins of fullpod's own). To land it, replace fullpod with it, keeping the old head as a tag, then run Engine pin on the new head:

git -C engine/mage fetch origin && git -C engine/mage tag pre-refresh/master-2026-10-01 origin/fullpod && git -C engine/mage push origin pre-refresh/master-2026-10-01
git -C engine/mage push --force-with-lease=fullpod:$(git -C engine/mage rev-parse origin/fullpod) origin origin/refresh/master-2026-10-01:fullpod

The PR then shows as merged. Not landing it is fine too: the next rehearsal overwrites the branch.

Refresh: fullpod rebased onto upstream 7c517e0f88

refresh/master-2026-10-01 is the fork's 88 commits replayed onto upstream
master's 7c517e0f88b7981b58225a2cb8aa185c6adc1931 ("server: fixed chat
messages of connection problems"), 611 upstream commits past the release tag
xmage_1.4.61V1 the branch sat on. 87 of the 88 land; one is dropped because
upstream carries it now. Four commits stopped with conflicts; the rest
replayed clean. Nothing is pushed — the clone has no credentials, fullpod
and the tags are untouched.

The collectors interface

Commit e526af76e0 "Engine: seeded games, stable ids, game-event and
replay-feeder collectors (carried from mage-bench + fullpod)".
Files Mage/src/main/java/mage/collectors/DataCollector.java,
DataCollectorServices.java, services/EmptyDataCollector.java.

Upstream reworked the same interface in three commits: 1f00762840 split
onTestsStackResolve into onTestsStackResolveStart/onTestsStackResolveEnd
(both taking the StackObject), 5123af7974 widened onGameError's second
parameter from Exception to Throwable, and a04e6abacd added
onGameEndResult for the result that is only known after onGameEnd. Our
commit widens onGameLog with a gameSeq and adds onPlayerQuery /
onPlayerResponse, the two hooks the record is written from. The two sets of
methods are independent; they conflicted only because they are neighbours in
the file and both add an import.

Kept both, in upstream's order: upstream's StackObject import beside our
PlayerQueryEvent one, upstream's onGameEndResult ahead of our two player
hooks, and upstream's Throwable and StackObject signatures as they stand.
ServerGameEventLogCollector and ReplayFeederCollector both extend
EmptyDataCollector, so upstream's three new methods reach them as no-ops
and neither collector needed touching.

The response window

Commit d14d0c768e "HumanPlayer: response window that survives a slow
client (carried from mage-bench)".
File Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java.

Upstream has arrived at half of this commit by its own route. It renamed
PlayerResponse.clear() to resetAnswers() and made it leave the async
concede/cheat marks alone ("do not reset async commands -- it's processing
independend"), added hasAnswer() — the same predicate, field for field, as
the hasSyncResponse() this commit adds — and hasAsyncCommand(), and now
checks both before sleeping, so a signal that arrives before the wait is not
slept through. What upstream still does not do is what this commit's title is
about: open the window at all before the question is fired. Upstream's
prepareForResponse sets responseOpenedForAnswer = false and the window
opens only once the game thread reaches waitForResponse, so a client that
answers inside the callback spins in waitResponseOpen() until it does.

Kept ours: the window opens in prepareForResponse, the field stays
volatile boolean (upstream's is still a non-volatile Boolean read from the
CALL thread), and the firstWait guard keeps the first pass through the loop
from clearing the window prepareForResponse just opened. Took upstream's
shape around it — resetAnswers() for the gone clear() in both the
prepareForResponse hunk and the re-arm, upstream's comment block above the
field declaration, and upstream's reason for the async terms folded into our
comment on the condition. Kept our predicate rather than upstream's
hasAnswer(), because the next commit but one replaces it with something
upstream cannot express (below).

Two deliberate consequences of adopting resetAnswers(): a prompt no longer
clears a pending concede or cheat mark, which is upstream's fix for conceding
lost under a slow watcher, and the harness's two concede tests
(GameHostTest.aConcedeBeforeTheStartingPlayerIsChosenIsALoss,
aConcedeAtTheMulliganWithNoChooserIsALossFromTheFirstPoll) pass on it.

Ichor Elixir — upstream implemented the same card, the other way round

Commit cca68123f5 "Ichor Elixir: a planar die roll a player may ignore".
Files Mage.Sets/src/mage/cards/i/IchorElixir.java (add/add),
Mage/src/main/java/mage/players/PlayerImpl.java.

Upstream implemented Ichor Elixir on 2026-09-26 in 6c8260bb1a ("[MOC]
Implement Ichor Elixir (magefree#16050)"), six days after we did, and gave it the same
two pieces: the card, and a planar branch in PlayerImpl.rollDiceInner. The
card bodies are all but identical. The engine branches ask the opposite
question:

  • Ours fires on getIgnoreLowestAmount() > 0 (the card calls
    incIgnoreLowestAmount(1) beside incAmount(1)), asks the roller which
    results to ignore
    , one at a time, and keeps the rest; a computer skips the
    prompt and keeps the most useful roll (chaos, then planar, then blank), and
    nobody is asked when every result is the same.
  • Upstream's fires on getAmount() > 1 — any multi-planar roll, Elixir or
    not — asks which single result to keep, discards everything else, and
    asks even when the results are identical. Its computer answer is whatever
    ComputerPlayer.choose happens to take off an unordered set.

With Ichor Elixir the only such effect in the game, the two always keep one
die and so always agree on the outcome; they differ in the question the player
is asked and in what the CPU does with it.

Kept ours, per the rule that a resolution keeps what the fork commit's message
says it does — the message is specifically about the roller choosing what to
ignore and the AI keeping chaos. Took upstream's shape for the card file,
which is upstream's file now: no @author block of ours, upstream's
this.staticText line, and only the replaceEvent body is ours (the
incIgnoreLowestAmount(1) that drives our branch). Upstream's planar branch in
rollDiceInner is replaced by ours; the helper ours calls,
choosePlanarRollToIgnore, came in with the same commit and applied clean.

This is the one resolution the owner may want to overturn, and the cost of
keeping ours is visible: upstream's 6c8260bb1a also added two cases to
Mage.Tests cards/single/pc2/FracturedPowerstoneTest (testIchorElixir,
testMultipleIchorElixir) that answer setChoice(playerA, "Blank Roll") and
expect the blank to be kept; under our semantics that answer ignores the
blank and keeps the chaos, so both now fail. Our own
cards/single/moc/IchorElixirTest is the mirror image and fails against
upstream's branch. Mage.Tests is not in the harness this refresh runs, so
neither was executed here. Dropping cca68123f5 in favour of upstream's
implementation is a real option and makes the fork one commit shorter — but it
is not free either: the commit also carries the game.applyEffects() call in
PlayerImpl.drawCards that the Aminatou, Veil Piercer work depends on (a
history quirk — that hunk rides in this commit, not in either Aminatou
commit), so the commit cannot simply be skipped. Someone taking that route has
to keep the draw hunk and delete our card, our engine branch, our helper and
our test.

The cancel and the abort before the wait

Commit 3dc54df3df "HumanPlayer: a cancel or an abort that lands before
the wait is not slept through".
Files HumanPlayer.java, PlayerResponse.java.

This commit replaces hasSyncResponse() with isAnswered() — a flag every
payload setter raises, cleared with the window — because a seat's cancel is
setResponseUUID(null) and a rollback's abort() carries no value at all, so
"has a value" misses both and the game thread sleeps on a notifyAll that has
already run. Upstream's hasAnswer() has exactly that blind spot, so there is
nothing of upstream's to adopt here; the conflict in HumanPlayer was only
against the comment I had written one commit earlier, and I took this commit's
own.

In PlayerResponse.resetAnswers the two sides disagreed about the async
marks: ours cleared them (it was patching the old clear()), upstream's
leaves them, deliberately. Kept upstream's — the async marks stay — and kept
our one new line, this.answered = false, which is the point of the commit:
the flag is cleared with the window. Also repointed the isAnswered javadoc's
{@link #clear} at #resetAnswers, since clear no longer exists.

GameHostTest.activationCanBeBackedOutOf and RollbackTest, the two the
commit message names as the stalls it fixes, pass.

Dropped

  • 096ba16c7e "Ashling, the Limitless, backported from upstream master" —
    dropped by the rebase itself ("patch contents already upstream"). Upstream's
    d899f60568 ("Implemented Ashling the Limitless and rework Evoke ([ECC] Implement Ashling the Limitless with evoke rework magefree/mage#15611)")
    carries it, which is where we backported it from; engine-fork.md already
    recorded that this one was never ours.

No other commit came out empty. 87 of 88 remain on the branch.

The harness

mvn install -pl Mage.Player.Seat -am builds clean. mvn test -pl Mage.Player.Seat: 121 run, 2 failures, 1 skipped. Both failures are in
SnapshotResumeTest, both caused by upstream, neither by a resolution above.

aBoardSavedByAnotherProcessStillHasItsPermanentExile —
java.io.InvalidClassException: mage.game.GameImpl; local class incompatible: stream classdesc serialVersionUID = 6632437972736271678, local class serialVersionUID = 4703906100310664727. This is the re-record that
engine-refresh.md says is part of a refresh, not a regression: the test holds
one game serialized by another process, and GameImpl has no pinned
serialVersionUID, so any change to its public shape moves it. Upstream moved
it — 5123af7974 added the public method endWithTechnicalWinner and
77186e021b the public static COPIED_COUNT, both of which the default UID is
computed over. The assertion message carries the command to re-record the
fixture. Left alone: re-recording writes a new fixture file, which is outside
the conflicting hunks.

aCpuGameResumesFromItsSnapshotAtTheSameQuestion — seqs continue expected:<75> but was:<78>. The test pauses a CPU game at an open question on
turn 5 (seq 74), resumes the serialized game in a fresh host, and expects the
same question back at seq 75. It comes back at 78. The resumed game's record
says why:

{"seq":0,"type":"game_start",...}
{"seq":75,"type":"game_action","message":"Turn 5"}
{"seq":76,"type":"turn_change","turn":5,"active_player":"You"}
{"seq":77,"type":"phase_change","turn":5,"phase":"PRECOMBAT_MAIN",...}

Upstream's 28fb5f005b ("tests: fixed miss game logs about draw and mulligan")
changed one line in GameImpl.resume() from fireInformEvent("Turn " + state.getTurnNum()) to informPlayers(...). fireInformEvent goes to the
clients only; informPlayers goes through onGameLog, which is our recorded,
sequenced stream. So the resume now spends a seq announcing the turn — and two
more besides, because ServerGameEventLogCollector synthesises turn_change
and phase_change inside onGameLog whenever the state has moved since the
last line it saw, and in a freshly resumed process it has seen none. One
upstream line, three seqs.

Nothing in the four resolutions touches GameImpl, the seq counter or the
collector's bookkeeping, and the drift is entirely accounted for by that line.
It is a real behaviour change the owner has to settle, though, and it is not a
re-record: either the resumed game should not re-announce a turn it is in the
middle of, or the collector should not treat the first log line after a resume
as a state advance, or the test's seq + 1 should become the looser "the same
question, at whatever seq". That is a decision about the record format, which
is why it is left here rather than guessed at.

Everything else is green, including the tests the conflicting commits exist
for: GameHostTest (concedes, activationCanBeBackedOutOf), RollbackTest,
ResponseWindowTest, CpuPerfTest, TriggerOrderTest, QuestionKindsTest.

Run: https://github.com/narfman0/fullpod/actions/runs/36867477284

narfman0 and others added 30 commits October 1, 2026 13:19
Upstream targets Java 8; the fork builds with --release 21. H2 goes from
1.4.197 to 2.4.240, the format of the card DB files the DB retry code
and the fullpod deployment already use. Nothing else of mage-bench's
root pom (build cache, timestamps, plugin bumps) is carried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…tors (carried from mage-bench + fullpod)

A game carries its seed (GameOptions/MatchOptions) and every shuffle and
iteration order is deterministic under it (LinkedHashMap where HashMap
leaked order); ShortIdRegistry names objects stably for logs and
decisions; ServerGameEventLogCollector writes the append-only event
stream fullpod reads; ReplayFeederCollector/ReplayScript resume a seeded
game from its recorded decisions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…ker, game view extras a headless seat needs (carried from mage-bench + fullpod)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…m mage-bench)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…o lazy loads during the scan, microsecond log layout (carried from mage-bench)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…/rearch.md)

Mage.Player.Seat: one JUnit test that runs a TwoPlayerDuel in one JVM
with a HumanPlayer whose PlayerQueryEvents are answered by an in-process
listener (GameController's role, minus the network) against
ComputerPlayer7, to turn 3. Not in the root reactor; run from the module
dir with mvn -o test against the installed jars.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…rocess over a loopback socket

GameHost creates a game the way the engine's tests do (no table, no room,
no remoting), seats SeatPlayers (a HumanPlayer that pays mana by tapping
the obvious sources, asking only when nothing obvious pays) and CPU
players, and answers the engine's questions from the product's answers.
DecisionRenderer turns each PlayerQueryEvent — the engine's own question,
with the objects behind it — into the decision JSON the product already
reads (ported from mage-bench's projection, MIT); batch attackers/blockers
are answered across the engine's re-asks. SeatHost is a newline-JSON
loopback server: create_game, next_decision, choose_action, state,
rollback, concede, set, end_game. Decisions are recorded to
server_game_events.jsonl through the data collectors as before.

Harness: a scripted seat vs ComputerPlayer7 through the host API (lands,
a creature cast with auto-paid mana, an attack, no mana prompt, the
record on disk) and the socket round trip. The spike test is subsumed.
CI: build the engine modules and run the harness on every push.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…n and seats the commanders

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…d_through_seq); undo harness test

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
Views: a seat's commanders come from the player's commander ids (the engine
keeps a command-zone object only while the card is physically there), each
as a card with its short id, where it is (command / battlefield / ...), its
cast count and tax ({2} per cast from the command zone), and the damage it
has dealt per player. DecisionRenderer: the commander in the command zone
is offered as a cast (from=command) with its cost, not as an activation.

SeatPlayer: the auto-payer scores each mana ability, not each permanent,
and activates exactly the one it picked — a Vivid land's plain tap ability
is fine beside its remove-a-counter one (before, the whole land was
skipped and the seat was asked for {W} it could pay: a cast/cancel loop in
the harness). Colour-sensitive spells and post-convoke payment still ask.

Harness: 99 Forests + Marwyn — the cast is offered from the command zone,
happens, and the board then shows it on the battlefield with count 1 and
tax 2 under the same id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
… object

The engine keys a transforming DFC's front-face half as playable beside
the main card; the views know only the main card, so the seat saw
"Unknown (3cc3a2fd) (activate)" next to the real cast (a report on
2026-09-17, Meren vs the CPU: Growing Rites of Itlimoc in hand). Faces are
skipped; an object the views genuinely can't place is named from the game
with its zone and logged at WARN. Harness: a Growing Rites deck lists each
copy in hand once, never an Unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
CI's harness saw a seat wait 60 s after answering "Select a starting
player" while the same game takes 0.7 s here; the result only said
timed_out. Now a timeout carries the game thread's state and top frames
(engine_stack) and logs them, so slow and stuck can be told apart. The
harness waits 120 s per decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…is engine

Terramorphic Expanse activated at priority raises the ability picker;
"no" is accepted and the game goes on. Deck fixture of Expanses + Forests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…already narrows attackers

BLOCKABLE_ATTACKERS published, before the first click, the same per-blocker
legality the engine computes one step later in selectCombatGroup ("Select
attacker to block" lists only legal attackers, auto-picking when there is
one). It existed for a client that declares all blocks in one answer; that
path is still served — a batch is walked through the engine's re-asks and
falls back to the engine's own question when a pair is illegal — and the
whole-declaration rules (menace, "no more than one") were never in the map
anyway. One fewer commit to carry against upstream; Constants is now
identical to the tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…e bridge event buffer, view short ids, the H2 retry, the lazy card load, the JVM-wide seed

The engine delta against xmage_1.4.61V1 goes from +3.3 k to +1.8 k lines
with nothing the product reads removed:

- ServerGameEventLogCollector no longer embeds a state snapshot in every
  decision (the largest game dir on the dev box was 12.5 MB of record, of
  which 12.5 MB were snapshots nobody read — the replay feeder answers
  from the decisions, the product keeps its own boards). 1015 → 565 lines.
- GameImpl: the bridge event buffer (get_game_log's source) and the
  -Dxmage.game.seed property are gone; a game is seeded by
  GameOptions.gameSeed alone. The seeded-seat ordering stays.
- Views: no short ids on CardView/PlayerView/SimpleCardView and no
  assignment pass in GameView — the seat renderer names objects from the
  game's own ShortIdRegistry. CardView.controllerId and
  PlayerView.landsPlayed stay (read).
- CardRepository/CardScanner/DatabaseUtils/ExpansionRepository back to
  upstream: the H2 open-with-retry served several JVMs on one DB file and
  the single-card lazy load served clients that skipped the scan; this
  engine scans once at boot and upstream's .dck importer already falls
  back by name.
- Sets.retainOnly, MicrosecondPatternLayout, a ThreadUtils constant, the
  MatchOptions/MatchImpl seed plumbing, and HumanPlayer's blocker debug
  logging: unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
…order, and the feeder's name fallback covers priority windows

Without the views' name-ordered id assignment, ids were handed out in
render order — different in a replay, where nothing renders while the
feeder answers — and a recorded "p7 (Forest)" landed on nothing. Now every
deck card is numbered at create_game in seat-then-decklist order (the
Deck's card set is insertion-ordered), and when an id still misses, the
feeder matches by name among what the player may play at that window,
not only among a question's explicit targets. Paused at turn 3, resumed
to turn 5, twice, records ~10 KB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FxhL53Rw5Xgh1Xda1aTWw
… and text and replayed by content

A mode is no game object and its id is new every game (Mode() draws a
random UUID), as are the short ids the Done and Cancel entries get at
response time — so a CHOOSE_MODE answer recorded as a bare short id
("p66") resolved to nothing on resume and the feeder stopped with
"recorded choice p66 (null) does not exist in this game": no game with a
charm in it could be resumed. The recorder now writes the mode's
position and text (`mode_index`, `name`), the script reads them, and the
feeder resolves a mode the way it resolves an ability: by text, then by
position, then the only one offered.

Also: normalizeLog collapses whitespace runs whether or not the line
went through Jsoup — the recorder's did, so an empty Fact or Fiction
pile's "HAND: (none)" no longer diverges from the live "HAND:  (none)".

Found by fullpod's golden games (charms-duel, fact-or-fiction).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…open" response

The collector buffered a query until its answer, so a decision open when
the game ended — the app ended or parked it, or a report was filed at that
very question — left no trace: the Meren report's engine record stopped
before the choice it was about. onGameEnd now drains the pending queries
and writes each as a decision with its choices and `response: {"type":
"open"}`, before game_end and in seq order. ReplayScript skips them (the
live game asks the question again), so a resume's hold and count come
from the answered decisions only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ation; {X} is announced with a bound

DecisionRenderer called every hand card a cast with its own mana cost, so
Satoru Umezawa's ninjutsu on a Blightsteel Colossus in hand showed as
"Blightsteel Colossus, cast, {12}" in the declare-blockers window — what
was playable was ninjutsu {2}{U}{B}. A hand card with no castable spell
ability and no land play (PlayableObjectStats.hasBasicCast / hasBasicPlay)
is now rendered as an `activate` with its `playable_abilities` text, the
way the same card is from any other zone.

The engine announces {X} with max = Integer.MAX_VALUE (VariableManaCost.
maxX), which reached the client's stepper and a pilot's quick answers as
two billion. An {X} announcement now carries `max` bounded by the most
mana the seat could make (the largest of its mana options) and
`mana_available` alongside.

Both found by fullpod's golden games (satoru-ninjutsu, charms-duel).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…om their own seed

RandomUtil was one java.util.Random per JVM. A seeded game reseeded it at
init, which made a game replayable from its seed only if nothing else drew
from the generator meanwhile: two games on one host interleaved their
shuffles, tosses and coin flips, and neither could be resumed (fullpod
docs/save-resume.md, "one RNG per JVM").

RandomUtil keeps the shared generator and adds a thread-bound one:
bindThread(seed) gives the calling thread a Random of its own, and every
static draw (nextInt, nextBoolean, nextDouble, getRandom() for
Collections.shuffle) reads the current thread's if one is bound, else the
shared one. GameImpl.init binds it where it used to call setSeed -- on the
game thread, right before the first shuffle -- so a seeded game's stream is
its own whatever else the JVM does; GameHost unbinds when the game thread
ends. Callers are unchanged. Unseeded games keep the shared generator, and
setSeed reseeds whichever is current, so the test path (setSeed(42) under
skipInitShuffling) behaves as before. The CPU player simulates on its own
threads and therefore draws from the shared generator: a CPU game was never
resumable, and its simulations now leave a seeded game's stream alone.

ConcurrentGamesTest plays one seed alone, then the same seed beside a
second game on another thread, and compares the two records line by line
(decisions, actions, turns and phases up to the stop turn; the [abc] refs,
the offered lists and the ending excluded). The fullpod goldens replay two
records through the feeder at the same time on one host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sk otherwise

The auto-payer picked a source by score and let ties fall to battlefield
order, and "tap-only" looked at costs, not effects: Ancient Tomb ranked as
clean as a Forest, a Sol Ring could pay a one-drop with the rest wasted, a
mana creature could be tapped before a basic, and a generic pip was paid
from whichever colour came first. Each is a choice that changes what the
player has left, made for them.

AutoPay plans the payment over the clean sources — tap-only abilities
with no effect beyond the mana, on a permanent that is not a creature,
wears no attachment, has no becomes-tapped trigger, and makes
unrestricted mana; sources with the same outputs are one group — and
enumerates every way to pay. A plan whose leftover another plan's can
stand in for, source for source (a dual covers a basic, an any-colour
rock covers a dual), is not a choice anyone would make for mana; if one
plan remains the payment is automatic, otherwise it is ambiguous. A
person's seat (the offer_mana_sources switch, the product's one signal
that a person sits here) then gets the engine's mana prompt; a pilot's
takes the plan that wastes least and keeps the most kinds of mana up,
and is never asked. Unclean sources are the person's to tap first at
priority; their existence does not make a payment ambiguous, or Ancient
Tomb on the board would turn every cast into a prompt.

AutoPayTest: the planner on hand-built boards (three Forests, Forest or
Island for {1}, tapping out, a dual kept up, a rock paying exactly, a
hybrid pip, a 21-source board in under a second). AutoPayGameTest: the
same through the engine with cheated boards — Ancient Tomb never tapped
for you when a Forest can and life stays 20, the Tomb alone asks, Crystal
Vein pays with its plain ability and stays, a mana creature and a
conditional source (Ancient Ziggurat) are left alone, Breeding Pool is
kept up beside basics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ones

A person's seat plans over clean sources only, because the rest are
theirs to tap; a pilot has no one to ask, so restricting it the same way
left an AI seat unable to cast a green spell when a mana creature was its
only green (the precon-pod golden: Tribute to the Wild now prompted, and
the pilot's driver cancels every prompt). The pilot's plan now takes in
every tap-only source (still never a sacrifice, life, mana or counter
cost, still never conditional mana), and its choice among plans prefers
the fewest unclean taps after the least waste — a Forest before a dork
or an Ancient Tomb. Two plans that are the same for mana (a dork or a
Forest for {G}) collapse to the pilot's preference rather than the first
found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… rest

What the browser's multi-select (one decision to the person, several to
the engine) relies on, pinned: Forest, Island, Swamp paying {1}{G} —
the first prompt asks for the whole cost and offers all three; answered
with the Forest, the second asks for {1} and offers Island and Swamp
under the same ids; answered with the Island, the cast completes with
the Swamp up. Answered with the Island first, the {G} left has one
clean source and the engine pays it without asking again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chooseAction sent the answer (which wakes the game thread) and then
cleared whatever the seat had pending. On a loaded machine the game
thread had already asked and parked the next question by then, so the
clear dropped it: the engine sat in waitForResponse (chooseMulligan,
right after the starting-player answer) with nothing pending, every
awaitDecision timed out, and the harness failed three ways from one
cause — CI on the pin tags, whose runs race the branch runs.

Seat.clearPending now takes the answered decision and clears only if it
is still the pending one. SeatTest pins the invariant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every engine change pushes two refs at the same commit (fullpod and
pin/<date>), and the workflow ran for each: the same build and harness
twice, concurrently, on separate runners — double the minutes, and the
tag run was the slower and flakier of the two. A check attaches to the
commit, so the branch run already covers the tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l-off (or random) for who goes first

GameHost.Config gains freeMulligans and startingPlayer (create_game args
free_mulligans, starting_player). The mulligan is XMage's London rule as
before, now with the table's free count: 0 as the rule is written, 1 the
common multiplayer table rule.

Who goes first: "host" (the first seat chooses, as before), "toss" (the
engine's coin toss picks who chooses), "roll" or "random". The last two
are a GameOptions.StartingPlayer the engine honours in GameImpl.init
before the 103.2 prompt: ROLL is a d20 roll-off among the players who can
respond — highest goes first, ties re-roll among the tied, each round a log
line ("CPU rolls 17, You rolls 5", "CPU goes first") — and RANDOM picks
one with no prompt. Both draw from the game's own generator after it is
bound (seeded games sort the seats by name first, as the toss does), so a
seeded game rolls the same dice on replay.

TableRulesTest covers the roll-off and random starter (no "starting
player" question reaches the seat; the rolls are in the log) and one free
mulligan then London (the free one, then "down to 6" with the bottom
question reaching the seat and answerable, six cards kept).
rollbackTurns aborts the player's wait and restarts the turn on the game
thread, but GameHost.rollback left the seat's pending Decision in place,
so the next awaitDecision handed the dead question out again and the
person answered it — with Undo, a second time ("undo takes two clicks",
report 009462e808). rollback now captures the pending question before
the engine call and clearPending()s it after (only that one: the fresh
question may already have landed), and drops any half-sent combat
batch. RollbackTest asserts the first question after a rollback is from
the restarted turn; it used to loop past the stale one, answering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Report a9bddc7747: {B} and {G} floating, {1}{B} owed for Demonic Tutor,
and the prompt offered only "Black". DecisionRenderer.poolChoices scanned
the unpaid cost for colour symbols and, finding one, listed only the pool
colours those symbols name — so a floating colour with no matching pip
had no button, even though the generic pip takes any of them, and the
prompt read as demanding another source be tapped. Now a generic pip
(or a twobrid one, {2/W}, which the colour scan also matches) in what's
left keeps every non-empty pool colour on offer. Spending the float stays
the person's choice: the tap-a-source options are still beside the
buttons. AutoPayGameTest floats two colours at priority, casts, and
checks the prompt for both; and the twobrid case with red floating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFKgkfdpwtjhSJc5YjBa8i
Report ed184e0607: floated 6 mana in 3 colours off Selvala, spent 2 of
them through the mana prompt's pool buttons, and the rest of the cost
was silently paid by tapping fresh lands while the other 4 sat unused
in the pool. Traced the game's own event log: after the second pool
pick the remaining {1} was payable by exactly one untapped land, so
AutoPay's "one plan, pay without a word" branch fired — but AutoPay's
plan is built only from untapped permanents (SeatPlayer.sources()),
never the mana pool, so it had no way to know the float could still
help and never re-offered it.

SeatPlayer.playManaHandling now checks whether the floating pool has
anything that could still pay part of what's owed before taking that
silent branch; if it does, the payment falls through to the normal ask
flow (which already renders the pool buttons alongside tap-a-source
choices), so spending the float stays the player's own click same as
every other pool spend, never forced and never skipped. New
AutoPayGameTest confirms the fix against the reproduction (fails
without it, one prompt and the land tapped instead of the float) and
passes with it (two prompts, the land stays untapped).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
narfman0 and others added 20 commits October 1, 2026 13:27
…n names the seat

isOver() was true when the game thread had merely died, and finish()
then wrote game_over with game.getWinner() — which, for a game that
never ended, is the sentence "Game is a draw". An OutOfMemoryError in
GameState.copy reached the player as "Winner: Game is a draw" (report
fa5cec13dd). And for a real host-side win getWinner() is "Player X is
the winner", never a name, so the product could not show the seat.

finish() now tells the two apart. An ended game is game_over with
winner = the winning seat's name (the player that hasWon), or draw:
true. A thread gone while the game never ended is engine_died with the
thread's cause in error and no game_over: nothing was decided. isOver()
still means "no more questions will come" for the long-poll;
threadDied() and winnerName() say which. The thread body is started by a
package-private launch(Runnable) so the harness can make one die.

GameHostTest: aDeadGameThreadIsACrashNotADraw (a body that throws
OutOfMemoryError: engine_died, the cause, no game_over/winner/draw), and
the concede test now asserts game_over with winner "CPU".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK316TsRbEzP861aXKB6en
…30 simulated

A CPU simulating a triggered ability with targets copies the whole game once
per target option and keeps them all at once. On a late four-player commander
board that ran the engine's heap out: Inferno Titan's "3 damage divided among
one, two or three targets" over thirty distinct creatures was ~5,500 options,
each a multi-megabyte game copy, inside a loop that never looked at the
interrupt a timed-out think gets.

- TargetOptimization kept one target from every group of alike targets even
  when the groups outnumbered its limit; past the limit it now takes a target
  from random groups up to the limit (fewer groups: unchanged).
- SimulatedPlayer2.triggerAbility simulates at most 30 of a trigger's options,
  evenly spread, and stops copying once its thread is interrupted.

TriggerOptionsMemoryAITest: a big board with 90-card libraries; before, 5,456
options for the Titan and a 1 GB heap run out casting it or Deepglow Skate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK316TsRbEzP861aXKB6en
A board that shows only the current pair has nothing to say when a Seeker
of Skybreak reads 1/2 under an opponent's Gravitational Shift (fullpod
report 237c17a7e3) or myriad tokens read the printed 2/1 beside an
original set to 8/8 (d8e85f3b6f): `modified` compares the rules alone.
`base_power` / `base_toughness`, which a token already carried for its
art, now go on every creature from `PermanentView.getOriginal()` — the
token as created, or the card built without the game.

That card is the wrong answer when the permanent is no longer that card:
a Clone copying Bears would say 0/0, a morph its hidden face, a
transformed or flipped card its front. So a card's pair is sent only while
the permanent still wears the card's name and is neither a copy, mutated
nor face down; no signal beats a wrong one. PrintedPowerTest holds a Hill
Giant made a copy of Grizzly Bears and a manifested Centaur Courser (no
pair on either, the Bears beside them 2/2); TokenBasePowerTest's Bears
under Ajani's emblem now reads 4/4 with 2/2 printed.
…nt's creature carries it too

The view's `original` is built only for the permanents the viewer
controls (XMage keeps a face-down card from its opponents that way), so
the pair from pin/2026-09-26a reached your own creatures and none of
theirs — and an opponent's pumped creature is the number a player asks
about most. `game.getCard(perm.getId())` is the printed card whoever
controls the permanent; the same guards apply (a copy, a mutated or a
face-down permanent, a name the card no longer wears send nothing).
PrintedPowerTest now has the CPU's Runeclaw Bear at 2/2 from our seat.
…opponent's identical triggers is thought about once

The window ceiling on CPU thinking reset when a seat was asked, so a
"Pass until the stack resolves" hold — the server answering every trigger's
priority for the person — restarted it at each trigger and two CPUs each
took their full 3 s per trigger: 29 Scute Swarm landfalls were three
minutes of waiting (fullpod report a374dd040c). Two changes in the seat
module, no upstream file touched:

- The window ends when a question is answered, not asked, and choose_action
  takes `held: true` — the server's own pass on the person's behalf, nobody
  asked — which keeps the window open. The table_window record says how
  many held answers it ran through. The turn-change check reads the turn
  of the last answer, so an answer's held count isn't dropped at the turn's
  first think.
- SeatCpu remembers the question it last passed on when an opponent's
  triggered ability is on top of the stack (turn, step, active player, the
  trigger's controller, rule text and targets, and its own hand, permanents,
  untapped ones, life and playable count) and passes the same question
  again without simulating: a cpu_think with ms 0 and memo true. A
  simulated PassAbility counts as a pass, and a pass from ComputerPlayer6's
  own chain memos too.

CpuPerfTest: the reporter's shape (eight Scute Swarms, a CPU with instants
in hand so its think has a tree) — one simulation per run, the rest memo
passes; a held run is one window with every held pass counted, a plain run
a window per pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013KaReWkYvMJt3h8ZqDswC9
…, triggered)

Views.stackItems carried name, source, text, owner and targets, and
nothing said whether an item was a spell somebody cast, an ability
somebody activated, or a trigger the game added on its own. The product's
"Pass until the stack resolves" needs the difference: a new trigger is
the stack resolving, a new spell or activation is a seat responding.
`kind` comes from the StackAbilityView's AbilityType — "triggered" for a
triggered ability, "activated" for any other ability, "spell" for a plain
CardView (a spell or a copy of one). StackKindTest casts Grizzly Bears
with a Soul Warden out and sees the spell, then the trigger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013KaReWkYvMJt3h8ZqDswC9
…est)

"Pick triggered ability (goes to the stack first)" is asked once per
trigger still waiting. XMage orders identical triggers by itself only
when every trigger still waiting reads the same (HumanPlayer's
autoOrderUse, all-or-nothing on the list): one different text and each is
a question, the identical ones too — a landfall board returning five
lands was fifty-three prompts (fullpod report b1e2a45dc3).

choose_action {choice, remember: "rest"} on a trigger-order prompt now
puts the chosen trigger on the stack first and leaves the rest of the run
to the seat: SeatPlayer keeps what the prompt asked about, and while every
trigger the engine asks about is one of those it answers in the engine's
order (a remembered first/last still honoured) with no query fired —
nothing asked, recorded or waited for, as a remembered order. The first
batch with a trigger it was not asked about ends the run and is asked as
usual; the trigger-order reset forgets the run too.

PlayerAction.TRIGGER_AUTO_ORDER_REST is the one fork action: the host
sends it for the answer, and ReplayFeederCollector sends it for a
recorded `remember: "rest"` so a resumed game is not asked what the
original was not. RememberedAnswerTest covers the run and its end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013KaReWkYvMJt3h8ZqDswC9
…lept through

The response window opens in prepareForResponse, before the question is
fired, and the game thread sleeps on the response object in
waitForResponse. A seat answers as soon as the question is delivered —
inside the fire, before the sleep — so its signal can land in the gap.
An answer with a value was already kept (hasSyncResponse, the "survives
a slow client" commit); a cancel (setResponseUUID(null), how a seat
backs out of an activation) and a rollback's abort() were not: their
notifyAll ran with nobody waiting, and the game thread then slept on a
response that was never coming. On the 2-vCPU CI runner that was the
"no decision within 120 s" stall — GameHostTest.activationCanBeBackedOutOf
(runs 35518170214, 36448662866) and RollbackTest (three runs of six on
2026-09-19, 35810094717, 35869880021) — never seen locally.

PlayerResponse now records that an answer arrived at all (isAnswered,
set by every payload setter, cleared with the window), and the wait is
skipped when one has, or when abort is already set. ResponseWindowTest
holds the gap open on purpose and sends each signal into it: before this
change the cancel and the abort cases hang; now the wait comes straight
back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PR4Te3eWrydYiZUdWS4Yo
…ed by one JVM and read by another keeps its permanent exile

The key was a UUID drawn at random once per JVM and serialized with the
zone map, so a snapshot (mage.player.seat.Snapshot) read by the next
engine process had its permanent exile under a key nobody asked for:
getPermanentExile() returned null and the first exile with no named zone
(cascade, Path to Exile, a commander sent to exile) threw, which the game
answered by restoring the start of the step. The key is a constant now,
and Exile.readObject heals a board saved before it: the zone named
"Permanent - Exile" is moved under the constant with its cards and the
zones' order, or an empty one is started. The serial form is unchanged
(the new members are private), so older boards still read.

SnapshotResumeTest resumes a checked-in board written by another process
(Paths and Plains against the bears, turn 2) and plays until Path to Exile
has sent bears to the permanent exile, with no game error; a unit case
round-trips an Exile moved to a random key and one with no permanent zone.
recordForeignFixture re-records the board on request when a serialized
class changes shape.
… CPU search tree in the file

The snapshot write (GameHost) wrote at every top-level question still
open 1.5 s after it was asked, and any answer aborted it. Three changes:

- At most one completed write every snapshot_interval_ms (default 20 s):
  a resume needs a recent board, not every stop, and each write is CPU a
  throttled box spends. A question already in the file is not written
  again.
- A bound on staleness for a person who always answers mid-write: once a
  write has been given up for an answer and the file is snapshot_stale_ms
  old (default 60 s), the next write is held — an answer during it waits
  for it (an answer_blocked record, by snapshot; the write's record says
  held) instead of aborting it.
- ComputerPlayer6.root is transient. It is the search tree the CPU keeps
  between thinks for getNextAction, a branch of full game copies, and it
  was most of the file and all of its growth: a 4-player Commander board
  (a seat and three CPUs on the precons, skill 1, 3 s cap) to turn 9
  wrote 1.02-2.25 MB in 205-542 ms before, 251-262 KB in 26-32 ms after.
  A CPU read back from a file thinks afresh, as after any state change.
  The class keeps its previous serialVersionUID, so a file written with a
  tree still reads (the tree is dropped).

Also: a write never starts at a question something has already woken the
game thread from. The seat's question is cleared only after the answer
is sent, so for a moment an answered question still looked open, and a
write queued behind an earlier one could start while the game thread was
already moving.

SeatHost's create_game takes snapshot_interval_ms and snapshot_stale_ms.
SnapshotResumeTest: an answer mid-write on a live host goes through in
milliseconds and the previous file stays; the next write, past the stale
limit, is held, the answer waits for it, and the game resumes at that
question; at most one write in an interval; and a 4-player size probe
(-Dfullpod.sizeProbeTurns) that fails if the file names SimulationNode2.
…heel of Misfortune, and Choice of Damnations leaves the caster's choice to the caster

The generic getAmount fallback keeps a CPU's number safe (near the
minimum on a wide range, never its own life), not sensible: it doesn't
know what the number does. Each card here now has an isComputer()
branch around its getAmount call, the shape XMage's own hinted cards
use, and each hint reads only what the CPU can see (the battlefield,
graveyards, life totals, its own hand and library):

- Talion, the Kindly Lord: the mana value most seen among opponents'
  nonland permanents and graveyard cards; nothing seen, 2 or 3.
- Expel the Interlopers: the power threshold with the best trade,
  opposing creatures destroyed minus its own, each weighted by mana
  value; ties to the narrower sweep; none ahead, 10.
- Yusri, Fortune's Flame: 2 flips; 1 at 10 life or less; 5 only when
  at 20+ and 10 ahead of every opponent with two spells of mana value
  4+ in hand; never more than its library.
- Squee's Revenge: 1 or 2 (the expected cards peak there), never more
  than half its library.
- Sanctum Prelate: the noncreature mana value opponents have shown most
  less its own noncreature spells (hand and library); ties nearest 2.
- Goblin Game: 2 or 3, lowered (to 1 at least) while tying for fewest
  would kill it.
- Squall, Gunblade Duelist: the power or toughness most common among
  its creatures (battlefield, itself, hand); ties to the higher.
- Void: the mana value with the best battlefield trade, weighted as
  Expel; none ahead, the first of 2, 3, 1, 4... that spares its own.
- Menacing Ogre: an opponent 0; the controller 2 or 3, at most half
  its life.
- Scrying Glass: 1 and the colour most seen among that opponent's
  permanents; none coloured, the CPU's own choices.

Choice of Damnations asked targetPlayer.isComputer() for the caster's
life-or-sacrifice choice too, so a person casting it at a CPU never got
that choice. It asks controller.isComputer() now. The other upstream
hints (Illicit Auction, Mage's Contest, Pain's Reward, Volcano Hellion)
are bounded and stay as they are.

Tests: one class per card on CardTestPlayerBaseWithAIHelps, the CPU
seat under aiPlayStep while the card resolves; randomised picks get a
range assertion.
…ver written over one another

The host addresses a seat by its engine username: the seat and CPU maps
and a snapshot's players at resume are keyed by it, and a second put
under one name overwrote the first in silence, so one seat went unasked
and seatNames() reported one short. A second seat under a name is now
an IllegalArgumentException before anything is built or read, and every
put into those maps refuses a name it already holds. Keying the host on
a seat id is the fix this stands in for (fullpod issue magefree#24).
…nd a helpful Aura is never cast on an opponent's permanent

Two choices by the CPU that read as broken (fullpod reports 078b7f4b49 and
f8f425be73):

- Seeker of Skybreak ({T}: Untap target creature) targeting itself resolves
  to the board it started from, so it scored the same as passing, and the
  passivity penalty taken off a pass at the root made the no-op the choice
  at every priority of an opponent's turn: 2,340 activations over 18
  minutes, a fresh think each. ComputerPlayer6.addActions now compares the
  board after a node's ability resolved (boardValue: permanents, life,
  hands, graveyards, libraries, exile, the rest of the stack) with its
  parent's, and a match is NO_OP_SCORE, which simulatePriority and minimaxAB
  skip. Only an activated ability can be one. The seat's own backstop,
  SeatCpu's loop guard, turns the fourth identical act at the same board in
  a step into a pass (a cpu_loop_break perf record), for the cycles the
  search can't see.
- SimulatedPlayer2.optimizeOptions rightly removed an opponent's permanents
  as targets of a wholly good effect, but simulateOptions then added the
  ability with no target at all, and chooseTarget's required-target
  fallback put Utopia Sprawl on the player's Forest when the CPU had none.
  An ability whose options were all filtered away is not an option.

Tests: AI/basic/NoOpActionAITest, AI/basic/AuraTargetsAITest (Rancor for
the positive case: the evaluator never values Utopia Sprawl's mana), and
the seat harness plays a CPU with a Seeker to turn 6 with no self-untap.
The snapshot fixture is re-recorded (SeatCpu has a new field).
… before anyone has priority is a loss, never a draw

GameHost.concede left the seat's pending decision in place, so the next
next_decision handed the stale question out again (with player_dead, and
the app had to know to drop it). The concede now drops it, clears any
batch, and a conceded seat is delivered nothing more; every result for it
says `conceded: true` (fullpod issue magefree#32).

A concede while nobody has priority (the starting player's question, the
mulligans) is processed on the conceding thread: it ends the game, then
marks the winner, and the game thread sets winnerId later still. The
result read GameImpl.isADraw (an ended game without winnerId), so a poll in
that gap said "draw". The winner is now whoever has won, and an ended game
without one is a draw only once the game thread has finished with it
(joined for up to a second).

Tests: GameHostTest aConcedeBeforeTheStartingPlayerIsChosenIsALoss and
aConcedeAtTheMulliganWithNoChooserIsALossFromTheFirstPoll (roll and
random: the mulligan question was handed out again).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…again, flagged chosen, so a divided-damage pick can be taken back

Every one of HumanPlayer's several-targets loops (chooseTargetAmount for
divided damage and distributed counters included) removes a pick that is
picked again, but a permanent's or player's possibleTargets leaves the
picked out, and the seat answers only with what is on the list: a
divided-damage pick could not be taken back short of an undo (fullpod
issue magefree#34). DecisionRenderer now adds the engine's chosenTargets to the
offered targets, each flagged `chosen: true`, the way a card search
already re-offered its picks; the board's tap and the pilots' "(chosen -
pick again to remove)" label were already there for it.

The picks are listed after every other choice, so the first choice, which
a default or a scripted answer takes, is a new target while there is one:
first-choice answers to a required pick-several (a cleanup discard) would
otherwise take the first pick back and pick it again forever. ScriptedSeat
answers Done when only picks are left.

Tests: MultiTargetPromptTest aDividedDamagePickCanBeTakenBack (Arc
Lightning: a Bear picked, picked again to take it back, the other picked,
Done - the other takes all 3, the first is untouched); Frost Breath's
second round lists both Bears with the first flagged; the delve pick in
SpecialManaActionTest sees its seven picks offered again before Done.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…most creatures they would keep, less its own

By Invitation Only (0-13, each player sacrifices that many creatures) had
no hint and took the generic getAmount pick, within three of the minimum:
safe, never the number a person would name. It was the one wide "choose
a number" card the sweep for the ten hinted cards left out (fullpod issue
magefree#38). Same shape as those: an isComputer() branch around getAmount,
reading only the battlefield. Each player sacrifices creatures of their
own choosing, so a number n costs each player their n least valuable
creatures, each weighed by mana value (at least 1) as Expel the
Interlopers weighs them; each number scores its opponents' loss less its
own, ties to the smaller number, and nothing ahead is 0.

Tests: cards/single/vow/ByInvitationOnlyTest on
CardTestPlayerBaseWithAIHelps (an Elf for five opposing creatures: 5,
which the generic pick never reaches; an Elf for a Bear but not the
Behemoth: 1; a Behemoth against a Bear: 0). Without the hint two of the
three fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Signets and two lands casts its four-drop

AutoPay planned only tap-only mana abilities, and a pilot's driver cancels
every mana prompt, so a pilot never used a Signet or a filter land: Kaalia
of the Vast with Boros Signet, Plains, Swamp, Swamp up was cancelled every
time (fullpod issue magefree#31, seen re-recording precon-pod).

For a pilot's seat (never a person's: they tap a Signet themselves) an
ability whose only costs are {T} and mana is now an Output with a `cost`:
tapping it owes those pips, paid by fresh cost-free taps only (never its
own mana, which arrives after the cost is paid; one level deep). A plan
that taps a Signet taps it first and carries the taps for its cost
(Pick.costPicks); the engine asks for that cost inside the activation and
SeatPlayer answers it from the plan. If the activation fails anyway the
payment goes on without costly abilities. A costly source never stands in
for a cost-free one when plans are compared, and a Signet left up counts
toward a plan's leftovers only while the free sources left could pay for
it, so {2} from a Signet, a Plains and a Swamp taps a land into the Signet
and keeps the other land up.

A Signet makes two mana for a one-pip spell, and the rest floats; a pilot
passing then was asked "You still have mana in your mana pool and it will
be lost. Pass anyway?", and a "no" put it back at the same priority to be
asked again (the golden recorder looped there). A pilot's seat no longer
confirms emptying its pool (UserData.confirmEmptyManaPool, set with
setAskWhenAmbiguous); a person's still does.

Tests: AutoPayTest aSignetIsTappedWithABasicIntoIt and
aSignetLeftUpWithNoLandIsWorthNothing; AutoPayGameTest
aPilotTapsABasicIntoASignetForItsFourDrop (Kaalia, no prompt, all four
tapped), aPilotKeepsALandUpRatherThanAnUnpayableSignet,
aPilotPassesWithASignetsSpareManaUnasked and
aPersonIsAskedRatherThanHavingASignetTappedForThem.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed and named in the same order every run of the same game

A trigger-order question took its abilities from a hash set of their
UUIDs, random every game, and sorted them by name and short id; two
Soul Warden triggers have one name and no short id yet, so they kept
hash order, were handed their short ids in it, and swapped ids and
index between two runs of the same game (fullpod goldens
wardens-triggers / wardens-remembered re-recorded with p77/p78 swapped
three times). Now they are ordered by their source's short id, then
the order the engine lists them (the order they triggered).

The same tie, broken the same way, everywhere a hash set reaches a
list: a target prompt's targets fall back to the offered list's order,
then the game's own (Views.gameOrder: players in turn order, the
battlefield in entering order, the stack, hands, graveyards, libraries,
exile, the command zone); the playable list too; a several-targets
prompt's `chosen` follows the choices. The record writes a target
prompt's targets in short-id order instead of hash order.

TriggerOrderTest: two Soul Wardens and a Soul's Attendant see a Bear
enter, twice; the triggers are listed by source, named in that order,
an index answer puts the one at that index on the stack, and the two
games list them identically.

fullpod issue magefree#33

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e each

The questions a scripted game never asks, each rendered as the board
reads it and answered with something other than the default:
Fact or Fiction (the CPU separates, the seat takes pile 2 and draws
exactly it), Harnessed Lightning (pay 2 of 3 energy: the Bear dies, 1
left), Boros Charm (three modes and Cancel, the second taken: the Bear
is indestructible, nobody dealt 4) and Blaze (X announced with the
mana bound, 3, the CPU dealt 3).

fullpod issue magefree#23

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…f that name

The source line under a prompt ("Stomp [a3f]") names its object by the
first three hex digits of a random UUID. With one registered object on
that ref, the name beside it went unchecked; an adventure's spell part
has no short id, so a stranger that happened to share the three digits
became the source in one run and nothing in the next (the zone-plays
golden through the seats). The name is checked for a lone match too.

fullpod issue magefree#33

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@narfman0

narfman0 commented Oct 5, 2026

Copy link
Copy Markdown
Owner Author

Superseded by the xmage_1.4.62V1 refresh (#3), landed on fullpod 2026-10-05.

@narfman0 narfman0 closed this Oct 5, 2026
@narfman0
narfman0 deleted the refresh/master-2026-10-01 branch October 5, 2026 03:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant