Extend Semuta battles and specials to native factions
Verify and publish checkpoint container / container (push) Successful in 10m34s
Verify and publish checkpoint container / container (push) Successful in 10m34s
This commit is contained in:
1 parent
58303654e8
commit
4ee4d9fa10
6 files changed
+362
-4
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorClean as clean,
|
||||
basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador';
|
||||
|
||||
|
||||
|
||||
/** Original public selected-family setup, Richese cache sale, paid shipments
|
||||
* and real native-faction battle. Only unopened starting-deck and Spice-card
|
||||
* order are selected for this witness. */
|
||||
function createNativeBattle(kind: 'loser-reinforcements' | 'loser-harass' | 'winner-mandatory' | 'winner-selected', native: 'tleilaxu' | 'ecaz') {
|
||||
let game = createGame('SEMUTANATIVEBATTLE', newPlayer(native, native, native), false, ['choam', 'ix', 'ecaz']);
|
||||
for (const faction of ['atreides', 'richese', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const loser = native, winner = 'atreides', holder = 'guild', location = 'red_chasm:7';
|
||||
const perform = (actor: string, action: Action) => { game = act(game, actor, action); };
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
for (let count = 0; count < 1800 && !until(game); count++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
const reaction = game.pendingTreacheryDiscard.reaction;
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id));
|
||||
assert.ok(actor);
|
||||
perform(actor.id, {type:'semutaPass', event:game.pendingTreacheryDiscard.batch.event});
|
||||
} else {
|
||||
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step, `Native step required at ${game.phase}`);
|
||||
perform(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.ok(until(game), 'The real game must reach the requested boundary.');
|
||||
};
|
||||
perform(loser, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
for (const seat of game.players) perform(seat.id, {type:'ready'});
|
||||
perform(loser, {type:'start'});
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
const originalInventory = inventory(game);
|
||||
const cardId = kind === 'loser-harass' ? 'ecaz-harass-withdraw' : 'ecaz-reinforcements';
|
||||
const shield = game.deck.find(card => card.kind === 'shield'); assert.ok(shield);
|
||||
const reserved = new Set([cardId, shield.id]);
|
||||
const filler = game.deck.filter(card => !reserved.has(card.id)).slice(0, 2); assert.equal(filler.length, 2);
|
||||
const starting = kind === 'winner-mandatory'
|
||||
? [filler[0].id, cardId, filler[1].id, shield.id]
|
||||
: [cardId, shield.id, filler[0].id, filler[1].id];
|
||||
for (let index = 0; index < starting.length; index++) {
|
||||
const from = game.deck.findIndex(card => card.id === starting[index]); assert.ok(from >= index);
|
||||
[game.deck[index], game.deck[from]] = [game.deck[from], game.deck[index]];
|
||||
}
|
||||
reach(state => state.status === 'playing');
|
||||
assert.ok(player(game, kind === 'winner-mandatory' ? winner : loser).hand.some(card => card.id === cardId));
|
||||
if (kind !== 'winner-mandatory') assert.ok(player(game, winner).hand.some(card => card.id === shield.id));
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const from = game.spiceDeck.findIndex((card, pos) => pos >= index && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= index); game.spiceDeck.splice(index, 0, game.spiceDeck.splice(from, 1)[0]);
|
||||
}
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
||||
perform('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; !player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
|
||||
const lot = game.richeseAuction; assert.ok(lot?.active && !lot.outcome);
|
||||
perform(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0});
|
||||
reach(clean);
|
||||
}
|
||||
assert.ok(player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
const shipped = new Set<string>();
|
||||
while (shipped.size < 2) {
|
||||
reach(state => state.phase === 5 && !!state.active && clean(state));
|
||||
const actor = game.active!;
|
||||
if ([loser, winner].includes(actor) && !shipped.has(actor)) {
|
||||
perform(actor, {type:'ship', ...splitLocation(location), amount:actor === winner ? 4 : 1, elite:0, allyPayment:0});
|
||||
reach(clean); shipped.add(actor);
|
||||
}
|
||||
perform(actor, {type:'endMovement'});
|
||||
}
|
||||
reach(state => state.phase === 6 && !state.battle && clean(state));
|
||||
const actor = game.active!;
|
||||
const choice = viewGame(game, actor).battleChoices.find(row => row.territory === 'red_chasm' &&
|
||||
[row.attacker, row.defender].includes(loser) && [row.attacker, row.defender].includes(winner));
|
||||
assert.ok(choice);
|
||||
perform(choice.chooser, {type:'chooseBattle', territory:choice.territory,
|
||||
target:choice.attacker === choice.chooser ? choice.defender : choice.attacker});
|
||||
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && clean(state));
|
||||
const strongest = player(game, winner).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => b.strength-a.strength)[0];
|
||||
const weakest = player(game, loser).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => a.strength-b.strength)[0];
|
||||
assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength + 2);
|
||||
perform(winner, {type:'battlePlan', dial:1, support:0, leader:strongest.id, weapon:null,
|
||||
defense:kind === 'winner-mandatory' ? cardId : shield.id});
|
||||
perform(loser, {type:'battlePlan', dial:0, support:0, leader:weakest.id,
|
||||
weapon:kind === 'winner-mandatory' ? null : cardId, defense:null});
|
||||
let final: {actor:string;action:Action} | undefined;
|
||||
for (let count = 0; count < 100; count++) {
|
||||
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step);
|
||||
if (step.action.type === 'traitorCall') {
|
||||
const voters = viewGame(game, game.host).battle!.traitorVoters;
|
||||
if (voters.every(id => id === step.actor || game.battle!.traitorCalls[id] !== undefined)) { final = step; break; }
|
||||
}
|
||||
perform(step.actor, step.action);
|
||||
}
|
||||
assert.ok(final);
|
||||
return {game, loser, winner, holder, location, card:cardId, shield:shield.id, originalInventory,
|
||||
finalActor:final.actor, finalAction:final.action, kind};
|
||||
}
|
||||
|
||||
|
||||
function react(game: Game, holder: string, claim: boolean, selected: string) {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
const event = pending.batch.event;
|
||||
if (!claim) {
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
|
||||
return game;
|
||||
}
|
||||
game = act(game, holder, {type:'semutaCommit', event});
|
||||
return game.pendingTreacheryDiscard?.reaction?.stage === 'select'
|
||||
? act(game, holder, {type:'semutaSelect', event, card:selected}) : game;
|
||||
}
|
||||
function advance(game: Game, until: (state: Game) => boolean) {
|
||||
for (let count = 0; count < 600 && !until(game); count++) {
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = react(game, '', false, '');
|
||||
else {
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, `Original battle needs a step at ${game.phase}/${game.decision?.kind}`);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
}
|
||||
assert.ok(until(game)); return game;
|
||||
}
|
||||
for (const native of ['tleilaxu','ecaz'] as const)
|
||||
for (const kind of ['loser-reinforcements','loser-harass','winner-mandatory','winner-selected'] as const)
|
||||
for (const claim of [false,true]) void test(`Basic ${native} ${kind} offers Semuta: ${claim ? 'claim' : 'pass'}`, () => {
|
||||
const f = createNativeBattle(kind, native);
|
||||
let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction));
|
||||
if (kind === 'winner-selected') {
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
|
||||
game = react(game, f.holder, false, f.card);
|
||||
game = advance(game, state => state.decision?.kind === 'battleCards' && state.decision.player === f.winner);
|
||||
game = act(game, f.winner, {type:'decision', discard:[f.shield]});
|
||||
}
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
assert.equal(pending.batch.cause, kind === 'winner-selected' ? 'battle:winner' :
|
||||
kind === 'winner-mandatory' ? 'battle:winnerMandatory' : 'battle:mandatory');
|
||||
const card = kind === 'winner-selected' ? f.shield : f.card;
|
||||
assert.ok(pending.batch.entries.some(entry => entry.card.id === card));
|
||||
const before = structuredClone(game);
|
||||
if (native === 'tleilaxu') {
|
||||
assert.equal(game.pendingFaceDance?.player, native);
|
||||
assert.equal(game.pendingFaceDance?.winner, f.winner);
|
||||
}
|
||||
if (native === 'tleilaxu' && kind === 'loser-reinforcements') for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.holder); view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(view).find(option => option.type === 'semutaCommit' || option.type === 'semutaPass');
|
||||
assert.ok(action); assert.doesNotThrow(() => act(game, f.holder, action));
|
||||
}
|
||||
game = react(game, f.holder, claim, card);
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(game, f.loser).tanks, player(before, f.loser).tanks);
|
||||
assert.equal(game.lastBattleContext?.event, before.lastBattleContext?.event);
|
||||
assert.equal(player(game, f.holder).hand.some(held => held.id === card), claim);
|
||||
assert.equal(game.discard.some(discarded => discarded.id === card), !claim);
|
||||
assert.equal(game.discard.some(discarded => discarded.id === SEMUTA_DRUG_ID), claim);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.throws(() => act(game, f.holder, {type:'semutaCommit',event:pending.batch.event}));
|
||||
if (native === 'tleilaxu') assert.equal(game.pendingFaceDance?.player, native);
|
||||
if (native === 'tleilaxu' && kind === 'loser-reinforcements' && claim) {
|
||||
game = advance(game, state => state.decision?.kind === 'faceDance' && state.decision.player === native);
|
||||
game = act(game, native, {type:'decision', reveal:false});
|
||||
assert.equal(game.pendingFaceDance ?? null, null);
|
||||
assert.ok(player(game, f.holder).hand.some(held => held.id === card));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { distance, location, TERRITORIES } from '../game/board';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory,
|
||||
basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador';
|
||||
import { moveSemutaEmperorCard, semutaEmperorCardId } from './fixture-semuta-emperor-share';
|
||||
|
||||
type Native = 'tleilaxu' | 'ecaz';
|
||||
type Kind = 'box' | 'distrans' | 'truth' | 'ornithopter';
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
|
||||
function advance(game: Game, until: (game: Game) => boolean): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
if (pending?.reaction?.stage === 'offer') {
|
||||
const reaction = pending.reaction;
|
||||
const next = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(next);
|
||||
game = act(game, next.id, {type:'semutaPass', event:pending.batch.event});
|
||||
} else {
|
||||
const next = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(next, `The native game needs a decision at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
}
|
||||
throw Error('The original native game did not reach its requested boundary.');
|
||||
}
|
||||
|
||||
/** Public Basic setup with selected native faction decks; Guild buys the real
|
||||
* Semuta and Atreides buys Hajr. Unopened main/Spice ordering is selected.
|
||||
* A single additional physical card is moved to Atreides after its purchase. */
|
||||
function fixture(kind: Kind, native: Native) {
|
||||
let game = createGame('SEMUTANATIVESPECIALS', newPlayer('atreides','Atreides','atreides'), false,
|
||||
['choam','ix','ecaz']);
|
||||
for (const faction of ['richese','guild',native] as const)
|
||||
joinGame(game,newPlayer(faction,faction,faction));
|
||||
game = act(game,'atreides',{type:'moduleOption',module:'ecazTreachery',enabled:true});
|
||||
for (const seat of game.players) game = act(game,seat.id,{type:'ready'});
|
||||
game = act(game,'atreides',{type:'start'});
|
||||
assert.equal(game.status,'setup'); assert.equal(game.semutaRecruitsPreview,true);
|
||||
const originalInventory = inventory(game);
|
||||
const starting = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind))
|
||||
.slice(0,game.players.length).map(card => card.id);
|
||||
const hajr = game.deck.find(card => card.effect === 'hajr'); assert.ok(hajr);
|
||||
for (const [position,id] of [...starting,hajr.id].entries()) {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position],game.deck[from]] = [game.deck[from],game.deck[position]];
|
||||
}
|
||||
game = advance(game,state => state.status === 'playing');
|
||||
for (let position = 0; position < 4; position++) {
|
||||
const from = game.spiceDeck.findIndex((card,index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); game.spiceDeck.splice(position,0,game.spiceDeck.splice(from,1)[0]);
|
||||
}
|
||||
game = advance(game,state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
||||
game = act(game,'richese',{type:'decision',event:game.richeseBidding!.event,card:SEMUTA_DRUG_ID,
|
||||
method:'onceAround',direction:'counterclockwise'});
|
||||
game = advance(game,state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
||||
for (let count = 0; !player(game,'guild').hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
|
||||
const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome);
|
||||
game = act(game,lot.active,{type:'richeseBid',event:lot.event,amount:lot.active === 'guild' ? 1 : null,allyPayment:0});
|
||||
game = advance(game,clean);
|
||||
}
|
||||
assert.ok(player(game,'guild').hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
game = advance(game,state => state.phase === 3 && clean(state) && state.auction?.active === 'atreides');
|
||||
assert.equal(game.auction!.cards[game.auction!.index].id,hajr.id);
|
||||
game = act(game,'atreides',{type:'bid',amount:1,allyPayment:0});
|
||||
game = advance(game,state => clean(state) && player(state,'atreides').hand.some(card => card.id === hajr.id));
|
||||
game = advance(game,state => state.phase === 5 && state.active === 'atreides' && clean(state));
|
||||
const effect = kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : kind;
|
||||
const card = semutaEmperorCardId(game,item => item.effect === effect);
|
||||
moveSemutaEmperorCard(game,card,'atreides');
|
||||
assert.deepEqual(inventory(game),originalInventory);
|
||||
let given: string | undefined, selected: string | undefined;
|
||||
if (kind === 'box') {
|
||||
game = act(game,'atreides',{type:'card',card:hajr.id});
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
for (const seat of game.players) game = act(game,seat.id,{type:'semutaPass',event:pending.batch.event});
|
||||
selected = hajr.id;
|
||||
game = act(game,'atreides',{type:'card',card});
|
||||
if (game.pendingNullentropy)
|
||||
game = act(game,'atreides',{type:'decision',event:game.pendingNullentropy.event,card:selected});
|
||||
} else if (kind === 'distrans') {
|
||||
given = hajr.id;
|
||||
game = act(game,'atreides',{type:'card',card,target:native,give:given});
|
||||
} else if (kind === 'truth') {
|
||||
game = act(game,'atreides',{type:'card',card});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const next = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(next);
|
||||
game = act(game,next.id,{type:'truthPass'});
|
||||
}
|
||||
game = act(game,'atreides',{type:'truthAsk',question:{kind:'freeform',target:native,
|
||||
scope:'fact',text:'Do you intend to protect Arrakeen?'}});
|
||||
game = act(game,native,{type:'truthAnswer',answer:'yes'});
|
||||
} else {
|
||||
const open = TERRITORIES.flatMap(place => place.type === 'sand' ? place.sectors.map(sector => ({place,sector})) : [])
|
||||
.find(({place,sector}) => sector !== game.storm && game.players.every(seat =>
|
||||
!Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`))));
|
||||
assert.ok(open);
|
||||
game = act(game,'atreides',{type:'ship',territory:open.place.id,sector:open.sector,amount:1,elite:0,allyPayment:0});
|
||||
game = advance(game,clean);
|
||||
const from = location(open.place.id,open.sector);
|
||||
const target = TERRITORIES.flatMap(place => place.sectors.map(sector => ({place,sector})))
|
||||
.find(({place,sector}) => place.id !== open.place.id && sector !== game.storm &&
|
||||
distance(from,location(place.id,sector)) <= 1 && game.players.every(seat =>
|
||||
!Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`))));
|
||||
assert.ok(target);
|
||||
game = act(game,'atreides',{type:'move',from,amount:1,elite:0,territory:target.place.id,
|
||||
sector:target.sector,movementCard:card,ornithopter:'range3'});
|
||||
}
|
||||
return {game,card,given,selected,originalInventory};
|
||||
}
|
||||
|
||||
for (const native of ['tleilaxu','ecaz'] as const) for (const kind of ['box','distrans','truth','ornithopter'] as const)
|
||||
for (const claim of [false,true]) {
|
||||
void test(`Basic ${native} ${kind} discard offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => {
|
||||
const f = fixture(kind,native), frame = f.game.pendingTreacheryDiscard; assert.ok(frame?.reaction);
|
||||
assert.deepEqual(frame.batch.entries.map(entry => entry.card.id),[f.card]);
|
||||
assert.equal(viewGame(f.game,'guild').semutaReaction?.canCommit,true);
|
||||
if (kind === 'distrans') {
|
||||
assert.ok(player(f.game,native).hand.some(card => card.id === f.given));
|
||||
assert.ok(!frame.batch.entries.some(entry => entry.card.id === f.given));
|
||||
}
|
||||
if (kind === 'box') assert.ok(player(f.game,'atreides').hand.some(card => card.id === f.selected));
|
||||
if (kind === 'truth') assert.equal(f.game.truthHistory?.at(-1)?.answer,'yes');
|
||||
const before = structuredClone({players:f.game.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})),
|
||||
truthHistory:f.game.truthHistory,moved:player(f.game,'atreides').moved});
|
||||
if (kind === 'distrans' && native === 'tleilaxu') for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game,'guild'); view.players.find(row => row.id === 'guild')!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'));
|
||||
}
|
||||
let done = f.game;
|
||||
if (claim) {
|
||||
done = act(done,'guild',{type:'semutaCommit',event:frame.batch.event});
|
||||
if (done.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
done = act(done,'guild',{type:'semutaSelect',event:frame.batch.event,card:f.card});
|
||||
} else for (const seat of done.players) done = act(done,seat.id,{type:'semutaPass',event:frame.batch.event});
|
||||
assert.equal(done.pendingTreacheryDiscard,null);
|
||||
assert.deepEqual({players:done.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})),
|
||||
truthHistory:done.truthHistory,moved:player(done,'atreides').moved},before);
|
||||
assert.equal(player(done,'guild').hand.some(card => card.id === f.card),claim);
|
||||
assert.equal(done.discard.some(card => card.id === f.card),!claim);
|
||||
assert.equal(done.discard.some(card => card.id === SEMUTA_DRUG_ID),claim);
|
||||
assert.deepEqual(inventory(done),f.originalInventory);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user