Preserve native Ixian casualties and Moritani retention across Semuta

This commit is contained in:
Quintine committed 2026-10-12 06:13:47 +11:00
1 parent a6869b0166
commit 74768647f4
14 files changed
+879 -9

No files matched your search

+2 -1
View File
@@ -1167,7 +1167,8 @@ export function GameTable({
may remain in supported fresh 3–6-seat CHOAM/Ix profiles; the original
winner’s award follows card cleanup and precedes Face Dance. Native Moritani
support is Basic only: clean shipment/movement Sabotage and Robbery
overflow; other arrival continuations remain guarded.{' '}
overflow plus native losing-ally card-retention cleanup; other arrival
continuations remain guarded.{' '}
<a href="/rules?topic=card-richese-semuta-drug#card-richese-semuta-drug">Preview rules and limits</a>
</p>
)}
+21
View File
@@ -29,6 +29,27 @@ 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 / native casualty and retention continuations — 11 October.**
Mandatory loser discards now react while the original compulsory winner card
remains reserved. Native Ixian casualties/substitution still finish before
the used winning Tooth becomes its own fresh target. Basic native Moritani
additionally admits preceding compulsory/optional winner batches and completed
unkept allied-card disposal, preserving its original choice, Karama response,
kept card and played-card pool. Existing canonical aftermath validation and
reciprocal alliance checks bind the pending native opportunity; pending Ixian
substitution, `battleResolved` with retention, Advanced Moritani and Nexus
retention remain guarded. No module/profile or mode gate changed.
Targeted cases fail before and pass after; 162 affected unit cases across
thirteen files pass. Eighty-six isolated runtime cases prove both native modes,
actual ordering, claim/pass/four legal profiles, typed substitution/decline and
original cancellation. Real390px tables claim Snooper, confirm1/1 and inspect
the final Ixian counters; native Basic retention declines, commits and selects
Maula Pistol from two fresh cards. Both reach original phase7 with no overflow
or repeated effect. Types/lint passed; one fixture narrowing and the obsolete
Hero timing assumption were repaired rather than changing native behavior.
See [native retention contract](MORITANI_ALLY_RETENTION_RULES.md#semuta-composition--11-october-2026)
and [Semuta ordering evidence](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime).
**Semuta / original native Tech — 11 October.** Fresh three-through-six-seat
Basic/Advanced Richese games with CHOAM and optional Ix now retain original
unused Tech under existing native roster prerequisites. Printed and first-Storm
+49
View File
@@ -1,5 +1,54 @@
# Dune implementation status
## 11 October 2026 — native casualty and allied-retention Semuta continuations
Two disjoint native slices supply genuine setup fixtures and consumer-visible
regressions; one owner integrates shared engine, table and guidance changes.
The actual Ixian blocker was the original compulsory-winner queue, not a
discard inside active substitution: loser disposal precedes winner casualties,
then native substitution/decline precedes winning used Tooth disposal. Allowing
that initial resolved-battle batch preserves the reserved Tooth and optional
Shield. The pending Ixian-substitution exclusion stays intact.
Basic native Moritani now admits winner compulsory/optional batches before the
original choose-stage retention and its completed unkept-card batch afterward.
Existing canonical aftermath validation plus reciprocal alliance binds the
preceding native opportunity. The original selected kept card, unrelated hand
card and a newly acquired winner card never enter another claim/retention pool.
Native Karama finishes before final disposal. All losing played cards are
reserved here, so `battleResolved` with retention remains gated, as do Advanced
native Moritani and Nexus retention. No original handler, source ruling,
module/profile composition or public mode gate is changed.
Types/lint pass; 162 unit cases across thirteen affected files pass. The initial
native cases reproduce missing offers. A test fixture lacked a physical rival
Karama but expected its response; conserving one original Karama repairs that
fixture. A nullable-shape assertion became a public no-reaction assertion and
one captured union narrowing was repaired. The original Hero consumer test now
passes the preceding loser offer before claiming the later reserved Hero;
the former immediate-Hero assumption was obsolete, not an engine regression.
Setup/default-copy tests and the proposed malformed-save campaign were removed.
Forty-eight Ix runtime cases cover both modes, both fresh boundaries,
claim/pass/four legal profiles and substitution/decline; four more cancel
the original exchange with a real rival Karama. Typed counters, payment,
casualties, winner and physical Treachery inventory remain intact once.
Thirty-four Moritani cases cover keep either/none, both preceding winner
frames, claim/pass/four legal profiles and native enemy/ally allowance/cancel.
The first throwaway probe mixed IDs from independently shuffled fixtures;
binding each action to its actual fixture corrected that probe only.
Real GameTable at390px claims Snooper, confirms1Suboid/1Cyborg, allows native
Karama, completes later Tooth acknowledgements and keeps Shield. Actual force
inspection shows board1Suboid+1Cyborg, Tanks1Suboid and reserves17 at the original
phase7 Ix opening, with Snooper still Richese-owned. Basic native retention
declines, commits and privately selects Maula Pistol from two fresh cards, then
reaches phase7 without another retention choice. Both surfaces have no
horizontal overflow. Warning, card tooltips, internal reference and contracts
match these boundaries. Temporary probes/services/tabs removed; existing games,
storage, user PDF and deployment untouched. Complete Semuta/mode acceptance
remains unfinished.
## 11 October 2026 — original native Tech after Semuta battle discards
The fresh CHOAM/optional-Ix Semuta entry now retains original unused Tech at
+23
View File
@@ -104,3 +104,26 @@ The engine now implements this flow with `moritaniRetention` and the shared `can
`spendKarama` and `cashInCards` reject reserved cards, including during winner cleanup and nested Worthless conversion. The interface and bot response choices also honor the reservation. A public declared-card projection enables response-stage inspection without exposing unrelated hand contents. The linked internal reference and general battle-card guide now explain the exception.
Eighteen focused real-battle/server tests and five AI tests cover the integrated ability. The full suite reached935 passes;45 persisted multiplayer checks, TypeScript, lint and build passed. Independent review additionally exercised both nested Worthless cancellation and counter-cancellation through JSON reloads. Desktop browser QA completed declaration, response refresh and final exact-card settlement in S85SLVRR. Complete Moritani/expansion acceptance remains unfinished and faction starts remain gated.
## Semuta composition — 11 October 2026
The existing Basic Richese/Moritani CHOAM/Ecaz entry now offers Semuta after
completed native allied-card disposal. The losing ally's original choice and
native Karama response finish first; only unkept played cards form the fresh
batch. A sole card completes automatically after commitment; multiple cards
require one private selection. Kept and unrelated hand cards remain unavailable.
Compulsory/optional winner-card batches may also pause before the original
choose-stage retention. Existing aftermath validation and reciprocal alliance
checks preserve that native opportunity. A loser acquiring the winner's card
through Semuta cannot add it to the original played/eligible pool. All losing
played cards are reserved in this envelope, so no losing `battleResolved`
batch precedes retention; that distinct pending-retention path stays gated.
Advanced native Moritani and Nexus/Skills compositions are not opened.
Thirty-four isolated runtime cases cover keep either/none, claim/pass/four legal
AI profiles, both preceding winner batches and original enemy/ally Karama
allowance or cancellation. Actual GameTable at390px declines native retention,
commits Semuta, privately selects Maula Pistol from two fresh cards and reaches
phase7 without another retention choice or horizontal overflow. No source
ruling, original native behavior outside the Semuta entry or public gate changed.
+13 -1
View File
@@ -202,7 +202,19 @@ once. Native capture, Face Dance, quoted CHOAM force income and Auditor remain
pending across the offer; original native Tech is also supported in the fresh
three-to-six-seat CHOAM/optional-Ix entry. Token custody stays unchanged until
card cleanup completes and the original winner takes one loser-owned token,
still before Face Dance. Other rewards remain automatic without Semuta.
still before Face Dance. The original compulsory winner-card queue may remain
reserved during the earlier loser batch. Native Ixian casualties/substitution
finish before the used winning Tooth becomes its own later fresh discard.
Basic native Moritani additionally admits preceding compulsory/optional winner
batches with the original choose-stage allied retention still pending, then
the completed `battleCleanup` with `source: 'moritani'`. The chosen kept card
never enters that batch. Native Karama finishes first; claim/pass neither
re-offers retention nor expands its original played-card pool. Canonical
aftermath validation and reciprocal alliance bind the preceding native choice.
`battleResolved` with pending retention, Nexus retention, Advanced native
Moritani and a pending Ixian substitution remain outside this bounded offer.
Other rewards remain automatic without Semuta.
The explicit Richese preview also accepts a fresh physical Ix deck.
Clean Thumper disposal stages `thumperDiscard` before `blowSpice`
+29
View File
@@ -636,3 +636,32 @@ claims Hunter Seeker, offers Axlotl Tanks/Heighliners to Atreides, takes only
Heighliners, then restores Tleilaxu's Decline Face Dancer control. Phase7 original
Ix opening retains the card and all three owners with no horizontal overflow.
This is local changed-path evidence, not complete Semuta or deployed acceptance.
**11 October 2026 — native casualties and allied retention.** A completed
mandatory loser-card batch may now offer Semuta with the original compulsory
winning-card queue still reserved. Native Ixian casualty settlement and
one-for-one substitution keep their original position before the winning used
Tooth is physically discarded. That later Tooth is a separate fresh opportunity
before optional Shield cleanup, not an early target at the first reaction.
The pending Ixian-substitution guard remains: no actual discard was moved into
that active decision. Forty-eight runtime scenarios cover both modes, both
discard boundaries, claim/pass/four legal profiles and substitution/decline;
four more preserve the native rival Karama cancellation instead of substituting.
Actual 390px GameTable claims Snooper, confirms1/1, allows the original power,
retires the later Tooth offer and keeps Shield. The force inspector proves
Arrakeen1Suboid+1Cyborg, Tanks1Suboid, reserves17, with the claimed Snooper still
held at phase7 original Ix opening and no horizontal overflow.
The Basic native Moritani entry now offers its completed unkept-card batch,
plus preceding compulsory/optional winner batches while the original native
choose-stage retention remains pending. Existing canonical aftermath validation
and reciprocal alliance checks preserve the original choice. Kept/unrelated
cards and a newly Semuta-acquired winner card cannot enter its original pool.
Native Karama still finishes first; taking one fresh card cannot repeat the
choice, combat or bounty. `battleResolved` with pending retention, Advanced
native Moritani and Nexus retention remain separate. Thirty-four runtime cases
cover keep either/none, claim/pass/four legal profiles, both preceding winner
frames and original enemy/ally Karama allowance/cancellation. Real390px table
declines native retention, commits and privately selects Maula Pistol from the
two fresh cards, then reaches phase7 without re-offering retention or overflow.
These are local bounded continuation proofs, not complete Semuta/mode acceptance.
+2 -2
View File
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
if (definition?.card.effect === 'distrans')
return 'Choose a recipient and card in the Distrans transfer panel.';
if (definition?.card.effect === 'semutaDrug')
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor; original cleanup and the same opportunity resume once, using the current audited hand when relevant. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. A Basic Richese/Moritani CHOAM/Ecaz profile reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La pause after cost without completing the stopped move or free revival. Proactive/final-Mentat Trip to Gamont preserves the exact ordinary/elite return or native No-Field reveal and capped recall, restoring its final owner window when applicable. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.';
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor; original cleanup and the same opportunity resume once, using the current audited hand when relevant. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after its original choice and Karama response. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La pause after cost without completing the stopped move or free revival. Proactive/final-Mentat Trip to Gamont preserves the exact ordinary/elite return or native No-Field reveal and capped recall, restoring its final owner window when applicable. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.';
return definition && definition.card.effect !== 'karama'
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
: null;
@@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
richese.card.effect === 'juiceOfSapho'
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
: richese.card.effect === 'semutaDrug'
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor) and private normal-auction or Black Market Ixian ally replacements. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. The Basic Richese/Moritani CHOAM/Ecaz entry adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.'
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor) and private normal-auction or Black Market Ixian ally replacements. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after the original choice and Karama response, preserving its kept card and unchanged original retention pool. The Basic Richese/Moritani CHOAM/Ecaz entry also adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.'
: richese.card.effect === 'mirrorWeapon'
? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.'
: richese.card.effect === 'stoneBurner'
+18 -3
View File
@@ -6652,9 +6652,19 @@ function treacheryDiscardIntegrity(g: Game) {
batch.entries.every((entry) => entry.discardedBy === c.owner && !entry.publicFace),
'The committed Kaitain discard no longer matches its payment and closing opportunity.');
} else throw new RuleError('Unknown saved discard continuation.');
if (pending.reaction && g.moritaniRetention &&
(continuation.kind === 'winnerMandatoryDiscard' ||
(continuation.kind === 'battleCleanup' && continuation.source === 'winner'))) {
const retention = g.moritaniRetention;
const owner = getPlayer(g, retention.owner), loser = getPlayer(g, retention.player);
requireRule(!g.advanced && retention.source === undefined && retention.stage === 'choose' &&
owner.faction === 'moritani' && owner.ally === loser.id && loser.ally === owner.id,
'The pending native allied retention no longer matches the preceding winner discard.');
currentBattleAftermathQuote(g);
}
}
/** Bounded public single-card reaction after completed effects. Battle cleanup
* may preserve native Tech, capture, Face Dance, CHOAM income and Auditor; other rewards stay gated.
* preserves the original winner queue and bounded native retention/rewards without replaying them.
* Final flight arrival may resume native Intrusion, but other entry responses remain gated. */
function semutaOfferSupported(
g: Game,
@@ -6684,7 +6694,10 @@ function semutaOfferSupported(
continuation.kind !== 'choamBalisetDiscard' &&
continuation.kind !== 'choamGamontDiscard' &&
continuation.kind !== 'choamJubbaDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
!(continuation.kind === 'battleCleanup' &&
(continuation.source === 'winner' ||
(!g.advanced && continuation.source === 'moritani' &&
continuation.retention?.source === undefined))) &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
!(continuation.kind === 'ornithopterDiscard' &&
@@ -6702,7 +6715,9 @@ function semutaOfferSupported(
if ((continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved') &&
(g.pendingWinnerDiscards || g.moritaniRetention ||
((g.pendingWinnerDiscards && continuation.kind !== 'battleResolved') ||
(g.moritaniRetention && (g.advanced || g.moritaniRetention.source !== undefined ||
g.moritaniRetention.stage !== 'choose' || continuation.kind === 'battleResolved')) ||
g.pendingIxSubstitution)) return false;
if (continuation.kind === 'terrorDiscard' &&
(continuation.entry.resume !== 'none' ||
+2
View File
@@ -1177,6 +1177,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'Mandatory and winner battle discards also pause before quoted Advanced CHOAM force-payment income. Claim or Continue leaves the original amount unpaid through casualties and card cleanup, then opens the same allow/Karama response. Allow pays once; cancellation sends that income to the bank without refunding the original force payments. The original post-battle market still completes afterward.',
'Mandatory and winner battle discards also pause before a native Auditor opportunity. Claim or Continue preserves the original survival limit and opponent-used card identities, then eligibility and the full cancellation price use the current opposing hand. A card acquired from the other combatant can be eligible if the audited player did not use it. Payment or private inspection resumes once; only CHOAM receives the historical snapshot.',
'Fresh three-through-six-seat Basic/Advanced Richese games with CHOAM and optional Ix may retain original unused Tech Tokens under the existing native roster prerequisites. Mandatory and winner discards now offer Semuta before the original winner takes one loser-owned token. Claim or Continue leaves every token and income ledger in place until original cleanup finishes; the winner’s selected award still precedes Face Dance. Skills, Strongholds, other overlays and the separate Basic Moritani entry remain excluded.',
'A mandatory loser-card discard may also offer Semuta while the original winner’s compulsory used card remains reserved. For native Ixians, claim or Continue settles the original casualties, then restores the one-for-one Suboid/Cyborg substitution and its Karama response. The winning used Poison Tooth is not discarded until substitution or decline finishes; only then is that physical Tooth a separate fresh target before optional winner cleanup.',
'In the Basic native Moritani entry, winner compulsory/optional card discards may pause before the losing ally’s original retention choice. After that ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch then offers Semuta. A kept or unrelated hand card is never a target. Multiple fresh cards require commitment and one private selection; taking a winner’s card does not add it to the original retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.',
'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.',
+156
View File
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
export type SemutaIxStep = { actor: string; action: Action };
export interface SemutaIxFixture {
game: Game;
holder: string;
winner: string;
loser: string;
territory: string;
location: string;
semuta: string;
tooth: string;
shield: string;
snooper: string;
cards: string[];
}
export const semutaIxPlayer = (game: Game, id: string) => game.players.find(p => p.id === id)!;
export const semutaIxInventory = (game: Game) => [...game.deck, ...game.discard, ...game.richeseCache!,
...game.players.flatMap(p => p.hand)].map(card => card.id).sort();
export function semutaIxPhysical(game: Game) {
return game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, battleLosses: p.battleLosses, spice: p.spice }));
}
/** Original native lobby, private Ix card choice, Traitors and actual first Storm/HMS placement. */
export function initializeSemutaIxGame(advanced = false): Game {
let game = createGame('SEMUTAIX', newPlayer('r', 'Richese', 'richese'), advanced, ['choam', 'ix']);
joinGame(game, newPlayer('i', 'Ixians', 'ixians'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const p of game.players) { p.bot = 'Medium'; p.ready = true; }
game = initializeSemutaGameForAudit(game);
for (let step = 0; (game.status === 'setup' || game.phase === 1 || !game.mobileStronghold?.location) && step < 160; step++) {
let next: Game | undefined;
for (const p of game.players) {
const action = botActions(viewGame(game, p.id))[0];
if (action) { next = applyAction(game, p.id, action); break; }
}
assert.ok(next, 'Original Ix setup and first Storm require a legal native continuation.');
game = next;
}
assert.equal(game.status, 'playing');
assert.ok(game.mobileStronghold?.location);
assert.equal(game.richeseCache!.length, 10);
assert.equal(semutaIxInventory(game).length, 57, 'Original union-47 deck plus separate Richese ten-card cache.');
return game;
}
/** Later rule position uses conserved native pieces, not a synthetic Game or replacement players. */
export function semutaIxBattleFixture(advanced = false): SemutaIxFixture {
const game = initializeSemutaIxGame(advanced);
const cards = semutaIxInventory(game);
assert.equal(new Set(cards).size, cards.length);
for (const p of game.players) {
game.deck.push(...p.hand); p.hand = [];
p.reserves += p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0);
p.forces = {}; p.tanks = 0; p.battleLosses = 0;
if (p.elites) {
p.elites.reserves += p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
p.elites.forces = {}; p.elites.tanks = 0;
}
}
const take = (id: string) => {
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
const index = zone.findIndex(c => c.id === id);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error(`Missing original physical ${id}`);
};
const semuta = take(SEMUTA_DRUG_ID);
const tooth = take(game.deck.find(c => c.kind === 'poisonTooth')!.id);
const shield = take(game.deck.find(c => c.kind === 'shield')!.id);
const snooper = take(game.deck.find(c => c.kind === 'snooper')!.id);
semutaIxPlayer(game, 'r').hand.push(semuta);
// A real rival-held Karama keeps the native substitution response actionable.
semutaIxPlayer(game, 'r').hand.push(take(game.deck.find(c => c.effect === 'karama')!.id));
semutaIxPlayer(game, 'i').hand.push(tooth, shield);
semutaIxPlayer(game, 'e').hand.push(snooper);
const location = 'arrakeen:10';
const ix = semutaIxPlayer(game, 'i');
assert.equal(ix.reserves, 20); assert.equal(ix.elites!.reserves, 7);
ix.reserves -= 3; ix.forces[location] = 3;
ix.elites!.reserves--; ix.elites!.forces[location] = 1;
const emperor = semutaIxPlayer(game, 'e');
emperor.reserves -= 5; emperor.forces[location] = 5;
Object.assign(game, { turn: 2, phase: 6, active: 'i', order: ['i', 'e', 'r'], storm: 18,
ready: [], response: null, decision: null, phaseOpening: null, movementRemaining: [] });
assert.deepEqual(semutaIxInventory(game), cards);
return { game, holder: 'r', winner: 'i', loser: 'e', territory: 'arrakeen', location,
semuta: semuta.id, tooth: tooth.id, shield: shield.id, snooper: snooper.id, cards };
}
/** Export exact native plan actions for CLI/UI proof; one supported Cyborg is the sole dial-two casualty. */
export function semutaIxPlanSteps(game: Game, f: SemutaIxFixture): SemutaIxStep[] {
return [
{ actor: f.winner, action: { type: 'battlePlan', dial: 2, support: game.advanced ? 1 : 0,
leader: semutaIxPlayer(game, f.winner).leaders.find(l => !l.dead)!.id, weapon: f.tooth, defense: f.shield } },
{ actor: f.loser, action: { type: 'battlePlan', dial: 0, support: 0,
leader: semutaIxPlayer(game, f.loser).leaders.find(l => !l.dead)!.id, defense: f.snooper } },
];
}
/** Stops at the LOSER'S physical Snooper discard, before winner casualties or substitution. */
export function semutaIxLoserDiscardFixture(advanced = false): SemutaIxFixture {
const f = semutaIxBattleFixture(advanced);
let game = applyAction(f.game, f.winner, { type: 'chooseBattle', territory: f.territory, target: f.loser });
for (let step = 0; step < 40; step++) {
if (game.response) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id))!;
game = applyAction(game, actor.id, { type: 'passResponse' });
} else if (game.battle?.preparation)
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
else if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
else break;
}
for (const actor of [f.winner, f.loser])
game = applyAction(game, actor, { type: 'battlePreparationReady', event: game.battle!.preLeader!.event });
for (const step of semutaIxPlanSteps(game, f)) game = applyAction(game, step.actor, step.action);
assert.equal(game.decision?.kind, 'poisonTooth');
game = applyAction(game, f.winner, { type: 'decision', activate: true });
for (const actor of [f.winner, f.loser]) game = applyAction(game, actor, { type: 'traitorCall', call: false });
return { ...f, game };
}
export function semutaIxPassDiscard(game: Game): Game {
const pending = game.pendingTreacheryDiscard!;
assert.equal(pending.reaction?.stage, 'offer');
const event = pending.batch.event;
for (const p of game.players) game = applyAction(game, p.id, { type: 'semutaPass', event });
return game;
}
export function semutaIxSubstitutionStep(f: SemutaIxFixture, substitute: boolean): SemutaIxStep {
return { actor: f.winner, action: substitute
? { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } }
: { type: 'decision', decline: true } };
}
export function semutaIxAllowResponses(game: Game): Game {
for (let step = 0; game.response && step < 12; step++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id))!;
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
return game;
}
/** Used Tooth discard is correctly AFTER native substitution, never offered before it. */
export function semutaIxWinnerDiscardFixture(advanced = false, substitute = true): SemutaIxFixture {
const f = semutaIxLoserDiscardFixture(advanced);
let game = semutaIxPassDiscard(f.game);
assert.equal(game.decision?.kind, 'ixSubstitution');
const step = semutaIxSubstitutionStep(f, substitute);
game = semutaIxAllowResponses(applyAction(game, step.actor, step.action));
return { ...f, game };
}
+148
View File
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import type { Difficulty } from '../game/bot-profiles';
import type { Card } from '../game/cards';
export type SemutaMoritaniStep = { actor: string; action: Action };
export type SemutaMoritaniOptions = { winnerMandatory?: boolean; karamaHolder?: 'r' | 'm' | 'w' | 'l'; semutaHolder?: 'r' | 'l' };
export type SemutaMoritaniFixture = {
game: Game; semuta: string; weapon: string; defense: string;
winnerWeapon: string; winnerDefense: string; winnerHero: string | null;
unrelated: string; karama: string | null; cards: string[];
loserLeader: string; loserStrength: number; winnerLeader: string;
};
export const semutaMoritaniReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
export function semutaMoritaniPlayer(game: Game, id: string) {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
}
export function semutaMoritaniInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
assert.equal(new Set(cards).size, cards.length); return cards;
}
export function semutaMoritaniResources(game: Game) {
return game.players.map(({ id, spice, reserves, tanks, forces, leaders, ally }) => ({
id, spice, reserves, tanks, forces, leaders, ally,
}));
}
export function semutaMoritaniAct(game: Game, step: SemutaMoritaniStep, actions?: SemutaMoritaniStep[]) {
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
export function semutaMoritaniPolicy(game: Game, actor: string, difficulty: Difficulty) {
const view = viewGame(game, actor), player = view.players.find(p => p.id === actor); assert.ok(player); player.bot = difficulty;
return botActions(view);
}
/** Genuine Basic Richese/Moritani + classic opponents; no optional modules. */
export function initializeSemutaMoritaniRetentionGame(): Game {
let game = createGame('SEMUTAMORITANI', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ecaz']);
joinGame(game, newPlayer('m', 'Moritani', 'moritani'));
joinGame(game, newPlayer('w', 'Emperor', 'emperor'));
joinGame(game, newPlayer('l', 'Guild', 'guild'));
for (const player of game.players) { player.bot = 'Medium'; player.ready = true; }
game = initializeSemutaGameForAudit(game);
for (let step = 0; game.status === 'setup' && step < 160; step++) {
let next: SemutaMoritaniStep | undefined;
for (const player of game.players) {
const action = botActions(viewGame(game, player.id))[0];
if (action) { next = { actor: player.id, action }; break; }
}
assert.ok(next, 'Native original setup must offer a legal continuation.');
game = semutaMoritaniAct(game, next);
}
assert.equal(game.status, 'playing'); assert.equal(game.phase, 0);
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, ['choam', 'ecaz']);
assert.ok(game.moritaniTerror); assert.equal(game.moritaniAssassinate == null, true);
assert.equal(game.techTokens == null, true); assert.equal(game.leaderSkills == null, true);
assert.equal(game.strongholdCards == null, true); assert.equal(game.homeworlds == null, true);
assert.equal(game.nexusCards?.cards == null, true); assert.equal(game.ecazTreachery == null, true);
return game;
}
/** Later reciprocal alliance and conserved physical battle are explicitly staged,
* not asserted to have arisen naturally in the original setup. */
export function semutaMoritaniRetentionFixture(options: SemutaMoritaniOptions = {}): SemutaMoritaniFixture {
const game = initializeSemutaMoritaniRetentionGame();
game.deck.push(...game.players.flatMap(p => p.hand));
for (const player of game.players) player.hand = [];
const take = (predicate: (card: Card) => boolean): Card => {
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
const index = zone.findIndex(predicate); if (index >= 0) return zone.splice(index, 1)[0];
}
throw Error('Missing physical original fixture card.');
};
const hold = (owner: string, predicate: (card: Card) => boolean) => {
const card = take(predicate); semutaMoritaniPlayer(game, owner).hand.push(card);
return card.id;
};
const semuta = hold(options.semutaHolder ?? 'r', c => c.id === SEMUTA_DRUG_ID);
const weapon = hold('l', c => c.kind === 'projectile'), defense = hold('l', c => c.kind === 'snooper');
const unrelated = hold('l', c => c.kind === 'worthless');
const winnerWeapon = hold('w', c => c.kind === 'projectile'), winnerDefense = hold('w', c => c.kind === 'shield');
const winnerHero = options.winnerMandatory ? hold('w', c => c.kind === 'hero') : null;
const karama = options.karamaHolder ? hold(options.karamaHolder, c => c.effect === 'karama') : null;
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
assert.equal(player.tanks, 0); assert.equal(player.reserves, 20);
}
for (const id of ['w', 'l']) {
const player = semutaMoritaniPlayer(game, id); player.reserves -= 5; player.forces['arrakeen:10'] = 5;
}
semutaMoritaniPlayer(game, 'm').ally = 'l'; semutaMoritaniPlayer(game, 'l').ally = 'm';
Object.assign(game, { turn: 2, phase: 6, storm: 18, active: 'w', order: ['w', 'l', 'm', 'r'],
ready: [], response: null, decision: null, phaseOpening: null, stormPending: null, movementRemaining: [] });
const winnerLeader = semutaMoritaniPlayer(game, 'w').leaders.reduce((best, leader) => leader.strength > best.strength ? leader : best).id;
const loser = semutaMoritaniPlayer(game, 'l').leaders[0];
return { game, semuta, weapon, defense, winnerWeapon, winnerDefense, winnerHero,
unrelated, karama, cards: semutaMoritaniInventory(game), winnerLeader,
loserLeader: loser.id, loserStrength: loser.strength };
}
export function resolveSemutaMoritaniBattle(f: SemutaMoritaniFixture, actions?: SemutaMoritaniStep[]): Game {
let game = semutaMoritaniAct(f.game, { actor: 'w', action: { type: 'chooseBattle', territory: 'arrakeen', target: 'l' } }, actions);
for (let step = 0; step < 40; step++) {
if (game.response) game = semutaMoritaniAct(game, { actor: game.players.find(p => !game.response!.passed.includes(p.id))!.id, action: { type: 'passResponse' } }, actions);
else if (game.battle?.preparation) game = semutaMoritaniAct(game, { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } }, actions);
else if (game.decision?.kind === 'fullPlanOffer') game = semutaMoritaniAct(game, { actor: game.decision.player, action: { type: 'decision', decline: true } }, actions);
else break;
}
assert.ok(game.battle?.preLeader);
for (const actor of ['w', 'l']) game = semutaMoritaniAct(game, { actor, action: { type: 'battlePreparationReady', event: game.battle!.preLeader!.event } }, actions);
game = semutaMoritaniAct(game, { actor: 'w', action: { type: 'battlePlan', dial: 2, support: 0,
leader: f.winnerHero ?? f.winnerLeader, weapon: f.winnerWeapon, defense: f.winnerDefense } }, actions);
game = semutaMoritaniAct(game, { actor: 'l', action: { type: 'battlePlan', dial: 0, support: 0,
leader: f.loserLeader, weapon: f.weapon, defense: f.defense } }, actions);
for (const actor of ['w', 'l']) game = semutaMoritaniAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions);
return game;
}
export function passSemutaMoritaniDiscard(game: Game, actions?: SemutaMoritaniStep[]): Game {
const event = game.pendingTreacheryDiscard!.batch.event;
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
for (const actor of game.players.map(p => p.id)) game = semutaMoritaniAct(game, { actor, action: { type: 'semutaPass', event } }, actions);
return game;
}
export function claimSemutaMoritaniDiscard(game: Game, card?: string, actions?: SemutaMoritaniStep[]): Game {
const event = game.pendingTreacheryDiscard!.batch.event;
game = semutaMoritaniAct(game, { actor: 'r', action: { type: 'semutaCommit', event } }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
assert.ok(card, 'A multiple fresh-card batch needs an exact committed selection.');
game = semutaMoritaniAct(game, { actor: 'r', action: { type: 'semutaSelect', event, card } }, actions);
}
return game;
}
export function finishSemutaMoritaniWinner(game: Game, discard: string[] = [], actions?: SemutaMoritaniStep[]): Game {
assert.equal(game.decision?.kind, 'battleCards');
return semutaMoritaniAct(game, { actor: 'w', action: { type: 'decision', discard } }, actions);
}
export function chooseSemutaMoritaniRetention(game: Game, keep: string | null, actions?: SemutaMoritaniStep[]): Game {
assert.equal(game.decision?.kind, 'moritaniRetention');
return semutaMoritaniAct(game, { actor: 'l', action: { type: 'decision', keep } }, actions);
}
export function allowSemutaMoritaniResponse(game: Game, actions?: SemutaMoritaniStep[]): Game {
for (let step = 0; game.response && step < 40; step++) {
game = semutaMoritaniAct(game, { actor: game.players.find(p => !game.response!.passed.includes(p.id))!.id, action: { type: 'passResponse' } }, actions);
}
assert.equal(game.response, null); return game;
}
+7 -2
View File
@@ -1815,7 +1815,7 @@ void test('Semuta settles mandatory and winner discards before the original Hark
}
});
void test('a mandatory winner Hero discard can be claimed before optional battle cleanup', () => {
void test('a mandatory winner Hero is reserved through the loser offer and claimable before optional cleanup', () => {
const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [];
g.deck.push(hajr);
@@ -1852,7 +1852,12 @@ void test('a mandatory winner Hero discard can be claimed before optional battle
battle = applyAction(battle, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(battle, 'e').leaders[0].id, defense: defense.id });
battle = applyAction(battle, 'a', { type: 'traitorCall', call: false });
const pending = applyAction(battle, 'e', { type: 'traitorCall', call: false });
let pending = applyAction(battle, 'e', { type: 'traitorCall', call: false });
assert.equal(player(pending, 'a').hand.some(card => card.id === hero.id), true);
assert.equal(pending.discard.some(card => card.id === hero.id), false);
const loserEvent = pending.pendingTreacheryDiscard!.batch.event;
for (const id of ['e', 'a', 'r'])
pending = applyAction(pending, id, { type: 'semutaPass', event: loserEvent });
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [hero.id]);
+166
View File
@@ -0,0 +1,166 @@
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 { DIFFICULTIES } from '../game/bot-profiles';
import { semutaIxLoserDiscardFixture, semutaIxWinnerDiscardFixture,
semutaIxPlayer, semutaIxInventory, semutaIxPhysical, semutaIxPassDiscard,
semutaIxSubstitutionStep, semutaIxAllowResponses, type SemutaIxFixture } from './fixture-semuta-ix-substitution';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function assertCasualties(game: Game, f: SemutaIxFixture, substitute: boolean) {
const ix = semutaIxPlayer(game, f.winner), emperor = semutaIxPlayer(game, f.loser);
assert.deepEqual(ix.forces, { [f.location]: 2 });
assert.equal(ix.elites!.forces[f.location] ?? 0, Number(substitute));
assert.equal(ix.reserves, 17);
assert.equal(ix.elites!.reserves, 6);
assert.equal(ix.tanks, 1);
assert.equal(ix.elites!.tanks, substitute ? 0 : 1);
assert.equal(ix.battleLosses, 1, 'One casualty remains one casualty when its type is exchanged.');
assert.deepEqual(emperor.forces, {});
assert.equal(emperor.reserves, 15);
assert.equal(emperor.tanks, 5);
assert.equal(emperor.battleLosses, 5);
assert.equal(game.lastBattleContext!.winner, f.winner);
assert.equal(game.lastBattleContext!.result, 'normal');
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'ixians' ? 7 : 5);
}
assert.deepEqual(semutaIxInventory(game), f.cards);
}
function finishShieldCleanup(game: Game, f: SemutaIxFixture): Game {
assert.equal(game.decision?.kind, 'battleCards');
if (game.decision?.kind !== 'battleCards') throw new Error('Original winner cleanup was not restored.');
assert.equal(game.decision.player, f.winner);
assert.deepEqual(game.decision.cards, [f.shield]);
game = applyAction(game, f.winner, { type: 'decision', discard: [f.shield] });
if (game.pendingTreacheryDiscard) game = semutaIxPassDiscard(game);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingIxSubstitution, null);
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(game.discard.filter(c => c.id === f.shield).length, 1);
assert.equal(game.phase, 7);
return game;
}
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`Semuta ${mode} reacts to actual loser discard before native Ix losses/substitution, then disposes Tooth once`, () => {
const f = semutaIxLoserDiscardFixture(advanced), pending = f.game;
// The former guard skipped this offer while the used winning Tooth was reserved.
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.pendingTreacheryDiscard!.continuation.kind, 'battleResolved');
assert.equal(pending.pendingTreacheryDiscard!.batch.cause, 'battle:mandatory');
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.snooper]);
assert.deepEqual(pending.pendingWinnerDiscards!.cards, [f.tooth]);
assert.equal(pending.pendingIxSubstitution == null, true, 'Native substitution is staged only AFTER this reaction and winner losses.');
assert.equal(semutaIxPlayer(pending, f.winner).forces[f.location], 3);
assert.equal(semutaIxPlayer(pending, f.winner).tanks, 0);
assert.equal(pending.discard.filter(c => c.id === f.snooper).length, 1);
assert.equal(pending.discard.some(c => c.id === f.tooth), false);
const event = pending.pendingTreacheryDiscard!.batch.event;
const premature = semutaIxSubstitutionStep(f, true);
assert.throws(() => applyAction(reload(pending), premature.actor, premature.action));
assert.throws(() => applyAction(reload(pending), f.winner, { type: 'decision', decline: true }));
for (const claim of [false, true]) for (const substitute of [false, true]) {
let game = claim ? applyAction(reload(pending), f.holder, { type: 'semutaCommit', event })
: semutaIxPassDiscard(reload(pending));
assert.equal(game.decision?.kind, 'ixSubstitution');
assert.equal(game.decision?.player, f.winner);
assert.deepEqual(game.pendingIxSubstitution!.losses, { [f.location]: 1 });
assert.deepEqual(game.pendingIxSubstitution!.cards, [f.shield]);
assertCasualties(game, f, false);
assert.equal(semutaIxPlayer(game, f.winner).hand.some(c => c.id === f.tooth), true);
assert.equal(game.discard.some(c => c.id === f.tooth), false);
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.snooper), claim);
assert.throws(() => applyAction(reload(game), f.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(reload(game), f.winner, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } }));
const step = semutaIxSubstitutionStep(f, substitute);
game = applyAction(reload(game), step.actor, step.action);
if (substitute) {
assert.equal(game.response?.kind, 'ixSubstitution');
assertCasualties(game, f, false);
game = semutaIxAllowResponses(reload(game));
}
assertCasualties(game, f, substitute);
assert.equal(game.pendingIxSubstitution, null);
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(game.discard.filter(c => c.id === f.tooth).length, 1);
// Finish any later neutral Tooth opportunity before optional Shield cleanup.
if (game.pendingTreacheryDiscard) {
assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'winnerMandatoryDiscard');
assert.deepEqual(game.pendingTreacheryDiscard.batch.entries.map(e => e.card.id), [f.tooth]);
assert.throws(() => applyAction(reload(game), step.actor, step.action));
if (claim) assert.throws(() => applyAction(reload(game), f.holder,
{ type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event }));
game = semutaIxPassDiscard(reload(game));
}
assert.throws(() => applyAction(reload(game), step.actor, step.action));
const physical = reload(game);
assert.throws(() => applyAction(game, f.holder, { type: 'semutaPass', event }));
assert.deepEqual(game, physical);
game = finishShieldCleanup(reload(game), f);
assertCasualties(game, f, substitute);
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.snooper), claim);
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claim));
assert.throws(() => applyAction(reload(game), step.actor, step.action));
}
});
void test(`Semuta ${mode} claims or passes used Tooth only after original substitution/decline and resumes shield cleanup`, () => {
for (const substitute of [false, true]) {
const f = semutaIxWinnerDiscardFixture(advanced, substitute), pending = f.game;
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard');
assertCasualties(pending, f, substitute);
const event = pending.pendingTreacheryDiscard!.batch.event;
for (const claim of [false, true]) {
let game = claim ? applyAction(reload(pending), f.holder, { type: 'semutaCommit', event })
: semutaIxPassDiscard(reload(pending));
assert.deepEqual(semutaIxPhysical(game), semutaIxPhysical(pending), 'Semuta never replays support, bounty, losses or substitution.');
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.tooth), claim);
assert.equal(game.discard.filter(c => c.id === f.tooth).length, Number(!claim));
assert.equal(game.discard.filter(c => c.id === f.snooper).length, 1);
assert.equal(game.lastBattleContext!.winnerDiscards!.completed, true);
game = finishShieldCleanup(reload(game), f);
assertCasualties(game, f, substitute);
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.tooth), claim);
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claim));
assert.throws(() => applyAction(reload(game), f.holder, { type: 'semutaCommit', event }));
const step = semutaIxSubstitutionStep(f, true);
assert.throws(() => applyAction(reload(game), step.actor, step.action));
}
}
});
void test(`Semuta ${mode} all four original bot profiles legally participate in fresh discard and typed Ix aftermath`, () => {
const f = semutaIxLoserDiscardFixture(advanced);
for (const profile of DIFFICULTIES) {
let game = reload(f.game);
for (const p of game.players) p.bot = profile;
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
let sawIx = false;
for (let step = 0; game.phase === 6 && step < 80; step++) {
if (game.decision?.kind === 'ixSubstitution') sawIx = true;
let next: Game | undefined;
for (const p of game.players) {
const action = botActions(viewGame(game, p.id))[0];
if (action) { next = applyAction(reload(game), p.id, action); break; }
}
assert.ok(next, `${profile} must offer a legal native continuation.`);
game = next;
}
assert.equal(sawIx, true);
assert.equal(game.phase, 7);
assert.equal(game.pendingIxSubstitution, null);
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(semutaIxPlayer(game, f.winner).forces[f.location], 2);
assert.equal(semutaIxPlayer(game, f.winner).tanks, 1);
assert.equal(semutaIxPlayer(game, f.loser).tanks, 5);
assert.deepEqual(semutaIxInventory(game), f.cards);
}
});
}
+243
View File
@@ -0,0 +1,243 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
semutaMoritaniRetentionFixture,
resolveSemutaMoritaniBattle, finishSemutaMoritaniWinner, chooseSemutaMoritaniRetention,
allowSemutaMoritaniResponse, claimSemutaMoritaniDiscard, passSemutaMoritaniDiscard,
semutaMoritaniPlayer as player, semutaMoritaniInventory as inventory,
semutaMoritaniResources as resources, semutaMoritaniReload as reload,
semutaMoritaniPolicy, type SemutaMoritaniFixture,
} from './fixture-semuta-moritani-retention';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function retentionOffer(f: SemutaMoritaniFixture) {
const game = finishSemutaMoritaniWinner(resolveSemutaMoritaniBattle(f));
assert.equal(game.decision?.kind, 'moritaniRetention'); assert.equal(game.decision?.player, 'l');
assert.equal(game.moritaniRetention?.owner, 'm'); assert.equal(game.moritaniRetention?.source, undefined);
assert.deepEqual(game.moritaniRetention!.played, [f.weapon, f.defense]);
assert.deepEqual(game.moritaniRetention!.eligible, [f.weapon, f.defense]);
for (const actor of ['r', 'm', 'w']) {
const view = viewGame(reload(game), actor);
assert.equal(view.players.find(p => p.id === 'l')!.hand, undefined);
assert.equal(JSON.stringify(view.moritaniRetention).includes(f.unrelated), false);
assert.deepEqual(view.moritaniRetention?.played, [f.weapon, f.defense]);
}
rejected(game, 'm', { type: 'decision', keep: f.weapon });
rejected(game, 'l', { type: 'decision', keep: f.unrelated });
rejected(game, 'l', { type: 'decision', keep: [f.weapon, f.defense] });
return game;
}
function finalQuantities(game: Game, f: SemutaMoritaniFixture, before: Game, keep: string | null, claimed: string | null, karamaSpent = false) {
assert.equal(game.phase, 7); assert.equal(game.battle, null); assert.equal(game.moritaniRetention, null);
assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.response, null);
assert.equal(game.decision, null);
assert.equal(player(game, 'w').tanks, 2); assert.deepEqual(player(game, 'w').forces, { 'arrakeen:10': 3 });
assert.equal(player(game, 'l').tanks, 5); assert.deepEqual(player(game, 'l').forces, {});
assert.equal(player(game, 'w').reserves, 15); assert.equal(player(game, 'l').reserves, 15);
for (const id of ['r', 'm']) { assert.equal(player(game, id).tanks, 0); assert.equal(player(game, id).reserves, 20); }
for (const id of ['r', 'm', 'l']) assert.equal(player(game, id).spice, player(f.game, id).spice);
assert.equal(player(game, 'w').spice, player(f.game, 'w').spice + f.loserStrength);
assert.equal(player(game, 'l').leaders.find(l => l.id === f.loserLeader)!.dead, true);
assert.deepEqual(resources(game), resources(before), 'Reaction must not repeat combat losses, bounty, payment or leader death.');
for (const card of [f.weapon, f.defense]) {
assert.equal(player(game, 'l').hand.some(c => c.id === card), card === keep);
assert.equal(player(game, 'r').hand.some(c => c.id === card), card === claimed);
assert.equal(game.discard.some(c => c.id === card), card !== keep && card !== claimed);
}
assert.equal(player(game, 'l').hand.some(c => c.id === f.unrelated), true);
assert.equal(player(game, 'r').hand.some(c => c.id === f.semuta), claimed === null);
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claimed !== null));
if (f.karama) assert.equal(game.discard.filter(c => c.id === f.karama).length, Number(karamaSpent));
assert.deepEqual(inventory(game), f.cards);
}
for (const choice of ['weapon', 'defense', 'none'] as const) for (const claim of [false, true]) {
void test(`Semuta ${claim ? 'claims' : 'passes'} completed Moritani discard after keeping ${choice}, without re-offering cleanup`, () => {
const f = semutaMoritaniRetentionFixture(), offered = retentionOffer(f);
const keep = choice === 'none' ? null : f[choice];
let pending = chooseSemutaMoritaniRetention(reload(offered), keep);
pending = allowSemutaMoritaniResponse(pending);
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const receipt = pending.pendingTreacheryDiscard!, event = receipt.batch.event;
assert.equal(receipt.batch.cause, 'battle:moritani'); assert.equal(receipt.continuation.kind, 'battleCleanup');
if (receipt.continuation.kind !== 'battleCleanup') throw Error('Expected original typed battle cleanup.');
assert.equal(receipt.continuation.source, 'moritani'); assert.equal(receipt.continuation.player, 'l');
assert.deepEqual(receipt.continuation.kept, keep ? [keep] : []);
assert.deepEqual(receipt.continuation.played, [f.weapon, f.defense]);
assert.equal(receipt.continuation.retention?.owner, 'm');
assert.equal(pending.moritaniRetention, null);
const fresh = [f.weapon, f.defense].filter(card => card !== keep);
assert.deepEqual(receipt.batch.entries.map(e => e.card.id), fresh);
assert.equal(receipt.batch.entries.every(e => e.publicFace && e.discardedBy === 'l'), true);
assert.equal(fresh.every(card => pending.discard.some(c => c.id === card)), true, 'Physical discard precedes the reaction.');
for (const id of ['r', 'm', 'w', 'l']) assert.deepEqual(viewGame(reload(pending), id).semutaReaction?.candidates, []);
rejected(pending, 'l', { type: 'decision', keep });
rejected(pending, 'r', { type: 'semutaSelect', event, card: fresh[0] });
let final: Game;
if (claim) {
let committed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
if (fresh.length === 2) {
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.deepEqual(viewGame(committed, 'r').semutaReaction!.candidates.map(c => c.id), fresh);
for (const id of ['m', 'w', 'l']) assert.deepEqual(viewGame(committed, id).semutaReaction!.candidates, []);
rejected(committed, 'm', { type: 'semutaSelect', event, card: fresh[0] });
rejected(committed, 'r', { type: 'semutaSelect', event, card: f.unrelated });
rejected(committed, 'r', { type: 'semutaPass', event });
committed = applyAction(reload(committed), 'r', { type: 'semutaSelect', event, card: fresh[0] });
}
final = committed;
} else final = passSemutaMoritaniDiscard(reload(pending));
finalQuantities(final, f, pending, keep, claim ? fresh[0] : null);
rejected(final, 'l', { type: 'decision', keep });
rejected(final, 'r', { type: 'semutaCommit', event });
rejected(final, 'r', { type: 'semutaSelect', event, card: fresh[0] });
if (keep) {
const committed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
rejected(committed, 'r', { type: 'semutaSelect', event, card: keep });
assert.equal(player(committed, 'l').hand.some(c => c.id === keep), true);
}
});
}
for (const holder of ['w', 'l'] as const) for (const cancel of [false, true]) {
void test(`Original retention Karama response permits ${holder} to ${cancel ? 'cancel' : 'allow'} before Semuta sees the final batch`, () => {
const f = semutaMoritaniRetentionFixture({ karamaHolder: holder }), offered = retentionOffer(f);
const response = chooseSemutaMoritaniRetention(reload(offered), f.weapon);
assert.equal(response.response?.kind, 'moritaniRetention'); assert.equal(response.response?.owner, 'm');
assert.equal(viewGame(response, 'w').semutaReaction, null);
assert.equal(viewGame(response, 'w').moritaniRetentionCard?.id, f.weapon);
rejected(response, 'l', { type: 'card', card: f.weapon, mode: 'discard' });
const settled = cancel
? applyAction(reload(response), holder, { type: 'card', card: f.karama!, mode: 'cancel' })
: allowSemutaMoritaniResponse(reload(response));
assert.equal(settled.pendingTreacheryDiscard?.batch.cause, 'battle:moritani');
assert.equal(settled.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(settled.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), cancel ? [f.weapon, f.defense] : [f.defense]);
const expected = cancel ? f.weapon : f.defense;
const final = claimSemutaMoritaniDiscard(reload(settled), expected);
finalQuantities(final, f, settled, cancel ? null : f.weapon, expected, cancel);
});
}
void test('Moritani cannot cancel its own alliance power with its own unreserved Karama', () => {
const f = semutaMoritaniRetentionFixture({ karamaHolder: 'm' }), offered = retentionOffer(f);
const pending = chooseSemutaMoritaniRetention(offered, f.weapon);
assert.equal(pending.response, null, 'Only the owner holds Karama: original automatic allowance applies.');
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:moritani');
rejected(pending, 'm', { type: 'card', card: f.karama!, mode: 'cancel' });
const final = claimSemutaMoritaniDiscard(pending);
finalQuantities(final, f, pending, f.weapon, f.defense);
assert.equal(player(final, 'm').hand.some(c => c.id === f.karama), true);
});
void test('Winner optional fresh cleanup pauses with the later native retention choice still reserved', () => {
const f = semutaMoritaniRetentionFixture();
const pending = finishSemutaMoritaniWinner(resolveSemutaMoritaniBattle(f), [f.winnerWeapon]);
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
assert.equal(pending.moritaniRetention?.stage, 'choose');
assert.equal(pending.moritaniRetention?.player, 'l');
for (const claim of [false, true]) {
const resumed = claim ? claimSemutaMoritaniDiscard(reload(pending)) : passSemutaMoritaniDiscard(reload(pending));
assert.equal(resumed.decision?.kind, 'moritaniRetention');
assert.deepEqual(resumed.moritaniRetention, pending.moritaniRetention);
assert.deepEqual(resources(resumed), resources(pending));
let final = chooseSemutaMoritaniRetention(resumed, f.weapon);
if (claim) {
assert.equal(final.pendingTreacheryDiscard?.reaction?.stage, 'offer');
final = passSemutaMoritaniDiscard(final);
} else final = claimSemutaMoritaniDiscard(final);
assert.equal(final.phase, 7); assert.equal(final.moritaniRetention, null);
assert.equal(player(final, 'r').hand.some(c => c.id === (claim ? f.winnerWeapon : f.defense)), true);
assert.deepEqual(inventory(final), f.cards); assert.deepEqual(resources(final), resources(pending));
}
});
void test('Winner compulsory Cheap Hero batch precedes optional cleanup and Moritani retention without replaying losses', () => {
const f = semutaMoritaniRetentionFixture({ winnerMandatory: true }), pending = resolveSemutaMoritaniBattle(f);
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:winnerMandatory');
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.winnerHero]);
assert.equal(pending.moritaniRetention?.stage, 'choose');
for (const claim of [false, true]) {
let resumed = claim ? claimSemutaMoritaniDiscard(reload(pending)) : passSemutaMoritaniDiscard(reload(pending));
assert.equal(resumed.decision?.kind, 'battleCards');
assert.deepEqual(resumed.moritaniRetention, pending.moritaniRetention);
assert.deepEqual(resources(resumed), resources(pending));
resumed = finishSemutaMoritaniWinner(resumed);
assert.equal(resumed.decision?.kind, 'moritaniRetention');
const discarded = chooseSemutaMoritaniRetention(resumed, f.weapon);
assert.equal(discarded.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const final = passSemutaMoritaniDiscard(discarded);
assert.equal(final.phase, 7); assert.equal(final.moritaniRetention, null);
assert.equal(player(final, 'r').hand.some(c => c.id === f.winnerHero), claim);
assert.equal(final.discard.some(c => c.id === f.winnerHero), !claim);
assert.deepEqual(resources(final), resources(pending)); assert.deepEqual(inventory(final), f.cards);
assert.equal(player(final, 'w').tanks, 2); assert.equal(player(final, 'l').tanks, 5);
}
});
void test('All four bot profiles participate legally in native retention and sole/multiple Semuta batches', () => {
const f = semutaMoritaniRetentionFixture(), offered = retentionOffer(f);
for (const profile of DIFFICULTIES) {
const choice = semutaMoritaniPolicy(reload(offered), 'l', profile)[0]; assert.ok(choice);
const chosen = applyAction(reload(offered), 'l', choice);
assert.equal(chosen.moritaniRetention == null || chosen.moritaniRetention.stage === 'response', true);
for (const keep of [f.weapon, null]) {
let pending = chooseSemutaMoritaniRetention(reload(offered), keep);
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
for (let step = 0; pending.pendingTreacheryDiscard && step < 8; step++) {
const reaction = pending.pendingTreacheryDiscard.reaction; assert.ok(reaction);
const actor = reaction.stage === 'select' ? reaction.player
: pending.players.find(p => !reaction.passed.includes(p.id))!.id;
const action = semutaMoritaniPolicy(reload(pending), actor, profile)[0]; assert.ok(action);
pending = applyAction(reload(pending), actor, action);
}
assert.equal(pending.pendingTreacheryDiscard, null); assert.equal(pending.phase, 7);
assert.deepEqual(inventory(pending), f.cards);
}
}
});
void test('Multiple discarded Moritani cards permit either exact fresh selection, never the unrelated hand card', () => {
const f = semutaMoritaniRetentionFixture(), pending = chooseSemutaMoritaniRetention(retentionOffer(f), null);
const event = pending.pendingTreacheryDiscard!.batch.event;
for (const card of [f.weapon, f.defense]) {
const committed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.deepEqual(resources(committed), resources(pending));
const final = applyAction(reload(committed), 'r', { type: 'semutaSelect', event, card });
finalQuantities(final, f, pending, null, card);
}
});
void test('Defeated ally claiming the winner card cannot add it to its original retention pool', () => {
const f = semutaMoritaniRetentionFixture({ semutaHolder: 'l' });
const pending = finishSemutaMoritaniWinner(resolveSemutaMoritaniBattle(f), [f.winnerWeapon]);
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const event = pending.pendingTreacheryDiscard!.batch.event;
const resumed = applyAction(reload(pending), 'l', { type: 'semutaCommit', event });
assert.equal(resumed.decision?.kind, 'moritaniRetention');
assert.deepEqual(resumed.moritaniRetention!.played, [f.weapon, f.defense]);
assert.deepEqual(resumed.moritaniRetention!.eligible, [f.weapon, f.defense]);
assert.equal(player(resumed, 'l').hand.some(c => c.id === f.winnerWeapon), true);
assert.equal(resumed.discard.filter(c => c.id === f.semuta).length, 1);
assert.deepEqual(resources(resumed), resources(pending));
rejected(resumed, 'l', { type: 'decision', keep: f.winnerWeapon });
const discarded = chooseSemutaMoritaniRetention(reload(resumed), f.weapon);
assert.deepEqual(discarded.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.defense]);
const final = passSemutaMoritaniDiscard(discarded);
assert.equal(final.phase, 7); assert.equal(final.moritaniRetention, null);
assert.deepEqual(player(final, 'l').hand.map(c => c.id).sort(), [f.unrelated, f.weapon, f.winnerWeapon].sort());
assert.equal(final.discard.filter(c => c.id === f.semuta).length, 1);
assert.equal(final.discard.filter(c => c.id === f.defense).length, 1);
assert.deepEqual(inventory(final), f.cards); assert.deepEqual(resources(final), resources(pending));
rejected(final, 'l', { type: 'decision', keep: f.weapon });
rejected(final, 'l', { type: 'semutaCommit', event });
});