Offer Semuta on printed Emperor-share cancellation and repair stale unit tests

This commit is contained in:
Quintine committed 2026-10-12 14:46:09 +11:00
1 parent 63e5f22607
commit 308797567e
12 files changed
+319 -28

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { baseDeck, spiceDeck } from '../game/cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
export type SemutaEmperorShareKind = 'emperorGift' | 'emperorRevival';
export type SemutaEmperorShareFixture = {
game: Game;
owner: string;
holder: string;
recipient: string;
karama: string;
karamaCard: { id: string; name: string; kind: string; effect?: string };
semuta: string;
kind: SemutaEmperorShareKind;
declare: Action;
cancel: Action;
};
export function semutaEmperorPlayer(game: Game, id: string): Player {
const player = game.players.find(seat => seat.id === id);
assert.ok(player, `Missing original ${id} seat`);
return player;
}
/** Locate one existing physical card identity across the original zones. */
export function semutaEmperorCardId(game: Game, match: (card: { id: string; effect?: string; kind?: string }) => boolean): string {
for (const zone of [game.deck, game.discard, game.richeseCache ?? [],
game.richeseRemoved ?? [], ...game.players.map(p => p.hand)]) {
const card = zone.find(candidate => match(candidate));
if (card) return card.id;
}
throw new Error('The requested original physical card is unavailable');
}
/** Move one existing physical card between zones; never duplicate a face. */
export function moveSemutaEmperorCard(game: Game, id: string, to: string): string {
for (const zone of [game.deck, game.discard, game.richeseCache ?? [],
game.richeseRemoved ?? [], ...game.players.map(p => p.hand)]) {
const index = zone.findIndex(card => card.id === id);
if (index < 0) continue;
const card = zone.splice(index, 1)[0];
semutaEmperorPlayer(game, to).hand.push(card);
return card.id;
}
throw new Error(`Missing original physical ${id}`);
}
/** Genuine three-seat Richese/Emperor/Atreides Semuta setup; only the clock,
* phase, alliance, active seat and later held cards are conserved. */
export function createSemutaEmperorShareFixture(kind: SemutaEmperorShareKind): SemutaEmperorShareFixture {
let game = createGame('SEMUTAEMPEROR', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
for (const player of game.players) player.ready = true;
game = initializeSemutaGameForAudit(game);
game.status = 'playing';
game.phase = kind === 'emperorRevival' ? 4 : 2;
game.turn = 2;
game.storm = 18;
game.active = 'r';
game.order = ['r', 'e', 'a'];
game.ready = [];
game.decision = null;
game.response = null;
game.phaseOpening = null;
game.deck = baseDeck();
game.spiceDeck = spiceDeck();
const owner = semutaEmperorPlayer(game, 'e'), holder = semutaEmperorPlayer(game, 'r');
const recipient = semutaEmperorPlayer(game, 'a');
for (const player of game.players) { player.hand = []; player.spice = 20; player.forces = {}; }
owner.ally = recipient.id;
recipient.ally = owner.id;
recipient.tanks = 3;
const karamaId = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.effect === 'karama'), recipient.id);
const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, holder.id);
const karamaCard = recipient.hand.find(card => card.id === karamaId)!;
const declare: Action = kind === 'emperorGift'
? { type: 'emperorGift', amount: 3 }
: { type: 'emperorRevival', amount: 2 };
return {
game, owner: owner.id, holder: holder.id, recipient: recipient.id,
karama: karamaId, karamaCard, semuta, kind, declare,
cancel: { type: 'card', mode: 'cancel', card: karamaId },
};
}
/** Resolve the neutral offer: a real commit (singleton completes directly) or all passes. */
export function reactSemutaEmperorShare(
state: Game, fixture: SemutaEmperorShareFixture, claim: boolean,
): Game {
const event = state.pendingTreacheryDiscard!.batch.event;
if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event });
let game = state;
for (const player of state.players)
game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
export function semutaEmperorSharePhysical(game: Game) {
return game.players.map(player => ({
id: player.id, faction: player.faction, spice: player.spice, forces: player.forces,
reserves: player.reserves, tanks: player.tanks, elites: player.elites,
moved: player.moved, revived: player.revived,
}));
}
export function semutaEmperorShareViews(game: Game): void {
for (const player of game.players) viewGame(game, player.id);
}
-1
View File
@@ -452,6 +452,5 @@ void test('shipment completion emits an executable mixed-Homeworld source witnes
minimum: 2,
},
},
/Basic/,
);
});
+2 -5
View File
@@ -233,9 +233,6 @@ void test('the panel states the connected role modifiers, Diplomat copy and unfi
])
assert.match(html, new RegExp(name));
assert.match(html, /development-only module/);
assert.match(
html,
/Diplomat copying an opposing base defense with a committed Worthless card after plans are revealed/,
);
assert.match(html, /Other Leader Skill effects are unfinished/);
assert.match(html, /Diplomat/);
assert.match(html, /complete mode acceptance is unfinished/);
});
+1
View File
@@ -182,6 +182,7 @@ void test('Basic Moritani permits a projected Sandmaster worm collection and one
key: 'habbanya_ridge_flat:17',
before: 2,
blocked: null,
piles: [{ before: 2, key: 'habbanya_ridge_flat:17' }],
});
const botView = viewGame(restored, 'f');
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import {
createSemutaEmperorShareFixture, reactSemutaEmperorShare,
semutaEmperorPlayer, semutaEmperorSharePhysical, semutaEmperorShareViews,
type SemutaEmperorShareKind,
} from './fixture-semuta-emperor-share';
for (const kind of ['emperorGift', 'emperorRevival'] as SemutaEmperorShareKind[]) {
for (const claim of [false, true]) void test(
`Semuta ${claim ? 'claims' : 'passes'} the printed Karama canceling Emperor ${kind === 'emperorGift' ? 'gift' : 'revival'} before the native cancellation`, () => {
const f = createSemutaEmperorShareFixture(kind);
assert.equal(semutaEmperorPlayer(f.game, f.holder).hand.some(card => card.id === f.semuta), true);
const declared = applyAction(f.game, f.owner, f.declare);
assert.equal(declared.response?.kind, kind);
assert.equal(semutaEmperorPlayer(declared, f.owner).spice, 20);
const pending = applyAction(declared, f.recipient, f.cancel);
const reaction = pending.pendingTreacheryDiscard!;
assert.equal(reaction.batch.cause, `karama:${kind}`);
assert.deepEqual(reaction.batch.entries, [{ card: f.karamaCard, discardedBy: f.recipient, publicFace: true }]);
assert.equal(reaction.reaction?.stage, 'offer');
assert.ok(!pending.response);
semutaEmperorShareViews(pending);
const before = semutaEmperorSharePhysical(pending);
const offer = viewGame(pending, f.holder).semutaReaction!;
assert.equal(offer.canCommit, true);
assert.deepEqual(offer.candidates, [], 'No cost inspection before commitment');
const done = reactSemutaEmperorShare(pending, f, claim);
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(done.resolvedTreacheryDiscardSequence, reaction.sequence);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), claim);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim));
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
// The denied share moves nothing: no spice, forces, revivals or extra-revival ledger.
assert.deepEqual(semutaEmperorSharePhysical(done), before);
assert.equal(done.emperorExtra[f.recipient] ?? 0, 0);
assert.ok(!done.response);
assert.throws(() => applyAction(done, f.recipient, f.cancel));
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: reaction.batch.event }));
semutaEmperorShareViews(done);
},
);
}
void test('the native Emperor share still settles when its cost is declined', () => {
const f = createSemutaEmperorShareFixture('emperorGift');
const declared = applyAction(f.game, f.owner, f.declare);
// The neutral window still requires the Karama holder's own choice.
const settled = applyAction(declared, f.recipient, { type: 'passResponse' });
assert.ok(!settled.response);
assert.equal(semutaEmperorPlayer(settled, f.owner).spice, 17);
assert.equal(semutaEmperorPlayer(settled, f.recipient).spice, 23);
assert.ok(!settled.pendingTreacheryDiscard);
assert.equal(semutaEmperorPlayer(settled, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true);
});
void test('every legal Semuta profile finishes the printed Emperor-share cancellation cost', () => {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = createSemutaEmperorShareFixture('emperorRevival');
const pending = applyAction(applyAction(f.game, f.owner, f.declare), f.recipient, f.cancel);
const own = viewGame(pending, f.holder);
own.players.find(player => player.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action);
assert.equal(action.type, 'semutaCommit');
const done = applyAction(pending, f.holder, action);
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), true);
assert.equal(done.emperorExtra[f.recipient] ?? 0, 0);
}
});
+12 -10
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine';
import { applyAction, createGame, joinGame, newPlayer, initializeLeaderSkillsGameForAudit, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ecazTreacheryCards } from '../game/ecaz-cards';
@@ -187,16 +187,18 @@ void test('own modified plans fail before sealing without changing any resources
reject(trainer, { type: 'battlePlan', leader: 'guild-0', dial: 0, weapon: trainer.players[0].hand[0].id });
});
void test('the independent Ecaz card variant is gated before a Smuggler plan is sealed', () => {
const game = smugglerBattle();
// The Smuggler battle lives in the Leader Skills profile, which never admits
// the variant module; a mid-battle toggle is rejected as a changed saved state.
let game = createGame('SMUGGLERGATE', newPlayer('a', 'Emperor', 'emperor'), false);
joinGame(game, newPlayer('d', 'Guild', 'guild'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game.ecazTreachery = true;
game.deck.push(...ecazTreacheryCards());
// The variant inventory is valid: admission is rejected for its public scope,
// irrespective of which player secretly holds an expansion card.
const own = viewGame(game, 'a');
assert.equal(own.ecazTreachery, true);
const action = { type: 'battlePlan' as const, leader: 'emperor-0', dial: 0 };
assert.throws(() => applyAction(game, 'a', action), /other optional modules/);
reject(game, action);
assert.throws(() => initializeLeaderSkillsGameForAudit(game), /base factions without expansions or optional modules/);
const staged = smugglerBattle();
staged.ecazTreachery = true;
staged.deck.push(...ecazTreacheryCards());
assert.throws(() => viewGame(staged, 'a'), /invalid battle version/);
reject(staged, { type: 'battlePlan' as const, leader: 'emperor-0', dial: 0 });
});
void test('all four actual bot profiles retain a legal unmodified path and finish pending collection', () => {
for (const difficulty of DIFFICULTIES) {