Offer Semuta on Tleilaxu revival income cancellation costs

This commit is contained in:
Quintine committed 2026-10-26 00:34:43 +11:00
1 parent 70c95f2686
commit 2431370abb
8 files changed
+513 -1

No files matched your search

+29
View File
@@ -16,6 +16,35 @@ open mode gates.
## Current checkpoint and work
**Semuta on Tleilaxu revival-income cancellation — 26 October. Prototyped / Partial.**
Printed-role Karama stopping clean native ordinary/Ghola income now offers its
exact used cost through `karamaIncomeDiscard`. Claim/pass denies the original
income once while retaining completed returns, payment and allowance/free-income
history. Native validation owns the recipient/amount; a subtype-only signature
binds those ledgers without changing earlier income signatures. Existing controls
and minimal profiles serve the new producer. Unfinished battle, Ambassador,
market and arrival parents remain separate.
[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#revival-income-cancellation-costs--26-october-2026).
One native-source lane, bounded read-only continuation review and root integration
pass **15 files,285/285**, types/lint/build and **eight native engine/GameTable
SSR/real Semuta-callback journeys** on unchanged source. Eleven new cases cover
Basic/Advanced ordinary/Ghola claim/pass, original allowed income, stale/foreign/
underlying replies and all four minimal profiles. Genuine paid cache1 Semuta,
dealt Karama/Ghola, paid shipment4 and next-Storm two-force casualties precede
the real return. Ordinary201/211 source actions pay2, use normal2/free1 and offer
income3; Ghola205/214 pays0, keeps normal quotas0 and offers income1 after five
real earlier discard passes. Reserves20/Tanks0 and all57 cards remain physical.
Fixture feedback repairs explicit reaction typing/narrowing. An existing Ghola
consumer now explicitly passes the new counter-cost group before its completion
assertions. No production rule guard is weakened. Private source-bound report:
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-revival-reaction-typed/`;
journeys/HTML: adjacent `20261026-semuta-revival-runtime/`.
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
These are engine/SSR/callback results, not hydration, DOM/browser/mobile or
deployed evidence. Wider producers/combinations, full modes and final assurance
remain unfinished; continue the remaining rule/checklist gaps.
**Semuta on auction-advantage cancellation costs — 26 October. Prototyped / Partial.**
Printed-role Karama stopping Atreides inspection, Ixian pool preparation or
Advanced Technology now offers its exact used cost before native prevention.
+36
View File
@@ -1,5 +1,41 @@
# Dune implementation status
## 26 October 2026 — Semuta claims Tleilaxu revival-income cancellation costs
**Prototyped / Partial.** Printed-role Karama stopping clean native ordinary or
Ghola `revivalIncome` now offers that used physical cost before native denial.
The existing `karamaIncomeDiscard` resumes once. Completed returns, actual fees,
normal/free/elite quota use, leader/cycle state and free-income history remain.
The native terminal quote validates owner/recipient/amount; a subtype-only signed
branch binds revival ledgers while earlier income signatures keep their format.
Quiet Ghola income is not restricted to phase4. Existing persistent controls and
minimal legal bots serve the new producer. Battle, Ambassador, market and other
unfinished arrival/return parents retain their separate boundaries.
[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#revival-income-cancellation-costs--26-october-2026).
One native-source lane, bounded continuation review and root integration pass
**15 files,285/285**, types/lint/build and **eight native engine/GameTable SSR/
actual Semuta-callback journeys** on unchanged source. Eleven new cases cover
Basic/Advanced ordinary/Ghola claim/pass, original allowance, stale/foreign/
underlying rejection and all four minimal profiles.
Real cache1 Semuta, original Karama/Ghola deals, paid shipment4 and next-Storm
two-force casualties precede the return. Ordinary201/211 Basic/Advanced actions
pay2 and use normal2/free1 for income3; Ghola205/214 pays0, preserves normal
quotas0 and passes its earlier fresh discard with all five original seats before
income1. Reserves20/Tanks0 and all57 cards remain physical. No played-state hand,
wallet, casualty, phase or receipt is installed. Two static attempts expose test
inference/narrowing; explicit reaction typing repairs them. An existing Ghola
consumer now finishes the new counter-cost group before completion assertions.
No production rule guard is weakened.
Private source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-revival-reaction-typed/`;
journeys/HTML: adjacent `20261026-semuta-revival-runtime/`.
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
These are engine/SSR/callback results; hydration, DOM/browser/mobile, broader
combinations, full modes and deployed/final assurance remain open.
## 26 October 2026 — Semuta claims auction-advantage cancellation costs
**Prototyped / Partial.** Printed-role Karama stopping Atreides inspection,
+55
View File
@@ -2,6 +2,61 @@
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
## Revival-income cancellation costs — 26 October 2026
**Prototyped / Partial.** Printed-role Karama stopping clean native Tleilaxu
`revivalIncome` now offers that exact cost before the original income denial.
Reuse `karamaIncomeDiscard` and its existing persistent neutral controls, private
claim/pass and minimal legal profiles. The native terminal cancellation quote
validates its Tleilaxu owner, seated recipient and positive amount. A recipient
may be Tleilaxu itself; quiet Ghola income is not restricted to Revival phase.
This applies the existing physical Semuta face and fresh-discard convention,
not a new publisher income ruling.
Ordinary or Ghola revival is already committed. Its actual returned physical
pieces, paid spice, normal/free/elite allowances, leader/cycle/Ghola state,
Axlotl accrual and free-income history remain. A subtype-only signature binds
these ledgers and the original response; earlier income signatures omit that
branch. Claim/pass retires the fresh group and runs native income prevention
once without another return, fee, Tleilaxu income, draw or producer action.
Ghola's own original disposal is a separate fresh group; passing it may retain
Semuta for the later counter's cost. Recovering that cost cannot recover Ghola
or undo its use.
Unfinished revival, Homeworld-return, battle, movement/arrival, Ambassador and
CHOAM-market parents remain outside this producer. Existing native Banker
payment markers retain their own accounting suffix and ledger; this does not
certify that module combination. No broad profile or release gate opens.
[Native source](../tests/fixture-semuta-revival-income.ts) and
[engine cases](../tests/semuta-revival-income-engine.test.ts) record the exact
action paths. Wider producers/combinations, complete modes and final assurance
remain open.
Frozen **15 files,285/285**, types/lint/build and **eight actual native engine/
GameTable SSR/real Semuta-callback journeys** pass on unchanged source. Eleven
new cases cover both rules bands, ordinary/Ghola claim/pass, original allowed
income, stale/foreign/underlying rejection and all four minimal profiles.
Native sources earn paid cache1 Semuta and originally dealt Karama/Ghola.
Emperor pays4 to ship2 normal counters to Cielago North1; the real next Storm
sends them to Tanks. Ordinary201 Basic/211 Advanced source actions then return2,
pay2, use normal2/free1 and offer income3. Ghola205/214 returns2 for0 with normal
quotas0, then actually passes its earlier fresh discard with all five seats,
preserving Semuta for the later cost. Both finish with reserves20/Tanks0 and all
57 original cards. No played-state hand, wallet, casualty, phase or receipt is
installed. Source lotteries and unopened positions remain explicitly selected.
Two initial static attempts identify a test driver's cyclic inference and union
narrowing; explicit live-reaction typing repairs both before the related union.
The existing Ghola-income consumer now passes the new counter-cost group before
asserting finished cancellation. No production guard is weakened. Private
source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-revival-reaction-typed/`;
journeys/HTML: adjacent `20261026-semuta-revival-runtime/`.
No saved-room/database/server/reset/restart/deployment/recovery operation occurs.
Engine/SSR/callback evidence leaves hydration, DOM/browser/mobile, wider
combinations, complete modes and exact-revision deployed acceptance open.
## Auction-advantage cancellation costs — 26 October 2026
**Prototyped / Partial.** Exact printed-role Karama stopping Atreides auction
+31 -1
View File
@@ -5762,6 +5762,22 @@ function semutaIncomeKaramaResponse(g: Game, response: ResponseWindow): boolean
if (response.kind === 'guildIncome')
return g.phase === 5 && owner?.faction === 'guild' &&
Number.isSafeInteger(response.amount) && response.amount! > 0;
if (response.kind === 'revivalIncome') {
// Ordinary returns and Ghola have already committed. Only their income
// remains; other producers retain their own unfinished parent contracts.
if (response.source !== undefined || g.pendingRevival || g.pendingChoamMarketGhola ||
(g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete') ||
g.pendingHomeworldShipment || g.pendingSpiritualAdvisor ||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
g.pendingIxMove || g.pendingFremenMove || g.pendingChoamMove ||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0) return false;
try {
return terminalCancellationQuote(g,response)?.kind === 'revivalIncome';
} catch (error) {
if (error instanceof RuleError) return false;
throw error;
}
}
if (owner?.faction !== 'emperor') return false;
if (response.kind === 'emperorIncome')
return g.phase === 3 && !!g.currentAuctionSale &&
@@ -5847,6 +5863,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
const worthless = c.kind === 'karamaChoamWorthlessDiscard';
const emperorShare = c.kind === 'karamaEmperorShareDiscard';
const income = c.kind === 'karamaIncomeDiscard';
const revivalIncome = income && c.use.response.kind === 'revivalIncome';
const shipment = c.kind === 'karamaShipmentDiscard';
const conversion = c.kind === 'karamaBgConversionCancelDiscard';
const richese = c.kind === 'karamaRicheseAuctionDiscard' || c.kind === 'karamaRicheseGiftDiscard';
@@ -5886,6 +5903,19 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
percentages: g.homeworldOccupiedPercentageLedger,
} : undefined,
} : undefined,
...(revivalIncome ? { revivalIncome: {
rules:g.revivalRules, requests:g.revivalRequests, freeIncome:g.revivalFreeIncome,
free:g.freeRevival, emperor:g.emperorExtra, tech:g.techTokens,
homeworldRevival:g.homeworldRevival, return:g.homeworldRevivalReturn,
progress:g.homeworldRevivalProgress, duke:g.dukeVidal,
banker:g.spiceBankerIncome,
players:g.players.map(p => ({
id:p.id, revived:p.revived, freeForcesRevived:p.freeForcesRevived,
leaderRevived:p.leaderRevived, revivalCycle:p.revivalCycle,
ecazRevivalCohort:p.ecazRevivalCohort, gholaBlocked:p.gholaBlocked,
kwisatz:p.kwisatz, leaders:p.leaders,
})),
} } : {}),
shipment: shipment ? {
reserve: g.pendingShipment, transport: g.pendingGuildTransport,
homeworld: g.pendingHomeworldShipment, custody: g.homeworlds,
@@ -15718,7 +15748,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
: use.response.kind === 'emperorGift' || use.response.kind === 'emperorRevival'
? 'karamaEmperorShareDiscard'
: use.response.kind === 'guildIncome' || use.response.kind === 'emperorIncome' ||
use.response.kind === 'richesePurchaseIncome'
use.response.kind === 'richesePurchaseIncome' || use.response.kind === 'revivalIncome'
? 'karamaIncomeDiscard' : 'karamaBattleDiscard',
player: p.id, card: used.id, use,
resume: { response: g.response, decision: g.decision,
+3
View File
@@ -1290,6 +1290,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A printed-role Karama stopping Richese’s compulsory cache auction or allied card gift also offers its exact used cost before prevention completes. Claim or Continue resumes that original prevention once. The cache keeps its cards and frozen ordinary count, including any completed Black Market reduction; a prevented gift stays with Richese and its original parent resumes. Recovering Karama does not repeat the attempt, transfer the gift or replay a paid purchase. Existing gift retry protection remains.',
'When Semuta itself was proposed as the now-prevented allied gift, its donor may spend it to claim that counter’s used cost. Prevention first retains Semuta and restores the original parent; the same committed response then spends Semuta and takes the cost, without a second prevention or transfer to the ally. Passing keeps Semuta. Other commitments remain enforced.',
'Printed-role Karama stopping Atreides auction inspection, Ixian auction preparation or Advanced Technology also offers its exact cost before prevention. Claim or Continue resumes the original lot or ordinary draw once, without new inspection, extra Ix choice or exchange. Technology stays used and its original unseen lot remains; the native Atreides peek follows. If outgoing Semuta was selected for that now-prevented exchange, only that reservation releases so Semuta can claim the counter’s cost. Other commitments and independent knowledge remain.',
'Printed-role Karama stopping clean native Tleilaxu revival income offers that exact used cost before income prevention. The ordinary or Ghola revival has already completed: returned forces/leaders, actual payment, normal/free/elite allowance use and the free-income history stay unchanged. Claim or Continue withholds the original income once; taking its counter cannot repeat the revival or recover an already discarded Ghola. Ghola’s own earlier discard is a separate opportunity. Unfinished battle, Ambassador, market and arrival parents retain their separate boundaries.',
'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.',
'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.',
'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.',
@@ -1424,6 +1425,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/fixture-semuta-ixian-auction.ts',
'tests/semuta-ixian-auction-engine.test.ts',
'tests/semuta-prevented-gift-reservation.test.ts',
'tests/fixture-semuta-revival-income.ts',
'tests/semuta-revival-income-engine.test.ts',
'tests/semuta-homeworld-bg-charity.test.ts',
'tests/semuta-homeworld-emperor-share.test.ts',
'tests/semuta-homeworld-capture-karama.test.ts',
+195
View File
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
} from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actTruthMovement } from './fixture-truth-movement';
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type SemutaRevivalIncomeStep = { actor: string; action: Action };
export type SemutaRevivalIncomeKind = 'ordinary' | 'ghola';
export const actSemutaRevivalIncome = actTruthMovement;
export const semutaRevivalIncomePlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
};
export const semutaRevivalIncomeClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
export function semutaRevivalIncomeInventory(game: Game) {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original main/cache card retains one physical zone'); return ids;
}
/** Original optional choices are declined. Fresh Ghola/cost disposal remains
* explicit and the ordinary income counter is never synthesized. */
export function nextSemutaRevivalIncomeStep(game: Game): SemutaRevivalIncomeStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
if (!game.response && !game.phaseOpening) {
if (game.decision?.kind === 'revivalStop') return { actor: game.decision.player,
action: { type: 'decision', decline: true } };
if (!game.decision && game.status === 'playing' && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1
? actor === game.stormDialers[0] ? 17 : 0 : 1 } };
}
}
return nextSemutaRicheseCacheKarama(game);
}
export function advanceSemutaRevivalIncome(game: Game, until: (state: Game) => boolean,
actions: SemutaRevivalIncomeStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextSemutaRevivalIncomeStep(game);
assert.ok(next, 'Choose the actual Ghola or Karama fresh-discard reaction before advancing');
game = actSemutaRevivalIncome(game, next.actor, next.action, actions);
}
throw Error('The genuine revival-income programme did not reach its finite boundary');
}
/** Only this batch, preserving the original resumed income response. */
export function passSemutaRevivalIncome(game: Game, actions: SemutaRevivalIncomeStep[] = []): Game {
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
game = actSemutaRevivalIncome(game, actor.id, { type: 'semutaPass', event }, actions);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
}
export function claimSemutaRevivalIncome(game: Game, holder: string,
actions: SemutaRevivalIncomeStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
game = actSemutaRevivalIncome(game, holder, { type: 'semutaCommit', event }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaRevivalIncome(game, holder, { type: 'semutaSelect', event, card }, actions);
return game;
}
/** Allow one original income opportunity; this does not request another return. */
export function allowSemutaRevivalIncome(game: Game, actions: SemutaRevivalIncomeStep[] = []): Game {
assert.equal(game.response?.kind, 'revivalIncome');
for (let count = 0; game.response?.kind === 'revivalIncome' && count < game.players.length; count++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = actSemutaRevivalIncome(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response); return game;
}
/** Genuine native source: actual cache purchase, paid shipment, next Storm
* casualties, then original ordinary/Ghola return. No played custody, resource,
* casualty, phase, alliance, pending return or income receipt is installed. */
export function createSemutaRevivalIncomeFixture(options: { kind?: SemutaRevivalIncomeKind; advanced?: boolean } = {}) {
const kind = options.kind ?? 'ordinary', advanced = options.advanced ?? false;
const seller = 'richese', holder = 'choam', incomeOwner = 'tleilaxu', spender = 'atreides', recipient = 'emperor';
let game = createGame('SEMUTAREVIVALINCOME', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']);
for (const faction of ['choam', 'tleilaxu', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: SemutaRevivalIncomeStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaRevivalIncome(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRevivalIncome(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
else act(game.host, { type: 'start' });
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: advanced ? 'explicit-original-semuta-initializer' : 'public-basic-start' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => !p.hand.length));
const originalInventory = semutaRevivalIncomeInventory(game); assert.equal(originalInventory.length, 57);
const costCard = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
const gholaCard = game.deck.find(card => card.effect === 'ghola');
const shield = game.deck.find(card => card.kind === 'shield');
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 2);
assert.ok(costCard && gholaCard && shield); assert.equal(ordinary.length, 2);
const deal = [ordinary[0].id, ordinary[1].id, shield.id, costCard.id, gholaCard.id];
const dealOrder = deal.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { card, from, position };
});
const beforeStartingDeal = structuredClone(game), cost = costCard.id, ghola = gholaCard.id;
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), deal);
assert.equal(semutaRevivalIncomePlayer(game, incomeOwner).faceDancers?.length, 3);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
for (let position = 0; position < (advanced ? 4 : 2); position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].includes(card.territory));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
spiceOrder.push({ position, from, card: structuredClone(card) });
}
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let beforeBid: Game | undefined, bid: SemutaRevivalIncomeStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaRevivalIncomePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaRevivalIncomeStep(game); assert.ok(next);
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
}
act(next.actor, next.action);
}
assert.ok(beforeBid && bid && bidAt >= 0);
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
assert.ok(semutaRevivalIncomePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.equal(semutaRevivalIncomePlayer(beforeBid, holder).spice - semutaRevivalIncomePlayer(game, holder).spice, 1);
assert.equal(semutaRevivalIncomePlayer(game, seller).spice - semutaRevivalIncomePlayer(beforeBid, seller).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === recipient && semutaRevivalIncomeClean(state));
assert.equal(game.storm, 18);
const shipment = transition(() => {
act(recipient, { type: 'ship', territory: 'cielago_north', sector: 1, amount: 2, elite: 0, allyPayment: 0 });
reach(semutaRevivalIncomeClean);
});
assert.equal(semutaRevivalIncomePlayer(shipment.before, recipient).spice - semutaRevivalIncomePlayer(game, recipient).spice, 4);
assert.equal(semutaRevivalIncomePlayer(game, recipient).forces['cielago_north:1'], 2);
act(recipient, { type: 'endMovement' });
reach(state => state.turn === 2 && state.phase === 0 && semutaRevivalIncomeClean(state));
const storm = transition(() => {
reach(state => state.turn === 2 && state.phase === 1 && semutaRevivalIncomeClean(state));
});
assert.equal(storm.before.storm, 18); assert.ok(storm.after.storm >= 1 && storm.after.storm <= 6);
assert.equal(semutaRevivalIncomePlayer(game, recipient).tanks, 2);
assert.equal(semutaRevivalIncomePlayer(game, recipient).forces['cielago_north:1'] ?? 0, 0);
reach(state => state.turn === 2 && state.phase === 4 && semutaRevivalIncomeClean(state));
const beforeRevival = structuredClone(game), revivalAction: Action = kind === 'ordinary'
? { type: 'revive', amount: 2, elite: 0 } : { type: 'card', card: ghola, amount: 2, elite: 0 };
const revival = transition(() => {
act(recipient, revivalAction);
if (kind === 'ordinary') reach(state => state.response?.kind === 'revivalIncome' && !state.pendingTreacheryDiscard);
});
let gholaDiscard: { before: Game; steps: SemutaRevivalIncomeStep[]; after: Game } | null = null;
if (kind === 'ghola') {
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ordinaryCardDiscard');
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [ghola]);
gholaDiscard = transition(() => { game = passSemutaRevivalIncome(game, actions); });
assert.ok(semutaRevivalIncomePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
}
assert.equal(game.response?.kind, 'revivalIncome'); assert.equal(game.response?.owner, incomeOwner);
assert.equal(game.response?.recipient, recipient); assert.equal(game.response?.amount, kind === 'ordinary' ? 3 : 1);
assert.ok(!game.pendingRevival && !game.pendingTreacheryDiscard && !game.battle && !game.pendingAmbassador && !game.pendingChoamMarketGhola);
assert.equal(semutaRevivalIncomePlayer(game, recipient).tanks, 0);
assert.equal(semutaRevivalIncomePlayer(game, recipient).reserves - semutaRevivalIncomePlayer(beforeRevival, recipient).reserves, 2);
assert.equal(semutaRevivalIncomePlayer(beforeRevival, recipient).spice - semutaRevivalIncomePlayer(game, recipient).spice,
kind === 'ordinary' ? 2 : 0);
assert.ok(semutaRevivalIncomePlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaRevivalIncomeInventory(game), originalInventory);
const source = structuredClone(game), amount = game.response!.amount!, action: Action = { type: 'card', card: cost, mode: 'cancel' };
return { game, source, beforeCost: source, kind, advanced, holder, spender, recipient, payer: recipient,
incomeOwner, owner: incomeOwner, seller, cost, karama: cost, card: cost, semuta: SEMUTA_DRUG_ID, ghola, amount, action,
lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder, originalSpice, spiceOrder,
acquisition, acquired, shipment, storm, beforeRevival, revivalAction, revival, gholaDiscard,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Public bare Basic Richese/CHOAM/Tleilaxu/Atreides/Emperor start, or the original Advanced Semuta initializer on its ready undealt lobby, retains original union47/cache10. Only unopened starting-card/Spice positions and scalar lotteries are selected; Atreides actually receives its printed Karama and Emperor its Ghola.',
'CHOAM actually buys cache Semuta for1 before any later source. Legal original first Storm dials17+0 end18; Emperor pays4 to ship2 original normal reserves into exposed Cielago North1. The next actual native Storm sweeps sector1 and places those two physical counters in Tanks. No casualty, wallet, force, phase or history is installed.',
kind === 'ordinary' ? 'At clean turn2 Revival Emperor really requests2 normals. Original CHOAM free-revival choice is declined; the completed return spends2, records ordinary2/free1 and offers native Tleilaxu income3 (paid2 plus the first free-event reward1).' : 'At clean turn2 Revival Emperor really plays its originally dealt Ghola for2 normals, preserving ordinary quotas and paying0. Every seat actually passes that earlier original Ghola fresh-discard group; paid-cache Semuta stays held for the later Karama group. Native Ghola income1 is still conditional.',
'Stop at clean native revivalIncome before Atreides spends the actual printed cancellation cost. Completed counters/payment/allowance/free-income stay native; Ambassador, market, battle and other unfinished parents are absent.',
] };
}
export type SemutaRevivalIncomeFixture = ReturnType<typeof createSemutaRevivalIncomeFixture>;
+5
View File
@@ -58,6 +58,11 @@ for (const claims of [false, true]) for (const canceled of [false, true])
game = canceled
? act(game, f.counterOwner, { type: 'card', card: f.counter, mode: 'cancel' })
: allow(game);
if (canceled) {
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'karamaIncomeDiscard');
game = pass(game);
assert.equal(game.pendingTreacheryDiscard, null);
}
assert.equal(game.response, null);
assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!canceled));
assert.equal(player(game, f.owner).spice, 11, 'Ghola and a counter do not refund or repay the Box');
+159
View File
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaRevivalIncomeFixture as fixture, actSemutaRevivalIncome as act,
advanceSemutaRevivalIncome as advance, passSemutaRevivalIncome as pass,
claimSemutaRevivalIncome as claim, allowSemutaRevivalIncome as allow,
semutaRevivalIncomeClean as clean, semutaRevivalIncomePlayer as player,
semutaRevivalIncomeInventory as inventory, type SemutaRevivalIncomeFixture,
} from './fixture-semuta-revival-income';
/** Include the accepted revival's physical, cash and allowance history. Hand
* changes are checked separately for the exact two-card Semuta exchange. */
const completed = (game: Game) => ({ turn: game.turn, phase: game.phase, order: game.order,
active: game.active, ready: game.ready, pendingRevival: game.pendingRevival,
revivalRules: game.revivalRules, revivalFreeIncome: game.revivalFreeIncome,
freeRevival: game.freeRevival, emperorExtra: game.emperorExtra, revivalRequests: game.revivalRequests,
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, tanks: p.tanks,
forces: p.forces, elites: p.elites, revived: p.revived, freeForcesRevived: p.freeForcesRevived,
leaderRevived: p.leaderRevived, revivalCycle: p.revivalCycle, leaders: p.leaders,
shipped: p.shipped, moved: p.moved, ally: p.ally })) });
function reject(game: Game, actor: string, proposed: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaRevivalIncomeFixture) {
assert.equal(f.setup.advanced, f.advanced); assert.equal(f.game.advanced, f.advanced);
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(f.afterSetup, f.recipient).hand.some(card => card.id === f.ghola));
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
assert.equal(player(f.shipment.before, f.recipient).spice - player(f.shipment.after, f.recipient).spice, 4);
assert.ok(f.shipment.steps.some(step => step.actor === f.recipient && step.action.type === 'ship' && step.action.amount === 2));
assert.equal(player(f.shipment.after, f.recipient).forces['cielago_north:1'], 2);
assert.equal(f.storm.before.storm, 18); assert.equal(player(f.storm.before, f.recipient).tanks, 0);
assert.equal(player(f.storm.after, f.recipient).tanks, 2);
assert.equal(player(f.storm.after, f.recipient).forces['cielago_north:1'] ?? 0, 0);
assert.equal(player(f.beforeRevival, f.recipient).tanks, 2);
assert.equal(player(f.game, f.recipient).tanks, 0);
assert.equal(player(f.game, f.recipient).reserves - player(f.beforeRevival, f.recipient).reserves, 2);
assert.equal(f.game.response?.kind, 'revivalIncome'); assert.equal(f.game.response?.owner, f.incomeOwner);
assert.equal(f.game.response?.recipient, f.recipient); assert.equal(f.game.response?.amount, f.amount);
assert.equal(f.amount, f.kind === 'ordinary' ? 3 : 1);
assert.ok(!f.game.pendingRevival && !f.game.pendingChoamMarketGhola && !f.game.pendingAmbassador && !f.game.battle);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
if (f.kind === 'ordinary') {
assert.equal(player(f.beforeRevival, f.recipient).spice - player(f.game, f.recipient).spice, 2);
assert.equal(player(f.game, f.recipient).revived, 2); assert.equal(player(f.game, f.recipient).freeForcesRevived, 1);
assert.equal(f.game.revivalFreeIncome?.[f.recipient], f.game.turn);
assert.ok(player(f.game, f.recipient).hand.some(card => card.id === f.ghola));
} else {
assert.equal(player(f.game, f.recipient).spice, player(f.beforeRevival, f.recipient).spice);
assert.equal(player(f.game, f.recipient).revived, 0); assert.equal(player(f.game, f.recipient).freeForcesRevived ?? 0, 0);
assert.equal(f.game.revivalFreeIncome?.[f.recipient], undefined);
assert.ok(f.gholaDiscard); assert.equal(f.gholaDiscard.steps.length, f.game.players.length);
assert.ok(f.gholaDiscard.steps.every(step => step.action.type === 'semutaPass'));
assert.deepEqual(new Set(f.gholaDiscard.steps.map(step => step.actor)), new Set(f.game.players.map(p => p.id)));
assert.equal(f.game.discard.filter(card => card.id === f.ghola).length, 1);
}
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaRevivalIncomeFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.continuation.kind, 'karamaIncomeDiscard');
if (pending.continuation.kind !== 'karamaIncomeDiscard') throw Error('Expected the original income cancellation cost');
assert.equal(pending.continuation.use.response.kind, 'revivalIncome');
assert.deepEqual(pending.continuation.use.response, f.game.response);
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]);
assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]);
assert.equal(game.response, null);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.deepEqual(completed(game), completed(f.game)); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); return game;
}
function denied(game: Game, f: SemutaRevivalIncomeFixture, claimed: boolean) {
assert.ok(!game.pendingTreacheryDiscard); assert.equal(game.response, null); assert.ok(!game.pendingRevival);
assert.deepEqual(completed(game), completed(f.game)); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(player(game, f.incomeOwner).spice, player(f.game, f.incomeOwner).spice);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
if (f.kind === 'ghola') assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
reject(game, f.recipient, f.revivalAction); reject(game, f.spender, f.action);
return game;
}
for (const advanced of [false, true]) for (const kind of ['ordinary', 'ghola'] as const)
for (const claimed of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} native ${kind} revival-income cost Semuta ${claimed ? 'claim' : 'pass'} preserves completed returns, payment and quotas`, () => {
const f = fixture({ advanced, kind }); nativeSource(f);
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
const game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event });
const before = player(game, f.incomeOwner).spice;
const next = advance(game, state => state.phase === 5 && clean(state));
assert.equal(player(next, f.incomeOwner).spice, before);
assert.equal(player(next, f.recipient).tanks, 0);
assert.equal(player(next, f.recipient).reserves, player(game, f.recipient).reserves);
assert.equal(player(next, f.recipient).spice, player(game, f.recipient).spice);
assert.equal(player(next, f.recipient).revived, player(game, f.recipient).revived);
assert.equal(player(next, f.recipient).freeForcesRevived, player(game, f.recipient).freeForcesRevived);
assert.deepEqual(next.revivalFreeIncome, game.revivalFreeIncome);
assert.deepEqual(inventory(next), f.originalInventory);
},
);
void test('native revival-income allowance credits the actual ordinary or Ghola amount once without another return or payment', () => {
for (const kind of ['ordinary', 'ghola'] as const) {
const f = fixture({ kind }), before = player(f.game, f.incomeOwner).spice, game = allow(structuredClone(f.game));
assert.equal(game.response, null); assert.ok(!game.pendingRevival && !game.pendingTreacheryDiscard);
assert.equal(player(game, f.incomeOwner).spice, before + f.amount);
assert.deepEqual(player(game, f.recipient), player(f.game, f.recipient));
assert.deepEqual(game.revivalFreeIncome, f.game.revivalFreeIncome);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.incomeOwner, { type: 'passResponse' });
}
});
void test('native income-cost reactions reject stale, foreign and underlying replies including the already declined Ghola batch', () => {
for (const kind of ['ordinary', 'ghola'] as const) {
const f = fixture({ kind }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event });
reject(game, f.spender, f.action); reject(game, f.incomeOwner, { type: 'passResponse' });
reject(game, f.recipient, f.revivalAction);
if (f.gholaDiscard) reject(game, f.holder, { type: 'semutaCommit', event: f.gholaDiscard.before.pendingTreacheryDiscard!.batch.event });
denied(pass(game), f, false);
}
});
void test('all four minimal profiles legally answer actual ordinary and Ghola revival-income cancellation costs', () => {
for (const advanced of [false, true]) for (const kind of ['ordinary', 'ghola'] as const) {
const f = fixture({ advanced, kind }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
let game = act(structuredClone(offered), f.holder, proposed);
const claimed = proposed.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
const selected = botActions(selecting)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
game = act(game, f.holder, selected);
} else if (game.pendingTreacheryDiscard) game = pass(game);
denied(game, f, claimed);
}
}
});