Files
Dune/tests/richese-reveal-foreign-movement-runtime.test.ts
T
Quintine 605cb2a32f
Verify and publish checkpoint container / container (push) Canceled after 0s
Expand Basic Richese movement and worm interactions
2026-10-29 09:54:24 +11:00

241 lines
14 KiB
TypeScript

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 { presenceAt } from '../game/force-presence';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { truthMovementInventory } from './fixture-truth-movement';
type Kind = 'fremenMovement' | 'ixMovement' | 'choamMovement';
const seat = (g: Game, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; };
const clean = (g: Game) => !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingTreacheryDiscard;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const cards = (g: Game) => truthMovementInventory(g);
/** Select an existing physical card by a custody-preserving swap after the
* actual starting deal. This controls only who can answer the test window. */
function swapHeld(g: Game, id: string, predicate: (c: Game['deck'][number]) => boolean): string {
const target = seat(g, id);
const held = target.hand.find(predicate);
if (held) return held.id;
assert.ok(target.hand.length);
const index = g.deck.findIndex(predicate);
if (index >= 0) {
[target.hand[0], g.deck[index]] = [g.deck[index], target.hand[0]];
return target.hand[0].id;
}
for (const other of g.players) {
if (other.id === id) continue;
const at = other.hand.findIndex(predicate);
if (at >= 0) {
[target.hand[0], other.hand[at]] = [other.hand[at], target.hand[0]];
return target.hand[0].id;
}
}
throw Error('The original card must exist in the native deal or deck');
}
/** All pending movement/decisions come from actual player actions. The only
* controlled inputs are unopened Spice cards, first Storm dials and conserved
* card swaps. Richese's marker is actually shipped on its own turn. */
function fixture(kind: Kind, value: 0 | 3 = 3, markerAtDestination = false) {
assert.ok(!markerAtDestination || kind === 'fremenMovement');
const mover = kind === 'fremenMovement' ? 'fremen' : kind === 'ixMovement' ? 'ixians' : 'emperor';
const expansions = kind === 'ixMovement' ? ['choam', 'ix'] : ['choam'];
let g = createGame(`REVEAL-${kind}-${value}`, newPlayer('richese', 'Richese', 'richese'), false, expansions);
if (kind === 'choamMovement') joinGame(g, newPlayer('choam', 'CHOAM', 'choam'));
else joinGame(g, newPlayer(mover, mover, mover));
joinGame(g, newPlayer('emperor', 'Emperor', 'emperor'));
for (const p of g.players) g = act(g, p.id, {type:'ready'});
g = act(g, g.host, {type:'start'});
assert.equal(g.status, 'setup');
if (kind === 'ixMovement') {
// The native Ix setup driver chooses a Shield from the inspected packet.
// Move the original unopened card to its first draw position.
const shield = g.deck.findIndex(card => card.kind === 'shield');
assert.ok(shield >= 0);
[g.deck[0], g.deck[shield]] = [g.deck[shield], g.deck[0]];
}
const next = (state: Game) => {
const fremen = state.players.find(p => p.faction === 'fremen');
if (state.status === 'setup' && state.setupStage === 'forces' && fremen?.reserves === 20 &&
viewGame(state, fremen.id).setupPending.includes(fremen.id))
return {actor:fremen.id, action:{type:'fremenSetup', placements:{'sietch_tabr:14':10}} satisfies Action};
if (state.phase === 5 && state.active === 'richese' && clean(state) && !seat(state,'richese').shipped &&
!seat(state,'richese').noField?.deployed) {
const owner = seat(state,'richese'), marker = owner.noField!.tokens.find(t => t.value === value);
assert.ok(marker && owner.noFieldEvent);
const target = markerAtDestination
? TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s)))
.find(key => splitLocation(key).sector > 7 && state.players.every(p => !(p.forces[key] > 0)) &&
gameDistance(state,'sietch_tabr:14',key,k => splitLocation(k).sector === state.storm) === 2 &&
gameDistance(state,'sietch_tabr:14',key,k => splitLocation(k).sector === (state.storm % 18) + 1) === 2)
: 'polar_sink:0';
assert.ok(target, 'The original Fremen position has a safe distance-two marker destination');
return {actor:'richese', action:{type:'ship', noField:marker.id, event:owner.noFieldEvent,
...splitLocation(target), allyPayment:0} satisfies Action};
}
if (kind === 'choamMovement' && state.turn === 1 && state.phase === 5 && state.active === 'choam' &&
clean(state) && !seat(state,'choam').shipped)
return {actor:'choam', action:{type:'ship', territory:'arrakeen', sector:10, amount:1, elite:0, allyPayment:0} satisfies Action};
if (state.phase === 5 && state.active === 'emperor' && clean(state))
return {actor:'emperor', action:{type:'endMovement'} satisfies Action};
const step = nextTruthMovementNativeEarlyStep(state);
assert.ok(step, `Missing native continuation ${state.status}/${state.turn}/${state.phase}/${state.decision?.kind}`);
if (step.action.type === 'stormDial' && state.turn === 1)
return {...step, action:{...step.action, amount:0}};
return step;
};
const reach = (until: (s: Game) => boolean) => {
for (let n = 0; n < 2400 && !until(g); n++) {
assert.notEqual(g.status, 'finished');
const step = next(g); g = act(g, step.actor, step.action);
}
assert.ok(until(g), 'The real game reached the requested native boundary');
};
reach(state => state.status === 'playing');
const karama = swapHeld(g, 'emperor', c => c.effect === 'karama');
const baliset = kind === 'choamMovement' ? swapHeld(g, 'choam', c => c.name === 'Baliset') : undefined;
for (let pos = 0; pos < 2; pos++) {
const at = g.spiceDeck.findIndex((c, index) => index >= pos && 'territory' in c && !c.discovery);
assert.ok(at >= pos); [g.spiceDeck[pos], g.spiceDeck[at]] = [g.spiceDeck[at], g.spiceDeck[pos]];
}
reach(state => state.turn === 2 && state.phase === 5 && state.active === mover && clean(state) &&
!!seat(state,'richese').noField?.deployed);
const marker = seat(g,'richese').noField!.deployed!.tokenId;
const event = seat(g,'richese').noFieldEvent; assert.ok(event);
let origin: string;
if (kind === 'fremenMovement') {
origin = 'sietch_tabr:14';
assert.ok((seat(g,mover).forces[origin] ?? 0) > 0);
} else if (kind === 'choamMovement') {
origin = 'carthag:11';
g = act(g,mover,{type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0});
reach(clean);
assert.ok((seat(g,'choam').forces['arrakeen:10'] ?? 0) > 0);
} else {
origin = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s)))
.find(key => splitLocation(key).sector !== g.storm && g.players.every(p => !(p.forces[key] > 0)) &&
TERRITORIES.some(t => t.type === 'sand' && t.sectors.some(s => s !== g.storm &&
gameDistance(g,key,location(t.id,s),k => splitLocation(k).sector === g.storm) === 2)))!;
assert.ok(origin);
g = act(g,mover,{type:'ship', ...splitLocation(origin), amount:1, elite:1, allyPayment:0});
reach(clean);
}
const markerLocation = seat(g,'richese').noField!.deployed!.location;
const destination = markerAtDestination && 'territory' in markerLocation
? location(markerLocation.territory,markerLocation.sector)
: kind === 'choamMovement' ? 'arrakeen:10' :
TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s)))
.find(key => splitLocation(key).sector !== g.storm && g.players.every(p => !(p.forces[key] > 0)) &&
gameDistance(g,origin,key,k => splitLocation(k).sector === g.storm) === 2)!;
assert.ok(destination);
const beforeMove = structuredClone(g);
const move: Action = {type:'move', forces:{[origin]:1},
...(kind === 'ixMovement' ? {eliteForces:{[origin]:1}} : {}), ...splitLocation(destination)};
g = act(g,mover,move);
assert.equal(kind === 'choamMovement' ? g.decision?.kind : g.response?.kind, kind);
assert.deepEqual(seat(g,mover).forces, seat(beforeMove,mover).forces);
return {game:g, mover, marker, event, karama, baliset, value, origin, destination, beforeMove};
}
void test('a foreign movement into a zero No-Field destination still commits after the marker vanishes', () => {
const f = fixture('fremenMovement',0,true), original = structuredClone(f.game);
const territory = splitLocation(f.destination).territory;
assert.equal(presenceAt(seat(original,'richese'),territory),1);
assert.equal(original.response?.kind,'fremenMovement');
assert.equal(viewGame(original,'richese').richeseNoField?.canReveal,true);
const response = structuredClone(original.response), order = structuredClone(original.pendingFremenMove);
const after = act(original,'richese',{type:'revealNoField',token:f.marker,event:f.event});
assert.equal(presenceAt(seat(after,'richese'),territory),0);
assert.deepEqual(after.response,response);
assert.deepEqual(after.pendingFremenMove,order);
assert.deepEqual(seat(after,f.mover).forces,seat(f.beforeMove,f.mover).forces);
const control = finishPending(reload(original)), done = finishPending(reload(after));
assert.equal(presenceAt(seat(control,'richese'),territory),1);
assert.equal(presenceAt(seat(done,'richese'),territory),0);
assert.deepEqual(seat(done,f.mover),seat(control,f.mover));
assert.equal(seat(done,f.mover).forces[f.destination],1);
assert.equal(seat(done,f.mover).moved,seat(f.beforeMove,f.mover).moved+1);
});
const cached = new Map<string, ReturnType<typeof fixture>>();
function source(kind: Kind, value: 0 | 3 = 3) {
const key = `${kind}:${value}`;
if (!cached.has(key)) cached.set(key,fixture(kind,value));
return cached.get(key)!;
}
function reveal(kind: Kind, value: 0 | 3 = 3) {
const f = source(kind,value), g = structuredClone(f.game);
const pending = structuredClone(kind === 'choamMovement' ? g.pendingChoamMove :
kind === 'fremenMovement' ? g.pendingFremenMove : g.pendingIxMove);
const control = structuredClone(kind === 'choamMovement' ? g.decision : g.response);
const before = structuredClone(seat(g,'richese')), inventory = cards(g);
assert.equal(viewGame(g,'richese').richeseNoField?.canReveal,true);
const action: Action = {type:'revealNoField', token:f.marker, event:f.event};
const invalid = structuredClone(g);
assert.throws(() => act(invalid,f.mover,action)); assert.deepEqual(invalid,g);
const after = act(g,'richese',action);
assert.deepEqual(kind === 'choamMovement' ? after.pendingChoamMove :
kind === 'fremenMovement' ? after.pendingFremenMove : after.pendingIxMove,pending);
assert.deepEqual(kind === 'choamMovement' ? after.decision : after.response,control);
assert.deepEqual(seat(after,f.mover).forces,seat(g,f.mover).forces);
assert.equal(seat(after,'richese').noField?.deployed,null);
assert.equal(seat(after,'richese').reserves,before.reserves-value);
assert.equal(seat(after,'richese').forces['polar_sink:0'] ?? 0,(before.forces['polar_sink:0'] ?? 0)+value);
assert.deepEqual(cards(after),inventory);
assert.throws(() => act(after,'richese',action));
return after;
}
function finishPending(g: Game) {
for (let n = 0; n < 40; n++) {
if (g.pendingKull?.stage === 'offer') {
g = act(g,g.pendingKull.player,{type:'kullDecision',event:g.pendingKull.event,decline:true}); continue;
}
if (g.pendingTreacheryDiscard) {
const reaction = g.pendingTreacheryDiscard.reaction; assert.ok(reaction?.stage === 'offer');
const actor = g.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
g = act(g,actor.id,{type:'semutaPass',event:g.pendingTreacheryDiscard.batch.event}); continue;
}
if (g.response) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor);
g = act(g,actor.id,{type:'passResponse'}); continue;
}
if (g.decision?.kind === 'choamMovement') {
g = act(g,g.decision.player,{type:'decision',decline:true}); continue;
}
return g;
}
throw Error('The original movement response did not finish');
}
for (const kind of ['fremenMovement','ixMovement','choamMovement'] as const) {
for (const value of [0,3] as const) void test(`Basic ${kind} retains its original physical move through No-Field ${value} reveal`, () => {
const f = source(kind,value), after = reveal(kind,value);
const control = finishPending(reload(f.game)), done = finishPending(reload(after));
assert.deepEqual(seat(done,f.mover),seat(control,f.mover));
assert.equal(seat(done,f.mover).forces[f.destination],(seat(f.beforeMove,f.mover).forces[f.destination] ?? 0)+1);
assert.equal(seat(done,f.mover).forces[f.origin] ?? 0,(seat(f.beforeMove,f.mover).forces[f.origin] ?? 0)-1);
assert.equal(seat(done,f.mover).moved,seat(f.beforeMove,f.mover).moved+1);
assert.equal(seat(done,'richese').forces['polar_sink:0'] ?? 0,value);
});
void test(`Basic ${kind} cancellation after reveal preserves unspent movement`, () => {
const f = source(kind), after = reveal(kind);
let pending = kind === 'choamMovement'
? act(after,'choam',{type:'card',card:f.baliset,mode:'choam',target:f.mover,territory:splitLocation(f.destination).territory})
: act(after,'emperor',{type:'card',card:f.karama,mode:'cancel'});
pending = finishPending(pending);
assert.deepEqual(seat(pending,f.mover).forces,seat(f.beforeMove,f.mover).forces);
assert.equal(seat(pending,f.mover).moved,seat(f.beforeMove,f.mover).moved);
assert.equal(seat(pending,'richese').forces['polar_sink:0'],3);
});
void test(`all bot profiles have a legal response to preserved Basic ${kind}`, () => {
const after = reveal(kind), actor = kind === 'choamMovement' ? 'choam' : 'emperor';
for (const difficulty of DIFFICULTIES) {
const view = viewGame(after,actor); view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action,difficulty);
assert.doesNotThrow(() => act(reload(after),actor,action));
}
});
}