Compose same-plan Harass and Reinforcements with original reserve costs

This commit is contained in:
Quintine committed 2026-10-16 06:48:04 +11:00
1 parent feb2b03251
commit be0644c0e9
15 files changed
+288 -30

No files matched your search

+11 -4
View File
@@ -551,10 +551,8 @@ export function GameTable({
const reinforcementsPlanReason = !reinforcementsSelected ? null :
!g.battle?.reinforcements ? 'Reinforcements is unavailable in this battle.' :
g.battle.reinforcements.blocked ??
((selectedBattleWeapon?.id === 'ecaz-harass-withdraw' ||
selectedBattleDefense?.id === 'ecaz-harass-withdraw' ||
(!!selectedBattleWeapon && isStoneBurner(selectedBattleWeapon)))
? 'Reinforcements cannot share this plan with Harass & Withdraw or Stone Burner.' : null);
((!!selectedBattleWeapon && isStoneBurner(selectedBattleWeapon))
? 'Reinforcements cannot share this plan with Stone Burner.' : null);
const selectedBattleKwisatz = kwisatz && !!me.kwisatz?.active &&
!me.kwisatz.dead && !g.battle?.kwisatzBlocked &&
(!me.kwisatz.usedAt || me.kwisatz.usedAt === g.battle?.territory);
@@ -5347,6 +5345,15 @@ export function GameTable({
{g.battle.reinforcements.blocked && ` ${g.battle.reinforcements.blocked}`}
</p>
)}
{reinforcementsSelected &&
(selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw') && (
<p className="fine">
Provisional combined timing: pay the original three-reserve
Reinforcements cost before Harass &amp; Withdraw returns any
undialed forces. Returned reserves cannot fund or change that
cost. Both physical cards are discarded after use.
</p>
)}
{reinforcementsPlanReason && <p role="status" className="notice">{reinforcementsPlanReason}</p>}
{!battlePairValid && <p role="status" className="notice">Choose two compatible battle cards. A single physical card cannot fill both slots.</p>}
{selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon) && (
+20 -1
View File
@@ -29,6 +29,25 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Same-plan Harass / Reinforcements — 16 October.** Both slot orders now follow
the existing original-reserve-cost-before-return order under explicit
choose-recommended interpretation. Future returns cannot fund deficient reserves
or replace the original normal/elite quote. Original score-only +2, fixed Ecaz
commitment, variable ally losses, all-outcome reserve cost and Traitor-canceled
Harass remain separate; both physical cards discard once.
Affected eleven-file134/134, types/lint and build pass. Six new behavioral
regressions, eight fresh Node policy programs/all four profiles with16 accepted
continuation actions and a real390px typed-cost journey pass. Original2-normal/
1-elite reserve payment survives the later4-normal return; owner finishes
reserves4/Tanks16/elite Tanks5/spice29, both cards discarded and collection open.
No root overflow or browser/application error. Current parse/policy anchor and38
topic evidence paths resolve; all ten facets remain Partial.
[Policy and scoped proof](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026).
Stone/Reinforcements, other profile/module limits and final assurance remain
separate. No human server, saved room, D1, deployment or public gate changed.
**Harass / Stone Burner interaction — 16 October.** Richese-family Harass now
uses visibly provisional post-withdrawal physical comparison under the standing
choose-recommended priority; the earlier source question remains unanswered.
@@ -47,7 +66,7 @@ Original Richese lot and shared-preparation fixture drivers were corrected;
incidental collection-wallet expectations were removed. Current guide parse,
policy anchor and33 evidence paths resolve; both topics' five facets stay
Partial. [Policy and scoped proof](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026).
Ix/Poison Tooth, same-plan Harass/Reinforcements, Reinforcements/Stone, own
Ix/Poison Tooth, Reinforcements/Stone and own
concealed No-Field Harass and other module gates remain. No human server,
saved room, D1, deployment or public gate changed; final assurance stays due.
+5 -3
View File
@@ -249,9 +249,11 @@ Reinforcements uses the card user's own three normal-first reserve counters
and existing +2 score-only modifier, not ally reserves, extra physical dial
losses or support. Existing provisional all-revealed-outcome cost/disposal
is unchanged and remains explicitly not a publisher ruling. Both slots and
opposing holders work; own same-plan Harass/Reinforcements and Reinforcements
with Stone/optional modules remain guarded. Basic/unprofiled co-side card
battles remain rejected.
opposing holders work. Same-plan Harass/Reinforcements now follows the
[provisional original-reserve-cost-first policy](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026),
preserving actual card-user ownership and original fixed losses.
Reinforcements with Stone/optional modules and Basic/unprofiled co-side
card battles remain guarded.
Native/pure67 cases, relevant284-case batch and27 actual native-program
cases pass, including all four legal policies. Two natural captured games
+6 -5
View File
@@ -110,11 +110,12 @@ This is a legal strategy path, not strength calibration.
returns only actual card-user forces: original fixed Ecaz commitment or the
selected ally's native variable commitment. It never returns the other ally
or recomputes mandatory fixed losses from the diminished board.
- [Bounded Reinforcements](REINFORCEMENTS_RUNTIME.md) can appear in the
opposing Battle Plan in classic-faction Basic/Advanced games. The two cards
cannot share one player's plan; their relative same-plan timing remains
unresolved. Both prototypes retain their separate provisional cost and
outcome interpretations.
- [Bounded Reinforcements](REINFORCEMENTS_RUNTIME.md) may appear in the opposing
or the same supported Battle Plan. Same-plan use provisionally pays the
original reserve quote before returning undialed counters; a future return
cannot fund or change that cost. Both prototypes retain their separate
provisional outcome interpretations. See the
[combined policy](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026).
## Scoped interaction proof — 16 October 2026
+26
View File
@@ -1,5 +1,31 @@
# Dune implementation status
## 16 October 2026 — Same-plan Harass and Reinforcements
Connected both physical slot orders without adding a new settlement mechanism
or state field. Existing native order pays the original three-reserve quote
before Harass returns undialed fighters. Future returns cannot fund deficient
reserves or change quoted elite cost. This is explicitly provisional under
choose-recommended, not a new source ruling; original all-outcome fee and
score-only +2 remain. Own fixed/variable Occupy pools and Traitor cancellation
stay separate. Both physical cards discard once.
Eleven-file134/134, types/lint and production build pass with the existing
module-register/large-chunk/Unknown-route warnings. Six new behavioral cases
cover original typed reserve cost, eligibility, both slots, Traitor/explosion
and fixed/variable Occupy. Eight fresh Node programs exercise all four legal
policies/both modes and16 accepted continuation actions.
Actual390px controls seal original2-normal/1-elite payment and dial1/support1.
The +2 decisively beats opposing leader4; four ordinary forces return, but
the original elite cost still pays. Final reserves4/Tanks16/elite Tanks5,
spice29, both cards discarded and collection open. No root overflow or
browser/application error. Current parse/anchor and38 topic paths resolve;
all ten facets stay Partial. Obsolete pair rejection and absent-card pin
removed. Other gates remain; no human server, D1, saved room, deployment or
broad/HTTP/recovery assurance campaign changed.
[Combined policy and proof](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026).
## 16 October 2026 — Provisional post-Harass Stone comparison
Removed only the public Richese-family Harass guard. Under standing
+53 -2
View File
@@ -9,8 +9,9 @@ families; Basic/unprofiled co-side use stays guarded. 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.
fixes the exact card. Harass & Withdraw may share the plan under the
[provisional original-cost-first policy](#combined-utility-plan--16-october-2026).
Stone Burner, Homeworld and other optional-module combinations remain gated.
The [combined-army follow-up](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026)
retains own-holder reserve cost and selected lead card/payment ownership.
@@ -23,3 +24,53 @@ The holder must have three own reserve counters when sealing the plan. The owner
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, 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.
## Combined utility plan — 16 October 2026
The standing choose-recommended priority selects the existing native settlement
order for the two physical specials in one plan: quote and debit three
**original own reserve** counters before Harass returns any undialed counters.
This is an implementation interpretation, not a newly retrieved publisher FAQ
or an answer to the earlier reserve-payment question. The source audit's printed
effects and original provisional all-outcome/normal-first cost are unchanged.
Three reserves must exist at commitment. A future return cannot fund a deficient
plan or change its quoted normal/elite mix. For example, two original ordinary
and one elite reserve are spent even when Harass subsequently returns four
ordinary counters. Both cards discard once. The +2 remains normal-score-only,
not extra physical dial, support or winner casualties. Opposing successful
Traitor cancels only Harass; Reinforcements still pays under its existing
provisional all-outcome policy.
Both slot orders compose in the existing Basic/Advanced and selected Advanced
Occupy envelopes. Only the card user's reserves and undialed forces change;
original fixed Ecaz commitment and the other ally's pool stay separate.
Stone/Reinforcements, Basic/unprofiled co-side and other module gates stay.
No persisted state or fresh/public profile is added.
### Scoped proof
The affected eleven-file offline union passes **134/134**, with types/lint and
production build passing (module-register, large-chunk and Unknown-route
warnings). Six new behavioral regressions cover both slots, original typed
costs, deficient original reserves, all Traitor outcomes, explosion and both
active Occupy card-user roles. The test battle uses rock so automatic
collection income cannot obscure support accounting.
Fresh Node programs exercise eight Basic/Advanced cases across all four
profiles, both slot orders and16 accepted continuation actions. A real styled
390px journey starts at three reserves (two normal, one elite), selects both
cards and shows the explicit provisional order. Original dial1/support1 with
leader2 plus bonus2 beats opposing leader4. Four ordinary fighters return;
the original elite reserve is spent, leaving reserves4/Tanks16/elite Tanks5,
spice29 and no army, battle or decision. Both cards discard once; native
collection opens. No root overflow or browser/application error occurred.
Current guide parse, combined-policy anchor and38 evidence paths across the
two topics resolve; all ten checklist facets remain Partial. Removed the
obsolete pair rejection and an incidental absent-card candidate pin. No
human server, saved room, D1, deployment or public gate changed; no new broad,
HTTP, recovery/privacy/custody assurance campaign ran. Private proof artifacts
remain outside Git; the owned source host and throwaway scripts were removed.
+1 -1
View File
@@ -758,7 +758,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Boundary | Still unresolved | Existing record |
| --- | --- | --- |
| 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) |
| Reinforcements cost | The earlier all-outcome versus sole-Traitor-winner question remains unanswered. The bounded prototype provisionally charges on every revealed outcome, chooses normal reserves first and counts the transfer as battle losses. Under standing choose-recommended, same-plan Harass composes by paying that original reserve quote before returns; future returns cannot fund or change it. Those are product interpretations, not publisher rulings or a numerical user answer. Stone/Reinforcements and other module gates remain. Eligibility never depends on concealed matching Traitors. | [Source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Combined policy](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026) |
| Harass / Stone Burner | The earlier question (keep gated or compare after withdrawal) remains unanswered. Under the standing 14 October choose-recommended instruction, the development prototype now provisionally compares undialed counters remaining after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without reading hidden custody. Allocation ambiguity, Ix/Poison Tooth and other module boundaries remain. This is an implementation interpretation, not a publisher ruling or a user answer. | [Provisional timing](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) |
| Recruits late accounting and rates | The earlier one/two-further-free question remains unanswered. The content-first prototype now preserves paid spice, deducts recorded actual free usage from the doubled rate and all returns from the total cap: two-free/one-paid Atreides can take two more free. This is visibly provisional, not a numerical user ruling. Unknown ledgers, pending repricing, late rate changes and same-turn replay remain guarded. | [Source alternatives](RECRUITS_RULES.md), [provisional runtime](RECRUITS_RUNTIME.md) |
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and later battle timing stay guarded. The existing pre-plan opportunity supports [early aggressor](SAPHO_AGGRESSOR.md). Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
+1 -2
View File
@@ -411,9 +411,8 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
!(side === d && reinforcements.length),
'Reinforcements needs a unique physical card and valid weapon and defense slot pair.');
requireQuote(!input.homeworld && !(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length) &&
!slots.some(isHarassWithdraw) &&
!hasStone,
'Reinforcements cannot combine with Homeworld, leader skills, own Harass & Withdraw or Stone Burner.');
'Reinforcements cannot combine with Homeworld, leader skills or Stone Burner.');
const reserves = side.reinforcementsReserves!;
requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves),
'Reinforcements needs own typed reserves.');
+1 -2
View File
@@ -823,8 +823,7 @@ function plans(g: GameView): Action[] {
if ((!leader && (weapon || defense)) || !validBattleSlotPair(w, d, specialForLeader(w,leader)))
continue;
const reinforcements = weapon === 'ecaz-reinforcements' || defense === 'ecaz-reinforcements';
if (reinforcements && (weapon === 'ecaz-harass-withdraw' ||
defense === 'ecaz-harass-withdraw' || isStoneBurner(w))) continue;
if (reinforcements && isStoneBurner(w)) continue;
if (voice) {
const used =
playedVoiceMatch(
+1 -1
View File
@@ -121,7 +121,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
: ecaz.card.effect === 'harassWithdraw'
? 'Harass & Withdraw has development controls in either battle-card slot, with physical allocation choices after reveal. Stone Burner provisionally compares undialed forces remaining after successful withdrawal; additional optional modules remain guarded.'
: 'Reinforcements has development controls in either battle-card slot, including the explicit Advanced Occupy card variant. It spends three of the card user’s own reserve forces and adds +2 battle score without extra spice support. Charging that cost on every revealed outcome is provisional; same-plan Harass & Withdraw, Stone Burner and other optional modules remain guarded.',
: 'Reinforcements has development controls in either battle-card slot, including the explicit Advanced Occupy card variant. It spends three of the card user’s own reserve forces and adds +2 battle score without extra spice support. All-outcome payment and paying its original reserve cost before same-plan Harass returns are provisional. Returned forces cannot fund or change that cost. Stone Burner and other optional modules remain guarded.',
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')),
};
const topicIds =
+2 -2
View File
@@ -21252,8 +21252,8 @@ function validatePlan(
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
);
if ([w, d].some(isReinforcements)) {
requireRule(![w, d].some(card => isHarassWithdraw(card) || isStoneBurner(card)),
'Reinforcements cannot share this bounded plan with Harass & Withdraw or Stone Burner.');
requireRule(![w, d].some(isStoneBurner),
'Reinforcements cannot share this bounded plan with Stone Burner.');
currentReinforcementsCost(g, p);
}
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
+4 -3
View File
@@ -4950,15 +4950,15 @@ export const RULE_TOPICS: RuleTopic[] = [
'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. Classic/paired Ecaz/Moritani Basic/Advanced and explicitly selected native Advanced Occupy prototypes spend ordinary reserves first and then elite reserves, moving exactly three to corresponding Tanks subpools at resolution. These are the actual card holder’s reserves, not the variable ally’s pool; no extra forces arrive.',
'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.',
'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional interpretations, not publisher rulings. The explicit Advanced Occupy/independent-card profile is verified through native and human play; Basic/unprofiled co-side, own same-plan Harass/Reinforcements, Stone Burner and other optional modules remain guarded.',
'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional interpretations, not publisher rulings. Same-plan Harass & Withdraw provisionally pays the original three-reserve cost before undialed forces return; returned reserves cannot fund or change that quote. Basic/unprofiled co-side, Stone Burner and other optional modules remain guarded.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'card-harass-withdraw', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical card, both slots, own typed reserve cost, score-only bonus and single mandatory discard connect in classic/paired modes and explicit native Advanced Occupy. Selected lead cards/payment and variable ally casualties stay separate.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Physical card, both slots, own typed reserve cost, score-only bonus and mandatory discard connect in supported profiles. Same-plan Harass preserves the original reserve quote before return; selected lead cards/payment and variable ally casualties stay separate.', 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: 'Existing rule/control proofs plus combined-card67 cases, actual27-case program, four policies, natural combined games and390px decisive equal-leader Reinforcements cover the bounded profile. Full faction/optional-module acceptance remains open; comprehensive assurance follows all rules.', evidence: ['tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original rule/control proof and same-plan Harass regressions cover both slots, unfunded original reserves, typed cost-before-return, Traitor/explosion and fixed/variable Occupy. Actual mobile control and legal four-profile smoke are bounded evidence; complete modes, calibration and final assurance remain open.', evidence: ['tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
},
{
@@ -4975,6 +4975,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Discard Harass after use, including a win by your own Traitor call. Reveal-before-explosion return and card-specific discard precedence are explicit implementation inferences; neither is a dedicated combined FAQ ruling.',
'For multiple legal physical allocations, choose all undialed ordinary and elite counters by sector after both plans reveal and before traitor declarations. Unique returns are automatic. The selected return is public; it is not an extra sealed plan element. Stone Burner provisionally compares the undialed forces remaining after successful withdrawal, including same-plan, opposing and copied Stone. Additional optional modules remain guarded. The printed card forbids use on your own Homeworld.',
'In explicitly selected Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves its original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses its selected lead’s ordinary own pool. Returned reserves remain outside losses and Face Dance. Basic/unprofiled co-side, public starts and full acceptance remain gated.',
'In supported same-plan Reinforcements, the provisional order pays three original own reserves before Harass returns undialed counters. Future returns cannot fund or change that reserve quote; neither the +2 score bonus nor that cost adds to the physical commitment. Both cards discard after use. An opposing successful Traitor cancels Harass but not the provisional Reinforcements cost.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'ecaz-modules', 'implementation-checklist'],
checklist: [
-1
View File
@@ -30,7 +30,6 @@ void test('each AI profile can commit Harass in either slot through genuine Basi
assert.equal(sealed.battle!.plans.a[slot], 'ecaz-harass-withdraw');
harassCustody(sealed);
}
assert.ok(candidates.every(action => action.weapon !== 'ecaz-reinforcements' && action.defense !== 'ecaz-reinforcements'));
}
});
+156
View File
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction, viewGame, type Action, type Game} from '../game/engine';
import {finishHarassPreparation, harassCustody, harassWithdrawGame, takeHarassCard} from './fixture-harass-withdraw';
import {chooseEcazOccupyLead, ecazOccupyFixture, openEcazOccupyPlans} from './fixture-ecaz-occupy';
import {finishOccupyCardsAftermath, holdOccupyCardsTraitor} from './fixture-ecaz-occupy-cards';
const harass = 'ecaz-harass-withdraw';
const reinforcements = 'ecaz-reinforcements';
function fixture(advanced = false) {
// Rock has no collection income to obscure original battle support payments.
const game = harassWithdrawGame({advanced, factions: ['emperor', 'atreides', 'guild'],
territory: 'shield_wall', sector: 8});
takeHarassCard(game, 'a', reinforcements);
return game;
}
function ownPlan(game: Game, slot: 'weapon' | 'defense' = 'weapon'): Action {
return {type: 'battlePlan', dial: 1, support: game.advanced ? 1 : 0,
leader: 'emperor-4', weapon: slot === 'weapon' ? reinforcements : harass,
defense: slot === 'weapon' ? harass : reinforcements};
}
function seal(game: Game, slot: 'weapon' | 'defense' = 'weapon', other: Partial<Action> = {}) {
game = applyAction(game, 'a', ownPlan(game, slot));
return applyAction(game, 'd', {type: 'battlePlan', dial: 0, support: 0,
leader: 'atreides-2', ...other});
}
function finish(game: Game, calls: readonly string[] = []) {
for (const player of ['a', 'd']) game = applyAction(game, player,
{type: 'traitorCall', call: calls.includes(player)});
game = finishOccupyCardsAftermath(game);
harassCustody(game);
return game;
}
function assertDiscarded(game: Game) {
for (const id of [harass, reinforcements]) {
assert.equal(game.discard.filter(card => card.id === id).length, 1);
assert.ok(game.players.every(player => !player.hand.some(card => card.id === id)));
}
}
void test('both same-plan slot orders add the decisive bonus without changing Harass commitment, support or disposal', () => {
for (const advanced of [false, true]) for (const slot of ['weapon', 'defense'] as const) {
const game = fixture(advanced);
const done = finish(seal(game, slot));
assert.equal(done.lastBattleContext?.winner, 'a', 'dial1 + leader2 + bonus2 beats leader4');
assert.equal(done.players[0].reserves, 16, 'fifteen original reserves minus cost3 plus four returned');
assert.equal(done.players[0].tanks, 4, 'three reserve counters and only the one original dialed counter');
assert.deepEqual(done.players[0].forces, {});
assert.equal(done.players[0].spice, advanced ? 29 : 30);
assert.equal(done.players[0].leaders.find(leader => leader.id === 'emperor-4')!.dead, false);
assertDiscarded(done);
assert.equal(done.battle, null);
assert.equal(done.decision, null);
}
});
void test('returned ordinary counters cannot replace the original elite reserve cost', () => {
const game = fixture(true), owner = game.players[0];
owner.reserves = 3;
owner.tanks = 12;
owner.elites!.reserves = 1;
owner.elites!.tanks = 4;
harassCustody(game);
const preview = viewGame(game, 'a').battle!.reinforcements!;
assert.equal(preview.normal, 2);
assert.equal(preview.elite, 1);
const done = finish(seal(game));
assert.equal(done.players[0].reserves, 4);
assert.equal(done.players[0].elites!.reserves, 0);
assert.equal(done.players[0].elites!.tanks, 5, 'the original elite reserve was spent before ordinary returns');
assert.equal(done.players[0].tanks, 16);
assert.equal(done.players[0].spice, 29);
assertDiscarded(done);
});
void test('a future Harass return cannot fund an underfunded same-plan Reinforcements commitment', () => {
const game = fixture(true), owner = game.players[0];
owner.reserves = 2;
owner.tanks = 13;
owner.elites!.reserves = 1;
owner.elites!.tanks = 4;
harassCustody(game);
const before = structuredClone(game);
assert.throws(() => applyAction(game, 'a', ownPlan(game)), /three own forces in reserves/);
assert.deepEqual(game, before);
});
void test('own, opposing and mutual Traitors preserve the original Reinforcements cost and cancel only opposing Harass', () => {
for (const advanced of [false, true]) for (const calls of [['a'], ['d'], ['a', 'd']]) {
const game = fixture(advanced);
if (calls.includes('a')) holdOccupyCardsTraitor(game, 'a', 'atreides-2');
if (calls.includes('d')) holdOccupyCardsTraitor(game, 'd', 'emperor-4');
const done = finish(seal(game), calls);
const canceled = calls.includes('d');
assert.equal(done.lastBattleContext?.result, calls.length === 2 ? 'mutualTraitors' : 'traitor');
assert.equal(done.lastBattleContext?.winner, calls.length === 2 ? null : calls[0]);
assert.equal(done.players[0].reserves, canceled ? 12 : 16);
assert.equal(done.players[0].tanks, canceled ? 8 : 3);
assert.deepEqual(done.players[0].forces, canceled ? {} : {'shield_wall:8': 1});
const bounty = !canceled ? game.players[1].leaders.find(leader => leader.id === 'atreides-2')!.strength : 0;
assert.equal(done.players[0].spice, game.players[0].spice - (advanced && canceled ? 1 : 0) + bounty);
assertDiscarded(done);
}
});
void test('same-plan utility cards both pay and discard while Harass returns precede a Lasgun Shield explosion', () => {
for (const advanced of [false, true]) {
const game = fixture(advanced);
const laser = game.deck.find(card => card.kind === 'lasgun')!;
const shield = game.deck.find(card => card.kind === 'shield')!;
takeHarassCard(game, 'd', laser.id);
takeHarassCard(game, 'd', shield.id);
const done = finish(seal(game, 'weapon', {weapon: laser.id, defense: shield.id}));
assert.equal(done.lastBattleContext?.result, 'explosion');
assert.equal(done.lastBattleContext?.winner, null);
assert.equal(done.players[0].reserves, 16);
assert.equal(done.players[0].tanks, 4);
assert.deepEqual(done.players[0].forces, {});
assert.equal(done.players[1].tanks, 5);
assertDiscarded(done);
}
});
void test('active Occupy same-plan utilities pay only the actual card user and retain original fixed versus variable losses', () => {
for (const lead of ['ecaz', 'ally'] as const) {
const source = ecazOccupyFixture({ecazTreachery: true, allyFaction: 'guild', opponentFaction: 'emperor',
ecazForces: 5, allyForces: 4});
const owner = lead === 'ecaz' ? source.ecaz : source.ally;
takeHarassCard(source.game, owner, harass);
takeHarassCard(source.game, owner, reinforcements);
let game = finishHarassPreparation(openEcazOccupyPlans(chooseEcazOccupyLead(source, lead)));
const before = structuredClone(game);
const profile = viewGame(game, owner).battle!.ecazOccupy!.profile!;
const player = game.players.find(player => player.id === owner)!;
const other = game.players.find(player => player.id === source.opponent)!;
game = applyAction(game, owner, {type: 'battlePlan', dial: profile.fixedEcazDial + 1, support: 1,
leader: player.leaders.find(leader => !leader.dead && !leader.usedAt)!.id,
weapon: reinforcements, defense: harass});
game = applyAction(game, source.opponent, {type: 'battlePlan', dial: 0, support: 0,
leader: [...other.leaders].sort((a,b) => a.strength - b.strength)[0].id});
for (const voter of viewGame(game, owner).battle!.traitorVoters)
game = applyAction(game, voter, {type: 'traitorCall', call: false});
game = finishOccupyCardsAftermath(game);
assert.equal(game.lastBattleContext?.winner, owner);
const delta = (id: string, field: 'reserves' | 'tanks') =>
game.players.find(player => player.id === id)![field] - before.players.find(player => player.id === id)![field];
assert.equal(delta(owner, 'reserves'), lead === 'ecaz' ? -1 : 0);
assert.equal(delta(source.ecaz, 'tanks'), lead === 'ecaz' ? 6 : 3);
assert.equal(delta(source.ally, 'tanks'), lead === 'ally' ? 4 : 1);
assert.equal(game.players.find(player => player.id === source.ecaz)!.forces[source.location] ?? 0,
lead === 'ecaz' ? 0 : 2);
assert.equal(game.players.find(player => player.id === source.ally)!.forces[source.location] ?? 0,
lead === 'ally' ? 0 : 3);
assertDiscarded(game);
}
});
+1 -3
View File
@@ -143,7 +143,7 @@ void test('Reinforcements normal-first cost preserves elite subpool and is paid
assert.equal(done.discard.filter(c => c.id === cardId).length, 1);
});
void test('insufficient reserves, unsupported module, and a paired special reject without mutating the original game', () => {
void test('insufficient reserves and unsupported modules reject without mutating the original game', () => {
const g = fixture(true);
const p = g.players[0];
p.reserves = 2;
@@ -157,8 +157,6 @@ void test('insufficient reserves, unsupported module, and a paired special rejec
p.tanks = 0;
p.elites!.reserves = 5;
p.elites!.tanks = 0;
reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId,
defense: 'ecaz-harass-withdraw' }, /cannot share/);
g.sandtrout = {} as Game['sandtrout'];
assert.throws(() => applyAction(g, 'a',
{ type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }), RuleError);