Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
date: "2026-10-19"
title: "Battle simulator: turn timing and the end of a game follow the Advanced Player's Rulebook and the Tournament Rules"
decided_by: "@cheyras (asked for everything left on purpose that is actually against the rules to be aligned with them); Claude Opus 5.5."
areas: ["decks"]
supersedes: []
---
## 2026-10-19 — Battle simulator: turn timing and the end of a game follow the Advanced Player's Rulebook and the Tournament Rules
**Decided by:** @cheyras (asked for everything left on purpose that is actually against the rules to be aligned with them); Claude Opus 5.5.

**Decision:** The items the 2026-10-17 pass left open were checked against the Pokémon Card Game Advanced Player's Rulebook v3.4 (2025) and the Play! Pokémon Tournament Rules Handbook. Five turned out to be against the rules, and the engine now follows the documents:
- **The tiebreaker game has 6 Prize cards.** A game where both players win at once is a tie. A Tiebreaker game decides it: a new game from the start, a new coin flip and 6 Prize cards each, won by the first player to have fewer Prize cards left than the opponent, or to win outright (APR "Games can result in a tie"; Tournament Rules 5.8.3.1). This corrects the 2026-10-17 entry's 1 Prize card each, which followed older wording. PTCG Live has the mode (`MatchMode.SuddenDeath`), and its starting Prize count is data the engine scan did not read. A third tied tiebreaker game is recorded as a draw.
- **"During ..." effects end before Pokémon Checkup.** "During this turn", "during your next turn" and "during your opponent's next turn" do not apply in the Checkup after that turn, because Checkup is in neither player's turn (APR E-02). "Until the end of your next turn" does still apply in it (E-22, E-23): `TimedEffect.throughCheckup`.
- **A Knock Out from an end-of-turn effect is resolved before Checkup.** A Knock Out is checked whenever a Pokémon's remaining HP becomes zero (APR D), so the turn's end-of-turn effects are followed by a Knock Out check before Checkup starts.
- **Setup is face down.** Each player's Active and Benched Pokémon are chosen face down and kept with the hidden hand (`PlayerState.setupPick`). Both players' Pokémon go into play together when the game starts, after Prize cards and the extra mulligan draws (APR G, steps 5–8). No choice can see the other player's setup.
- **The turn cap is time being called.** `maxTurns` is the turn time is called in. That turn is finished and 3 more are played, then the player with fewer Prize cards left wins (reason `'time'`). With the same number left, play goes on until one player is ahead or wins outright (Tournament Rules 5.8.3, single elimination, where a winner is needed). Only a game still level 40 turns later is recorded as a draw (`'turn limit'`).

Left as they are, because they break no rule: the CPU choosing to go first (a legal choice; batches swap seats on purpose), and Energy and damage-counter moves written per card.

**Why:** The owner asked for everything left on purpose that is actually wrong under the rules to be fixed. The documents settle each of these five: the APR's Knock Out timing (D), Checkup (F), timing terms (E-02, E-21–E-23), setup (G) and tie rules; and the Tournament Rules' end-of-round procedure.

**Implications:**
- `determinism.test.ts` is re-pinned: 18 of its 40 seeded games now play differently, mostly from setup being face down, which changes the order of setup events.
- Every one of the 36 real PTCG Live games tried still replays to the end, with every winner matching.
- Time-called games now count as wins and losses in `simulate_battles` (reason `'time'`). Only the rare level stalemate stays a `'turn limit'` time-out.
- `rules_lookup`'s winning, self-KO and durations entries now say "tie, decided by a tiebreaker game with 6 Prize cards", and describe Checkup timing and time being called.
10 changes: 5 additions & 5 deletions packages/agent-tools/src/__tests__/rulesLookup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ test('a self-KO is not always a loss: simultaneous Knock Outs are resolved befor
const out = await ask({ query: 'if my Dusknoir knocks itself out with cursed blast does my opponent take a prize' });
assert.match(out, /OPPONENT takes the Prize cards/);
assert.match(out, /every Knock Out is resolved before anyone wins/);
assert.match(out, /both or neither goes to Sudden Death/);
assert.match(out, /both or neither is a tie/);
});

