Extend Semuta ordinary discards to native factions
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
0f8e0b4153
commit
58303654e8
8 files changed
+323
-6
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { placeTerror } from '../game/moritani-terror';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
|
||||
const player = (game: Game, id: string) => {
|
||||
const seat = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(seat); return seat;
|
||||
};
|
||||
|
||||
/** Genuine original Discovery and Fremen/Moritani setup. The first movement
|
||||
* position, Terror placement and the later unplayed Spice ordering are staged;
|
||||
* the original Atomics reveal, Great Maker vote and reserve ride are actions. */
|
||||
function encounterAfterAtomics() {
|
||||
let game = createGame('MAKERATOMICS', newPlayer('m','Moritani','moritani'), false, ['ecaz']);
|
||||
joinGame(game,newPlayer('f','Fremen','fremen'));
|
||||
joinGame(game,newPlayer('a','Atreides','atreides'));
|
||||
game.discoveryEnabled = true;
|
||||
for (const seat of game.players) game = applyAction(game,seat.id,{type:'ready'});
|
||||
game = initializeDiscoveryGameForAudit(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 150; count++) {
|
||||
if (game.decision?.kind === 'moritaniSetup')
|
||||
game = applyAction(game,game.decision.player,{type:'decision',territory:'carthag',sector:11});
|
||||
else {
|
||||
const next = nextSpiceBankerIncomeNativeStep(game); assert.ok(next);
|
||||
game = applyAction(game,next.actor,next.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.status,'playing');
|
||||
const atomics = game.moritaniTerror!.tokens.find(token => token.kind === 'atomics'); assert.ok(atomics);
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror!,atomics.id,'arrakeen',1);
|
||||
Object.assign(game,{turn:2,phase:5,storm:18,active:'a',order:['a','m','f'],
|
||||
movementRemaining:['a','m','f'],ready:[],response:null,decision:null,phaseOpening:null});
|
||||
for (const seat of game.players) {
|
||||
game.deck.push(...seat.hand);
|
||||
Object.assign(seat,{hand:[],forces:{},reserves:20,tanks:0,ally:null,shipped:false,moved:0,spice:20});
|
||||
if (seat.elites) Object.assign(seat.elites,{reserves:seat.faction === 'fremen' ? 3 : 0,tanks:0,forces:{},revived:0});
|
||||
}
|
||||
game = applyAction(game,'a',{type:'ship',territory:'arrakeen',sector:10,amount:1,elite:0,allyPayment:0});
|
||||
assert.equal(game.pendingTerrorEntry?.stage,'offer');
|
||||
game = applyAction(game,'m',{type:'decision',reveal:true});
|
||||
assert.equal(game.moritaniAtomics?.territory,'arrakeen');
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === atomics.id)?.status,'removed');
|
||||
|
||||
const find = (predicate: (card: Game['spiceDeck'][number]) => boolean) => {
|
||||
const index = game.spiceDeck.findIndex(predicate); assert.ok(index >= 0);
|
||||
return game.spiceDeck.splice(index,1)[0];
|
||||
};
|
||||
const maker = find(card => 'worm' in card && card.greatMaker === true);
|
||||
const source = find(card => 'territory' in card && card.territory === 'broken_land' && !card.discovery);
|
||||
const replacement = find(card => 'territory' in card && !card.discovery);
|
||||
assert.ok('territory' in source);
|
||||
Object.assign(game,{phase:1,storm:18,nexus:false,summonedBeforeBlow:false,wormRides:[],
|
||||
spiceSequence:null,spiceResolution:null,spiceWindow:null,response:null,decision:null,phaseOpening:null,ready:[]});
|
||||
game.spiceDiscard = [[source],[]];
|
||||
game.spiceDeck = [maker,replacement,...game.spiceDeck];
|
||||
game.spice = {[`${source.territory}:${source.sector}`]:7};
|
||||
for (const seat of game.players) game = applyAction(game,seat.id,{type:'ready'});
|
||||
assert.equal(game.greatMaker?.stage,'vote');
|
||||
while (game.decision?.kind === 'greatMakerVote')
|
||||
game = applyAction(game,game.decision.player,{type:'decision',event:game.decision.event,yes:false});
|
||||
assert.equal(game.greatMaker?.stage,'ride');
|
||||
assert.equal(game.decision?.player,'f');
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Great Maker reserve ride may enter Atomics Aftermath without becoming a shipment', () => {
|
||||
const game = encounterAfterAtomics(), view = viewGame(game,'f');
|
||||
assert.ok(view.greatMaker?.ride?.destinations.some(destination =>
|
||||
destination.territory === 'arrakeen' && destination.sector === 10));
|
||||
const before = player(game,'f'), count = before.reserves, spice = before.spice;
|
||||
const shipped = before.shipped, moved = before.moved;
|
||||
const event = game.greatMaker!.event;
|
||||
const done = applyAction(game,'f',{type:'decision',event,accept:true,
|
||||
territory:'arrakeen',sector:10,amount:2,elite:0});
|
||||
assert.equal(player(done,'f').reserves,count-2);
|
||||
assert.equal(player(done,'f').forces['arrakeen:10'],2);
|
||||
assert.equal(player(done,'f').spice,spice);
|
||||
assert.equal(player(done,'f').shipped,shipped);
|
||||
assert.equal(player(done,'f').moved,moved);
|
||||
assert.equal(done.moritaniAtomics?.territory,'arrakeen');
|
||||
assert.equal(done.greatMaker?.stage,'complete');
|
||||
for (const seat of done.players)
|
||||
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum,n) => sum+n,0),20);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory,
|
||||
basicSemutaAmbassadorPlayer as seat } from './fixture-basic-semuta-ambassador';
|
||||
|
||||
type Effect = 'hajr' | 'ghola' | 'weather' | 'harvester' | 'atomics';
|
||||
type Native = 'tleilaxu' | 'ecaz';
|
||||
const act = (game: Game, player: string, action: Action) => applyAction(game, player, action);
|
||||
|
||||
/** Real public selected-family setup, cache purchase, ordinary auction and
|
||||
* shipment. Unopened main/Spice order is selected to shorten the route. When
|
||||
* the native faction is the actor, the bought physical card moves between
|
||||
* hands after the auction to exercise that faction's own control. */
|
||||
function nativeFixture(effect: Effect, native: Native, actor: 'atreides' | Native = 'atreides') {
|
||||
let game = createGame('SEMUTANATIVEORDINARY', newPlayer('atreides', 'Atreides', 'atreides'), false,
|
||||
['choam', 'ix', 'ecaz']);
|
||||
for (const faction of ['richese', 'guild', native] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const owner = actor, buyer = 'atreides', holder = 'guild';
|
||||
const send = (actor: string, action: Action) => { game = act(game, actor, action); };
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const reaction = game.pendingTreacheryDiscard?.reaction;
|
||||
if (reaction?.stage === 'offer') {
|
||||
const responder = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(responder);
|
||||
send(responder.id, {type:'semutaPass', event:game.pendingTreacheryDiscard!.batch.event});
|
||||
continue;
|
||||
}
|
||||
const step = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
send(step.actor, step.action);
|
||||
}
|
||||
throw Error('The selected native Semuta fixture did not reach its boundary.');
|
||||
};
|
||||
send(buyer, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
for (const player of game.players) send(player.id, {type:'ready'});
|
||||
send(buyer, {type:'start'});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
const original = inventory(game);
|
||||
const starting = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind))
|
||||
.slice(0, game.players.length).map(card => card.id);
|
||||
assert.equal(starting.length, game.players.length);
|
||||
const card = game.deck.find(item => item.effect === effect); assert.ok(card);
|
||||
for (const [position, id] of [...starting, card.id].entries()) {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
}
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step); send(step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
for (let position = 0; position < 4; position++) {
|
||||
const from = game.spiceDeck.findIndex((item, index) => index >= position && 'territory' in item && !item.discovery);
|
||||
assert.ok(from >= position);
|
||||
const item = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, item);
|
||||
}
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
||||
send('richese', {type:'decision', event:game.richeseBidding!.event, card:SEMUTA_DRUG_ID,
|
||||
method:'onceAround', direction:'counterclockwise'});
|
||||
reach(state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
||||
for (let count = 0; !seat(game, holder).hand.some(item => item.id === SEMUTA_DRUG_ID) && count < 8; count++) {
|
||||
const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome);
|
||||
send(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0});
|
||||
reach(clean);
|
||||
}
|
||||
assert.ok(seat(game, holder).hand.some(item => item.id === SEMUTA_DRUG_ID));
|
||||
reach(state => state.phase === 3 && clean(state) && state.auction?.active === buyer);
|
||||
assert.equal(game.auction!.cards[game.auction!.index].id, card.id);
|
||||
send(buyer, {type:'bid', amount:1, allyPayment:0});
|
||||
reach(state => clean(state) && seat(state, buyer).hand.some(item => item.id === card.id));
|
||||
if (owner !== buyer) {
|
||||
const source = seat(game, buyer), recipient = seat(game, owner);
|
||||
const index = source.hand.findIndex(item => item.id === card.id); assert.ok(index >= 0);
|
||||
recipient.hand.push(...source.hand.splice(index, 1));
|
||||
assert.deepEqual(inventory(game), original);
|
||||
}
|
||||
reach(state => state.phase === 5 && state.active === owner && clean(state));
|
||||
if (effect === 'hajr') return {game, owner, holder, card:card.id, original, play:{type:'card',card:card.id} as Action};
|
||||
const sector = effect === 'atomics' ? [8,9].find(value => value !== game.storm)! : game.storm % 18 + 1;
|
||||
const target = TERRITORIES.find(place => (effect === 'atomics' ? place.id === 'shield_wall' : place.type === 'sand') &&
|
||||
place.sectors.includes(sector) && game.players.every(player =>
|
||||
!Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
|
||||
assert.ok(target);
|
||||
const casualty = `${target.id}:${sector}`;
|
||||
send(owner, {type:'ship', territory:target.id, sector, amount:3, elite:0, allyPayment:0});
|
||||
if (effect === 'weather' || effect === 'atomics')
|
||||
reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && clean(state));
|
||||
else if (effect === 'harvester')
|
||||
reach(state => state.turn === 2 && state.phase === 1 && !!state.spiceWindow && !state.spiceWindow.harvested && clean(state));
|
||||
else {
|
||||
reach(state => state.turn === 2 && state.phase === 4 && clean(state));
|
||||
assert.equal(seat(game, owner).forces[casualty], undefined);
|
||||
assert.equal(seat(game, owner).tanks, 3);
|
||||
}
|
||||
assert.deepEqual(inventory(game), original);
|
||||
const play = (effect === 'weather' ? {type:'card',card:card.id,amount:1}
|
||||
: effect === 'ghola' ? {type:'card',card:card.id,amount:3,elite:0}
|
||||
: {type:'card',card:card.id}) as Action;
|
||||
return {game, owner, holder, card:card.id, original, play, casualty};
|
||||
}
|
||||
|
||||
for (const native of ['tleilaxu','ecaz'] as const) {
|
||||
void test(`Basic native ${native} plays Hajr and Semuta resumes its actual movement`, () => {
|
||||
const f = nativeFixture('hajr', native, native);
|
||||
const pending = act(f.game, native, f.play);
|
||||
const frame = pending.pendingTreacheryDiscard; assert.ok(frame);
|
||||
assert.equal(frame.reaction?.stage, 'offer');
|
||||
assert.equal(frame.continuation.kind, 'ordinaryCardDiscard');
|
||||
assert.equal(frame.continuation.player, native);
|
||||
assert.ok(pending.hajr.includes(native));
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
||||
let done = act(pending, f.holder, {type:'semutaCommit',event:frame.batch.event});
|
||||
if (done.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
done = act(done, f.holder, {type:'semutaSelect',event:frame.batch.event,card:f.card});
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.ok(done.hajr.includes(native));
|
||||
assert.equal(seat(done, f.holder).hand.some(item => item.id === f.card), true);
|
||||
assert.deepEqual(inventory(done), f.original);
|
||||
done = act(done, native, {type:'endMovement'});
|
||||
assert.ok(!done.movementRemaining?.includes(native));
|
||||
});
|
||||
}
|
||||
|
||||
for (const effect of ['hajr','ghola','weather','harvester','atomics'] as const) {
|
||||
for (const native of ['tleilaxu','ecaz'] as const) for (const claim of [false,true]) {
|
||||
void test(`Basic ${native} ${effect} discard offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => {
|
||||
const f = nativeFixture(effect, native), original = structuredClone(f.game);
|
||||
const prior = seat(f.game, f.owner);
|
||||
let game = act(f.game, f.owner, f.play);
|
||||
assert.deepEqual(f.game, original);
|
||||
const frame = game.pendingTreacheryDiscard; assert.ok(frame);
|
||||
assert.equal(frame.reaction?.stage, 'offer');
|
||||
assert.equal(frame.continuation.kind, 'ordinaryCardDiscard');
|
||||
assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [f.card]);
|
||||
if (effect === 'hajr') assert.ok(game.hajr.includes(f.owner));
|
||||
if (effect === 'ghola') {
|
||||
assert.equal(seat(game, f.owner).reserves, prior.reserves + 3);
|
||||
assert.equal(seat(game, f.owner).tanks, prior.tanks - 3);
|
||||
}
|
||||
if (effect === 'weather') assert.equal(game.stormPending, 1);
|
||||
if (effect === 'harvester') {
|
||||
assert.equal(game.spiceWindow!.amount, f.game.spiceWindow!.amount * 2);
|
||||
assert.equal(game.spiceWindow!.harvested, true);
|
||||
}
|
||||
if (effect === 'atomics') {
|
||||
assert.equal(game.shieldWallDestroyed, true);
|
||||
assert.equal(seat(game, f.owner).forces[f.casualty!], undefined);
|
||||
assert.equal(seat(game, f.owner).tanks, prior.tanks + 3);
|
||||
}
|
||||
const committed = {phase:game.phase, turn:game.turn, storm:game.storm, stormPending:game.stormPending,
|
||||
hajr:game.hajr, spice:game.spice, spiceWindow:game.spiceWindow,
|
||||
shieldWallDestroyed:game.shieldWallDestroyed, forces:seat(game, f.owner).forces,
|
||||
reserves:seat(game, f.owner).reserves, tanks:seat(game, f.owner).tanks};
|
||||
if (effect === 'hajr' && native === 'tleilaxu') for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.holder); view.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'));
|
||||
}
|
||||
const event = frame.batch.event;
|
||||
if (claim) {
|
||||
game = act(game, f.holder, {type:'semutaCommit',event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, f.holder, {type:'semutaSelect',event,card:f.card});
|
||||
} else for (const player of game.players) game = act(game, player.id, {type:'semutaPass',event});
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual({phase:game.phase, turn:game.turn, storm:game.storm, stormPending:game.stormPending,
|
||||
hajr:game.hajr, spice:game.spice, spiceWindow:game.spiceWindow,
|
||||
shieldWallDestroyed:game.shieldWallDestroyed, forces:seat(game, f.owner).forces,
|
||||
reserves:seat(game, f.owner).reserves, tanks:seat(game, f.owner).tanks}, committed);
|
||||
assert.equal(seat(game, f.holder).hand.some(item => item.id === f.card), claim);
|
||||
assert.equal(game.discard.some(item => item.id === f.card), !claim);
|
||||
assert.equal(game.discard.some(item => item.id === SEMUTA_DRUG_ID), claim);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
assert.throws(() => act(game, f.holder, {type:'semutaCommit',event}));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user