Preserve native Homeworld paid shares through Semuta income costs
This commit is contained in:
1 parent
a4f1cfc650
commit
bbf27c4f3d
11 files changed
+1005
-6
No files matched your search
@@ -1166,11 +1166,14 @@ export function GameTable({
|
||||
<>
|
||||
Semuta Drug with Homeworlds development preview · original Richese,
|
||||
CHOAM and classic occupation setup, with no other optional modules.
|
||||
Only paid high-Kaitain closing-Bidding discards and occupied Tupile
|
||||
lost-slot cleanup offer Semuta here. Private discarded faces remain
|
||||
Paid high-Kaitain closing discards, occupied Tupile lost-slot cleanup
|
||||
and printed Karama canceling native Guild, Emperor auction or Richese
|
||||
purchase income offer Semuta here. Private discarded faces remain
|
||||
hidden until commitment; a simultaneous group requires one selection.
|
||||
Claim or Continue keeps the paid price or lost slot and resumes the
|
||||
same closing owner, next old-holder cleanup or phase opening once.
|
||||
same original source once. Canceling native income does not cancel
|
||||
the printed occupied share: its original receiver still allocates it
|
||||
before Movement, the next auction lot or saved purchase controls.
|
||||
Other Homeworld producers and Basic unresolved Tupile loss remain
|
||||
guarded. Not complete or certified Homeworld rules.{' '}
|
||||
</>
|
||||
|
||||
@@ -29,6 +29,23 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Semuta / Homeworld income costs and retained printed shares — 11 October.**
|
||||
The existing `karamaIncomeDiscard` now also covers the same source-qualified
|
||||
Guild, Emperor auction and Richese special-purchase income in the original
|
||||
Homeworld Semuta envelope. Only its HW income signature adds original
|
||||
custody, occupation history and percentage ledger; plain cost shapes stay
|
||||
unchanged. Seven before programs had no cost offer; fourteen actual
|
||||
post-cutover claim/pass programs retain full payment and canceled native
|
||||
credit0. Low native ceilings3 from5 or2 from3 remain original; occupied
|
||||
floors2 or1 still go through their original controller/ally allocation once.
|
||||
Styled390px humans claim at4/4, settle Junction2 to Richese, settle Kaitain2
|
||||
and1 to real Atreides ally, and resume original Movement/auction/cache
|
||||
controls. The just-purchased Karama can be the actual canceled-auction cost;
|
||||
the separately bought Residual Poison and Richese's paid activation remain.
|
||||
No broader module admission, payment policy or public gate changes.
|
||||
Twenty-six new consumer cases and the frozen six-file union65/65 pass with
|
||||
types/lint clean. No broad-suite or deployed acceptance follows.
|
||||
|
||||
**Semuta / native Homeworld discard composition — 11 October.**
|
||||
The separate fresh `--profile semuta --homeworlds` route reuses original
|
||||
Richese/CHOAM/classic occupation setup, exact CHOAM, Basic/Advanced2–6 and
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 11 October 2026 — Homeworld income cost before original printed share
|
||||
|
||||
Seven source-qualified before programs lacked Semuta for Basic/Advanced low
|
||||
Guild income, Advanced occupied Junction, and clean/occupied Emperor auction
|
||||
and Richese special purchase. Original native cancellation already retained
|
||||
the printed occupied portion; this checkpoint changes its discard timing,
|
||||
not its economics or beneficiary.
|
||||
|
||||
Existing `karamaIncomeDiscard` is now admitted in the original separate
|
||||
Homeworld Semuta roster. Only that HW income signature adds original
|
||||
custody, occupation history and percentage ledger. Plain income fields omit
|
||||
the addition; no new response/source identity, callback snapshot, payment
|
||||
calculation, module admission or low/occupied ruling is introduced.
|
||||
|
||||
Fourteen actual source replay/claim/pass programs retain the price and
|
||||
physical transaction. Single-payer shipment5 leaves five arrivals, native
|
||||
Guild canceled credit0 and original occupied2; original Movement follows
|
||||
allocation. Auction5 and special purchase3 leave native Emperor0 while their
|
||||
original occupied controller can allocate2/1 to its real ally. The next
|
||||
original Atreides inspection or saved Richese declaration follows once.
|
||||
All tested20-counter pools include visitors and original Emperor subsets;
|
||||
no force or payment is recreated by the reaction.
|
||||
|
||||
Child feedback exposed two fixture assumptions, not engine failures:
|
||||
Basic has no separate Salusa, so its included elite subset also counts on
|
||||
Kaitain. Conserved Basic low staging now moves that subset with its typed
|
||||
counter record to Polar Sink, leaving four actual natives; Advanced retains
|
||||
the original Salusa subset. An uncanceled-income baseline still holds its
|
||||
Karama and can answer the original Richese declaration response; the helper
|
||||
now follows that real queue before cache selection. The obsolete null and
|
||||
fixture-wiring assertions were removed, not re-pinned.
|
||||
|
||||
Styled390px actual GameTable humans Cancel→Commit at4/4 before any printed
|
||||
allocation. Junction2 is settled to original Richese; Guild then resumes the
|
||||
same paid Atreides five-force Habbanya Sietch Movement. Original occupied
|
||||
Kaitain gives its real Guild/Atreides alliance2 or1, even after native income
|
||||
is canceled. The paid-auction Karama itself is acquired; Richese's distinct
|
||||
Residual Poison acquisition/activation/debit remain and its real declaration
|
||||
opens the next original cache choice after allocation. Screenshots show
|
||||
original amounts/controllers, no cancel option for the printed share and
|
||||
390px root widths. Four initial optimizer fetches aborted; exercised controls
|
||||
subsequently rendered and completed. No error-free/full-phone/deployed claim.
|
||||
|
||||
CLI/table/reference scopes now describe the admitted source types. Temporary
|
||||
scripts, server and tab were removed; no existing game/database was touched.
|
||||
Other Homeworld producers, overlapping/ambassador parents, module mixtures,
|
||||
public starts and comprehensive assurance remain separate.
|
||||
Twenty-six new consumer cases and the frozen six-file union pass65/65;
|
||||
types/lint pass. The bot's raw event-forwarding assertion was deleted; its
|
||||
actual native allocation now proves nonnegative owner/ally credits totaling
|
||||
the original printed amount without pinning a strategy or response shape.
|
||||
|
||||
## 11 October 2026 — native Homeworld Semuta cost composition
|
||||
|
||||
Separate `semuta --homeworlds` private entry reuses original Homeworld
|
||||
|
||||
@@ -901,3 +901,26 @@ cases cover private choice, full hands, old-holder ordering, readiness and
|
||||
legal bots. Styled390px humans pay4, select Poison Tooth, retain Emperor6
|
||||
spice and finish both closing owners; separate Guild/Atreides costs restore
|
||||
normal4 limits and original Movement. No public gate or full-mode acceptance.
|
||||
|
||||
**11 October 2026 — original Homeworld income costs and printed share.**
|
||||
The separate Homeworld entry now also offers the existing printed Karama
|
||||
cost for original Guild income, non-Ambassador Emperor auction income and
|
||||
Advanced Richese purchase income. Original paid transaction, native low
|
||||
rounding and occupation source are signed through the pause. No additional
|
||||
module or economic authority is introduced.
|
||||
|
||||
Claim/pass completes the native denial once, with credit0 rather than the
|
||||
allowed ceiling3 from5 or2 from3. The printed occupied floor2/1 remains
|
||||
uncancelable and follows its original controller/reciprocal ally allocation
|
||||
before original Movement, next auction or saved purchase controls resume.
|
||||
This reuses the existing [occupied-income source contract](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026),
|
||||
not a newly located FAQ.
|
||||
|
||||
Seven before programs lacked the offer; fourteen source-action replay and
|
||||
claim/pass programs now retain original payment, acquisition/forces and
|
||||
printed allocation. Styled390px humans prove all three source families:
|
||||
Junction2 to Richese, Kaitain2/1 to the real Atreides ally, and the next native
|
||||
Movement or Richese cache controls. A just-purchased Karama keeps its paid
|
||||
identity through the exchange. Basic low staging moves its included elite
|
||||
subset from Kaitain; Advanced retains its original Salusa subset. Other
|
||||
Homeworld producers, unresolved Basic epochs and module mixtures stay separate.
|
||||
+6
-1
@@ -5234,6 +5234,10 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
auction: g.auction, sale: g.currentAuctionSale,
|
||||
richeseBidding: g.richeseBidding, richeseAuction: g.richeseAuction,
|
||||
active: g.active, ready: g.ready, movementRemaining: g.movementRemaining,
|
||||
homeworld: g.homeworlds ? {
|
||||
custody: g.homeworlds, history: g.homeworldOccupationHistory,
|
||||
percentages: g.homeworldOccupiedPercentageLedger,
|
||||
} : undefined,
|
||||
} : undefined,
|
||||
order: g.order,
|
||||
players: g.players.map(p => ({
|
||||
@@ -6941,7 +6945,8 @@ function semutaOfferSupported(
|
||||
continuation.promise.kind === 'shipment' ||
|
||||
continuation.promise.kind === 'battle'))) return false;
|
||||
if (g.homeworlds && continuation.kind !== 'kaitainDiscard' &&
|
||||
continuation.kind !== 'tupileCleanupDiscard') return false;
|
||||
continuation.kind !== 'tupileCleanupDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard') return false;
|
||||
if (continuation.kind === 'battleResolved' &&
|
||||
!g.lastBattleContext?.mandatoryDiscard) return false;
|
||||
if ((continuation.kind === 'battleCleanup' ||
|
||||
|
||||
+3
-1
@@ -1186,7 +1186,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A distinct printed Karama canceling those five ordinary native CHOAM printed or Cunning powers also offers its public cost before the original denial completes. CHOAM retains the declared fuel; a Cunning Nexus remains spent. Claim or Continue restores the same saved response and one original movement, free-revival, storm or closing-Mentat suffix. Acquiring Karama cannot return the canceled force, grant range, stop the original request, protect the storm or restore Nexus. The retained CHOAM fuel is not a discard candidate; Kull and unsupported arrival/flight parents remain separate.',
|
||||
'A printed Karama canceling the Emperor’s shared gift or extra allied revival also offers that exact public cost before the denial completes. The canceled share moves no spice, forces or revival; the Emperor’s ally and extra-revival ledger are unchanged. Claim or Continue restores the saved response and finishes the cancellation once. Only the actual Semuta holder sees the private claimant choice; other seats see the neutral offer.',
|
||||
'A printed Karama canceling original paid Guild shipment income, non-Ambassador Emperor auction income or Richese special-purchase income also offers its exact public cost before the native denial. The price and physical transaction have already committed: claim or Continue cannot refund spice, re-ship forces, undo cache acquisition or replay a purchase. Original Movement, next-lot inspection or the Richese purchase’s suspended controls resume once. A winner may spend the very Karama it just bought; claiming that identity does not reverse its payment. Homeworld and Ambassador payment compositions remain separate.',
|
||||
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Only paid high-Kaitain closing-Bidding disposal and occupied Tupile lost-slot cleanup offer Semuta in this envelope. Kaitain pays two spice per discarded card; multiple private cards become one fresh group, inspected only after commitment. Taking one cannot play it, refund the cost or finish either original closing owner. After a proved Tupile loss, each old holder discards its own exact normal-limit excess in a separate private event; claim or Continue completes that original cleanup before the next holder or saved phase opening. The lost slot is not restored. Basic unresolved Tupile epochs, other Homeworld producers, module mixtures and public starts stay guarded.',
|
||||
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid high-Kaitain disposal, occupied Tupile lost-slot cleanup and printed Karama canceling original Guild/Emperor auction/Richese-purchase income offer Semuta in this envelope. Kaitain pays two spice per card; its private group requires commitment before one selection. Each lost Tupile holder has a separate private cost before the next cleanup or saved opening. Native income cancellation retains the full original price and acquisition/forces but does not cancel the printed occupied portion; its original receiver still allocates it once before Movement, the next lot or saved purchase controls. Low-native rounding, paid invoice and occupation source remain original. Basic unresolved epochs, other Homeworld producers, module mixtures and public starts stay guarded.',
|
||||
'An ordinary Ghola played during battle also offers its consumed card after revival. Claim or Continue preserves the revived leader or forces and the opposing sealed plan, then normal planning resumes. It cannot revive again by resuming the receipt.',
|
||||
'A completed paid Nullentropy Box search during battle offers only the used Box. Claim or Continue preserves the two-spice payment, private recovered card, already-shuffled pile and sealed opposing plan. The recovered card may be used in normal legal planning; the search is not repeated.',
|
||||
'A clean Distrans transfer during battle also offers only the used Distrans. The private card has already reached its recipient; claim or Continue cannot repeat the gift or change a sealed plan. A recipient holding Semuta may claim the used Distrans and keep the already-transferred card.',
|
||||
@@ -1259,6 +1259,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-emperor-income.test.ts',
|
||||
'tests/semuta-kaitain.test.ts',
|
||||
'tests/semuta-tupile.test.ts',
|
||||
'tests/semuta-homeworld-guild-income.test.ts',
|
||||
'tests/semuta-homeworld-emperor-income.test.ts',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -30,7 +30,7 @@ async function main() {
|
||||
'\nSemuta with CHOAM and optional Ix preserves original unused Tech Tokens in fresh 3–6-seat Basic/Advanced Richese lobbies under existing native prerequisites. Advanced additionally retains original unused Stronghold Cards at2–6; both modules require3–6. Native setup, first-Storm Tech and final-Mentat Stronghold assignment remain original. Mandatory/winner discards react before the original winner takes one loser-owned token, before Face Dance. Closing Gamont reacts before the original CHOAM finish and changed-board claim. Optional original skills require --leader-skills as described below; other modules, the separate CHOAM/Ecaz Moritani entry, retrofit and public starts remain excluded.',
|
||||
'\n--profile semuta --leader-skills explicitly composes original all14 skills with fresh native Basic Richese/CHOAM and classics using exact CHOAM, or Advanced native E1/E2 with Richese and selected required CHOAM/optional Ix decks, at2–6 seats. Optional original Tech requires3+; Strongholds requires Advanced. Original private offers/training, Auditor exclusion, normal Mentat question, typed Suk rescue and Rihani inspection/exchange remain. Traitor/Face Dancer movement is not a Treachery discard. Basic Ix mixtures without their original Nexus envelope, E3/Moritani, Homeworld/Discovery/Banker overlays, public starts and played-game conversion are not opened.',
|
||||
'\n--profile semuta --nexus-cards retains original all12 Nexus in fresh native Richese CHOAM/optional Ix lobbies, optionally --leader-skills, original Tech3+ and Advanced Strongholds2+. Single/paired CHOAM or both selected E1/E2 native families retain original prerequisites. No starting Nexus hand is added: a qualifying actual end-Spice draw follows a Nexus and settled alliance. CHOAM Secret Ally spends a Worthless for two bank spice only with CHOAM absent; CHOAM Betrayal spends its Nexus to discard one server-sampled native CHOAM hand card without compensation. Semuta claim/pass retires the original receipt once, after the committed fresh discard. No Homeworld, Discovery, E3, retrofit or public gate opens.',
|
||||
'\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native Homeworld occupation setup and Tupile source history remain original. This bounded composition offers only paid high-Kaitain closing-Bidding discards and completed occupied-Tupile lost-slot cleanup; other Semuta/Homeworld producers remain guarded. No Nexus, Skills, Tech, Strongholds, Discovery, Ix/E3 mixing, retrofit or public gate opens.',
|
||||
'\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native occupation setup and source history remain original. Paid high-Kaitain disposal, occupied-Tupile lost-slot cleanup and printed Karama canceling original Guild/Emperor auction/Richese-purchase income offer Semuta before their original suffix. Canceling native income does not cancel the printed occupied portion: its original receiver still allocates it once before Movement, the next lot or saved purchase controls. Other Homeworld producers, Nexus, Skills, Tech, Strongholds, Discovery, Ix/E3 mixing, retrofit and public starts remain guarded.',
|
||||
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. Ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' +
|
||||
'\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
|
||||
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +
|
||||
|
||||
Reference in new issue
Block a user