Preserve Basic shared arrival reactions through Semuta
Admit the original ordered Ecaz/Moritani entry in bare Basic Richese games, retain both native opportunities during fresh discard recovery, and bind each suspended child to its actual arrival. Show the current discard response in the table banner. Cover real five-seat setup and placements, both owner orders, physical payouts/draws/gifts, persistent controls and minimal legal bot participation.
This commit is contained in:
1 parent
7b9938219d
commit
4b2f4f3fa3
12 files changed
+815
-23
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createSemutaArrivalOverlapFixture as fixture, actSemutaArrivalOverlap as act,
|
||||
player, inventory, passCurrentSemuta as pass, claimOverlapSemuta as claim,
|
||||
openOverlapAmbassadorGrant as grant, openOverlapTerrorDiscard as terrorDiscard,
|
||||
finishOverlapTerror as finishTerror, type SemutaArrivalOverlapFixture,
|
||||
} from './fixture-semuta-arrival-overlap';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function arrived(game: Game, f: SemutaArrivalOverlapFixture) {
|
||||
assert.equal(player(game, f.entrant).forces[f.location], 1);
|
||||
assert.equal(player(game, f.entrant).shipped, true);
|
||||
assert.equal(player(game, f.entrant).moved, f.arrival === 'movement' ? 1 : 0);
|
||||
assert.equal(player(game, f.entrant).spice, f.balances[f.entrant] - Number(f.arrival === 'shipment'));
|
||||
for (const p of game.players)
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
function ambassadorDiscard(source: Game, f: SemutaArrivalOverlapFixture) {
|
||||
const entry = source.pendingAmbassador; assert.equal(entry?.stage, 'cards');
|
||||
const game = act(source, f.owner, { type: 'decision', event: entry!.event, cards: f.chosenCards });
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
|
||||
assert.equal(game.pendingTreacheryDiscard.reaction?.stage, 'offer');
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
assert.ok(game.pendingArrivalOverlap, 'The original ordered parent remains while the discard is read');
|
||||
assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates, []);
|
||||
arrived(game, f); return game;
|
||||
}
|
||||
function done(game: Game, f: SemutaArrivalOverlapFixture) {
|
||||
assert.ok(!game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry);
|
||||
assert.ok(!game.pendingTreacheryDiscard && !game.decision && !game.response);
|
||||
assert.equal(game.active, f.entrant); arrived(game, f);
|
||||
game = act(game, f.entrant, { type: 'endMovement' });
|
||||
assert.ok(!game.movementRemaining?.includes(f.entrant));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
|
||||
for (const first of ['ambassador', 'terror'] as const)
|
||||
for (const arrival of ['shipment', 'movement'] as const)
|
||||
void test(`actual Basic ${arrival} snapshots both original native owners, ${first} first from real lobby circles`, () => {
|
||||
const f = fixture({ effect: 'choam', first, arrival });
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false);
|
||||
assert.equal(f.afterSetup.status, 'playing'); assert.equal(f.game.turn, 2);
|
||||
assert.equal(f.game.players.length, 5); assert.equal(f.game.semutaPreview, true);
|
||||
assert.ok(f.actions.some(step => step.action.type === 'seatPosition'));
|
||||
assert.equal(f.game.moritaniTerror!.placementTurn, 1);
|
||||
assert.equal(f.game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'placed');
|
||||
assert.ok(f.game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(f.territory + ':'))));
|
||||
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
|
||||
const overlap = game.pendingArrivalOverlap; assert.ok(overlap);
|
||||
assert.equal(overlap.first, first); assert.equal(overlap.stage, 'first');
|
||||
assert.equal(overlap.entrant, f.entrant); assert.equal(overlap.cause, arrival);
|
||||
assert.equal(overlap.ambassadorToken, f.token);
|
||||
assert.deepEqual(overlap.terrorTokens, [f.terrorToken]);
|
||||
assert.equal(game.decision?.player, first === 'ambassador' ? f.owner : f.moritani);
|
||||
arrived(game, f);
|
||||
reject(game, f.entrant, { type: 'endMovement' });
|
||||
game = first === 'ambassador'
|
||||
? act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, decline: true })
|
||||
: act(game, f.moritani, { type: 'decision', decline: true });
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second'); arrived(game, f);
|
||||
game = first === 'ambassador'
|
||||
? act(game, f.moritani, { type: 'decision', decline: true })
|
||||
: act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, decline: true });
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('CHOAM first recovers one private card before Sabotage, pays once and cannot spend the same Semuta twice', () => {
|
||||
const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'movement' });
|
||||
let game = grant(f.game, f);
|
||||
const beforeDiscard = structuredClone(game);
|
||||
game = ambassadorDiscard(game, f);
|
||||
const firstEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
const overlap = structuredClone(game.pendingArrivalOverlap);
|
||||
assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
|
||||
reject(game, f.moritani, { type: 'decision', reveal: true });
|
||||
game = act(game, f.claimant, { type: 'semutaCommit', event: firstEvent });
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
||||
assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates.map(card => card.id), f.chosenCards);
|
||||
assert.deepEqual(game.pendingArrivalOverlap, overlap);
|
||||
for (const p of game.players.filter(p => p.id !== f.claimant))
|
||||
assert.deepEqual(viewGame(game, p.id).semutaReaction!.candidates, []);
|
||||
reject(game, f.owner, { type: 'semutaSelect', event: firstEvent, card: f.chosenCards[0] });
|
||||
game = act(game, f.claimant, { type: 'semutaSelect', event: firstEvent, card: f.chosenCards[0] });
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
|
||||
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.chosenCards[0]));
|
||||
game = terrorDiscard(game, f);
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.notEqual(game.pendingTreacheryDiscard.batch.event, firstEvent);
|
||||
for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false);
|
||||
reject(game, f.claimant, { type: 'semutaCommit', event: firstEvent });
|
||||
game = finishTerror(pass(game), f, { give: true });
|
||||
assert.ok(player(game, f.entrant).hand.some(card => card.id === f.gift));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('Robbery first passes its overflow before a full-hand Ixian claim and one original replacement', () => {
|
||||
const f = fixture({ effect: 'ixians', first: 'terror', terror: 'robbery', fullHand: true });
|
||||
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
|
||||
const top = game.deck[0].id, size = game.deck.length;
|
||||
game = terrorDiscard(game, f);
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'terrorDiscard');
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'first');
|
||||
assert.equal(game.deck.length, size - 1);
|
||||
assert.ok(player(game, f.moritani).hand.some(card => card.id === top));
|
||||
const terrorEvent = game.pendingTreacheryDiscard.batch.event;
|
||||
game = pass(game);
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
||||
game = grant(game, f); const replacement = game.deck[0].id;
|
||||
game = ambassadorDiscard(game, f);
|
||||
reject(game, f.claimant, { type: 'semutaCommit', event: terrorEvent });
|
||||
assert.equal(game.deck.length, size - 1, 'Ixian replacement has not drawn during the fresh discard');
|
||||
game = claim(game, f.claimant, f.chosenCards[0]);
|
||||
assert.equal(player(game, f.claimant).hand.length, 4);
|
||||
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.chosenCards[0]));
|
||||
assert.equal(game.deck.length, size - 2);
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === replacement));
|
||||
assert.ok(player(game, f.moritani).hand.some(card => card.id === top));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('Sabotage first spends Semuta on its victim before copied CHOAM creates a distinct discard', () => {
|
||||
const f = fixture({ effect: 'choam', first: 'terror', arrival: 'movement', copied: true });
|
||||
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
|
||||
game = terrorDiscard(game, f);
|
||||
const terrorEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(game.pendingTreacheryDiscard!.batch.entries[0].card.id, f.entrantCard);
|
||||
game = claim(game, f.claimant, f.entrantCard);
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'first');
|
||||
game = finishTerror(game, f);
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
game = grant(game, f);
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'removed');
|
||||
game = ambassadorDiscard(game, f);
|
||||
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, terrorEvent);
|
||||
for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false);
|
||||
game = pass(game);
|
||||
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.entrantCard));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('Ixian first passes before Robbery overflow recovery without replaying either draw', () => {
|
||||
const f = fixture({ effect: 'ixians', first: 'ambassador', terror: 'robbery', arrival: 'movement' });
|
||||
let game = grant(f.game, f);
|
||||
const size = game.deck.length;
|
||||
const ixianDraw = game.deck[0].id;
|
||||
game = ambassadorDiscard(game, f);
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
game = pass(game);
|
||||
assert.equal(game.deck.length, size - 1);
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === ixianDraw));
|
||||
const robberyDraw = game.deck[0].id;
|
||||
game = terrorDiscard(game, f);
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.notEqual(game.pendingTreacheryDiscard.batch.event, event);
|
||||
assert.equal(game.deck.length, size - 2);
|
||||
assert.ok(player(game, f.moritani).hand.some(card => card.id === robberyDraw));
|
||||
game = claim(game, f.claimant, f.overflowDiscard);
|
||||
assert.equal(game.deck.length, size - 2);
|
||||
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.overflowDiscard));
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('an empty copied CHOAM grant opens no empty fresh group and retains the second native Terror offer', () => {
|
||||
const f = fixture({ effect: 'choam', first: 'ambassador', copied: true });
|
||||
let game = grant(f.game, f); const spice = player(game, f.owner).spice;
|
||||
game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: [] });
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.equal(player(game, f.owner).spice, spice);
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
||||
game = act(game, f.moritani, { type: 'decision', decline: true });
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('the Ambassador discard must keep the original shared entry sector and active owner', () => {
|
||||
const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'movement' });
|
||||
const game = ambassadorDiscard(grant(f.game, f), f);
|
||||
const action: Action = { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event };
|
||||
const moved = structuredClone(game), continuation = moved.pendingTreacheryDiscard!.continuation;
|
||||
assert.equal(continuation.kind, 'ambassador');
|
||||
if (continuation.kind === 'ambassador') continuation.entry.sector = 18;
|
||||
reject(moved, f.claimant, action);
|
||||
const advanced = structuredClone(game);
|
||||
advanced.pendingArrivalOverlap!.stage = 'second';
|
||||
reject(advanced, f.claimant, action);
|
||||
});
|
||||
|
||||
void test('the Terror discard must retain the original shared entry amount before Semuta resumes it', () => {
|
||||
const f = fixture({ effect: 'ixians', first: 'terror', terror: 'robbery' });
|
||||
const game = terrorDiscard(act(f.game, f.arrivalStep.actor, f.arrivalStep.action), f);
|
||||
const action: Action = { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event };
|
||||
const changed = structuredClone(game), continuation = changed.pendingTreacheryDiscard!.continuation;
|
||||
assert.equal(continuation.kind, 'terrorDiscard');
|
||||
if (continuation.kind === 'terrorDiscard') continuation.entry.amount++;
|
||||
reject(changed, f.claimant, action);
|
||||
});
|
||||
Reference in new issue
Block a user