diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e31ab9e7..182737d7 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,6 +6,16 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work +The separate [Reinforcements card prototype](REINFORCEMENTS_RUNTIME.md) +now works in clean Ecaz/Moritani expansion battles as well as classic-faction +tables in Basic and Advanced. Genuine setup, owner-only two-slot controls, +legal AI offers, typed reserve-to-Tanks payment and saved D1 continuation +follow the existing card contract. Co-present Ecaz allied armies, Richese, +Ixians/Tleilaxu and other optional modules remain gated; the printed cost +timing is still a documented product interpretation, not a publisher ruling. +This does not open public expansion or card-variant starts, and this source is +not in the last verified live image. + A five-seat Advanced combined Homeworld/Nexus/expansion sample exposed two known-unsupported Ecaz Ambassador/Moritani Terror shipment candidates from Tleilaxu's own Homeworld. [AI arrival preflight](BOT_ARRIVAL_PREFLIGHT.md#29-september-homeworld-sourced-shipment-overlap) diff --git a/docs/ECAZ_TREACHERY_RULES.md b/docs/ECAZ_TREACHERY_RULES.md index c717e7c6..d5a15193 100644 --- a/docs/ECAZ_TREACHERY_RULES.md +++ b/docs/ECAZ_TREACHERY_RULES.md @@ -133,3 +133,16 @@ publisher adjudications or an answer to the pending user question. The card cannot share a plan with Harass & Withdraw or Stone Burner until those combinations are resolved. The earlier “remains disabled” statement describes the 14 September snapshot rather than this bounded current prototype. + +## 29 September paired-faction extension + +The independent card variant now permits the same bounded Reinforcements +Battle Plan in clean Ecaz/Moritani expansion games with classic opponents, +in Basic and Advanced. It reuses the physical reserve cost and score-only +modifier; the earlier provisional outcome/type/Atreides-loss interpretation +is unchanged and does not become a publisher ruling by applying to another +faction. Co-present Ecaz allied armies, Richese, Ixians/Tleilaxu and other +optional modules remain gated. The [runtime boundary](REINFORCEMENTS_RUNTIME.md) +and [checkpoint](IMPLEMENTATION_STATUS.md#29-september-2026--reinforcements-in-paired-ecazmoritani-battles) +record the exact evidence; the 27 September classic-only statement above is +historical. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 0deaeda3..0ee1bc2e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,33 @@ # Dune implementation status +## 29 September 2026 — Reinforcements in paired Ecaz/Moritani battles + +The independently selected three-card Ecaz variant now allows its physical +Reinforcements card in clean Basic/Advanced Ecaz/Moritani battles, as well as +the prior classic-only profile. The existing battle quote pays three own +reserve counters into typed Tanks, adds two to normal score without changing +the physical dial, and mandates one discard after reveal. Owner-only card +slots and all four minimal AI policies follow the server offer. Co-present +Ecaz allied armies, Richese, Ixians/Tleilaxu and other optional modules stay +guarded; prior provisional cost rulings are unchanged. + +A failing-before/passing-after genuine-setup regression covers each paired +faction as holder in both rules modes, private plans, physical custody and +JSON recovery. A paired Advanced room also survives restart and a competing +final D1 write with one cost and discard. Forty-five focused +Reinforcements/Harass cases pass. A separate engine smoke settled one paired +battle with 15→12 own reserves and one discard, while the co-present-ally +attempt was rejected without mutation. A React-rendered owner fixture in a +mobile Chromium tab showed Reinforcements selectable; it was not a live-room +UI check. Independent static review found no patch-introduced defect and noted +co-present Ecaz armies as a separate boundary, now guarded in the server and +owner offer. No public start or complete module acceptance is claimed. + +`npm run check` passes typecheck, lint and 5,761/5,761 offline cases; +`npm run build` passes. No route, database schema or public start gate changed. +The last verified NAS image predates this source; local results are not +deployed acceptance. + ## 29 September 2026 — Moritani and Tleilaxu Leader Skills samples At source revision `29aedf1`, all five Basic Moritani plus Leader Skills diff --git a/docs/REINFORCEMENTS_RUNTIME.md b/docs/REINFORCEMENTS_RUNTIME.md index 873e04f8..c0c6d0d0 100644 --- a/docs/REINFORCEMENTS_RUNTIME.md +++ b/docs/REINFORCEMENTS_RUNTIME.md @@ -1,11 +1,19 @@ # Reinforcements battle prototype -The separate Ecaz Treachery Cards variant shuffles one physical Reinforcements card independently of choosing the Ecaz faction. In a Basic or Advanced game with only classic factions and no other optional modules, its holder may commit it in either Battle Plan card slot. It is neither a weapon nor a defense: Prescience of a category receives **None**, while the card's physical slot remains occupied and hidden until reveal. A named-card inspection still fixes the exact card. Harass & Withdraw and Stone Burner cannot share this bounded plan; Homeworld and other optional-module combinations remain gated. +The separate Ecaz Treachery Cards variant shuffles one physical Reinforcements +card independently of choosing the Ecaz faction. Its bounded handler supports +Basic or Advanced classic-faction tables and clean Ecaz/Moritani expansion +tables without other optional modules or co-present Ecaz allied armies. The +holder may commit it in either Battle Plan card slot. It is neither a weapon +nor a defense: Prescience of a category receives **None**, while the physical +slot stays occupied and hidden until reveal. A named-card inspection still +fixes the exact card. Harass & Withdraw and Stone Burner cannot share this +plan; Homeworld and other optional-module combinations remain gated. The holder must have three own reserve counters when sealing the plan. The owner-only battle offer displays availability and the exact provisional cost. The current prototype deterministically takes ordinary reserves first, then elite reserves if needed, transferring precisely three physical counters to the corresponding Tanks subpools when the battle settles. There is no spice payment or on-board arrival. The +2 changes the calculated normal-outcome battle score, **not** the committed physical dial, support payment, casualty count or leader survival. The card is discarded after any revealed result, including an own, opposing or mutual Traitor call and Lasgun/Shield explosion. The cost is likewise paid on each of those outcomes. A successful Traitor call still supersedes the normal score; the modifier never overrides it. Public history records the transfer; the sealed card and owner reserve mix stay private until the reveal. JSON-restored decisions and transactional room updates cannot charge the same cost twice. **Provisional interpretations, not publisher rulings:** The card says “increase the dialed number by 2” and transfer three reserves, but no retrieved combined ruling answers whether a sole successful Traitor caller instead keeps their reserves, how the transfer orders against an explosion, whether +2 adds Advanced support or physical losses, which reserve types the holder may select, or whether the three spent reserves count as battle losses for Atreides. For this bounded prototype, payment is at battle settlement before on-board casualties on every outcome; the engine counts the three as battle losses, chooses normal-first rather than offering a type selection, and keeps the score bonus separate from physical dial. [The source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) records the competing reading and pending user question. These choices are visible so they can be revised without changing prior hands or inventing hidden information. -The owner receives a card-slot explanation and disabled reason when fewer than three reserve counters remain or the bounded mode is unsupported. All four AI profiles generate only legal candidates and can select either slot, including an Advanced plan. The current scoring is a minimal legal participation heuristic, not difficulty calibration. The prototype does not certify the whole three-card variant, other faction combinations, optional modules, historical-save compatibility or public mode starts. +The owner receives a card-slot explanation and disabled reason when fewer than three reserve counters remain or the bounded mode is unsupported. All four AI profiles generate legal candidates in the classic and Ecaz/Moritani profiles and can select either slot, including an Advanced plan. The current scoring is a minimal legal participation heuristic, not difficulty calibration. The prototype does not certify the whole three-card variant, other expansion combinations, optional modules, historical-save compatibility or public mode starts. -Focused rule, engine, controls and in-memory SQLite tests cover both slots, Basic/Advanced payment, elite subpool conservation, traitor/explosion disposal, private sealed views, JSON restoration, rejected-action immutability and competing final room commits. The test results and actual browser/UI evidence for the current checkpoint are recorded in [Implementation status](IMPLEMENTATION_STATUS.md); test coverage is not a publisher ruling. +Focused rule, engine, controls and in-memory SQLite tests cover both slots, Basic/Advanced payment, elite subpool conservation, traitor/explosion disposal, private sealed views, JSON restoration, rejected-action immutability and competing final room commits, now including a paired Ecaz/Moritani saved battle. The test results and actual browser/UI evidence for the current checkpoint are recorded in [Implementation status](IMPLEMENTATION_STATUS.md); test coverage is not a publisher ruling. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 83f04eb1..856df0ca 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -73,7 +73,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Boundary | Still unresolved | Existing record | | --- | --- | --- | | Bureaucrat / Emperor gifts | Whether an allied gift is a qualifying payment and whether its threshold may be revealed. Existing gift amounts are private to payer/recipient; the card and retrieved primary sources do not explicitly settle this combination. The user question is pending; no answer or exact-amount disclosure is assumed. | [Gift classification review](BUREAUCRAT_PAYMENTS.md#21-september-gift-classification-review) | -| Reinforcements cost | The user question still asks whether to keep the card disabled, always transfer three reserve forces on every outcome, or waive only a sole traitor-call winner's cost; no answer is assumed. The bounded classic-faction prototype provisionally charges on all revealed outcomes, chooses normal reserves before elite and counts the transfer as battle losses. Those are product interpretations, not a ruling. Same-plan Harass/Stone Burner remain gated; opposing Harass is supported. Eligibility never depends on concealed matching Traitors. | [Source cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Bounded runtime](REINFORCEMENTS_RUNTIME.md) | +| Reinforcements cost | The user question still asks whether to keep the card disabled, always transfer three reserve forces on every outcome, or waive only a sole traitor-call winner's cost; no answer is assumed. The bounded classic and Ecaz/Moritani prototype provisionally charges on all revealed outcomes, chooses normal reserves before elite and counts the transfer as battle losses. Those are product interpretations, not a ruling. Same-plan Harass/Stone Burner remain gated; opposing Harass is supported. Eligibility never depends on concealed matching Traitors. | [Source cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Bounded runtime](REINFORCEMENTS_RUNTIME.md) | | Harass / Stone Burner | A user question has been sent: keep the combination gated or compare forces remaining after withdrawal. No answer is assumed. The Richese card-family configuration is guarded publicly; hidden Stone Burner custody never controls eligibility. Ambiguous physical allocations and other optional modules remain unfinished. | [Current boundaries](HARASS_WITHDRAW_RUNTIME.md#current-boundaries) | | Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) | | Sandmaster / native HMS relocation | No official source located establishes that passengers remaining inside the HMS enter outside territories for this skill. Extra collection eligibility is pending a user ruling; native sector collection and ordinary force entry/exit remain available. | [Source comparison and question](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility) | diff --git a/game/engine.ts b/game/engine.ts index 678c956a..cf49bb9b 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9477,14 +9477,30 @@ function recruitsModeSupported(g: Game) { !g.discoveryEnabled ); } +function ecazAllyAtBattle(g: Game, side: Player, territoryId: string) { + const ally = g.players.find(player => player.id === side.ally && player.ally === side.id); + return !!ally && (side.faction === 'ecaz' || ally.faction === 'ecaz') && + Object.entries(ally.forces).some(([key, amount]) => amount > 0 && splitLocation(key).territory === territoryId); +} + function reinforcementsModeSupported(g: Game) { + const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz'; return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout && - g.expansions.length === 0 && - g.players.every(player => faction(player.faction).expansion === 'base'); + (g.expansions.length === 0 || pairedEcaz) && + g.players.every(player => { + const expansion = faction(player.faction).expansion; + return expansion === 'base' || (pairedEcaz && expansion === 'ecaz'); + }); } function currentReinforcementsCost(g: Game, p: Player) { requireRule(reinforcementsModeSupported(g), - 'Reinforcements currently requires the standalone Ecaz card variant with classic factions.'); + 'Reinforcements requires the standalone Ecaz card variant with classic or Ecaz/Moritani factions.'); + if (g.battle) { + const other = getPlayer(g, g.battle.attacker === p.id ? g.battle.defender : g.battle.attacker); + requireRule(!ecazAllyAtBattle(g, p, g.battle.territory) && + !ecazAllyAtBattle(g, other, g.battle.territory), + 'Reinforcements with co-present Ecaz allies awaits combined-army battle integration.'); + } try { return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0); } catch (error) { @@ -9500,9 +9516,12 @@ function reinforcementsPreview(g: Game, p: Player) { const elite = Math.min(3 - normal, Math.max(0, p.elites?.reserves ?? 0)); let blocked: string | null = null; if (!reinforcementsModeSupported(g)) - blocked = 'Reinforcements currently requires the standalone Ecaz card variant with classic factions.'; + blocked = 'Reinforcements requires the standalone Ecaz card variant with classic or Ecaz/Moritani factions.'; else if (g.status !== 'playing' || g.phase !== 6 || battle.revealed || battle.plans[p.id]) blocked = 'Choose Reinforcements before sealing your Battle Plan.'; + else if (ecazAllyAtBattle(g, p, battle.territory) || + ecazAllyAtBattle(g, getPlayer(g, battle.attacker === p.id ? battle.defender : battle.attacker), battle.territory)) + blocked = 'Reinforcements with co-present Ecaz allies awaits combined-army battle integration.'; else if (normal + elite < 3) blocked = 'Reinforcements needs three physical forces in your reserves.'; return { blocked, normal, elite }; @@ -15186,11 +15205,8 @@ function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext { blocked = 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.'; else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined) blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.'; - else if ([p, other].some(side => { - const ally = g.players.find(player => player.id === side.ally && player.ally === side.id); - return ally && (side.faction === 'ecaz' || ally.faction === 'ecaz') && - Object.entries(ally.forces).some(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory); - })) blocked = 'Harass & Withdraw with co-present Ecaz allies awaits combined-army battle integration.'; + else if (ecazAllyAtBattle(g, p, b.territory) || ecazAllyAtBattle(g, other, b.territory)) + blocked = 'Harass & Withdraw with co-present Ecaz allies awaits combined-army battle integration.'; else if (p.noField?.deployed?.location.territory === b.territory) blocked = 'Reveal your No-Field before using Harass & Withdraw.'; const locations = Object.fromEntries(Object.entries(p.forces) diff --git a/game/reference.ts b/game/reference.ts index 752f95c1..f9885c3f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4225,17 +4225,17 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Add two to battle strength while paying three own reserve counters into the Tanks when the physical card is revealed.', steps: [ 'In the independent Ecaz Treachery Cards variant, place your held Reinforcements card in either battle-card slot; it is neither a weapon nor a defense. A category Prescience answer is None, not the name of this card. One physical card cannot fill both slots.', - 'Have three own forces in reserves when sealing. The current Basic/Advanced classic-faction prototype spends ordinary reserves first and then elite reserves, moving exactly three to their corresponding Tanks subpools when the battle resolves. No extra forces arrive in the battle territory.', + 'Have three own forces in reserves when sealing. The current Basic/Advanced classic-faction and paired Ecaz/Moritani prototype spends ordinary reserves first and then elite reserves, moving exactly three to their corresponding Tanks subpools when the battle resolves. No extra forces arrive in the battle territory.', 'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.', - 'Payment on every outcome, deterministic normal-first selection and counting the reserve transfer as battle losses are documented provisional interpretations, not publisher rulings. Harass/Stone Burner combinations, other optional modules and expansion factions remain unavailable in this bounded prototype.', + 'Payment on every outcome, deterministic normal-first selection and counting the reserve transfer as battle losses are documented provisional interpretations, not publisher rulings. Harass/Stone Burner, co-present Ecaz allied armies, other optional modules and other expansion pairings remain unavailable in this bounded prototype.', ], related: ['battle-cards', 'battle', 'card-recruits', 'card-harass-withdraw', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Physical card custody, either battle slot, three typed reserve counters, normal score modifier and mandatory discard connect in classic-faction Basic/Advanced games only.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Physical card custody, either battle slot, three typed reserve counters, normal score modifier and mandatory discard connect in classic or paired Ecaz/Moritani Basic/Advanced games.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Owner-only availability, exact cost guidance and both battle selectors support the card; private category answers remain None.', evidence: ['components/battle-preparation.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Four profiles consider legal physical slot candidates and the normal score bonus; strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'The printed effect and unresolved Traitor, explosion, type-selection and accounting interpretations are distinguished from this temporary prototype.', evidence: ['docs/ECAZ_TREACHERY_RULES.md', 'docs/REINFORCEMENTS_RUNTIME.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Rule, engine, controls and saved concurrent recovery cases cover this bounded behavior; full variant and combined modes are not certified.', evidence: ['tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/reinforcements-controls.test.tsx', 'tests/reinforcements-recovery.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Rule, paired-faction engine/controls and saved concurrent recovery cases cover bounded behavior; co-present Ecaz allied armies, the full card variant and combined optional modules remain uncertified.', evidence: ['tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/reinforcements-controls.test.tsx', 'tests/reinforcements-recovery.test.ts'] }, ], }, { @@ -4273,7 +4273,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.', 'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.', 'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.', - 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits, bounded Harass & Withdraw and bounded Reinforcements development controls for classic-faction games.', + 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits, bounded Harass & Withdraw and bounded Reinforcements development controls for classic or Ecaz/Moritani games.', 'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.', 'Fresh unsupported movement arrivals still reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. The bounded Ecaz/Moritani same-entry Ambassador/Terror overlap instead snapshots both eligible owners in storm order without replaying the original move.', 'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent when its still-gated arrival cannot commit; older No-Field, Smuggler, Nexus and Ambassador shipments retain their separate recovery boundaries. Classic plus Ecaz/Moritani Ambassador/Terror overlap has a provisional saved storm-order sequence, while other simultaneous priorities remain unfinished.', diff --git a/tests/fixture-harass-withdraw.ts b/tests/fixture-harass-withdraw.ts index e04eb2b5..53b55465 100644 --- a/tests/fixture-harass-withdraw.ts +++ b/tests/fixture-harass-withdraw.ts @@ -153,6 +153,7 @@ export function harassCustody(g: Game) { ); assert.deepEqual( [ + ...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : []), ...(g.traitorReserve ?? []), ...g.players.flatMap((p) => [ ...p.traitors, diff --git a/tests/reinforcements-controls.test.tsx b/tests/reinforcements-controls.test.tsx index 2f8bc79f..28df4b59 100644 --- a/tests/reinforcements-controls.test.tsx +++ b/tests/reinforcements-controls.test.tsx @@ -33,9 +33,9 @@ function battle(advanced = false): Game { return game; } -function selections(view: GameView) { +function selections(view: GameView, leader = 'emperor-0') { return (['weapon', 'defense'] as const).map(slot => - battlePlanCardOptions(view, slot, 'emperor-0', '', '').some(card => card.id === cardId)); + battlePlanCardOptions(view, slot, leader, '', '').some(card => card.id === cardId)); } function selector(html: string, slot: 'weapon' | 'defense') { @@ -61,6 +61,28 @@ void test('a held Reinforcements card with an owner offer is selectable in eithe assert.doesNotMatch(opponentHtml, /value="ecaz-reinforcements"/); }); +void test('paired Ecaz/Moritani holders have private two-slot controls and legal AI plans', () => { + for (const advanced of [false, true]) for (const owner of ['ecaz', 'moritani'] as const) { + const game = harassWithdrawGame({ advanced, factions: [owner, owner === 'ecaz' ? 'moritani' : 'ecaz', 'atreides'] }); + takeHarassCard(game, 'a', cardId); + const view = viewGame(game, 'a'); + assert.deepEqual(selections(view, `${owner}-0`), [true, true]); + const html = renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false })); + assert.match(selector(html, 'weapon'), /value="ecaz-reinforcements"/); + assert.match(selector(html, 'defense'), /value="ecaz-reinforcements"/); + const opponent = viewGame(game, 'd'); + assert.equal(opponent.battle?.reinforcements, null); + assert.ok(!opponent.players.find(player => player.id === 'a')?.hand?.some(card => card.id === cardId)); + for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + view.players.find(player => player.id === 'a')!.bot = profile; + const choice = botActions(view).find(action => + action.type === 'battlePlan' && (action.weapon === cardId || action.defense === cardId)); + assert.ok(choice, `${owner} ${profile} ${advanced ? 'Advanced' : 'Basic'}`); + assert.ok(applyAction(game, 'a', choice).battle?.plans.a); + } + } +}); + void test('a no-category inspection permits a held Reinforcements card but a second slot cannot reuse it', () => { const view = viewGame(battle(), 'a'); view.battle!.ownCommitments = [{ source: 'native', beneficiary: 'd', target: 'a', field: 'weapon', value: null }]; diff --git a/tests/reinforcements-engine.test.ts b/tests/reinforcements-engine.test.ts index dd040738..c415d008 100644 --- a/tests/reinforcements-engine.test.ts +++ b/tests/reinforcements-engine.test.ts @@ -69,6 +69,44 @@ void test('Reinforcements consumes exactly three own normal reserves in either c } }); +void test('paired Ecaz/Moritani battles spend one physical Reinforcements cost without changing the card slot or private plan', () => { + for (const advanced of [false, true]) for (const owner of ['ecaz', 'moritani'] as const) { + let g = harassWithdrawGame({ advanced, factions: [owner, owner === 'ecaz' ? 'moritani' : 'ecaz', 'atreides'] }); + takeHarassCard(g, 'a', cardId); + harassCustody(g); + assert.deepEqual(viewGame(g, 'a').battle?.reinforcements, { blocked: null, normal: 3, elite: 0 }); + assert.equal(viewGame(g, 'd').battle?.reinforcements, null); + g = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'a', { + type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, + leader: `${owner}-0`, defense: cardId, + }); + assert.deepEqual(viewGame(g, 'd').battle?.plans, {}); + g = applyAction(g, 'd', { + type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, + leader: `${owner === 'ecaz' ? 'moritani' : 'ecaz'}-0`, + }); + const done = resolve(g); + assert.equal(done.battle, null); + assert.equal(done.players[0].reserves, 12); + assert.equal(done.discard.filter(card => card.id === cardId).length, 1); + assert.equal(done.log.filter(event => event.text.includes('used Reinforcements: three reserve forces')).length, 1); + } +}); + +void test('co-present Ecaz allied armies do not open the unintegrated Reinforcements battle path', () => { + const g = harassWithdrawGame({ advanced: true, factions: ['moritani', 'atreides', 'ecaz'] }); + g.players[0].ally = 't'; + g.players[2].ally = 'a'; + g.players[2].forces = { 'arrakeen:10': 1 }; + g.players[2].reserves = 19; + takeHarassCard(g, 'a', cardId); + harassCustody(g); + assert.match(viewGame(g, 'a').battle?.reinforcements?.blocked ?? '', /co-present Ecaz allies/); + reject(g, 'a', { + type: 'battlePlan', dial: 1, support: 1, leader: 'moritani-0', weapon: cardId, + }, /co-present Ecaz allies/); +}); + void test('opposing physical Harass plan resolves with Reinforcements instead of trapping both revealed plans', () => { for (const advanced of [false, true]) { const g = fixture(advanced); @@ -129,3 +167,14 @@ void test('insufficient reserves, unsupported module, and a paired special rejec g.sandtrout = {} as Game['sandtrout']; reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }, /standalone Ecaz card variant/); }); + +void test('other expansion factions remain ineligible for the paired Ecaz card prototype', () => { + for (const owner of ['ixians', 'choam'] as const) { + const g = harassWithdrawGame({ advanced: true, factions: [owner, 'atreides', 'harkonnen'] }); + takeHarassCard(g, 'a', cardId); + assert.match(viewGame(g, 'a').battle?.reinforcements?.blocked ?? '', /classic or Ecaz\/Moritani/); + reject(g, 'a', { + type: 'battlePlan', dial: 0, support: 0, leader: `${owner}-0`, weapon: cardId, + }, /classic or Ecaz\/Moritani/); + } +}); diff --git a/tests/reinforcements-recovery.test.ts b/tests/reinforcements-recovery.test.ts index 9d64a4cc..41020ce8 100644 --- a/tests/reinforcements-recovery.test.ts +++ b/tests/reinforcements-recovery.test.ts @@ -13,17 +13,22 @@ const rows = (sqlite: DatabaseSync) => ({ }); void test('a privately sealed Reinforcements plan and its one-time physical cost survive D1-style restart and competing final writes', async t => { - for (const advanced of [false, true]) { + for (const { advanced, factions } of [ + { advanced: false, factions: ['emperor', 'atreides', 'harkonnen'] as const }, + { advanced: true, factions: ['emperor', 'atreides', 'harkonnen'] as const }, + { advanced: true, factions: ['moritani', 'ecaz', 'atreides'] as const }, + ]) { const store = unitStore(); t.after(() => store.sqlite.close()); - const made = await store.rooms.createRoom('Reinforcements recovery', 'emperor', advanced, []); + const made = await store.rooms.createRoom('Reinforcements recovery', factions[0], advanced, + factions[0] === 'moritani' ? ['ecaz'] : []); const code = made.view.code; - const tokens = [made.token, - (await store.rooms.joinRoom(code, 'Atreides', 'atreides')).token!, - (await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen')).token!]; + const tokens = [made.token]; + for (const faction of factions.slice(1)) + tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!); const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); const ids = auths.map(auth => auth.playerId) as [string, string, string]; - let game = harassWithdrawGame({ advanced, ids, factions: ['emperor', 'atreides', 'harkonnen'] }); + let game = harassWithdrawGame({ advanced, ids, factions }); game.code = code; game.version = (await store.rooms.readRoom(code)).version; takeHarassCard(game, ids[0], 'ecaz-reinforcements'); @@ -38,7 +43,7 @@ void test('a privately sealed Reinforcements plan and its one-time physical cost return rooms.readRoom(code); }; game = await act(0, { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, - leader: 'emperor-0', defense: 'ecaz-reinforcements' }); + leader: `${factions[0]}-0`, defense: 'ecaz-reinforcements' }); const preReveal = rows(store.sqlite); for (const [index, token] of tokens.entries()) { const rooms = store.restart(); @@ -53,7 +58,7 @@ void test('a privately sealed Reinforcements plan and its one-time physical cost } assert.deepEqual(rows(store.sqlite), preReveal); game = await act(1, { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, - leader: 'atreides-0' }); + leader: `${factions[1]}-0` }); game = await act(0, { type: 'traitorCall', call: false }); const before = rows(store.sqlite); const version = game.version;