diff --git a/decisions/2026/2026-10-19-battle-simulator-turn-timing-and-the-end-of-a-game-follow-the-advanced-player-s.md b/decisions/2026/2026-10-19-battle-simulator-turn-timing-and-the-end-of-a-game-follow-the-advanced-player-s.md new file mode 100644 index 00000000..3d969fa8 --- /dev/null +++ b/decisions/2026/2026-10-19-battle-simulator-turn-timing-and-the-end-of-a-game-follow-the-advanced-player-s.md @@ -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. diff --git a/packages/agent-tools/src/__tests__/rulesLookup.test.ts b/packages/agent-tools/src/__tests__/rulesLookup.test.ts index 0df26fd8..248709ce 100644 --- a/packages/agent-tools/src/__tests__/rulesLookup.test.ts +++ b/packages/agent-tools/src/__tests__/rulesLookup.test.ts @@ -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 () => { @@ -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/); }); @@ -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 () => { diff --git a/packages/agent-tools/src/rules/coreRules.ts b/packages/agent-tools/src/rules/coreRules.ts index cee0a177..564990e8 100644 --- a/packages/agent-tools/src/rules/coreRules.ts +++ b/packages/agent-tools/src/rules/coreRules.ts @@ -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)', }, { @@ -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', @@ -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', diff --git a/packages/sim/src/__tests__/cards-std-c23.test.ts b/packages/sim/src/__tests__/cards-std-c23.test.ts index 384d64ef..5819fb3f 100644 --- a/packages/sim/src/__tests__/cards-std-c23.test.ts +++ b/packages/sim/src/__tests__/cards-std-c23.test.ts @@ -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 diff --git a/packages/sim/src/__tests__/cards-std-engine7.test.ts b/packages/sim/src/__tests__/cards-std-engine7.test.ts index c9553455..0856a5a3 100644 --- a/packages/sim/src/__tests__/cards-std-engine7.test.ts +++ b/packages/sim/src/__tests__/cards-std-engine7.test.ts @@ -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); }); diff --git a/packages/sim/src/__tests__/determinism.test.ts b/packages/sim/src/__tests__/determinism.test.ts index 0357a0db..e1751cdc 100644 --- a/packages/sim/src/__tests__/determinism.test.ts +++ b/packages/sim/src/__tests__/determinism.test.ts @@ -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', ]; diff --git a/packages/sim/src/__tests__/engine-features-7.test.ts b/packages/sim/src/__tests__/engine-features-7.test.ts index 89de11ab..dda2757e 100644 --- a/packages/sim/src/__tests__/engine-features-7.test.ts +++ b/packages/sim/src/__tests__/engine-features-7.test.ts @@ -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); diff --git a/packages/sim/src/__tests__/rules-audit.test.ts b/packages/sim/src/__tests__/rules-audit.test.ts index e7348133..2a3f4153 100644 --- a/packages/sim/src/__tests__/rules-audit.test.ts +++ b/packages/sim/src/__tests__/rules-audit.test.ts @@ -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'); diff --git a/packages/sim/src/__tests__/rules-checklist.test.ts b/packages/sim/src/__tests__/rules-checklist.test.ts index 674f8cf6..6ce22617 100644 --- a/packages/sim/src/__tests__/rules-checklist.test.ts +++ b/packages/sim/src/__tests__/rules-checklist.test.ts @@ -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'); +}); diff --git a/packages/sim/src/__tests__/rules-timing.test.ts b/packages/sim/src/__tests__/rules-timing.test.ts index 03648824..31b4b551 100644 --- a/packages/sim/src/__tests__/rules-timing.test.ts +++ b/packages/sim/src/__tests__/rules-timing.test.ts @@ -12,6 +12,7 @@ import { Game } from '../game.js'; import { durationTurns } from '../interp.js'; import { scenario } from '../scenario.js'; import type { Decision, GameState, Player } from '../types.js'; +import { POISONED } from '../types.js'; import { FIGHTING, PUP, deck, pokemon } from './fixtures.js'; const ANCHOR = pokemon('t-t01', 'Anchor', { hp: 90, retreat: 1 }); @@ -79,3 +80,42 @@ test('the effect window belongs to the player whose card made it, whoever\'s tur assert.deepEqual(durationTurns(at(5, 0), 0, 'throughMyNextTurn'), { start: 5, until: 7 }); assert.deepEqual(durationTurns(at(5, 0), 0, 'thisTurn'), { start: 5, until: 5 }); }); + +// ---------------------------------------------------------------- Pokémon Checkup is in neither player's turn + +const STINGER = pokemon('t-t03', 'Stinger', { hp: 90 }); +STINGER.abilities = [{ kind: 'Ability', name: 'Parting Sting', effect: "At the end of your turn, put 1 damage counter on your opponent's Active Pokémon." }]; +const MENDER = pokemon('t-t04', 'Mender', { hp: 90 }); +MENDER.abilities = [{ kind: 'Ability', name: 'Field Care', effect: 'During Pokémon Checkup, heal 20 damage from your Active Pokémon.' }]; +const CHECKUP_SCRIPTS: CardScript[] = [ + ...SCRIPTS, + { id: 't-t03', name: 'Stinger', abilities: [{ name: 'Parting Sting', triggers: [{ on: 'endOfTurn', program: [{ op: 'counters', n: 1, to: 'oppActive' }] }] }] }, + { id: 't-t04', name: 'Mender', abilities: [{ name: 'Field Care', triggers: [{ on: 'checkup', program: [{ op: 'heal', amount: 20, to: 'myActive' }] }] }] }, +]; +const C = deck('C', [ + [ANCHOR, 8], + [STINGER, 8], + [MENDER, 8], + [PUP, 10], + [FIGHTING, 26], +]); + +test('"During ..." effects end with their turn and do not apply in Pokémon Checkup; "until the end of ..." effects do (APR v3.4 E-02, E-22)', () => { + const run = (throughCheckup: boolean): number => { + const g = scenario(C, C, [{ active: 'Anchor' }, { active: 'Pup', cond: POISONED }], { scripts: CHECKUP_SCRIPTS }); + const pup = g.state.p[1].active!; + // "During this turn, put 2 more damage counters on that Pokémon for Poison" (or "until the end of your next turn"). + g.state.effects.push({ static: { k: 'checkupCounters', cond: 'poisoned', n: 2 }, slot: pup.id, player: 1, until: g.state.turn, fromAttack: false, src: 0, ...(throughCheckup ? { throughCheckup: true } : {}) }); + choose(g, 'End turn'); + return pup.damage; + }; + assert.equal(run(false), 10, 'the turn is over: Poison places its 1 counter only'); + assert.equal(run(true), 30, '"until the end of ..." still applies in Checkup: 1 + 2 counters'); +}); + +test('a Pokémon an end-of-turn effect Knocks Out is Knocked Out before Pokémon Checkup (APR v3.4 D: whenever its HP becomes zero)', () => { + // Pup (70 HP) has 60 damage. Parting Sting's counter brings it to 0 at the end of P1's turn; Field Care's Checkup healing comes too late. + const g = scenario(C, C, [{ active: 'Stinger' }, { active: 'Pup', bench: ['Mender'], damage: { active: 60 } }], { scripts: CHECKUP_SCRIPTS }); + choose(g, 'End turn'); + assert.equal(g.state.p[0].prizesTaken, 1, 'Knocked Out at the end of the turn, not healed back in Checkup'); +}); diff --git a/packages/sim/src/__tests__/rules.test.ts b/packages/sim/src/__tests__/rules.test.ts index 6c0ec8a9..fd406648 100644 --- a/packages/sim/src/__tests__/rules.test.ts +++ b/packages/sim/src/__tests__/rules.test.ts @@ -360,11 +360,12 @@ test('p.8, 18 setup: mulligans give the opponent extra draws; a deck with no Bas assert.equal(d.player, 1); assert.equal(d.labels!.length, m.state.p[0].mulligans - m.state.p[1].mulligans + 1); assert.ok(order.includes('setupActive:0') && order.includes('setupActive:1'), `both players must set up before the extra draws (${order.join(', ')})`); - assert.equal(m.state.p[1].active !== null, true, 'P2 placed an Active before drawing extras'); + assert.equal(m.state.p[1].setupPick?.length ? true : false, true, 'P2 chose an Active before drawing extras'); + assert.equal(m.state.p[1].active, null, 'face down: the Pokémon are put in play when the game starts (APR v3.4 G)'); assert.equal(m.state.p[0].prizes.length, 6); assert.equal(m.state.p[1].prizes.length, 6, 'Prize cards are set aside before the extra draws'); sawExtra = true; - const activeBefore = m.state.p[1].active!.id; + const activeBefore = m.state.p[1].setupPick![0]; const handBefore = m.state.p[1].hand.slice(); m.submit([d.labels!.length - 1]); // draw them all const next = m.decision as Decision; @@ -372,7 +373,7 @@ test('p.8, 18 setup: mulligans give the opponent extra draws; a deck with no Bas // Only Basics drawn just now are offered, and the Active can't change. assert.ok(next.values!.every((c) => !handBefore.includes(c)), 'a Basic from the opening hand was offered again'); m.submit([0]); - assert.equal(m.state.p[1].active!.id, activeBefore); + assert.equal((m.state.p[1].active as { cards: number[] } | null)?.cards[0], activeBefore, 'the Active choice stays, and is in play once the game starts'); sawBench = true; } } diff --git a/packages/sim/src/__tests__/rulings-general-kos.test.ts b/packages/sim/src/__tests__/rulings-general-kos.test.ts index f3c85631..7a2929fd 100644 --- a/packages/sim/src/__tests__/rulings-general-kos.test.ts +++ b/packages/sim/src/__tests__/rulings-general-kos.test.ts @@ -424,7 +424,7 @@ test('Compendium #887: taking the last Prize card on your turn wins before Poké assert.equal(lose.state.winReason, 'no Pokémon in play'); }); -test('Compendium #882: when an attack makes both players take their last Prize card, the one who can still put a Pokémon in the Active Spot wins; otherwise sudden death', () => { +test('Compendium #882: when an attack makes both players take their last Prize card, the one who can still put a Pokémon in the Active Spot wins; otherwise a tiebreaker game decides it', () => { // Selfdestruct: 100 to P2's Pup, 60 to Bomber itself; both Knocked Out, both players at 1 Prize card. const run = (myBench: string[], oppBench: string[]): Game => { const g = at([{ active: 'Bomber', bench: myBench, energy: { active: [E] }, prizes: 1 }, { active: 'Pup', bench: oppBench, prizes: 1 }]); @@ -438,11 +438,11 @@ test('Compendium #882: when an attack makes both players take their last Prize c for (const [mine, theirs] of [[['Pup'], ['Pup']], [[], []]] as [string[], string[]][]) { const tie = run(mine, theirs); assert.equal(tie.state.phase, 'setup', `bench ${mine.length}/${theirs.length}: the same number of ways each, so a sudden-death game starts`); - assert.equal(tie.state.suddenDeath, 1); + assert.equal(tie.state.tiebreaker, 1); } }); -test('Compendium #884: an attack that Knocks Out the attacker and the Defending Pokémon for the last Prize card is sudden death if the attacker is left with no Pokémon, a win if the opponent is too', () => { +test('Compendium #884: an attack that Knocks Out the attacker and the Defending Pokémon for the last Prize card is a tiebreaker game if the attacker is left with no Pokémon, a win if the opponent is too', () => { // P1 needs 1 Prize card, P2 needs 3: P2's only way to win is P1 having no Pokémon left. const run = (oppBench: string[]): Game => { const g = at([{ active: 'Bomber', energy: { active: [E] }, prizes: 1 }, { active: 'Pup', bench: oppBench, prizes: 3 }]); @@ -451,13 +451,13 @@ test('Compendium #884: an attack that Knocks Out the attacker and the Defending }; const tie = run(['Pup']); assert.equal(tie.state.phase, 'setup', 'a tie: a sudden-death game starts'); - assert.equal(tie.state.suddenDeath, 1); + assert.equal(tie.state.tiebreaker, 1); const win = run([]); assert.equal(win.state.winner, 0, 'two ways (last Prize card, no Pokémon left) against one'); assert.equal(win.state.winReason, 'prizes'); }); -test('Compendium #245: win conditions reached at the same moment by an Ability are counted: more ways wins, the same number is sudden death', () => { +test('Compendium #245: win conditions reached at the same moment by an Ability are counted: more ways wins, the same number is a tiebreaker game', () => { // Last Curse Knocks Out Grave and puts 3 damage counters on P2's Pup (30 HP left), in P1's main phase. const run = (mine: SideLayout, theirs: SideLayout): Game => { const g = at([mine, { active: 'Pup', damage: { active: 40 }, ...theirs }]); @@ -467,7 +467,7 @@ test('Compendium #245: win conditions reached at the same moment by an Ability a // Both on 1 Prize card, both left with no Pokémon: two ways each. const tie = run({ active: 'Grave', prizes: 1 }, { prizes: 1 }); assert.equal(tie.state.phase, 'setup', 'a tie: a sudden-death game starts'); - assert.equal(tie.state.suddenDeath, 1); + assert.equal(tie.state.tiebreaker, 1); // P1 keeps a Benched Pokémon: two ways against one. const more = run({ active: 'Grave', bench: ['Pup'], prizes: 1 }, { prizes: 1 }); assert.equal(more.state.winner, 0); diff --git a/packages/sim/src/__tests__/rulings-r01.test.ts b/packages/sim/src/__tests__/rulings-r01.test.ts index 1d8c5cfc..864c011d 100644 --- a/packages/sim/src/__tests__/rulings-r01.test.ts +++ b/packages/sim/src/__tests__/rulings-r01.test.ts @@ -197,7 +197,7 @@ test('Compendium #1976: Drakloak, Recon Directive with 1 card in the deck puts i // Compendium #1832 (Aqua Return taking the last Prize while the attacker is the only Pokemon in play): wins are checked after everything resolves; // both players with one win condition is a tiebreaker game; two win conditions beats one. -test('Compendium #1832: Primarina, Aqua Return that takes the last Prize and leaves no Pokemon is a tie (sudden death), or a win if the opponent has none either', () => { +test('Compendium #1832: Primarina, Aqua Return that takes the last Prize and leaves no Pokemon is a tie (a tiebreaker game decides it), or a win if the opponent has none either', () => { const dk = deck([PRIMARINA, SLOWPOKE]); const mk = (oppBench: string[]): Game => scenario(dk, dk, [ @@ -207,7 +207,7 @@ test('Compendium #1832: Primarina, Aqua Return that takes the last Prize and lea const tie = mk(['Slowpoke']); choose(tie, 'Attack: Aqua Return'); assert.equal(tie.state.phase, 'setup', 'a tie: a sudden-death game starts'); - assert.equal(tie.state.suddenDeath, 1); + assert.equal(tie.state.tiebreaker, 1); assert.equal(tie.state.p[0].prizes.length, 0); const win = mk([]); choose(win, 'Attack: Aqua Return'); diff --git a/packages/sim/src/__tests__/rulings-r03.test.ts b/packages/sim/src/__tests__/rulings-r03.test.ts index ca4de84d..fd1c3e15 100644 --- a/packages/sim/src/__tests__/rulings-r03.test.ts +++ b/packages/sim/src/__tests__/rulings-r03.test.ts @@ -171,7 +171,7 @@ test('Compendium #2264: Cursed Blast onto a Benched Pokémon under Battle Cage - // ------------------------------------------------------------------ Cursed Blast timing and winners // https://compendium.pokegym.net/ruling/2240/ : when an Active Dusknoir's Cursed Blast Knocks Out both Active Pokémon with both players on their last Prize, whoever has a Pokémon to promote wins; if both or neither do, it is a tiebreaker game (a draw). -test('Compendium #2240: Cursed Blast Knocking Out both Active Pokémon with both players on 1 Prize - the player who can promote wins; both or neither means sudden death', () => { +test('Compendium #2240: Cursed Blast Knocking Out both Active Pokémon with both players on 1 Prize - the player who can promote wins; both or neither means a tiebreaker game', () => { const game = (mineBench: boolean, theirBench: boolean): Game => { const g = at([ { active: 'Duskull', evolve: { active: ['Dusclops', 'Dusknoir'] }, ...(mineBench ? { bench: ['Dreepy'] } : {}), prizes: 1 }, @@ -186,12 +186,12 @@ test('Compendium #2240: Cursed Blast Knocking Out both Active Pokémon with both const b = game(false, true); assert.ok(b.over && b.state.winner === 1, 'the other way round'); const both = game(true, true); - assert.ok(both.state.suddenDeath === 1 && both.state.phase === 'setup', 'both can promote: sudden death'); + assert.ok(both.state.tiebreaker === 1 && both.state.phase === 'setup', 'both can promote: a tiebreaker game'); const neither = game(false, false); - assert.ok(neither.state.suddenDeath === 1 && neither.state.phase === 'setup', 'neither can promote: sudden death'); + assert.ok(neither.state.tiebreaker === 1 && neither.state.phase === 'setup', 'neither can promote: a tiebreaker game'); }); -test('Cursed Blast from the Bench with both players on 1 Prize: your Active stays in play, so it is sudden death whenever they can promote (win conditions counted)', () => { +test('Cursed Blast from the Bench with both players on 1 Prize: your Active stays in play, so it is a tiebreaker game whenever they can promote (win conditions counted)', () => { // Both take their last Prize (one win condition each); neither is left with no Pokémon in play, so the count is equal. const g = at([ { active: 'Snorlax', bench: ['Duskull'], evolve: { 0: ['Dusclops', 'Dusknoir'] }, prizes: 1 }, @@ -201,7 +201,7 @@ test('Cursed Blast from the Bench with both players on 1 Prize: your Active stay if (g.decision?.kind === 'slots') choose(g, 'Slowpoke'); while (g.decision?.kind === 'promote') g.submit([0]); assert.equal(g.state.winner, null, 'not a win for the player who could promote'); - assert.equal(g.state.suddenDeath, 1, 'a tie: a sudden-death game starts'); + assert.equal(g.state.tiebreaker, 1, 'a tie: a sudden-death game starts'); }); // https://compendium.pokegym.net/ruling/2239/ : after an Active Dusknoir's Cursed Blast Knocks Out the opposing Active, the player whose turn is next takes Prizes first, then the other; promotions go in the same order. diff --git a/packages/sim/src/flow.ts b/packages/sim/src/flow.ts index 58c9b196..f30f34fe 100644 --- a/packages/sim/src/flow.ts +++ b/packages/sim/src/flow.ts @@ -58,7 +58,7 @@ import { leaveHand, newSlot, opp, removeFrom, - resetForSuddenDeath, + resetForTiebreaker, shuffleDeck, slotCards, takePicked, @@ -125,7 +125,7 @@ export function submit(env: Env, s: GameState, choice: number[]): void { const ps = s.p[r.p]; const n = draw(env, s, r.p, choice[0] as number); const drawnBasics = ps.hand.slice(ps.hand.length - n).filter((c) => isBasic(env, c)); - const room = 5 - ps.bench.length; + const room = 6 - (ps.setupPick?.length ?? 1); // the Bench is 5; the first pick is the Active Pokémon s.step = r.p === 0 ? 'extra1' : 'begin'; if (drawnBasics.length && room > 0) { s.pending = { @@ -145,13 +145,7 @@ export function submit(env: Env, s: GameState, choice: number[]): void { } case 'extraBench': { const ps = s.p[r.p]; - for (const i of choice) { - const card = (d.values as number[])[i] as number; - leaveHand(ps, card); - const sl = newSlot(s, card); - ps.bench.push(sl); - emit(env, { type: 'play_to_bench', player: r.p, card, slot: sl.id }); - } + ps.setupPick = [...(ps.setupPick ?? []), ...choice.map((i) => (d.values as number[])[i] as number)]; break; } case 'dealChoice': { @@ -163,23 +157,14 @@ export function submit(env: Env, s: GameState, choice: number[]): void { break; } case 'setupActive': { - const card = (d.values as number[])[choice[0] as number] as number; - const ps = s.p[r.p]; - leaveHand(ps, card); - ps.active = newSlot(s, card); - emit(env, { type: 'play_to_active', player: r.p, card, slot: ps.active.id }); + // Face down (APR v3.4 G): the choice is kept with the hand, hidden, and the Pokémon are put in play face up when the game starts. + s.p[r.p].setupPick = [(d.values as number[])[choice[0] as number] as number]; s.step = `bench${r.p}`; break; } case 'setupBench': { const ps = s.p[r.p]; - for (const i of choice) { - const card = (d.values as number[])[i] as number; - leaveHand(ps, card); - const sl = newSlot(s, card); - ps.bench.push(sl); - emit(env, { type: 'play_to_bench', player: r.p, card, slot: sl.id }); - } + ps.setupPick = [...(ps.setupPick ?? []), ...choice.map((i) => (d.values as number[])[i] as number)]; s.step = r.p === 0 ? 'place1' : 'prizes'; break; } @@ -451,6 +436,24 @@ function mulliganUntilBasic(env: Env, s: GameState, p: Player): void { finishDeal(env, s, p); } +/** Put a player's face-down setup choices into play: the first is the Active Pokémon, the rest go to the Bench. */ +function revealSetup(env: Env, s: GameState, p: Player): void { + const ps = s.p[p]; + const picks = (ps.setupPick ?? []).filter((c) => ps.hand.includes(c)); + ps.setupPick = undefined; + picks.forEach((card, i) => { + leaveHand(ps, card); + const sl = newSlot(s, card); + if (i === 0) { + ps.active = sl; + emit(env, { type: 'play_to_active', player: p, card, slot: sl.id }); + } else { + ps.bench.push(sl); + emit(env, { type: 'play_to_bench', player: p, card, slot: sl.id }); + } + }); +} + function finishDeal(env: Env, s: GameState, p: Player): void { emit(env, { type: 'opening_hand', player: p, size: s.p[p].hand.length }); s.step = p === 0 ? 'deal1' : 'place0'; @@ -459,8 +462,8 @@ function finishDeal(env: Env, s: GameState, p: Player): void { function setupStep(env: Env, s: GameState): void { switch (s.step) { case 'coin': { - // A sudden-death game always starts with a new coin flip, even when the match had a fixed first player. - if (env.ctx.opts.first !== null && !s.suddenDeath) { + // A tiebreaker game always starts with a new coin flip, even when the match had a fixed first player. + if (env.ctx.opts.first !== null && !s.tiebreaker) { s.first = env.ctx.opts.first; emit(env, { type: 'go_first', player: s.first }); s.step = 'deal'; @@ -518,7 +521,7 @@ function setupStep(env: Env, s: GameState): void { case 'bench0': case 'bench1': { const p: Player = s.step === 'bench0' ? 0 : 1; - const basics = s.p[p].hand.filter((c) => isBasic(env, c)); + const basics = s.p[p].hand.filter((c) => isBasic(env, c) && !s.p[p].setupPick?.includes(c)); if (!basics.length) { s.step = p === 0 ? 'place1' : 'prizes'; return; @@ -539,13 +542,15 @@ function setupStep(env: Env, s: GameState): void { case 'prizes': { for (const p of [0, 1] as Player[]) { const ps = s.p[p]; - // 6 Prize cards; 1 in a sudden-death game. - for (let i = 0; i < (s.suddenDeath ? 1 : 6) && ps.deck.length; i++) ps.prizes.push(ps.deck.pop() as number); + // 6 Prize cards, in a tiebreaker game too. + for (let i = 0; i < 6 && ps.deck.length; i++) ps.prizes.push(ps.deck.pop() as number); } s.step = 'extra0'; return; } case 'begin': { + // "Flip all Active and Benched Pokémon face up, and start the game" (APR v3.4 G, step 8): both players' choices at once. + for (const p of [0, 1] as Player[]) revealSetup(env, s, p); s.phase = 'main'; s.turn = 1; s.current = s.first; @@ -581,7 +586,11 @@ function turnStep(env: Env, s: GameState): void { for (const sl of allSlots(ps)) sl.usedAbilities = []; s.attacked = false; emit(env, { type: 'turn_start', player: p, turn: s.turn }); - if (s.turn > env.ctx.opts.maxTurns) { + // `maxTurns` is when time is called: that turn is finished, then +3 turns are played, and then the player with fewer Prize cards + // left wins; with the same number, play goes on until one has fewer or wins outright (Play! Pokémon Tournament Rules 5.8.3, + // single elimination: a winner is needed). A game that still has not ended long after that is recorded as a draw. + if (overtimeOver(env, s) && prizeLead(env, s)) return; + if (s.turn > env.ctx.opts.maxTurns + OVERTIME_TURNS + STALEMATE_TURNS) { endGame(env, s, null, 'turn limit'); return; } @@ -635,11 +644,16 @@ function turnStep(env: Env, s: GameState): void { // lane:misc — "at the end of your turn" effects (Powerglass, Ignition Energy) resolve before Checkup. for (const sl of allSlots(s.p[s.current])) queueTriggers(env, s, sl, s.current, 'endOfTurn'); // The turn's player chooses their order (Advanced Player's Rulebook v3.4, the end-of-turn entry; TPCi Rules Team 2025-05-01, ruling 2135): - // `advance` asks when they differ. - s.step = 'checkup'; + // `advance` asks when they differ. A Pokémon those effects Knock Out is Knocked Out then, before Pokémon Checkup (a Knock Out is + // checked whenever a Pokémon's remaining HP becomes zero: APR v3.4 D). + s.afterKo = 'checkup'; + s.step = 'ko'; return; } case 'checkup': + // The turn is over: what lasted "during" it (this turn, your next turn, your opponent's next turn) does not apply in Pokémon + // Checkup, which is in neither player's turn. "Until the end of your next turn" does (APR v3.4 E-02, E-22, E-23). + s.effects = s.effects.filter((e) => e.until > s.turn || e.throughCheckup); checkup(env, s); if (s.phase === 'over') return; s.afterKo = 'nextTurn'; @@ -1416,10 +1430,22 @@ function resolveKOs(env: Env, s: GameState): void { s.step = s.afterKo; } -/** Sudden-death games played before a still-tied match is recorded as a draw. */ -const SUDDEN_DEATH_MAX = 3; +/** Tiebreaker games played before a still-tied match is recorded as a draw. */ +const TIEBREAKER_MAX = 3; +/** Turns played after time is called (the turn it is called in is turn 0). */ +const OVERTIME_TURNS = 3; +/** Turns allowed after those for one player to get ahead on Prize cards, before the game is recorded as a draw. */ +const STALEMATE_TURNS = 40; + +/** Time has been called and its +3 turns are over. */ +function overtimeOver(env: Env, s: GameState): boolean { + return s.turn > env.ctx.opts.maxTurns + OVERTIME_TURNS; +} -/** Win conditions after Knock Outs. Returns true when the game ended (or a sudden-death game began). */ +/** + * Win conditions after Knock Outs. Returns true when the game ended (or a tiebreaker game began). In a tiebreaker game, or once time + * has been called and its +3 turns are over, a player with fewer Prize cards left than the opponent has won as well. + */ function checkWin(env: Env, s: GameState): boolean { const ways: [number, number] = [0, 0]; for (const p of [0, 1] as Player[]) { @@ -1428,14 +1454,16 @@ function checkWin(env: Env, s: GameState): boolean { if (me.prizes.length === 0 && me.prizesTaken > 0) ways[p]++; if (!them.active && !them.bench.length) ways[p]++; } - if (!ways[0] && !ways[1]) return false; + if (!ways[0] && !ways[1]) return prizeLead(env, s); if (ways[0] === ways[1]) { - // Both win in the same number of ways: Sudden Death (rulebook; PTCG Live's engine has the same mode). A new game with 1 Prize card - // each decides the match. After 3 sudden-death games that also tied, it is recorded as a draw rather than played on for ever. - if ((s.suddenDeath ?? 0) >= SUDDEN_DEATH_MAX) endGame(env, s, null, 'simultaneous win'); + // Both win in the same number of ways: a tie. A winner is needed, so a Tiebreaker game decides it: a new game from the start, 6 Prize + // cards each, won by the first player to have fewer Prize cards left than the opponent (Advanced Player's Rulebook v3.4 C, "Games can + // result in a tie"; Play! Pokémon Tournament Rules 5.8.3.1; PTCG Live's Sudden Death mode). After 3 tiebreaker games that also tied, + // it is recorded as a draw rather than played on for ever. + if ((s.tiebreaker ?? 0) >= TIEBREAKER_MAX) endGame(env, s, null, 'simultaneous win'); else { - resetForSuddenDeath(env.ctx, s); - emit(env, { type: 'sudden_death', n: s.suddenDeath as number }); + resetForTiebreaker(env.ctx, s); + emit(env, { type: 'tiebreaker_game', n: s.tiebreaker as number }); } } else { const w: Player = ways[0] > ways[1] ? 0 : 1; @@ -1444,6 +1472,15 @@ function checkWin(env: Env, s: GameState): boolean { return true; } +/** In a tiebreaker game, or after time is called and the +3 turns are played: the player with fewer Prize cards left wins. */ +function prizeLead(env: Env, s: GameState): boolean { + if (!s.tiebreaker && !overtimeOver(env, s)) return false; + const [a, b] = [s.p[0].prizes.length, s.p[1].prizes.length]; + if (a === b) return false; + endGame(env, s, a < b ? 0 : 1, s.tiebreaker ? 'tiebreaker' : 'time'); + return true; +} + export function endGame(env: Env, s: GameState, winner: Player | null, reason: string): void { s.phase = 'over'; s.winner = winner; diff --git a/packages/sim/src/interp.ts b/packages/sim/src/interp.ts index 54ff7ad4..6412a875 100644 --- a/packages/sim/src/interp.ts +++ b/packages/sim/src/interp.ts @@ -845,6 +845,7 @@ function step(env: Env, s: GameState, f: Frame, st: Step): R { case 'effect': { const { start, until } = durationTurns(s, f.player, st.duration); const later = start > s.turn ? { start } : {}; + const lastsCheckup = st.duration === 'throughMyNextTurn' ? { throughCheckup: true } : {}; if (st.on) { const t = resolveSlot(s, e, st.on); if (!t) return 'next'; @@ -855,6 +856,7 @@ function step(env: Env, s: GameState, f: Frame, st: Step): R { player: ownerOf(s, t), until, ...later, + ...lastsCheckup, fromAttack: f.kind === 'attack', src: f.src, since: s.turn, @@ -869,6 +871,7 @@ function step(env: Env, s: GameState, f: Frame, st: Step): R { player: side(e, st.onPlayer), until, ...later, + ...lastsCheckup, fromAttack: f.kind === 'attack', src: f.src, filter: st.filter, diff --git a/packages/sim/src/pilot/lookahead.ts b/packages/sim/src/pilot/lookahead.ts index 412e6338..bbfce33e 100644 --- a/packages/sim/src/pilot/lookahead.ts +++ b/packages/sim/src/pilot/lookahead.ts @@ -198,11 +198,12 @@ export function publicKey(env: Env, s: GameState, me: Player): string { h.seq(s.attackUses ?? 0).seq(+!!s.attackedViaTool + 2 * +s.attacked + 4 * +!!s.noMoreAttacks).seq(s.stack.length).bag(s.limbo, cd); h.seq(s.first).seq(s.afterKo.length).seq(s.attackBy ?? -1).seq(s.attackByCard === undefined ? -1 : (cd[s.attackByCard] as number)).seq(s.benchOwner ?? -1).seq(s.queued.length); for (const x of s.attackHits ?? []) h.seq(x); + h.seq(s.tiebreaker ?? 0); if (s.prizeTaking) h.seq(s.prizeTaking.left[0]).seq(s.prizeTaking.left[1]).seq(s.prizeTaking.took[0]).seq(s.prizeTaking.took[1]); str(h, s.afterKo); h.seq(s.effects.length); for (const e of s.effects) { - h.seq(e.slot).seq(e.player).seq(e.until).seq(e.start ?? -1).seq(+e.fromAttack).seq(e.since ?? -1).seq(cd[e.src] as number); + h.seq(e.slot).seq(e.player).seq(e.until).seq(e.start ?? -1).seq(+!!e.throughCheckup).seq(+e.fromAttack).seq(e.since ?? -1).seq(cd[e.src] as number); h.seq(e.by ?? -1).seq(e.from ?? -1).seq(e.atk ? e.atk.by * 1000 + e.atk.use : -1); if (e.scope) json(h, typeof e.scope === 'object' ? e.scope : { scope: e.scope }); json(h, e.static); diff --git a/packages/sim/src/rulingIndex.ts b/packages/sim/src/rulingIndex.ts index a7d45af7..5390c017 100644 --- a/packages/sim/src/rulingIndex.ts +++ b/packages/sim/src/rulingIndex.ts @@ -152,7 +152,7 @@ export const RULING_INDEX: readonly RulingIndexEntry[] = [ {"id":2243,"date":"2025-11-13","source":"Phantasmal Flames FAQ; TPCi Rules Team (2025-11-13)","categories":["Arf Arf Bark","Sandy Flapping"],"summaries":["Flygon may use it on evolving from hand and again when later Knocked Out in the Active Spot by opposing attack damage (an evolved Flygon that is later Knocked Out may use it again)","Flygon, Sandy Flapping: when evolved from the hand you may discard the top 2 cards of the opponent's deck; used again when attack damage Knocks it Out in the Active Spot (ruling 2243)"]}, {"id":2242,"date":"2025-11-13","source":"Phantasmal Flames FAQ; TPCi Rules Team (2025-11-13)","categories":["Multi Adapter"],"summaries":["Pokémon containing \"Rotom\" in their names qualify.","Rotom ex, Multi Adapter: Fan Rotom may hold a second Pokémon Tool (ruling 2242)","Rotom ex, Multi Adapter: each of your Pokémon with \"Rotom\" in its name may have up to 2 Tools; the others still 1 (rulings 2242, 2292)"]}, {"id":2241,"date":"2025-11-13","source":"Phantasmal Flames FAQ; TPCi Rules Team (2025-11-13)","categories":["Excited Heal"],"summaries":["a Basic Mega Heracross ex satisfies the requirement; the {G} Mega Evolution Pokémon ex need not be an evolved Pokémon."]}, - {"id":2240,"date":"2025-11-06","source":"TPCi Rules Team (2025-11-06)","categories":["Cursed Blast"],"summaries":["Cursed Blast Knocking Out both Active Pokémon with both players on 1 Prize - the player who can promote wins; both or neither means sudden death"]}, + {"id":2240,"date":"2025-11-06","source":"TPCi Rules Team (2025-11-06)","categories":["Cursed Blast"],"summaries":["Cursed Blast Knocking Out both Active Pokémon with both players on 1 Prize - the player who can promote wins; both or neither means a tiebreaker game"]}, {"id":2239,"date":"2025-11-06","source":"TPCi Rules Team (2025-11-06)","categories":["Cursed Blast"],"summaries":["Cursed Blast Knock Outs - the next turn's player takes Prizes and promotes first"]}, {"id":2237,"date":"2025-10-23","source":"TPCi Rules Team (2025-10-23)","categories":["Myriad Leaf Shower","Wild Growth"],"summaries":["Extra Energy from Wild Growth replaces, it does not add: a second source does not add to the first (ruling 2236); the extra Energy pays attack costs and Retreat Costs (rulings 2237, 2288)","Meganium, Wild Growth: each Basic {G} Energy on your Pokémon provides {G}{G}; everything that counts attached Energy counts the extra (rulings 2237, 2288)","Teal Mask Ogerpon ex, Myriad Leaf Shower: Wild Growth's extra Energy counts as attached, 3 Basic {G} is +180","an effect that counts attached energy uses the total after Abilities like Wild Growth add to it."]}, {"id":2236,"date":"2025-10-23","source":"TPCi Rules Team (2025-10-23)","categories":["Jungle Totem","Wild Growth"],"summaries":["Extra Energy from Wild Growth replaces, it does not add: a second source does not add to the first (ruling 2236); the extra Energy pays attack costs and Retreat Costs (rulings 2237, 2288)","Meganium, Wild Growth: only Basic {G} Energy, only your Pokémon (Active and Benched); two copies do not add up (ruling 2236)","Wild Growth and Jungle Totem are replacements, not additions; they do not combine, so each Basic Grass still provides 2 Grass energy."]}, @@ -490,7 +490,7 @@ export const RULING_INDEX: readonly RulingIndexEntry[] = [ {"id":1835,"date":"2023-06-01","source":"TPCi Rules Team (2023-06-01)","categories":["Pulling Currents"],"summaries":[]}, {"id":1834,"date":"2023-06-01","source":"TPCi Rules Team (2023-06-01)","categories":["G-Max Spiral","Skyscraper"],"summaries":[]}, {"id":1833,"date":"2023-05-11","source":"TPCi Rules Team (2023-05-11)","categories":["Blazing Energy","Energy Burn","Jungle Totem"],"summaries":[]}, - {"id":1832,"date":"2026-03-12","source":"TPCi Rules Team (2023-05-04), TPCi Rules Team (2026-03-12)","categories":["Aqua Return","Win States"],"summaries":["Primarina, Aqua Return that takes the last Prize and leaves no Pokemon is a tie (sudden death), or a win if the opponent has none either"]}, + {"id":1832,"date":"2026-03-12","source":"TPCi Rules Team (2023-05-04), TPCi Rules Team (2026-03-12)","categories":["Aqua Return","Win States"],"summaries":["Primarina, Aqua Return that takes the last Prize and leaves no Pokemon is a tie (a tiebreaker game decides it), or a win if the opponent has none either"]}, {"id":1831,"date":"2023-05-04","source":"TPCi Rules Team (2023-05-04)","categories":["Concealed Cards"],"summaries":[]}, {"id":1829,"date":"2023-04-13","source":"TPCi Rules Team (2023-04-13)","categories":["Boss Pockets","Emergency Jelly"],"summaries":[]}, {"id":1828,"date":"2023-04-06","source":"TPCi Rules Team (2023-04-06)","categories":["Deck"],"summaries":[]}, @@ -1098,9 +1098,9 @@ export const RULING_INDEX: readonly RulingIndexEntry[] = [ {"id":887,"date":"2026-03-12","source":"WotC Chat (2000-10-05), TPCi Rules Team (2026-03-12)","categories":["Win States"],"summaries":["taking the last Prize card on your turn wins before Pokémon Checkup, even if Poison would then Knock Out your last Pokémon"]}, {"id":886,"date":"2000-03-09","source":"WotC Chat (2000-03-09)","categories":["Win States"],"summaries":["discarding the last cards of the opponent's deck wins when they cannot draw at the start of their next turn"]}, {"id":885,"date":"2000-02-24","source":"WotC Chat (2000-02-24)","categories":["Win States"],"summaries":["a player with no cards left in the deck still wins by Knocking Out the opponent's last Pokémon (or taking the last Prize card) that turn"]}, - {"id":884,"date":"2026-03-12","source":"PUI Rules Team (2005-03-10), TPCi Rules Team (2026-03-12)","categories":["Mass Destruction","Win States"],"summaries":["an attack that Knocks Out the attacker and the Defending Pokémon for the last Prize card is sudden death if the attacker is left with no Pokémon, a win if the opponent is too"]}, + {"id":884,"date":"2026-03-12","source":"PUI Rules Team (2005-03-10), TPCi Rules Team (2026-03-12)","categories":["Mass Destruction","Win States"],"summaries":["an attack that Knocks Out the attacker and the Defending Pokémon for the last Prize card is a tiebreaker game if the attacker is left with no Pokémon, a win if the opponent is too"]}, {"id":883,"date":"1999-09-30","source":"WotC Chat (1999-09-30)","categories":["Win States"],"summaries":["three ways to win: take the last Prize card, leave the opponent with no Pokémon in play, or the opponent cannot draw at the start of their turn"]}, - {"id":882,"date":"2026-03-12","source":"WotC Chat (1999-10-04), TPCi Rules Team (2026-03-12)","categories":["Win States"],"summaries":["when an attack makes both players take their last Prize card, the one who can still put a Pokémon in the Active Spot wins; otherwise sudden death"]}, + {"id":882,"date":"2026-03-12","source":"WotC Chat (1999-10-04), TPCi Rules Team (2026-03-12)","categories":["Win States"],"summaries":["when an attack makes both players take their last Prize card, the one who can still put a Pokémon in the Active Spot wins; otherwise a tiebreaker game decides it"]}, {"id":881,"date":"2018-04-26","source":"TPCi Rules Team (2018-04-26)","categories":["Knock Outs & Prizes","Win States"],"summaries":[]}, {"id":880,"date":"2018-04-26","source":"TPCi Rules Team (2018-04-26)","categories":["Knock Outs & Prizes","Win States"],"summaries":[]}, {"id":879,"date":"2026-03-12","source":"TPCi Rules Team (2018-05-03), TPCi Rules Team (2026-03-12)","categories":["Knock Outs & Prizes","Stinger GX","Win States"],"summaries":[]}, @@ -1712,7 +1712,7 @@ export const RULING_INDEX: readonly RulingIndexEntry[] = [ {"id":248,"date":"2012-02-09","source":"Next Destinies FAQ (2012-02-09), TPCi Rules Team (2012-02-09)","categories":["Smooth Coat"],"summaries":["Smooth Coat flips for damage done to its own Pokémon by its player's attack (Diggersby's Earthquake on the Bench)"]}, {"id":247,"date":"2012-02-09","source":"Next Destinies FAQ (2012-02-09), TPCi Rules Team (2012-02-09)","categories":["Slippery Soles"],"summaries":[]}, {"id":246,"date":"2014-11-13","source":"Phantom Forces FAQ (2014-11-13), TPCi Rules Team (2014-11-13)","categories":["Slip Trip"],"summaries":[]}, - {"id":245,"date":"2026-03-12","source":"Plasma Freeze FAQ (2013-05-16), TPCi Rules Team (2013-05-16), TPCi Rules Team (2026-03-12)","categories":["Six Feet Under","Win States"],"summaries":["win conditions reached at the same moment by an Ability are counted: more ways wins, the same number is sudden death"]}, + {"id":245,"date":"2026-03-12","source":"Plasma Freeze FAQ (2013-05-16), TPCi Rules Team (2013-05-16), TPCi Rules Team (2026-03-12)","categories":["Six Feet Under","Win States"],"summaries":["win conditions reached at the same moment by an Ability are counted: more ways wins, the same number is a tiebreaker game"]}, {"id":244,"date":"2014-08-28","source":"TPCi Rules Team (2014-08-28)","categories":["Sinister Hand"],"summaries":[]}, {"id":243,"date":"2015-11-19","source":"BREAKthrough FAQ (2015-11-19), TPCi Rules Team (2015-11-19)","categories":["Shining Body"],"summaries":[]}, {"id":242,"date":"2018-08-02","source":"Celestial Storm FAQ (2018-07-20), TPCi Rules Team (2018-08-02)","categories":["Shady Move"],"summaries":[]}, diff --git a/packages/sim/src/runner.ts b/packages/sim/src/runner.ts index c2cae94d..a32fd2d5 100644 --- a/packages/sim/src/runner.ts +++ b/packages/sim/src/runner.ts @@ -94,7 +94,7 @@ export interface GameSummary { /** Deck A went first. */ aFirst: boolean; winner: Side | null; - /** Engine reason: 'prizes', 'no Pokémon in play', 'deck out', 'no Basic Pokémon', 'turn limit', 'decision limit', 'time limit', 'simultaneous win', or 'engine error'. */ + /** Engine reason: 'prizes', 'no Pokémon in play', 'deck out', 'no Basic Pokémon', 'time' (time called: fewer Prize cards left after +3 turns), 'tiebreaker', 'turn limit', 'decision limit', 'time limit', 'simultaneous win', or 'engine error'. */ reason: string; /** Turns played, both players counted (turn 1 is the first player's first turn). */ turns: number; diff --git a/packages/sim/src/state.ts b/packages/sim/src/state.ts index 2f525e99..91f4eac0 100644 --- a/packages/sim/src/state.ts +++ b/packages/sim/src/state.ts @@ -32,10 +32,10 @@ function emptyPlayer(deck: number[]): PlayerState { } /** - * Start a sudden-death game in place: every card goes back to its owner's deck, the board and every effect are cleared, and setup - * runs again (a new coin flip; 1 Prize card each, see flow.ts). The RNG streams carry on. + * Start a tiebreaker game in place: every card goes back to its owner's deck, the board and every effect are cleared, and setup + * runs again (a new coin flip, 6 Prize cards each; see flow.ts checkWin). The RNG streams carry on. */ -export function resetForSuddenDeath(ctx: GameContext, s: GameState): void { +export function resetForTiebreaker(ctx: GameContext, s: GameState): void { s.p = [emptyPlayer(ctx.iids[0].slice()), emptyPlayer(ctx.iids[1].slice())]; s.turn = 0; s.phase = 'setup'; @@ -58,7 +58,7 @@ export function resetForSuddenDeath(ctx: GameContext, s: GameState): void { s.benchOwner = undefined; s.nest = undefined; s.prizeTaking = undefined; - s.suddenDeath = (s.suddenDeath ?? 0) + 1; + s.tiebreaker = (s.tiebreaker ?? 0) + 1; } export function newState(ctx: GameContext, seed: number): GameState { @@ -134,6 +134,7 @@ const OPTIONAL_PLAYER_FIELDS = [ 'prevAttackRec', 'toolUsed', 'oncePerGame', + 'setupPick', ] as const; // Written out field by field (a `...p` spread over a shape with this many optional fields is slow, and a clone is made per search node). diff --git a/packages/sim/src/transcript.ts b/packages/sim/src/transcript.ts index e199bd88..0b2d340d 100644 --- a/packages/sim/src/transcript.ts +++ b/packages/sim/src/transcript.ts @@ -127,8 +127,8 @@ export function transcript(ctx: GameContext, events: readonly GameEvent[], o: Tr case 'game_end': lines.push('', `GAME OVER on turn ${e.turn}: ${e.winner === null ? `draw (${e.reason})` : `${who(e.winner)} wins (${e.reason})`}.`); break; - case 'sudden_death': - lines.push('', `BOTH PLAYERS WON AT ONCE: sudden death${e.n > 1 ? ` #${e.n}` : ''}, a new game with 1 Prize card each.`, ''); + case 'tiebreaker_game': + lines.push('', `BOTH PLAYERS WON AT ONCE: tiebreaker game${e.n > 1 ? ` #${e.n}` : ''}, a new game with 6 Prize cards each; the first to have fewer Prize cards left wins.`, ''); break; } } diff --git a/packages/sim/src/types.ts b/packages/sim/src/types.ts index e0b278db..2c6008c0 100644 --- a/packages/sim/src/types.ts +++ b/packages/sim/src/types.ts @@ -229,6 +229,8 @@ export interface PlayerState { knownTop: number; /** Hand cards the opponent has seen (revealed by a search), until they leave the hand. */ revealed: number[]; + /** Setup: the Active (first) and Benched Pokémon chosen face down, still in the hand until the game starts (flow.ts revealSetup). */ + setupPick?: number[]; /** Cards (instance ids) this player played from their hand during their current turn by their own actions. Reset when their turn starts. */ playedThisTurn?: number[]; /** `prizesTaken` when this player's current turn began, and how many Prize cards they took in their most recent finished turn. */ @@ -287,6 +289,8 @@ export interface TimedEffect { until: number; /** The first turn it applies in: "During your next turn" waits for that turn. Absent = from when it was made. */ start?: number; + /** "Until the end of ... turn": still applies in the Pokémon Checkup after its last turn (APR v3.4 E-22, E-23). "During" effects do not. */ + throughCheckup?: boolean; /** Attack effects on a Pokémon end when it leaves the Active Spot or evolves. */ fromAttack: boolean; src: number; @@ -356,8 +360,8 @@ export interface GameState { * They are taken one at a time, so a player with a face-up Prize card chooses each time (see flow.ts `takePrizes`). */ prizeTaking?: { left: [number, number]; took: [number, number] }; - /** Sudden-death games started so far (both players won at once): each is a new game with 1 Prize card per player. */ - suddenDeath?: number; + /** Tiebreaker games started so far (both players won at once): each is a new game, 6 Prize cards each, won by the first player to have fewer Prize cards left. */ + tiebreaker?: number; /** Where the flow continues once Knock Outs and promotions are resolved. */ afterKo: string; winner: Player | null; @@ -476,15 +480,18 @@ export type GameEvent = | { type: 'condition'; player: Player; slot: number; cond: string } | { type: 'end_turn'; player: Player; turn: number } | { type: 'game_end'; winner: Player | null; reason: string; turn: number } - /** Both players won at once: a sudden-death game starts (1 Prize card each). `n` counts them in this match. */ - | { type: 'sudden_death'; n: number }; + /** Both players won at once: a tiebreaker game starts (6 Prize cards; first to have fewer left wins). `n` counts them in this match. */ + | { type: 'tiebreaker_game'; n: number }; export interface EngineOptions { /** Record events (off by default in batch simulation). */ events?: boolean; /** Force who goes first (skips the coin flip decision). */ first?: Player; - /** Hard cap on turns; the game is recorded as a draw ('turn limit'). */ + /** + * The turn time is called in: +3 turns are played, then the player with fewer Prize cards left wins ('time'); level, play goes on + * until one is ahead. A game still level 40 turns after that is recorded as a draw ('turn limit'). + */ maxTurns?: number; /** Extra scripts matched by card id (tests, or a script under review). Win over the registry. */ scripts?: import('./dsl.js').CardScript[];