Expand Basic Richese movement and worm interactions
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
3dbd326891
commit
605cb2a32f
12 files changed
+637
-8
No files matched your search
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actBasicSemutaAmbassador as act, nextBasicSemutaAmbassadorStep,
|
||||
basicSemutaAmbassadorPlayer as player, basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
type Effect = 'choam' | 'ixians';
|
||||
const holder = 'guild', owner = 'ecaz', rider = 'fremen';
|
||||
/** Public setup, paid Ambassador placement, real Spice Blow/Nexus and Fremen
|
||||
* ride. Conserved staging chooses the supplied Ambassador packet, exchanges Semuta
|
||||
* from the cache for Guild's dealt card in the deck, moves one starting Fremen counter to
|
||||
* the worm's desert, and selects the first two unopened Spice cards. */
|
||||
function source(effect: Effect, copied: boolean) {
|
||||
let g = createGame('SEMUTAWORM', newPlayer(owner, 'Ecaz', 'ecaz'), 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, owner, {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;
|
||||
}
|
||||
const next = g.decision?.kind === 'richeseCache' ? nextRicheseStrongholdsNativeStep(g) : nextBasicSemutaAmbassadorStep(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: (g: Game) => boolean) => {
|
||||
for (let n = 0; n < 2000; n++) {
|
||||
if (condition(g)) return;
|
||||
assert.notEqual(g.status, 'finished'); step();
|
||||
}
|
||||
throw Error('Native worm source did not reach the requested phase');
|
||||
};
|
||||
reach(state => state.status === 'playing');
|
||||
const packet = g.ecazAmbassadors!, selected = packet.tokens.find(t => t.effect === (copied ? 'beneGesserit' : effect))!;
|
||||
const swap = (included: boolean, tokenId: string) => {
|
||||
if (packet.cohort.includes(tokenId) === included) return;
|
||||
const other = packet.cohort.find(id => id !== selected.id && id !== packet.tokens.find(t => t.effect === effect)!.id)!;
|
||||
const replacement = packet.tokens.find(t => t.id === tokenId)!;
|
||||
const displaced = packet.tokens.find(t => t.id === other)!;
|
||||
if (included) {
|
||||
packet.cohort[packet.cohort.indexOf(other)] = tokenId;
|
||||
displaced.zone = 'pool'; replacement.zone = 'supply';
|
||||
} else {
|
||||
const candidate = packet.tokens.find(t => t.zone === 'pool' && t.id !== selected.id && t.id !== tokenId)!;
|
||||
packet.cohort[packet.cohort.indexOf(tokenId)] = candidate.id;
|
||||
replacement.zone = 'pool'; candidate.zone = 'supply';
|
||||
}
|
||||
};
|
||||
swap(true, selected.id);
|
||||
if (copied) swap(false, packet.tokens.find(t => t.effect === effect)!.id);
|
||||
const guild = player(g, holder), semutaIndex = g.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
||||
assert.ok(semutaIndex >= 0 && guild.hand.length);
|
||||
const semuta = g.richeseCache!.splice(semutaIndex,1)[0];
|
||||
g.deck.push(guild.hand[0]); guild.hand[0] = semuta;
|
||||
const sourceForce = Object.keys(player(g, rider).forces).find(key => player(g, rider).forces[key] > 0)!;
|
||||
assert.ok(sourceForce);
|
||||
player(g, rider).forces[sourceForce]--;
|
||||
if (!player(g, rider).forces[sourceForce]) delete player(g, rider).forces[sourceForce];
|
||||
player(g, rider).forces['the_great_flat:15'] = 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 === 4 && !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
const destination = ['carthag','arrakeen','habbanya_ridge_sietch','tueks_sietch']
|
||||
.find(id => territory(id).sectors.some(sector => sector !== g.storm))!;
|
||||
assert.ok(destination);
|
||||
const sector = territory(destination).sectors.find(value => value !== g.storm)!;
|
||||
g = act(g, owner, {type:'decision',token:selected.id,territory:destination});
|
||||
reach(state => !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
g = act(g, owner, {type:'decision',decline:true});
|
||||
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');
|
||||
assert.notEqual(g.storm, 15);
|
||||
g = act(g, rider, {type:'decision',accept:true,territory:destination,sector,
|
||||
forces:{'the_great_flat:15':1}});
|
||||
assert.equal(g.pendingAmbassador?.arrivalCause, 'wormRide');
|
||||
assert.equal(g.pendingAmbassador?.resume, 'wormRide');
|
||||
const event = g.pendingAmbassador!.event;
|
||||
g = act(g, owner, {type:'decision',event,trigger:true,beneficiary:owner,choice:'effect'});
|
||||
if (copied) g = act(g, owner, {type:'decision',event,effect});
|
||||
assert.equal(g.pendingAmbassador?.stage,'cards');
|
||||
const cards = player(g, owner).hand.slice(0, effect === 'choam' ? 2 : 1).map(c => c.id);
|
||||
assert.ok(cards.length >= 1);
|
||||
if (effect === 'ixians') assert.equal(cards.length, 1);
|
||||
return {g,event,cards,destination,sector,original:inventory(g)};
|
||||
}
|
||||
|
||||
for (const effect of ['choam','ixians'] as const) for (const copied of [false,true])
|
||||
for (const claim of [false,true]) void test(
|
||||
`Basic worm ride ${copied ? 'BG copy' : 'native'} ${effect} preserves entry after Semuta ${claim ? 'claim' : 'pass'}`, () => {
|
||||
const f = source(effect,copied), before = structuredClone(f.g);
|
||||
const spice = player(before,owner).spice, top = before.deck[0]?.id;
|
||||
let g = act(before,owner,{type:'decision',event:f.event,cards:f.cards});
|
||||
const pending = g.pendingTreacheryDiscard!;
|
||||
assert.equal(pending?.continuation.kind,'ambassador');
|
||||
assert.equal(pending?.reaction?.stage,'offer');
|
||||
assert.equal(pending.batch.cause,`ambassador:${effect}`);
|
||||
assert.deepEqual(pending.batch.entries.map(e => e.card.id),f.cards);
|
||||
assert.ok(pending.batch.entries.every(e => e.discardedBy === owner && !e.publicFace));
|
||||
assert.deepEqual(g.deck,before.deck);
|
||||
assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? f.cards.length * 3 : 0));
|
||||
assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1);
|
||||
for (const p of g.players) {
|
||||
const projected = viewGame(g,p.id).semutaReaction!;
|
||||
assert.deepEqual(projected.candidates,[]); assert.equal(projected.canCommit,p.id === holder);
|
||||
}
|
||||
const event = pending.batch.event;
|
||||
if (claim) {
|
||||
g = act(g,holder,{type:'semutaCommit',event});
|
||||
if (g.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
g = act(g,holder,{type:'semutaSelect',event,card:f.cards[0]});
|
||||
} else for (const p of g.players) g = act(g,p.id,{type:'semutaPass',event});
|
||||
assert.equal(g.pendingTreacheryDiscard,null); assert.equal(g.pendingAmbassador,null);
|
||||
assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1);
|
||||
assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? f.cards.length * 3 : 0));
|
||||
assert.equal(player(g,holder).hand.some(c => c.id === f.cards[0]),claim);
|
||||
if (effect === 'ixians') assert.ok(player(g,owner).hand.some(c => c.id === top));
|
||||
assert.deepEqual(inventory(g),f.original);
|
||||
assert.throws(() => act(g,holder,{type:'semutaCommit',event}));
|
||||
assert.notEqual(g.decision?.kind,'ecazAmbassador');
|
||||
});
|
||||
|
||||
void test('a conserved staged remaining ride is bound to the discard and continues only once', () => {
|
||||
const f = source('ixians',false), before = structuredClone(f.g);
|
||||
// Supplement the real first ride with a conserved queued second ride.
|
||||
const fremen = player(before,rider); assert.ok(fremen.reserves > 0);
|
||||
fremen.reserves--; fremen.forces['hagga_basin:12'] = (fremen.forces['hagga_basin:12'] ?? 0) + 1;
|
||||
before.wormRides.push('hagga_basin');
|
||||
let pending = act(before,owner,{type:'decision',event:f.event,cards:f.cards});
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.deepEqual(pending.wormRides,['hagga_basin']);
|
||||
const tampered = structuredClone(pending); tampered.wormRides.push('hagga_basin');
|
||||
const saved = structuredClone(tampered);
|
||||
assert.throws(() => act(tampered,holder,{type:'semutaPass',event}));
|
||||
assert.deepEqual(tampered,saved);
|
||||
for (const p of pending.players) pending = act(pending,p.id,{type:'semutaPass',event});
|
||||
assert.equal(pending.pendingTreacheryDiscard,null);
|
||||
assert.deepEqual(pending.decision,{kind:'wormRide',player:rider,territory:'hagga_basin'});
|
||||
assert.deepEqual(pending.wormRides,[]);
|
||||
pending = act(pending,rider,{type:'decision',accept:false});
|
||||
assert.notEqual(pending.decision?.kind,'wormRide');
|
||||
assert.deepEqual(pending.wormRides,[]);
|
||||
assert.equal(player(pending,rider).forces[`${f.destination}:${f.sector}`],1);
|
||||
});
|
||||
@@ -28,13 +28,16 @@ void test('fresh public bare Basic Richese starts initialize Semuta before the o
|
||||
}
|
||||
});
|
||||
|
||||
void test('automatic Semuta initialization does not spread to Advanced, optional-module or non-Richese starts', () => {
|
||||
void test('Semuta initialization follows the current Advanced, Skills and non-Richese profiles', () => {
|
||||
const advanced = lobby(['richese', 'guild'], true);
|
||||
const optional = lobby(['richese', 'guild']); optional.lobbyOptions = { leaderSkills: true };
|
||||
for (const source of [advanced, optional, lobby(['ecaz', 'choam', 'guild'])]) {
|
||||
for (const source of [advanced, lobby(['ecaz', 'choam', 'guild'])]) {
|
||||
const started = applyAction(source, source.host, source.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
assert.equal(started.semutaPreview, undefined);
|
||||
}
|
||||
const skills = applyAction(optional, optional.host, { type: 'start' });
|
||||
assert.equal(skills.semutaPreview, true);
|
||||
assert.ok(skills.leaderSkills);
|
||||
const dirty = lobby(['richese', 'guild']); dirty.semutaPreview = true;
|
||||
assert.throws(() => applyAction(dirty, dirty.host, { type: 'start' }));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
|
||||
import { richeseRevealForeignMovementPending } from '../game/richese-reveal-foreign-movement';
|
||||
|
||||
/** Staged response packets exercise only the classifier. Native actions and
|
||||
* actual reveal/continuation are covered by the separate runtime programme. */
|
||||
function movement(kind: 'fremen' | 'ixians' | 'choam'): Game {
|
||||
const richese = newPlayer('r', 'Richese', 'richese');
|
||||
richese.noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), {
|
||||
tokenId: 'zero', controller: 'r', location: { territory: 'polar_sink', sector: 0 },
|
||||
});
|
||||
const g = createGame('MOVEQUOTE', richese, false, ['choam', 'ix']);
|
||||
const mover = newPlayer('m', 'Mover', kind === 'choam' ? 'emperor' : kind);
|
||||
mover.forces = { 'arrakeen:10': 3 };
|
||||
if (mover.elites) mover.elites.forces = { 'arrakeen:10': 1 };
|
||||
const choam = newPlayer('c', 'CHOAM', 'choam');
|
||||
choam.forces = { 'carthag:11': 1 };
|
||||
g.players.push(mover, choam);
|
||||
Object.assign(g, { status: 'playing', phase: 5, turn: 2, active: 'm', decision: null, response: null });
|
||||
const order: NonNullable<Game['pendingIxMove']> = {
|
||||
player: 'm', group: [['arrakeen:10', 3]], eliteGroup: { 'arrakeen:10': kind === 'ixians' ? 1 : 0 },
|
||||
elite: kind === 'ixians' ? 1 : 0, origin: 'arrakeen', origins: ['arrakeen'],
|
||||
total: 3, to: 'carthag', sector: 11, advisors: false, wantsFighters: false,
|
||||
};
|
||||
if (kind === 'fremen') {
|
||||
g.pendingFremenMove = { turn: g.turn, move: 0, order };
|
||||
g.response = { kind: 'fremenMovement', owner: 'm', location: 'carthag:11', amount: 3, passed: [] };
|
||||
} else if (kind === 'ixians') {
|
||||
g.pendingIxMove = order;
|
||||
g.response = { kind: 'ixMovement', owner: 'm', location: 'carthag:11', amount: 3, elite: 1, passed: [] };
|
||||
} else {
|
||||
g.pendingChoamMove = order;
|
||||
g.decision = { kind: 'choamMovement', player: 'c', mover: 'm', territory: 'carthag', sector: 11, amount: 3 };
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('all three ordinary foreign-movement parents are classified without mutation', () => {
|
||||
for (const [kind, expected] of [
|
||||
['fremen', 'pendingFremenMove'], ['ixians', 'pendingIxMove'], ['choam', 'pendingChoamMove'],
|
||||
] as const) {
|
||||
const g = movement(kind), before = structuredClone(g);
|
||||
assert.equal(richeseRevealForeignMovementPending(g), expected);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('foreign movement requires the original actor, source, response and no unrelated child', () => {
|
||||
for (const kind of ['fremen', 'ixians', 'choam'] as const) {
|
||||
const g = movement(kind);
|
||||
const stale = structuredClone(g); stale.active = 'r';
|
||||
assert.equal(richeseRevealForeignMovementPending(stale), null);
|
||||
const child = structuredClone(g); child.pendingShipment = {} as NonNullable<Game['pendingShipment']>;
|
||||
assert.equal(richeseRevealForeignMovementPending(child), null);
|
||||
const wrongPhase = structuredClone(g); wrongPhase.phase = 4;
|
||||
assert.equal(richeseRevealForeignMovementPending(wrongPhase), null);
|
||||
const noMarker = structuredClone(g); noMarker.players[0].noField!.deployed = null;
|
||||
assert.equal(richeseRevealForeignMovementPending(noMarker), null);
|
||||
const lostForces = structuredClone(g); lostForces.players[1].forces = {};
|
||||
assert.equal(richeseRevealForeignMovementPending(lostForces), null);
|
||||
if (kind === 'choam') {
|
||||
const wrong = structuredClone(g);
|
||||
if (wrong.decision?.kind === 'choamMovement') wrong.decision.amount++;
|
||||
assert.equal(richeseRevealForeignMovementPending(wrong), null);
|
||||
} else {
|
||||
const wrong = structuredClone(g); wrong.response!.location = 'arrakeen:10';
|
||||
assert.equal(richeseRevealForeignMovementPending(wrong), null);
|
||||
const duplicate = structuredClone(g); duplicate.response!.passed = ['r', 'r'];
|
||||
assert.equal(richeseRevealForeignMovementPending(duplicate), null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('marker identity and denomination are not needed to identify a foreign movement', () => {
|
||||
const g = movement('fremen');
|
||||
g.players[0].noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), {
|
||||
tokenId: 'five', controller: 'r', location: { territory: 'arrakeen', sector: 10 },
|
||||
});
|
||||
assert.equal(richeseRevealForeignMovementPending(g), 'pendingFremenMove');
|
||||
g.players[0].noField!.deployed!.controller = 'm';
|
||||
assert.equal(richeseRevealForeignMovementPending(g), null);
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user