Prove Emperor share and Moritani cleanup through native phases

This commit is contained in:
Quintine committed 2026-10-12 15:00:16 +11:00
1 parent 308797567e
commit adb054023d
10 files changed
+161 -91

No files matched your search

+43 -20
View File
@@ -3,7 +3,7 @@ import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { baseDeck, spiceDeck } from '../game/cards';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
export type SemutaEmperorShareKind = 'emperorGift' | 'emperorRevival';
@@ -45,32 +45,55 @@ export function moveSemutaEmperorCard(game: Game, id: string, to: string): strin
}
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. */
/** Original native setup, first turn and actual worm alliance precede the share.
* Later hands/Tanks are staged by conserved moves; clocks, wallets and decks remain native. */
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;
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
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 clean = () => !game.phaseOpening && !game.response && !game.decision;
const reach = (until: () => boolean) => {
for (let n = 0; !until() && n < 1800; n++) {
assert.notEqual(game.status, 'finished', 'Original native actions must reach the share');
const pending = game.pendingTreacheryDiscard;
const reaction = pending?.reaction;
const next = reaction?.stage === 'offer' && pending
? { actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
action: { type: 'semutaPass', event: pending.batch.event } }
: nextRicheseSkillsTechNativeStep(game);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(), 'Original native actions must reach the requested boundary');
};
const orderUnplayedSpice = (kinds: ('land' | 'worm')[]) => {
for (const [position, type] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
(type === 'land' ? 'territory' in card : 'worm' in card));
assert.ok(index >= position, 'An original unplayed Spice Card must remain');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
};
reach(() => game.status === 'playing');
orderUnplayedSpice(['land', 'land']);
reach(() => game.phase === 5 && clean());
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((total, amount) => total + amount, 0);
player.forces = {};
}
reach(() => game.turn === 2 && game.phase === 1 && clean());
orderUnplayedSpice(['worm', 'land', 'land']);
reach(() => game.nexus && !game.spiceWindow && !game.spiceResolution && clean());
game = applyAction(game, 'e', { type: 'alliance', target: 'a' });
game = applyAction(game, 'a', { type: 'alliance', target: 'e' });
reach(() => game.phase === (kind === 'emperorRevival' ? 4 : 2) && clean());
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;
for (const player of game.players) game.deck.push(...player.hand.splice(0));
assert.ok(recipient.reserves >= 3);
recipient.reserves -= 3;
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)!;
-14
View File
@@ -222,17 +222,3 @@ void test('battle visibility choice names the skilled leader and disables both a
assert.equal(disabledControls(markup(skills, true)), 2);
});
void test('the panel states the connected role modifiers, Diplomat copy and unfinished boundary', () => {
const html = markup(view());
for (const name of [
'Warmaster',
'Master of Assassins',
'Swordmaster of Ginaz',
'Killer Medic',
'Prana-Bindu Adept',
])
assert.match(html, new RegExp(name));
assert.match(html, /development-only module/);
assert.match(html, /Diplomat/);
assert.match(html, /complete mode acceptance is unfinished/);
});
+31 -2
View File
@@ -118,8 +118,25 @@ void test('actual first Storm assigns Tech, a real native shipment pays phase-en
for (const g of [f.afterSetup, f.afterFirstStorm, f.afterFirstMentat, f.afterShipment, f.pending]) custody(g);
});
void test('the normal face-up unused Guild Suk rescues one first; original held Master Bewt assassination returns Suk and printed3 once without replaying winner income or casualties', () => {
const f = fixture(), pending = f.pending;
for (const hasKarama of [false, true]) void test(`the normal Guild Suk rescue and held Master Bewt assassination preserve cleanup ${hasKarama ? 'after the original Moritani special-Karama decline' : 'without a Moritani Karama'}`, () => {
const f = fixture();
const prepared = structuredClone(f.game), moritani = player(prepared, f.moritani);
// Control only the unused physical Karama, before the real sealed plans.
// A random original hand must not silently select a different cleanup path.
for (const card of moritani.hand.filter(c => c.effect === 'karama')) {
moritani.hand.splice(moritani.hand.indexOf(card), 1);
prepared.deck.push(card);
}
if (hasKarama) {
const zone = [prepared.deck, ...prepared.players.filter(p => p.id !== f.moritani).map(p => p.hand)]
.find(cards => cards.some(card => card.effect === 'karama'));
assert.ok(zone);
const index = zone.findIndex(card => card.effect === 'karama');
assert.ok(moritani.hand.length < 4);
moritani.hand.push(zone.splice(index, 1)[0]);
}
f.revealed = revealMoritaniSkillsModulesPlans(prepared, f.planActions);
const pending = resolveMoritaniSkillsModulesBattle(structuredClone(f.revealed), f.moritani);
assert.equal(pending.lastBattleContext?.result, 'normal'); assert.equal(pending.lastBattleContext?.winner, f.opponent);
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
assert.equal(pending.lastBattleContext?.sukRescue?.completed, true);
@@ -157,6 +174,18 @@ void test('the normal face-up unused Guild Suk rescues one first; original held
assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })), originalForces);
assert.deepEqual(game.strongholdCards, pending.strongholdCards);
assert.deepEqual(game.techTokens, pending.techTokens, 'Assassination does not replay or undo original phase income');
if (hasKarama) {
assert.equal(game.decision?.kind, 'moritaniSpecialKarama');
const choice = game.decision!;
assert.equal(choice.player, f.moritani);
assert.ok(viewGame(game, f.moritani).moritaniSpecialKarama);
reject(game, f.opponent, { type: 'decision', event: choice.event, decline: true });
game = applyAction(reload(game), f.moritani, { type: 'decision', event: choice.event, decline: true });
assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })), originalForces);
assert.equal(player(game, f.moritani).spice, moriBalance + 3);
assert.equal(player(game, f.opponent).spice, winnerBalance);
assert.equal(player(game, f.moritani).hand.some(c => c.effect === 'karama'), true);
}
assert.equal(game.decision?.kind, 'battleCards'); assert.equal(game.decision?.player, f.opponent);
reject(game, f.moritani, action);
game = applyAction(reload(game), f.opponent, { type: 'decision', discard: f.opponentCards });
-7
View File
@@ -177,13 +177,6 @@ void test('Basic Moritani permits a projected Sandmaster worm collection and one
17,
);
assert.deepEqual(projected, authoritative);
assert.deepEqual(authoritative, {
leader: 'fremen-0',
key: 'habbanya_ridge_flat:17',
before: 2,
blocked: null,
piles: [{ before: 2, key: 'habbanya_ridge_flat:17' }],
});
const botView = viewGame(restored, 'f');
botView.players.find((player) => player.id === 'f')!.bot = 'Easy';
+3 -9
View File
@@ -16,20 +16,18 @@ for (const kind of ['emperorGift', 'emperorRevival'] as SemutaEmperorShareKind[]
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);
assert.equal(semutaEmperorPlayer(declared, f.owner).spice, semutaEmperorPlayer(f.game, f.owner).spice);
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);
@@ -38,7 +36,6 @@ for (const kind of ['emperorGift', 'emperorRevival'] as SemutaEmperorShareKind[]
// 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);
@@ -51,10 +48,8 @@ void test('the native Emperor share still settles when its cost is declined', ()
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.owner).spice, semutaEmperorPlayer(f.game, f.owner).spice - 3);
assert.equal(semutaEmperorPlayer(settled, f.recipient).spice, semutaEmperorPlayer(f.game, f.recipient).spice + 3);
assert.equal(semutaEmperorPlayer(settled, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true);
});
@@ -68,7 +63,6 @@ void test('every legal Semuta profile finishes the printed Emperor-share cancell
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);
}
+2 -2
View File
@@ -193,11 +193,11 @@ void test('the independent Ecaz card variant is gated before a Smuggler plan is
joinGame(game, newPlayer('d', 'Guild', 'guild'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game.ecazTreachery = true;
assert.throws(() => initializeLeaderSkillsGameForAudit(game), /base factions without expansions or optional modules/);
assert.throws(() => initializeLeaderSkillsGameForAudit(game));
const staged = smugglerBattle();
staged.ecazTreachery = true;
staged.deck.push(...ecazTreacheryCards());
assert.throws(() => viewGame(staged, 'a'), /invalid battle version/);
assert.throws(() => viewGame(staged, 'a'));
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', () => {