Prove Emperor share and Moritani cleanup through native phases
This commit is contained in:
1 parent
308797567e
commit
adb054023d
10 files changed
+161
-91
No files matched your search
@@ -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)!;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user