Connect Ecaz Betrayal and Fremen worm Cunning with build revision

This commit is contained in:
Quintine committed 2026-09-27 18:52:18 +10:00
1 parent 1e690b7062
commit 07c739fcf7
30 files changed
+2878 -25

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
import assert from 'node:assert/strict';
import { TERRITORIES } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { test } from 'node:test';
import { captureFremenCunningOccurrence, fremenCunningDestinationBlock,
quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection,
type FremenCunningOccurrence, type FremenCunningRideAuthorization,
type FremenCunningSelection } from '../game/nexus-fremen-cunning';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { nexusFixture, nexusTurnTwo, nexusReady, nexusAllow, nexusInventory, nexusReload, orderNexusSpice } from './fixture-nexus-cards';
function fixture() {
const g = nexusFixture({ advanced: true });
g.turn = 2;
g.phase = 1;
g.storm = 0;
for (const p of g.players) {
p.forces = {};
if (p.elites) p.elites.forces = {};
}
const cards = g.nexusCards!.cards!;
cards.deck = cards.deck.filter(card => card !== 'fremen');
cards.discard = cards.discard.filter(card => card !== 'fremen');
for (const id of Object.keys(cards.hands))
if (cards.hands[id] === 'fremen') cards.hands[id] = null;
cards.hands[g.players[0].id] = 'fremen';
const fremen = g.players[0];
fremen.forces['wind_pass:14'] = 3;
fremen.forces['wind_pass:15'] = 2;
fremen.elites!.forces['wind_pass:14'] = 1;
fremen.elites!.forces['wind_pass:15'] = 2;
return { g, fremen, other: g.players[1] };
}
function appearance(g: Game, territory = 'hagga_basin', event = 'worm-pile-0') {
return captureFremenCunningOccurrence(g, {
event, territory, origin: 'natural', pile: 0,
});
}
function accepted(g: Game, occurrence: FremenCunningOccurrence): FremenCunningRideAuthorization {
const owner = g.players[0].id;
g.nexusCards!.cards!.hands[owner] = null;
g.nexusCards!.cards!.discard.push('fremen');
return { event: occurrence.event, turn: occurrence.turn, owner };
}
const selection: FremenCunningSelection = {
source: 'wind_pass',
forces: { 'wind_pass:14': { normal: 1, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 } },
destination: { territory: 'carthag', sector: 11 },
};
void test('initial public emptiness belongs to an individual appearance, not its post-devour board', () => {
const { g, other } = fixture();
const empty = appearance(g);
assert.equal(empty.initiallyEmpty, true);
other.forces['hagga_basin:12'] = 2;
const occupied = appearance(g, 'hagga_basin', 'worm-pile-1');
assert.equal(occupied.initiallyEmpty, false);
delete other.forces['hagga_basin:12']; // worm devoured the defenders
assert.equal(quoteFremenCunning(g, g.players[0].id, occupied), null);
const quote = quoteFremenCunning(g, g.players[0].id, empty)!;
assert.equal(quote.event, 'worm-pile-0');
assert.deepEqual(quote.sources, [{ territory: 'wind_pass', sectors: {
'wind_pass:14': { normal: 2, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 },
} }]);
const receipt = accepted(g, empty);
assert.deepEqual(validateFremenCunningSelection(g, g.players[0].id, empty, selection, receipt),
{ total: 4, elite: 3 });
assert.equal(g.players[0].forces['wind_pass:14'], 3); // pure quote, no movement
assert.equal(quoteFremenCunning(g, g.players[0].id, { ...empty, turn: 1 }), null);
assert.equal(quoteFremenCunningRide(g, g.players[0].id, occupied, receipt), null);
assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
{ ...receipt, event: 'worm-pile-1' }), null);
assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
{ ...receipt, owner: other.id }), null);
assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
{ ...receipt, turn: empty.turn - 1 }), null);
assert.equal(quoteFremenCunningRide(g, g.players[0].id, { ...empty, turn: 1 }, receipt), null);
});
void test('advisers and a public No-Field count as initial force presence without reading marker denomination', () => {
const { g, other } = fixture();
other.faction = 'beneGesserit';
other.forces['hagga_basin:13'] = 1;
other.advisors = { hagga_basin: {} };
assert.equal(appearance(g).initiallyEmpty, false);
delete other.forces['hagga_basin:13'];
other.faction = 'richese';
const marker = { deployed: { location: { territory: 'hagga_basin', sector: 12 } } };
Object.defineProperty(marker, 'tokens', { get() { throw new Error('private denomination read'); } });
other.noField = marker as typeof other.noField;
assert.equal(appearance(g).initiallyEmpty, false);
assert.equal(appearance(g, 'carthag').initiallyEmpty, true);
});
void test('typed one-desert-source subset and ordinary destination occupancy/storm restrictions', () => {
const { g, fremen, other } = fixture();
const occurrence = appearance(g);
const receipt = accepted(g, occurrence);
const valid = (move: FremenCunningSelection) =>
validateFremenCunningSelection(g, fremen.id, occurrence, move, receipt);
const reject = (move: FremenCunningSelection, reason: RegExp) => assert.throws(() => valid(move), reason);
reject({ ...selection, forces: { ...selection.forces, 'hagga_basin:12': { normal: 1, elite: 0 } } }, /physical forces/);
reject({ ...selection, forces: { 'wind_pass:14': { normal: 3, elite: 0 } } }, /physical forces/);
reject({ ...selection, forces: { 'wind_pass:15': { normal: 1, elite: 0 } } }, /physical forces/);
reject({ ...selection, forces: { constructor: { normal: 20, elite: 0 } } },
/physical forces/);
reject({ ...selection, forces: {} }, /at least one/);
reject({ ...selection, source: 'carthag' }, /desert/);
assert.equal(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source,
{ territory: 'hagga_basin', sector: 12 }), null);
reject({ ...selection, destination: { territory: 'wind_pass', sector: 14 } }, /another destination/);
reject({ ...selection, destination: { territory: 'carthag', sector: 12 } }, /Arrakis board sector/);
g.storm = 11;
reject(selection, /storm/);
g.storm = 14;
assert.equal(quoteFremenCunningRide(g, fremen.id, occurrence, receipt)!.sources[0].sectors['wind_pass:14'], undefined);
reject(selection, /physical forces/);
g.storm = 0;
fremen.ally = other.id;
other.ally = fremen.id;
other.forces['carthag:11'] = 1;
assert.notEqual(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source, selection.destination), null);
fremen.ally = null;
other.ally = null;
const third = g.players[2];
third.forces['carthag:11'] = 1;
assert.notEqual(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source, selection.destination), null);
});
void test('physical Fremen card ownership and native entitlement are required', () => {
const { g, fremen, other } = fixture();
const occurrence = appearance(g);
const cards = g.nexusCards!.cards!;
cards.hands[fremen.id] = null;
cards.hands[other.id] = 'fremen';
assert.equal(quoteFremenCunning(g, fremen.id, occurrence), null);
assert.equal(quoteFremenCunning(g, other.id, occurrence), null);
assert.throws(() => validateFremenCunningSelection(g, fremen.id, occurrence, selection,
{ event: occurrence.event, turn: occurrence.turn, owner: fremen.id }), /not available/);
cards.hands[other.id] = null;
cards.hands[fremen.id] = 'fremen';
cards.deck.push('fremen'); // duplicate physical card cannot establish custody
assert.throws(() => quoteFremenCunning(g, fremen.id, occurrence), /Nexus/);
cards.deck.pop();
fremen.ally = other.id;
other.ally = fremen.id;
assert.equal(quoteFremenCunning(g, fremen.id, occurrence), null);
});
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} empty worm offers privately, spends once and rides remotely after the Nexus`, () => {
let g = nexusTurnTwo({ advanced });
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('fremen');
assert.ok(index >= 0);
cards.deck.splice(index, 1);
cards.hands.f = 'fremen';
const sourceSector = g.storm === 14 ? 15 : 14;
g.players[0].reserves--;
g.players[0].forces[`wind_pass:${sourceSector}`] = 1;
orderNexusSpice(g, ['worm', 'land']);
const empty = g.spiceDeck.find(card => 'territory' in card &&
g.players.every(player => presenceAt(player, card.territory) === 0));
assert.ok(empty && 'territory' in empty);
g.spiceDeck.splice(g.spiceDeck.indexOf(empty), 1);
if (!('territory' in g.spiceDeck[1])) {
const nextLand = g.spiceDeck.findIndex((card, index) =>
index > 1 && 'territory' in card);
assert.ok(nextLand > 1);
g.spiceDeck.splice(1, 0, g.spiceDeck.splice(nextLand, 1)[0]);
}
g.spiceDiscard[0].push(empty);
g = nexusReady(g);
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
const event = g.nexusFremenCunningOffer!.occurrence.event;
assert.equal(viewGame(g, 'a').nexusCards?.card, null);
assert.equal(viewGame(g, 'f').nexusCards?.card, 'fremen');
const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
assert.ok(karamaIndex >= 0);
g.players[1].hand.push(g.deck.splice(karamaIndex, 1)[0]);
const noCard = nexusReload(g);
noCard.nexusCards!.cards!.hands.f = null;
noCard.nexusCards!.cards!.deck.push('fremen');
assert.equal(viewGame(noCard, 'a').decision?.kind, 'nexusFremenCunningOffer');
assert.throws(() => applyAction(noCard, 'f', {
type: 'decision', event, accept: true,
}), /Fremen Cunning needs/);
const passed = applyAction(noCard, 'f', { type: 'decision', event, accept: false });
assert.equal(passed.nexusFremenCunningOffer, null);
assert.equal(passed.nexusCards!.cards!.hands.f, null);
assert.throws(() => applyAction(g, 'f', {
type: 'decision', event: 'not-this-worm', accept: true,
}), /empty-worm Nexus opportunity/);
g = applyAction(nexusReload(g), 'f', { type: 'decision', event, accept: true });
assert.equal(g.response?.kind, 'nexusFremenCunning');
assert.equal(g.nexusCards!.cards!.hands.f, null);
assert.ok(g.nexusCards!.cards!.discard.includes('fremen'));
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
const canceled = applyAction(nexusReload(g), 'a', {
type: 'card', card: g.players[1].hand[0].id, mode: 'cancel',
});
assert.equal(canceled.nexusFremenCunningRides?.[0].stage, 'complete');
assert.ok(canceled.log.some(line => line.text.includes('Shai-Hulud appeared')));
assert.equal(canceled.players[0].forces[`wind_pass:${sourceSector}`], 1);
nexusInventory(canceled);
g = nexusAllow(g);
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
for (let i = 0; !g.decision && g.phase === 1 && i < 6; i++) {
if (g.response) g = nexusAllow(g);
else g = nexusReady(g);
}
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'select');
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
const botView = viewGame(g, 'f');
botView.players.find(player => player.id === 'f')!.bot = 'Easy';
const botRide = botActions(botView).find(candidate => candidate.accept === true);
assert.ok(botRide);
const botResult = applyAction(nexusReload(g), 'f', botRide);
assert.equal(botResult.nexusFremenCunningRides?.[0].stage, 'complete');
nexusInventory(botResult);
const action = {
type: 'decision' as const, event, accept: true, source: 'wind_pass',
forces: { [`wind_pass:${sourceSector}`]: { normal: 1, elite: 0 } },
territory: 'polar_sink', sector: 0,
};
assert.throws(() => applyAction(g, 'f', { ...action, source: 'sietch_tabr' }),
/desert territory/);
assert.throws(() => applyAction(g, 'f', {
...action, forces: { constructor: { normal: 20, elite: 0 } },
}), /physical forces/);
const before = nexusReload(g);
assert.deepEqual(g, before);
g = applyAction(g, 'f', action);
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
assert.equal(g.players[0].forces[`wind_pass:${sourceSector}`] ?? 0, 0);
assert.equal(g.players[0].forces['polar_sink:0'], 1);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
nexusInventory(g);
});
void test('an accepted Advanced additional-worm placement offers the distinct empty appearance', () => {
let g = nexusTurnTwo({ advanced: true });
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('fremen');
assert.ok(index >= 0);
cards.hands.f = cards.deck.splice(index, 1)[0];
const sourceSector = g.storm === 14 ? 15 : 14;
g.players[0].reserves--;
g.players[0].forces[`wind_pass:${sourceSector}`] = 1;
const empty = TERRITORIES.find(row => row.type === 'sand' &&
g.players.every(player => presenceAt(player, row.id) === 0));
assert.ok(empty);
orderNexusSpice(g, ['worm', 'land']);
const priorWorm = g.spiceDeck.findIndex((card, i) => i > 1 && 'worm' in card);
assert.ok(priorWorm > 1);
g.spiceDiscard[0].push(g.spiceDeck.splice(priorWorm, 1)[0]);
g = nexusReady(g);
assert.equal(g.decision?.kind, 'wormPlacement');
g = applyAction(g, 'f', { type: 'decision', accept: true, territory: empty.id });
g = nexusAllow(g);
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
const occurrence = g.nexusFremenCunningOffer!.occurrence;
assert.equal(occurrence.origin, 'additional');
assert.equal(occurrence.territory, empty.id);
assert.equal(occurrence.initiallyEmpty, true);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
g = applyAction(g, 'f', { type: 'decision', event: occurrence.event, accept: true });
g = nexusAllow(g);
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, occurrence.event);
assert.equal(g.nexusCards!.cards!.hands.f, null);
nexusInventory(g);
});