Offer Semuta on printed Emperor-share cancellation and repair stale unit tests
This commit is contained in:
1 parent
63e5f22607
commit
308797567e
12 files changed
+319
-28
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -452,6 +452,5 @@ void test('shipment completion emits an executable mixed-Homeworld source witnes
|
||||
minimum: 2,
|
||||
},
|
||||
},
|
||||
/Basic/,
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user