Files
Dune/tests/semuta-homeworld-guild-income.test.ts

195 lines
12 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 { semutaGuildIncomeInventory } from './fixture-semuta-guild-income';
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import {
createSemutaHomeworldGuildIncomeFixture, semutaHomeworldGuildCounters,
semutaHomeworldGuildPosition, type SemutaHomeworldGuildFixture,
} from './fixture-semuta-homeworld-guild-income';
function paidSource(f: SemutaHomeworldGuildFixture): Game {
const paid = f.income, before = semutaEmperorPlayer(f.game, f.payer), after = semutaEmperorPlayer(paid, f.payer);
assert.equal(f.cost, 5, 'Five genuine stronghold arrivals pay five, never a canceled price');
assert.equal(f.nativeIncome, 3, 'Original physical-low Junction rounds native income up');
assert.equal(after.spice, before.spice - 5);
assert.equal(after.reserves, before.reserves - 5);
assert.equal(after.forces[f.key], (before.forces[f.key] ?? 0) + 5);
assert.equal(after.shipped, true);
assert.equal(after.moved, before.moved);
assert.equal(paid.active, f.payer);
assert.deepEqual(paid.movementRemaining, f.game.movementRemaining);
assert.equal(semutaEmperorPlayer(paid, f.owner).spice, semutaEmperorPlayer(f.game, f.owner).spice);
assert.deepEqual(semutaGuildIncomeInventory(paid), semutaGuildIncomeInventory(f.setup));
assert.ok(semutaHomeworldGuildCounters(paid).every(row => row.total === 20));
return paid;
}
function costOpportunity(f: SemutaHomeworldGuildFixture, paid: Game): Game {
assert.ok(viewGame(paid, f.canceller).responseControls!.cancelCards.includes(f.karama.id));
assert.throws(() => applyAction(paid, f.payer, f.cancel), 'The payer cannot spend Emperor\'s foreign printed cost');
const pending = applyAction(paid, f.canceller, f.cancel), discard = pending.pendingTreacheryDiscard;
assert.ok(discard, 'Homeworld native income cancellation must offer its printed cost BEFORE settlement');
assert.equal(discard.batch.cause, 'karama:guildIncome');
assert.equal(discard.reaction?.stage, 'offer');
assert.deepEqual(discard.batch.entries, [{ card: f.karama, discardedBy: f.canceller, publicFace: true }]);
assert.deepEqual(semutaHomeworldGuildPosition(pending), semutaHomeworldGuildPosition(paid));
assert.deepEqual(pending.homeworldOccupiedPercentageLedger, paid.homeworldOccupiedPercentageLedger,
'No percentage admission or payout precedes the Semuta offer');
assert.notEqual(pending.decision?.kind, 'homeworldOccupiedPercentage');
assert.equal(semutaEmperorPlayer(pending, f.canceller).hand.some(card => card.id === f.karama.id), false);
for (const seat of pending.players)
assert.equal(viewGame(pending, seat.id).semutaReaction!.canCommit, seat.id === f.holder);
assert.throws(() => applyAction(pending, f.payer, { type: 'endMovement' }));
assert.throws(() => applyAction(pending, f.owner, { type: 'semutaCommit', event: discard.batch.event }));
return pending;
}
function settleShare(f: SemutaHomeworldGuildFixture, state: Game, bot = false): Game {
if (!f.allocation) {
assert.notEqual(state.decision?.kind, 'homeworldOccupiedPercentage');
return state;
}
const offer = viewGame(state, f.allocation.actor).homeworldOccupiedPercentage;
assert.ok(offer);
assert.equal(offer.owner, f.occupiedReceiver);
assert.equal(offer.amount, 2, 'The printed occupied leg rounds the same original gross five DOWN');
assert.equal(offer.minOwnAmount, 2);
assert.equal(offer.maxOwnAmount, 2);
assert.equal(state.decision?.kind, 'homeworldOccupiedPercentage');
assert.equal(state.decision.player, f.allocation.actor);
assert.equal(state.pendingHomeworldOccupiedPercentage!.source.kind, 'guild-shipping');
const source = state.pendingHomeworldOccupiedPercentage!.source;
assert.equal(source.amount, 5);
assert.ok('payment' in source);
assert.deepEqual(source.payment.contributions, [{ payer: f.payer, amount: 5 }]);
assert.equal(source.payment.recipient, f.owner);
const allocation: Action = { type: 'decision', event: offer.event, ownAmount: f.allocation.ownAmount };
assert.throws(() => applyAction(state, f.owner, allocation), 'The native Guild does not own the printed occupied share');
assert.throws(() => applyAction(state, f.allocation!.actor, { ...allocation, ownAmount: 1 }),
'A sole occupier without an ally cannot leave its original share unallocated');
assert.throws(() => applyAction(state, f.payer, { type: 'endMovement' }));
const before = semutaEmperorPlayer(state, f.allocation.actor).spice;
let action = allocation;
if (bot) {
const own = viewGame(state, f.allocation.actor);
own.players.find(player => player.id === f.allocation!.actor)!.bot = 'Easy';
const chosen = botActions(own)[0];
assert.ok(chosen, 'The original occupied-percentage bot must provide a legal allocation');
action = chosen;
}
const done = applyAction(state, f.allocation.actor, action);
assert.equal(semutaEmperorPlayer(done, f.allocation.actor).spice, before + 2);
assert.equal(done.homeworldOccupiedPercentageLedger!.receipts.filter(row => row.source.event === offer.event).length, 1);
assert.equal(done.homeworldOccupiedPercentageLedger!.receipts.find(row => row.source.event === offer.event)!.status, 'settled');
assert.deepEqual(done.homeworldOccupationHistory, state.homeworldOccupationHistory);
assert.deepEqual(done.homeworlds, state.homeworlds);
assert.throws(() => applyAction(done, f.allocation!.actor, allocation), 'The source is allocated once, not replayed');
return done;
}
function finishCanceled(
f: SemutaHomeworldGuildFixture, paid: Game, pending: Game, reacted: Game, claim: boolean, bot = false,
): Game {
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(reacted.resolvedTreacheryDiscardSequence, pending.pendingTreacheryDiscard!.sequence);
const holder = semutaEmperorPlayer(reacted, f.holder);
assert.equal(holder.hand.some(card => card.id === f.karama.id), claim);
assert.equal(holder.hand.some(card => card.id === f.semuta), !claim);
assert.equal(reacted.discard.filter(card => card.id === f.karama.id).length, Number(!claim));
assert.equal(reacted.discard.filter(card => card.id === f.semuta).length, Number(claim));
assert.deepEqual(semutaHomeworldGuildPosition(reacted), semutaHomeworldGuildPosition(paid));
if (f.allocation) {
const source = reacted.pendingHomeworldOccupiedPercentage!.source;
assert.equal(source.kind, 'guild-shipping');
assert.ok('nativeIncome' in source);
assert.equal(source.nativeIncome, 'karama-canceled');
}
const done = settleShare(f, reacted, bot);
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.game, f.owner).spice,
'Original native credit is zero, not ceil three, while the occupied two survives');
assert.equal(semutaEmperorPlayer(done, f.payer).spice, semutaEmperorPlayer(f.game, f.payer).spice - 5);
assert.equal(semutaEmperorPlayer(done, f.payer).forces[f.key], 5);
assert.equal(semutaEmperorPlayer(done, f.payer).reserves, semutaEmperorPlayer(f.game, f.payer).reserves - 5);
assert.equal(semutaEmperorPlayer(done, f.payer).shipped, true);
assert.deepEqual(done.homeworldOccupationHistory, paid.homeworldOccupationHistory);
assert.deepEqual(done.homeworlds, paid.homeworlds);
assert.deepEqual(semutaGuildIncomeInventory(done), semutaGuildIncomeInventory(f.setup));
assert.ok(semutaHomeworldGuildCounters(done).every(row => row.total === 20));
assert.throws(() => applyAction(done, f.payer, f.ship));
assert.throws(() => applyAction(done, f.canceller, f.cancel));
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
const allowed = settleShare(f, applyAction(paid, f.canceller, { type: 'passResponse' }));
assert.equal(semutaEmperorPlayer(allowed, f.owner).spice, semutaEmperorPlayer(f.game, f.owner).spice + 3,
'Uncanceled original income is ceil three from exactly the same paid source');
const originalMovement = applyAction(allowed, f.payer, { type: 'endMovement' });
const canceledMovement = applyAction(done, f.payer, { type: 'endMovement' });
assert.equal(canceledMovement.turn, originalMovement.turn);
assert.equal(canceledMovement.phase, originalMovement.phase);
assert.equal(canceledMovement.active, originalMovement.active);
assert.deepEqual(canceledMovement.movementRemaining, originalMovement.movementRemaining);
assert.deepEqual(canceledMovement.decision, originalMovement.decision);
assert.deepEqual(canceledMovement.response, originalMovement.response);
assert.ok(!canceledMovement.movementRemaining?.includes(f.payer));
assert.equal(semutaEmperorPlayer(canceledMovement, f.payer).forces[f.key], 5);
assert.equal(semutaEmperorPlayer(canceledMovement, f.owner).spice, semutaEmperorPlayer(f.game, f.owner).spice);
return done;
}
for (const advanced of [false, true]) {
for (const claim of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} original low Junction income ${claim ? 'claim' : 'pass'} retains gross-five payment and native Movement`, () => {
const f = createSemutaHomeworldGuildIncomeFixture({ advanced, fullHand: true });
const paid = paidSource(f), pending = costOpportunity(f, paid), event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(semutaEmperorPlayer(f.game, f.holder).hand.length, 4);
let reacted = pending;
if (claim) reacted = applyAction(reacted, f.holder, { type: 'semutaCommit', event });
else for (const seat of pending.players) reacted = applyAction(reacted, seat.id, { type: 'semutaPass', event });
const done = finishCanceled(f, paid, pending, reacted, claim);
const held = semutaEmperorPlayer(f.game, f.holder);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, handLimit(held));
assert.deepEqual(semutaEmperorPlayer(done, f.holder).hand.map(card => card.id).sort(),
(claim ? [...held.hand.filter(card => card.id !== f.semuta).map(card => card.id), f.karama.id]
: held.hand.map(card => card.id)).sort());
},
);
}
for (const claim of [false, true]) void test(
`Advanced actual invaded Junction cost ${claim ? 'claim' : 'pass'} pauses before original owner allocation two and resumes paid Movement`, () => {
const f = createSemutaHomeworldGuildIncomeFixture({ advanced: true, occupied: true, fullHand: true });
assert.equal(f.beforeInvasion!.homeworlds!.custody!.visitors['homeworld:guild']?.r, undefined);
assert.equal(f.occupied!.homeworlds!.custody!.visitors['homeworld:guild'].r.normal, 1);
assert.ok(f.occupationActions.some(step => step.actor === f.occupiedReceiver && step.action.type === 'homeworldShip'));
assert.ok(f.occupied!.homeworldOccupationHistory!.qualifications.some(row =>
row.world === 'homeworld:guild' && row.player === f.occupiedReceiver && row.cause === 'sole'));
const paid = paidSource(f), pending = costOpportunity(f, paid), event = pending.pendingTreacheryDiscard!.batch.event;
let reacted = pending;
if (claim) reacted = applyAction(reacted, f.holder, { type: 'semutaCommit', event });
else {
for (const actor of [f.owner, f.payer, f.canceller]) {
reacted = applyAction(reacted, actor, { type: 'semutaPass', event });
assert.equal(reacted.pendingTreacheryDiscard!.reaction!.stage, 'offer');
assert.deepEqual(semutaHomeworldGuildPosition(reacted), semutaHomeworldGuildPosition(paid));
assert.ok(!reacted.pendingHomeworldOccupiedPercentage);
}
reacted = applyAction(reacted, f.holder, { type: 'semutaPass', event });
}
const done = finishCanceled(f, paid, pending, reacted, claim);
assert.equal(semutaEmperorPlayer(done, f.occupiedReceiver!).spice,
semutaEmperorPlayer(paid, f.occupiedReceiver!).spice + 2);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4);
},
);
void test('legal full-hand Semuta bot and original occupied owner bot finish canceled Junction income without replaying shipment', () => {
const f = createSemutaHomeworldGuildIncomeFixture({ advanced: true, occupied: true, fullHand: true });
const paid = paidSource(f), pending = costOpportunity(f, paid), own = viewGame(pending, f.holder);
own.players.find(player => player.id === f.holder)!.bot = 'Easy';
const action = botActions(own)[0];
assert.ok(action);
const reacted = applyAction(pending, f.holder, action);
finishCanceled(f, paid, pending, reacted, true, true);
});