Repository navigation
Conversation
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
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>
…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>
Owner
Author
|
Superseded by the xmage_1.4.62V1 refresh (#3), landed on fullpod 2026-10-05. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebase of
fullpod(2ea9603) onto7c517e0f88b7981b58225a2cb8aa185c6adc1931(master-2026-10-01), conflicts resolved by the refresh workflow's agent; seat harness on the result: failure.A build-only
engine-pin.ymlrun was dispatched for0174d259931dda165596253a2174b765a535a73c(harness and golden games; nothing lands).This branch is
fullpodrewritten on top of the target, so the merge button cannot land it (its commits are the twins of fullpod's own). To land it, replacefullpodwith it, keeping the old head as a tag, then run Engine pin on the new head:The PR then shows as merged. Not landing it is fine too: the next rehearsal overwrites the branch.
Refresh:
fullpodrebased onto upstream7c517e0f88refresh/master-2026-10-01is the fork's 88 commits replayed onto upstreammaster's
7c517e0f88b7981b58225a2cb8aa185c6adc1931("server: fixed chatmessages of connection problems"), 611 upstream commits past the release tag
xmage_1.4.61V1the branch sat on. 87 of the 88 land; one is dropped becauseupstream carries it now. Four commits stopped with conflicts; the rest
replayed clean. Nothing is pushed — the clone has no credentials,
fullpodand the tags are untouched.
The collectors interface
Commit
e526af76e0"Engine: seeded games, stable ids, game-event andreplay-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:
1f00762840splitonTestsStackResolveintoonTestsStackResolveStart/onTestsStackResolveEnd(both taking the
StackObject),5123af7974widenedonGameError's secondparameter from
ExceptiontoThrowable, anda04e6abacdaddedonGameEndResultfor the result that is only known afteronGameEnd. Ourcommit widens
onGameLogwith agameSeqand addsonPlayerQuery/onPlayerResponse, the two hooks the record is written from. The two sets ofmethods 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
StackObjectimport beside ourPlayerQueryEventone, upstream'sonGameEndResultahead of our two playerhooks, and upstream's
ThrowableandStackObjectsignatures as they stand.ServerGameEventLogCollectorandReplayFeederCollectorboth extendEmptyDataCollector, so upstream's three new methods reach them as no-opsand neither collector needed touching.
The response window
Commit
d14d0c768e"HumanPlayer: response window that survives a slowclient (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()toresetAnswers()and made it leave the asyncconcede/cheat marks alone ("do not reset async commands -- it's processing
independend"), added
hasAnswer()— the same predicate, field for field, asthe
hasSyncResponse()this commit adds — andhasAsyncCommand(), and nowchecks 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
prepareForResponsesetsresponseOpenedForAnswer = falseand the windowopens only once the game thread reaches
waitForResponse, so a client thatanswers inside the callback spins in
waitResponseOpen()until it does.Kept ours: the window opens in
prepareForResponse, the field staysvolatile boolean(upstream's is still a non-volatileBooleanread from theCALL thread), and the
firstWaitguard keeps the first pass through the loopfrom clearing the window
prepareForResponsejust opened. Took upstream'sshape around it —
resetAnswers()for the goneclear()in both theprepareForResponsehunk and the re-arm, upstream's comment block above thefield 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 somethingupstream cannot express (below).
Two deliberate consequences of adopting
resetAnswers(): a prompt no longerclears 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. Thecard bodies are all but identical. The engine branches ask the opposite
question:
getIgnoreLowestAmount() > 0(the card callsincIgnoreLowestAmount(1)besideincAmount(1)), asks the roller whichresults 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.
getAmount() > 1— any multi-planar roll, Elixir ornot — asks which single result to keep, discards everything else, and
asks even when the results are identical. Its computer answer is whatever
ComputerPlayer.choosehappens 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
@authorblock of ours, upstream'sthis.staticTextline, and only thereplaceEventbody is ours (theincIgnoreLowestAmount(1)that drives our branch). Upstream's planar branch inrollDiceInneris 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
6c8260bb1aalso added two cases toMage.Testscards/single/pc2/FracturedPowerstoneTest(testIchorElixir,testMultipleIchorElixir) that answersetChoice(playerA, "Blank Roll")andexpect 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/IchorElixirTestis the mirror image and fails againstupstream's branch.
Mage.Testsis not in the harness this refresh runs, soneither was executed here. Dropping
cca68123f5in favour of upstream'simplementation 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 inPlayerImpl.drawCardsthat the Aminatou, Veil Piercer work depends on (ahistory 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 beforethe wait is not slept through".
Files
HumanPlayer.java,PlayerResponse.java.This commit replaces
hasSyncResponse()withisAnswered()— a flag everypayload setter raises, cleared with the window — because a seat's cancel is
setResponseUUID(null)and a rollback'sabort()carries no value at all, so"has a value" misses both and the game thread sleeps on a
notifyAllthat hasalready run. Upstream's
hasAnswer()has exactly that blind spot, so there isnothing of upstream's to adopt here; the conflict in
HumanPlayerwas onlyagainst the comment I had written one commit earlier, and I took this commit's
own.
In
PlayerResponse.resetAnswersthe two sides disagreed about the asyncmarks: ours cleared them (it was patching the old
clear()), upstream'sleaves 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
isAnsweredjavadoc's{@link #clear}at#resetAnswers, sinceclearno longer exists.GameHostTest.activationCanBeBackedOutOfandRollbackTest, the two thecommit 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.mdalreadyrecorded 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 -ambuilds clean.mvn test -pl Mage.Player.Seat: 121 run, 2 failures, 1 skipped. Both failures are inSnapshotResumeTest, 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 thatengine-refresh.mdsays is part of a refresh, not a regression: the test holdsone game serialized by another process, and
GameImplhas no pinnedserialVersionUID, so any change to its public shape moves it. Upstream movedit —
5123af7974added the public methodendWithTechnicalWinnerand77186e021bthe public staticCOPIED_COUNT, both of which the default UID iscomputed 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 onturn 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:
Upstream's
28fb5f005b("tests: fixed miss game logs about draw and mulligan")changed one line in
GameImpl.resume()fromfireInformEvent("Turn " + state.getTurnNum())toinformPlayers(...).fireInformEventgoes to theclients only;
informPlayersgoes throughonGameLog, which is our recorded,sequenced stream. So the resume now spends a seq announcing the turn — and two
more besides, because
ServerGameEventLogCollectorsynthesisesturn_changeand
phase_changeinsideonGameLogwhenever the state has moved since thelast 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 thecollector'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 + 1should become the looser "the samequestion, 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