Preserve native Homeworld paid shares through Semuta income costs

This commit is contained in:
Quintine committed 2026-10-12 17:36:01 +11:00
1 parent a4f1cfc650
commit bbf27c4f3d
11 files changed
+1005 -6

No files matched your search

@@ -0,0 +1,256 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import type { Card } from '../game/cards';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { semutaEmperorIncomeCards } from './fixture-semuta-emperor-income';
import {
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
} from './fixture-semuta-emperor-share';
export type SemutaHomeworldEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome';
export type SemutaHomeworldEmperorIncomeStep = { actor: string; action: Action };
export type SemutaHomeworldEmperorIncomeFixture = {
kind: SemutaHomeworldEmperorIncomeKind;
game: Game;
setup: Game;
occupied: Game | null;
beforePayment: Game;
sourceActions: SemutaHomeworldEmperorIncomeStep[];
qualificationActions: SemutaHomeworldEmperorIncomeStep[];
owner: string;
holder: string;
canceler: string;
buyer: string;
acquired: string;
karama: string;
karamaCard: Card;
special?: string;
cancel: Action;
amount: number;
nativeIncome: number;
occupiedAmount: number;
allocationOwner: string | null;
allocationAlly: string | null;
originalCards: string[];
staging: string[];
};
export function nextSemutaHomeworldEmperorIncomeStep(game: Game): SemutaHomeworldEmperorIncomeStep {
const pending = game.pendingTreacheryDiscard;
const reaction = pending?.reaction;
if (reaction?.stage === 'offer' && pending) return {
actor: game.players.find(player => !reaction.passed.includes(player.id))!.id,
action: { type: 'semutaPass', event: pending.batch.event },
};
if (game.decision?.kind === 'homeworldShipmentGuild') return {
actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, allow: true },
};
if (game.decision?.kind === 'choamMarket') return {
actor: game.decision.player, action: { type: 'decision', done: true },
};
return nextRicheseSkillsTechNativeStep(game);
}
/** Subset counters are already included in the twenty physical forces. */
export function semutaHomeworldEmperorForceTotals(game: Game) {
return game.players.map(player => ({
id: player.id,
total: player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0) +
Object.values(game.homeworlds!.custody!.visitors).reduce((sum, visitors) =>
sum + (visitors[player.id]?.normal ?? 0) + (visitors[player.id]?.elite ?? 0), 0),
elite: player.elites ? player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0) +
Object.values(game.homeworlds!.custody!.visitors).reduce((sum, visitors) =>
sum + (visitors[player.id]?.elite ?? 0), 0) : null,
}));
}
/** Move an original card to the next native draw, never replace the deck. */
function nextOriginalDraw(game: Game, id: string): void {
for (const zone of [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
...game.players.map(player => player.hand)]) {
const at = zone.findIndex(card => card.id === id);
if (at < 0) continue;
game.deck.unshift(zone.splice(at, 1)[0]);
return;
}
throw new Error(`Missing original draw ${id}`);
}
/** Original setup/phase controls and an actual Advanced invasion establish all
* authority. Only unplayed Spice order and conserved later cards/forces are staged.
* Emperor Sardaukar remain a subset of reserves on their original Salusa world. */
export function createSemutaHomeworldEmperorIncomeFixture(
kind: SemutaHomeworldEmperorIncomeKind,
options: { advanced?: boolean; occupied?: boolean; acquiredKarama?: boolean } = {},
): SemutaHomeworldEmperorIncomeFixture {
const advanced = options.advanced ?? (kind === 'richesePurchaseIncome' || !!options.occupied);
assert.ok(kind !== 'richesePurchaseIncome' || advanced);
assert.ok(!options.occupied || advanced, 'No new Basic occupation epoch interpretation');
assert.ok(!options.acquiredKarama || kind === 'emperorIncome');
let game = createGame('SEMUTAHWEMPEROR', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('g', 'Guild', 'guild'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeSemutaGameForAudit(game);
const originalCards = semutaEmperorIncomeCards(game), staging: string[] = [];
const sourceActions: SemutaHomeworldEmperorIncomeStep[] = [], qualificationActions: SemutaHomeworldEmperorIncomeStep[] = [];
const act = (step: SemutaHomeworldEmperorIncomeStep, record?: SemutaHomeworldEmperorIncomeStep[]) => {
record?.push(step);
game = applyAction(game, step.actor, step.action);
};
const clean = () => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
const reach = (until: () => boolean, record?: SemutaHomeworldEmperorIncomeStep[]) => {
for (let n = 0; !until() && n < 2600; n++) {
assert.notEqual(game.status, 'finished', 'Original native actions must reach the paid income source');
act(nextSemutaHomeworldEmperorIncomeStep(game), record);
}
assert.ok(until(), 'Original native actions must reach the requested income boundary');
};
const orderUnplayedSpice = (kinds: ('land' | 'worm')[]) => {
for (const [position, type] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
(type === 'land' ? 'territory' in card : 'worm' in card));
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
};
reach(() => game.status === 'playing');
const setup = structuredClone(game);
orderUnplayedSpice(['land', 'land']);
let occupied: Game | null = null;
if (options.occupied) {
reach(() => game.phase === 5 && clean());
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
}
reach(() => game.turn === 2 && game.phase === 1 && clean());
orderUnplayedSpice(['worm', 'land', 'land']);
reach(() => game.nexus && !game.spiceWindow && !game.spiceResolution && clean());
act({ actor: 'g', action: { type: 'alliance', target: 'a' } }, qualificationActions);
act({ actor: 'a', action: { type: 'alliance', target: 'g' } }, qualificationActions);
reach(() => game.phase === 5 && game.active === 'g' && clean());
const emperor = semutaEmperorPlayer(game, 'e');
const normal = emperor.reserves - (emperor.elites?.reserves ?? 0);
emperor.forces['polar_sink:0'] = (emperor.forces['polar_sink:0'] ?? 0) + normal;
emperor.reserves -= normal;
const invade = homeworldShipmentChoice(viewGame(game, 'g'), 'homeworld:emperor',
{ 'homeworld:guild': { normal: 1, elite: 0 } });
assert.ok(invade.action, invade.blocked ?? undefined);
act({ actor: 'g', action: invade.action }, qualificationActions);
reach(clean, qualificationActions);
const authority = quoteStableHomeworldOccupation(game, 'kaitain');
assert.equal(authority.blocked, null);
assert.equal(authority.entitlement?.occupier, 'g');
assert.equal(authority.entitlement?.ally, 'a');
assert.ok(game.homeworldOccupationHistory!.sources.some(source =>
source.event === authority.entitlement!.qualification && source.cause !== 'setup'));
occupied = structuredClone(game);
orderUnplayedSpice(['land', 'land', 'land', 'land']);
reach(() => game.turn === 3 && game.phase === 2 && clean());
staging.push('Original turn2 worm alliance and real one-counter Guild invasion qualify Kaitain; original Salusa Sardaukar remain untouched.');
} else {
reach(() => game.phase === 2 && clean());
const emperor = semutaEmperorPlayer(game, 'e');
const normal = emperor.reserves - (emperor.elites?.reserves ?? 0);
assert.ok(normal >= 4);
emperor.forces['polar_sink:0'] = (emperor.forces['polar_sink:0'] ?? 0) + normal - 4;
emperor.reserves -= normal - 4;
// Basic has no separate Salusa: its elite subset also counts on Kaitain.
// Move those original counters with their typed subset to keep only four.
if (!advanced && emperor.elites) {
const elite = emperor.elites.reserves;
emperor.reserves -= elite;
emperor.forces['polar_sink:0'] += elite;
emperor.elites.reserves = 0;
emperor.elites.forces['polar_sink:0'] = (emperor.elites.forces['polar_sink:0'] ?? 0) + elite;
}
staging.push('Conserved later Emperor counters leave four Kaitain natives; Advanced retains its original Salusa subset, while Basic moves that included subset to Polar Sink.');
}
// Physical staging follows all earlier original purchases, before this source.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
const special = kind === 'richesePurchaseIncome' ? moveSemutaEmperorCard(game,
semutaEmperorCardId(game, card => card.effect === 'karama'), 'r') : undefined;
const karama = semutaEmperorCardId(game, card => card.effect === 'karama' && card.id !== special);
if (options.acquiredKarama) nextOriginalDraw(game, karama);
else moveSemutaEmperorCard(game, karama, 'a');
if (kind === 'emperorIncome') moveSemutaEmperorCard(game,
semutaEmperorCardId(game, card => card.effect === 'karama' && card.id !== karama), 'e');
const fillHolder = () => {
const holder = semutaEmperorPlayer(game, 'r');
while (holder.hand.length < handLimit(holder)) {
const card = game.deck.find(candidate => candidate.effect !== 'karama');
assert.ok(card);
moveSemutaEmperorCard(game, card.id, holder.id);
}
assert.equal(holder.hand.length, 4);
};
let beforePayment: Game, acquired: string;
if (kind === 'richesePurchaseIncome') {
fillHolder();
reach(() => game.decision?.kind === 'richeseDeclaration' && !game.phaseOpening && !game.response);
assert.equal(game.decision!.player, 'r');
beforePayment = structuredClone(game);
acquired = game.richeseCache![0].id;
act({ actor: 'r', action: { type: 'card', mode: 'special', card: special!, acquire: acquired } }, sourceActions);
} else {
acquired = options.acquiredKarama ? karama : game.deck.find(card => card.effect !== 'karama')!.id;
nextOriginalDraw(game, acquired);
reach(() => game.phase === 3 && !!game.auction && clean());
assert.equal(game.auction!.cards[game.auction!.index].id, acquired);
assert.ok(game.auction!.cards.length >= 2);
fillHolder();
for (let n = 0; game.auction!.active !== 'a' && n < 8; n++)
act({ actor: game.auction!.active, action: { type: 'passBid' } });
assert.equal(game.auction!.active, 'a');
beforePayment = structuredClone(game);
act({ actor: 'a', action: { type: 'bid', amount: 5 } }, sourceActions);
reach(() => game.response?.kind === kind, sourceActions);
}
assert.equal(game.response?.kind, kind);
assert.equal(game.response!.owner, 'e');
assert.notEqual(game.response!.source, 'ambassador');
const amount = kind === 'emperorIncome' ? 5 : 3;
const payment = viewGame(game, 'e').paymentIncome!;
assert.equal(payment.gross, amount);
assert.equal(payment.low, true);
assert.equal(payment.income, Math.ceil(amount / 2));
const authority = quoteStableHomeworldOccupation(game, 'kaitain');
assert.equal(authority.blocked, null);
assert.equal(authority.entitlement?.occupier ?? null, options.occupied ? 'g' : null);
const karamaCard = semutaEmperorPlayer(game, 'a').hand.find(card => card.id === karama)!;
assert.ok(karamaCard);
assert.deepEqual(semutaEmperorIncomeCards(game), originalCards);
assert.ok(semutaHomeworldEmperorForceTotals(game).every(row => row.total === 20 &&
(row.elite === null || row.elite === 5)));
staging.push('Canonical later cards fill Richese to hand4; foreign printed cancellation and own special activation have distinct original identities.');
return { kind, game, setup, occupied, beforePayment, sourceActions, qualificationActions,
owner: 'e', holder: 'r', canceler: 'a', buyer: kind === 'emperorIncome' ? 'a' : 'r',
acquired, karama, karamaCard, special, cancel: { type: 'card', mode: 'cancel', card: karama },
amount, nativeIncome: Math.ceil(amount / 2), occupiedAmount: options.occupied ? Math.floor(amount / 2) : 0,
allocationOwner: authority.entitlement?.occupier ?? null, allocationAlly: authority.entitlement?.ally ?? null,
originalCards, staging };
}
export function reactSemutaHomeworldEmperorIncome(state: Game, holder: string, claim: boolean): Game {
const event = state.pendingTreacheryDiscard!.batch.event;
if (claim) return applyAction(state, holder, { type: 'semutaCommit', event });
let game = state;
for (const player of state.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { botEntryAllowed } from '../game/bot-mobility';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { reserveShipmentCost } from '../game/shipment-price';
import { quoteHomeworldPaymentIncome } from '../game/homeworld-payment-income';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import type { Card } from '../game/cards';
import { nextSemutaGuildIncomeNativeStep, semutaGuildIncomeInventory } from './fixture-semuta-guild-income';
import {
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
} from './fixture-semuta-emperor-share';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldGuildOptions = { advanced?: boolean; occupied?: boolean; fullHand?: boolean };
export type SemutaHomeworldGuildFixture = {
game: Game;
income: Game;
setup: Game;
beforeStaging: Game;
beforeInvasion: Game | null;
occupied: Game | null;
actions: RicheseSkillsTechStep[];
occupationActions: RicheseSkillsTechStep[];
producerActions: RicheseSkillsTechStep[];
staging: string[];
owner: string;
payer: string;
holder: string;
canceller: string;
karama: Card;
semuta: string;
ship: Action;
cancel: Action;
amount: number;
cost: number;
key: string;
nativeIncome: number;
occupiedReceiver: string | null;
allocation: { actor: string; ownAmount: number } | null;
};
/** Elites are subsets of native reserves/Arrakis forces, not extra counters.
* Foreign Homeworld visitors are outside their owner's native reserve count. */
export function semutaHomeworldGuildCounters(game: Game) {
return game.players.map(player => ({ id: player.id, total: player.reserves + player.tanks +
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0) +
Object.values(game.homeworlds!.custody!.visitors).reduce((sum, visitors) =>
sum + (visitors[player.id]?.normal ?? 0) + (visitors[player.id]?.elite ?? 0), 0),
}));
}
/** Hands/discard and cancellation continuation are excluded; original paid
* mechanics, native controls and occupation authority must remain unchanged. */
export function semutaHomeworldGuildPosition(game: Game) {
return {
turn: game.turn, phase: game.phase, active: game.active, order: game.order,
movementRemaining: game.movementRemaining, pendingShipment: game.pendingShipment,
homeworlds: game.homeworlds, history: game.homeworldOccupationHistory,
players: game.players.map(player => ({ id: player.id, faction: player.faction,
ally: player.ally, spice: player.spice, reserves: player.reserves, tanks: player.tanks,
forces: player.forces, elites: player.elites, shipped: player.shipped, moved: player.moved,
})),
};
}
/** Original fresh Homeworld entry, setup/Storm/auctions/Movement. Only conserved
* later physical positions and still-unplayed Spice order isolate the source.
* Occupied Junction is ALWAYS established by the real native invasion action. */
export function createSemutaHomeworldGuildIncomeFixture(
options: SemutaHomeworldGuildOptions = {},
): SemutaHomeworldGuildFixture {
const advanced = options.advanced ?? false, occupiedSource = options.occupied ?? false;
assert.ok(!occupiedSource || advanced, 'This fixture never manufactures a Basic occupation epoch');
let game = createGame('SEMUTAHOMEWORLDGUILD', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('g', 'Guild', 'guild'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeSemutaGameForAudit(game);
const stock = semutaGuildIncomeInventory(game);
const actions: RicheseSkillsTechStep[] = [], occupationActions: RicheseSkillsTechStep[] = [];
const staging: string[] = [];
const act = (step: RicheseSkillsTechStep, invasion = false) => {
actions.push(step);
if (invasion) occupationActions.push(step);
game = applyAction(game, step.actor, step.action);
};
const clean = () => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
const reach = (until: () => boolean) => {
for (let n = 0; !until() && n < 2400; n++) {
assert.notEqual(game.status, 'finished', 'Original native phases must reach the paid source');
const decision = game.decision;
const step = decision?.kind === 'homeworldShipmentGuild'
? { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } as Action }
: nextSemutaGuildIncomeNativeStep(game);
act(step, decision?.kind === 'homeworldShipmentGuild');
}
assert.ok(until(), 'Original native phase driver must reach the requested boundary');
};
const nonWormBlows = (count: number) => {
for (let position = 0; position < count; position++) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
};
reach(() => game.status === 'playing');
const setup = structuredClone(game);
nonWormBlows(4);
reach(() => game.phase === 5 && clean());
// Later physical Arrakis counters return to their original native pools.
// Emperor's elite subset follows its counters; Salusa custody is untouched.
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0);
player.elites.forces = {};
}
}
const guild = semutaEmperorPlayer(game, 'g'), retained = occupiedSource ? 0 : 4;
guild.forces['polar_sink:0'] = guild.reserves - retained;
guild.reserves = retained;
staging.push(`Conserved later Arrakis forces into their native reserves, then moved Guild's original counters to Polar Sink leaving ${retained} at Junction; no wallet, visitor or occupation history is assigned.`);
let beforeInvasion: Game | null = null, occupied: Game | null = null;
if (occupiedSource) {
reach(() => game.phase === 5 && game.active === 'r' && clean());
beforeInvasion = structuredClone(game);
const invasion = homeworldShipmentChoice(viewGame(game, 'r'), 'homeworld:guild',
{ 'homeworld:richese': { normal: 1, elite: 0 } });
assert.ok(invasion.action, invasion.blocked ?? undefined);
act({ actor: 'r', action: invasion.action }, true);
reach(clean);
assert.equal(game.homeworlds!.custody!.visitors['homeworld:guild']?.r?.normal, 1);
assert.ok(game.homeworldOccupationHistory!.qualifications.some(row =>
row.world === 'homeworld:guild' && row.player === 'r' && row.cause === 'sole'));
occupied = structuredClone(game);
reach(() => game.turn === 2 && game.phase === 5 && game.active === 'a' && clean());
} else reach(() => game.phase === 5 && game.active === 'a' && clean());
const beforeStaging = structuredClone(game), position = semutaHomeworldGuildPosition(game);
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const karamaId = moveSemutaEmperorCard(game, semutaEmperorCardId(game,
card => card.kind === 'special' && card.effect === 'karama'), 'e');
const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
const karama = semutaEmperorPlayer(game, 'e').hand.find(card => card.id === karamaId)!;
if (options.fullHand) {
const holder = semutaEmperorPlayer(game, 'r');
while (holder.hand.length < handLimit(holder)) {
const id = semutaEmperorCardId(game, card => card.id !== semuta && card.id !== karamaId &&
card.effect !== 'karama' && !holder.hand.some(held => held.id === card.id));
moveSemutaEmperorCard(game, id, holder.id);
}
assert.equal(holder.hand.length, 4);
}
staging.push(`Conserved original ${semuta} in Richese's ${options.fullHand ? 'four-card' : 'one-card'} hand and distinct printed ${karamaId} in Emperor's hand after native auctions.`);
assert.deepEqual(semutaHomeworldGuildPosition(game), position);
assert.deepEqual(semutaGuildIncomeInventory(game), stock);
assert.ok(semutaHomeworldGuildCounters(game).every(row => row.total === 20));
const own = viewGame(game, 'a'), payer = own.players.find(player => player.id === 'a')!;
const destination = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm) &&
!game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${t.id}:`))) &&
botEntryAllowed(own, payer, t.id, t.sectors[0], 'ship'));
assert.ok(destination, 'An original legal empty stronghold must remain for an odd five-spice shipment');
const amount = 5, cost = reserveShipmentCost({ faction: payer.faction, halfRate: false }, destination.type, amount);
assert.equal(cost, 5);
const homeworldSources = nativeShipmentSources(own, amount, 0);
assert.deepEqual(homeworldSources, { 'homeworld:atreides': { normal: amount, elite: 0 } });
assert.ok(payer.reserves >= amount && (payer.spice ?? 0) >= cost);
const nativeIncome = quoteHomeworldPaymentIncome(game, 'g', 'shipment', cost).income;
assert.equal(nativeIncome, 3);
const ship: Action = { type: 'ship', territory: destination.id, sector: destination.sectors[0],
amount, elite: 0, allyPayment: 0, homeworldSources };
const producerActions: RicheseSkillsTechStep[] = [{ actor: 'a', action: ship }];
let income = applyAction(game, 'a', ship);
if (advanced) {
assert.equal(income.decision?.kind, 'guildShipment');
const allow: RicheseSkillsTechStep = { actor: 'g', action: { type: 'decision', allow: true } };
producerActions.push(allow);
income = applyAction(income, allow.actor, allow.action);
}
assert.equal(income.response?.kind, 'guildIncome');
assert.equal(income.response.owner, 'g');
assert.equal(income.response.amount, cost);
return { game, income, setup, beforeStaging, beforeInvasion, occupied, actions, occupationActions,
producerActions, staging, owner: 'g', payer: 'a', holder: 'r', canceller: 'e', karama, semuta,
ship, cancel: { type: 'card', mode: 'cancel', card: karamaId }, amount, cost,
key: `${destination.id}:${destination.sectors[0]}`, nativeIncome,
occupiedReceiver: occupiedSource ? 'r' : null,
allocation: occupiedSource ? { actor: 'r', ownAmount: 2 } : null };
}
@@ -0,0 +1,250 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { semutaEmperorIncomeCards } from './fixture-semuta-emperor-income';
import {
createSemutaHomeworldEmperorIncomeFixture, nextSemutaHomeworldEmperorIncomeStep,
reactSemutaHomeworldEmperorIncome, semutaHomeworldEmperorForceTotals,
type SemutaHomeworldEmperorIncomeFixture, type SemutaHomeworldEmperorIncomeKind,
} from './fixture-semuta-homeworld-emperor-income';
const configurations: { kind: SemutaHomeworldEmperorIncomeKind; advanced: boolean; occupied: boolean }[] = [
{ kind: 'emperorIncome', advanced: false, occupied: false },
{ kind: 'emperorIncome', advanced: true, occupied: false },
{ kind: 'emperorIncome', advanced: true, occupied: true },
{ kind: 'richesePurchaseIncome', advanced: true, occupied: false },
{ kind: 'richesePurchaseIncome', advanced: true, occupied: true },
];
/** Consume the original suffix, not a forwarded or reconstructed source. */
function exerciseOriginalIncomeSuffix(game: Game, fixture: SemutaHomeworldEmperorIncomeFixture): Game {
const wallets = game.players.map(player => ({ id: player.id, spice: player.spice }));
if (fixture.kind === 'emperorIncome') {
assert.equal(game.auction!.index, fixture.game.auction!.index + 1);
assert.deepEqual(game.auction!.cards.map(card => card.id), fixture.game.auction!.cards.map(card => card.id));
assert.equal(game.response?.kind, 'atreidesAuction');
assert.equal(game.response!.owner, 'a');
for (let n = 0; game.response?.kind === 'atreidesAuction' && n < game.players.length; n++) {
const next = nextSemutaHomeworldEmperorIncomeStep(game);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.auction!.index, fixture.game.auction!.index + 1);
const bidder = game.auction!.active;
game = applyAction(game, bidder, { type: 'bid', amount: 1 });
assert.equal(game.auction!.bidder, bidder);
assert.equal(game.auction!.index, fixture.game.auction!.index + 1);
} else {
assert.deepEqual(game.decision, fixture.beforePayment.decision);
assert.equal(game.decision!.player, fixture.buyer);
assert.equal(game.richeseCache!.length, fixture.beforePayment.richeseCache!.length - 1);
assert.equal(semutaEmperorPlayer(game, fixture.buyer).specialKaramaUsed, true);
assert.equal(game.discard.filter(card => card.id === fixture.special).length, 1);
const next = nextSemutaHomeworldEmperorIncomeStep(game);
assert.equal(next.actor, fixture.buyer);
assert.throws(() => applyAction(game, fixture.owner, next.action));
game = applyAction(game, next.actor, next.action);
// An uncanceled-income baseline still holds its Karama and can answer the
// original Richese declaration response. Follow that real queue first.
for (let n = 0; game.response?.kind === 'richeseAuction' && n < game.players.length; n++) {
const response = nextSemutaHomeworldEmperorIncomeStep(game);
game = applyAction(game, response.actor, response.action);
}
assert.equal(game.decision?.kind, 'richeseCache');
assert.equal(game.decision!.player, fixture.buyer);
assert.throws(() => applyAction(game, next.actor, next.action), 'The source declaration cannot replay');
}
assert.deepEqual(game.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
return game;
}
for (const config of configurations) {
for (const claim of [false, true]) void test(
`Homeworld ${config.advanced ? 'Advanced' : 'Basic'} ${config.occupied ? 'occupied' : 'clean low'} ${config.kind}: Semuta ${claim ? 'claims' : 'passes'} before native denial and original allocation`, () => {
const f = createSemutaHomeworldEmperorIncomeFixture(config.kind, config);
assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice,
semutaEmperorPlayer(f.beforePayment, f.buyer).spice - f.amount);
assert.equal(semutaEmperorPlayer(f.game, f.owner).spice,
semutaEmperorPlayer(f.beforePayment, f.owner).spice);
assert.equal(semutaEmperorPlayer(f.game, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(semutaEmperorPlayer(f.game, f.holder).hand.length, 4);
if (config.occupied) {
assert.ok(f.occupied);
const originalAuthority = quoteStableHomeworldOccupation(f.occupied!, 'kaitain').entitlement!;
assert.equal(originalAuthority.occupier, f.allocationOwner);
assert.equal(originalAuthority.ally, f.allocationAlly);
assert.equal(quoteStableHomeworldOccupation(f.game, 'kaitain').entitlement!.qualification,
originalAuthority.qualification, 'The original invasion epoch still owns this later source');
assert.equal(semutaEmperorPlayer(f.game, f.owner).reserves,
semutaEmperorPlayer(f.game, f.owner).elites!.reserves,
'Salusa elites are included in reserves; Kaitain has no invented extra elite stock');
}
const wallets = f.game.players.map(player => ({ id: player.id, spice: player.spice }));
const forces = semutaHomeworldEmperorForceTotals(f.game);
const receiptCount = f.game.homeworldOccupiedPercentageLedger!.receipts.length;
const pending = applyAction(f.game, f.canceler, f.cancel);
const cost = pending.pendingTreacheryDiscard;
assert.ok(cost, 'The real printed income-cancellation cost must pause for Semuta');
assert.equal(cost.batch.cause, `karama:${f.kind}`);
assert.deepEqual(cost.batch.entries, [{ card: f.karamaCard, discardedBy: f.canceler, publicFace: true }]);
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
assert.deepEqual(viewGame(pending, f.holder).semutaReaction!.candidates, []);
assert.equal(pending.homeworldOccupiedPercentageLedger!.receipts.length, receiptCount);
assert.deepEqual(pending.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.throws(() => applyAction(pending, f.buyer, { type: 'bid', amount: 1 }));
assert.throws(() => applyAction(pending, f.owner,
{ type: 'decision', event: cost.batch.event, ownAmount: f.occupiedAmount }));
if (f.kind === 'emperorIncome') {
assert.equal(pending.auction!.index, f.game.auction!.index);
assert.equal(pending.currentAuctionSale!.winner, f.buyer);
} else {
assert.notEqual(f.special, f.karama);
assert.equal(pending.richeseCache!.some(card => card.id === f.acquired), false);
assert.equal(pending.discard.filter(card => card.id === f.special).length, 1);
assert.equal(cost.batch.entries.some(entry => entry.card.id === f.special), false);
assert.equal(semutaEmperorPlayer(pending, f.buyer).specialKaramaUsed, true);
}
let done = reactSemutaHomeworldEmperorIncome(pending, f.holder, claim);
assert.equal(done.resolvedTreacheryDiscardSequence, cost.sequence);
assert.deepEqual(done.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
assert.equal(semutaEmperorPlayer(done, f.owner).spice,
semutaEmperorPlayer(f.beforePayment, f.owner).spice, 'Native canceled credit is zero');
assert.equal(semutaEmperorPlayer(done, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4);
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));
assert.throws(() => applyAction(done, f.canceler, f.cancel));
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
const receipt = done.homeworldOccupiedPercentageLedger!.receipts[receiptCount];
assert.ok(receipt);
assert.equal(receipt.nativeRetained, 0);
assert.equal(receipt.source.kind, 'emperor-treachery');
if (receipt.source.kind !== 'emperor-treachery') throw new Error('Wrong native source');
assert.equal(receipt.source.nativeIncome, 'karama-canceled');
assert.equal(receipt.source.amount, f.amount);
assert.equal(receipt.source.payment.paid, f.amount);
assert.deepEqual(receipt.source.payment.contributions, [{ payer: f.buyer, amount: f.amount }]);
assert.equal(receipt.source.payment.recipient, f.owner);
assert.equal(receipt.occupiedAmount, f.occupiedAmount);
assert.equal(receipt.bankRetained, f.amount - f.occupiedAmount);
if (config.occupied) {
const allocation = viewGame(done, f.allocationOwner!).homeworldOccupiedPercentage!;
assert.equal(done.decision!.kind, 'homeworldOccupiedPercentage');
assert.equal(done.decision!.player, f.allocationOwner);
assert.equal(allocation.owner, 'g');
assert.equal(allocation.ally, 'a');
assert.equal(allocation.amount, f.occupiedAmount);
assert.equal(allocation.minOwnAmount, 0);
assert.equal(allocation.maxOwnAmount, f.occupiedAmount);
assert.equal(allocation.blocked, null);
assert.deepEqual(receipt.entitlement, quoteStableHomeworldOccupation(f.game, 'kaitain').entitlement);
assert.equal(done.pendingHomeworldOccupiedPercentage!.suffix.kind,
f.kind === 'emperorIncome' ? 'auctionBonus' : 'richesePurchase');
const action = { type: 'decision', event: allocation.event, ownAmount: claim ? 0 : f.occupiedAmount };
assert.throws(() => applyAction(done, f.allocationAlly!, action), 'Only the original occupier chooses');
assert.throws(() => applyAction(done, f.owner, action));
assert.throws(() => applyAction(done, f.allocationOwner!, { ...action, event: 'stale-allocation' }));
assert.throws(() => applyAction(done, f.allocationOwner!, { ...action, ownAmount: f.occupiedAmount + 1 }));
const receiver = claim ? f.allocationAlly! : f.allocationOwner!;
done = applyAction(done, f.allocationOwner!, action);
for (const player of done.players) assert.equal(player.spice,
wallets.find(row => row.id === player.id)!.spice + (player.id === receiver ? f.occupiedAmount : 0));
const settled = done.homeworldOccupiedPercentageLedger!.receipts[receiptCount];
assert.equal(settled.status, 'settled');
assert.deepEqual(settled.source, receipt.source);
assert.deepEqual(settled.entitlement, receipt.entitlement);
assert.deepEqual(settled.credits, [{ player: receiver, amount: f.occupiedAmount }]);
assert.throws(() => applyAction(done, f.allocationOwner!, action), 'The printed share is available once');
} else {
assert.equal(receipt.status, 'settled');
assert.equal(receipt.entitlement, null);
assert.deepEqual(receipt.credits, []);
}
done = exerciseOriginalIncomeSuffix(done, f);
assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
assert.deepEqual(semutaHomeworldEmperorForceTotals(done), forces);
},
);
}
for (const config of configurations) void test(
`uncanceled ${config.advanced ? 'Advanced' : 'Basic'} ${config.occupied ? 'occupied' : 'clean low'} ${config.kind} retains original ceil native income`, () => {
const f = createSemutaHomeworldEmperorIncomeFixture(config.kind, config);
const native = semutaEmperorPlayer(f.game, f.owner).spice;
const buyer = semutaEmperorPlayer(f.game, f.buyer).spice;
let done = f.game;
for (let n = 0; done.response?.kind === f.kind && n < done.players.length; n++) {
const next = nextSemutaHomeworldEmperorIncomeStep(done);
done = applyAction(done, next.actor, next.action);
}
assert.notEqual(done.response?.kind, f.kind);
assert.equal(semutaEmperorPlayer(done, f.owner).spice, native + f.nativeIncome);
assert.equal(semutaEmperorPlayer(done, f.buyer).spice, buyer);
if (config.occupied) {
const allocation = viewGame(done, f.allocationOwner!).homeworldOccupiedPercentage!;
assert.equal(allocation.amount, f.occupiedAmount);
done = applyAction(done, allocation.owner, { type: 'decision', event: allocation.event, ownAmount: f.occupiedAmount });
}
exerciseOriginalIncomeSuffix(done, f);
},
);
for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as const) void test(
`a legal full-hand Easy bot completes occupied ${kind} cost before original owner allocation`, () => {
const f = createSemutaHomeworldEmperorIncomeFixture(kind, { occupied: true });
const pending = applyAction(f.game, f.canceler, f.cancel);
const view = viewGame(pending, f.holder);
view.players.find(player => player.id === f.holder)!.bot = 'Easy';
const action = botActions(view)[0];
assert.ok(action);
assert.equal(action.type, 'semutaCommit');
let done = applyAction(pending, f.holder, action);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), true);
assert.equal(done.decision!.player, f.allocationOwner);
const ownerView = viewGame(done, f.allocationOwner!);
const ownerBalance = semutaEmperorPlayer(done, f.allocationOwner!).spice;
const allyBalance = semutaEmperorPlayer(done, f.allocationAlly!).spice;
ownerView.players.find(player => player.id === f.allocationOwner)!.bot = 'Easy';
const allocationAction = botActions(ownerView)[0];
assert.ok(allocationAction);
done = applyAction(done, f.allocationOwner!, allocationAction);
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.game, f.owner).spice);
const ownerCredit = semutaEmperorPlayer(done, f.allocationOwner!).spice - ownerBalance;
const allyCredit = semutaEmperorPlayer(done, f.allocationAlly!).spice - allyBalance;
assert.ok(ownerCredit >= 0 && allyCredit >= 0);
assert.equal(ownerCredit + allyCredit, f.occupiedAmount,
'A legal bot allocates the original printed share without pinning its strategy');
assert.equal(done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.status, 'settled');
exerciseOriginalIncomeSuffix(done, f);
},
);
for (const claim of [false, true]) void test(
`occupied Kaitain newly purchased original Karama is its own foreign cost, Semuta ${claim ? 'claims' : 'passes'}`, () => {
const f = createSemutaHomeworldEmperorIncomeFixture('emperorIncome', { occupied: true, acquiredKarama: true });
assert.equal(f.acquired, f.karama);
const pending = applyAction(f.game, f.canceler, f.cancel);
assert.ok(pending.pendingTreacheryDiscard);
assert.equal(pending.pendingTreacheryDiscard.batch.entries[0].card.id, f.acquired);
assert.equal(pending.pendingTreacheryDiscard.batch.entries[0].discardedBy, f.buyer);
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.some(card => card.id === f.acquired), false);
let done = reactSemutaHomeworldEmperorIncome(pending, f.holder, claim);
assert.equal(semutaEmperorPlayer(done, f.buyer).spice,
semutaEmperorPlayer(f.beforePayment, f.buyer).spice - 5, 'Buying then spending never refunds the source');
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.acquired), claim);
assert.equal(done.discard.filter(card => card.id === f.acquired).length, Number(!claim));
const allocation = viewGame(done, f.allocationOwner!).homeworldOccupiedPercentage!;
assert.equal(allocation.amount, 2);
done = applyAction(done, allocation.owner, { type: 'decision', event: allocation.event, ownAmount: 2 });
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice);
exerciseOriginalIncomeSuffix(done, f);
assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
},
);
+194
View File
@@ -0,0 +1,194 @@
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);
});