Extend Advanced Semuta discard continuations
Verify and publish checkpoint container / container (push) Successful in 10m54s
Verify and publish checkpoint container / container (push) Successful in 10m54s
This commit is contained in:
1 parent
b93c5c4cf5
commit
b2bc3f2f01
8 files changed
+304
-6
No files matched your search
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
function inventory(game: Game): string[] {
|
||||
const ids = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
|
||||
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
return ids;
|
||||
}
|
||||
function take(game: Game, predicate: (card: Game['deck'][number]) => boolean) {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)]) {
|
||||
const index = zone.findIndex(predicate);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw new Error('Missing original physical card.');
|
||||
}
|
||||
function setup(kind: 'loser' | 'mandatory' | 'selected') {
|
||||
let game = createGame('ADVSEMUTABATTLE', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']);
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('r', 'Richese', 'richese'));
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
game = act(game, 'a', {type:'start', advancedPreview:true});
|
||||
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
|
||||
assert.equal(game.semutaPreview, undefined);
|
||||
for (let n = 0; game.status === 'setup' && n < 100; n++) {
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, 'Native setup must advance.');
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const cards = inventory(game);
|
||||
// Preserve physical inventory while positioning the exact battle cards and
|
||||
// keep the discard free from unrelated earlier setup choices.
|
||||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||||
const semuta = take(game, card => card.id === SEMUTA_DRUG_ID);
|
||||
const winnerWeapon = take(game, card => card.kind === 'projectile');
|
||||
const loserDefense = take(game, card => card.kind === 'shield');
|
||||
const hero = kind === 'mandatory' ? take(game, card => card.kind === 'hero') : null;
|
||||
seat(game, 'r').hand.push(semuta);
|
||||
seat(game, 'a').hand.push(winnerWeapon, ...(hero ? [hero] : []));
|
||||
seat(game, 'e').hand.push(loserDefense);
|
||||
assert.deepEqual(inventory(game), cards);
|
||||
// Put conserved physical force groups in a clear shared territory. Emperor's
|
||||
// existing Advanced elite counters stay in reserves; both armies are normal.
|
||||
for (const id of ['a', 'e']) {
|
||||
const player = seat(game, id);
|
||||
player.reserves += Object.values(player.forces).reduce((total, amount) => total + amount, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((total, amount) => total + amount, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
const amount = id === 'a' ? 6 : 2;
|
||||
player.reserves -= amount;
|
||||
player.forces['red_chasm:7'] = amount;
|
||||
}
|
||||
Object.assign(game, {phase:6, active:'a', order:['a','e','r'], storm:18, ready:[],
|
||||
response:null, decision:null, phaseOpening:null, battle:null});
|
||||
game = act(game, 'a', {type:'chooseBattle', territory:'red_chasm', target:'e'});
|
||||
for (let n = 0; n < 80; n++) {
|
||||
if (game.battle && !game.response && !game.decision && !game.phaseOpening &&
|
||||
!game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed)) break;
|
||||
const next = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(next, 'The current native battle interaction must finish before preparation.');
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed));
|
||||
const winnerLeader = hero?.id ?? seat(game, 'a').leaders.filter(leader => !leader.dead && !leader.usedAt)
|
||||
.sort((left, right) => right.strength - left.strength)[0]!.id;
|
||||
const loserLeader = seat(game, 'e').leaders.filter(leader => !leader.dead && !leader.usedAt)
|
||||
.sort((left, right) => left.strength - right.strength)[0]!.id;
|
||||
game = act(game, 'a', {type:'battlePlan', dial:hero ? 5 : 1, support:hero ? 5 : 1,
|
||||
leader:winnerLeader, weapon:winnerWeapon.id, defense:null});
|
||||
game = act(game, 'e', {type:'battlePlan', dial:0, support:0,
|
||||
leader:loserLeader, weapon:null, defense:loserDefense.id});
|
||||
const voters = viewGame(game, 'a').battle!.traitorVoters;
|
||||
assert.equal(voters.length, 2);
|
||||
game = act(game, voters[0], {type:'traitorCall', call:false});
|
||||
return {game, finalActor:voters[1], loserDefense, winnerWeapon, hero, cards};
|
||||
}
|
||||
function react(game: Game, claim: boolean, selected: string): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
const event = pending.batch.event;
|
||||
if (claim) {
|
||||
game = act(game, 'r', {type:'semutaCommit', event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, 'r', {type:'semutaSelect', event, card:selected});
|
||||
} else {
|
||||
for (const player of game.players) {
|
||||
if (!game.pendingTreacheryDiscard) break;
|
||||
game = act(game, player.id, {type:'semutaPass', event});
|
||||
}
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function advance(game: Game, until: (state: Game) => boolean): Game {
|
||||
for (let n = 0; n < 400 && !until(game); n++) {
|
||||
if (game.pendingTreacheryDiscard?.reaction) game = react(game, false, '');
|
||||
else {
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, `Native continuation needed at ${game.phase}/${game.decision?.kind}`);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
}
|
||||
assert.ok(until(game));
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const kind of ['loser', 'mandatory', 'selected'] as const)
|
||||
for (const claim of [false, true]) void test(`public Advanced Semuta ${kind} battle discard: ${claim ? 'claim' : 'pass'}`, () => {
|
||||
const f = setup(kind);
|
||||
let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, {type:'traitorCall',call:false}));
|
||||
if (kind !== 'loser') {
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
|
||||
game = react(game, false, f.loserDefense.id);
|
||||
if (kind === 'selected') {
|
||||
game = advance(game, state => state.decision?.kind === 'battleCards');
|
||||
game = act(game, 'a', {type:'decision', discard:[f.winnerWeapon.id]});
|
||||
}
|
||||
}
|
||||
const card = kind === 'loser' ? f.loserDefense : kind === 'mandatory' ? f.hero! : f.winnerWeapon;
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
assert.equal(pending.batch.cause, kind === 'loser' ? 'battle:mandatory' :
|
||||
kind === 'mandatory' ? 'battle:winnerMandatory' : 'battle:winner');
|
||||
assert.ok(pending.batch.entries.some(entry => entry.card.id === card.id));
|
||||
const original = structuredClone(game), event = pending.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const botGame = structuredClone(game);
|
||||
seat(botGame, 'r').bot = difficulty;
|
||||
const action = botActions(viewGame(botGame, 'r')).find(candidate =>
|
||||
candidate.type === 'semutaCommit' || candidate.type === 'semutaPass');
|
||||
assert.ok(action, difficulty);
|
||||
assert.doesNotThrow(() => act(botGame, 'r', action), difficulty);
|
||||
}
|
||||
game = react(game, claim, card.id);
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(seat(game, 'r').hand.some(held => held.id === card.id), claim);
|
||||
assert.equal(game.discard.some(discarded => discarded.id === card.id), !claim);
|
||||
assert.equal(seat(game, 'e').tanks, seat(original, 'e').tanks);
|
||||
for (const player of game.players) assert.equal(player.spice, seat(original, player.id).spice);
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
assert.throws(() => act(game, 'r', {type:'semutaCommit', event}));
|
||||
assert.equal(game.lastBattleContext?.event, original.lastBattleContext?.event);
|
||||
game = advance(game, state => state.phase >= 7 && clean(state));
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
});
|
||||
@@ -34,7 +34,8 @@ void test('public Advanced Richese setup displays the limited ordinary Semuta no
|
||||
const html = renderToStaticMarkup(createElement(GameTable, {
|
||||
game:view, busy:false, send:async () => {}, onExit() {},
|
||||
}));
|
||||
assert.match(html, /Advanced Semuta preview: supported ordinary Hajr, Ghola, Harvester and Weather Control discards can be claimed/);
|
||||
assert.match(html, /Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans and simple Truthtrance discards can be claimed/);
|
||||
assert.match(html, /supported battle cleanup/);
|
||||
assert.match(html, /Other discard opportunities remain unfinished/);
|
||||
assert.doesNotMatch(html, /Semuta Drug development preview/);
|
||||
assert.doesNotMatch(html, /Semuta Drug with Homeworlds development preview/);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
|
||||
type Kind = 'box' | 'distrans' | 'truth';
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
|
||||
|
||||
function inventory(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...game.players.flatMap(seat => seat.hand)];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
return ids;
|
||||
}
|
||||
function take(game: Game, identity: string) {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(seat => seat.hand)]) {
|
||||
const index = zone.findIndex(card => card.id === identity || card.effect === identity);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw new Error(`Missing physical card ${identity}.`);
|
||||
}
|
||||
|
||||
/** Genuine Advanced public start and normal setup. To reach the selected clean
|
||||
* special-card window, the phase and physical card custody are staged. No
|
||||
* unopened game or saved room is altered, and card identities are conserved. */
|
||||
function fixture(kind: Kind) {
|
||||
let game = createGame('ADVSEMUTASPECIAL', newPlayer('a','Atreides','atreides'), true, ['choam']);
|
||||
joinGame(game, newPlayer('r','Richese','richese'));
|
||||
joinGame(game, newPlayer('e','Emperor','emperor'));
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
||||
game = act(game, 'a', {type:'start',advancedPreview:true});
|
||||
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
|
||||
assert.equal(game.semutaPreview, undefined);
|
||||
for (let count = 0; game.status === 'setup' && count < 80; count++) {
|
||||
let next: Game | undefined;
|
||||
for (const seat of game.players) {
|
||||
const view = viewGame(game, seat.id);
|
||||
view.players.find(row => row.id === seat.id)!.bot = 'Medium';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { next = act(game, seat.id, action); break; }
|
||||
}
|
||||
assert.ok(next, 'The genuine setup has a legal next step.');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const before = inventory(game);
|
||||
const semuta = take(game, SEMUTA_DRUG_ID);
|
||||
const source = take(game, kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : 'distrans');
|
||||
const extra = take(game, 'hajr');
|
||||
player(game, 'r').hand.push(semuta);
|
||||
player(game, 'a').hand.push(source);
|
||||
if (kind === 'box') game.discard.push(extra);
|
||||
else if (kind === 'distrans') player(game, 'a').hand.push(extra);
|
||||
else game.deck.push(extra);
|
||||
assert.deepEqual(inventory(game), before);
|
||||
Object.assign(game, {turn:2, phase:5, active:'a', storm:18, ready:[], response:null,
|
||||
decision:null, phaseOpening:null, stormPending:null, movementRemaining:['a','r','e']});
|
||||
const beforeSpice = player(game, 'a').spice;
|
||||
if (kind === 'box') {
|
||||
game = act(game, 'a', {type:'card',card:source.id});
|
||||
// The native Box selects a sole eligible discard without another prompt.
|
||||
if (game.pendingNullentropy)
|
||||
game = act(game, 'a', {type:'decision',event:game.pendingNullentropy.event,card:extra.id});
|
||||
assert.equal(player(game,'a').spice,beforeSpice - 2);
|
||||
assert.ok(player(game,'a').hand.some(card => card.id === extra.id));
|
||||
} else if (kind === 'distrans') {
|
||||
game = act(game,'a',{type:'card',card:source.id,target:'e',give:extra.id});
|
||||
assert.ok(player(game,'e').hand.some(card => card.id === extra.id));
|
||||
} else {
|
||||
game = act(game,'a',{type:'card',card:source.id});
|
||||
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,'a',{type:'truthAsk',question:{kind:'freeform',target:'e',
|
||||
scope:'fact',text:'Do you intend to protect Arrakeen?'}});
|
||||
game = act(game,'e',{type:'truthAnswer',answer:'yes'});
|
||||
assert.equal(game.truthHistory?.at(-1)?.answer, 'yes');
|
||||
}
|
||||
return {game, source, extra, before, beforeSpice};
|
||||
}
|
||||
|
||||
for (const kind of ['box','distrans','truth'] as const) for (const claim of [false,true])
|
||||
void test(`Advanced public ${kind} Semuta ${claim ? 'claim' : 'pass'} preserves the committed original effect`, () => {
|
||||
const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
assert.equal(pending.continuation.kind, kind === 'box' ? 'nullentropyDiscard' :
|
||||
kind === 'truth' ? 'truthtranceDiscard' : 'distransDiscard');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.source.id]);
|
||||
assert.equal(viewGame(f.game, 'r').semutaReaction?.canCommit, true);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, 'r');
|
||||
view.players.find(seat => seat.id === 'r')!.bot = difficulty;
|
||||
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'), difficulty);
|
||||
}
|
||||
let game = f.game;
|
||||
if (claim) {
|
||||
game = act(game,'r',{type:'semutaCommit',event:pending.batch.event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game,'r',{type:'semutaSelect',event:pending.batch.event,card:f.source.id});
|
||||
} else for (const seat of game.players) game = act(game,seat.id,{type:'semutaPass',event:pending.batch.event});
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(game,'r').hand.some(card => card.id === f.source.id),claim);
|
||||
assert.equal(game.discard.some(card => card.id === f.source.id),!claim);
|
||||
if (kind === 'box') {
|
||||
assert.equal(player(game,'a').spice,f.beforeSpice - 2);
|
||||
assert.ok(player(game,'a').hand.some(card => card.id === f.extra.id));
|
||||
} else if (kind === 'distrans') assert.ok(player(game,'e').hand.some(card => card.id === f.extra.id));
|
||||
else assert.equal(game.truthHistory?.at(-1)?.answer,'yes');
|
||||
assert.deepEqual(inventory(game),f.before);
|
||||
game = act(game,'a',{type:'endMovement'});
|
||||
assert.ok(!game.movementRemaining?.includes('a'));
|
||||
});
|
||||
Reference in new issue
Block a user