Expand Basic Richese and Terror interactions
Verify and publish checkpoint container / container (push) Successful in 11m25s

This commit is contained in:
Quintine committed 2026-10-29 09:59:45 +11:00
1 parent 605cb2a32f
commit 9ab9b9a613
10 files changed
+426 -9

No files matched your search

@@ -0,0 +1,173 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, handLimit, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { territory } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorPlayer as player,
basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
const moritani = 'moritani', richese = 'richese', rider = 'fremen', holder = 'guild';
/** Real public setup, native Terror placement, Spice Blow, Nexus and worm ride.
* Physical cards and two Fremen forces are moved between their original zones
* to give the chosen branch a deterministic victim, holder and second ride;
* only unopened Spice positions are selected. No pending event is staged. */
function source(kind: 'sabotage' | 'robbery') {
let g = createGame('SEMUTATERRORWORM', newPlayer(moritani, 'Moritani', 'moritani'), false, ['ecaz', 'choam']);
for (const faction of [richese, rider, holder] as const) joinGame(g, newPlayer(faction, faction, faction));
for (const p of g.players) g = act(g, p.id, {type:'ready'});
g = act(g, moritani, {type:'start'});
assert.equal(g.status, 'setup'); assert.equal(g.semutaPreview, true);
const step = () => {
if (g.status === 'setup' && g.setupStage === 'forces' && player(g, rider).reserves === 20) {
g = act(g, rider, {type:'fremenSetup', placements:{'sietch_tabr:14':10}}); return;
}
if (g.status === 'setup' && !g.setupStage && g.decision?.kind === 'moritaniSetup') {
g = act(g, moritani, {type:'decision', territory:'oh_gap', sector:9}); return;
}
const next = nextRicheseStrongholdsNativeStep(g);
assert.ok(next, `Missing native action at ${g.status}/${g.turn}/${g.phase}/${g.decision?.kind}`);
g = act(g, next.actor, next.action);
};
const reach = (condition: (state: Game) => boolean) => {
for (let n = 0; n < 2000; n++) {
if (condition(g)) return;
assert.notEqual(g.status, 'finished'); step();
}
throw Error('Native Terror worm source did not reach the requested phase');
};
reach(state => state.status === 'playing');
const f = player(g, rider);
const originalForce = Object.keys(f.forces).find(key => f.forces[key] >= 2)!;
assert.ok(originalForce);
f.forces[originalForce] -= 2;
if (!f.forces[originalForce]) delete f.forces[originalForce];
f.forces['the_great_flat:15'] = 1;
f.forces['hagga_basin:12'] = 1;
for (const [position, predicate] of [
[0, (c: Game['spiceDeck'][number]) => 'territory' in c && c.territory === 'the_great_flat'],
[1, (c: Game['spiceDeck'][number]) => 'worm' in c && !c.greatMaker],
] as const) {
const index = g.spiceDeck.findIndex((card, i) => i >= position && predicate(card));
assert.ok(index >= position); [g.spiceDeck[position], g.spiceDeck[index]] = [g.spiceDeck[index], g.spiceDeck[position]];
}
reach(state => state.phase === 8 && !state.response && state.decision?.kind === 'moritaniPlacement');
const token = g.moritaniTerror!.tokens.find(t => t.kind === kind)!;
assert.ok(token);
const destination = ['carthag', 'arrakeen', 'habbanya_ridge_sietch', 'tueks_sietch']
.find(id => territory(id).sectors.some(sector => sector !== g.storm))!;
g = act(g, moritani, {type:'decision', token:token.id, territory:destination});
reach(state => state.turn === 2 && state.decision?.kind === 'wormRide' && state.decision.player === rider);
assert.equal(g.decision?.kind === 'wormRide' && g.decision.territory, 'the_great_flat');
const sector = territory(destination).sectors.find(value => value !== g.storm)!;
assert.ok(sector !== undefined); assert.notEqual(g.storm, 15); assert.notEqual(g.storm, 12);
g = act(g, rider, {type:'decision', accept:true, territory:destination, sector,
forces:{'the_great_flat:15':1}});
assert.equal(g.pendingTerrorEntry?.cause, 'wormRide');
assert.equal(g.pendingTerrorEntry?.resume, 'wormRide');
// Supplement the real first ride with one conserved, staged later ride.
g.wormRides.push('hagga_basin');
const semutaAt = g.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(semutaAt >= 0 && player(g, holder).hand.length > 0);
const semuta = g.richeseCache!.splice(semutaAt, 1)[0];
g.deck.push(player(g, holder).hand[0]); player(g, holder).hand[0] = semuta;
for (const id of [moritani, rider]) {
const seat = player(g, id); g.deck.push(...seat.hand); seat.hand = [];
}
const card = () => {
const index = g.deck.findIndex(c => ['projectile','poison','shield','snooper','worthless'].includes(c.kind));
assert.ok(index >= 0); return g.deck.splice(index, 1)[0];
};
const gift = card();
if (kind === 'sabotage') {
player(g, moritani).hand.push(gift);
player(g, rider).hand.push(card());
} else {
g.deck.push(gift);
for (let n = 0; n < handLimit(player(g, moritani)); n++) player(g, moritani).hand.push(card());
}
const original = inventory(g);
return {g, token:token.id, destination, sector, gift:gift.id, original};
}
function pass(g: Game) {
const event = g.pendingTreacheryDiscard!.batch.event;
for (const seat of g.players) g = act(g, seat.id, {type:'semutaPass', event});
return g;
}
function finishSemuta(g: Game, claim: boolean) {
const batch = g.pendingTreacheryDiscard!.batch;
const selected = batch.entries[0].card.id;
if (claim) {
g = act(g, holder, {type:'semutaCommit', event:batch.event});
if (g.pendingTreacheryDiscard?.reaction?.stage === 'select')
g = act(g, holder, {type:'semutaSelect', event:batch.event, card:selected});
} else g = pass(g);
return {g, selected, event:batch.event};
}
for (const cause of ['sabotage', 'sabotageGiftOverflow', 'robberyOverflow'] as const)
for (const claim of [false, true]) void test(
`Basic ${cause} during worm ride preserves Semuta ${claim ? 'claim' : 'pass'} and original continuation`, () => {
const f = source(cause === 'robberyOverflow' ? 'robbery' : 'sabotage');
let g = act(f.g, moritani, {type:'decision', reveal:true});
if (cause === 'robberyOverflow') {
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
const deck = g.deck.map(card => card.id);
g = act(g, moritani, {type:'decision', choice:'card'});
assert.equal(g.pendingTerrorEntry?.stage, 'discard');
assert.ok(player(g, moritani).hand.some(card => card.id === deck[0]));
g = act(g, moritani, {type:'decision', card:player(g, moritani).hand[0].id});
} else if (cause === 'sabotageGiftOverflow') {
assert.equal(g.pendingTreacheryDiscard?.batch.cause, 'terror:sabotage');
g = pass(g);
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
// A conserved intervening hand refill tests the distinct second discard.
while (player(g, rider).hand.length < handLimit(player(g, rider))) {
const card = g.deck.pop(); assert.ok(card); player(g, rider).hand.push(card);
}
g = act(g, moritani, {type:'decision', card:f.gift});
assert.equal(g.pendingTerrorEntry?.stage, 'giftDiscard');
g = act(g, rider, {type:'decision', card:f.gift});
}
const pending = g.pendingTreacheryDiscard;
assert.equal(pending?.continuation.kind, 'terrorDiscard');
assert.equal(pending?.reaction?.stage, 'offer');
assert.equal(pending?.batch.cause, `terror:${cause}`);
assert.equal(pending?.batch.entries.length, 1);
assert.equal(pending?.batch.entries[0].discardedBy, cause === 'robberyOverflow' ? moritani : rider);
assert.equal(pending?.batch.entries[0].publicFace, false);
assert.equal(player(g, rider).forces[`${f.destination}:${f.sector}`], 1);
assert.deepEqual(g.wormRides, ['hagga_basin']);
for (const p of g.players) {
const view = viewGame(g, p.id);
assert.deepEqual(view.semutaReaction?.candidates, []);
assert.equal(view.semutaReaction?.canCommit, p.id === holder);
}
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, holder); view.players.find(p => p.id === holder)!.bot = difficulty;
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'));
}
const result = finishSemuta(g, claim); g = result.g;
assert.equal(player(g, holder).hand.some(card => card.id === result.selected), claim);
assert.equal(g.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
if (cause === 'sabotage') {
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
g = act(g, moritani, {type:'decision', card:f.gift});
assert.ok(player(g, rider).hand.some(card => card.id === f.gift));
}
assert.equal(g.pendingTerrorEntry ?? null, null);
assert.equal(g.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed');
assert.deepEqual(g.decision, {kind:'wormRide', player:rider, territory:'hagga_basin'});
assert.deepEqual(g.wormRides, []);
assert.equal(player(g, rider).forces[`${f.destination}:${f.sector}`], 1);
assert.deepEqual(inventory(g), f.original);
assert.throws(() => act(g, holder, {type:'semutaCommit', event:result.event}));
g = act(g, rider, {type:'decision', accept:false});
assert.notEqual(g.decision?.kind, 'wormRide'); assert.deepEqual(g.wormRides, []);
assert.deepEqual(inventory(g), f.original);
},
);
+179
View File
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
import { ambassadorPlacementDestinations } from '../game/ecaz-ambassador-placement';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
const seat = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id);
assert.ok(player, `Missing ${id}`);
return player;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
/** Genuine public Basic setup and live declarations. Original unopened Spice
* card positions are selected to avoid random worms; first-turn marker shipment,
* Mentat Terror and second-turn Ambassador use the normal player actions. */
function source(value: 0 | 3) {
let game = createGame(`REVEALE3${value}`, newPlayer('richese', 'Richese', 'richese'), false, ['choam', 'ecaz']);
for (const faction of ['ecaz', 'moritani', 'fremen', 'choam'] as const)
joinGame(game, newPlayer(faction, faction, faction));
for (const p of game.players) game = act(game, p.id, {type:'ready'});
game = act(game, game.host, {type:'start'});
assert.equal(game.status, 'setup');
// Select one unopened original card for Ecaz so the CHOAM Charity power
// retains its real optional Karama response instead of auto-passing.
const karama = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(karama >= 1);
[game.deck[1], game.deck[karama]] = [game.deck[karama], game.deck[1]];
let ecazWindow: Game | null = null;
let charityWindow: Game | null = null;
let ambassadorDeclared = false;
let terrorDeclared = false;
for (let n = 0; n < 2400; n++) {
if (game.status === 'playing' && game.turn === 2 && game.phase === 5 &&
game.active === 'fremen' && clean(game) && seat(game, 'richese').noField?.deployed) break;
assert.notEqual(game.status, 'finished');
if (!charityWindow && game.turn === 2 && game.response?.kind === 'choamCharity')
charityWindow = structuredClone(game);
if (game.status === 'setup' && game.setupStage === 'forces' && seat(game, 'fremen').reserves === 20 &&
viewGame(game, 'fremen').setupPending.includes('fremen')) {
game = act(game, 'fremen', {type:'fremenSetup', placements:{'sietch_tabr:14':10}});
continue;
}
if (game.status === 'playing' && game.turn === 1 && game.phase === 5 && game.active === 'richese' &&
clean(game) && !seat(game, 'richese').shipped && !seat(game, 'richese').noField?.deployed) {
const richese = seat(game, 'richese'), token = richese.noField!.tokens.find(t => t.value === value);
assert.ok(token && richese.noFieldEvent);
game = act(game, 'richese', {type:'ship', noField:token.id, event:richese.noFieldEvent,
territory:'polar_sink', sector:0, allyPayment:0});
continue;
}
if (game.decision?.kind === 'ecazPlacement' && !game.response) {
if (game.turn === 2 && !ambassadorDeclared) {
ecazWindow = structuredClone(game);
const token = game.ecazAmbassadors!.tokens.find(t => t.zone === 'supply');
const destination = ambassadorPlacementDestinations(game)[0];
assert.ok(token && destination);
game = act(game, 'ecaz', {type:'decision', token:token.id, territory:destination.id});
ambassadorDeclared = true;
} else game = act(game, 'ecaz', {type:'decision', decline:true});
continue;
}
if (game.decision?.kind === 'moritaniPlacement' && !game.response) {
if (game.turn === 1 && !terrorDeclared) {
const token = game.moritaniTerror!.tokens.find(t => t.status === 'available');
assert.ok(token);
game = act(game, 'moritani', {type:'decision', token:token.id, territory:'carthag'});
terrorDeclared = true;
} else game = act(game, 'moritani', {type:'decision', decline:true});
continue;
}
if (game.status === 'playing' && game.phase === 5 && game.active && clean(game)) {
game = act(game, game.active, {type:'endMovement'});
continue;
}
const next = nextTruthMovementNativeEarlyStep(game);
assert.ok(next, `Missing native continuation ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, next.actor, next.action.type === 'stormDial'
? {...next.action, amount:game.turn === 1 ? 0 : 1} : next.action);
if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.spiceDeck.length) {
for (let pos = 0; pos < 2; pos++) {
const at = game.spiceDeck.findIndex((card, index) => index >= pos && 'territory' in card && !card.discovery);
assert.ok(at >= pos);
[game.spiceDeck[pos], game.spiceDeck[at]] = [game.spiceDeck[at], game.spiceDeck[pos]];
}
}
}
assert.ok(game.turn === 2 && game.phase === 5 && game.active === 'fremen' && clean(game));
assert.ok(ecazWindow && charityWindow && ambassadorDeclared && terrorDeclared,
`Missing native window: ${JSON.stringify({ecazWindow:!!ecazWindow,charityWindow:!!charityWindow,ambassadorDeclared,terrorDeclared})}`);
assert.ok(game.ecazAmbassadors!.tokens.some(t => t.zone === 'placed'));
assert.ok(game.moritaniTerror!.tokens.some(t => t.status === 'placed'));
const richese = seat(game, 'richese'), deployed = richese.noField!.deployed;
assert.ok(deployed && richese.noFieldEvent);
const origin = 'sietch_tabr:14';
const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s)))
.find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)) &&
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === 2);
assert.ok(destination);
const beforeMove = structuredClone(game);
game = act(game, 'fremen', {type:'move', forces:{[origin]:1}, ...splitLocation(destination)});
assert.equal(game.response?.kind, 'fremenMovement');
return {game, beforeMove, ecazWindow, charityWindow, token:deployed.tokenId, event:richese.noFieldEvent, origin, destination};
}
function passResponse(game: Game) {
for (let n = 0; game.response && n < 12; n++) {
const p = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(p);
game = act(game, p.id, {type:'passResponse'});
}
assert.equal(game.response, null);
return game;
}
for (const value of [0, 3] as const) void test(
`Basic Ecaz/Moritani native tokens preserve foreign movement after No-Field ${value} reveal`, () => {
const f = source(value), before = structuredClone(f.game);
const response = structuredClone(before.response), move = structuredClone(before.pendingFremenMove);
const ambassadors = structuredClone(before.ecazAmbassadors), terror = structuredClone(before.moritaniTerror);
assert.equal(viewGame(before, 'richese').richeseNoField?.canReveal, true);
const reveal: Action = {type:'revealNoField', token:f.token, event:f.event};
assert.throws(() => act(before, 'ecaz', reveal));
const after = act(before, 'richese', reveal);
assert.deepEqual(after.response, response);
assert.deepEqual(after.pendingFremenMove, move);
assert.deepEqual(after.ecazAmbassadors, ambassadors);
assert.deepEqual(after.moritaniTerror, terror);
assert.equal(seat(after, 'richese').noField?.deployed, null);
assert.equal(seat(after, 'richese').reserves, seat(before, 'richese').reserves - value);
assert.equal(seat(after, 'richese').forces['polar_sink:0'] ?? 0, value);
const control = passResponse(reload(before)), done = passResponse(reload(after));
assert.deepEqual(seat(done, 'fremen'), seat(control, 'fremen'));
assert.equal(seat(done, 'fremen').forces[f.destination], 1);
assert.equal(seat(done, 'fremen').forces[f.origin], seat(f.beforeMove, 'fremen').forces[f.origin] - 1);
assert.equal(seat(done, 'fremen').moved, seat(f.beforeMove, 'fremen').moved + 1);
assert.deepEqual(done.ecazAmbassadors, ambassadors);
assert.deepEqual(done.moritaniTerror, terror);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(after, 'ecaz');
view.players.find(p => p.id === 'ecaz')!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, difficulty);
assert.doesNotThrow(() => act(reload(after), 'ecaz', action));
}
},
);
void test('a live native Ecaz placement still blocks an unrelated voluntary reveal', () => {
const {ecazWindow, token, event} = source(3);
assert.ok(ecazWindow);
const before = structuredClone(ecazWindow);
assert.equal(before.decision?.kind, 'ecazPlacement');
assert.equal(viewGame(before, 'richese').richeseNoField?.canReveal, false);
assert.throws(() => act(before, 'richese', {type:'revealNoField', token, event}));
assert.deepEqual(before, ecazWindow);
});
void test('Basic E3 native Terror remains placed while CHOAM Charity continues after a reveal', () => {
const {charityWindow, token, event} = source(3);
assert.ok(charityWindow);
const before = structuredClone(charityWindow), response = structuredClone(before.response);
const terror = structuredClone(before.moritaniTerror);
assert.equal(response?.kind, 'choamCharity');
assert.ok(terror?.tokens.some(t => t.status === 'placed'));
assert.equal(viewGame(before, 'richese').richeseNoField?.canReveal, true);
const after = act(before, 'richese', {type:'revealNoField', token, event});
assert.deepEqual(after.response, response);
assert.deepEqual(after.moritaniTerror, terror);
const original = passResponse(reload(before)), done = passResponse(reload(after));
assert.equal(seat(done, 'choam').spice, seat(original, 'choam').spice);
assert.deepEqual(done.moritaniTerror, terror);
assert.equal(seat(done, 'richese').forces['polar_sink:0'], 3);
});
@@ -148,7 +148,7 @@ void test('a legacy table without public Truthtrance history keeps its earlier i
void test('profile and nested-interaction boundaries do not admit another parent or module', () => {
const patches: Array<Partial<Game>> = [
{advanced:true}, {status:'setup'}, {phase:6}, {phase:-1}, {turn:0},
{expansions:['choam','ecaz']}, {expansions:['ix']},
{expansions:['choam','unknown']}, {expansions:['ix']},
{response:{kind:'richeseAuction', owner:'r', passed:[]}},
{pendingNullentropy:{} as NonNullable<Game['pendingNullentropy']>},
{pendingTreacheryDiscard:{} as NonNullable<Game['pendingTreacheryDiscard']>},
@@ -170,6 +170,9 @@ void test('profile and nested-interaction boundaries do not admit another parent
const wrongSeat = parent('auctionPayment'); wrongSeat.auction!.bidder = 'r'; assert.equal(supported(wrongSeat), false);
const native = parent('auctionPayment'); native.players.push(newPlayer('i','Ixians','ixians'));
native.expansions = ['choam']; assert.equal(supported(native), false);
const e3 = parent('auctionPayment'); e3.players.push(newPlayer('z','Ecaz','ecaz'));
e3.expansions = ['choam']; assert.equal(supported(e3), false);
e3.expansions = ['choam','ecaz']; assert.equal(supported(e3), true);
});
void test('live current-turn commitments block the interruption; settled and previous-turn records do not', () => {