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
@@ -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