164 lines
9.7 KiB
TypeScript
164 lines
9.7 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import {
|
|
createSemutaTupileFixture, reactSemutaTupile, semutaTupileCards,
|
|
semutaTupileCleanupStep, semutaTupileForceTotals,
|
|
type SemutaTupileFixture,
|
|
} from './fixture-semuta-tupile';
|
|
import { semutaEmperorPlayer as semutaTupilePlayer } from './fixture-semuta-emperor-share';
|
|
|
|
function assertOriginalDeparture(f: SemutaTupileFixture) {
|
|
for (const id of [f.occupier, f.ally]) {
|
|
const occupied = semutaTupilePlayer(f.beforeDeparture, id);
|
|
assert.equal(occupied.hand.length, 5);
|
|
assert.equal(handLimit(occupied), 5);
|
|
assert.equal(handLimit(semutaTupilePlayer(f.game, id)), 4);
|
|
}
|
|
assert.equal(semutaTupilePlayer(f.occupied, f.occupier).ally, f.ally);
|
|
assert.equal(semutaTupilePlayer(f.occupied, f.ally).ally, f.occupier);
|
|
const lease = f.occupied.homeworldTupileState!.leases[f.occupied.homeworldTupileState!.current!];
|
|
assert.deepEqual(lease.holders, [f.occupier, f.ally]);
|
|
const source = f.occupied.homeworldOccupationHistory!.sources.find(row => row.event === lease.source.qualification)!;
|
|
assert.ok(source);
|
|
assert.notEqual(source.cause, 'setup');
|
|
assert.equal(f.game.homeworldTupileState!.current, null);
|
|
assert.deepEqual(f.game.homeworldTupileState!.leases[lease.id].lost, [f.occupier, f.ally]);
|
|
assert.equal(semutaTupilePlayer(f.game, f.occupier).spice,
|
|
semutaTupilePlayer(f.beforeDeparture, f.occupier).spice - 1);
|
|
assert.equal(semutaTupilePlayer(f.game, f.occupier).reserves,
|
|
semutaTupilePlayer(f.beforeDeparture, f.occupier).reserves + 1);
|
|
assert.equal(semutaTupilePlayer(f.game, f.occupier).shipped, true);
|
|
assert.equal(f.game.active, f.occupier);
|
|
assert.equal(f.game.phase, 5);
|
|
assert.deepEqual(semutaTupileCards(f.game), f.cards);
|
|
assert.ok(semutaTupileForceTotals(f.game).every(row => row.total === 20));
|
|
}
|
|
|
|
function openOriginalCleanup(state: Game, actor: string, action: Parameters<typeof applyAction>[2]): Game {
|
|
const selected = action.cards as string[], event = action.event as string;
|
|
const cards = selected.map(id => semutaTupilePlayer(state, actor).hand.find(card => card.id === id)!);
|
|
const pending = applyAction(state, actor, action);
|
|
const batch = pending.pendingTreacheryDiscard!;
|
|
assert.ok(batch, 'The selected original private cleanup cost must pause before its source suffix');
|
|
assert.deepEqual(batch.batch.entries, cards.map(card => ({ card, discardedBy: actor, publicFace: false })));
|
|
assert.equal(semutaTupilePlayer(pending, actor).hand.length, 4);
|
|
assert.equal(pending.homeworldTupileState!.cleanups.find(row => row.event === event)!.status, 'pending');
|
|
const holder = state.players.find(player => player.hand.some(card => card.id === SEMUTA_DRUG_ID))?.id;
|
|
for (const seat of state.players) {
|
|
const offer = viewGame(pending, seat.id).semutaReaction!;
|
|
assert.deepEqual(offer.candidates, [], 'No private cleanup faces before commitment');
|
|
assert.equal(offer.canCommit, seat.id === holder);
|
|
assert.throws(() => applyAction(pending, seat.id, {
|
|
type: 'semutaSelect', event: batch.batch.event, card: selected[0],
|
|
}));
|
|
}
|
|
assert.throws(() => applyAction(pending, actor, action));
|
|
assert.throws(() => applyAction(pending, state.active!, { type: 'endMovement' }));
|
|
return pending;
|
|
}
|
|
|
|
for (const claimAt of [null, 'first', 'second'] as const) void test(
|
|
`Advanced real Tupile departure: ${claimAt ? `claim ${claimAt}` : 'pass both'} original old-holder costs before native Movement`, () => {
|
|
const f = createSemutaTupileFixture();
|
|
assertOriginalDeparture(f);
|
|
const first = openOriginalCleanup(f.game, f.owner, f.action);
|
|
const firstEvent = first.pendingTreacheryDiscard!.batch.event;
|
|
const firstCard = f.selected[0];
|
|
assert.equal(viewGame(first, f.holder).semutaReaction!.canCommit, true);
|
|
assert.throws(() => applyAction(first, f.owner, { type: 'semutaCommit', event: firstEvent }));
|
|
let game = reactSemutaTupile(first, f.holder, claimAt === 'first');
|
|
assert.equal(game.homeworldTupileState!.cleanups.find(row => row.event === f.event)!.status, 'completed');
|
|
assert.equal(semutaTupilePlayer(game, f.holder).hand.some(card => card.id === firstCard), claimAt === 'first');
|
|
assert.equal(semutaTupilePlayer(game, f.owner).hand.length, 4);
|
|
assert.equal(handLimit(semutaTupilePlayer(game, f.owner)), 4);
|
|
assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
|
|
const second = semutaTupileCleanupStep(game);
|
|
assert.notEqual(second.actor, f.owner);
|
|
assert.notEqual(second.action.event, f.event);
|
|
assert.equal(semutaTupilePlayer(game, second.actor).hand.length, 5, 'Separate old holder has not paid yet');
|
|
assert.throws(() => applyAction(game, f.owner, second.action));
|
|
assert.throws(() => applyAction(game, f.owner, f.action));
|
|
const secondCard = (second.action.cards as string[])[0];
|
|
const secondBefore = game;
|
|
if (claimAt === 'first') {
|
|
game = applyAction(game, second.actor, second.action);
|
|
// Semuta was spent on the first cost; the second ORIGINAL cleanup still
|
|
// discards its own selected card, without creating a fake pair offer.
|
|
if (game.pendingTreacheryDiscard) game = reactSemutaTupile(game, f.holder, false);
|
|
} else {
|
|
const secondPending = openOriginalCleanup(game, second.actor, second.action);
|
|
assert.notEqual(secondPending.pendingTreacheryDiscard!.batch.event, firstEvent);
|
|
assert.equal(secondPending.pendingTreacheryDiscard!.batch.entries.length, 1);
|
|
assert.equal(secondPending.pendingTreacheryDiscard!.batch.entries[0].card.id, secondCard);
|
|
game = reactSemutaTupile(secondPending, f.holder, claimAt === 'second');
|
|
}
|
|
assert.equal(semutaTupilePlayer(game, second.actor).hand.length, 4);
|
|
assert.equal(semutaTupilePlayer(game, f.holder).hand.length, 4, 'Full Richese hand exchanges Semuta rather than gaining capacity');
|
|
assert.equal(semutaTupilePlayer(game, f.holder).hand.some(card => card.id === secondCard), claimAt === 'second');
|
|
for (const row of game.homeworldTupileState!.cleanups) assert.equal(row.status, 'completed');
|
|
assert.equal(game.phase, 5);
|
|
assert.equal(game.active, f.occupier);
|
|
assert.deepEqual(game.movementRemaining, f.game.movementRemaining);
|
|
assert.deepEqual(game.players.map(player => player.spice), f.game.players.map(player => player.spice));
|
|
assert.deepEqual(semutaTupileForceTotals(game), semutaTupileForceTotals(f.game));
|
|
assert.deepEqual(semutaTupileCards(game), f.cards);
|
|
assert.equal(game.discard.filter(card => card.id === firstCard).length, Number(claimAt !== 'first'));
|
|
assert.equal(game.discard.filter(card => card.id === secondCard).length, Number(claimAt !== 'second'));
|
|
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimAt !== null));
|
|
assert.throws(() => applyAction(game, second.actor, second.action));
|
|
assert.throws(() => applyAction(game, f.holder, { type: 'semutaCommit', event: firstEvent }));
|
|
// The actual unused Movement suffix remains legal exactly once: departure
|
|
// already paid and shipped, and neither cleanup can replay it.
|
|
const suffix = applyAction(game, f.occupier, { type: 'endMovement' });
|
|
assert.ok(!suffix.movementRemaining?.includes(f.occupier));
|
|
assert.notEqual(suffix.active, f.occupier);
|
|
assert.throws(() => applyAction(suffix, f.occupier, { type: 'endMovement' }));
|
|
assert.equal(semutaTupilePlayer(suffix, f.occupier).spice, semutaTupilePlayer(secondBefore, f.occupier).spice);
|
|
assert.deepEqual(semutaTupileCards(suffix), f.cards);
|
|
},
|
|
);
|
|
|
|
void test('neutral Tupile passes keep the next old holder closed until the last seat acknowledges', () => {
|
|
const f = createSemutaTupileFixture();
|
|
let game = openOriginalCleanup(f.game, f.owner, f.action);
|
|
const event = game.pendingTreacheryDiscard!.batch.event;
|
|
for (const seat of game.players.filter(player => player.id !== f.holder)) {
|
|
game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
|
assert.ok(game.pendingTreacheryDiscard);
|
|
assert.equal(game.homeworldTupileState!.cleanups.find(row => row.event === f.event)!.status, 'pending');
|
|
assert.throws(() => applyAction(game, seat.id, { type: 'semutaPass', event }));
|
|
}
|
|
game = applyAction(game, f.holder, { type: 'semutaPass', event });
|
|
assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
|
|
assert.notEqual(game.decision!.player, f.owner);
|
|
});
|
|
|
|
|
|
void test('minimal legal bot settles both native Tupile cleanup owners and the full-hand Semuta claim', () => {
|
|
const f = createSemutaTupileFixture();
|
|
let game = f.game;
|
|
for (let n = 0; (game.decision?.kind === 'homeworldTupileCleanup' || game.pendingTreacheryDiscard) && n < 24; n++) {
|
|
const pending = game.pendingTreacheryDiscard;
|
|
const actor = pending?.reaction?.stage === 'offer'
|
|
? f.holder
|
|
: game.decision!.player;
|
|
const view = viewGame(game, actor);
|
|
view.players.find(player => player.id === actor)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
assert.ok(action, 'Actual original cleanup / Semuta owner has a legal bot action');
|
|
game = applyAction(game, actor, action);
|
|
if (game.pendingTreacheryDiscard && !semutaTupilePlayer(game, f.holder).hand.some(card => card.id === f.semuta))
|
|
game = reactSemutaTupile(game, f.holder, false);
|
|
}
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
assert.equal(game.pendingHomeworldTupileCleanup, null);
|
|
assert.equal(game.decision, null);
|
|
assert.equal(game.active, f.occupier);
|
|
assert.ok(game.homeworldTupileState!.cleanups.every(row => row.status === 'completed'));
|
|
assert.equal(semutaTupilePlayer(game, f.holder).hand.length, 4);
|
|
assert.deepEqual(semutaTupileCards(game), f.cards);
|
|
});
|