Expand Semuta and Richese Homeworld shipments
Verify and publish checkpoint container / container (push) Successful in 10m7s
Verify and publish checkpoint container / container (push) Successful in 10m7s
This commit is contained in:
1 parent
1ba942138d
commit
4d769af8c7
15 files changed
+626
-39
No files matched your search
@@ -1281,8 +1281,9 @@ export function GameTable({
|
|||||||
{g.semutaRecruitsPreview && (
|
{g.semutaRecruitsPreview && (
|
||||||
<p className="notice" role="status">
|
<p className="notice" role="status">
|
||||||
Semuta Drug preview: in this independent-card game, Semuta can react
|
Semuta Drug preview: in this independent-card game, Semuta can react
|
||||||
to another player discarding Recruits. Other Semuta discard
|
to another player discarding Recruits, Hajr, Ghola, Harvester,
|
||||||
opportunities in this combination are still being implemented.{' '}
|
Weather Control or Family Atomics. Other discard opportunities are
|
||||||
|
still being implemented.{' '}
|
||||||
<a href="/rules?topic=card-recruits#card-recruits">Recruits rules</a>
|
<a href="/rules?topic=card-recruits#card-recruits">Recruits rules</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,16 +30,17 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
|
|||||||
const destination = targets.find(world => world.id === selectedDestination) ?? targets[0];
|
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 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 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,
|
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment, source,
|
||||||
source ? forces : undefined);
|
forces);
|
||||||
return <fieldset disabled={busy || !!me.autopilot || !!game.homeworldShipment?.blocked}
|
return <fieldset disabled={busy || !!me.autopilot || !!game.homeworldShipment?.blocked}
|
||||||
className="space-y-3 pt-3">
|
className="space-y-3 pt-3">
|
||||||
<legend>Ship a concealed No-Field</legend>
|
<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
|
<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
|
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.
|
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
|
<label className="block space-y-1">Destination Homeworld for No-Field
|
||||||
<select aria-label="No-Field destination Homeworld" value={destination?.id ?? ''}
|
<select aria-label="No-Field destination Homeworld" value={destination?.id ?? ''}
|
||||||
onChange={event => setSelectedDestination(event.target.value)}>
|
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>)}
|
{tokens.map(candidate => <option key={candidate.id} value={candidate.id}>No-Field {candidate.value}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{source && pool && <fieldset className="space-y-2">
|
{pool && <fieldset className="space-y-2">
|
||||||
<legend>Accompanying forces from {homeworldName(game, source)}</legend>
|
<legend>Accompanying forces from {source ? homeworldName(game, source) : 'your reserves'}</legend>
|
||||||
{(['normal', 'elite'] as const).filter(kind => kind === 'normal' || pool.elite > 0).map(kind =>
|
{(['normal', 'elite'] as const).filter(kind => kind === 'normal' || pool.elite > 0).map(kind =>
|
||||||
<label key={kind} className="block space-y-1">
|
<label key={kind} className="block space-y-1">
|
||||||
{kind === 'normal' ? 'Normal forces' : specialForceName(me.faction)} accompanying No-Field · {pool[kind]} available
|
{kind === 'normal' ? 'Normal forces' : specialForceName(me.faction)} accompanying No-Field · {pool[kind]} available
|
||||||
|
|||||||
+22
-3
@@ -16,11 +16,28 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## 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 /
|
**Semuta after Recruits in Basic independent cards — 28 October. Prototyped /
|
||||||
Partial.** Fresh Richese/classic Basic starts with the CHOAM deck and Ecaz
|
Partial.** Fresh Richese/classic Basic starts with the CHOAM deck and Ecaz
|
||||||
Treachery Cards offer the actual Recruits discard to Semuta, retaining one
|
Treachery Cards offer the actual Recruits discard to Semuta, retaining one
|
||||||
committed revival benefit and the original phase. Other Semuta discards in
|
committed revival benefit and the original phase. Other unsupported Semuta
|
||||||
this independent-card combination, native expansion factions and optional
|
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).
|
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.
|
The related eleven-file union passes 118 cases and the application builds.
|
||||||
Two Advanced module fixtures were corrected to follow the real ordered Revival;
|
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
|
revision-specific image and `checkpoint`. Run 6 for `cea310b` succeeded and
|
||||||
published its revision tag and `checkpoint` with digest
|
published its revision tag and `checkpoint` with digest
|
||||||
`sha256:74340835931011be1d0be907c8db688719f05a1a1b949ba0633eff4c5fb55248`.
|
`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.
|
Deployment remains unverified.
|
||||||
Existing NAS deployment and its persistent storage remain untouched.
|
Existing NAS deployment and its persistent storage remain untouched.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Existing marker with same-source Homeworld forces — 28 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** In supported native Basic and Advanced Homeworld
|
**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;
|
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
|
the marker is not a physical counter and does not consume the physical
|
||||||
twenty-counter limit. Normal payment and Advanced Guild interception remain.
|
twenty-counter limit. Normal payment and Advanced Guild interception remain.
|
||||||
No forces can be picked up from a different source. Initial placement of a
|
No forces can be picked up from a different source. Own-world return and optional-module overlays
|
||||||
fresh marker with companions, own-world return and optional-module overlays
|
|
||||||
remain unsupported.
|
remain unsupported.
|
||||||
|
|
||||||
The supplied Advanced revision permits Homeworld-to-Homeworld shipment
|
The supplied Advanced revision permits Homeworld-to-Homeworld shipment
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Rule decision index
|
# 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
|
## Optional short alliance victory — 28 October 2026
|
||||||
|
|
||||||
The printed optional two-member alliance target is selectable at three
|
The printed optional two-member alliance target is selectable at three
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Semuta discard producers and continuation inventory
|
# 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
|
## Recruits in the Basic independent-card preview — 28 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** A fresh Basic public start with Richese and classic
|
**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
|
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
|
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
|
cannot replay the effect in that turn. Existing physical cards and free-use
|
||||||
ledgers remain authoritative. This is a Recruits-only preview: other Semuta
|
ledgers remain authoritative. Other Semuta discard producers in the
|
||||||
discard producers in the independent-card combination remain unfinished, and
|
independent-card combination remain unfinished, and
|
||||||
other factions, decks and optional modules retain their prior boundaries.
|
other factions, decks and optional modules retain their prior boundaries.
|
||||||
The printed discard opportunity follows the source audit below; the order of
|
The printed discard opportunity follows the source audit below; the order of
|
||||||
the completed effect and online reaction is a provisional implementation.
|
the completed effect and online reaction is a provisional implementation.
|
||||||
|
|||||||
+10
-7
@@ -2605,7 +2605,7 @@ export type Game = {
|
|||||||
moritaniAssassinatePreview?: true;
|
moritaniAssassinatePreview?: true;
|
||||||
/** Explicit development-only neutral Semuta reaction profile. */
|
/** Explicit development-only neutral Semuta reaction profile. */
|
||||||
semutaPreview?: true;
|
semutaPreview?: true;
|
||||||
/** Fresh Basic independent-card preview for Semuta after Recruits only. */
|
/** Fresh Basic independent-card Semuta preview; scoped per discard source. */
|
||||||
semutaRecruitsPreview?: true;
|
semutaRecruitsPreview?: true;
|
||||||
moritaniAssassinate?: MoritaniAssassinateState;
|
moritaniAssassinate?: MoritaniAssassinateState;
|
||||||
moritaniAssassinateCallEvents?: string[];
|
moritaniAssassinateCallEvents?: string[];
|
||||||
@@ -9520,7 +9520,10 @@ function semutaOfferSupported(
|
|||||||
if (continuation.kind === 'thumperDiscard' &&
|
if (continuation.kind === 'thumperDiscard' &&
|
||||||
(g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 ||
|
(g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 ||
|
||||||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false;
|
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') &&
|
g.players.some(p => p.faction === 'richese') &&
|
||||||
entries.length > 0 &&
|
entries.length > 0 &&
|
||||||
(ambassadorDiscard || continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' ||
|
(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.');
|
'Homeworld No-Field shipment currently supports native Homeworld play without optional modules.');
|
||||||
requireRule(!intent.route && !intent.guildSecretEvent && intent.sources && typeof intent.sources === 'object' &&
|
requireRule(!intent.route && !intent.guildSecretEvent && intent.sources && typeof intent.sources === 'object' &&
|
||||||
!Array.isArray(intent.sources) &&
|
!Array.isArray(intent.sources) &&
|
||||||
(intent.noField.from === undefined ? Object.keys(intent.sources).length === 0 :
|
Object.keys(intent.sources).every(source =>
|
||||||
Object.keys(intent.sources).every(source => source === intent.noField!.from)) &&
|
source === (intent.noField!.from ?? 'homeworld:richese')) &&
|
||||||
p.noField && p.noFieldEvent === intent.noField.event &&
|
p.noField && p.noFieldEvent === intent.noField.event &&
|
||||||
(intent.noField.from !== undefined || p.noFieldBlockedTurn !== g.turn),
|
(intent.noField.from !== undefined || p.noFieldBlockedTurn !== g.turn),
|
||||||
'The original concealed Homeworld shipment is no longer available.');
|
'The original concealed Homeworld shipment is no longer available.');
|
||||||
const marker = intent.noField.from === undefined
|
const marker = intent.noField.from === undefined
|
||||||
? {...homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
|
? homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
|
||||||
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId},p.noField!)),
|
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,
|
||||||
state:g.homeworlds.custody, players:context.players, sources:[]}
|
...(Object.hasOwn(intent.sources, 'homeworld:richese') ? {forces:intent.sources['homeworld:richese']} : {})},p.noField!))
|
||||||
: homeworldRule(() => quoteRicheseHomeworldMarkerTransfer(context, g.homeworlds!.custody!,
|
: homeworldRule(() => quoteRicheseHomeworldMarkerTransfer(context, g.homeworlds!.custody!,
|
||||||
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,source:intent.noField!.from!,
|
{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!));
|
...(Object.hasOwn(intent.sources, intent.noField!.from!) ? {forces:intent.sources[intent.noField!.from!]} : {})},p.noField!));
|
||||||
|
|||||||
@@ -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
|
/** Owner-only marker quote. The face stays private; the marker counts as one
|
||||||
* one regardless of denomination. Authoritative timing and payment remain in
|
* regardless of denomination, plus any selected physical companions. */
|
||||||
* the normal Homeworld shipment action. */
|
|
||||||
export function richeseHomeworldMarkerChoice(
|
export function richeseHomeworldMarkerChoice(
|
||||||
view: GameView,
|
view: GameView,
|
||||||
destination: string,
|
destination: string,
|
||||||
@@ -71,11 +70,10 @@ export function richeseHomeworldMarkerChoice(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (source === undefined) {
|
if (source === undefined) {
|
||||||
if (forces !== undefined) {
|
const quote = quoteRicheseHomeworldMarkerShipment(context, custody,
|
||||||
result.blocked = 'Physical forces may accompany only a deployed Homeworld No-Field.';
|
{ player: me.id, destination, tokenId, ...(forces === undefined ? {} : { forces }) }, info.private);
|
||||||
return result;
|
result.amount = quote.amount;
|
||||||
}
|
result.cost = quote.cost;
|
||||||
quoteRicheseHomeworldMarkerShipment(context, custody, { player: me.id, destination, tokenId }, info.private);
|
|
||||||
} else {
|
} else {
|
||||||
const quote = quoteRicheseHomeworldMarkerTransfer(context, custody,
|
const quote = quoteRicheseHomeworldMarkerTransfer(context, custody,
|
||||||
{ player: me.id, destination, tokenId, source, ...(forces === undefined ? {} : { forces }) }, info.private);
|
{ 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.';
|
result.blocked = 'Your spice and selected allied contribution cannot pay this shipment.';
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
const sources = source !== undefined && forces && forces.normal + forces.elite > 0
|
const sources = forces && forces.normal + forces.elite > 0
|
||||||
? { [source]: forces } : {};
|
? { [source ?? 'homeworld:richese']: forces } : {};
|
||||||
result.action = { type: 'homeworldShip', event: option.event, destination, sources,
|
result.action = { type: 'homeworldShip', event: option.event, destination, sources,
|
||||||
noField: tokenId, noFieldEvent: info.event, allyPayment, ...(source === undefined ? {} : { noFieldFrom: source }) };
|
noField: tokenId, noFieldEvent: info.event, allyPayment, ...(source === undefined ? {} : { noFieldFrom: source }) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -412,7 +410,8 @@ export function homeworldShipmentActions(
|
|||||||
Math.max(0, 1 - (me.spice ?? 0)), source);
|
Math.max(0, 1 - (me.spice ?? 0)), source);
|
||||||
if (quote.action) {
|
if (quote.action) {
|
||||||
actions.push(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)) {
|
if (pool && (pool.normal > 0 || pool.elite > 0)) {
|
||||||
const forces = { normal: pool.normal > 0 ? 1 : 0, elite: pool.normal > 0 ? 0 : 1 };
|
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);
|
const initial = richeseHomeworldMarkerChoice(view, target.world.id, token.id, 0, source, forces);
|
||||||
|
|||||||
+2
-2
@@ -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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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: [
|
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 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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type RicheseHomeworldMarkerShipmentIntent = {
|
|||||||
player: string;
|
player: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
tokenId: string;
|
tokenId: string;
|
||||||
|
/** Optional physical companions withdrawn from native Richese reserves. */
|
||||||
|
forces?: HomeworldForces;
|
||||||
};
|
};
|
||||||
export type RicheseHomeworldMarkerTransferIntent = RicheseHomeworldMarkerShipmentIntent & {
|
export type RicheseHomeworldMarkerTransferIntent = RicheseHomeworldMarkerShipmentIntent & {
|
||||||
source: string;
|
source: string;
|
||||||
@@ -21,10 +23,9 @@ function requireMarker(condition: unknown, message: string): asserts condition {
|
|||||||
if (!condition) throw new HomeworldCustodyError(message);
|
if (!condition) throw new HomeworldCustodyError(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quote one concealed Richese marker as a single shipment, not a physical
|
/** A fresh concealed marker counts as one for the whole shipment invoice. Any
|
||||||
* counter. The engine owns timing, payment, response windows and commitment.
|
* physical companions are independently withdrawn from native Richese reserves;
|
||||||
* Nothing in the input state is mutated, and the marker's denomination never
|
* the marker's denomination never enters the price or physical custody. */
|
||||||
* changes its invoice or causes reserve counters to move. */
|
|
||||||
export function quoteRicheseHomeworldMarkerShipment(
|
export function quoteRicheseHomeworldMarkerShipment(
|
||||||
context: HomeworldShipmentContext,
|
context: HomeworldShipmentContext,
|
||||||
custody: HomeworldCustody,
|
custody: HomeworldCustody,
|
||||||
@@ -33,9 +34,17 @@ export function quoteRicheseHomeworldMarkerShipment(
|
|||||||
) {
|
) {
|
||||||
// This validates all physical reserve and foreign garrison custody first.
|
// This validates all physical reserve and foreign garrison custody first.
|
||||||
const worlds = homeworldForceGroups(context, custody);
|
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',
|
typeof intent.player === 'string' && typeof intent.destination === 'string' && typeof intent.tokenId === 'string',
|
||||||
'Choose a Richese player, one active Homeworld and one physical No-Field.');
|
'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 ||
|
requireMarker(context.players.every(seat => seat.ally === null ||
|
||||||
typeof seat.ally === 'string' && seat.ally !== seat.id && context.players.some(other => other.id === seat.ally)),
|
typeof seat.ally === 'string' && seat.ally !== seat.id && context.players.some(other => other.id === seat.ally)),
|
||||||
'Homeworld shipment needs valid seated alliance identities.');
|
'Homeworld shipment needs valid seated alliance identities.');
|
||||||
@@ -60,6 +69,16 @@ export function quoteRicheseHomeworldMarkerShipment(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new HomeworldCustodyError(error instanceof Error ? error.message : 'Invalid No-Field shipment.');
|
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 {
|
return {
|
||||||
player: player.id,
|
player: player.id,
|
||||||
tokenId: intent.tokenId,
|
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);
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user