Wire the Tleilaxu Ambassador force return
The last unimplemented Ambassador effect is now reachable. A placed Tleilaxu token gives its beneficiary — the Ecaz owner or its ally — a private ecazAmbassador decision at the new revival stage: one to four physical counters return from the Tanks to reserves at zero cost, quoted by the existing game/tleilaxu-ambassador-forces.ts, which also enforces the shared Fedaykin/Sardaukar one-per-turn counter and keeps Ixian cyborgs uncapped. The player view projects the maximum, Tanks and elite inventory and current elite usage to the beneficiary only; the entry control offers a per-count button and an explicit "Return no forces" decline; all four AI profiles choose a legal return from the decision owner's own view or decline when the Tanks are empty. Player-facing coverage text now lists Tleilaxu. Kept gated exactly as recorded: the printed leader alternative, ordinary force/free/leader allowance accounting around this independent grant, dead-twice eligibility, and Tleilaxu free-income repeat scope. No ordinary revival allowance, price, phase or income path changes. Evidence: tests/tleilaxu-ambassador-engine.test.ts drives a genuine arrival into a placed token and asserts the beneficiary-only projection, the typed return with unchanged spice, the shared elite limit, malformed/over-maximum rejection with immutability, the ordinary-only return, the decline path and the rendered control. The four-profile ecaz-entry-bots loop now covers tleilaxu, and the former unsupported-effect and copy-list pins were updated to the supported behaviour rather than re-pinned. Offline suite 7,797/7,797 with typecheck and lint clean. No game, seat, saved state or deployment changes.
This commit is contained in:
1 parent
07c20ae806
commit
0e1a442dca
11 files changed
+503
-27
No files matched your search
@@ -23,7 +23,7 @@ import { TERRITORIES } from '@/game/board';
|
||||
import { faction } from '@/game/catalog';
|
||||
|
||||
export const AMBASSADOR_COVERAGE =
|
||||
'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition and consensual alliance, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are supported, plus Bene Gesserit copies of those eight ordinary effects. Duke loan choices, other effects and some competing arrival reactions remain unfinished; full Ecaz starts remain disabled.';
|
||||
'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition and consensual alliance, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen, Guild and Tleilaxu entry effects are supported, plus Bene Gesserit copies of those eight ordinary effects. The Tleilaxu effect returns physical forces only; its printed leader alternative and ordinary revival accounting remain unfinished. Duke loan choices, other effects and some competing arrival reactions remain unfinished; full Ecaz starts remain disabled.';
|
||||
function tokenLocation(token: AmbassadorToken) {
|
||||
if (token.zone === 'placed')
|
||||
return `Placed in ${TERRITORIES.find((t) => t.id === token.location)?.name ?? 'a stronghold'}`;
|
||||
|
||||
@@ -32,7 +32,7 @@ export function EcazEntry({
|
||||
!entry ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me ||
|
||||
!['offer', 'allianceReply', 'copy', 'cards', 'move', 'ship'].includes(
|
||||
!['offer', 'allianceReply', 'copy', 'cards', 'move', 'ship', 'revival'].includes(
|
||||
entry.stage,
|
||||
)
|
||||
)
|
||||
@@ -254,6 +254,59 @@ export function EcazEntry({
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : entry.stage === 'revival' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Return up to {entry.revival?.maximum ?? 0} physical{' '}
|
||||
{(entry.revival?.maximum ?? 0) === 1 ? 'force' : 'forces'} from the
|
||||
Tanks to your reserves for free. Ordinary revival allowances,
|
||||
prices and income are unchanged. The printed leader alternative
|
||||
stays unavailable pending its recorded interpretation.
|
||||
</p>
|
||||
{entry.revival?.blocked && (
|
||||
<p className="m-0 text-sm leading-6">{entry.revival.blocked}</p>
|
||||
)}
|
||||
{!entry.revival?.blocked &&
|
||||
(entry.revival?.maximum ?? 0) > 0 && (
|
||||
<fieldset
|
||||
className="flex min-w-0 flex-col gap-2 border-0 p-0"
|
||||
disabled={busy}
|
||||
>
|
||||
<legend className="text-sm">Forces to return</legend>
|
||||
{Array.from(
|
||||
{ length: entry.revival!.maximum },
|
||||
(_, index) => index + 1,
|
||||
).map((count) => (
|
||||
<Button
|
||||
key={count}
|
||||
variant="outline"
|
||||
className={buttonClass}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
send({
|
||||
forces: count,
|
||||
elite: Math.min(
|
||||
entry.revival!.eliteTanks,
|
||||
Math.max(0, 1 - entry.revival!.eliteRevived),
|
||||
count,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Return {count} {count === 1 ? 'force' : 'forces'}
|
||||
</Button>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className={buttonClass}
|
||||
disabled={busy}
|
||||
onClick={() => send({ decline: true })}
|
||||
>
|
||||
Return no forces
|
||||
</Button>
|
||||
</>
|
||||
) : entry.stage === 'ship' ? (
|
||||
<AmbassadorShipment
|
||||
key={entry.event}
|
||||
|
||||
@@ -50,6 +50,18 @@ settles, without replaying payment or force transfer. A Terror entry already in
|
||||
flight and every Ambassador combination still reject. Selected decks/cache,
|
||||
shared-Duke/capture, force/payment and pending-effect guards remain.
|
||||
|
||||
**In progress — Tleilaxu Ambassador force return.** The last unimplemented
|
||||
Ambassador effect is now wired: a placed Tleilaxu token gives its beneficiary —
|
||||
the Ecaz owner or its ally — a private decision to return one to four physical
|
||||
counters from the Tanks to reserves at zero cost, using the existing
|
||||
`game/tleilaxu-ambassador-forces.ts` quotation, the shared Fedaykin/Sardaukar
|
||||
one-per-turn counter, a beneficiary-only projection and an entry control with
|
||||
an explicit decline. All four AI profiles pick a legal return or decline. The
|
||||
printed leader alternative, ordinary allowance accounting, dead-twice
|
||||
eligibility and Tleilaxu income repeat scope stay gated exactly as recorded in
|
||||
[TLEILAXU_AMBASSADOR_RULES.md](TLEILAXU_AMBASSADOR_RULES.md#implementation-decision-boundary);
|
||||
no ordinary revival allowance, price, phase or income path changed.
|
||||
|
||||
**In progress — administrator operations.** The owner-only operations page now
|
||||
also diagnoses [stalled decisions](ADMIN_STALLED_DECISIONS.md): a bounded,
|
||||
read-only sample of at most ten live rooms whose public pending interaction — a
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 7 October 2026 — Tleilaxu Ambassador force return wired
|
||||
|
||||
The last unimplemented Ambassador effect is now reachable. A placed Tleilaxu
|
||||
token gives its beneficiary — the Ecaz owner or its ally — a private
|
||||
`ecazAmbassador` decision at stage `revival`: one to four physical counters
|
||||
return from the Tanks to reserves at zero cost, quoted by the existing
|
||||
`game/tleilaxu-ambassador-forces.ts`, which also enforces the shared
|
||||
Fedaykin/Sardaukar one-per-turn counter and keeps Ixian cyborgs uncapped. The
|
||||
player view projects the maximum, Tanks and elite inventory and current elite
|
||||
usage to the beneficiary only; the entry control offers a per-count button and
|
||||
an explicit "Return no forces" decline; all four AI profiles choose a legal
|
||||
return from the decision owner's own view or decline when the Tanks are empty.
|
||||
|
||||
Kept gated exactly as recorded: the printed leader alternative
|
||||
(`leaderAlternative: false`), ordinary force/free/leader allowance accounting
|
||||
around this independent grant, dead-twice eligibility, and Tleilaxu
|
||||
free-income repeat scope. No ordinary revival allowance, price, phase or
|
||||
income path changes; the Tleilaxu special Karama keeps its FAQ-resolved
|
||||
Revival-only timing.
|
||||
|
||||
Evidence: `tests/tleilaxu-ambassador-engine.test.ts` drives a genuine arrival
|
||||
into a placed token and asserts the beneficiary-only projection, the typed
|
||||
return with unchanged spice, the shared elite limit, malformed/over-maximum
|
||||
rejection with immutability, the ordinary-only return and the decline path. The
|
||||
four-profile `tests/ecaz-entry-bots.test.ts` loop now covers `tleilaxu`, and its
|
||||
former unsupported-effect pin was replaced by the supported offer/decline
|
||||
expectation rather than re-pinned. No game, seat, saved state, deployment,
|
||||
public start or played-save conversion changes.
|
||||
|
||||
## 7 October 2026 — administrator stalled-decision diagnosis
|
||||
|
||||
The owner-only operations page gains **List stalled decisions**: a bounded,
|
||||
|
||||
@@ -59,10 +59,46 @@ These three change meaningful legal choices or resources and require a supported
|
||||
|
||||
Verification used publisher-authored local extracts `base.txt`, `ix-official-mirror.txt`, `choam-lelekan-mirror.txt`, and `ecaz-audit.txt` under `/tmp/dune-rules`, plus freshly indexed official GF9 E1/E2/E3 and November FAQ passages. Movie-game rules, tournament amendments and unofficial implementations were not used as authority. No runtime files, tests, live rooms or automation were changed.
|
||||
|
||||
## Engine wiring checkpoint, 7 October 2026
|
||||
|
||||
`game/tleilaxu-ambassador-forces.ts` is now wired into the Ambassador
|
||||
resolution. When an eligible entrant triggers a placed Tleilaxu token, the
|
||||
beneficiary — the Ecaz owner or its ally — receives the printed force
|
||||
alternative as a private `ecazAmbassador` decision at stage `revival`:
|
||||
|
||||
- one to four physical counters return from the Tanks to reserves, at zero
|
||||
cost, in the printed amount and without touching ordinary revival prices;
|
||||
- the shared Fedaykin/Sardaukar one-per-turn counter is spent and enforced
|
||||
through the same helper (Ixian cyborgs keep their distinct uncapped
|
||||
treatment);
|
||||
- the player view projects the maximum, the Tanks and elite inventory and the
|
||||
current elite usage to the beneficiary only, and the entry control offers a
|
||||
per-count button plus an explicit "Return no forces" decline;
|
||||
- all four AI profiles pick a legal return from the decision owner's own view,
|
||||
or decline when the Tanks are empty.
|
||||
|
||||
**Still gated, unchanged:** the printed leader alternative (the decision's
|
||||
projection reports `leaderAlternative: false`), ordinary force/free/leader
|
||||
allowance accounting before and after this independent grant, dead-twice
|
||||
eligibility, and Tleilaxu free-income repeat scope. The three recorded policy
|
||||
questions above are not answered here. No ordinary revival allowance, price,
|
||||
phase or income path changes, and the Tleilaxu special Karama keeps its
|
||||
FAQ-resolved Revival-only timing.
|
||||
|
||||
Focused evidence: `tests/tleilaxu-ambassador-engine.test.ts` drives a genuine
|
||||
arrival into a placed token and asserts the projection, the typed return, the
|
||||
shared elite limit, malformed and over-maximum rejections with immutability,
|
||||
the ordinary-only return, the decline path and unchanged spice; the four-profile
|
||||
`tests/ecaz-entry-bots.test.ts` loop now covers `tleilaxu`, and the former
|
||||
"unsupported effect" pin was replaced by the supported offer/decline
|
||||
expectation rather than re-pinned.
|
||||
|
||||
|
||||
|
||||
## Physical foundation checkpoint, 7 September 2026
|
||||
|
||||
The later `game/tleilaxu-ambassador-forces.ts` helper implements only the resolved physical force quotation. It accepts a beneficiary's faction, reserve/Tanks totals and typed elite inventory, and quotes one through four returned counters with detached before/after custody and the shared elite revival counter. It rejects malformed counts, unavailable ordinary/elite selections, unsupported elite factions and unsafe arithmetic. It uses the minimum necessary elite count when no explicit count is selected; zero returns remain a separate caller choice. Ixian cyborgs retain their distinct uncapped treatment.
|
||||
|
||||
Root reviewed the complete helper and its tests. All **11 focused tests pass**, covering all twelve ordinary faction pools, mixed custody conservation, cross-event elite limits, Ixian quantities, invalid selections, safe arithmetic, detached results and independence from private information and RNG. The tests are registered in `npm test`; formatting, lint and full TypeScript checks pass. Root's focused result is `/tmp/dune-tleilaxu-ambassador-forces-root.log`.
|
||||
|
||||
This helper is not wired into the engine and does not activate the Ambassador, add player or AI controls, change ordinary revival allowances, choose leader eligibility or pay income. The three material accounting/leader/income questions above are pending user interpretation. The prior Guild checkpoint remains 2,930 passing registered tests plus twenty completed Basic games; these eleven new helper tests are additional focused evidence, not another full-suite or full-expansion run.
|
||||
**Superseded 7 October 2026:** the helper is now wired into the Ambassador resolution — see the engine wiring checkpoint above. Ordinary revival allowances, leader eligibility and income still remain unchanged, and the three material accounting/leader/income questions above remain pending user interpretation. The prior Guild checkpoint remains 2,930 passing registered tests plus twenty completed Basic games; these eleven new helper tests are additional focused evidence, not another full-suite or full-expansion run.
|
||||
@@ -1911,6 +1911,19 @@ function policyActions(g: GameView): Action[] {
|
||||
me.noField.deployed.location.sector !== g.storm)
|
||||
);
|
||||
if (entry.stage === 'allianceReply') return [{ ...action, accept: true }];
|
||||
if (entry.stage === 'revival') {
|
||||
const revival = entry.revival;
|
||||
if (!revival || revival.blocked || revival.maximum < 1)
|
||||
return [{ ...action, decline: true }];
|
||||
// Minimal legal participation: return as many Tanks counters as the
|
||||
// printed maximum allows, spending the single elite option first.
|
||||
const elite = Math.min(
|
||||
revival.eliteTanks,
|
||||
Math.max(0, 1 - revival.eliteRevived),
|
||||
);
|
||||
const forces = Math.min(revival.maximum, revival.tanks);
|
||||
return [{ ...action, forces, elite: Math.min(elite, forces) }];
|
||||
}
|
||||
if (entry.stage === 'offer') {
|
||||
if (entry.effect === 'ecaz')
|
||||
return [
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type BattleLeaderSkill,
|
||||
} from './leader-skill-combat';
|
||||
import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
|
||||
import { quoteTleilaxuAmbassadorForces, TleilaxuAmbassadorForceError } from './tleilaxu-ambassador-forces';
|
||||
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
|
||||
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
|
||||
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement';
|
||||
@@ -1601,6 +1602,7 @@ export type Game = {
|
||||
| 'bonus'
|
||||
| 'move'
|
||||
| 'ship'
|
||||
| 'revival'
|
||||
| 'arrival';
|
||||
relocation?: {
|
||||
order: FremenAmbassadorMove;
|
||||
@@ -14660,6 +14662,7 @@ const implementedAmbassadorEffects: readonly AmbassadorEffect[] = [
|
||||
'richese',
|
||||
'fremen',
|
||||
'guild',
|
||||
'tleilaxu',
|
||||
];
|
||||
function returnAmbassadorsIn(g: Game, to: string, reason: string) {
|
||||
for (const token of g.ecazAmbassadors?.tokens.filter(
|
||||
@@ -16319,6 +16322,20 @@ function resolveAmbassadorEffect(g: Game) {
|
||||
g.decision = { kind: 'ecazAmbassador', player: p.id };
|
||||
return;
|
||||
}
|
||||
if (effect === 'tleilaxu') {
|
||||
// Printed grant: "Revive one of your leaders or up to 4 of your forces for
|
||||
// free." Only the physical force alternative is source-supported here; the
|
||||
// leader alternative, ordinary allowance accounting and Tleilaxu income for
|
||||
// this distinct event stay gated pending the recorded interpretations.
|
||||
entry.stage = 'revival';
|
||||
g.decision = { kind: 'ecazAmbassador', player: p.id };
|
||||
log(
|
||||
g,
|
||||
`${p.name} may return up to four physical forces from the Tanks for free through the Tleilaxu Ambassador. Ordinary revival allowances and income are unchanged.`,
|
||||
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (effect === 'emperor') {
|
||||
p.spice += 5;
|
||||
log(
|
||||
@@ -16452,6 +16469,65 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
finishAmbassador(g);
|
||||
return;
|
||||
}
|
||||
if (entry.stage === 'revival') {
|
||||
requireRule(
|
||||
Object.keys(action).every((key) =>
|
||||
['type', 'event', 'decline', 'forces', 'elite'].includes(key),
|
||||
),
|
||||
'Choose a physical force return or decline it.',
|
||||
);
|
||||
if (action.decline === true) {
|
||||
log(
|
||||
g,
|
||||
`${p.name} returned no forces through the Tleilaxu Ambassador.`,
|
||||
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' },
|
||||
);
|
||||
finishAmbassador(g);
|
||||
return;
|
||||
}
|
||||
const forces = integer(action.forces, 1, 4, 'Forces to return');
|
||||
const elite =
|
||||
action.elite === undefined
|
||||
? undefined
|
||||
: integer(action.elite, 0, 4, 'Special forces to return');
|
||||
let quote;
|
||||
try {
|
||||
quote = quoteTleilaxuAmbassadorForces(
|
||||
{
|
||||
faction: p.faction,
|
||||
reserves: p.reserves,
|
||||
tanks: p.tanks,
|
||||
elites: p.elites
|
||||
? {
|
||||
reserves: p.elites.reserves,
|
||||
tanks: p.elites.tanks,
|
||||
revived: p.elites.revived,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
forces,
|
||||
elite,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TleilaxuAmbassadorForceError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
p.reserves = quote.after.reserves;
|
||||
p.tanks = quote.after.tanks;
|
||||
if (p.elites) {
|
||||
p.elites.reserves = quote.after.eliteReserves;
|
||||
p.elites.tanks = quote.after.eliteTanks;
|
||||
p.elites.revived = quote.eliteRevivedNext;
|
||||
}
|
||||
log(
|
||||
g,
|
||||
`${p.name} returned ${quote.amount} physical ${quote.amount === 1 ? 'force' : 'forces'} (${quote.elite} special) from the Tanks to its reserves for free through the Tleilaxu Ambassador. Ordinary revival allowances, prices and income are unchanged.`,
|
||||
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' },
|
||||
);
|
||||
finishAmbassador(g);
|
||||
return;
|
||||
}
|
||||
if (entry.stage === 'offer') {
|
||||
const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry.token);
|
||||
requireRule(
|
||||
@@ -31964,6 +32040,23 @@ export function viewGame(state: Game, id: string) {
|
||||
blocked: ambassadorDiscardBlock(g, me, card, entry.effect),
|
||||
}))
|
||||
: [],
|
||||
revival:
|
||||
entry.stage === 'revival' && id === entry.beneficiary
|
||||
? {
|
||||
maximum: Math.min(4, me.tanks),
|
||||
tanks: me.tanks,
|
||||
eliteTanks: me.elites?.tanks ?? 0,
|
||||
eliteRevived: me.elites?.revived ?? 0,
|
||||
leaderAlternative: false,
|
||||
blocked:
|
||||
ambassadorEffectBlock(
|
||||
g,
|
||||
'tleilaxu',
|
||||
me,
|
||||
entrant,
|
||||
) ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})()
|
||||
: null,
|
||||
|
||||
@@ -123,6 +123,7 @@ void test('all four profiles legally resolve each supported ordinary effect from
|
||||
'harkonnen',
|
||||
'choam',
|
||||
'ixians',
|
||||
'tleilaxu',
|
||||
] as const) {
|
||||
const initial = enter(fixture(effect));
|
||||
for (const id of ['entrant', 'ally'])
|
||||
@@ -166,19 +167,22 @@ void test('all four profiles legally resolve each supported ordinary effect from
|
||||
}
|
||||
});
|
||||
|
||||
void test('unsupported effects are explicitly declined without consuming the placed token at every difficulty', () => {
|
||||
for (const difficulty of DIFFICULTIES)
|
||||
for (const effect of ['tleilaxu'] as const) {
|
||||
const entered = enter(fixture(effect));
|
||||
const { action, next } = choose(entered, difficulty);
|
||||
assert.equal(action.decline, true);
|
||||
assert.equal(next.pendingAmbassador, null);
|
||||
assert.equal(
|
||||
next.ecazAmbassadors!.tokens.find((t) => t.effect === effect)!.zone,
|
||||
'placed',
|
||||
);
|
||||
assert.equal(next.players[0].spice, 10);
|
||||
}
|
||||
void test('the Tleilaxu force return is offered and declined when the beneficiary has nothing in the Tanks', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const entered = enter(fixture('tleilaxu'));
|
||||
const offered = choose(entered, difficulty).next;
|
||||
assert.equal(offered.pendingAmbassador?.stage, 'revival');
|
||||
assert.equal(offered.decision?.kind, 'ecazAmbassador');
|
||||
const { action, next } = choose(offered, difficulty);
|
||||
assert.equal(action.decline, true);
|
||||
assert.equal(next.pendingAmbassador, null);
|
||||
assert.equal(
|
||||
next.ecazAmbassadors!.tokens.find((t) => t.effect === 'tleilaxu')!.zone,
|
||||
'used',
|
||||
);
|
||||
assert.equal(next.players[0].spice, 10);
|
||||
assert.equal(next.players[0].tanks, 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Bene Gesserit copies choose only supported projected effects and complete without another acknowledgement', () => {
|
||||
|
||||
@@ -210,7 +210,7 @@ void test('unselected BG copy permits spending its final Ixian discard while a F
|
||||
.map((choice) => choice.effect)
|
||||
.sort();
|
||||
assert.equal(g.pendingAmbassador?.stage, 'copy');
|
||||
assert.deepEqual(copies(g), ['fremen', 'guild', 'ixians']);
|
||||
assert.deepEqual(copies(g), ['fremen', 'guild', 'ixians', 'tleilaxu']);
|
||||
assert.equal(
|
||||
g.ecazAmbassadors!.tokens.find(
|
||||
(token) => token.effect === 'beneGesserit',
|
||||
@@ -249,7 +249,7 @@ void test('unselected BG copy permits spending its final Ixian discard while a F
|
||||
assert.equal(g.pendingAmbassador?.event, event);
|
||||
assert.equal(g.pendingAmbassador?.stage, 'copy');
|
||||
assert.deepEqual(g.players[1].hand, []);
|
||||
assert.deepEqual(copies(g), ['fremen', 'guild']);
|
||||
assert.deepEqual(copies(g), ['fremen', 'guild', 'tleilaxu']);
|
||||
assert.equal(g.discard.filter((held) => held.id === card).length, 1);
|
||||
if (karama)
|
||||
assert.equal(g.discard.filter((held) => held.id === karama).length, 1);
|
||||
|
||||
@@ -374,21 +374,27 @@ void test('unresolved simultaneous Terror or Guild income rejects the complete o
|
||||
}
|
||||
});
|
||||
|
||||
void test('incomplete effect remains visibly unavailable and can be declined without consuming its token', () => {
|
||||
void test('the Tleilaxu effect is offered to an eligible beneficiary and returns forces without consuming its token early', () => {
|
||||
for (const effect of ['tleilaxu'] as const) {
|
||||
const g = enter(ambassadorFixture(effect));
|
||||
assert.match(
|
||||
viewGame(g, 'ec').ambassadorEntry!.beneficiaries[0].blocked!,
|
||||
/implemented/,
|
||||
assert.equal(
|
||||
viewGame(g, 'ec').ambassadorEntry!.beneficiaries[0].blocked,
|
||||
null,
|
||||
);
|
||||
reject(g, 'ec', {
|
||||
type: 'decision',
|
||||
event: g.pendingAmbassador!.event,
|
||||
const offered = decide(g, 'ec', {
|
||||
trigger: true,
|
||||
beneficiary: 'ec',
|
||||
choice: 'effect',
|
||||
});
|
||||
const done = decide(g, 'ec', { decline: true });
|
||||
assert.deepEqual(done.ecazAmbassadors, g.ecazAmbassadors);
|
||||
assert.equal(offered.pendingAmbassador?.stage, 'revival');
|
||||
assert.equal(offered.decision?.player, 'ec');
|
||||
assert.equal(viewGame(offered, 'ec').ambassadorEntry!.revival?.maximum, 0);
|
||||
const done = decide(offered, 'ec', { decline: true });
|
||||
assert.equal(done.pendingAmbassador, null);
|
||||
assert.equal(
|
||||
done.ecazAmbassadors!.tokens.find((t) => t.effect === effect)!.zone,
|
||||
'used',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { registerHooks } from 'node:module';
|
||||
|
||||
/** A genuine Ecaz arrival whose Tleilaxu token is assigned to an elite ally. */
|
||||
function fixture() {
|
||||
const g = createGame('TLEIAMBA', newPlayer('ecaz', 'Ecaz', 'ecaz'));
|
||||
g.players.push(
|
||||
newPlayer('entrant', 'Entrant', 'atreides'),
|
||||
newPlayer('ally', 'Ally', 'emperor'),
|
||||
);
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
phase: 5,
|
||||
turn: 2,
|
||||
storm: 18,
|
||||
order: ['entrant', 'ecaz', 'ally'],
|
||||
active: 'entrant',
|
||||
});
|
||||
g.movementRemaining = [...g.order];
|
||||
g.deck = baseDeck();
|
||||
for (const p of g.players) {
|
||||
p.hand = [];
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.spice = 10;
|
||||
p.traitors = [p.leaders[0].id];
|
||||
}
|
||||
const ecaz = g.players[0], ally = g.players[2];
|
||||
ecaz.ally = ally.id;
|
||||
ally.ally = ecaz.id;
|
||||
ally.tanks = 3;
|
||||
ally.reserves = 15;
|
||||
ally.elites = { reserves: 2, tanks: 1, forces: {}, revived: 0 };
|
||||
const state = createAmbassadors(() => 0);
|
||||
const token = state.tokens.find((t) => t.effect === 'tleilaxu')!;
|
||||
const cohortEffects = [
|
||||
'tleilaxu',
|
||||
...state.tokens
|
||||
.map((t) => t.effect)
|
||||
.filter((e) => e !== 'tleilaxu' && e !== 'ecaz'),
|
||||
].slice(0, 5);
|
||||
state.cohort = state.tokens
|
||||
.filter((t) => cohortEffects.includes(t.effect))
|
||||
.map((t) => t.id);
|
||||
for (const other of state.tokens) {
|
||||
other.zone =
|
||||
other.effect === 'ecaz' || state.cohort.includes(other.id)
|
||||
? 'supply'
|
||||
: 'pool';
|
||||
other.location = null;
|
||||
}
|
||||
token.zone = 'placed';
|
||||
token.location = 'arrakeen';
|
||||
validateAmbassadors(state);
|
||||
g.ecazAmbassadors = state;
|
||||
return g;
|
||||
}
|
||||
const ship = (g: Game) =>
|
||||
applyAction(g, 'entrant', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 });
|
||||
const trigger = (g: Game) => {
|
||||
const event = g.pendingAmbassador!.event;
|
||||
return applyAction(g, 'ecaz', {
|
||||
type: 'decision',
|
||||
event,
|
||||
trigger: true,
|
||||
beneficiary: 'ally',
|
||||
choice: 'effect',
|
||||
});
|
||||
};
|
||||
const counters = (g: Game, id: string) => {
|
||||
const p = g.players.find((seat) => seat.id === id)!;
|
||||
return {
|
||||
reserves: p.reserves,
|
||||
tanks: p.tanks,
|
||||
eliteReserves: p.elites?.reserves ?? 0,
|
||||
eliteTanks: p.elites?.tanks ?? 0,
|
||||
eliteRevived: p.elites?.revived ?? 0,
|
||||
spice: p.spice,
|
||||
};
|
||||
};
|
||||
|
||||
void test('the Tleilaxu Ambassador returns typed forces from the Tanks for free and spends the shared elite option', () => {
|
||||
const offered = trigger(ship(fixture()));
|
||||
assert.equal(offered.pendingAmbassador?.stage, 'revival');
|
||||
assert.equal(offered.pendingAmbassador?.effect, 'tleilaxu');
|
||||
assert.equal(offered.decision?.kind, 'ecazAmbassador');
|
||||
assert.equal(offered.decision?.player, 'ally');
|
||||
const view = viewGame(offered, 'ally').ambassadorEntry!;
|
||||
assert.deepEqual(view.revival, {
|
||||
maximum: 3,
|
||||
tanks: 3,
|
||||
eliteTanks: 1,
|
||||
eliteRevived: 0,
|
||||
leaderAlternative: false,
|
||||
blocked: null,
|
||||
});
|
||||
assert.equal(viewGame(offered, 'ecaz').ambassadorEntry!.revival, null);
|
||||
assert.equal(viewGame(offered, 'entrant').ambassadorEntry!.revival, null);
|
||||
const before = counters(offered, 'ally');
|
||||
const event = offered.pendingAmbassador!.event;
|
||||
// A second elite return is rejected by the shared one-per-turn limit.
|
||||
const spent = structuredClone(offered);
|
||||
spent.players.find((p) => p.id === 'ally')!.elites!.revived = 1;
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAction(spent, 'ally', {
|
||||
type: 'decision',
|
||||
event,
|
||||
forces: 1,
|
||||
elite: 1,
|
||||
}),
|
||||
/Only one Fedaykin or Sardaukar may be revived per turn/,
|
||||
);
|
||||
assert.deepEqual(spent.players.find((p) => p.id === 'ally')!.tanks, 3);
|
||||
for (const bad of [
|
||||
{ forces: 0 },
|
||||
{ forces: 5 },
|
||||
{ forces: 2, elite: 1.5 },
|
||||
]) {
|
||||
const copy = structuredClone(offered);
|
||||
assert.throws(() =>
|
||||
applyAction(copy, 'ally', { type: 'decision', event, ...bad }),
|
||||
);
|
||||
assert.deepEqual(copy, offered);
|
||||
}
|
||||
const returned = applyAction(offered, 'ally', {
|
||||
type: 'decision',
|
||||
event,
|
||||
forces: 2,
|
||||
elite: 1,
|
||||
});
|
||||
assert.deepEqual(counters(returned, 'ally'), {
|
||||
reserves: before.reserves + 2,
|
||||
tanks: before.tanks - 2,
|
||||
eliteReserves: before.eliteReserves + 1,
|
||||
eliteTanks: before.eliteTanks - 1,
|
||||
eliteRevived: 1,
|
||||
spice: before.spice,
|
||||
});
|
||||
assert.equal(returned.pendingAmbassador, null);
|
||||
assert.equal(
|
||||
returned.ecazAmbassadors!.tokens.find((t) => t.effect === 'tleilaxu')!.zone,
|
||||
'used',
|
||||
);
|
||||
assert.equal(
|
||||
returned.log.some((line) =>
|
||||
line.text.includes('Tleilaxu Ambassador') &&
|
||||
line.text.includes('Ordinary revival allowances'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
void test('the Tleilaxu Ambassador can return only ordinary forces and can return none at all', () => {
|
||||
const offered = trigger(ship(fixture()));
|
||||
const event = offered.pendingAmbassador!.event;
|
||||
const ordinary = applyAction(offered, 'ally', {
|
||||
type: 'decision',
|
||||
event,
|
||||
forces: 2,
|
||||
elite: 0,
|
||||
});
|
||||
const ally = ordinary.players.find((p) => p.id === 'ally')!;
|
||||
assert.equal(ally.tanks, 1);
|
||||
assert.equal(ally.reserves, 17);
|
||||
assert.equal(ally.elites!.tanks, 1);
|
||||
assert.equal(ally.elites!.revived, 0);
|
||||
assert.equal(ally.spice, 10);
|
||||
const fresh = trigger(ship(fixture()));
|
||||
const declined = applyAction(fresh, 'ally', {
|
||||
type: 'decision',
|
||||
event: fresh.pendingAmbassador!.event,
|
||||
decline: true,
|
||||
});
|
||||
assert.equal(declined.pendingAmbassador, null);
|
||||
const seat = declined.players.find((p) => p.id === 'ally')!;
|
||||
assert.equal(seat.tanks, 3);
|
||||
assert.equal(seat.reserves, 15);
|
||||
});
|
||||
|
||||
void test('the entry control offers each legal count, the elite-aware return and an explicit decline', async () => {
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
});
|
||||
const { EcazEntry } = await import('../components/ecaz-entry');
|
||||
aliases.deregister();
|
||||
const offered = trigger(ship(fixture()));
|
||||
const view = viewGame(offered, 'ally') as unknown as Parameters<typeof EcazEntry>[0]['game'];
|
||||
const actions: unknown[] = [];
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(EcazEntry, {
|
||||
game: view,
|
||||
act: (action: unknown) => actions.push(action),
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
for (const label of ['Return 1 force', 'Return 2 forces', 'Return 3 forces', 'Return no forces'])
|
||||
assert.ok(markup.includes(label), label);
|
||||
assert.ok(markup.includes('Tleilaxu Ambassador'));
|
||||
assert.ok(markup.includes('leader alternative'));
|
||||
const blocked = structuredClone(offered);
|
||||
blocked.players.find((p) => p.id === 'ally')!.tanks = 0;
|
||||
const blockedView = viewGame(blocked, 'ally') as unknown as Parameters<typeof EcazEntry>[0]['game'];
|
||||
const blockedMarkup = renderToStaticMarkup(
|
||||
createElement(EcazEntry, {
|
||||
game: blockedView,
|
||||
act: (action: unknown) => actions.push(action),
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
assert.equal(blockedMarkup.includes('Return 1 force'), false);
|
||||
assert.ok(blockedMarkup.includes('Return no forces'));
|
||||
});
|
||||
Reference in new issue
Block a user