Connect Semuta reactions before original native Tech award

This commit is contained in:
Quintine committed 2026-10-12 05:44:56 +11:00
1 parent a1d620d7b0
commit a6869b0166
12 files changed
+220 -14

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
/** Genuine original lobby/setup/first Storm; later battle components are staged by conserved moves. */
export function initializeSemutaTechGame(advanced = false): Game {
let game = createGame('SEMUTATECH', newPlayer('r', 'Richese', 'richese'), advanced, ['choam', 'ix']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Tleilaxu', 'tleilaxu'));
game = applyAction(game, 'r', { type: 'techTokens', enabled: true });
for (const player of game.players) { player.bot = 'Medium'; player.ready = true; }
game = initializeSemutaGameForAudit(game);
for (let step = 0; (game.status === 'setup' || Object.values(game.techTokens!).some(token => !token.owner)) && 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, 'Original setup and first Storm need a legal native continuation.');
game = next;
}
assert.equal(game.status, 'playing');
assert.ok(Object.values(game.techTokens!).every(token => token.owner));
assert.equal(game.techTokens!.axlotl.owner, 'e');
return game;
}
export function semutaTechBattleFixture(advanced = false): { game: Game; semuta: string; weapon: string; defense: string; cards: string[] } {
const game = initializeSemutaTechGame(advanced);
game.deck.push(...game.players.flatMap(player => player.hand));
for (const player of game.players) player.hand = [];
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 physical ${id}`);
};
const semuta = take(SEMUTA_DRUG_ID);
const weapon = take(game.deck.find(card => card.kind === 'projectile')!.id);
const defense = take(game.deck.find(card => card.kind === 'snooper')!.id);
game.players.find(player => player.id === 'r')!.hand.push(semuta);
game.players.find(player => player.id === 'a')!.hand.push(weapon);
game.players.find(player => player.id === 'e')!.hand.push(defense);
for (const player of game.players) {
Object.assign(player, { forces: player.id === 'r' ? {} : { 'arrakeen:10': 5 }, reserves: player.id === 'r' ? 20 : 15, tanks: 0 });
}
// Stage two public tokens with the loser to exercise the original owned choice;
// all three physical tokens and their existing ledgers remain original.
game.techTokens!.heighliners.owner = 'e';
game.techTokens!.production.owner = 'r';
Object.assign(game, { turn: 2, phase: 6, active: 'a', order: ['a', 'e', 'r'], ready: [],
response: null, decision: null, phaseOpening: null, movementRemaining: [] });
const cards = [...game.deck, ...game.discard, ...game.richeseCache!, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
assert.equal(new Set(cards).size, cards.length);
return { game, semuta: semuta.id, weapon: weapon.id, defense: defense.id, cards };
}
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, type Game } from '../game/engine';
import { initializeSemutaTechGame, semutaTechBattleFixture } from './fixture-semuta-tech';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
const inventory = (game: Game) => [...game.deck, ...game.discard, ...game.richeseCache!, ...game.players.flatMap(p => p.hand)].map(card => card.id).sort();
const pass = (game: Game) => {
const event = game.pendingTreacheryDiscard!.batch.event;
for (const id of ['a', 'e', 'r']) game = applyAction(game, id, { type: 'semutaPass', event });
return game;
};
for (const advanced of [false, true]) {
void test(`Semuta ${advanced ? 'Advanced' : 'Basic'} keeps original setup and first-Storm Tech ownership`, () => {
const game = initializeSemutaTechGame(advanced);
assert.equal(game.techTokens!.axlotl.owner, 'e');
assert.equal(new Set(Object.values(game.techTokens!).map(token => token.owner)).size, 3);
});
void test(`Semuta ${advanced ? 'Advanced' : 'Basic'} resumes one original token choice after mandatory and winner discards`, () => {
const f = semutaTechBattleFixture(advanced);
let state = applyAction(f.game, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' });
for (let step = 0; step < 40; step++) {
if (state.response) {
const actor = state.players.find(p => !state.response!.passed.includes(p.id))!;
state = applyAction(state, actor.id, { type: 'passResponse' });
} else if (state.battle?.preparation)
state = applyAction(state, state.battle.preparation.owner, { type: 'declineBattlePower' });
else if (state.decision?.kind === 'fullPlanOffer')
state = applyAction(state, state.decision.player, { type: 'decision', decline: true });
else break;
}
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'battlePreparationReady', event: state.battle!.preLeader!.event });
state = applyAction(state, 'a', { type: 'battlePlan', dial: 2, support: advanced ? 2 : 0,
leader: player(state, 'a').leaders[0].id, weapon: f.weapon });
state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(state, 'e').leaders[0].id, defense: f.defense });
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false });
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const winner = applyAction(pass(reload(state)), 'a', { type: 'decision', discard: [f.weapon] });
assert.equal(winner.pendingTreacheryDiscard?.reaction?.stage, 'offer');
for (const pending of [state, winner]) {
const event = pending.pendingTreacheryDiscard!.batch.event;
const fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card.id;
for (const claim of [false, true]) {
let resumed = claim ? applyAction(reload(pending), 'r', { type: 'semutaCommit', event }) : pass(reload(pending));
if (resumed.decision?.kind === 'battleCards') resumed = applyAction(resumed, 'a', { type: 'decision', discard: [] });
assert.equal(resumed.decision?.kind, 'techToken');
assert.equal(resumed.decision?.player, 'a');
assert.equal(resumed.techTokens!.axlotl.owner, 'e');
assert.equal(resumed.techTokens!.heighliners.owner, 'e');
for (const token of ['axlotl', 'heighliners'] as const) {
let awarded = applyAction(reload(resumed), 'a', { type: 'decision', token });
assert.equal(awarded.techTokens![token].owner, 'a');
assert.equal(awarded.techTokens![token === 'axlotl' ? 'heighliners' : 'axlotl'].owner, 'e');
assert.equal(awarded.techTokens!.production.owner, 'r');
assert.equal(awarded.decision?.kind, 'faceDance');
assert.throws(() => applyAction(awarded, 'a', { type: 'decision', token }));
awarded = applyAction(awarded, 'e', { type: 'decision', reveal: false });
assert.equal(awarded.techTokens![token].owner, 'a');
assert.equal(player(awarded, 'r').hand.some(card => card.id === fresh), claim);
assert.equal(awarded.discard.some(card => card.id === f.semuta), claim);
assert.deepEqual(inventory(awarded), f.cards);
assert.throws(() => applyAction(awarded, 'r', { type: 'semutaCommit', event }));
}
}
}
});
}