test('mulligans: only the DIFFERENCE is drawn, and equal mulligans draw nothing extra', async () => {
Expand Down Expand Up @@ -125,10 +125,10 @@ test('Bellowing Thunder vs Ancient Bulwark keeps its Energy condition (round 5)'
assert.doesNotMatch(out, /Ancient Bulwark has nothing to prevent/);
});

test('a Benched Cursed Blast on both players\' last Prize goes to Sudden Death when they can promote: win conditions are counted (round 6)', async () => {
test('a Benched Cursed Blast on both players\' last Prize is a tie when they can promote: win conditions are counted (round 6)', async () => {
const out = await ask({ query: 'my benched dusknoir uses cursed blast, we both have one prize left, who wins', cards: ['Cursed Blast'] });
assert.match(out, /count win conditions: a player also meets one when the opponent has no Pokémon left in play/);
assert.match(out, /a Benched Dusknoir that leaves your Active in play goes to Sudden Death whenever the opponent can promote/);
assert.match(out, /a Benched Dusknoir that leaves your Active in play is a tie whenever the opponent/);
assert.match(out, /Cursed Blast Knocking Out both Active Pokémon/, 'the #2240 summary keeps its both-Active condition');
assert.doesNotMatch(out, /the player who can still promote a new Active Pokémon wins/);
});
Expand Down Expand Up @@ -168,10 +168,10 @@ test('repeated whitespace in a name still matches it (round 2)', async () => {
assert.match(await ask({ query: 'Does Nighttime Mine affect Dragapult ex?' }), /#2288/);
});

test('simultaneous wins: more win conditions wins, an equal count is Sudden Death (round 2)', async () => {
test('simultaneous wins: more win conditions wins, an equal count is a tie, decided by a tiebreaker game (round 2)', async () => {
const out = await ask({ query: 'I took my last prize but my last pokemon was knocked out too, who wins or is it a draw' });
assert.match(out, /whoever meets MORE of them wins/);
assert.match(out, /an equal count means Sudden Death: a new game with 1 Prize card each/);
assert.match(out, /an equal count is a tie. A tie is decided by a tiebreaker game: a new game with 6 Prize cards each/);
});

test('an unknown card with a generic question gets no topic-matched rulings presented as "filed under" it', async () => {
Expand Down
22 changes: 12 additions & 10 deletions packages/agent-tools/src/rules/coreRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,10 @@ export const CORE_RULES: readonly CoreRule[] = [
'last Prizes it usually hands them the game. Not always: when one effect Knocks Out Pokémon on both sides (a ' +
'Cursed Blast that also KOs its target), every Knock Out is resolved before anyone wins. Both players take ' +
'Prizes, the player whose turn is next first. If both take their last Prize, count win conditions: a player also ' +
'meets one when the opponent has no Pokémon left in play. More conditions wins; equal goes to Sudden Death. So ' +
'when a Cursed Blast Knocks Out BOTH Active Pokémon, the player who can promote from the Bench wins and both or ' +
'neither goes to Sudden Death; but a Benched Dusknoir that leaves your Active in play goes to Sudden Death ' +
'whenever the opponent can promote.',
'meets one when the opponent has no Pokémon left in play. More conditions wins; equal is a tie, decided by a tiebreaker ' +
'game. So when a Cursed Blast Knocks Out BOTH Active Pokémon, the player who can promote from the Bench wins and ' +
'both or neither is a tie; but a Benched Dusknoir that leaves your Active in play is a tie whenever the opponent ' +
'can promote.',
checked: 'sim rulings-r03.test.ts (Compendium #2425, #2239, #2240)',
},
{
Expand All @@ -168,9 +168,11 @@ export const CORE_RULES: readonly CoreRule[] = [
'cannot draw at the start of their turn. Running out of cards during a draw EFFECT does not lose; only the ' +
'start-of-turn draw does. When Knock Outs on both sides happen at once, both players take Prizes first, then ' +
'the win conditions are counted for each player: whoever meets MORE of them wins (taking your last Prize and ' +
'leaving your opponent no Pokémon is two), and an equal count means Sudden Death: a new game with 1 Prize card ' +
'each decides it. Only if nobody has won do both players promote and play goes on.',
checked: 'sim rules.test.ts (rulebook p.8, 21), rules-audit.test.ts (Destined Fight, sudden death), flow.ts checkWin',
'leaving your opponent no Pokémon is two), and an equal count is a tie. A tie is decided by a tiebreaker game: ' +
'a new game with 6 Prize cards each, won by the first player to have fewer Prize cards left (Advanced Player\'s ' +
'Rulebook). Only if nobody has won do both players promote and play goes on. When time is called in a tournament, ' +
'the turn is finished and 3 more are played; then the player with fewer Prize cards left wins.',
checked: 'sim rules.test.ts (rulebook p.8, 21), rules-audit.test.ts (Destined Fight, tiebreaker game), rules-checklist.test.ts (time called), flow.ts checkWin',
},
{
id: 'special-conditions',
Expand All @@ -190,10 +192,10 @@ export const CORE_RULES: readonly CoreRule[] = [
keywords: ['next turn', 'during your next turn', "during your opponent's next turn", 'until the end', 'this turn', 'lasting effect', 'duration', 'how long'],
text:
'"During your next turn" and "during your opponent\'s next turn" cover that whole turn only: not the rest of the ' +
'turn the card was used in, and they are over when that turn ends. "Until the end of your next turn" starts at ' +
'once. "Your" is the player whose card made the effect, even when it happens on the opponent\'s turn. An attack\'s ' +
'turn the card was used in, and they are over when that turn ends, before Pokémon Checkup. "Until the end of your ' +
'next turn" starts at once and still applies in the Checkup after that turn. "Your" is the player whose card made the effect, even when it happens on the opponent\'s turn. An attack\'s ' +
'effect on a Pokémon also ends early if that Pokémon leaves the Active Spot or evolves.',
checked: 'sim rules-timing.test.ts; ruling 2404 (delayed attack effects)',
checked: 'sim rules-timing.test.ts (APR v3.4 E-02, E-22); ruling 2404 (delayed attack effects)',
},
{
id: 'setup-deck',
Expand Down
2 changes: 1 addition & 1 deletion packages/sim/src/__tests__/cards-std-c23.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ test('Deluxe Bomb: Knocked Out holder still deals the counters, and everything r
// All effects resolve before checking if the game is over.
assert.equal(g.state.p[0].prizes.length, 0, 'the attacker took its last Prize card for the Knock Out');
assert.equal(g.state.p[1].prizes.length, 0, 'and the Bomb holder\'s side took its last Prize card for the Knock Out the 12 counters caused');
assert.equal(g.state.suddenDeath, 1, 'both won at once (last Prize card and no Pokémon each): a sudden-death game starts');
assert.equal(g.state.tiebreaker, 1, 'both won at once (last Prize card and no Pokémon each): a sudden-death game starts');
});

// ------------------------------------------------------------------ Grand Tree
Expand Down
10 changes: 6 additions & 4 deletions packages/sim/src/__tests__/cards-std-engine7.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,13 +467,15 @@ test('Cinderace, Explosiveness: if it is in your hand when you set up, you may p
tested++;
assert.ok(inHand.every((c) => (d.values as number[]).includes(c)), 'offered as an Active Pokémon');
s.submit([(d.values as number[]).indexOf(inHand[0]!)]);
const active = s.state.p[d.player].active!;
assert.equal(nameOf(s, active.cards[0]!), 'Cinderace');
assert.ok(!s.state.p[d.player].hand.includes(inHand[0]!));
// The rest of setup goes on as usual: the other Pokémon go to the Bench and the game begins.
assert.equal(s.state.p[d.player].setupPick?.[0], inHand[0], 'chosen face down');
// The rest of setup goes on as usual: the other Pokémon go to the Bench and the game begins, with Cinderace Active.
let more = 0;
while (s.decision && s.state.phase === 'setup' && more++ < 20) s.submit(s.decision.kind === 'setupBench' ? [] : [0]);
assert.equal(s.state.phase, 'main');
const active = s.state.p[d.player].active!;
assert.equal(active.cards[0], inHand[0]);
assert.equal(nameOf(s, active.cards[0]!), 'Cinderace');
assert.ok(!s.state.p[d.player].hand.includes(inHand[0]!));
}
assert.ok(tested > 0);
});
Expand Down
36 changes: 18 additions & 18 deletions packages/sim/src/__tests__/determinism.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,45 +32,45 @@ function hashGame(i: number): string {
}

const GOLDEN: string[] = [
'f1dd29ec77667ff2',
'ffa23bd9350201c0',
'463875b18814e9fa',
'99322547f6eea095',
'aa539e53fdf96adf',
'dcc5ac3125accd87',
'24fd44ec8a0c335d',
'cf4bd8674362d1d4',
'9777cdd599022455',
'37b8f9b61a77393b',
'aeea45828c74b302',
'fa2b406497358926',
'48b4e069223edaae',
'fe96d463526eafb2',
'59bff6fd7dccf425',
'd580f4fcb7622cf8',
'34e358e2e1763e8b',
'8fcac70e06760e69',
'089cc930aa4f2de3',
'e992aab78bd33ef7',
'fa29011fcdd24b85',
'185ccabccfb80336',
'f613198e4cccf0d1',
'dc075e4945d5fc83',
'c1d04423693886d9',
'e44dfb2903d18d05',
'5e46bef910990cde',
'3ed9aa059fe450f5',
'058931d5ab07bd6e',
'9e27f98472bc1c93',
'782aa5bb538e36b5',
'0a5f8228cb758a67',
'da630e08aa403d4b',
'bc04439d23dde7ef',
'6d80759a9b9eb3f9',
'e7eed1854dba6454',
'435885a9a359a7c6',
'0b539a6fdda08276',
'a04f6d9e361139b2',
'36953fd9be87043b',
'5e263ab4459a32e7',
'992d81905c5eb756',
'b81b3e7a4537fc92',
'f170cfdcffd8e00a',
'e881e76ff867e560',
'c8605ee094b89589',
'd72fdc324ebca3fa',
'98d4827514d954b8',
'99e37bfda6dcc2a2',
'5d5f6753834b926f',
'56e2c9ebaee926eb',
'af02615c7dfb97d6',
'1188d40bf5813ab0',
'21df4f4eb88747f8',
'7a470169ab4ea133',
'99746b85d4bef759',
'4d1701c8edc02037',
'b4e6fab87435e66f',
];

Expand Down
2 changes: 2 additions & 0 deletions packages/sim/src/__tests__/engine-features-7.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,8 @@ test('setupActive: a Pokémon with it in the hand may be put in the Active Spot
if (!cinderace.length) continue;
found++;
s.submit([(d.values as number[]).indexOf(cinderace[0]!)]);
let more = 0;
while (s.decision && s.state.phase === 'setup' && more++ < 20) s.submit(s.decision.kind === 'setupBench' ? [] : [0]);
const active = s.state.p[d.player].active!;
assert.equal(nameOf(s, active.cards[0]!), 'Cinderace');
assert.equal(active.cards.length, 1);
Expand Down
6 changes: 3 additions & 3 deletions packages/sim/src/__tests__/rules-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,18 +193,18 @@ test('fewer Prize cards left than a Knock Out is worth: take what is left, and w
assert.equal(g.state.winReason, 'prizes');
});

test('Destined Fight with the last Prize: both players win one way each → sudden death', () => {
test('Destined Fight with the last Prize: both players win one way each → a tiebreaker game', () => {
const g = at([{ active: 'Fated', energy: { active: ['Psychic Energy'] }, prizes: 1 }, { active: 'Pup', bench: ['Pup'] }]);
choose(g, 'Attack: Destined Fight');
assert.equal(g.state.phase, 'setup', 'a tie: a sudden-death game starts');
assert.equal(g.state.suddenDeath, 1);
assert.equal(g.state.tiebreaker, 1);
// The sudden-death game is a new game: every card back in its deck, a fresh setup, and 1 Prize card each.
const all = (p: 0 | 1) => g.state.p[p].deck.length + g.state.p[p].hand.length;
assert.equal(all(0), g.ctx.iids[0].length);
assert.equal(all(1), g.ctx.iids[1].length);
const pilot = makePilot('greedy', 7);
while (g.state.phase === 'setup' && g.decision) g.submit(pilot.choose(g, g.decision));
assert.deepEqual([g.state.p[0].prizes.length, g.state.p[1].prizes.length], [1, 1], 'sudden death: 1 Prize card each');
assert.deepEqual([g.state.p[0].prizes.length, g.state.p[1].prizes.length], [6, 6], 'a tiebreaker game: 6 Prize cards each');
playOut(g, [makePilot('greedy', 1), makePilot('greedy', 2)]);
assert.ok(g.over, 'and it is played to a result');

Expand Down
19 changes: 19 additions & 0 deletions packages/sim/src/__tests__/rules-checklist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,22 @@ test('a gust pulls up a Benched Pokémon even when the Active Pokémon is protec
choose(push, 'Use Push');
assert.equal(nameOf(push, push.state.p[1].active!.cards[0]!), 'Veiled', 'switching out the protected Active does nothing');
});

test('time called: the turn is finished, +3 turns are played, then the player with fewer Prize cards left wins; level, play goes on (Tournament Rules 5.8.3)', () => {
const run = (myPrizes: number) => {
const g = scenario(M, M, [{ active: 'Pup', bench: ['Pup'], prizes: myPrizes }, { active: 'Pup', bench: ['Pup'] }], { maxTurns: 2, turn: 2 });
const turns: number[] = [];
while (!g.over && g.state.turn <= 8) {
turns.push(g.state.turn);
choose(g, 'End turn');
}
return { g, turns };
};
const ahead = run(5);
assert.ok(ahead.g.over);
assert.equal(ahead.g.state.winner, 0, 'P1 has fewer Prize cards left');
assert.equal(ahead.g.state.winReason, 'time');
assert.deepEqual(ahead.turns, [2, 3, 4, 5], 'time is called in turn 2 (turn 0), then 3 more turns');
const level = run(6);
assert.ok(!level.g.over, 'level on Prize cards: the game goes on until one player is ahead');
});
Loading
Loading