Expand Semuta and Richese Homeworld shipments
Verify and publish checkpoint container / container (push) Successful in 10m7s

This commit is contained in:
Quintine committed 2026-10-28 22:26:47 +11:00
1 parent 1ba942138d
commit 4d769af8c7
15 files changed
+626 -39

No files matched your search

+3 -2
View File
@@ -1281,8 +1281,9 @@ export function GameTable({
{g.semutaRecruitsPreview && (
<p className="notice" role="status">
Semuta Drug preview: in this independent-card game, Semuta can react
to another player discarding Recruits. Other Semuta discard
opportunities in this combination are still being implemented.{' '}
to another player discarding Recruits, Hajr, Ghola, Harvester,
Weather Control or Family Atomics. Other discard opportunities are
still being implemented.{' '}
<a href="/rules?topic=card-recruits#card-recruits">Recruits rules</a>
</p>
)}
+6 -5
View File
@@ -30,16 +30,17 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
const destination = targets.find(world => world.id === selectedDestination) ?? targets[0];
const tokens = info.private.tokens.filter(token => source ? token.id === deployed?.tokenId : token.id !== info.private!.lastShipped);
const token = tokens.find(candidate => candidate.id === selectedToken) ?? tokens[0];
const pool = source ? game.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] : undefined;
const pool = source ? game.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] :
{ normal: me.reserves - (me.elites?.reserves ?? 0), elite: me.elites?.reserves ?? 0 };
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment, source,
source ? forces : undefined);
forces);
return <fieldset disabled={busy || !!me.autopilot || !!game.homeworldShipment?.blocked}
className="space-y-3 pt-3">
<legend>Ship a concealed No-Field</legend>
<p className="fine">Provisional Homeworld rule: ship one concealed marker {source ? `from ${homeworldName(game, source)}` : 'from your supply'} to
another non-allied Homeworld. Its value stays private and its represented forces
remain in your reserves until the marker is revealed. This uses your normal shipment.
{source && ' Physical forces already on the same Homeworld may accompany it.'}</p>
{' Physical forces from the same source may accompany it.'}</p>
<label className="block space-y-1">Destination Homeworld for No-Field
<select aria-label="No-Field destination Homeworld" value={destination?.id ?? ''}
onChange={event => setSelectedDestination(event.target.value)}>
@@ -56,8 +57,8 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
{tokens.map(candidate => <option key={candidate.id} value={candidate.id}>No-Field {candidate.value}</option>)}
</select>
</label>
{source && pool && <fieldset className="space-y-2">
<legend>Accompanying forces from {homeworldName(game, source)}</legend>
{pool && <fieldset className="space-y-2">
<legend>Accompanying forces from {source ? homeworldName(game, source) : 'your reserves'}</legend>
{(['normal', 'elite'] as const).filter(kind => kind === 'normal' || pool.elite > 0).map(kind =>
<label key={kind} className="block space-y-1">
{kind === 'normal' ? 'Normal forces' : specialForceName(me.faction)} accompanying No-Field · {pool[kind]} available
+22 -3
View File
@@ -16,11 +16,28 @@ open mode gates.
## Current checkpoint and work
**Fresh No-Field with reserves to a foreign Homeworld — 28 October.
Prototyped / Partial.** In the native Basic/Advanced Homeworld preview,
Richese can send selected physical reserves with its fresh concealed marker;
one invoice and the native No-Field response cover the group. A purchased
Guild price rounds once, and physical forces use the existing typed custody.
This provisional composition keeps own-world and optional-module boundaries.
The related Semuta and Homeworld batch passes 107 cases, including genuine
allow/cancel journeys and the owner control. [Scope](RICHESE_NO_FIELD_RULES.md#fresh-marker-with-native-reserves--28-october-2026).
**Semuta after ordinary specials in Basic independent cards — 28 October.
Prototyped / Partial.** Fresh Richese/classic Basic starts with the independent
Ecaz cards can offer used Hajr, Ghola, Harvester, Weather Control and Family
Atomics to Semuta. The original effect commits once before claim/pass and its
native phase continues. Eleven original-action tests pass, including both
answers for each card and legal AI responses. Other discard producers and
roster/module combinations remain separately guarded.
**Semuta after Recruits in Basic independent cards — 28 October. Prototyped /
Partial.** Fresh Richese/classic Basic starts with the CHOAM deck and Ecaz
Treachery Cards offer the actual Recruits discard to Semuta, retaining one
committed revival benefit and the original phase. Other Semuta discards in
this independent-card combination, native expansion factions and optional
committed revival benefit and the original phase. Other unsupported Semuta
discards in this independent-card combination, native expansion factions and optional
modules remain outside this preview. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#recruits-in-the-basic-independent-card-preview--28-october-2026).
The related eleven-file union passes 118 cases and the application builds.
Two Advanced module fixtures were corrected to follow the real ordered Revival;
@@ -149,7 +166,9 @@ cases, restart/replacement and origin verification, and published both its
revision-specific image and `checkpoint`. Run 6 for `cea310b` succeeded and
published its revision tag and `checkpoint` with digest
`sha256:74340835931011be1d0be907c8db688719f05a1a1b949ba0633eff4c5fb55248`.
Run 7 was canceled before starting; run 8 for `e529cc2` is running.
Run 7 was canceled before starting; run 8 for `e529cc2` succeeded and
published digest `sha256:9a188718e46adec7ec9e4edc29fe03126c1afe2a349bb426052339a5ee4dce55`.
Run 9 was canceled before starting; run 10 for `1ba9421` is running.
Deployment remains unverified.
Existing NAS deployment and its persistent storage remain untouched.
+14 -2
View File
@@ -2,6 +2,19 @@
Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode.
## Fresh marker with native reserves — 28 October 2026
**Prototyped / Partial.** In the existing native Basic and Advanced Homeworld
preview, Richese may ship a fresh concealed marker with selected physical
forces from its own reserves to an eligible foreign non-allied Homeworld.
The entire shipment is priced as one marker plus the selected physical forces;
a purchased Guild rate halves the total and rounds once. The No-Field response
precedes payment and transfer, and physical forces move through the original
typed Homeworld custody if the shipment proceeds. The marker remains concealed
until its normal reveal. This composes the source rules cited below and is a
provisional interpretation; own-world placement and optional-module overlays
remain outside this scope.
## Existing marker with same-source Homeworld forces — 28 October 2026
**Prototyped / Partial.** In supported native Basic and Advanced Homeworld
@@ -17,8 +30,7 @@ Count the marker as one plus the selected physical counters for one shipment
price. A separately purchased Guild rate halves that total, rounding up once;
the marker is not a physical counter and does not consume the physical
twenty-counter limit. Normal payment and Advanced Guild interception remain.
No forces can be picked up from a different source. Initial placement of a
fresh marker with companions, own-world return and optional-module overlays
No forces can be picked up from a different source. Own-world return and optional-module overlays
remain unsupported.
The supplied Advanced revision permits Homeworld-to-Homeworld shipment
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Fresh No-Field with native Homeworld reserves — 28 October 2026
The supplied Advanced revision permits World-to-World shipment and counts a
concealed marker as one force, but does not specify an accompanying physical
group. The native preview provisionally permits fresh marker placement with
same-source reserves, priced together as one shipment and subject to its
original response. [Scope](RICHESE_NO_FIELD_RULES.md#fresh-marker-with-native-reserves--28-october-2026).
## Optional short alliance victory — 28 October 2026
The printed optional two-member alliance target is selectable at three
+13 -2
View File
@@ -1,5 +1,16 @@
# Semuta discard producers and continuation inventory
## Ordinary specials in Basic independent cards — 28 October 2026
**Prototyped / Partial.** The same fresh Basic Richese/classic independent-card
preview now offers used Hajr, Ghola, Harvester, Weather Control and Family
Atomics to Semuta. Each original effect is committed before the neutral
claim/pass window, and the native Movement, Revival, Spice Blow or Storm
continues afterward without replay. The ordinary source guards and physical
card ownership remain in force. Other discard producers, native expansion
factions and optional module combinations remain outside this preview. The
digital reaction ordering is provisional, as in the existing Semuta contract.
## Recruits in the Basic independent-card preview — 28 October 2026
**Prototyped / Partial.** A fresh Basic public start with Richese and classic
@@ -8,8 +19,8 @@ physical Recruits discard to Semuta. Recruits commits its doubled free rates
and seven-force ordinary limit once before the neutral claim/pass window; the
same Revival resumes afterward. The recipient may hold the recovered card but
cannot replay the effect in that turn. Existing physical cards and free-use
ledgers remain authoritative. This is a Recruits-only preview: other Semuta
discard producers in the independent-card combination remain unfinished, and
ledgers remain authoritative. Other Semuta discard producers in the
independent-card combination remain unfinished, and
other factions, decks and optional modules retain their prior boundaries.
The printed discard opportunity follows the source audit below; the order of
the completed effect and online reaction is a provisional implementation.
+10 -7
View File
@@ -2605,7 +2605,7 @@ export type Game = {
moritaniAssassinatePreview?: true;
/** Explicit development-only neutral Semuta reaction profile. */
semutaPreview?: true;
/** Fresh Basic independent-card preview for Semuta after Recruits only. */
/** Fresh Basic independent-card Semuta preview; scoped per discard source. */
semutaRecruitsPreview?: true;
moritaniAssassinate?: MoritaniAssassinateState;
moritaniAssassinateCallEvents?: string[];
@@ -9520,7 +9520,10 @@ function semutaOfferSupported(
if (continuation.kind === 'thumperDiscard' &&
(g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 ||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false;
return g.semutaPreview === true && g.status === 'playing' &&
return (g.semutaPreview === true || g.semutaRecruitsPreview === true &&
basicIndependentSemutaProfile(g) && continuation.kind === 'ordinaryCardDiscard' &&
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect)) &&
g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
entries.length > 0 &&
(ambassadorDiscard || continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' ||
@@ -37304,15 +37307,15 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
'Homeworld No-Field shipment currently supports native Homeworld play without optional modules.');
requireRule(!intent.route && !intent.guildSecretEvent && intent.sources && typeof intent.sources === 'object' &&
!Array.isArray(intent.sources) &&
(intent.noField.from === undefined ? Object.keys(intent.sources).length === 0 :
Object.keys(intent.sources).every(source => source === intent.noField!.from)) &&
Object.keys(intent.sources).every(source =>
source === (intent.noField!.from ?? 'homeworld:richese')) &&
p.noField && p.noFieldEvent === intent.noField.event &&
(intent.noField.from !== undefined || p.noFieldBlockedTurn !== g.turn),
'The original concealed Homeworld shipment is no longer available.');
const marker = intent.noField.from === undefined
? {...homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId},p.noField!)),
state:g.homeworlds.custody, players:context.players, sources:[]}
? homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,
...(Object.hasOwn(intent.sources, 'homeworld:richese') ? {forces:intent.sources['homeworld:richese']} : {})},p.noField!))
: homeworldRule(() => quoteRicheseHomeworldMarkerTransfer(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,source:intent.noField!.from!,
...(Object.hasOwn(intent.sources, intent.noField!.from!) ? {forces:intent.sources[intent.noField!.from!]} : {})},p.noField!));
+10 -11
View File
@@ -22,9 +22,8 @@ export function homeworldName(view: GameView, id: string): string {
);
}
/** Owner-only marker quote. The face stays private; the amount and invoice are
* one regardless of denomination. Authoritative timing and payment remain in
* the normal Homeworld shipment action. */
/** Owner-only marker quote. The face stays private; the marker counts as one
* regardless of denomination, plus any selected physical companions. */
export function richeseHomeworldMarkerChoice(
view: GameView,
destination: string,
@@ -71,11 +70,10 @@ export function richeseHomeworldMarkerChoice(
}
try {
if (source === undefined) {
if (forces !== undefined) {
result.blocked = 'Physical forces may accompany only a deployed Homeworld No-Field.';
return result;
}
quoteRicheseHomeworldMarkerShipment(context, custody, { player: me.id, destination, tokenId }, info.private);
const quote = quoteRicheseHomeworldMarkerShipment(context, custody,
{ player: me.id, destination, tokenId, ...(forces === undefined ? {} : { forces }) }, info.private);
result.amount = quote.amount;
result.cost = quote.cost;
} else {
const quote = quoteRicheseHomeworldMarkerTransfer(context, custody,
{ player: me.id, destination, tokenId, source, ...(forces === undefined ? {} : { forces }) }, info.private);
@@ -92,8 +90,8 @@ export function richeseHomeworldMarkerChoice(
result.blocked = 'Your spice and selected allied contribution cannot pay this shipment.';
return result;
}
const sources = source !== undefined && forces && forces.normal + forces.elite > 0
? { [source]: forces } : {};
const sources = forces && forces.normal + forces.elite > 0
? { [source ?? 'homeworld:richese']: forces } : {};
result.action = { type: 'homeworldShip', event: option.event, destination, sources,
noField: tokenId, noFieldEvent: info.event, allyPayment, ...(source === undefined ? {} : { noFieldFrom: source }) };
} catch (error) {
@@ -412,7 +410,8 @@ export function homeworldShipmentActions(
Math.max(0, 1 - (me.spice ?? 0)), source);
if (quote.action) {
actions.push(quote.action);
const pool = source && view.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id];
const pool = source ? view.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] :
{ normal: me.reserves - (me.elites?.reserves ?? 0), elite: me.elites?.reserves ?? 0 };
if (pool && (pool.normal > 0 || pool.elite > 0)) {
const forces = { normal: pool.normal > 0 ? 1 : 0, elite: pool.normal > 0 ? 0 : 1 };
const initial = richeseHomeworldMarkerChoice(view, target.world.id, token.id, 0, source, forces);
+2 -2
View File
@@ -1124,7 +1124,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld for the one-force price. On a later turn, you may ship that same concealed marker between distinct eligible foreign Homeworlds, alone or with selected physical Richese counters from the marker’s current World. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal, when available reserves are placed there. Only one marker may be deployed globally. Initial marker placement with companions, own-Homeworld placement or return, and optional-module combinations remain unavailable. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible foreign Homeworlds, alone or with selected physical Richese counters from the marker’s current World. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal, when available reserves are placed there. Only one marker may be deployed globally. Own-Homeworld placement or return and optional-module combinations remain unavailable. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
'The revealed token stays face up until another token is placed. Revealing it does not clear the previous-shipment restriction. An allied No-Field shipment reveals immediately and uses the ally’s reserves; an existing token must be revealed before another shipment.',
'Before an eligible ally uses its shipment, the game holds an offer opportunity for Richese. Richese may pass or privately propose a token, destination and payment. The ally accepts the exact offer and chooses its permitted regular/elite mix, or declines without payment. Success consumes the ally’s shipment, not Richese’s shipment or either player’s movement.',
@@ -6606,7 +6606,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.',
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. This preview covers the Recruits discard only; other Semuta discard opportunities in this combination remain unfinished.',
'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. Used Hajr, Ghola, Harvester, Weather Control and Family Atomics can also be claimed; other Semuta discard opportunities in this combination remain unfinished.',
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.',
'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.',
+24 -5
View File
@@ -9,6 +9,8 @@ export type RicheseHomeworldMarkerShipmentIntent = {
player: string;
destination: string;
tokenId: string;
/** Optional physical companions withdrawn from native Richese reserves. */
forces?: HomeworldForces;
};
export type RicheseHomeworldMarkerTransferIntent = RicheseHomeworldMarkerShipmentIntent & {
source: string;
@@ -21,10 +23,9 @@ function requireMarker(condition: unknown, message: string): asserts condition {
if (!condition) throw new HomeworldCustodyError(message);
}
/** Quote one concealed Richese marker as a single shipment, not a physical
* counter. The engine owns timing, payment, response windows and commitment.
* Nothing in the input state is mutated, and the marker's denomination never
* changes its invoice or causes reserve counters to move. */
/** A fresh concealed marker counts as one for the whole shipment invoice. Any
* physical companions are independently withdrawn from native Richese reserves;
* the marker's denomination never enters the price or physical custody. */
export function quoteRicheseHomeworldMarkerShipment(
context: HomeworldShipmentContext,
custody: HomeworldCustody,
@@ -33,9 +34,17 @@ export function quoteRicheseHomeworldMarkerShipment(
) {
// This validates all physical reserve and foreign garrison custody first.
const worlds = homeworldForceGroups(context, custody);
requireMarker(record(intent) && Object.keys(intent).sort().join(',') === 'destination,player,tokenId' &&
requireMarker(record(intent) &&
(Object.keys(intent).sort().join(',') === 'destination,player,tokenId' ||
Object.keys(intent).sort().join(',') === 'destination,forces,player,tokenId') &&
typeof intent.player === 'string' && typeof intent.destination === 'string' && typeof intent.tokenId === 'string',
'Choose a Richese player, one active Homeworld and one physical No-Field.');
if ('forces' in intent) {
requireMarker(record(intent.forces) && Object.keys(intent.forces).sort().join(',') === 'elite,normal' &&
Number.isSafeInteger(intent.forces.normal) && Number.isSafeInteger(intent.forces.elite) &&
intent.forces.normal >= 0 && intent.forces.elite >= 0,
'Choose a valid physical normal and special counter group.');
}
requireMarker(context.players.every(seat => seat.ally === null ||
typeof seat.ally === 'string' && seat.ally !== seat.id && context.players.some(other => other.id === seat.ally)),
'Homeworld shipment needs valid seated alliance identities.');
@@ -60,6 +69,16 @@ export function quoteRicheseHomeworldMarkerShipment(
} catch (error) {
throw new HomeworldCustodyError(error instanceof Error ? error.message : 'Invalid No-Field shipment.');
}
if (intent.forces && intent.forces.normal + intent.forces.elite > 0) {
const physical = quoteHomeworldShipment(context, custody, {
player: player.id, destination: destination.id, sources: { [source.id]: intent.forces },
});
const amount = 1 + physical.amount;
return { player: player.id, tokenId: intent.tokenId, source: source.id,
destination: destination.id, amount, elite: physical.elite,
cost: context.purchasedGuildRate ? Math.ceil(amount / 2) : amount, noField: deployed,
state: physical.state, players: physical.players, sources: physical.sources };
}
return {
player: player.id,
tokenId: intent.tokenId,
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import {
actBasicSemutaAmbassador, basicSemutaAmbassadorClean,
basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer,
type BasicSemutaAmbassadorStep,
} from './fixture-basic-semuta-ambassador';
export const actIndependentOrdinary = actBasicSemutaAmbassador;
export const independentOrdinaryInventory = basicSemutaAmbassadorInventory;
export const independentOrdinaryPlayer = basicSemutaAmbassadorPlayer;
export const independentOrdinaryClean = basicSemutaAmbassadorClean;
function next(game: Game): BasicSemutaAmbassadorStep | null {
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const reaction = game.pendingTreacheryDiscard.reaction;
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: {type: 'semutaPass', event: game.pendingTreacheryDiscard.batch.event} };
}
return nextRicheseStrongholdsNativeStep(game);
}
/** Continue the original phase using native player actions, never a staged jump. */
export function advanceIndependentOrdinary(game: Game, until: (state: Game) => boolean): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
const step = next(game); assert.ok(step, `Native continuation required at ${game.turn}/${game.phase}`);
game = actIndependentOrdinary(game, step.actor, step.action);
}
throw Error('The original Semuta phase did not reach its requested boundary.');
}
/** Genuine public setup and auctions: only unopened deal and Spice order are
* selected; Guild buys Semuta and Atreides buys the relevant physical card. */
export function createIndependentOrdinaryFixture(effect: 'hajr' | 'ghola' | 'weather' | 'harvester' | 'atomics') {
let game = createGame('SEMUTARECRUITS', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']);
for (const faction of ['emperor', 'richese', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const owner = 'atreides', holder = 'guild', actions: BasicSemutaAmbassadorStep[] = [];
const act = (actor: string, action: Action) => { game = actIndependentOrdinary(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => {
for (let count = 0; count < 1600; count++) {
if (until(game)) return;
assert.notEqual(game.status, 'finished');
const step = next(game); assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
act(step.actor, step.action);
}
throw Error('The original Semuta source did not reach its requested boundary.');
};
act(owner, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
act(owner, { type: 'start' });
assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true);
assert.ok(game.ecazTreachery && !game.techTokens && !game.leaderSkills && !game.nexusCards && !game.homeworlds);
const originalInventory = independentOrdinaryInventory(game);
const starting = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))
.slice(0, game.players.length).map(card => card.id);
assert.equal(starting.length, game.players.length);
const card = game.deck.find(item => item.effect === effect); assert.ok(card);
const selectedDeal = [...starting, card.id];
for (let position = 0; position < selectedDeal.length; position++) {
const from = game.deck.findIndex(card => card.id === selectedDeal[position]); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const step = next(game); assert.ok(step); act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
for (let position = 0; position < 4; position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
}
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
act('richese', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID,
method: 'onceAround', direction: 'counterclockwise' });
reach(state => independentOrdinaryClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
for (let count = 0; !independentOrdinaryPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome);
act(lot.active, { type: 'richeseBid', event: lot.event, amount: lot.active === holder ? 1 : null, allyPayment: 0 });
reach(independentOrdinaryClean);
}
assert.ok(independentOrdinaryPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
reach(state => state.phase === 3 && independentOrdinaryClean(state) && state.auction?.active === owner);
assert.equal(game.auction!.cards[game.auction!.index].id, card.id);
act(owner, { type: 'bid', amount: 1, allyPayment: 0 });
reach(state => independentOrdinaryClean(state) && independentOrdinaryPlayer(state, owner).hand.some(held => held.id === card.id));
reach(state => state.phase === 5 && state.active === owner && independentOrdinaryClean(state));
if (effect === 'hajr') return { game, owner, holder, card: card.id, originalInventory, actions,
play: {type: 'card', card: card.id} as Action };
const sector = effect === 'atomics' ? [8, 9].find(value => value !== game.storm)! : game.storm % 18 + 1;
const destination = TERRITORIES.find(place => (effect === 'atomics' ? place.id === 'shield_wall' : place.type === 'sand') &&
place.sectors.includes(sector) && game.players.every(player =>
!Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
assert.ok(destination, 'The next Storm path has an empty legal sand sector');
const casualtyLocation = `${destination.id}:${sector}`;
act(owner, { type: 'ship', territory: destination.id, sector, amount: 3, elite: 0, allyPayment: 0 });
if (effect === 'weather' || effect === 'atomics') {
reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && independentOrdinaryClean(state));
} else if (effect === 'harvester') {
reach(state => state.turn === 2 && state.phase === 1 && !!state.spiceWindow &&
!state.spiceWindow.harvested && independentOrdinaryClean(state));
} else {
reach(state => state.turn === 2 && state.phase === 4 && independentOrdinaryClean(state));
assert.equal(independentOrdinaryPlayer(game, owner).forces[casualtyLocation], undefined);
assert.equal(independentOrdinaryPlayer(game, owner).tanks, 3);
}
assert.deepEqual(independentOrdinaryInventory(game), originalInventory);
return { game, owner, holder, card: card.id, originalInventory, actions, casualtyLocation,
play: (effect === 'weather' ? {type: 'card', card: card.id, amount: 1}
: effect === 'ghola' ? {type: 'card', card: card.id, amount: 3, elite: 0}
: {type: 'card', card: card.id}) as Action };
}
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import type { Action, GameView } from '../game/engine';
import { createRicheseNoField } from '../game/richese-no-field';
import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
import { homeworldPopulations } from '../game/homeworld-population';
import * as combat from '../game/combat';
import * as options from '../game/homeworld-shipment-options';
import { HomeworldNoFieldShipment } from '../components/homeworld-shipment';
function fixture(): GameView {
const context = {advanced: false, players: [
{id: 'r', faction: 'richese' as const, reserves: 12, eliteReserves: 0, ally: null as string | null},
{id: 'a', faction: 'atreides' as const, reserves: 15, eliteReserves: 0, ally: null as string | null},
{id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null as string | null},
]};
const custody: HomeworldCustody = {salusa: null, visitors: {}};
const populations = homeworldPopulations(context, custody);
return {me: 'r', active: 'r', advanced: false, status: 'playing', phase: 5,
homeworldShipment: {event: 'homeworld-event', blocked: null}, aid: {available: 0, pledged: 0},
players: context.players.map(p => ({...p, name: p.faction, spice: p.id === 'r' ? 5 : undefined,
shipped: false, elites: {reserves: 0}})),
homeworlds: {worlds: homeworldForceGroups(context, custody).map(world => ({...world,
...populations.find(population => population.location === world.id)!}))},
richeseNoField: {owner: 'r', event: 'marker-event', private: createRicheseNoField(['zero', 'three', 'five']),
canShip: true, shipBlock: null},
} as unknown as GameView;
}
type Element = ReactElement<{children?: ReactNode; [key: string]: unknown}>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{children?: ReactNode; [key: string]: unknown}>(node)) return [];
return [node, ...elements(node.props.children)];
}
void test('fresh marker controls expose native reserve quantity and submit one mixed shipment', () => {
const view = fixture(), sent: Action[] = [], states: unknown[] = [];
const markup = renderToStaticMarkup(createElement(HomeworldNoFieldShipment, {game: view, act() {}, busy: false}));
assert.match(markup, /your reserves/); assert.match(markup, /12.*available/);
let cursor = 0;
const exports: {HomeworldNoFieldShipment?: typeof HomeworldNoFieldShipment} = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/homeworld-shipment.tsx', import.meta.url), 'utf8'), {
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX},
}).outputText, {exports, require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return {useState(initial: unknown) {
const index = cursor++; if (!(index in states)) states[index] = initial;
return [states[index], (next: unknown) => { states[index] = next; }];
}};
if (name === '@/game/combat') return combat;
if (name === '@/game/homeworld-shipment-options') return options;
if (name === './ui/button') return {Button: 'button'};
if (name === './ui/input') return {Input: 'input'};
throw Error(`Unexpected import ${name}`);
}});
assert.ok(exports.HomeworldNoFieldShipment);
const render = () => { cursor = 0; return elements(exports.HomeworldNoFieldShipment!({game: view, busy: false,
act: action => sent.push(action)})); };
for (const [label, value] of [['No-Field destination Homeworld', 'homeworld:atreides'],
['Private No-Field token', 'zero']] as const) {
const select = render().find(element => element.type === 'select' && element.props['aria-label'] === label);
assert.ok(select);
(select.props.onChange as (event: unknown) => void)({target: {value}});
}
const input = render().find(element => element.type === 'input'); assert.ok(input);
assert.equal(input.props.max, 12);
(input.props.onChange as (event: unknown) => void)({target: {value: '2'}});
const button = render().find(element => element.type === 'button'); assert.ok(button);
assert.equal(button.props.disabled, false);
(button.props.onClick as () => void)();
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), {type: 'homeworldShip', event: 'homeworld-event',
destination: 'homeworld:atreides', sources: {'homeworld:richese': {normal: 2, elite: 0}},
noField: 'zero', noFieldEvent: 'marker-event', allyPayment: 0});
});
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldShipmentActions, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
import { nextIndependentCardModulesStep } from './fixture-independent-card-modules';
const owner = 'richese', opponent = 'atreides', guild = 'guild';
const origin = 'homeworld:richese', destination = 'homeworld:atreides';
function seat(game: Game, id: string) {
const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player;
}
const quiet = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
/** Follow the actual native phase decisions and allow the real Advanced Guild
* interception. No saved phase, played card, reserve or marker is staged. */
function advance(game: Game, until: (state: Game) => boolean, trace: string[] = []): Game {
let state = game;
for (let count = 0; count < 2000; count++) {
if (until(state)) return state;
assert.notEqual(state.status, 'finished');
if (state.response) trace.push(`response:${state.response.kind}`);
if (state.decision?.kind === 'homeworldShipmentGuild') {
const decision = state.decision; trace.push(`decision:${decision.kind}`);
state = applyAction(state, decision.player, {type: 'decision', event: decision.event, allow: true});
continue;
}
const next = nextIndependentCardModulesStep(state);
assert.ok(next, `Native continuation stopped at ${state.phase}/${state.decision?.kind ?? 'no decision'}.`);
state = applyAction(state, next.actor, next.action);
}
throw Error('The real mixed Homeworld shipment did not reach its boundary.');
}
/** Original public Homeworld lobby, selecting just one undealt Karama position
* so Guild can actually exercise the marker response and Advanced stop. */
function setup(advanced: boolean): Game {
let game = createGame('RICHESENATIVEFRESHMIX', newPlayer(owner, owner, 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(opponent, opponent, 'atreides'));
joinGame(game, newPlayer(guild, guild, 'guild'));
game = applyAction(game, owner, {type: 'homeworlds', enabled: true});
for (const player of game.players) game = applyAction(game, player.id, {type: 'ready'});
game = applyAction(game, owner, advanced ? {type: 'start', advancedPreview: true} : {type: 'start'});
const card = game.deck.findIndex(candidate => candidate.effect === 'karama');
assert.ok(card >= 0 && game.deck.length >= 3);
[game.deck[2], game.deck[card]] = [game.deck[card], game.deck[2]];
game = advance(game, state => state.status === 'playing');
assert.ok(seat(game, guild).hand.some(card => card.effect === 'karama'));
return advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
!!viewGame(state, owner).homeworldShipment && !!viewGame(state, owner).richeseNoField?.canShip);
}
function inventory(game: Game) {
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
return Object.fromEntries(game.players.map(player => [player.id, player.reserves + player.tanks +
Object.values(player.forces).reduce((sum, value) => sum + value, 0) + homes
.filter(home => home.native !== player.id)
.reduce((sum, home) => sum + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0)]));
}
function declaration(game: Game) {
const own = viewGame(game, owner), info = own.richeseNoField!;
const token = info.private!.tokens.find(candidate => candidate.value === 3); assert.ok(token);
const selected = richeseHomeworldMarkerChoice(own, destination, token.id, 0, undefined, {normal: 2, elite: 0});
assert.equal(selected.amount, 3); assert.equal(selected.cost, 3); assert.ok(selected.action);
return {action: selected.action, token: token.id};
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} fresh marker and reserve companions use one paid shipment`, () => {
let game = setup(advanced);
const before = structuredClone(game), originalInventory = inventory(game), {action, token} = declaration(game);
assert.ok(seat(before, owner).spice >= 3);
for (const stale of [{...action, noFieldEvent: 'stale-marker'}, {...action, event: 'stale-shipment'}]) {
assert.throws(() => applyAction(game, owner, stale)); assert.deepEqual(game, before);
}
game = applyAction(game, owner, action);
assert.equal(game.response?.kind, 'richeseNoField');
assert.equal(seat(game, owner).spice, seat(before, owner).spice);
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
const trace: string[] = [];
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
seat(state, owner).shipped && !state.pendingHomeworldShipment, trace);
assert.ok(trace.includes('response:richeseNoField'));
assert.equal(trace.includes('decision:homeworldShipmentGuild'), advanced);
assert.equal(seat(game, owner).spice, seat(before, owner).spice - 3);
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves - 2);
assert.deepEqual(game.homeworlds!.custody!.visitors[destination]?.[owner], {normal: 2, elite: 0});
assert.deepEqual(seat(game, owner).noField!.deployed, {tokenId: token, controller: owner, location: {homeworld: destination}});
assert.equal(seat(game, owner).noField!.lastShipped, token);
assert.deepEqual(inventory(game), originalInventory);
const other = viewGame(game, opponent).richeseNoField!;
assert.equal(other.private, null); assert.equal(JSON.stringify(other.public).includes(token), false);
});
void test(`${advanced ? 'Advanced' : 'Basic'} Karama cancellation preserves the fresh mixed source and unused shipment`, () => {
let game = setup(advanced);
const before = structuredClone(game), {action} = declaration(game);
const karama = seat(game, guild).hand.find(card => card.effect === 'karama'); assert.ok(karama);
game = applyAction(game, owner, action);
assert.equal(game.response?.kind, 'richeseNoField');
game = applyAction(game, guild, {type: 'card', mode: 'cancel', card: karama.id});
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) && !state.pendingHomeworldShipment);
assert.equal(seat(game, owner).spice, seat(before, owner).spice);
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
assert.equal(seat(game, owner).shipped, false);
assert.deepEqual(seat(game, owner).noField, seat(before, owner).noField);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
assert.deepEqual(inventory(game), inventory(before));
const option = viewGame(game, owner).homeworldShipment!;
game = applyAction(game, owner, {type: 'homeworldShip', event: option.event, destination,
sources: {[origin]: {normal: 1, elite: 0}}, allyPayment: 0});
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) && seat(state, owner).shipped);
assert.equal(game.homeworlds!.custody!.visitors[destination][owner].normal, 1);
assert.equal(seat(game, owner).spice, seat(before, owner).spice - 1);
});
}
void test('all minimal bot profiles offer native legal marker candidates', () => {
const game = setup(false), view = viewGame(game, owner);
for (const difficulty of [0, 1, 2, 3]) {
const action = homeworldShipmentActions(view, difficulty).find(candidate => candidate.noField !== undefined &&
(candidate.sources as Record<string, {normal: number; elite: number}> | undefined)?.['homeworld:richese']?.normal === 1);
assert.ok(action, `Profile ${difficulty} must offer a marker with legal reserve companions when eligible.`);
assert.doesNotThrow(() => applyAction(game, owner, action));
}
});
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { GameView } from '../game/engine';
import { HomeworldCustodyError, homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
import { homeworldPopulations } from '../game/homeworld-population';
import { richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
import type { HomeworldShipmentContext } from '../game/homeworld-shipment';
import { createRicheseNoField } from '../game/richese-no-field';
import { quoteRicheseHomeworldMarkerShipment as quote,
type RicheseHomeworldMarkerShipmentIntent } from '../game/richese-homeworld-marker-shipment';
function fixture() {
const context: HomeworldShipmentContext = {advanced: false, players: [
{id: 'r', faction: 'richese', reserves: 12, eliteReserves: 0, ally: null},
{id: 'a', faction: 'atreides', reserves: 17, eliteReserves: 0, ally: null},
{id: 'g', faction: 'guild', reserves: 20, eliteReserves: 0, ally: null},
]};
const custody: HomeworldCustody = {salusa: null, visitors: {
'homeworld:atreides': {r: {normal: 2, elite: 0}},
}};
const noField = createRicheseNoField(['zero', 'three', 'five']);
const intent: RicheseHomeworldMarkerShipmentIntent = {player: 'r', destination: 'homeworld:atreides',
tokenId: 'zero', forces: {normal: 3, elite: 0}};
return {context, custody, noField, intent};
}
void test('fresh marker and paid physical companions form one detached shipment in both rulesets', () => {
for (const advanced of [false, true]) for (const tokenId of ['zero', 'three', 'five']) {
const f = fixture(); f.context.advanced = advanced; f.intent.tokenId = tokenId;
const before = structuredClone(f);
const result = quote(f.context, f.custody, f.intent, f.noField);
assert.equal(result.amount, 4); assert.equal(result.cost, 4); assert.equal(result.elite, 0);
assert.deepEqual(result.noField.deployed, {tokenId, controller: 'r', location: {homeworld: 'homeworld:atreides'}});
assert.equal(result.players?.find(player => player.id === 'r')?.reserves, 9);
assert.deepEqual(result.state?.visitors['homeworld:atreides'].r, {normal: 5, elite: 0});
assert.deepEqual(result.sources?.[0], {homeworld: 'homeworld:richese', player: 'r',
before: {normal: 12, elite: 0}, after: {normal: 9, elite: 0}});
assert.deepEqual(f, before);
result.state!.visitors['homeworld:atreides'].r.normal = 0;
assert.deepEqual(f, before);
}
});
void test('whole mixed invoice rounds once and zero companions keep the legacy quote', () => {
const f = fixture();
const legacy = {...f.intent}; delete legacy.forces;
assert.deepEqual(quote(f.context, f.custody, {...legacy, forces: {normal: 0, elite: 0}}, f.noField),
quote(f.context, f.custody, legacy, f.noField));
f.context.purchasedGuildRate = true;
for (const count of [1, 2, 3, 11, 12]) {
f.intent.forces = {normal: count, elite: 0};
const result = quote(f.context, f.custody, f.intent, f.noField);
assert.equal(result.amount, count + 1); assert.equal(result.cost, Math.ceil((count + 1) / 2));
}
});
void test('invalid companion quantities, unavailable reserves and extra sources reject without mutation', () => {
for (const forces of [
{normal: 13, elite: 0}, {normal: 1, elite: 1}, {normal: -1, elite: 0},
{normal: 0.5, elite: 0}, {normal: 1}, {normal: 1, elite: 0, extra: 0}, null,
]) {
const f = fixture();
f.intent = {...f.intent, forces} as unknown as RicheseHomeworldMarkerShipmentIntent;
const before = structuredClone(f);
assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
assert.deepEqual(f, before);
}
const f = fixture(); f.intent = {...f.intent, source: 'homeworld:guild'} as RicheseHomeworldMarkerShipmentIntent;
const before = structuredClone(f);
assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
assert.deepEqual(f, before);
});
void test('owned controls quote fresh reserve companions, exact source, affordability and rate', () => {
const f = fixture(), populations = homeworldPopulations(f.context, f.custody);
const view = {
me: 'r', active: 'r', advanced: false, status: 'playing', phase: 5,
homeworldShipment: {event: 'shipment', blocked: null}, aid: {available: 0, pledged: 0},
players: f.context.players.map(player => ({...player, name: player.faction, spice: player.id === 'r' ? 6 : undefined,
shipped: false, elites: {reserves: 0}})),
homeworlds: {worlds: homeworldForceGroups(f.context, f.custody).map(world => ({...world,
...populations.find(population => population.location === world.id)!}))},
richeseNoField: {owner: 'r', event: 'marker', private: f.noField, canShip: true},
} as unknown as GameView;
const before = structuredClone(view);
const chosen = richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 0, undefined, {normal: 3, elite: 0});
assert.equal(chosen.amount, 4); assert.equal(chosen.cost, 4);
assert.deepEqual(chosen.action, {type: 'homeworldShip', event: 'shipment', destination: 'homeworld:atreides',
sources: {'homeworld:richese': {normal: 3, elite: 0}}, noField: 'three', noFieldEvent: 'marker', allyPayment: 0});
assert.deepEqual(view, before);
const discount = {...view, karamaShipping: {player: 'r'}} as GameView;
assert.equal(richeseHomeworldMarkerChoice(discount, 'homeworld:atreides', 'three', 0, undefined,
{normal: 2, elite: 0}).cost, 2);
view.players[0].spice = 2;
assert.equal(richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 0, undefined,
{normal: 3, elite: 0}).action, null);
assert.equal(richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 0, undefined,
{normal: 13, elite: 0}).action, null);
});
+91
View File
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { type Game, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { stormSectorAfter } from '../game/disaster-rules';
import { actIndependentOrdinary as act, createIndependentOrdinaryFixture as fixture,
advanceIndependentOrdinary as advance, independentOrdinaryInventory as inventory,
independentOrdinaryPlayer as player } from './fixture-semuta-independent-ordinary';
function react(game: Game, holder: string, event: string, claim: boolean) {
if (claim) return act(game, holder, {type: 'semutaCommit', event});
for (const seat of game.players) game = act(game, seat.id, {type: 'semutaPass', event});
return game;
}
for (const effect of ['hajr', 'ghola', 'weather', 'harvester', 'atomics'] as const) for (const claim of [false, true]) {
void test(`Basic independent cards: ${effect} offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => {
const f = fixture(effect), before = structuredClone(f.game);
const oldPlayer = player(f.game, f.owner);
const pending = act(f.game, f.owner, f.play);
assert.deepEqual(f.game, before);
const frame = pending.pendingTreacheryDiscard; assert.ok(frame);
assert.equal(frame.reaction?.stage, 'offer');
assert.equal(frame.continuation.kind, 'ordinaryCardDiscard');
assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [f.card]);
assert.ok(viewGame(pending, f.holder).semutaReaction?.canCommit);
if (effect === 'hajr') assert.ok(pending.hajr.includes(f.owner));
if (effect === 'ghola') {
assert.equal(player(pending, f.owner).reserves, oldPlayer.reserves + 3);
assert.equal(player(pending, f.owner).tanks, oldPlayer.tanks - 3);
}
if (effect === 'weather') assert.equal(pending.stormPending, 1);
if (effect === 'atomics') {
assert.equal(pending.shieldWallDestroyed, true);
assert.equal(player(pending, f.owner).forces[f.casualtyLocation!], undefined);
assert.equal(player(pending, f.owner).tanks, oldPlayer.tanks + 3);
}
if (effect === 'harvester') {
assert.equal(pending.spiceWindow!.amount, f.game.spiceWindow!.amount * 2);
assert.equal(pending.spiceWindow!.harvested, true);
}
const committed = {phase: pending.phase, turn: pending.turn, storm: pending.storm,
stormPending: pending.stormPending, hajr: pending.hajr, spice: pending.spice,
spiceWindow: pending.spiceWindow, shieldWallDestroyed: pending.shieldWallDestroyed,
forces: player(pending, f.owner).forces,
reserves: player(pending, f.owner).reserves, tanks: player(pending, f.owner).tanks};
const done = react(pending, f.holder, frame.batch.event, claim);
assert.equal(done.pendingTreacheryDiscard, null);
assert.deepEqual({phase: done.phase, turn: done.turn, storm: done.storm,
stormPending: done.stormPending, hajr: done.hajr, spice: done.spice,
spiceWindow: done.spiceWindow, shieldWallDestroyed: done.shieldWallDestroyed,
forces: player(done, f.owner).forces,
reserves: player(done, f.owner).reserves, tanks: player(done, f.owner).tanks}, committed);
assert.equal(player(done, f.holder).hand.some(card => card.id === f.card), claim);
assert.equal(done.discard.some(card => card.id === f.card), !claim);
assert.equal(done.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
assert.deepEqual(inventory(done), f.originalInventory);
assert.throws(() => act(done, f.holder, {type: 'semutaCommit', event: frame.batch.event}));
if (effect === 'hajr') {
const next = act(done, f.owner, {type: 'endMovement'});
assert.ok(!next.movementRemaining?.includes(f.owner));
} else if (effect === 'weather' || effect === 'atomics') {
const next = advance(done, state => state.turn === done.turn && state.phase === 1);
assert.equal(next.storm, stormSectorAfter(done.storm, done.stormPending!));
if (effect === 'atomics') assert.equal(next.shieldWallDestroyed, true);
} else if (effect === 'harvester') {
const next = advance(done, state => state.turn === done.turn && state.phase === 2);
const blow = pending.spiceWindow!;
const key = `${blow.territory}:${blow.sector}`;
assert.equal(next.spice[key] ?? 0, committed.spice[key] ?? 0);
} else {
const next = advance(done, state => state.turn === done.turn && state.phase === 5);
assert.equal(player(next, f.owner).reserves, committed.reserves);
assert.equal(player(next, f.owner).tanks, committed.tanks);
}
});
}
void test('all AI profiles can legally respond to an independent-card Hajr discard', () => {
const f = fixture('hajr'), pending = act(f.game, f.owner, f.play);
for (const difficulty of DIFFICULTIES) {
const game = structuredClone(pending);
player(game, f.holder).bot = difficulty;
const response = botActions(viewGame(game, f.holder)).find(action =>
action.type === 'semutaCommit' || action.type === 'semutaPass');
assert.ok(response);
assert.doesNotThrow(() => act(game, f.holder, response));
}
});