198 lines
11 KiB
TypeScript
198 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
|
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
|
import { semutaHomeworldChoamMovementResources as resources,
|
|
semutaHomeworldChoamMovementStock as stock } from './fixture-semuta-homeworld-choam-movement';
|
|
import { withoutSemutaHomeworldEconomicHolder } from './fixture-semuta-homeworld-economic-karama';
|
|
import {
|
|
createSemutaHomeworldBgCharityFixture, disposeSemutaHomeworldBgCharity,
|
|
reactSemutaHomeworldBgCharity, finishSemutaHomeworldBgCharity,
|
|
type SemutaHomeworldBgCharityFixture,
|
|
} from './fixture-semuta-homeworld-bg-charity';
|
|
|
|
function reject(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before, 'Rejected wrong-owner, stale or replay actions have no effect');
|
|
}
|
|
|
|
function assertNative(f: SemutaHomeworldBgCharityFixture) {
|
|
assert.equal(f.game.turn, 1);
|
|
assert.equal(f.game.phase, 2);
|
|
assert.equal(player(f.setup, f.payer).spice, 2);
|
|
assert.equal(player(f.originalIncomeResponse, f.payer).spice, 2);
|
|
assert.equal(f.originalIncomeResponse.response?.kind, 'choamCharity');
|
|
assert.equal(player(f.game, f.payer).spice, 12, 'Actual five-player income is ten, once');
|
|
assert.deepEqual(f.game.choamCharity, { turn: 1, canceled: false });
|
|
assert.equal(player(f.setup, f.owner).spice, 5);
|
|
assert.equal(player(f.game, f.owner).spice, 5, 'BG is genuinely wealthy, never staged poor');
|
|
assert.deepEqual(f.game.homeworlds?.custody, f.setup.homeworlds?.custody);
|
|
for (const seat of f.game.players) {
|
|
assert.equal(seat.reserves, player(f.setup, seat.id).reserves);
|
|
assert.equal(seat.tanks, player(f.setup, seat.id).tanks);
|
|
assert.deepEqual(seat.forces, player(f.setup, seat.id).forces);
|
|
assert.deepEqual(seat.elites, player(f.setup, seat.id).elites);
|
|
if (seat.id !== f.payer) assert.equal(seat.spice, player(f.setup, seat.id).spice);
|
|
}
|
|
assert.deepEqual(stock(f.game), stock(f.setup), 'Native stock is physically conserved');
|
|
const own = viewGame(f.gameBeforeBGclaim, f.owner);
|
|
assert.equal(own.charity.homeworld, 0, 'High Wallach gives no fabricated low-population bonus');
|
|
assert.equal(own.charity.payer, f.payer);
|
|
if (f.game.advanced) {
|
|
assert.equal(own.charity.amount, 2);
|
|
assert.equal(f.amount, 2);
|
|
assert.equal(f.game.response?.kind, 'bgCharity');
|
|
assert.equal(f.game.response?.owner, f.owner);
|
|
assert.equal(f.game.response?.amount, 2);
|
|
assert.equal(f.game.response?.charityHomeworld, 0);
|
|
assert.notEqual(player(f.gameBeforeBGclaim, f.owner).charityTurn, 1);
|
|
assert.equal(player(f.game, f.owner).charityTurn, 1, 'Actual claim consumes the allowance before its response');
|
|
} else {
|
|
assert.equal(own.charity.amount, 0);
|
|
assert.equal(f.originalResponse, null);
|
|
assert.equal(f.game.response, null);
|
|
assert.notEqual(player(f.game, f.owner).charityTurn, 1);
|
|
}
|
|
}
|
|
|
|
function assertDenied(f: SemutaHomeworldBgCharityFixture, game: Game, claimed: boolean) {
|
|
assert.ok(!game.pendingTreacheryDiscard);
|
|
assert.notEqual(game.response?.kind, 'bgCharity');
|
|
assert.equal(game.turn, 1);
|
|
assert.equal(game.phase, 2);
|
|
assert.deepEqual(resources(game), resources(f.game), 'Denial and recovery neither pay BG nor debit CHOAM or move forces/custody');
|
|
assert.deepEqual(stock(game), stock(f.game));
|
|
assert.equal(player(game, f.owner).spice, 5);
|
|
assert.equal(player(game, f.payer).spice, 12);
|
|
assert.equal(player(game, f.owner).charityTurn, 1);
|
|
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
|
|
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
|
|
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), claimed);
|
|
assert.equal(player(game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID),
|
|
!claimed && player(f.game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
|
assert.equal(game.discard.filter(card => card.id === f.card).length, Number(!claimed));
|
|
assert.equal(game.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claimed));
|
|
for (const seat of game.players)
|
|
assert.equal(seat.hand.length, player(f.game, seat.id).hand.length - Number(seat.id === f.actor));
|
|
reject(game, f.owner, f.claim);
|
|
reject(game, f.actor, f.action);
|
|
if (claimed) reject(game, f.holder, f.action);
|
|
}
|
|
|
|
function assertSuffix(f: SemutaHomeworldBgCharityFixture, game: Game) {
|
|
const next = finishSemutaHomeworldBgCharity(game);
|
|
assert.equal(next.status, 'playing');
|
|
assert.equal(next.phase, 3);
|
|
assert.equal(next.turn, 1);
|
|
assert.ok(!next.pendingTreacheryDiscard && !next.response && !next.decision && !next.phaseOpening);
|
|
assert.deepEqual(resources(next), resources(f.game), 'Original native close never restores the denied two spice');
|
|
assert.equal(player(next, f.owner).charityTurn, 1);
|
|
assert.equal(viewGame(next, f.owner).charity.payer, f.payer);
|
|
assert.equal(player(next, f.payer).spice, 12);
|
|
reject(next, f.owner, f.claim);
|
|
reject(next, f.actor, f.action);
|
|
const nextAction = nextRicheseSkillsTechNativeStep(next);
|
|
const after = applyAction(next, nextAction.actor, nextAction.action);
|
|
assert.equal(after.turn, 1);
|
|
assert.equal(after.phase, 3);
|
|
assert.equal(player(after, f.owner).spice, 5);
|
|
assert.equal(player(after, f.payer).spice, 12);
|
|
}
|
|
|
|
for (const claimed of [false, true]) void test(`Advanced native BG charity: ${claimed ? 'claim' : 'pass'} pays the printed Karama once and denies the original two spice`, () => {
|
|
const f = createSemutaHomeworldBgCharityFixture();
|
|
assertNative(f);
|
|
reject(f.game, f.holder, f.action);
|
|
const game = disposeSemutaHomeworldBgCharity(f), cost = game.pendingTreacheryDiscard;
|
|
assert.ok(cost && cost.reaction?.stage === 'offer');
|
|
assert.equal(cost.continuation.kind, 'karamaBgCharityDiscard');
|
|
assert.equal(cost.batch.cause, 'karama:bgCharity');
|
|
assert.deepEqual(cost.batch.entries.map(entry => ({ card: entry.card.id, actor: entry.discardedBy, face: entry.publicFace })),
|
|
[{ card: f.card, actor: f.actor, face: true }]);
|
|
if (cost.continuation.kind === 'karamaBgCharityDiscard') {
|
|
assert.deepEqual(cost.continuation.use.response, f.originalResponse!.response);
|
|
assert.deepEqual(cost.continuation.resume.response, f.originalResponse!.response);
|
|
}
|
|
assert.equal(game.response, null);
|
|
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
|
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
|
|
assert.deepEqual(resources(game), resources(f.game));
|
|
assert.equal(player(game, f.owner).charityTurn, 1);
|
|
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
|
reject(game, f.owner, f.claim);
|
|
reject(game, f.actor, f.action);
|
|
reject(game, f.actor, { type: 'passResponse' });
|
|
reject(game, f.target, { type: 'semutaCommit', event: cost.batch.event });
|
|
reject(game, f.holder, { type: 'semutaCommit', event: `${cost.batch.event}:stale` });
|
|
const done = reactSemutaHomeworldBgCharity(JSON.parse(JSON.stringify(game)), f.holder, claimed);
|
|
assertDenied(f, done, claimed);
|
|
reject(done, f.holder, { type: 'semutaCommit', event: cost.batch.event });
|
|
reject(done, f.target, { type: 'semutaPass', event: cost.batch.event });
|
|
assertSuffix(f, done);
|
|
});
|
|
|
|
void test('Advanced native BG charity: full four-card Richese exchanges one card and keeps the absent-Semuta boundary private', () => {
|
|
const f = createSemutaHomeworldBgCharityFixture(true, true);
|
|
assertNative(f);
|
|
assert.equal(player(f.game, f.holder).hand.length, 4);
|
|
assert.equal(handLimit(player(f.game, f.holder)), 4);
|
|
const game = disposeSemutaHomeworldBgCharity(f);
|
|
const neutral = disposeSemutaHomeworldBgCharity(f, withoutSemutaHomeworldEconomicHolder(f.game, f.holder));
|
|
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
|
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
|
reject(neutral, f.holder, { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event });
|
|
for (const seat of game.players.filter(p => p.id !== f.holder)) {
|
|
assert.deepEqual(viewGame(game, seat.id), viewGame(neutral, seat.id));
|
|
assert.equal(viewGame(game, seat.id).semutaReaction?.canCommit, false);
|
|
}
|
|
const done = reactSemutaHomeworldBgCharity(game, f.holder, true);
|
|
assertDenied(f, done, true);
|
|
assert.equal(player(done, f.holder).hand.length, 4);
|
|
const passed = reactSemutaHomeworldBgCharity(neutral, f.holder, false);
|
|
assertDenied({ ...f, game: withoutSemutaHomeworldEconomicHolder(f.game, f.holder) }, passed, false);
|
|
assert.deepEqual(resources(passed), resources(done));
|
|
assertSuffix(f, done);
|
|
});
|
|
|
|
void test('Basic native wealthy BG cannot create an extra charity response or spend its cancel cost', () => {
|
|
const f = createSemutaHomeworldBgCharityFixture(false);
|
|
assertNative(f);
|
|
reject(f.game, f.owner, f.claim);
|
|
reject(f.game, f.actor, f.action);
|
|
assert.equal(player(f.game, f.actor).hand.some(card => card.id === f.card), true);
|
|
assert.equal(f.game.discard.some(card => card.id === f.card), false);
|
|
assert.ok(!f.game.pendingTreacheryDiscard && !f.game.response);
|
|
const next = finishSemutaHomeworldBgCharity(f.game);
|
|
assert.equal(next.phase, 3);
|
|
assert.deepEqual(resources(next), resources(f.game));
|
|
assert.equal(player(next, f.owner).spice, 5);
|
|
assert.equal(player(next, f.payer).spice, 12);
|
|
assert.notEqual(player(next, f.owner).charityTurn, 1);
|
|
});
|
|
|
|
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: actual Semuta and native next BG controls preserve the canceled charity allowance`, () => {
|
|
const f = createSemutaHomeworldBgCharityFixture();
|
|
let game = disposeSemutaHomeworldBgCharity(f);
|
|
const own = viewGame(game, f.holder);
|
|
own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
|
const reaction = botActions(own)[0];
|
|
assert.ok(reaction && (reaction.type === 'semutaCommit' || reaction.type === 'semutaPass'));
|
|
game = applyAction(game, f.holder, reaction);
|
|
if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldBgCharity(game, f.holder, false);
|
|
assertDenied(f, game, reaction.type === 'semutaCommit');
|
|
const nextView = viewGame(game, f.owner);
|
|
nextView.players.find(p => p.id === f.owner)!.bot = difficulty;
|
|
const next = botActions(nextView)[0];
|
|
assert.ok(next);
|
|
const after = applyAction(game, f.owner, next);
|
|
assert.equal(player(after, f.owner).charityTurn, 1);
|
|
assert.equal(player(after, f.owner).spice, 5);
|
|
assert.equal(player(after, f.payer).spice, 12);
|
|
assertSuffix(f, after);
|
|
});
|