Compose original Homeworld shared-benefit cancellation costs with Semuta
This commit is contained in:
1 parent
8c5b6c8b55
commit
afe3008cae
12 files changed
+851
-1
No files matched your search
@@ -1187,6 +1187,9 @@ export function GameTable({
|
||||
Printed Karama canceling CHOAM opening income or Inflation and
|
||||
normal-lot purchase/payment costs keep the original denial,
|
||||
spent placement attempt or one zero-spice acquisition.
|
||||
Native Advanced BG extra-charity and allied Emperor gift/extra-
|
||||
revival cancellation costs also retain the consumed claim and
|
||||
original ally, casualties and unawarded benefit.
|
||||
Private discarded faces stay hidden until commitment; a simultaneous
|
||||
group requires one selection. Claim or Continue resumes the same
|
||||
original source once without undoing payment or force commitment.
|
||||
|
||||
@@ -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 / original shared-benefit cancellations — 13 October.**
|
||||
Native Advanced BG extra-charity and genuinely allied Emperor gift/extra-revival
|
||||
printed Karama costs now offer their cards. Six positive before programs had
|
||||
no offer; a wealthy Basic BG claim rejected immutably. Twenty-one parent
|
||||
programs retain BG5/C12 and spent claim1, or real A3 storm casualties, turn2
|
||||
E/A alliance and unawarded benefit. Ordinary revival remains3 (free2/paid1),
|
||||
not a granted extra allowance. Three actual390px declarations/cancellations/
|
||||
4-of-4 exchanges keep those outcomes; Advanced A3 ordinary revival then
|
||||
returns toreserves10/Tanks0 atcost2. Artifact:
|
||||
`/tmp/dune-homeworld-native-shared-3223.json`.
|
||||
No profile/signature/price or public-gate change; no save/deployed/full-mode
|
||||
certification follows.
|
||||
Types/lint and175/175 across seven focused unit files pass (28 new cases).
|
||||
The initial Basic test incorrectly pinned Advanced Harg Pass/population;
|
||||
those setup-only assumptions were deleted, not replaced. Build passes with
|
||||
unchanged deprecation/chunk/route-classification warnings.
|
||||
|
||||
**Semuta / native economic Karama producers — 13 October.**
|
||||
Printed Karama canceling CHOAM opening income/Inflation, direct normal-lot
|
||||
purchase and free winning-bid payment now offer their costs. Eight genuine
|
||||
|
||||
@@ -58,6 +58,13 @@ free winning-bid payment offer their costs before one reserved card arrives
|
||||
at zero price; original ordinary/cache pools and later controls stay intact.
|
||||
No new low-Tupile income classification or Richese-lot scope is introduced.
|
||||
|
||||
Native Advanced BG extra-charity and genuinely allied Emperor gift/extra-
|
||||
revival cancellation costs also offer their printed card before denial.
|
||||
Consumed BG claim use stays spent. Original alliance, paid native deployment,
|
||||
real casualties and empty extra-revival ledger stay intact; ordinary legal
|
||||
revival is a separate later action. Wealthy Basic BG's ordinary eligibility
|
||||
and all unresolved source/publication boundaries remain unchanged.
|
||||
|
||||
Actual selected original cleanup cards are privately discarded first. The
|
||||
typed receipt retains only that cleanup's event, original held-ID commitment
|
||||
and suspended opening; its lease row remains pending during the neutral
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 October 2026 — original shared-benefit cancellation costs
|
||||
|
||||
Only existing `karamaBgCharityDiscard` and `karamaEmperorShareDiscard`
|
||||
are newly admitted in the Homeworld Semuta profile. Existing faction,
|
||||
phase, consumed claim, allied response and printed-card validators remain.
|
||||
No signing, pricing, roster or public-mode cutover.
|
||||
|
||||
Two actual Advanced BG before programs (thin/full) use five original
|
||||
R/C/E/A/B Homeworld participants, genuine prediction/Harg Pass setup and
|
||||
first ten-spice CHOAM income (C2→12). Wealthy BG5 claims its native extra2;
|
||||
claimTurn1 is consumed before the response. Emperor's original printed
|
||||
Karama discarded once/no offer and denied the same benefit, BG5/C12 intact.
|
||||
The actual Basic BG5 claim returns its original unavailable error unchanged,
|
||||
without a response, cost or fabricated poverty.
|
||||
|
||||
Four actual Emperor before programs use original R/C/E/A setup, an actual
|
||||
three-normal Atreides shipment to Red Chasm7 for6, native turn closure,
|
||||
printed Weather6 on the second Storm and real1→7 movement. The three deployed
|
||||
counters enter Tanks; original Arrakeen10 remains. An actual unplayed worm
|
||||
opens turn2 Nexus and E/A form a genuine mutual alliance. Native E10 can
|
||||
declare gift3 or extra revival2 in the proper phase. Printed A Karama
|
||||
disposed without an offer and canceled the original benefit: no transfer/
|
||||
return/extra allowance, Areserves7/Tanks3 and BasicA4/AdvancedA6 preserved.
|
||||
No old force-staging creator, clock, wallet, Tank or alliance assignment is
|
||||
used by these new native programs.
|
||||
|
||||
Twenty-one parent after/negative programs cover thin/full claim/pass for
|
||||
Advanced BG, both modes/two Emperor benefits, plus the Basic guard.
|
||||
BG5/C12 and claim1 stay consumed through original Bidding. Emperor gift
|
||||
leaves E10/A4 or6; revival denial retains the real three Tanks and empty
|
||||
extra ledger. Original ordinary revival then returns3/reserves10/Tanks0,
|
||||
free2 plus one paid force for2 (BasicA2/AdvancedA4), and original Movement
|
||||
becomes usable. Storm7, original alliance turn2, Arrakeen10 and physical
|
||||
card custody remain; no Weather, worm or benefit is replayed.
|
||||
|
||||
Three actual390px control flows start BEFORE the actual declaration, not
|
||||
just its cancellation. Advanced BG claims2 atwealth5, E cancels, R exchanges
|
||||
at4/4; BG5/C12/claim1 remain. Basic E declares gift3 through Share wealth,
|
||||
A cancels and R exchanges4/4; E10/A4 and actual casualty/ally state stay.
|
||||
Advanced E declares extra2, A cancels and R exchanges4/4; extra ledger stays
|
||||
empty, A6/reserves7/Tanks3. A's real ordinary Revive3 then passes the original
|
||||
CHOAM free-revival owner control and pays2: A4/reserves10/Tanks0, revived3/
|
||||
free2, Arrakeen10 unchanged. UI stops at original Revival; runtime covers
|
||||
the original later phase3/5 controls. Artifact outside Git:
|
||||
`/tmp/dune-homeworld-native-shared-3223.json`.
|
||||
The owned browser reported no errors; only its service/tab/root files were
|
||||
closed/removed. No saved room, deployed revision or completed mode is certified.
|
||||
|
||||
Parent removed fixture-only BG setup status/prediction pins and non-empty
|
||||
cancel-card assertions. The shared-cost assertion now accepts exact-card
|
||||
custody in discard or the claimant's hand; the alliance stamp compares with
|
||||
the real alliance snapshot, not the preceding storm snapshot. Ordinary return
|
||||
regressions check the real3/free2 outcome rather than a permissive upper bound.
|
||||
One worker expanded its runtime checks into960 fixture constructions,
|
||||
14 repeated harnesses and parser diagnostics. Parent stopped those sweeps
|
||||
and does not count them as integration proof; frozen owner checks govern.
|
||||
Frozen checks pass types/lint and175/175 across seven related unit files,
|
||||
including28 new cases. Initial Basic BG failed an Advanced Harg Pass setup
|
||||
pin; that location/reserve/population assumption was removed from both modes,
|
||||
not re-pinned. Actual original resource preservation and charity eligibility
|
||||
remain asserted. Only the failed unit union was rerun. Build passes with
|
||||
unchanged module.register deprecation, client chunks over500kB and Vinext
|
||||
static route-classification warnings. No broader/deployed certification.
|
||||
|
||||
## 13 October 2026 — native economic Karama costs
|
||||
|
||||
Only the existing `karamaCharityDiscard`, `karamaInflationDiscard`,
|
||||
|
||||
@@ -855,6 +855,25 @@ consumer cases pass on that genuine setup. Later held cards/Tanks use
|
||||
conserved transfers, not replacement decks or minted forces. No new ruling,
|
||||
module entry, public gate or complete-mode certification follows.
|
||||
|
||||
**13 October 2026 — original Homeworld shared-benefit cancellation costs.**
|
||||
Native Advanced BG extra-charity and allied Emperor gift/extra-revival
|
||||
printed Karama costs now offer before original denial. BG's real consumed
|
||||
turn claim remains spent; recovery cannot award its withheld2 or renew use.
|
||||
Wealthy Basic BG keeps ordinary charity eligibility. Emperor's actual ally,
|
||||
paid deployment/three storm casualties, wallets and extra-revival ledger
|
||||
remain committed; recovering the cost cannot move the canceled benefit.
|
||||
|
||||
Six positive actual before programs had no offer, with a separate Basic
|
||||
negative. Twenty-one parent after/negative programs preserve BG5/C12 or
|
||||
E10/A4/6, Tanks3/reserves7 and the real turn2 alliance. Ordinary revival is
|
||||
still legal3/free2/paid1, not extra2. Three actual390px declaration/claim/
|
||||
cancellation/exchange flows retain those boundaries and original next
|
||||
controls; runtime proves the later Bidding/Movement phase. Artifact:
|
||||
`/tmp/dune-homeworld-native-shared-3223.json`.
|
||||
No old staged-force fixture proves these new original paths. No signature,
|
||||
price, profile, pending ruling or public gate changes, and no saved/deployed/
|
||||
full-mode acceptance is claimed.
|
||||
|
||||
**13 October 2026 — original Homeworld economic Karama costs.**
|
||||
Printed CHOAM opening-income/Inflation cancellation and normal-lot purchase/
|
||||
winning-payment costs now offer before their original terminal effect.
|
||||
|
||||
+3
-1
@@ -6971,7 +6971,9 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaCharityDiscard' &&
|
||||
continuation.kind !== 'karamaInflationDiscard' &&
|
||||
continuation.kind !== 'karamaPurchaseDiscard' &&
|
||||
continuation.kind !== 'karamaPaymentDiscard') return false;
|
||||
continuation.kind !== 'karamaPaymentDiscard' &&
|
||||
continuation.kind !== 'karamaBgCharityDiscard' &&
|
||||
continuation.kind !== 'karamaEmperorShareDiscard') return false;
|
||||
if (continuation.kind === 'battleResolved' &&
|
||||
!g.lastBattleContext?.mandatoryDiscard) return false;
|
||||
if ((continuation.kind === 'battleCleanup' ||
|
||||
|
||||
@@ -1192,6 +1192,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Original Homeworld battle disposal offers accepted losing cards, later mandatory winning cards and optional winner-selected cards as separate fresh events. Commit and exact selection recover at most one other player’s newly discarded card; unselected cards stay in the pile. The original paid deployments, accepted result, leader deaths and casualty obligations remain committed, with the same native winner cleanup and rewards afterward. A recovered card cannot replay combat or consume another original opportunity.',
|
||||
'Clean Homeworld Residual Poison disposal offers its used card after exactly one native random eligible leader enters the Tanks, with no spice bounty or force loss; claim/pass resumes the original shared preparation and does not redraw the victim. Early Sapho aggressor disposal likewise keeps the original participants/chooser and changed ordinary tie priority, while clean between-battle first/last disposal keeps earlier resolved battles and only reorders remaining chooser opportunities. Physical storm order is unchanged. Readied or inspected Poison costs, late Sapho timing and Advanced Harkonnen captive disclosure remain outside these clean reactions.',
|
||||
'Printed Karama canceling original CHOAM opening income or native Inflation placement also offers its used card in the Homeworld profile. Claim/pass completes the same denial once: unpaid income stays withheld, and the original placement attempt remains spent with no token. Clean normal-lot printed Karama purchase and free winning-bid payment likewise offer their costs before exactly one original reserved card arrives without spice payment. Earlier bids, the normal pool, Richese cache, original wallets and later native controls stay committed. Richese lots, BG Worthless conversion, Kull and other cancellations remain separate.',
|
||||
'The native Advanced BG extra-charity cancellation cost also offers printed Karama after the genuine claim allowance is consumed but before denial; claim/pass withholds the same extra allowance and cannot renew that turn’s claim. Wealthy Basic BG retains its original ordinary-charity guard. Allied Emperor gift and extra-revival cancellation costs likewise offer after original native setup, actual casualty/alliance and declaration: recovering the card does not transfer the canceled gift, return a counter or award extra revival. Original ally, populations, wallets, Tanks and native closing controls remain committed.',
|
||||
'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.',
|
||||
@@ -1280,6 +1281,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-homeworld-battle-sapho.test.ts',
|
||||
'tests/semuta-homeworld-economic-karama.test.ts',
|
||||
'tests/semuta-homeworld-karama-auction.test.ts',
|
||||
'tests/semuta-homeworld-bg-charity.test.ts',
|
||||
'tests/semuta-homeworld-emperor-share.test.ts',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
import { applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
|
||||
|
||||
export type SemutaHomeworldBgCharityFixture = {
|
||||
initial: Game; setup: Game; gameBeforeBGclaim: Game; game: Game;
|
||||
originalIncomeResponse: Game; originalResponse: Game | null;
|
||||
actor: string; owner: string; holder: string; payer: string; target: string;
|
||||
card: string; amount: number; claim: Action; action: Action; event: string | null;
|
||||
actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Native human continuation: decline markets, allow responses, and ready the
|
||||
* original queues. Never use the optional paid Kaitain end-of-Bidding control. */
|
||||
function advance(state: Game, until: (game: Game) => boolean, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 1600; n++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
let next: RicheseSkillsTechStep;
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
if (pending && reaction?.stage === 'offer') next = {
|
||||
actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
|
||||
action: { type: 'semutaPass', event: pending.batch.event },
|
||||
};
|
||||
else if (game.decision?.kind === 'choamMarket') next = {
|
||||
actor: game.decision.player, action: { type: 'decision', done: true },
|
||||
};
|
||||
else if (game.biddingEnd && clean(game)) next = {
|
||||
actor: game.biddingEnd.owners.find(id => !game.biddingEnd!.ready.includes(id))!,
|
||||
action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' },
|
||||
};
|
||||
else next = nextRicheseSkillsTechNativeStep(game);
|
||||
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
|
||||
}
|
||||
assert.ok(until(game), 'Original native controls must reach the BG charity boundary');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Five original ready participants, actual prediction/force setup and first
|
||||
* CHOAM income. Only conserved physical cards and unplayed Spice order stage. */
|
||||
export function createSemutaHomeworldBgCharityFixture(
|
||||
advanced = true, fullHolder = false,
|
||||
): SemutaHomeworldBgCharityFixture {
|
||||
let game = createGame('SEMUTAHWBG', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
||||
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
|
||||
game = applySemutaHomeworldChoamStep(game, game.host, { type: 'homeworlds', enabled: true }, actions);
|
||||
for (const seat of game.players) game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'ready' }, actions);
|
||||
const initial = structuredClone(game);
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
game = advance(game, g => g.status === 'playing', actions);
|
||||
const setup = structuredClone(game);
|
||||
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 2);
|
||||
assert.equal(blows.length, 2);
|
||||
for (const [position, card] of blows.entries())
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('Order two original still-unplayed non-worm Spice Cards.');
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
const card = moveSemutaEmperorCard(game, semutaEmperorCardId(game, c => c.effect === 'karama'), 'e');
|
||||
staging.push('Conserved original Semuta to Richese and printed Karama to Emperor.');
|
||||
if (fullHolder) {
|
||||
const holder = semutaEmperorPlayer(game, 'r');
|
||||
while (holder.hand.length < handLimit(holder)) {
|
||||
assert.ok(game.deck[0]);
|
||||
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
|
||||
}
|
||||
staging.push('Conserved undealt stock fills the original Richese hand to four.');
|
||||
}
|
||||
game = advance(game, g => g.response?.kind === 'choamCharity', actions);
|
||||
const originalIncomeResponse = structuredClone(game);
|
||||
game = advance(game, g => g.phase === 2 && clean(g), actions);
|
||||
const gameBeforeBGclaim = structuredClone(game), claim: Action = { type: 'charity' };
|
||||
if (advanced) game = applySemutaHomeworldChoamStep(game, 'b', claim, actions);
|
||||
assert.equal(semutaEmperorPlayer(game, 'b').spice, 5);
|
||||
assert.equal(semutaEmperorPlayer(game, 'c').spice, 12);
|
||||
if (advanced) {
|
||||
assert.equal(game.response?.kind, 'bgCharity');
|
||||
assert.equal(game.response?.amount, 2);
|
||||
assert.ok(viewGame(game, 'e').responseControls?.cancelCards.includes(card));
|
||||
}
|
||||
return { initial, setup, gameBeforeBGclaim, originalIncomeResponse,
|
||||
originalResponse: advanced ? structuredClone(game) : null, game,
|
||||
actor: 'e', owner: 'b', holder: 'r', payer: 'c', target: 'a', card,
|
||||
amount: advanced ? game.response!.amount! : viewGame(game, 'b').charity.amount,
|
||||
claim, action: { type: 'card', mode: 'cancel', card }, event: null, actions, staging };
|
||||
}
|
||||
|
||||
/** Spend the real printed card before the bound original BG denial. */
|
||||
export function disposeSemutaHomeworldBgCharity(
|
||||
f: SemutaHomeworldBgCharityFixture, state = f.game, actions?: RicheseSkillsTechStep[],
|
||||
): Game {
|
||||
const game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
|
||||
f.event = game.pendingTreacheryDiscard?.batch.event ?? null;
|
||||
return game;
|
||||
}
|
||||
|
||||
export function reactSemutaHomeworldBgCharity(
|
||||
state: Game, holder: string, claim: boolean, actions?: RicheseSkillsTechStep[],
|
||||
): Game {
|
||||
const pending = state.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
assert.ok(pending && reaction?.stage === 'offer');
|
||||
const event = pending.batch.event;
|
||||
if (claim) return applySemutaHomeworldChoamStep(state, holder, { type: 'semutaCommit', event }, actions);
|
||||
let game = state;
|
||||
for (const seat of state.players.filter(p => !reaction.passed.includes(p.id)))
|
||||
game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'semutaPass', event }, actions);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Continue the same original native charity close into usable Bidding. */
|
||||
export function finishSemutaHomeworldBgCharity(
|
||||
state: Game, actions?: RicheseSkillsTechStep[],
|
||||
): Game {
|
||||
assert.ok(!state.pendingTreacheryDiscard && state.response?.kind !== 'bgCharity');
|
||||
return advance(state, g => g.phase === 3 && clean(g), actions);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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 { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
|
||||
type SemutaEmperorShareKind } from './fixture-semuta-emperor-share';
|
||||
|
||||
export type SemutaHomeworldEmperorShareFixture = {
|
||||
kind: SemutaEmperorShareKind; game: Game; setup: Game; beforeShipment: Game;
|
||||
paidShipment: Game; beforeWeather: Game; weather: Game; death: Game; worm: Game;
|
||||
alliance: Game; beforeBenefit: Game; beforeDeclaration: Game;
|
||||
owner: string; holder: string; recipient: string;
|
||||
canceller: string; karama: string; karamaCard: Card; weatherCard: string;
|
||||
shipment: Action; weatherAction: Action; declare: Action; cancel: Action;
|
||||
actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.pendingTreacheryDiscard;
|
||||
|
||||
export function nextSemutaHomeworldEmperorShareStep(game: Game): RicheseSkillsTechStep {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
if (pending && reaction?.stage === 'offer') return {
|
||||
actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
|
||||
action: { type: 'semutaPass', event: pending.batch.event },
|
||||
};
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.biddingEnd) {
|
||||
const end = game.biddingEnd;
|
||||
return { actor: end.owners.find(id => !end.ready.includes(id))!,
|
||||
action: { type: 'biddingEnd', event: end.event, mode: 'ready' } };
|
||||
}
|
||||
if (game.decision?.kind === 'choamMarket') return {
|
||||
actor: game.decision.player, action: { type: 'decision', done: true },
|
||||
};
|
||||
// The Homeworld CHOAM Mentat window is optional: decline it so its special
|
||||
// effects never disturb the deployed Atreides counter before the storm.
|
||||
if (game.decision?.kind === 'choamMentat') return {
|
||||
actor: game.decision.player, action: { type: 'decision', done: true },
|
||||
};
|
||||
return nextRicheseSkillsTechNativeStep(game);
|
||||
}
|
||||
export function advanceSemutaHomeworldEmperorShare(state: Game, until: (game: Game) => boolean,
|
||||
actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 2000; n++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaHomeworldEmperorShareStep(game);
|
||||
actions?.push(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), 'Original share continuation reaches the requested native boundary');
|
||||
return game;
|
||||
}
|
||||
export function semutaHomeworldEmperorShareStock(game: Game): string[] {
|
||||
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
|
||||
...game.players.map(p => p.hand)].flat().map(card => card.id).sort();
|
||||
}
|
||||
export function semutaHomeworldEmperorShareResources(game: Game) {
|
||||
return { custody: structuredClone(game.homeworlds?.custody), emperorExtra: { ...game.emperorExtra },
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: { ...p.forces },
|
||||
reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites),
|
||||
revived: p.revived, freeForcesRevived: p.freeForcesRevived,
|
||||
battleLosses: p.battleLosses, ally: p.ally, allySinceTurn: p.allySinceTurn })) };
|
||||
}
|
||||
function orderUnplayedSpice(game: Game, kinds: ('land' | 'worm')[]): void {
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const at = game.spiceDeck.findIndex((card, i) => i >= position &&
|
||||
(kind === 'land' ? 'territory' in card : 'worm' in card));
|
||||
assert.ok(at >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Original R/C/E/A setup, paid three-counter deployment, real Weather casualties
|
||||
* and mutual worm Nexus alliance. Only physical cards/unplayed Spice order move. */
|
||||
export function createSemutaHomeworldEmperorShareFixture(
|
||||
kind: SemutaEmperorShareKind, advanced = false, fullHolder = false,
|
||||
): SemutaHomeworldEmperorShareFixture {
|
||||
let game = createGame('SEMUTAHWSHARE', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
|
||||
const reach = (until: (g: Game) => boolean) => {
|
||||
game = advanceSemutaHomeworldEmperorShare(game, until, actions);
|
||||
};
|
||||
const act = (actor: string, action: Action) => {
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
};
|
||||
reach(g => g.status === 'playing');
|
||||
const setup = structuredClone(game);
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
orderUnplayedSpice(game, ['land', 'land']);
|
||||
staging.push('Original physical Semuta to Richese; first two unplayed original Spice Cards are non-worm.');
|
||||
reach(g => g.phase === 5 && g.active === 'a' && clean(g));
|
||||
const beforeShipment = structuredClone(game);
|
||||
const homeworldSources = nativeShipmentSources(viewGame(game, 'a'), 3, 0);
|
||||
assert.ok(homeworldSources);
|
||||
const shipment: Action = { type: 'ship', territory: 'red_chasm', sector: 7,
|
||||
amount: 3, elite: 0, homeworldSources, allyPayment: 0 };
|
||||
act('a', shipment);
|
||||
reach(clean);
|
||||
const paidShipment = structuredClone(game);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').spice, semutaEmperorPlayer(beforeShipment, 'a').spice - 6);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').forces['red_chasm:7'], 3);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').reserves, 7);
|
||||
act('a', { type: 'endMovement' });
|
||||
reach(g => g.turn === 2 && g.phase === 0 && clean(g));
|
||||
const weatherCard = semutaEmperorCardId(game, card => card.effect === 'weather');
|
||||
moveSemutaEmperorCard(game, weatherCard, 'a');
|
||||
staging.push('Conserved original printed Weather Control to Atreides before the second native Storm.');
|
||||
const beforeWeather = structuredClone(game);
|
||||
assert.equal(game.storm, 1);
|
||||
const weatherAction: Action = { type: 'card', card: weatherCard, amount: 6 };
|
||||
act('a', weatherAction);
|
||||
reach(clean);
|
||||
const weather = structuredClone(game);
|
||||
assert.equal(game.stormPending, 6);
|
||||
assert.equal(game.stormMovementSource?.kind, 'weather');
|
||||
reach(g => g.phase === 1 && clean(g));
|
||||
const death = structuredClone(game);
|
||||
assert.equal(game.storm, 7);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').forces['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').forces['arrakeen:10'], 10);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').tanks, 3);
|
||||
assert.equal(semutaEmperorPlayer(game, 'e').spice, 10);
|
||||
assert.ok(semutaEmperorPlayer(game, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
orderUnplayedSpice(game, ['worm', 'land', 'land']);
|
||||
staging.push('The next original unplayed worm opens the second-turn Nexus; later original land cards close Spice Blow.');
|
||||
reach(g => g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g));
|
||||
const worm = structuredClone(game);
|
||||
act('e', { type: 'alliance', target: 'a' });
|
||||
act('a', { type: 'alliance', target: 'e' });
|
||||
const alliance = structuredClone(game);
|
||||
reach(g => g.phase === (kind === 'emperorGift' ? 2 : 4) && clean(g));
|
||||
const beforeBenefit = structuredClone(game);
|
||||
// Conserved late hand positioning follows every preceding committed market pool.
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
const karama = semutaEmperorCardId(game, card => card.effect === 'karama');
|
||||
moveSemutaEmperorCard(game, karama, 'a');
|
||||
if (fullHolder) while (semutaEmperorPlayer(game, 'r').hand.length < handLimit(semutaEmperorPlayer(game, 'r'))) {
|
||||
assert.ok(game.deck[0]); moveSemutaEmperorCard(game, game.deck[0].id, 'r');
|
||||
}
|
||||
const karamaCard = semutaEmperorPlayer(game, 'a').hand.find(card => card.id === karama)!;
|
||||
staging.push('Conserved original printed Karama to Atreides and Semuta to Richese; optional full hand uses original undealt stock.');
|
||||
assert.deepEqual(semutaHomeworldEmperorShareStock(game), semutaHomeworldEmperorShareStock(setup));
|
||||
assert.equal(semutaEmperorPlayer(game, 'e').spice, 10);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').tanks, 3);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').revived, 0);
|
||||
// Real UI entry point for the Emperor's own declaration: every conserved card
|
||||
// is already positioned, and only the declared benefit response is missing.
|
||||
const beforeDeclaration = structuredClone(game);
|
||||
const declare: Action = { type: kind, amount: kind === 'emperorGift' ? 3 : 2 };
|
||||
act('e', declare);
|
||||
assert.equal(game.response?.kind, kind);
|
||||
return { kind, game, setup, beforeShipment, paidShipment, beforeWeather, weather, death,
|
||||
worm, alliance, beforeBenefit, beforeDeclaration,
|
||||
owner: 'e', holder: 'r', recipient: 'a', canceller: 'a',
|
||||
karama, karamaCard, weatherCard, shipment, weatherAction,
|
||||
declare,
|
||||
cancel: { type: 'card', mode: 'cancel', card: karama }, actions, staging };
|
||||
}
|
||||
|
||||
/** Stop at the paid printed cancellation receipt, before its native denial. */
|
||||
export function disposeSemutaHomeworldEmperorShare(f: SemutaHomeworldEmperorShareFixture,
|
||||
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
assert.equal(state.response?.kind, f.kind);
|
||||
actions?.push({ actor: f.canceller, action: f.cancel });
|
||||
return applyAction(state, f.canceller, f.cancel);
|
||||
}
|
||||
export function reactSemutaHomeworldEmperorShare(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 p of state.players) game = applyAction(game, p.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
/** The still-legal ordinary return this fixture exercises at that revival window. */
|
||||
export function semutaHomeworldEmperorOrdinaryRevive(game: Game, playerId: string): number {
|
||||
const seat = semutaEmperorPlayer(game, playerId);
|
||||
const limit = Math.min(forceRevivalRemaining(game, seat), seat.tanks);
|
||||
return Array.from({ length: limit }, (_, index) => index + 1)
|
||||
.filter(count => forceRevivalQuote(game, seat, count).cost <= seat.spice).at(-1) ?? 0;
|
||||
}
|
||||
/** Use the same original controls. Revival additionally exercises the still-legal
|
||||
* ordinary Atreides return of whatever two-cost revivals remain that turn. */
|
||||
export function finishSemutaHomeworldEmperorShare(state: Game, f: SemutaHomeworldEmperorShareFixture,
|
||||
actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = advanceSemutaHomeworldEmperorShare(state, clean, actions);
|
||||
if (f.kind === 'emperorRevival') {
|
||||
const amount = semutaHomeworldEmperorOrdinaryRevive(game, f.recipient);
|
||||
assert.ok(amount > 0, 'The original Tanks still support an ordinary revival this turn');
|
||||
const revive: Action = { type: 'revive', amount, elite: 0 };
|
||||
actions?.push({ actor: f.recipient, action: revive });
|
||||
game = applyAction(game, f.recipient, revive);
|
||||
}
|
||||
return advanceSemutaHomeworldEmperorShare(game,
|
||||
g => g.turn === 2 && g.phase === (f.kind === 'emperorGift' ? 3 : 5) && clean(g), actions);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
||||
import { semutaHomeworldChoamMovementResources as resources,
|
||||
semutaHomeworldChoamMovementStock as stock } from './fixture-semuta-homeworld-choam-movement';
|
||||
import { withoutSemutaHomeworldEconomicHolder } from './fixture-semuta-homeworld-economic-karama';
|
||||
import {
|
||||
createSemutaHomeworldBgCharityFixture, disposeSemutaHomeworldBgCharity,
|
||||
reactSemutaHomeworldBgCharity, finishSemutaHomeworldBgCharity,
|
||||
type SemutaHomeworldBgCharityFixture,
|
||||
} from './fixture-semuta-homeworld-bg-charity';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected wrong-owner, stale or replay actions have no effect');
|
||||
}
|
||||
|
||||
function assertNative(f: SemutaHomeworldBgCharityFixture) {
|
||||
assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.phase, 2);
|
||||
assert.equal(player(f.setup, f.payer).spice, 2);
|
||||
assert.equal(player(f.originalIncomeResponse, f.payer).spice, 2);
|
||||
assert.equal(f.originalIncomeResponse.response?.kind, 'choamCharity');
|
||||
assert.equal(player(f.game, f.payer).spice, 12, 'Actual five-player income is ten, once');
|
||||
assert.deepEqual(f.game.choamCharity, { turn: 1, canceled: false });
|
||||
assert.equal(player(f.setup, f.owner).spice, 5);
|
||||
assert.equal(player(f.game, f.owner).spice, 5, 'BG is genuinely wealthy, never staged poor');
|
||||
assert.deepEqual(f.game.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
for (const seat of f.game.players) {
|
||||
assert.equal(seat.reserves, player(f.setup, seat.id).reserves);
|
||||
assert.equal(seat.tanks, player(f.setup, seat.id).tanks);
|
||||
assert.deepEqual(seat.forces, player(f.setup, seat.id).forces);
|
||||
assert.deepEqual(seat.elites, player(f.setup, seat.id).elites);
|
||||
if (seat.id !== f.payer) assert.equal(seat.spice, player(f.setup, seat.id).spice);
|
||||
}
|
||||
assert.deepEqual(stock(f.game), stock(f.setup), 'Native stock is physically conserved');
|
||||
const own = viewGame(f.gameBeforeBGclaim, f.owner);
|
||||
assert.equal(own.charity.homeworld, 0, 'High Wallach gives no fabricated low-population bonus');
|
||||
assert.equal(own.charity.payer, f.payer);
|
||||
if (f.game.advanced) {
|
||||
assert.equal(own.charity.amount, 2);
|
||||
assert.equal(f.amount, 2);
|
||||
assert.equal(f.game.response?.kind, 'bgCharity');
|
||||
assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.response?.amount, 2);
|
||||
assert.equal(f.game.response?.charityHomeworld, 0);
|
||||
assert.notEqual(player(f.gameBeforeBGclaim, f.owner).charityTurn, 1);
|
||||
assert.equal(player(f.game, f.owner).charityTurn, 1, 'Actual claim consumes the allowance before its response');
|
||||
} else {
|
||||
assert.equal(own.charity.amount, 0);
|
||||
assert.equal(f.originalResponse, null);
|
||||
assert.equal(f.game.response, null);
|
||||
assert.notEqual(player(f.game, f.owner).charityTurn, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDenied(f: SemutaHomeworldBgCharityFixture, game: Game, claimed: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.notEqual(game.response?.kind, 'bgCharity');
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(game.phase, 2);
|
||||
assert.deepEqual(resources(game), resources(f.game), 'Denial and recovery neither pay BG nor debit CHOAM or move forces/custody');
|
||||
assert.deepEqual(stock(game), stock(f.game));
|
||||
assert.equal(player(game, f.owner).spice, 5);
|
||||
assert.equal(player(game, f.payer).spice, 12);
|
||||
assert.equal(player(game, f.owner).charityTurn, 1);
|
||||
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
|
||||
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), claimed);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID),
|
||||
!claimed && player(f.game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claimed));
|
||||
for (const seat of game.players)
|
||||
assert.equal(seat.hand.length, player(f.game, seat.id).hand.length - Number(seat.id === f.actor));
|
||||
reject(game, f.owner, f.claim);
|
||||
reject(game, f.actor, f.action);
|
||||
if (claimed) reject(game, f.holder, f.action);
|
||||
}
|
||||
|
||||
function assertSuffix(f: SemutaHomeworldBgCharityFixture, game: Game) {
|
||||
const next = finishSemutaHomeworldBgCharity(game);
|
||||
assert.equal(next.status, 'playing');
|
||||
assert.equal(next.phase, 3);
|
||||
assert.equal(next.turn, 1);
|
||||
assert.ok(!next.pendingTreacheryDiscard && !next.response && !next.decision && !next.phaseOpening);
|
||||
assert.deepEqual(resources(next), resources(f.game), 'Original native close never restores the denied two spice');
|
||||
assert.equal(player(next, f.owner).charityTurn, 1);
|
||||
assert.equal(viewGame(next, f.owner).charity.payer, f.payer);
|
||||
assert.equal(player(next, f.payer).spice, 12);
|
||||
reject(next, f.owner, f.claim);
|
||||
reject(next, f.actor, f.action);
|
||||
const nextAction = nextRicheseSkillsTechNativeStep(next);
|
||||
const after = applyAction(next, nextAction.actor, nextAction.action);
|
||||
assert.equal(after.turn, 1);
|
||||
assert.equal(after.phase, 3);
|
||||
assert.equal(player(after, f.owner).spice, 5);
|
||||
assert.equal(player(after, f.payer).spice, 12);
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(`Advanced native BG charity: ${claimed ? 'claim' : 'pass'} pays the printed Karama once and denies the original two spice`, () => {
|
||||
const f = createSemutaHomeworldBgCharityFixture();
|
||||
assertNative(f);
|
||||
reject(f.game, f.holder, f.action);
|
||||
const game = disposeSemutaHomeworldBgCharity(f), cost = game.pendingTreacheryDiscard;
|
||||
assert.ok(cost && cost.reaction?.stage === 'offer');
|
||||
assert.equal(cost.continuation.kind, 'karamaBgCharityDiscard');
|
||||
assert.equal(cost.batch.cause, 'karama:bgCharity');
|
||||
assert.deepEqual(cost.batch.entries.map(entry => ({ card: entry.card.id, actor: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.card, actor: f.actor, face: true }]);
|
||||
if (cost.continuation.kind === 'karamaBgCharityDiscard') {
|
||||
assert.deepEqual(cost.continuation.use.response, f.originalResponse!.response);
|
||||
assert.deepEqual(cost.continuation.resume.response, f.originalResponse!.response);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.equal(player(game, f.owner).charityTurn, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
reject(game, f.owner, f.claim);
|
||||
reject(game, f.actor, f.action);
|
||||
reject(game, f.actor, { type: 'passResponse' });
|
||||
reject(game, f.target, { type: 'semutaCommit', event: cost.batch.event });
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${cost.batch.event}:stale` });
|
||||
const done = reactSemutaHomeworldBgCharity(JSON.parse(JSON.stringify(game)), f.holder, claimed);
|
||||
assertDenied(f, done, claimed);
|
||||
reject(done, f.holder, { type: 'semutaCommit', event: cost.batch.event });
|
||||
reject(done, f.target, { type: 'semutaPass', event: cost.batch.event });
|
||||
assertSuffix(f, done);
|
||||
});
|
||||
|
||||
void test('Advanced native BG charity: full four-card Richese exchanges one card and keeps the absent-Semuta boundary private', () => {
|
||||
const f = createSemutaHomeworldBgCharityFixture(true, true);
|
||||
assertNative(f);
|
||||
assert.equal(player(f.game, f.holder).hand.length, 4);
|
||||
assert.equal(handLimit(player(f.game, f.holder)), 4);
|
||||
const game = disposeSemutaHomeworldBgCharity(f);
|
||||
const neutral = disposeSemutaHomeworldBgCharity(f, withoutSemutaHomeworldEconomicHolder(f.game, f.holder));
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
||||
reject(neutral, f.holder, { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event });
|
||||
for (const seat of game.players.filter(p => p.id !== f.holder)) {
|
||||
assert.deepEqual(viewGame(game, seat.id), viewGame(neutral, seat.id));
|
||||
assert.equal(viewGame(game, seat.id).semutaReaction?.canCommit, false);
|
||||
}
|
||||
const done = reactSemutaHomeworldBgCharity(game, f.holder, true);
|
||||
assertDenied(f, done, true);
|
||||
assert.equal(player(done, f.holder).hand.length, 4);
|
||||
const passed = reactSemutaHomeworldBgCharity(neutral, f.holder, false);
|
||||
assertDenied({ ...f, game: withoutSemutaHomeworldEconomicHolder(f.game, f.holder) }, passed, false);
|
||||
assert.deepEqual(resources(passed), resources(done));
|
||||
assertSuffix(f, done);
|
||||
});
|
||||
|
||||
void test('Basic native wealthy BG cannot create an extra charity response or spend its cancel cost', () => {
|
||||
const f = createSemutaHomeworldBgCharityFixture(false);
|
||||
assertNative(f);
|
||||
reject(f.game, f.owner, f.claim);
|
||||
reject(f.game, f.actor, f.action);
|
||||
assert.equal(player(f.game, f.actor).hand.some(card => card.id === f.card), true);
|
||||
assert.equal(f.game.discard.some(card => card.id === f.card), false);
|
||||
assert.ok(!f.game.pendingTreacheryDiscard && !f.game.response);
|
||||
const next = finishSemutaHomeworldBgCharity(f.game);
|
||||
assert.equal(next.phase, 3);
|
||||
assert.deepEqual(resources(next), resources(f.game));
|
||||
assert.equal(player(next, f.owner).spice, 5);
|
||||
assert.equal(player(next, f.payer).spice, 12);
|
||||
assert.notEqual(player(next, f.owner).charityTurn, 1);
|
||||
});
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: actual Semuta and native next BG controls preserve the canceled charity allowance`, () => {
|
||||
const f = createSemutaHomeworldBgCharityFixture();
|
||||
let game = disposeSemutaHomeworldBgCharity(f);
|
||||
const own = viewGame(game, f.holder);
|
||||
own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const reaction = botActions(own)[0];
|
||||
assert.ok(reaction && (reaction.type === 'semutaCommit' || reaction.type === 'semutaPass'));
|
||||
game = applyAction(game, f.holder, reaction);
|
||||
if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldBgCharity(game, f.holder, false);
|
||||
assertDenied(f, game, reaction.type === 'semutaCommit');
|
||||
const nextView = viewGame(game, f.owner);
|
||||
nextView.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
const next = botActions(nextView)[0];
|
||||
assert.ok(next);
|
||||
const after = applyAction(game, f.owner, next);
|
||||
assert.equal(player(after, f.owner).charityTurn, 1);
|
||||
assert.equal(player(after, f.owner).spice, 5);
|
||||
assert.equal(player(after, f.payer).spice, 12);
|
||||
assertSuffix(f, after);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import type { SemutaEmperorShareKind } from './fixture-semuta-emperor-share';
|
||||
import {
|
||||
createSemutaHomeworldEmperorShareFixture, disposeSemutaHomeworldEmperorShare,
|
||||
finishSemutaHomeworldEmperorShare, reactSemutaHomeworldEmperorShare,
|
||||
semutaHomeworldEmperorOrdinaryRevive,
|
||||
semutaHomeworldEmperorShareResources as resources,
|
||||
semutaHomeworldEmperorShareStock as stock,
|
||||
type SemutaHomeworldEmperorShareFixture,
|
||||
} from './fixture-semuta-homeworld-emperor-share';
|
||||
|
||||
const kinds: SemutaEmperorShareKind[] = ['emperorGift', 'emperorRevival'];
|
||||
const shareOf = (kind: SemutaEmperorShareKind) => kind === 'emperorGift' ? 'gift' : 'revival';
|
||||
const endPhase = (kind: SemutaEmperorShareKind) => kind === 'emperorGift' ? 3 : 5;
|
||||
|
||||
/** The denied share spends the printed Karama once and moves no benefit. */
|
||||
function assertConsumedDenial(game: Game, f: SemutaHomeworldEmperorShareFixture) {
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length +
|
||||
player(game, f.holder).hand.filter(card => card.id === f.karama).length, 1,
|
||||
'The exact spent cost is either still discarded or recovered once');
|
||||
assert.equal(player(game, f.canceller).hand.some(card => card.id === f.karama), false);
|
||||
assert.equal(game.emperorExtra[f.recipient] ?? 0, 0, 'No extra-revival allowance is granted');
|
||||
assert.deepEqual(resources(game), resources(f.beforeBenefit),
|
||||
'A denied share changes no wallet, force, Tank, custody or revival ledger');
|
||||
assert.equal(game.storm, f.death.storm, 'The original storm history is retained');
|
||||
assert.equal(player(game, f.recipient).allySinceTurn, player(f.alliance, f.recipient).allySinceTurn,
|
||||
'The original turn-two alliance stamp is retained');
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const label = advanced ? 'Advanced' : 'Basic';
|
||||
for (const kind of kinds) {
|
||||
void test(`${label} Homeworld: original Emperor ${shareOf(kind)} declaration accepts the real Atreides Karama cost`, () => {
|
||||
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced);
|
||||
assert.deepEqual(resources(f.game), resources(f.beforeBenefit),
|
||||
'Declaring the share spends nothing before the printed cancellation');
|
||||
assert.deepEqual(stock(f.game), stock(f.setup));
|
||||
assert.equal(f.game.turn, 2);
|
||||
assert.equal(f.game.phase, kind === 'emperorGift' ? 2 : 4);
|
||||
assert.equal(f.beforeWeather.storm, 1);
|
||||
assert.equal(f.weather.stormMovementSource?.kind, 'weather');
|
||||
assert.equal(f.weather.stormMovementSource?.distance, 6);
|
||||
assert.equal(f.game.storm, 7);
|
||||
assert.equal(player(f.paidShipment, f.recipient).spice, player(f.beforeShipment, f.recipient).spice - 6);
|
||||
assert.equal(player(f.paidShipment, f.recipient).forces['red_chasm:7'], 3);
|
||||
assert.equal(player(f.death, f.recipient).forces['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(player(f.death, f.recipient).tanks, 3);
|
||||
assert.equal(player(f.death, f.recipient).forces['arrakeen:10'], 10);
|
||||
assert.equal(f.worm.nexus, true);
|
||||
assert.equal(player(f.alliance, f.owner).ally, f.recipient);
|
||||
assert.equal(player(f.alliance, f.recipient).ally, f.owner);
|
||||
assert.equal(Boolean(f.game.nexus), false, 'The original worm Nexus has resolved');
|
||||
assert.equal(f.game.response?.kind, kind);
|
||||
assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.response?.recipient, f.recipient);
|
||||
assert.equal(f.game.response?.amount, kind === 'emperorGift' ? 3 : 2);
|
||||
assert.deepEqual(viewGame(f.game, f.canceller).responseControls?.cancelCards, [f.karama],
|
||||
'The allied recipient holds the exact original printed cancellation');
|
||||
for (const seat of f.game.players.filter(seat => seat.id !== f.canceller))
|
||||
assert.equal(viewGame(f.game, seat.id).responseControls?.cancelCards.length ?? 0, 0,
|
||||
'No other original seat can spend the allied printed cancellation');
|
||||
assert.equal(player(f.game, f.owner).spice, 10);
|
||||
|
||||
const receipt = disposeSemutaHomeworldEmperorShare(f);
|
||||
const pending = receipt.pendingTreacheryDiscard!;
|
||||
assert.equal(pending.continuation.kind, 'karamaEmperorShareDiscard');
|
||||
assert.equal(pending.batch.cause, `karama:${kind}`);
|
||||
assert.deepEqual(pending.batch.entries,
|
||||
[{ card: f.karamaCard, discardedBy: f.canceller, publicFace: true }]);
|
||||
assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.equal(receipt.response, null);
|
||||
assert.equal(receipt.decision, null);
|
||||
assert.equal(viewGame(receipt, f.holder).semutaReaction?.canCommit, true);
|
||||
for (const seat of receipt.players.filter(seat => seat.id !== f.holder))
|
||||
assert.equal(viewGame(receipt, seat.id).semutaReaction?.canCommit, false,
|
||||
'Only the original Semuta holder receives the neutral offer');
|
||||
assertConsumedDenial(receipt, f);
|
||||
});
|
||||
|
||||
for (const claim of [false, true]) void test(`${label} Homeworld: Semuta ${claim ? 'claim' : 'pass'} decides the spent Karama and denies the ${shareOf(kind)}`, () => {
|
||||
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced);
|
||||
const receipt = disposeSemutaHomeworldEmperorShare(f);
|
||||
const sequence = receipt.pendingTreacheryDiscard!.sequence;
|
||||
const event = receipt.pendingTreacheryDiscard!.batch.event;
|
||||
assert.deepEqual(resources(receipt), resources(f.beforeBenefit));
|
||||
const done = reactSemutaHomeworldEmperorShare(receipt, f.holder, claim);
|
||||
assert.equal(done.resolvedTreacheryDiscardSequence, sequence);
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(done, f.holder).hand.some(card => card.id === f.karama), claim);
|
||||
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim));
|
||||
assert.equal(player(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assertConsumedDenial(done, f);
|
||||
assert.equal(player(done, f.owner).spice, player(f.beforeBenefit, f.owner).spice);
|
||||
assert.equal(player(done, f.recipient).spice, player(f.beforeBenefit, f.recipient).spice);
|
||||
assert.equal(player(done, f.recipient).forces['arrakeen:10'], 10);
|
||||
assert.equal(player(done, f.recipient).forces['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(player(done, f.recipient).tanks, 3);
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(done, f.canceller, f.cancel),
|
||||
'The spent printed Karama cannot cancel again');
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'alliance', target: f.owner }),
|
||||
'No second alliance is offered outside the original worm Nexus');
|
||||
assert.deepEqual(stock(done), stock(f.setup));
|
||||
const finished = finishSemutaHomeworldEmperorShare(structuredClone(done), f);
|
||||
assert.equal(finished.turn, 2);
|
||||
assert.equal(finished.phase, endPhase(kind));
|
||||
assert.equal(finished.storm, 7);
|
||||
assert.equal(player(finished, f.recipient).forces['arrakeen:10'], 10);
|
||||
assert.equal(player(finished, f.recipient).forces['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(player(finished, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.deepEqual(stock(finished), stock(f.setup));
|
||||
if (kind === 'emperorRevival') {
|
||||
const legal = semutaHomeworldEmperorOrdinaryRevive(done, f.recipient);
|
||||
assert.equal(legal, 3);
|
||||
assert.equal(player(finished, f.recipient).revived, 3,
|
||||
'All three real storm casualties return through ordinary revival');
|
||||
assert.equal(player(finished, f.recipient).freeForcesRevived, 2,
|
||||
'Only the printed two ordinary free returns are granted');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${label} Homeworld: the paid Emperor share still settles natively when the allied cancellation is declined`, () => {
|
||||
for (const kind of kinds) {
|
||||
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced);
|
||||
const before = player(f.game, f.owner).spice;
|
||||
const settled = applyAction(f.game, f.canceller, { type: 'passResponse' });
|
||||
if (kind === 'emperorGift') {
|
||||
assert.equal(player(settled, f.owner).spice, before - 3);
|
||||
assert.equal(player(settled, f.recipient).spice, player(f.game, f.recipient).spice + 3);
|
||||
} else {
|
||||
assert.equal(settled.phase, 4);
|
||||
assert.equal(settled.emperorExtra[f.recipient] ?? 0, 2,
|
||||
'The accepted extra revival really grants its two-revival allowance');
|
||||
}
|
||||
assert.equal(player(settled, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true);
|
||||
assert.equal(player(settled, f.recipient).forces['arrakeen:10'], 10);
|
||||
assert.equal(settled.storm, 7);
|
||||
const finished = finishSemutaHomeworldEmperorShare(settled, f);
|
||||
assert.equal(finished.turn, 2);
|
||||
assert.equal(finished.phase, endPhase(kind));
|
||||
if (kind === 'emperorRevival')
|
||||
assert.equal(player(finished, f.recipient).revived,
|
||||
semutaHomeworldEmperorOrdinaryRevive(settled, f.recipient),
|
||||
'Only the ordinary return the remaining Tanks and allowance permit happens');
|
||||
}
|
||||
});
|
||||
|
||||
for (const kind of kinds) void test(`${label} Homeworld: full Richese hand still exchanges the Karama privately and finishes the original ${shareOf(kind)} step`, () => {
|
||||
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced, true);
|
||||
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
|
||||
const receipt = disposeSemutaHomeworldEmperorShare(f);
|
||||
assert.deepEqual(viewGame(f.game, f.canceller).responseControls?.cancelCards, [f.karama],
|
||||
'The allied recipient keeps its exact printed cancellation with a full holder hand');
|
||||
assert.equal(viewGame(receipt, f.holder).semutaReaction?.canCommit, true);
|
||||
const claimed = reactSemutaHomeworldEmperorShare(receipt, f.holder, true);
|
||||
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
|
||||
assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
|
||||
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assertConsumedDenial(claimed, f);
|
||||
assert.equal(finishSemutaHomeworldEmperorShare(claimed, f).phase, endPhase(kind));
|
||||
});
|
||||
|
||||
void test(`${label} Homeworld: every minimal bot profile legally finishes the spent Karama receipt`, () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const f = createSemutaHomeworldEmperorShareFixture('emperorRevival', advanced);
|
||||
const receipt = disposeSemutaHomeworldEmperorShare(f);
|
||||
const own = viewGame(structuredClone(receipt), f.holder);
|
||||
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(own)[0];
|
||||
assert.ok(action, `${difficulty} has an entitled receipt action`);
|
||||
assert.ok(['semutaCommit', 'semutaPass'].includes(action.type));
|
||||
let game: Game = structuredClone(receipt);
|
||||
game = applyAction(game, f.holder, action);
|
||||
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
||||
if (reaction?.stage !== 'offer') throw Error('Missing neutral discard offer');
|
||||
game = applyAction(game, game.players.find(seat => !reaction.passed.includes(seat.id))!.id,
|
||||
{ type: 'semutaPass', event: pending.batch.event });
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assertConsumedDenial(game, f);
|
||||
assert.equal(player(game, f.recipient).tanks, 3);
|
||||
assert.equal(player(game, f.recipient).forces['arrakeen:10'], 10);
|
||||
const finished = finishSemutaHomeworldEmperorShare(game, f);
|
||||
assert.equal(finished.phase, 5);
|
||||
const legal = semutaHomeworldEmperorOrdinaryRevive(game, f.recipient);
|
||||
assert.equal(legal, 3);
|
||||
assert.equal(player(finished, f.recipient).revived, 3,
|
||||
'The three ordinary returns remain legal after the denied extra revival');
|
||||
assert.equal(player(finished, f.recipient).freeForcesRevived, 2);
|
||||
assert.equal(finished.emperorExtra[f.recipient] ?? 0, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,7 @@ async function main() {
|
||||
'\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 occupation setup/history remain original. Paid Box, Distrans, definite Truthtrance, ordinary specials, Kaitain, Tupile cleanup and legal CHOAM market sales offer used cards before their original suffix. Final Ornithopter disposal keeps completed original groups; finite Once Around and clean unstarted movement Sapho disposal keeps the reordered queue/funding. Accepted losing-card, mandatory winning-card and selected winner disposal remain separate fresh events, preserving paid deployments, casualties and original rewards without replaying combat. Clean Residual Poison keeps one native random death without bounty; early Sapho aggressor and remaining Battle chooser costs keep original participants and finite priority. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. Printed CHOAM opening-income/Inflation cancellation and normal-lot Karama purchase/free-payment costs also pause before their original denial, spent attempt or zero-spice acquisition. Retained fuel/witness and credited sale spice remain unchanged. No repeated move/return/storm/payout/refund is introduced. Kull, Hajr/prior-flight integration, unresolved cyclic/partial/Guild/late Sapho timing, readied/inspected Poison costs, Advanced Harkonnen captive disclosure, other producers/modules, retrofit and public starts remain guarded.',
|
||||
'\nThe same Homeworld Semuta entry also offers printed Karama canceling native Advanced BG extra charity and genuine allied Emperor gift/extra revival. The consumed charity claim, original ally, paid deployment/casualties, wallets and unawarded revival remain committed. Wealthy Basic BG keeps its ordinary-charity guard; no new benefit or profile is granted.',
|
||||
'\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