Connect Semuta with native allied retention
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
4ee4d9fa10
commit
59579486a4
7 files changed
+297
-6
No files matched your search
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
||||
|
||||
export type SemutaIxStep = { actor: string; action: Action };
|
||||
export interface SemutaIxFixture {
|
||||
@@ -122,6 +123,7 @@ export function semutaIxLoserDiscardFixture(advanced = false): SemutaIxFixture {
|
||||
assert.equal(game.decision?.kind, 'poisonTooth');
|
||||
game = applyAction(game, f.winner, { type: 'decision', activate: true });
|
||||
for (const actor of [f.winner, f.loser]) game = applyAction(game, actor, { type: 'traitorCall', call: false });
|
||||
game = finishSemutaBattleOutcome(game);
|
||||
return { ...f, game };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import type { Card } from '../game/cards';
|
||||
import { resolveSemutaMoritaniBattle, finishSemutaMoritaniWinner,
|
||||
chooseSemutaMoritaniRetention, allowSemutaMoritaniResponse,
|
||||
passSemutaMoritaniDiscard, semutaMoritaniInventory as inventory,
|
||||
semutaMoritaniResources as resources, semutaMoritaniPlayer as seat,
|
||||
type SemutaMoritaniFixture } from './fixture-semuta-moritani-retention';
|
||||
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
|
||||
/** Actual public selected-deck setup with independent Ecaz cards. The later
|
||||
* reciprocal alliance, held physical cards and conserved battle position are
|
||||
* deliberately staged; battle plans, retention and Semuta use real actions. */
|
||||
function fixture(): SemutaMoritaniFixture & { hero: string } {
|
||||
let game = createGame('SEMUTANATIVERETENTION', newPlayer('r','Richese','richese'), false, ['choam','ecaz']);
|
||||
joinGame(game,newPlayer('m','Moritani','moritani'));
|
||||
joinGame(game,newPlayer('w','Emperor','emperor'));
|
||||
joinGame(game,newPlayer('l','Guild','guild'));
|
||||
game = act(game,'r',{type:'moduleOption',module:'ecazTreachery',enabled:true});
|
||||
for (const player of game.players) game = act(game,player.id,{type:'ready'});
|
||||
game = act(game,'r',{type:'start'});
|
||||
assert.equal(game.status,'setup');
|
||||
assert.equal(game.semutaRecruitsPreview,true);
|
||||
for (let count=0;game.status === 'setup' && count<160;count++) {
|
||||
let next: {actor:string;action:Action}|undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game,player.id);
|
||||
view.players.find(candidate => candidate.id === player.id)!.bot = 'Medium';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {next = {actor:player.id,action};break;}
|
||||
}
|
||||
assert.ok(next, 'Original setup must offer a legal decision.');
|
||||
game = act(game,next.actor,next.action);
|
||||
}
|
||||
assert.equal(game.status,'playing');
|
||||
assert.equal(game.ecazTreachery,true);
|
||||
assert.equal(game.semutaPreview,undefined);
|
||||
game.deck.push(...game.players.flatMap(player => player.hand));
|
||||
for (const player of game.players) player.hand = [];
|
||||
const hold = (owner:string,predicate:(card:Card)=>boolean) => {
|
||||
for (const zone of [game.deck,game.discard,game.richeseCache!]) {
|
||||
const index = zone.findIndex(predicate);
|
||||
if (index >= 0) {const [card] = zone.splice(index,1);seat(game,owner).hand.push(card);return card.id;}
|
||||
}
|
||||
throw Error('The original physical card is unavailable.');
|
||||
};
|
||||
const semuta = hold('r',card=>card.id === SEMUTA_DRUG_ID);
|
||||
const weapon = hold('l',card=>card.kind === 'projectile');
|
||||
const defense = hold('l',card=>card.kind === 'snooper');
|
||||
const unrelated = hold('l',card=>card.kind === 'worthless');
|
||||
const hero = hold('l',card=>card.kind === 'hero');
|
||||
const winnerWeapon = hold('w',card=>card.kind === 'projectile');
|
||||
const winnerDefense = hold('w',card=>card.kind === 'shield');
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum,count)=>sum+count,0);
|
||||
player.forces = {};
|
||||
assert.equal(player.tanks,0);assert.equal(player.reserves,20);
|
||||
}
|
||||
for (const id of ['w','l']) {seat(game,id).reserves-=5;seat(game,id).forces['arrakeen:10']=5;}
|
||||
seat(game,'m').ally='l';seat(game,'l').ally='m';
|
||||
Object.assign(game,{turn:2,phase:6,storm:18,active:'w',order:['w','l','m','r'],
|
||||
ready:[],response:null,decision:null,phaseOpening:null,stormPending:null,movementRemaining:[]});
|
||||
const winnerLeader = seat(game,'w').leaders.reduce((best,leader)=>leader.strength > best.strength ? leader : best).id;
|
||||
return {game,semuta,weapon,defense,unrelated,hero,winnerWeapon,winnerDefense,winnerHero:null,karama:null,
|
||||
cards:inventory(game),winnerLeader,loserLeader:hero,loserStrength:0};
|
||||
}
|
||||
|
||||
for (const initialClaim of [false,true]) for (const finalClaim of [false,true]) {
|
||||
if (initialClaim && finalClaim) continue; // Claiming spends the only Semuta.
|
||||
void test(`Basic independent cards: Moritani retention ${initialClaim ? 'claims' : 'passes'} initial Hero and ${finalClaim ? 'claims' : 'passes'} later discard`,()=>{
|
||||
const f = fixture();
|
||||
let game = resolveSemutaMoritaniBattle(f);
|
||||
const initial = game.pendingTreacheryDiscard;assert.ok(initial?.reaction);
|
||||
assert.equal(initial.batch.cause,'battle:mandatory');
|
||||
assert.deepEqual(initial.batch.entries.map(entry=>entry.card.id),[f.hero]);
|
||||
assert.deepEqual(game.moritaniRetention?.eligible,[f.weapon,f.defense]);
|
||||
const committed = structuredClone(resources(game));
|
||||
if (!initialClaim) for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game,'r');view.players.find(player=>player.id === 'r')!.bot=difficulty;
|
||||
assert.ok(botActions(view).some(action=>action.type === 'semutaCommit' || action.type === 'semutaPass'));
|
||||
}
|
||||
game = initialClaim ? act(game,'r',{type:'semutaCommit',event:initial.batch.event}) : passSemutaMoritaniDiscard(game);
|
||||
assert.deepEqual(resources(game),committed);
|
||||
assert.equal(seat(game,'r').hand.some(card=>card.id === f.hero),initialClaim);
|
||||
game = finishSemutaMoritaniWinner(game);
|
||||
assert.equal(game.decision?.kind,'moritaniRetention');
|
||||
game = chooseSemutaMoritaniRetention(game,f.weapon);
|
||||
game = allowSemutaMoritaniResponse(game);
|
||||
const later = game.pendingTreacheryDiscard;assert.ok(later?.reaction);
|
||||
assert.equal(later.batch.cause,'battle:moritani');
|
||||
assert.deepEqual(later.batch.entries.map(entry=>entry.card.id),[f.defense]);
|
||||
game = finalClaim ? act(game,'r',{type:'semutaCommit',event:later.batch.event}) : passSemutaMoritaniDiscard(game);
|
||||
assert.equal(game.pendingTreacheryDiscard,null);
|
||||
assert.equal(game.moritaniRetention,null);
|
||||
assert.equal(seat(game,'l').hand.some(card=>card.id === f.weapon),true);
|
||||
assert.equal(seat(game,'r').hand.some(card=>card.id === f.defense),finalClaim);
|
||||
assert.equal(game.discard.some(card=>card.id === f.defense),!finalClaim);
|
||||
assert.deepEqual(resources(game),committed);
|
||||
assert.deepEqual(inventory(game),f.cards);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
||||
import {
|
||||
semutaIxAllowResponses, semutaIxInventory, semutaIxPassDiscard, semutaIxPlanSteps,
|
||||
semutaIxPlayer, semutaIxSubstitutionStep, type SemutaIxFixture,
|
||||
} from './fixture-semuta-ix-substitution';
|
||||
|
||||
/** A fresh public Basic setup with the three selected families and the
|
||||
* independent Ecaz cards. A later battle position is explicitly staged by
|
||||
* moving the original held cards and all original forces between legal pools;
|
||||
* battle choice, plans, traitor calls and subsequent effects are actual actions. */
|
||||
function independentIxBattle(): SemutaIxFixture {
|
||||
let game = createGame('SEMUTAINDEPENDENTIX', newPlayer('r', 'Richese', 'richese'), false,
|
||||
['choam', 'ix', 'ecaz']);
|
||||
joinGame(game, newPlayer('i', 'Ixians', 'ixians'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
game = applyAction(game, 'r', {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
for (const player of game.players) player.bot = 'Medium';
|
||||
for (const id of game.players.map(player => player.id))
|
||||
game = applyAction(game, id, {type:'ready'});
|
||||
game = applyAction(game, 'r', {type:'start'});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
for (let step = 0; (game.status === 'setup' || game.phase === 1 || !game.mobileStronghold?.location) && step < 160; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const action = botActions(viewGame(game, player.id))[0];
|
||||
if (action) { next = applyAction(game, player.id, action); break; }
|
||||
}
|
||||
assert.ok(next, 'Native setup and first Storm need a legal action.');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.ok(game.mobileStronghold?.location);
|
||||
const cards = semutaIxInventory(game);
|
||||
assert.equal(new Set(cards).size, cards.length);
|
||||
for (const player of game.players) {
|
||||
game.deck.push(...player.hand); player.hand = [];
|
||||
player.reserves += player.tanks + Object.values(player.forces).reduce((sum,n) => sum + n,0);
|
||||
player.forces = {}; player.tanks = 0; player.battleLosses = 0;
|
||||
if (player.elites) {
|
||||
player.elites.reserves += player.elites.tanks + Object.values(player.elites.forces).reduce((sum,n) => sum + n,0);
|
||||
player.elites.forces = {}; player.elites.tanks = 0;
|
||||
}
|
||||
}
|
||||
const take = (id: string) => {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
|
||||
const index = zone.findIndex(card => card.id === id);
|
||||
if (index >= 0) return zone.splice(index,1)[0];
|
||||
}
|
||||
throw new Error(`Missing original physical card ${id}.`);
|
||||
};
|
||||
const semuta = take(SEMUTA_DRUG_ID);
|
||||
const tooth = take(game.deck.find(card => card.kind === 'poisonTooth')!.id);
|
||||
const shield = take(game.deck.find(card => card.kind === 'shield')!.id);
|
||||
const snooper = take(game.deck.find(card => card.kind === 'snooper')!.id);
|
||||
semutaIxPlayer(game,'r').hand.push(semuta, take(game.deck.find(card => card.effect === 'karama')!.id));
|
||||
semutaIxPlayer(game,'i').hand.push(tooth,shield);
|
||||
semutaIxPlayer(game,'e').hand.push(snooper);
|
||||
const location = 'arrakeen:10', ix = semutaIxPlayer(game,'i'), emperor = semutaIxPlayer(game,'e');
|
||||
assert.equal(ix.reserves,20); assert.equal(ix.elites!.reserves,7);
|
||||
ix.reserves -= 3; ix.forces[location] = 3; ix.elites!.reserves--; ix.elites!.forces[location] = 1;
|
||||
emperor.reserves -= 5; emperor.forces[location] = 5;
|
||||
Object.assign(game,{turn:2,phase:6,active:'i',order:['i','e','r'],storm:18,
|
||||
ready:[],response:null,decision:null,phaseOpening:null,movementRemaining:[]});
|
||||
assert.deepEqual(semutaIxInventory(game),cards);
|
||||
return {game, holder:'r', winner:'i', loser:'e', territory:'arrakeen', location,
|
||||
semuta:semuta.id, tooth:tooth.id, shield:shield.id, snooper:snooper.id, cards};
|
||||
}
|
||||
|
||||
function loserDiscard(): SemutaIxFixture {
|
||||
const fixture = independentIxBattle();
|
||||
let game = applyAction(fixture.game,fixture.winner,{type:'chooseBattle',territory:fixture.territory,target:fixture.loser});
|
||||
for (let step = 0; step < 40; step++) {
|
||||
if (game.response) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = applyAction(game,actor.id,{type:'passResponse'});
|
||||
} else if (game.battle?.preparation)
|
||||
game = applyAction(game,game.battle.preparation.owner,{type:'declineBattlePower'});
|
||||
else if (game.decision?.kind === 'fullPlanOffer')
|
||||
game = applyAction(game,game.decision.player,{type:'decision',decline:true});
|
||||
else break;
|
||||
}
|
||||
for (const actor of [fixture.winner,fixture.loser])
|
||||
game = applyAction(game,actor,{type:'battlePreparationReady',event:game.battle!.preLeader!.event});
|
||||
for (const step of semutaIxPlanSteps(game,fixture)) game = applyAction(game,step.actor,step.action);
|
||||
assert.equal(game.decision?.kind,'poisonTooth');
|
||||
game = applyAction(game,fixture.winner,{type:'decision',activate:true});
|
||||
for (const actor of [fixture.winner,fixture.loser])
|
||||
game = applyAction(game,actor,{type:'traitorCall',call:false});
|
||||
game = finishSemutaBattleOutcome(game);
|
||||
return {...fixture,game};
|
||||
}
|
||||
|
||||
for (const claim of [false,true]) for (const substitute of [false,true]) {
|
||||
void test(`Basic independent cards: Ixian loser discard ${claim ? 'claim' : 'pass'}, then ${substitute ? 'substitute' : 'decline'}`, () => {
|
||||
const fixture = loserDiscard(), pending = fixture.game;
|
||||
const batch = pending.pendingTreacheryDiscard; assert.ok(batch?.reaction);
|
||||
assert.equal(batch.reaction.stage,'offer');
|
||||
assert.equal(batch.continuation.kind,'battleResolved');
|
||||
assert.deepEqual(batch.batch.entries.map(entry => entry.card.id),[fixture.snooper]);
|
||||
assert.deepEqual(pending.pendingWinnerDiscards?.cards,[fixture.tooth]);
|
||||
assert.equal(pending.pendingIxSubstitution ?? null,null);
|
||||
const event = batch.batch.event;
|
||||
let game = claim ? applyAction(pending,fixture.holder,{type:'semutaCommit',event}) : semutaIxPassDiscard(pending);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = applyAction(game,fixture.holder,{type:'semutaSelect',event,card:fixture.snooper});
|
||||
assert.equal(game.decision?.kind,'ixSubstitution');
|
||||
assert.equal(game.decision?.player,fixture.winner);
|
||||
assert.deepEqual(game.pendingIxSubstitution!.losses,{[fixture.location]:1});
|
||||
assert.equal(semutaIxPlayer(game,fixture.winner).tanks,1);
|
||||
assert.equal(semutaIxPlayer(game,fixture.winner).elites!.tanks,1);
|
||||
assert.equal(semutaIxPlayer(game,fixture.holder).hand.some(card => card.id === fixture.snooper),claim);
|
||||
assert.equal(game.discard.some(card => card.id === fixture.tooth),false);
|
||||
const step = semutaIxSubstitutionStep(fixture,substitute);
|
||||
game = applyAction(game,step.actor,step.action);
|
||||
if (substitute) {
|
||||
assert.equal(game.response?.kind,'ixSubstitution');
|
||||
game = semutaIxAllowResponses(game);
|
||||
}
|
||||
assert.equal(game.pendingIxSubstitution,null);
|
||||
assert.equal(semutaIxPlayer(game,fixture.winner).elites!.tanks,substitute ? 0 : 1);
|
||||
assert.equal(semutaIxPlayer(game,fixture.winner).battleLosses,1);
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'winnerMandatoryDiscard');
|
||||
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id),[fixture.tooth]);
|
||||
game = semutaIxPassDiscard(game);
|
||||
assert.equal(game.decision?.kind,'battleCards');
|
||||
game = applyAction(game,fixture.winner,{type:'decision',discard:[fixture.shield]});
|
||||
if (game.pendingTreacheryDiscard) game = semutaIxPassDiscard(game);
|
||||
assert.equal(game.phase,7);
|
||||
assert.deepEqual(semutaIxInventory(game),fixture.cards);
|
||||
assert.equal(game.discard.filter(card => card.id === fixture.semuta).length,Number(claim));
|
||||
for (const player of game.players)
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum,n) => sum + n,0),20);
|
||||
});
|
||||
}
|
||||
|
||||
void test('all four AI profiles can answer the fresh discard before Ixian substitution', () => {
|
||||
const fixture = loserDiscard();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(fixture.game,fixture.holder);
|
||||
view.players.find(player => player.id === fixture.holder)!.bot = difficulty;
|
||||
const action = botActions(view).find(item => item.type === 'semutaCommit' || item.type === 'semutaPass');
|
||||
assert.ok(action);
|
||||
assert.doesNotThrow(() => applyAction(fixture.game,fixture.holder,action));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user