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);
|
||||
});
|
||||
Reference in new issue
Block a user