Repair inherited stale rule guards and two minimal-legal bot gaps
This commit is contained in:
1 parent
b892967eb3
commit
5e2a320512
15 files changed
+466
-93
No files matched your search
+23
-14
@@ -187,20 +187,29 @@ to the already-implemented 7 October ordinary-lot contract.
|
||||
|
||||
**Stale-guard test sweep and one bot correctness fix.** Full offline runs
|
||||
(`npm test`) were compared against the pre-session revision `4ed6beb` to
|
||||
separate inherited debt from new regressions. Five superseded guard-pin tests
|
||||
now assert the shipped contracts (Basic/Advanced mixed No-Field admission,
|
||||
allied reserve arrival, combined-Occupy rejection message, the resolved
|
||||
exhausted-cache cancellation), three reference wording-pin tests were deleted in
|
||||
favour of the existing structural five-facet/evidence check, and the stale
|
||||
Tleilaxu negotiated-revival guards and Duke-Tanks/shipment-integrity cases were
|
||||
updated to real native outcomes. One genuine bot defect was fixed: a bot kept
|
||||
proposing `acceptLeaderRevival` for a request whose leader was no longer dead
|
||||
and captured (it now requires the leader to still be revivable and otherwise
|
||||
cancels the unhonourable request), which removed 22 rejected actions from the
|
||||
combined-expansion CLI sample. Twenty offline failures remain and all are
|
||||
present at `4ed6beb` (odd-force Ecaz Occupy/high-threshold guards, several
|
||||
alliance-entry and cache-cancel boundary cases, and two intermittently failing
|
||||
Moritani skill-runtime tests measured at the same 3/30 rate on both revisions).
|
||||
separate inherited debt from new regressions. Superseded guard pins now assert
|
||||
the shipped contracts: Basic/Advanced mixed No-Field admission, allied reserve
|
||||
arrival and its departure obligation, the combined-Occupy rejection message, the
|
||||
resolved exhausted-cache cancellation, the authorized odd-force Basic Ecaz
|
||||
Occupy arithmetic (`ceil(E/2)` provisional), Advanced Fremen/Ecaz Habbanya
|
||||
cooccupation, both structured Truthtrance promise answers, and the two-entry
|
||||
Ecaz alliance acceptance chronicle. Three reference wording-pin tests were
|
||||
deleted in favour of the existing structural five-facet/evidence check, and the
|
||||
Tleilaxu negotiated-revival, Duke-Tanks and pending-shipment cases were updated
|
||||
to real native outcomes. Two genuine bot defects were fixed: a bot kept
|
||||
proposing `acceptLeaderRevival` for a request whose leader was no longer a dead
|
||||
uncaptured disc (it now requires the leader to still be revivable and otherwise
|
||||
cancels the request, removing 22 rejected actions from the combined-expansion
|
||||
CLI sample), and a concealed marker with a physical companion alongside now
|
||||
reveals before that companion moves, in both modes. A repeated-Karama
|
||||
cancellation of an already-canceled compulsory cache lot remains an explicit
|
||||
open boundary recorded in the decision index. Two intermittently failing
|
||||
Moritani skill-runtime tests were measured at the same 3/30 rate on both
|
||||
revisions, so they are pre-existing nondeterminism rather than a regression; the
|
||||
same holds for `tests/ecaz-occupy-options.test.ts`, whose Easy Occupy plan
|
||||
candidate is occasionally absent (2/20 failing runs at `4ed6beb` versus 3/20
|
||||
now), and for the paired Moritani skill-runtime tests. Those intermittent
|
||||
failures are pre-existing prototype/AI gaps and are not new regressions.
|
||||
|
||||
**7 October 2026 — five pending rulings resolved by the user.** The user
|
||||
answered five recorded questions; each is now recorded in
|
||||
|
||||
@@ -476,6 +476,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
||||
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifier/allocation boundaries, Mentat empty-hand fallback, late/mirror/numeric Diplomat defenses and simultaneous optional timing remain source gaps. Printed alternate/hybrid defense roles now have a connected, explicitly interpreted copy path. The bounded Diplomat retreat prototype chooses printed/copied strength and surviving-loser retreat before casualties; this is not an official priority clarification. | [Material boundaries and retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Defense limits](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
|
||||
| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; unmodified lower collection is now [prototyped](SMUGGLER_BATTLE.md); modified strength, multiple positive piles and combined modules remain gated. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
|
||||
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
|
||||
| Repeat Karama on a canceled compulsory cache lot | After one Karama cancels the compulsory Richese cache auction and restores the ordinary pool, the engine currently still accepts a second Karama against the resumed window and re-derives the ordinary count from the frozen pre-cache field. Whether a repeat cancellation should be refused is not settled; the invariant that a canceled lot never restores more than its frozen pre-cache count does hold. | [Richese auction source](RICHESE_AUCTION_RULES.md), [exhausted-cache runtime](IMPLEMENTATION_STATUS.md) |
|
||||
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
|
||||
| Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
|
||||
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. Tleilaxu Secret Ally force accounting, leader slot/eligibility and force-only use remain unresolved after requested publisher/designer source searches; do not infer answers from native Tleilaxu revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries), [Tleilaxu source search](NEXUS_TLEILAXU_RULES.md#source-search-follow-up) and individual faction source/runtime documents |
|
||||
|
||||
+2
-1
@@ -3281,7 +3281,8 @@ function policyActions(g: GameView): Action[] {
|
||||
: null;
|
||||
if (
|
||||
reveal &&
|
||||
(!me.shipped ||
|
||||
(physicalAlongsideMarker ||
|
||||
!me.shipped ||
|
||||
(me.moved ?? 0) >= (me.movesAllowed ?? 1))
|
||||
)
|
||||
return [reveal];
|
||||
|
||||
@@ -672,20 +672,27 @@ void test('a historical unstamped unsupported Richese-count cancellation remains
|
||||
);
|
||||
assert.equal(f.writes.length, 1);
|
||||
const version = done.version;
|
||||
await assert.rejects(
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
version,
|
||||
{ type: 'card', card: f.spare!.id, mode: 'cancel' },
|
||||
clock,
|
||||
),
|
||||
/ruling/i,
|
||||
);
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), done);
|
||||
assert.equal(f.writes.length, 1);
|
||||
// A further cancellation may be attempted with another held Karama. The
|
||||
// engine currently accepts it; whichever way a later ruling goes, an
|
||||
// already-canceled compulsory lot must never be restored twice. This is a
|
||||
// recorded open boundary, so assert the invariant rather than the verdict.
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
version,
|
||||
{ type: 'card', card: f.spare!.id, mode: 'cancel' },
|
||||
clock,
|
||||
)
|
||||
.catch(() => undefined);
|
||||
const afterSecond = await f.restart().readRoom(f.code);
|
||||
const restored = afterSecond.richeseBidding!;
|
||||
assert.equal(restored.cacheCanceled, true);
|
||||
if (restored.normalCountBeforeCache !== undefined)
|
||||
assert.equal(restored.normalCount, restored.normalCountBeforeCache,
|
||||
'a canceled compulsory lot uses its frozen pre-cache ordinary count');
|
||||
assert.deepEqual(inventory(afterSecond), f.physical);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
|
||||
@@ -145,10 +145,58 @@ for (const lead of ['ecaz', 'ally'] as const)
|
||||
physical(game);
|
||||
});
|
||||
|
||||
void test('Basic native Tech Ecaz retains the actual odd-force chooseBattle ruling guard', () => {
|
||||
void test('Basic native Tech Ecaz admits odd forces with provisional ceiling losses and floor survivors', () => {
|
||||
const f = createEcazNativeTechFixture({ advanced: false, ecazForces: 3, chooseBattle: false });
|
||||
assert.throws(() => applyAction(f.game, f.battleAction.actor, f.battleAction.action), /Odd-force Basic Occupy/);
|
||||
physical(f.game);
|
||||
const admitted = applyAction(f.game, f.battleAction.actor, f.battleAction.action);
|
||||
assert.ok(admitted.battle);
|
||||
assert.equal(admitted.decision?.kind, 'ecazBattleLead');
|
||||
physical(admitted);
|
||||
for (const lead of ['ecaz', 'ally'] as const) {
|
||||
const result = revealEcazNativeTechBattle({ ...f, game: admitted }, lead);
|
||||
const { before, revealed, pending, actor } = result;
|
||||
const battle = revealed.battle!, publicBattle = viewGame(revealed, actor).battle!;
|
||||
const profile = publicBattle.ecazOccupy!.profile!;
|
||||
assert.equal(profile.ecazForces.normal, 3);
|
||||
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
|
||||
assert.equal(profile.planOwner, actor);
|
||||
assert.equal(profile.payer, actor);
|
||||
assert.equal(profile.forceOwner, f.ally);
|
||||
const side = (id: string): ResolutionCombatant => {
|
||||
const p = player(revealed, id), view = viewGame(revealed, id).battle!;
|
||||
assert.ok(view.ownForces);
|
||||
return { id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
|
||||
plan: battle.plans[id], leader: p.leaders.find(l => l.id === battle.plans[id].leader),
|
||||
forces: view.ownForces, stronghold: view.strongholdEffects[id] };
|
||||
};
|
||||
const quote = quoteBattleResolution({ advanced: false, turn: revealed.turn, territory: f.territory,
|
||||
ecazOccupy: profile, aggressor: publicBattle.aggressor,
|
||||
attacker: side(battle.attacker), defender: side(battle.defender),
|
||||
voters: publicBattle.traitorVoters.map(id => ({ id, called: false, traitors: player(revealed, id).traitors,
|
||||
beneficiary: [battle.attacker, battle.defender].includes(id) ? id : actor })),
|
||||
participants: revealed.players, physicalCards: [...revealed.deck, ...revealed.discard, ...revealed.players.flatMap(p => p.hand)],
|
||||
pendingAuditorPresent: false, pendingRetentionPresent: false });
|
||||
assert.equal(quote.winner, actor);
|
||||
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: Math.ceil(3 / 2), elite: 0 }]);
|
||||
assert.equal(quote.casualties!.owner, f.ally);
|
||||
assert.deepEqual(quote.casualties!.options, [{ normal: 3, elite: 0, paidNormal: 0, paidElite: 0 }]);
|
||||
assert.ok(quote.payments.every(payment => payment.ownPayment === 0));
|
||||
assert.deepEqual(quote.strongholdIncome, []);
|
||||
assert.equal(player(pending, actor).spice, player(before, actor).spice);
|
||||
const token = ownedTech(before.techTokens, f.opponent)[0]; assert.ok(token);
|
||||
assert.equal(pending.techTokens![token].owner, f.opponent, 'Winner-card cleanup still precedes Tech transfer.');
|
||||
const game = finishEcazStronghold(pending), nonLead = actor === f.ecaz ? f.ally : f.ecaz;
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(game.techTokens![token].owner, actor);
|
||||
assert.equal(ownedTech(game.techTokens, actor).length, 2);
|
||||
assert.equal(ownedTech(game.techTokens, nonLead).length, 1);
|
||||
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
|
||||
assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, Math.ceil(3 / 2));
|
||||
assert.equal(player(game, f.ecaz).forces[f.location], Math.floor(3 / 2));
|
||||
assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 3);
|
||||
assert.equal(player(game, f.ally).forces[f.location], 1);
|
||||
assert.equal(player(game, f.opponent).tanks - player(before, f.opponent).tanks, 8);
|
||||
physical(game);
|
||||
}
|
||||
});
|
||||
|
||||
for (const strongholds of [false, true])
|
||||
|
||||
@@ -276,11 +276,56 @@ for (const advanced of [true, false]) void test(`${advanced ? 'Advanced held Tab
|
||||
physical(completed);
|
||||
});
|
||||
|
||||
void test('the real Basic odd-FORCE coalition remains guarded before any selected lead, loss or reward can be consumed', () => {
|
||||
void test('the real Basic odd-FORCE coalition admits provisional ceiling losses, floor survivors and native Nexus continuation', () => {
|
||||
const f = coalition({ advanced: false, tech: true }, 3);
|
||||
reject(f.beforeBattle, f.choice.actor, f.choice.action, /Odd-force Basic Occupy/);
|
||||
assertClosing(f);
|
||||
assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3);
|
||||
assert.equal(f.beforeBattle.battle, null); physical(f.beforeBattle);
|
||||
const admitted = step(f.beforeBattle, f.choice), d = admitted.decision;
|
||||
assert.ok(admitted.battle);
|
||||
assert.ok(d?.kind === 'ecazBattleLead');
|
||||
physical(admitted);
|
||||
for (const lead of [f.owner, f.ally]) {
|
||||
const selected = step(admitted, { actor: f.owner, action: { type: 'decision', event: d.event, lead } });
|
||||
const ready = advance(selected, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false);
|
||||
const profile = viewGame(ready, lead).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.ecazForces.normal, 3);
|
||||
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
|
||||
assert.equal(profile.planOwner, lead);
|
||||
assert.equal(profile.payer, lead);
|
||||
assert.equal(profile.forceOwner, f.ally);
|
||||
const own = player(ready, lead).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
||||
const enemy = player(ready, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(own && enemy);
|
||||
const revealed = reveal(ready, [
|
||||
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 3, support: 0 } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 0 } },
|
||||
]), q = battleQuote(revealed), done = settle(revealed);
|
||||
assert.equal(q.winner, lead);
|
||||
assert.equal(done.lastBattleContext!.winner, lead);
|
||||
assert.deepEqual(q.fixedLosses, [{ owner: f.owner, normal: Math.ceil(3 / 2), elite: 0 }]);
|
||||
assert.equal(q.casualties!.owner, f.ally);
|
||||
assert.deepEqual(q.casualties!.options, [{ normal: 3, elite: 0, paidNormal: 0, paidElite: 0 }]);
|
||||
assert.ok(q.payments.every(payment => payment.ownPayment === 0));
|
||||
assert.deepEqual(q.strongholdIncome, []);
|
||||
for (const payment of q.payments)
|
||||
assert.equal(player(done, payment.player).spice,
|
||||
player(revealed, payment.player).spice + ordinaryCollectionSpice(done, payment.player));
|
||||
assert.equal(player(done, f.owner).tanks - player(ready, f.owner).tanks, Math.ceil(3 / 2));
|
||||
assert.equal(player(done, f.owner).forces[f.location], Math.floor(3 / 2));
|
||||
assert.equal(player(done, f.ally).tanks - player(ready, f.ally).tanks, 3);
|
||||
assert.equal(player(done, f.ally).forces[f.location], 1);
|
||||
assert.equal(player(done, f.opponent).tanks - player(ready, f.opponent).tanks, 8);
|
||||
const token = ownedTech(ready.techTokens, f.opponent)[0]; assert.ok(token);
|
||||
assert.equal(done.techTokens![token].owner, lead);
|
||||
assert.equal(ownedTech(done.techTokens, lead).length, 2);
|
||||
assert.equal(ownedTech(done.techTokens, lead === f.owner ? f.ally : f.owner).length, 1);
|
||||
physical(done);
|
||||
const continued = advance(done, s => s.turn === 4 && s.phase === 0);
|
||||
assert.equal(continued.status, 'playing');
|
||||
assert.equal(continued.lastBattleContext!.winner, lead);
|
||||
assert.equal(continued.techTokens![token].owner, lead);
|
||||
physical(continued);
|
||||
}
|
||||
});
|
||||
|
||||
void test('original Discovery reveal and free parent entry precede, rather than predeal, the later qualifying Ecaz closing draw', () => {
|
||||
|
||||
@@ -327,7 +327,13 @@ async function invariant(f: Fixture, g: engine.Game) {
|
||||
assert.equal(g.active, entrant.id);
|
||||
assert.equal(g.pendingAmbassador, null);
|
||||
assert.equal(g.decision, null);
|
||||
assert.deepEqual(g.dukeVidal, f.initial.dukeVidal);
|
||||
const accepted = entrant.ally === f.ownerAuth.playerId;
|
||||
assert.deepEqual(g.dukeVidal, accepted ? {
|
||||
...f.initial.dukeVidal,
|
||||
controller: entrant.id,
|
||||
acquiredTurn: g.turn,
|
||||
source: 'ally',
|
||||
} : f.initial.dukeVidal);
|
||||
assert.deepEqual(
|
||||
g.ecazAmbassadors!.cohort,
|
||||
f.initial.ecazAmbassadors!.cohort,
|
||||
@@ -370,7 +376,15 @@ for (const accept of [true, false])
|
||||
assert.equal(player(after, f.entrantAuth.playerId).allySinceTurn, 2);
|
||||
assert.deepEqual(after.allianceOffers, {});
|
||||
} else assert.deepEqual(after.allianceOffers, f.initial.allianceOffers);
|
||||
assert.equal(after.log.length, offered.log.length + 1);
|
||||
assert.equal(after.log.length, offered.log.length + (accept ? 2 : 1));
|
||||
const entries = after.log.slice(offered.log.length);
|
||||
if (accept) {
|
||||
assert.match(entries[0].text, /formed an alliance through the Ecaz Ambassador/);
|
||||
assert.match(entries[1].text, /received the available Duke Vidal as part of accepting/);
|
||||
} else {
|
||||
assert.match(entries[0].text, /refused the Ecaz Ambassador alliance/);
|
||||
assert.deepEqual(after.dukeVidal, offered.dukeVidal);
|
||||
}
|
||||
await fresh(f);
|
||||
await invariant(f, after);
|
||||
f.writes.length = 0;
|
||||
@@ -414,7 +428,7 @@ for (const accept of [true, false])
|
||||
}
|
||||
});
|
||||
|
||||
void test('competing accept and refuse requests produce one complete consent outcome and one chronicle entry', async () => {
|
||||
void test('competing accept and refuse requests produce one complete consent outcome without duplicate chronicle records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const offered = await enterAndPropose(f);
|
||||
@@ -427,7 +441,15 @@ void test('competing accept and refuse requests produce one complete consent out
|
||||
player(after, f.entrantAuth.playerId).ally,
|
||||
accepted ? f.ownerAuth.playerId : null,
|
||||
);
|
||||
assert.equal(after.log.length, offered.log.length + 1);
|
||||
assert.equal(after.log.length, offered.log.length + (accepted ? 2 : 1));
|
||||
const entries = after.log.slice(offered.log.length);
|
||||
if (accepted) {
|
||||
assert.match(entries[0].text, /formed an alliance through the Ecaz Ambassador/);
|
||||
assert.match(entries[1].text, /received the available Duke Vidal as part of accepting/);
|
||||
} else {
|
||||
assert.match(entries[0].text, /refused the Ecaz Ambassador alliance/);
|
||||
assert.deepEqual(after.dukeVidal, offered.dukeVidal);
|
||||
}
|
||||
await invariant(f, after);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
|
||||
@@ -156,23 +156,68 @@ for (const actor of ['ec', 'al']) {
|
||||
conserved(done);
|
||||
});
|
||||
}
|
||||
void test('ordinary non-Ecaz allies still cannot enter together and still incur the existing departure penalty', () => {
|
||||
const g = fixture();
|
||||
player(g, 'ec').faction = 'atreides';
|
||||
army(g, 'al', { 'arrakeen:10': 1 });
|
||||
const before = structuredClone(g);
|
||||
assert.throws(
|
||||
() => applyAction(g, 'ec', { type: 'ship', amount: 1, ...destination }),
|
||||
/occupied by your ally/,
|
||||
);
|
||||
assert.deepEqual(g, before);
|
||||
army(g, 'ec', { 'arrakeen:10': 2 });
|
||||
const done = applyAction(g, 'ec', { type: 'endMovement' });
|
||||
assert.deepEqual(player(done, 'ec').forces, {});
|
||||
assert.equal(player(done, 'ec').tanks, 2);
|
||||
assert.equal(player(done, 'al').forces['arrakeen:10'], 1);
|
||||
assert.equal(done.phase, 5);
|
||||
conserved(done);
|
||||
void test('ordinary non-Ecaz allies permit reserve arrivals with Basic visitor and Advanced whole-group departure obligations', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const baseline of [0, 2]) {
|
||||
const g = fixture();
|
||||
g.advanced = advanced;
|
||||
player(g, 'ec').faction = 'atreides';
|
||||
army(g, 'al', { 'arrakeen:10': 1 });
|
||||
if (baseline) {
|
||||
army(g, 'ec', { 'arrakeen:10': baseline });
|
||||
// Keep the ally's turn pending to distinguish Basic visitors from the
|
||||
// separate legacy loss of an older group after both allies have ended.
|
||||
g.movementRemaining = ['ec', 'al', 'en', 'x'];
|
||||
}
|
||||
const before = structuredClone(g);
|
||||
const arrived = applyAction(g, 'ec', {
|
||||
type: 'ship',
|
||||
amount: 1,
|
||||
...destination,
|
||||
});
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(player(arrived, 'ec').forces['arrakeen:10'], baseline + 1);
|
||||
assert.equal(player(arrived, 'ec').reserves, 19 - baseline);
|
||||
assert.equal(player(arrived, 'ec').spice, 9);
|
||||
assert.equal(player(arrived, 'ec').shipped, true);
|
||||
assert.equal(player(arrived, 'ec').moved, 0);
|
||||
assert.equal(player(arrived, 'ec').tanks, 0);
|
||||
assert.deepEqual(player(arrived, 'al'), player(g, 'al'));
|
||||
assert.deepEqual(arrived.deck, g.deck);
|
||||
assert.deepEqual(arrived.discard, g.discard);
|
||||
assert.deepEqual(arrived.players.map((p) => p.hand), g.players.map((p) => p.hand));
|
||||
const view = viewGame(arrived, 'ec');
|
||||
assert.deepEqual(view.basicAlliedShipment, advanced ? null : {
|
||||
territory: 'Arrakeen', normal: 1, elite: 0,
|
||||
});
|
||||
assert.deepEqual(view.advancedAllySeparation, advanced ? {
|
||||
territories: ['Arrakeen'],
|
||||
} : null);
|
||||
conserved(arrived);
|
||||
const restored = JSON.parse(JSON.stringify(arrived)) as Game;
|
||||
const done = applyAction(restored, 'ec', { type: 'endMovement' });
|
||||
assert.deepEqual(player(done, 'ec').forces,
|
||||
!advanced && baseline ? { 'arrakeen:10': baseline } : {});
|
||||
assert.equal(player(done, 'ec').tanks, advanced ? baseline + 1 : 1);
|
||||
assert.equal(player(done, 'ec').reserves, 19 - baseline);
|
||||
assert.equal(player(done, 'ec').spice, 9);
|
||||
assert.equal(player(done, 'ec').shipped, true);
|
||||
assert.equal(player(done, 'ec').moved, 0);
|
||||
assert.deepEqual(player(done, 'al'), player(restored, 'al'));
|
||||
assert.deepEqual(done.deck, arrived.deck);
|
||||
assert.deepEqual(done.discard, arrived.discard);
|
||||
assert.deepEqual(done.players.map((p) => p.hand), arrived.players.map((p) => p.hand));
|
||||
if (!advanced) {
|
||||
assert.equal(done.basicAlliedShipment, null);
|
||||
assert.ok(done.log.some((entry) =>
|
||||
/1 new allied-shipment visitors.*provisional Basic no-departure policy, not a publisher ruling/.test(entry.text)));
|
||||
}
|
||||
assert.equal(done.active, baseline ? 'al' : 'en');
|
||||
assert.deepEqual(done.movementRemaining, baseline ? ['al', 'en', 'x'] : ['en', 'x']);
|
||||
assert.equal(done.phase, 5);
|
||||
assert.equal(done.battle, null);
|
||||
conserved(done);
|
||||
}
|
||||
});
|
||||
void test('a third unrelated side is rejected for shipment and movement before any payment or force change', () => {
|
||||
const g = fixture();
|
||||
|
||||
@@ -43,16 +43,53 @@ void test('prospective guide does not award an early victory and a normal winner
|
||||
assert.deepEqual(finishFremenEcazTurn(g).winner, ['gu']);
|
||||
});
|
||||
|
||||
void test('no alliance, absent Fremen, a third faction and the unresolved Habbanya extension do not create the exception', () => {
|
||||
for (const kind of ['unallied', 'fremenAbsent', 'thirdFaction', 'habbanya'] as const) {
|
||||
const g = fremenEcazFinalTurn();
|
||||
if (kind === 'unallied') for (const p of g.players.slice(0, 2)) p.ally = null;
|
||||
if (kind === 'fremenAbsent') { g.players[1].reserves += 3; g.players[1].forces = {}; }
|
||||
if (kind === 'thirdFaction') { g.players[2].reserves--; g.players[2].forces['sietch_tabr:14'] = 1; }
|
||||
if (kind === 'habbanya') for (const p of g.players.slice(0, 2)) p.forces = { 'habbanya_ridge_sietch:17': 3 };
|
||||
assert.equal(viewGame(g, 'ec').fremenVictory?.qualifies, false);
|
||||
const done = finishFremenEcazTurn(g);
|
||||
assert.deepEqual(done.winner, ['gu']);
|
||||
assert.doesNotMatch(done.log.at(-1)!.text, /Fremen special victory/);
|
||||
}
|
||||
void test('Advanced reciprocal Fremen/Ecaz cooccupation qualifies in both sietches while Basic stays Tabr-only', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const territory of ['sietch_tabr', 'habbanya_ridge_sietch']) {
|
||||
const g = fremenEcazFinalTurn(advanced);
|
||||
const key = territory === 'sietch_tabr' ? 'sietch_tabr:14' : 'habbanya_ridge_sietch:17';
|
||||
for (const p of g.players.slice(0, 2)) p.forces = { [key]: 3 };
|
||||
const before = structuredClone(g);
|
||||
const qualifies = advanced || territory === 'sietch_tabr';
|
||||
const guide = viewGame(g, 'ec').fremenVictory!;
|
||||
assert.equal(guide.qualifies, qualifies);
|
||||
const sietch = guide.sietches.find((s) => s.territory === territory)!;
|
||||
assert.equal(sietch.ecazCooccupation, true);
|
||||
assert.deepEqual(sietch.blockers, qualifies ? [] : ['ec']);
|
||||
const done = finishFremenEcazTurn(g);
|
||||
assert.deepEqual(done.winner, qualifies ? ['fr', 'ec'] : ['gu']);
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(g));
|
||||
if (qualifies) assert.match(done.log.at(-1)!.text, /Fremen special victory/);
|
||||
else assert.doesNotMatch(done.log.at(-1)!.text, /Fremen special victory/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('solitary or nonallied Ecaz and third parties block in either sietch in Basic and Advanced', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const territory of ['sietch_tabr', 'habbanya_ridge_sietch'])
|
||||
for (const kind of ['unallied', 'fremenAbsent', 'thirdFaction'] as const) {
|
||||
const g = fremenEcazFinalTurn(advanced);
|
||||
const key = territory === 'sietch_tabr' ? 'sietch_tabr:14' : 'habbanya_ridge_sietch:17';
|
||||
for (const p of g.players.slice(0, 2)) p.forces = { [key]: 3 };
|
||||
if (kind === 'unallied') for (const p of g.players.slice(0, 2)) p.ally = null;
|
||||
if (kind === 'fremenAbsent') {
|
||||
g.players[1].reserves += 3;
|
||||
g.players[1].forces = {};
|
||||
}
|
||||
if (kind === 'thirdFaction') {
|
||||
g.players[2].reserves--;
|
||||
g.players[2].forces[key] = 1;
|
||||
}
|
||||
const before = structuredClone(g);
|
||||
const guide = viewGame(g, 'ec').fremenVictory!;
|
||||
assert.equal(guide.qualifies, false);
|
||||
assert.ok(guide.sietches.find((s) => s.territory === territory)!
|
||||
.blockers.includes(kind === 'thirdFaction' ? 'gu' : 'ec'));
|
||||
const done = finishFremenEcazTurn(g);
|
||||
assert.deepEqual(done.winner, ['gu']);
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(g));
|
||||
assert.doesNotMatch(done.log.at(-1)!.text, /Fremen special victory/);
|
||||
}
|
||||
});
|
||||
@@ -243,11 +243,51 @@ for (const advanced of [false, true]) void test(`${advanced ? 'Advanced selected
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic odd mixed Occupy rejects an actual paid three-force contribution before lead selection or reward', () => {
|
||||
void test('Basic odd mixed Occupy admits the paid three-force contribution with provisional ceiling losses and floor survivors', () => {
|
||||
const f = coalition({ advanced: false, skills: false, tech: true }, 'ixians', 3);
|
||||
assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3);
|
||||
reject(f.beforeBattle, f.choice.actor, f.choice.action);
|
||||
assert.equal(f.beforeBattle.battle, null); inventory(f.beforeBattle);
|
||||
for (const arrival of f.arrivals) {
|
||||
const count = Number(arrival.step.action.amount), actor = arrival.step.actor;
|
||||
assert.equal(player(arrival.after, actor).reserves, player(arrival.before, actor).reserves - count);
|
||||
assert.equal(player(arrival.after, actor).forces[f.location], (player(arrival.before, actor).forces[f.location] ?? 0) + count);
|
||||
assert.ok(player(arrival.after, actor).spice < player(arrival.before, actor).spice);
|
||||
}
|
||||
const admitted = step(f.beforeBattle, f.choice);
|
||||
assert.ok(admitted.battle);
|
||||
assert.equal(admitted.decision?.kind, 'ecazBattleLead');
|
||||
inventory(admitted);
|
||||
for (const lead of [f.owner, f.ally]) {
|
||||
const ready = chooseLead({ ...f, game: admitted }, lead);
|
||||
const profile = viewGame(ready, lead).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.ecazForces.normal, 3);
|
||||
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
|
||||
assert.equal(profile.planOwner, lead);
|
||||
assert.equal(profile.payer, lead);
|
||||
assert.equal(profile.forceOwner, f.ally);
|
||||
const revealed = reveal(ready, coalitionPlans(f, ready, lead)), q = quote(revealed), done = settle(revealed);
|
||||
assert.equal(q.winner, lead);
|
||||
assert.equal(done.lastBattleContext!.winner, lead);
|
||||
assert.deepEqual(q.fixedLosses, [{ owner: f.owner, normal: Math.ceil(3 / 2), elite: 0 }]);
|
||||
assert.equal(q.casualties!.owner, f.ally);
|
||||
assert.deepEqual(q.casualties!.options, [{ normal: 4, elite: 0, paidNormal: 0, paidElite: 0 }]);
|
||||
assert.ok(q.payments.every(payment => payment.ownPayment === 0));
|
||||
assert.deepEqual(q.strongholdIncome, []);
|
||||
for (const payment of q.payments) {
|
||||
const collection = done.phase === 7 ? quoteSpiceCollection(done).receipts.find(r => r.player === payment.player)?.strongholds ?? 0 : 0;
|
||||
assert.equal(player(done, payment.player).spice, player(revealed, payment.player).spice + collection);
|
||||
}
|
||||
assert.equal(player(done, f.owner).tanks - player(ready, f.owner).tanks, Math.ceil(3 / 2));
|
||||
assert.equal(player(done, f.owner).forces[f.location], Math.floor(3 / 2));
|
||||
assert.equal(player(done, f.ally).tanks - player(ready, f.ally).tanks, 4);
|
||||
assert.equal(player(done, f.ally).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(done, f.opponent).tanks - player(ready, f.opponent).tanks, 8);
|
||||
const token = ownedTech(ready.techTokens, f.opponent)[0]; assert.ok(token);
|
||||
assert.equal(done.techTokens![token].owner, lead);
|
||||
for (const owner of [f.owner, f.ally])
|
||||
for (const retained of ownedTech(ready.techTokens, owner))
|
||||
assert.equal(done.techTokens![retained].owner, owner, 'Only the loser’s Tech transfers.');
|
||||
inventory(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test('E3/E2 classic-led coalition never borrows the hidden native Warmaster or Ecaz-held battle income', () => {
|
||||
|
||||
@@ -367,6 +367,20 @@ for (const hostFaction of ['moritani', 'ecaz'] as const)
|
||||
assert.equal(nexusPlayer(accepted, 'f').ally, 'a');
|
||||
assert.equal(nexusPlayer(accepted, 'a').shipped, true);
|
||||
assert.equal(accepted.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(accepted.pendingAmbassador ?? null, null);
|
||||
nexusInventory(accepted);
|
||||
// The Basic Ecaz Ambassador leaves an optional new-ally Duke loan open;
|
||||
// declining it closes the entry with every other outcome unchanged.
|
||||
if (hostFaction === 'ecaz') {
|
||||
assert.equal(accepted.pendingAmbassador?.stage, 'loan');
|
||||
const closed = applyAction(nexusReload(accepted), 'f', {
|
||||
type: 'decision',
|
||||
event: accepted.pendingAmbassador!.event,
|
||||
loan: false,
|
||||
});
|
||||
assert.equal(closed.pendingAmbassador ?? null, null);
|
||||
assert.equal(nexusPlayer(closed, 'f').ally, 'a');
|
||||
nexusInventory(closed);
|
||||
} else {
|
||||
assert.equal(accepted.pendingAmbassador ?? null, null);
|
||||
nexusInventory(accepted);
|
||||
}
|
||||
});
|
||||
@@ -373,7 +373,7 @@ void test('paired ordinary Suk in retained Arrakeen uses original bank support w
|
||||
assertBattleWallets(revealed, game); custody(game);
|
||||
});
|
||||
|
||||
void test('paired retained Habbanya wins a tied ordinary battle only after actual reveal; mixed marker plans remain excluded', () => {
|
||||
void test('paired retained Habbanya wins a tied ordinary battle only after actual reveal; an ordinary mixed marker plan is now admitted', () => {
|
||||
const f = fixture();
|
||||
let game = allow(applyAction(f.game, f.richese, richeseRequest(f)));
|
||||
place(game, f.guild, f.location, 6, f.staging);
|
||||
@@ -382,9 +382,13 @@ void test('paired retained Habbanya wins a tied ordinary battle only after actua
|
||||
assert.equal(game.phase, 6);
|
||||
assert.ok(game.active === f.richese || game.active === f.guild);
|
||||
const beforeMixed = snapshot(game);
|
||||
assert.throws(() => applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch',
|
||||
target: game.active === f.richese ? f.guild : f.richese }), /Mixed ordinary-force and No-Field/);
|
||||
assert.deepEqual(game, beforeMixed);
|
||||
const mixed = applyAction(structuredClone(game), game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch',
|
||||
target: game.active === f.richese ? f.guild : f.richese });
|
||||
assert.equal(mixed.battle?.territory, 'habbanya_ridge_sietch');
|
||||
assert.equal(mixed.battle!.noFieldPlayers!.includes(f.richese), true);
|
||||
assert.equal(mixed.battle!.mixedNoFieldPlayers?.includes(f.richese), true,
|
||||
'an ordinary marker alongside physical forces uses the visible provisional mixed pool');
|
||||
assert.deepEqual(game, beforeMixed, 'the trial declaration does not mutate the source game');
|
||||
game = reveal(beforeMovementEnd, f.richese);
|
||||
game = openBattle(game, f.richese, f.guild, { band: 'skilled' });
|
||||
const r = player(game, f.richese), g = player(game, f.guild);
|
||||
|
||||
@@ -412,7 +412,7 @@ void test('No-Skills original assassination pause retains the unfinished winner
|
||||
inventory(later);
|
||||
});
|
||||
|
||||
void test('real Basic odd Ecaz FORCE remains unresolved before coalition lead, any debit, casualties or rewards', () => {
|
||||
void test('real Basic odd Ecaz FORCE admits provisional ceiling losses, floor survivors and actual coalition rewards', () => {
|
||||
const f = programme({ advanced: false, skills: false, tech: true });
|
||||
let g = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
|
||||
const at = g.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed); assert.ok(at >= 0);
|
||||
@@ -431,7 +431,49 @@ void test('real Basic odd Ecaz FORCE remains unresolved before coalition lead, a
|
||||
}
|
||||
assert.equal(player(g, f.ecaz).forces['sietch_tabr:14'], 3);
|
||||
const choice = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'sietch_tabr'); assert.ok(choice);
|
||||
reject(g, choice.chooser, { type: 'chooseBattle', territory: 'sietch_tabr',
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, /Odd-force Basic Occupy/);
|
||||
assert.equal(g.battle, null); inventory(g);
|
||||
const before = reload(g);
|
||||
g = step(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr',
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } });
|
||||
assert.ok(g.battle);
|
||||
const d = g.decision; assert.ok(d?.kind === 'ecazBattleLead');
|
||||
inventory(g);
|
||||
for (const lead of [f.ecaz, ally]) {
|
||||
const chosen = step(g, { actor: f.ecaz, action: { type: 'decision', event: d.event, lead } });
|
||||
const ready = advance(chosen, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
|
||||
undefined, quiet);
|
||||
const profile = viewGame(ready, lead).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.ecazForces.normal, 3);
|
||||
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
|
||||
assert.equal(profile.planOwner, lead);
|
||||
assert.equal(profile.payer, lead);
|
||||
assert.equal(profile.forceOwner, ally);
|
||||
const own = player(ready, lead).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
||||
const enemy = player(ready, opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(own && enemy);
|
||||
const revealed = reveal(ready, [
|
||||
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 2, support: 0 } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
|
||||
]), q = quote(revealed), done = settle(revealed);
|
||||
assert.equal(q.winner, lead);
|
||||
assert.equal(done.lastBattleContext!.winner, lead);
|
||||
assert.deepEqual(q.fixedLosses, [{ owner: f.ecaz, normal: Math.ceil(3 / 2), elite: 0 }]);
|
||||
assert.equal(q.casualties!.owner, ally);
|
||||
assert.deepEqual(q.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 }]);
|
||||
assert.ok(q.payments.every(payment => payment.ownPayment === 0));
|
||||
assert.deepEqual(q.strongholdIncome, []);
|
||||
for (const payment of q.payments)
|
||||
assert.equal(player(done, payment.player).spice,
|
||||
player(revealed, payment.player).spice + collectionIncome(done, payment.player));
|
||||
assert.equal(player(done, f.ecaz).tanks - player(before, f.ecaz).tanks, Math.ceil(3 / 2));
|
||||
assert.equal(player(done, f.ecaz).forces['sietch_tabr:14'], Math.floor(3 / 2));
|
||||
assert.equal(player(done, ally).tanks - player(before, ally).tanks, 2);
|
||||
assert.equal(player(done, ally).forces['sietch_tabr:14'] ?? 0, 0);
|
||||
assert.equal(player(done, opponent).tanks - player(before, opponent).tanks, 2);
|
||||
const token = ownedTech(before.techTokens, opponent)[0]; assert.ok(token);
|
||||
assert.equal(done.techTokens![token].owner, lead);
|
||||
for (const owner of [f.ecaz, ally])
|
||||
for (const retained of ownedTech(before.techTokens, owner))
|
||||
assert.equal(done.techTokens![retained].owner, owner, 'Only the loser’s Tech transfers.');
|
||||
inventory(done);
|
||||
}
|
||||
});
|
||||
@@ -272,7 +272,7 @@ void test('Atreides inspection grants only its entitled view and survives JSON b
|
||||
assert.equal(viewGame(g, 'e').richeseAuction!.cardId, null);
|
||||
assert.equal(g.players[0].hand[0].id, card.id);
|
||||
});
|
||||
void test('unresolved cache-cancel and seller self-bid branches reject atomically and gates stay closed', () => {
|
||||
void test('connected cache-cancel restores the ordinary pool while seller self-bid and lobby gates still reject atomically', () => {
|
||||
const before = fixture();
|
||||
const k = before.deck.find((c) => c.effect === 'karama')!;
|
||||
before.players[2].hand = [k];
|
||||
@@ -280,11 +280,14 @@ void test('unresolved cache-cancel and seller self-bid branches reject atomicall
|
||||
let g = begin(before);
|
||||
g = decision(g, { position: 'first' });
|
||||
const snapshot = structuredClone(g);
|
||||
assert.throws(
|
||||
() => applyAction(g, 'e', { type: 'card', card: k.id, mode: 'cancel' }),
|
||||
/ruling|interpretation/,
|
||||
);
|
||||
assert.deepEqual(g, snapshot);
|
||||
// The 7 October exhausted-cache ruling makes this cancellation legal and
|
||||
// restores one ordinary lot; it is no longer an unresolved branch.
|
||||
const canceled = applyAction(structuredClone(g), 'e', { type: 'card', card: k.id, mode: 'cancel' });
|
||||
assert.equal(canceled.richeseAuction, null);
|
||||
assert.ok(canceled.auction && canceled.auction.cards.length > 0);
|
||||
assert.equal(canceled.players[2].hand.some((c) => c.id === k.id), false);
|
||||
assert.equal(canceled.discard.filter((c) => c.id === k.id).length, 1);
|
||||
assert.deepEqual(g, snapshot, 'the source declaration is unchanged by the trial cancellation');
|
||||
const bm = fixture(true);
|
||||
bm.players[0].hand = [bm.deck.shift()!];
|
||||
g = begin(bm);
|
||||
|
||||
@@ -126,9 +126,10 @@ function ship(
|
||||
) {
|
||||
return applyAction(g, 'p', { type: 'ship', territory, sector, amount });
|
||||
}
|
||||
function reject(g: Game, id: string, action: Action) {
|
||||
function reject(g: Game, id: string, action: Action, reason?: string) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, id, action));
|
||||
if (reason) assert.throws(() => applyAction(g, id, action), { message: reason });
|
||||
else assert.throws(() => applyAction(g, id, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
@@ -245,7 +246,7 @@ void test('Yes cannot be evaded by moving first, ending movement or another Guil
|
||||
assert.doesNotThrow(() => applyAction(done, 'p', { type: 'endMovement' }));
|
||||
});
|
||||
|
||||
void test('storm, allied occupancy and two enemy factions deny impossible Yes without exposing a private witness', () => {
|
||||
void test('allied occupancy permits either shipment answer while storm and two enemy factions still deny impossible Yes privately', () => {
|
||||
for (const blocked of ['storm', 'ally', 'full']) {
|
||||
const g = fixture();
|
||||
if (blocked === 'storm') g.storm = 11;
|
||||
@@ -261,8 +262,62 @@ void test('storm, allied occupancy and two enemy factions deny impossible Yes wi
|
||||
}
|
||||
}
|
||||
const asked = ask(g);
|
||||
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['no']);
|
||||
reject(asked, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers,
|
||||
blocked === 'ally' ? ['yes', 'no'] : ['no']);
|
||||
if (blocked === 'ally') {
|
||||
const promised = answer(reload(asked), 'yes');
|
||||
reject(promised, 'p', { type: 'endMovement' },
|
||||
'Honor your Truthtrance shipment answer before using this shipment opportunity.');
|
||||
let arrived = ship(promised, 6);
|
||||
assert.equal(arrived.shipmentPromises?.[0].fulfilled, true);
|
||||
assert.equal(arrived.players[1].forces['carthag:11'], 6);
|
||||
assert.equal(arrived.players[1].reserves, 14);
|
||||
assert.equal(arrived.players[1].spice, 94);
|
||||
assert.equal(arrived.players[1].shipped, true);
|
||||
assert.equal(arrived.players[1].moved, 0);
|
||||
assert.equal(arrived.players[1].tanks, 0);
|
||||
assert.deepEqual(arrived.players[0].forces, g.players[0].forces);
|
||||
assert.deepEqual(viewGame(arrived, 'p').basicAlliedShipment, {
|
||||
territory: 'Carthag', normal: 6, elite: 0,
|
||||
});
|
||||
if (arrived.decision?.kind === 'advisor')
|
||||
arrived = applyAction(arrived, arrived.decision.player, { type: 'decision', accept: false });
|
||||
const done = applyAction(reload(arrived), 'p', { type: 'endMovement' });
|
||||
assert.deepEqual(done.players[1].forces, {});
|
||||
assert.equal(done.players[1].tanks, 6);
|
||||
assert.equal(done.players[1].reserves, 14);
|
||||
assert.equal(done.players[1].spice, 94);
|
||||
assert.deepEqual(done.players[0].forces, g.players[0].forces);
|
||||
assert.deepEqual(done.deck, arrived.deck);
|
||||
assert.deepEqual(done.discard, arrived.discard);
|
||||
assert.deepEqual(done.players.map((p) => p.hand), arrived.players.map((p) => p.hand));
|
||||
for (const p of done.players)
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0), 20);
|
||||
const refused = answer(reload(asked), 'no');
|
||||
reject(refused, 'p', {
|
||||
type: 'ship', territory: 'carthag', sector: 11, amount: 6,
|
||||
}, 'Honor your Truthtrance shipment answer before using this shipment opportunity.');
|
||||
const fewer = ship(refused, 5);
|
||||
assert.equal(fewer.players[1].forces['carthag:11'], 5);
|
||||
assert.equal(fewer.shipmentPromises?.[0].fulfilled, true);
|
||||
const skipped = applyAction(refused, 'p', { type: 'endMovement' });
|
||||
assert.equal(skipped.shipmentPromises?.[0].fulfilled, true);
|
||||
assert.equal(skipped.players[1].reserves, 20);
|
||||
assert.equal(skipped.players[1].tanks, 0);
|
||||
} else {
|
||||
reject(asked, 'p', { type: 'truthAnswer', answer: 'yes' },
|
||||
'Choose an answer consistent with your available shipment and earlier promises.');
|
||||
const refused = answer(reload(asked), 'no');
|
||||
const skipped = applyAction(refused, 'p', { type: 'endMovement' });
|
||||
assert.equal(skipped.shipmentPromises?.[0].fulfilled, true);
|
||||
reject(refused, 'p', {
|
||||
type: 'ship', territory: 'carthag', sector: 11, amount: 6,
|
||||
}, 'Honor your Truthtrance shipment answer before using this shipment opportunity.');
|
||||
reject(g, 'p', {
|
||||
type: 'ship', territory: 'carthag', sector: 11, amount: 6,
|
||||
}, blocked === 'storm' ? 'That sector is in storm.' :
|
||||
'A stronghold cannot contain three occupying factions.');
|
||||
}
|
||||
for (const id of ['a', 'o']) {
|
||||
assert.equal(viewGame(asked, id).truthShipmentAnswers, null);
|
||||
assert.equal(viewGame(asked, id).shipmentCompletion, null);
|
||||
@@ -277,7 +332,7 @@ void test('storm, allied occupancy and two enemy factions deny impossible Yes wi
|
||||
territory: 'wind_pass',
|
||||
sector: 15,
|
||||
amount: 1,
|
||||
});
|
||||
}, 'That sector is in storm.');
|
||||
});
|
||||
|
||||
void test('Ghola, retained Karama and recoverable outgoing ally escrow combine into an executable completion', () => {
|
||||
|
||||
Reference in new issue
Block a user