Support concealed No-Field Guild transport
This commit is contained in:
1 parent
e41abdaf2e
commit
933a9c1379
24 files changed
+1131
-67
No files matched your search
@@ -5144,6 +5144,11 @@ export function GameTable({
|
||||
{!!me.hajrPlays && <p className="fine">{me.hajrPlays} Hajr play{me.hajrPlays === 1 ? '' : 's'} this turn. Each legal play grants one additional movement. A recovered Hajr must return to your hand; Advanced requires a fresh post-move opportunity.</p>}
|
||||
{transportPreview && (
|
||||
<>
|
||||
{includesNoField && <p className="fine">
|
||||
Provisional No-Field transport: cross-shipment keeps your marker concealed and prices it as one force.
|
||||
A permitted Secret Ally return sends it back face down without placing forces or clearing the last-token restriction.
|
||||
Your ordinary movement remains available.
|
||||
</p>}
|
||||
<ShipmentQuote
|
||||
id="guild-transport-quote"
|
||||
label={`Guild transport to ${territory(selected).name}`}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type ShipmentQuoteProps = {
|
||||
id?: string;
|
||||
label?: string;
|
||||
physicalForces?: number;
|
||||
noField?: true;
|
||||
quote: {
|
||||
cost: number;
|
||||
normalCost: number;
|
||||
@@ -22,6 +23,7 @@ export function ShipmentQuote({
|
||||
id,
|
||||
label = 'Reserve shipment',
|
||||
physicalForces,
|
||||
noField,
|
||||
quote,
|
||||
funding,
|
||||
unavailableReasons = [],
|
||||
@@ -48,7 +50,8 @@ export function ShipmentQuote({
|
||||
)
|
||||
) : (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Choose valid forces and funding to see the payment breakdown.
|
||||
{noField ? 'Choose a valid marker, force group and funding to see the payment breakdown.'
|
||||
: 'Choose valid forces and funding to see the payment breakdown.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -59,6 +62,10 @@ export function ShipmentQuote({
|
||||
<dd className="m-0 text-right tabular-nums">{physicalForces}</dd>
|
||||
</>
|
||||
)}
|
||||
{noField && <>
|
||||
<dt>Concealed No-Field selected</dt>
|
||||
<dd className="m-0 text-right tabular-nums">1 marker</dd>
|
||||
</>}
|
||||
<dt>Your available spice</dt>
|
||||
<dd className="m-0 text-right tabular-nums">{funding.ownSpice}</dd>
|
||||
<dt>Available ally pledge</dt>
|
||||
@@ -74,6 +81,9 @@ export function ShipmentQuote({
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{noField && <p className="m-0">
|
||||
The concealed marker is billed as one shipment unit. It is excluded from the physical force count.
|
||||
</p>}
|
||||
{unavailableReasons.length > 0 && (
|
||||
<div>
|
||||
<p className="m-0 font-semibold">Shipment unavailable</p>
|
||||
|
||||
@@ -20,6 +20,52 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Concealed No-Field Guild transport — 27 October. Prototyped / Partial.**
|
||||
Native reciprocal Guild allies can cross-ship their owned concealed Richese
|
||||
marker alone or with same-origin counters. Original permission/rate responses,
|
||||
full-price repricing and unfunded withdrawal remain. Actual absent-Guild Secret
|
||||
Ally can cross or recall it face down, spending its original Nexus once and
|
||||
paying the Bank. Both preserve token inventory and last-shipped identity;
|
||||
return deposits only physical companions.0 never invents a counter. One marker
|
||||
plus physical0..20 prices1..21 once. Ordinary ally return and marker Homeworld
|
||||
placement stay unavailable. Persistent provisional guidance and separate quote
|
||||
rows explain the marker/count distinction. All four minimal profiles can submit
|
||||
legal owned marker routes; full AI refinement remains deferred.
|
||||
[Source and contract](RICHESE_NO_FIELD_RULES.md#concealed-guild-cross-shipment-and-secret-ally-return--27-october-2026).
|
||||
|
||||
Frozen **15-file union, 120/120**, types/lint/build and eight original-game
|
||||
programmes pass. Actual Advanced Richese/Atreides/Guild-or-Emperor +CHOAM-deck
|
||||
starts earn paid marker shipments. Native R/G form a real Nexus alliance;
|
||||
Secret A/E form a real alliance leaving R unallied, then R draws the original
|
||||
unopened preselected Guild Nexus through its actual closing choice. A genuine
|
||||
one-counter shipment precedes the later mixed marker shipment. No played hand,
|
||||
purse, army, phase, alliance, marker or event is installed in these new sources.
|
||||
|
||||
Runtime records 33 GameTable SSR, 8 separate actual quote renders and 8 actual
|
||||
GameTable Finish callbacks; source/further action arrays total773/36. Transport
|
||||
selectors, declarations, permission/rate/cancel and Nexus controls use native
|
||||
packets directly; no clicked transport-control or browser hydration/navigation
|
||||
claim. Native all-pass0/5, permission cancel5, funded/unfunded rate cancel3,
|
||||
Secret cross0, return5 and mixed1+5 return are exercised. New native tests also
|
||||
cover0/3/5, stale/wrong source, the next actual same-token ban and all profiles.
|
||||
Controlled quote/bot fixtures separately cover the20+marker maximum.
|
||||
|
||||
Feedback repaired the original Secret source's missing real closing-draw
|
||||
eligibility, an old common bot filter that rejected the newly legal source,
|
||||
two historical controlled fixtures' stale Charity frames and the signature
|
||||
array type. The historical adapters are explicitly labelled controlled and do
|
||||
not claim native phase traversal. Failed v1/v2 proof remains preserved; v3
|
||||
runs the frozen related batch after repair. Existing physical signatures keep
|
||||
their bytes when no marker is present.
|
||||
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-guild-no-field-transport-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261027-guild-no-field-transport-runtime/run-v1/`.
|
||||
All checks use one frozen source followed only by a recorded three-document
|
||||
evidence overlay. Existing server HTTP200, no restart or saved-game/database
|
||||
operation. Selected conceal/recall composition remains provisional; depleted
|
||||
native supplies, broader module combinations, complete modes and final
|
||||
rules/recovery/privacy/custody/browser/deployed acceptance remain open.
|
||||
|
||||
**No-Field / allied separation — 27 October. Prototyped / Partial.**
|
||||
A due allied-separation end now reveals the concealed marker through the
|
||||
selected provisional destructive composition, materializes only available
|
||||
|
||||
@@ -2,6 +2,47 @@
|
||||
|
||||
Initial primary-source and engine audit, 2026-09-06. The bounded ordinary-reserve cancellation policy below was selected on 2026-09-28 after a targeted publisher-source search found no affordability procedure; the later original audit remains historical evidence for unfinished transport and module combinations.
|
||||
|
||||
## Concealed No-Field transport — 27 October 2026
|
||||
|
||||
Native allied cross-shipment now accepts an owned concealed Richese marker,
|
||||
alone or with same-origin physical counters. The marker contributes one to the
|
||||
rounded group price, including value0, without becoming a physical counter.
|
||||
Native permission precedes the existing rate response; all-pass pays/delivers
|
||||
once. Permission denial leaves original custody/event and shipment unused.
|
||||
Rate denial retains the existing full-price/original-split affordability policy;
|
||||
an unfunded declaration withdraws without delivery, with the responding card
|
||||
still spent. Ordinary Guild allies still have no reserve-return permission.
|
||||
|
||||
The real absent-Guild Nexus Secret Ally instead supplies its own cross/return
|
||||
grant and Bank-paid rate. Its concealed return provisionally recalls the token
|
||||
face down, preserves the next-shipment restriction and deposits only actual
|
||||
physical companions. Cross keeps the token concealed. Current token/event and
|
||||
the physical group remain bound through the original declaration/continuation.
|
||||
Old transport frame signatures keep their original bytes when no marker is
|
||||
present. This selected source composition does not settle broader module
|
||||
combinations. [Source and scope](RICHESE_NO_FIELD_RULES.md#concealed-guild-cross-shipment-and-secret-ally-return--27-october-2026).
|
||||
|
||||
### Scoped native evidence
|
||||
|
||||
Frozen15-file union120/120, types/lint/build and eight original-game programmes
|
||||
pass. Actual paid tokens, real R/G or A/E alliance consents and a real closing
|
||||
Guild Nexus draw create the new sources. Native all-pass, permission denial,
|
||||
funded/unfunded rate denial, Secret cross/return and physical1+marker5 return
|
||||
retain the original response/payment pipeline.33 GameTable SSR,8 quote renders
|
||||
and8 actual Finish callbacks are recorded; transport controls use native packets
|
||||
directly, without a clicked-selector or browser claim. Source/further arrays
|
||||
are773/36. Separate controlled quote/bot cases cover physical20+marker1.
|
||||
|
||||
Feedback repaired actual closing-draw eligibility, the common bot source filter,
|
||||
two old explicitly controlled fixtures' stale Charity frame and the signature
|
||||
array type. Failed v1/v2 results remain preserved. Final v3 uses one frozen
|
||||
source then a three-document evidence overlay; original physical signatures
|
||||
retain bytes. Existing server HTTP200, no restart or game/database operation.
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-guild-no-field-transport-proof-v3/`;
|
||||
runtime: adjacent `20261027-guild-no-field-transport-runtime/run-v1/`.
|
||||
Broader modules, depleted native supply, full modes and final browser/deployed
|
||||
and recovery/privacy/custody acceptance remain open.
|
||||
|
||||
## Nullentropy recovery of the price cost — 26 October 2026
|
||||
|
||||
Paid Box recovery now shares the existing claimed-cost nonduplication policy:
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Concealed No-Field Guild transport
|
||||
|
||||
**Concealed No-Field Guild transport — 27 October. Prototyped / Partial.**
|
||||
Native reciprocal Guild allies can cross-ship their owned concealed Richese
|
||||
marker alone or with same-origin counters. Original permission/rate responses,
|
||||
full-price repricing and unfunded withdrawal remain. Actual absent-Guild Secret
|
||||
Ally can cross or recall it face down, spending its original Nexus once and
|
||||
paying the Bank. Both preserve token inventory and last-shipped identity;
|
||||
return deposits only physical companions.0 never invents a counter. One marker
|
||||
plus physical0..20 prices1..21 once. Ordinary ally return and marker Homeworld
|
||||
placement stay unavailable. Persistent provisional guidance and separate quote
|
||||
rows explain the marker/count distinction. All four minimal profiles can submit
|
||||
legal owned marker routes; full AI refinement remains deferred.
|
||||
[Source and contract](RICHESE_NO_FIELD_RULES.md#concealed-guild-cross-shipment-and-secret-ally-return--27-october-2026).
|
||||
|
||||
Frozen **15-file union, 120/120**, types/lint/build and eight original-game
|
||||
programmes pass. Actual Advanced Richese/Atreides/Guild-or-Emperor +CHOAM-deck
|
||||
starts earn paid marker shipments. Native R/G form a real Nexus alliance;
|
||||
Secret A/E form a real alliance leaving R unallied, then R draws the original
|
||||
unopened preselected Guild Nexus through its actual closing choice. A genuine
|
||||
one-counter shipment precedes the later mixed marker shipment. No played hand,
|
||||
purse, army, phase, alliance, marker or event is installed in these new sources.
|
||||
|
||||
Runtime records 33 GameTable SSR, 8 separate actual quote renders and 8 actual
|
||||
GameTable Finish callbacks; source/further action arrays total773/36. Transport
|
||||
selectors, declarations, permission/rate/cancel and Nexus controls use native
|
||||
packets directly; no clicked transport-control or browser hydration/navigation
|
||||
claim. Native all-pass0/5, permission cancel5, funded/unfunded rate cancel3,
|
||||
Secret cross0, return5 and mixed1+5 return are exercised. New native tests also
|
||||
cover0/3/5, stale/wrong source, the next actual same-token ban and all profiles.
|
||||
Controlled quote/bot fixtures separately cover the20+marker maximum.
|
||||
|
||||
Feedback repaired the original Secret source's missing real closing-draw
|
||||
eligibility, an old common bot filter that rejected the newly legal source,
|
||||
two historical controlled fixtures' stale Charity frames and the signature
|
||||
array type. The historical adapters are explicitly labelled controlled and do
|
||||
not claim native phase traversal. Failed v1/v2 proof remains preserved; v3
|
||||
runs the frozen related batch after repair. Existing physical signatures keep
|
||||
their bytes when no marker is present.
|
||||
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-guild-no-field-transport-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261027-guild-no-field-transport-runtime/run-v1/`.
|
||||
All checks use one frozen source followed only by a recorded three-document
|
||||
evidence overlay. Existing server HTTP200, no restart or saved-game/database
|
||||
operation. Selected conceal/recall composition remains provisional; depleted
|
||||
native supplies, broader module combinations, complete modes and final
|
||||
rules/recovery/privacy/custody/browser/deployed acceptance remain open.
|
||||
|
||||
## 27 October 2026 — No-Field at allied separation
|
||||
|
||||
Prototyped / Partial. A due allied-separation end now reveals the concealed marker through the
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
Source audit, 10 September 2026. This is a source-backed implementation contract, not certification of the whole card or either expansion module. The broader [Guild audit](NEXUS_GUILD_RULES.md) remains relevant to Cunning and Betrayal.
|
||||
|
||||
## Concealed No-Field — 27 October 2026
|
||||
|
||||
The selected provisional composition now includes a currently owned concealed
|
||||
Richese marker in this card's cross-shipment or own-reserve return. Count one
|
||||
marker plus actual companions for price, rounding once; physical0..20 means
|
||||
billable1..21. Delivery spends one actual original Nexus and pays the Bank.
|
||||
Cross moves the marker without materialization. Return recalls it face down,
|
||||
retaining last-shipped identity; no hidden denomination becomes a reserve
|
||||
deposit. Only physical companions return as counters. Explicit Homeworld marker
|
||||
placement and ordinary Guild ally return remain unavailable.
|
||||
|
||||
This composes supplied Advanced physical36/printed34 B and official E2 FAQ10's
|
||||
one-force rule with the recorded card grant. Targeted publisher/designer search
|
||||
found no dedicated No-Field/Guild answer. The earlier component photograph is
|
||||
historical evidence; this checkpoint does not claim a fresh visual reread of
|
||||
that image. Existing card admission, source/storm restrictions, ordinary
|
||||
shipment allowance and native reserve custody remain. The source contract and
|
||||
remaining boundaries are in [No-Field rules](RICHESE_NO_FIELD_RULES.md#concealed-guild-cross-shipment-and-secret-ally-return--27-october-2026).
|
||||
|
||||
## Evidence checked
|
||||
|
||||
The original Guild component was visually reread in the [photograph of GF9's twelve Nexus cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), using the existing local inspection copy `/tmp/dune-nexus-cards.jpg`. Its Secret Ally panel grants Guild shipment rates and expressly adds cross-planet shipment or return to the holder's reserves as their shipping action. It applies when Guild is absent. It does not promise all Guild advantages.
|
||||
@@ -40,6 +59,6 @@ Consume the actual held Nexus atomically with a validated paid declaration, pres
|
||||
|
||||
Reuse `guildShipmentCost` for paid cross/return tariffs. Do not price the paid Fremen route through `reserveShipmentCost`, whose native Fremen branch intentionally returns zero. World-to-world pricing needs an explicit quoted rate modifier after route validation; changing the player's faction to Guild would also change custody and permissions. The native `quoteGuildHomeworldShipment` explicitly enforces Guild identity and ordinary Guild counters, so it is not a safe borrowed-return adapter without a separately settled contract.
|
||||
|
||||
Generic Karama cannot target an absent native Guild player. That does not establish universal immunity for every borrowed effect. Preserve supported arrival reactions and existing independent Karama payment routing; do not stack two Guild half-rate modifiers into quarter price. Richese concealed No-Field price/transport composition remains its separate documented boundary, without blocking actual physical groups unnecessarily.
|
||||
Generic Karama cannot target an absent native Guild player. That does not establish universal immunity for every borrowed effect. Preserve supported arrival reactions and existing independent Karama payment routing; do not stack two Guild half-rate modifiers into quarter price. Richese concealed No-Field cross/return now follows the selected provisional composition above; broader combinations remain separate.
|
||||
|
||||
Required runtime evidence includes odd/even prices, all supported route types, typed custody, Fremen free-versus-paid choices and storm behavior, native Ixian HMS access, current-source rejection, actual entry continuations, replay/JSON recovery, private projections and concurrent persistence. This audit changed documentation only.
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
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.
|
||||
|
||||
## Concealed Guild cross-shipment and Secret Ally return — 27 October 2026
|
||||
|
||||
The supplied Advanced source, physical36/printed34 B, and E2 FAQ printed10
|
||||
count a concealed marker as one force for all effects, including0. Compose
|
||||
that rule with Guild cross-shipment: a currently owned marker can travel alone
|
||||
or with actual counters from the same territory. It stays concealed, rotates
|
||||
its current event once at delivery, retains its physical token inventory and
|
||||
last-shipment identity, and consumes the ordinary shipment rather than movement.
|
||||
No reserve counter is materialized. This is the selected provisional
|
||||
composition; targeted publisher/designer searching found no specific combined
|
||||
No-Field/Guild ruling.
|
||||
|
||||
Native reciprocal Guild allies retain their separate permission and rate
|
||||
responses. Cancellation leaves the marker at its original source with its
|
||||
original event; permission denial leaves the shipment unused. Rate cancellation
|
||||
reprices the fixed group, with the existing unfunded withdrawal policy. An
|
||||
ordinary Guild ally still cannot return to reserves (November2020 FAQ printed9).
|
||||
|
||||
An actual absent-Guild Secret Ally card expressly grants cross-shipment or
|
||||
return to own reserves. Its original Nexus is spent once at paid commitment;
|
||||
payment goes to the Bank. Provisionally, return recalls the concealed token
|
||||
face down (`hiddenReturn`) without revealing its denomination or depositing
|
||||
its nominal value. The same-token next-shipment ban remains. Only actual
|
||||
physical companions are deposited through native reserves. Explicit Homeworld
|
||||
marker placement is unavailable; return means recall behind the shield.
|
||||
|
||||
Price the marker as one plus0..20 physical counters, rounding the whole group
|
||||
once; the billable total may therefore be21. Views and controls display the
|
||||
marker separately from physical counters. No hidden denomination/reserve count
|
||||
is used to price or propose the transport. The existing movement selector,
|
||||
transport buttons and real Secret Ally selection submit the current token/event.
|
||||
Broader module combinations and complete-mode acceptance remain open.
|
||||
[Guild response contract](GUILD_RATE_KARAMA_RULES.md#concealed-no-field-transport--27-october-2026),
|
||||
[Secret Ally contract](NEXUS_GUILD_SECRET_ALLY_RULES.md#concealed-no-field--27-october-2026).
|
||||
|
||||
## Allied separation at a due ending — 27 October 2026
|
||||
|
||||
A due Basic/Advanced alliance constraint now accepts normal Finish with an
|
||||
@@ -355,7 +390,7 @@ This table records the original adapter contract. The implemented subset and cur
|
||||
2. **Mixed forces and battle dials.** The FAQ forbids dialing more than reserves in a No-Field battle. Existing ordinary counters are possible, but no retrieved specific answer defines the cap across those counters, multiple sectors, typed units or independent reserve uses. Basic/Advanced ordinary and the selected Advanced Occupy prototype use the explicitly provisional additive pool above. This does not settle that source question or admit Basic mixed coalition/module profiles.
|
||||
3. **Full-plan special Karama and structured Truthtrance.** The current prototype distinguishes supplied p27 F's special entire-plan power from p27 C/p36 B ordinary Prescience under the explicit provisional contract above; private reading does not reveal the physical token. No retrieved dedicated combined FAQ adjudicates that distinction or broader promise questions. Structured Truthtrance, public maxima, other-player legal previews and reasoning retain their original entitled boundaries.
|
||||
4. **Homeworld use.** R3 recommends Richese in a Homeworld combination but supplies no retrieved No-Field-specific rule defining off-planet hidden custody, one-on-planet versus off-planet coexistence, or homeworld threshold disclosure. General module availability is not an answer to those lifecycle questions. The isolated model represents a single Arrakis marker and does not authorize Homeworld placement.
|
||||
5. **Capturing, transferring or returning a concealed marker as a force.** Movement-as-a-force and Gamont's explicit forced reveal do not establish a general conversion for every card or Guild transport-to-reserves. A generic return helper must not erase a hidden token without a source-backed reveal/custody cause. This module exposes no such automatic escape from last-use history.
|
||||
5. **Capturing, transferring or returning a concealed marker as a force.** The selected Guild Secret Ally route above now provisionally recalls the marker face down while preserving last-use history. It does not establish a general conversion for every card, ordinary Guild ally return, foreign custody or Homeworld placement. Other return causes retain their own recorded contracts.
|
||||
6. **Allied discounts and reserve source.** Ordinary consent, typed allocation and funding are integrated. Guild-recipient and already-active Karama price/payee use the explicitly provisional prototype in both modes. Ordinary Fremen reserves are on-planet and remain ineligible for this off-planet method. See `RICHESE_ALLIED_SHIPMENT_AUDIT.md`.
|
||||
7. **Supply orientation.** Concealed denomination is explicit. Hiding unused IDs/values in public JSON is the minimum privacy consequence, not a claim about an otherwise undocumented physical supply layout. The last revealed used face is public until another deployment; historical log knowledge may legitimately remain.
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Rule decision index
|
||||
|
||||
## Concealed No-Field Guild transport — 27 October 2026
|
||||
|
||||
Under the standing recommended-option policy, compose supplied Advanced
|
||||
physical36/printed34 B and E2 FAQ10's marker-as-one rule with native Guild
|
||||
allied cross-shipment and the actual absent-Guild Secret Ally cross/return
|
||||
grant. Cross preserves concealment; Secret return provisionally recalls face
|
||||
down without denomination materialization. Both retain last-shipped identity.
|
||||
Bill one marker plus physical companions once; only companions use counter
|
||||
removal/deposit. Native permission/rate responses and existing unfunded
|
||||
withdrawal remain; ordinary Guild allies still cannot return (November2020
|
||||
FAQ9). No marker Homeworld placement is granted. Targeted publisher/designer
|
||||
search found no specific combined ruling; broader combinations and final
|
||||
acceptance remain open. [Contract](RICHESE_NO_FIELD_RULES.md#concealed-guild-cross-shipment-and-secret-ally-return--27-october-2026).
|
||||
|
||||
## No-Field at allied separation — 27 October 2026
|
||||
|
||||
Compose E2 FAQ10/supplied physical36 B's one-force presence with the actual
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { gameTerritories } from './board';
|
||||
import type { Action, GameView } from './engine';
|
||||
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
|
||||
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct } from './nexus-guild-secret-ally-options';
|
||||
import { guildTransportQuote } from './transport-quote';
|
||||
|
||||
/** Minimal legal choices for the owned concealed marker, before auto-revelation.
|
||||
* The shared quotes own timing, entry, pricing and original source validation.
|
||||
*/
|
||||
export function guildNoFieldTransportActions(g: GameView): Action[] {
|
||||
const own = g.players.find(player => player.id === g.me);
|
||||
const inventory = g.richeseNoField;
|
||||
const marker = inventory?.owner === g.me ? inventory.private?.deployed : null;
|
||||
const publicMarker = inventory?.public.deployed;
|
||||
if (!own || own.shipped || !marker || marker.controller !== own.id || !publicMarker ||
|
||||
publicMarker.controller !== own.id || !inventory?.event ||
|
||||
!nexusGuildShipmentAvailable(g)) return [];
|
||||
const guild = g.players.find(player => player.faction === 'guild');
|
||||
const native = !!guild && own.ally === guild.id && guild.ally === own.id;
|
||||
const secret = nexusGuildSecretAllyCanAct(g);
|
||||
if (!native && !secret) return [];
|
||||
const destinations = gameTerritories(g).flatMap(territory => territory.id === publicMarker.location.territory
|
||||
? [] : territory.sectors.map(sector => ({ territory: territory.id, sector })));
|
||||
if (secret) destinations.push({ territory: 'reserves', sector: 0 });
|
||||
const actions: Action[] = [];
|
||||
for (const destination of destinations) {
|
||||
const packet: Action = { type: 'guildShip', noField: marker.tokenId, event: inventory.event,
|
||||
forces: {}, ...destination };
|
||||
const action = secret ? nexusGuildSecretAllyAction(g, g.nexusGuildSecretAlly!.event, packet) : packet;
|
||||
if (!action) continue;
|
||||
const quote = guildTransportQuote(g, action);
|
||||
if (!quote.unavailableReasons.length && quote.quote)
|
||||
actions.push({ ...action, allyPayment: quote.quote.pledgedPayment });
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
+2
-1
@@ -123,6 +123,7 @@ import { fighterCount, isAdvisor } from './advisors';
|
||||
import { quoteAdvisorMatching, AdvisorMatchingError } from './advisor-matching';
|
||||
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from './advisor-ambassador-matching';
|
||||
import { guildTransportQuote } from './transport-quote';
|
||||
import { guildNoFieldTransportActions } from './bot-guild-no-field-transport';
|
||||
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||
import { reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
@@ -4445,7 +4446,7 @@ export function botActions(g: GameView): Action[] {
|
||||
if (shrineTruth) return [shrineTruth];
|
||||
const intelligence = tupileIntelligenceActions(g);
|
||||
if (intelligence.length) return intelligence;
|
||||
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
|
||||
const actions = [...junctionTransportActions(g, rank(g)), ...guildNoFieldTransportActions(g), ...policyActions(g)].flatMap((action) => {
|
||||
if (action.type === 'move' && nexusGuildCunningActive(g) && !nexusGuildMovementAvailable(g)) return [];
|
||||
if (action.type === 'battlePlan' && g.ecazSpecialKarama?.declared &&
|
||||
(action.weapon || action.defense)) return [];
|
||||
|
||||
+70
-23
@@ -1345,6 +1345,8 @@ type PendingGuildTransport = {
|
||||
/** Fresh board transports retain their actual source stance at foreign arrivals.
|
||||
* Absent on declarations recorded before this source cutover. */
|
||||
advisorArrivalSource?: string;
|
||||
/** A concealed marker prices as one but is never a physical counter. */
|
||||
noField?: { tokenId: string; event: string; from: string };
|
||||
};
|
||||
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves' | 'permission'>;
|
||||
type JunctionTransportDeclaration = {
|
||||
@@ -11830,7 +11832,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
|
||||
case 'guildTransport': {
|
||||
requireRule(typeof continuation.fromReserves === 'boolean', 'The Guild transport source flag is malformed.');
|
||||
const frame = continuation.frame;
|
||||
requireRule(frame && Object.keys(frame).filter(key => key !== 'advisorArrivalSource').sort().join(',') ===
|
||||
requireRule(frame && Object.keys(frame).filter(key => key !== 'advisorArrivalSource' && key !== 'noField').sort().join(',') ===
|
||||
'advisors,allyPayment,amount,cost,elite,eliteGroup,group,origin,player,sector,to,turn',
|
||||
'The saved Guild transport frame is malformed.');
|
||||
const p = getPlayer(g, frame.player);
|
||||
@@ -35930,11 +35932,12 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
|
||||
}
|
||||
}
|
||||
function guildTransportSignature(intent: PendingGuildTransport): string {
|
||||
const fields = [intent.event, intent.turn, intent.player, intent.fromReserves,
|
||||
const fields: unknown[] = [intent.event, intent.turn, intent.player, intent.fromReserves,
|
||||
intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector,
|
||||
intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors];
|
||||
if (intent.permission !== undefined) fields.push(intent.permission);
|
||||
if (intent.advisorArrivalSource !== undefined) fields.push(intent.advisorArrivalSource);
|
||||
if (intent.noField !== undefined) fields.push(intent.noField);
|
||||
return JSON.stringify(fields);
|
||||
}
|
||||
function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null {
|
||||
@@ -35971,6 +35974,8 @@ function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
|
||||
const intent = g.pendingGuildTransport;
|
||||
const guild = byFaction(g, 'guild');
|
||||
const shipper = intent && g.players.find(p => p.id === intent.player);
|
||||
const marker = intent?.noField;
|
||||
const deployed = shipper?.noField?.deployed;
|
||||
const permission = response.kind === 'guildTransportPermission';
|
||||
requireRule(intent && guild && shipper &&
|
||||
(permission ? intent.permission === true && response.guildTransportEvent === intent.event
|
||||
@@ -35988,18 +35993,26 @@ function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
|
||||
typeof intent.to === 'string' && intent.to.length > 0 &&
|
||||
Number.isSafeInteger(intent.sector) &&
|
||||
Number.isSafeInteger(intent.amount) && intent.amount > 0 &&
|
||||
Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount &&
|
||||
Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount - Number(!!marker) &&
|
||||
Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 &&
|
||||
intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' &&
|
||||
(intent.advisorArrivalSource === undefined || g.advanced && shipper.faction === 'beneGesserit' &&
|
||||
!intent.fromReserves && intent.to !== 'reserves' && intent.advisorArrivalSource === intent.origin) &&
|
||||
(marker === undefined || typeof marker === 'object' && marker !== null &&
|
||||
Object.keys(marker).sort().join(',') === 'event,from,tokenId' &&
|
||||
shipper.faction === 'richese' && !intent.fromReserves && !intent.advisors &&
|
||||
typeof marker.tokenId === 'string' && typeof marker.event === 'string' &&
|
||||
typeof marker.from === 'string' && deployed?.tokenId === marker.tokenId &&
|
||||
deployed.controller === shipper.id && shipper.noFieldEvent === marker.event &&
|
||||
location(deployed.location.territory,deployed.location.sector) === marker.from &&
|
||||
deployed.location.territory === intent.origin) &&
|
||||
(intent.fromReserves
|
||||
? intent.origin === 'reserves' && shipper.faction === 'fremen' &&
|
||||
intent.group.length === 0 && Object.keys(intent.eliteGroup).length === 0 &&
|
||||
intent.to !== 'reserves'
|
||||
: intent.group.length > 0 && intent.group.every(([key]) =>
|
||||
: (intent.group.length > 0 || !!marker) && intent.group.every(([key]) =>
|
||||
splitLocation(key).territory === intent.origin) &&
|
||||
intent.group.reduce((sum, [, n]) => sum + n, 0) === intent.amount &&
|
||||
intent.group.reduce((sum, [, n]) => sum + n, 0) + Number(!!marker) === intent.amount &&
|
||||
Object.keys(intent.eliteGroup).length === intent.group.length &&
|
||||
intent.group.every(([key, n]) =>
|
||||
Number.isSafeInteger(intent.eliteGroup[key]) &&
|
||||
@@ -36026,6 +36039,7 @@ function guildTransportOrder(intent: PendingGuildTransport, cost = intent.cost):
|
||||
group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to,
|
||||
sector: intent.sector, amount: intent.amount, elite: intent.elite,
|
||||
cost, allyPayment: intent.allyPayment, advisors: intent.advisors,
|
||||
...(intent.noField ? {noField:{...intent.noField}} : {}),
|
||||
...(intent.advisorArrivalSource !== undefined ? {advisorArrivalSource:intent.advisorArrivalSource} : {}),
|
||||
};
|
||||
}
|
||||
@@ -36093,11 +36107,13 @@ function validateGuildTransportForCommit(
|
||||
'The original southern-reserve elite allocation is unavailable.');
|
||||
} else {
|
||||
const quoted = forceGroup(g, p, { type: 'guildShip',
|
||||
forces: Object.fromEntries(group), eliteForces: eliteGroup });
|
||||
forces: Object.fromEntries(group), eliteForces: eliteGroup,
|
||||
...(frame.noField ? {noField:frame.noField.tokenId,event:frame.noField.event} : {}) });
|
||||
requireRule(JSON.stringify(quoted.group) === JSON.stringify(group) &&
|
||||
JSON.stringify(quoted.eliteGroup) === JSON.stringify(eliteGroup) &&
|
||||
quoted.origin === origin && quoted.elite === elite && quoted.total === amount &&
|
||||
group.every(([key]) => splitLocation(key).sector !== g.storm),
|
||||
JSON.stringify(quoted.noField) === JSON.stringify(frame.noField) &&
|
||||
quoted.sourceKeys.every(key => splitLocation(key).sector !== g.storm),
|
||||
'The original Guild transport force group changed or entered the storm.');
|
||||
}
|
||||
requireRule((fromReserves || to === 'reserves' || to !== origin) &&
|
||||
@@ -36111,6 +36127,8 @@ function validateGuildTransportForCommit(
|
||||
requireRule(to === 'reserves' || advisors || sourceLock !== g.turn ||
|
||||
!g.players.some(other => other.id !== p.id && at(other, to)),
|
||||
'New advisors cannot become fighters this turn.');
|
||||
requireRule(!frame.noField || to === 'reserves' || !to.startsWith('homeworld:'),
|
||||
'A concealed No-Field cannot be placed on a Homeworld.');
|
||||
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
|
||||
const halfRate = !!guildSecretEvent || g.karamaShipping?.player === p.id ||
|
||||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id);
|
||||
@@ -36129,9 +36147,10 @@ function commitGuildTransport(
|
||||
) {
|
||||
requireRule(!g.guildBetrayalPreview || !guildSecretEvent,
|
||||
'Guild Betrayal native transport does not combine with a Guild Secret Ally producer.');
|
||||
if (!guildSecretEvent) validateGuildTransportForCommit(g, p, frame, fromReserves);
|
||||
if (!guildSecretEvent || frame.noField) validateGuildTransportForCommit(g, p, frame, fromReserves, guildSecretEvent);
|
||||
if (!settlement && beginGuildBetrayal(g, {source: 'guildTransport', frame, fromReserves})) return;
|
||||
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
|
||||
const physicalAmount = amount - Number(!!frame.noField);
|
||||
const sourceLock = p.advisors?.[origin]?.lockedTurn;
|
||||
const advisorArrival = advisors || (!movementPreparation && !fromReserves && to !== 'reserves' &&
|
||||
g.advanced && p.faction === 'beneGesserit' && isAdvisor(p, origin) && !at(p, to) &&
|
||||
@@ -36157,12 +36176,19 @@ function commitGuildTransport(
|
||||
p.reserves -= amount;
|
||||
if (p.elites) p.elites.reserves -= elite;
|
||||
} else removeGroup(p, group, eliteGroup);
|
||||
if (frame.noField) {
|
||||
const marker = frame.noField;
|
||||
p.noField = noFieldRule(() => to === 'reserves'
|
||||
? recallRicheseNoField(p.noField!,marker.tokenId)
|
||||
: moveRicheseNoField(p.noField!,marker.tokenId,{territory:to,sector}));
|
||||
p.noFieldEvent = crypto.randomUUID();
|
||||
}
|
||||
if (to === 'reserves') {
|
||||
// User ruling 7 October 2026: the Nexus Secret Ally reserve return is an
|
||||
// authorized native-reserve return, so it deposits into Homeworld custody.
|
||||
addNativeReserves(g, p, amount, elite);
|
||||
if (physicalAmount) addNativeReserves(g, p, physicalAmount, elite);
|
||||
} else {
|
||||
place(p, to, sector, amount, elite);
|
||||
if (physicalAmount) place(p, to, sector, physicalAmount, elite);
|
||||
if (advisorArrival) {
|
||||
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[to] = { ...p.advisors?.[to], ...(lockedTurn ? { lockedTurn } : {}) };
|
||||
@@ -36180,7 +36206,7 @@ function commitGuildTransport(
|
||||
closeAdvisorPreparationOnShipment(g, p.id, 'guildTransport');
|
||||
g.karamaShipping = null;
|
||||
if (movementPreparation) return;
|
||||
log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
|
||||
log(g, `${p.name} used Guild transport for ${physicalAmount} physical forces${frame.noField ? ` and one concealed No-Field. Provisional ${to === 'reserves' ? 'face-down return preserves its last-shipment restriction without materializing forces' : 'cross-shipment preserves concealment and token history'}` : ''}.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
|
||||
guildSecretEvent
|
||||
? { faction: 'guild', name: 'Secret Ally transport' }
|
||||
: { faction: p.faction, name: 'Guild transport' });
|
||||
@@ -36759,11 +36785,29 @@ function nexusGuildSecretIntegrity(g: Game) {
|
||||
const frame = nexusRule(() => JSON.parse(record.frame)) as Record<string,unknown>;
|
||||
requireRule(frame && typeof frame === 'object' && frame.guildSecretEvent === record.receipt.event &&
|
||||
frame.player === record.receipt.owner && frame.turn === record.receipt.turn &&
|
||||
!frame.nexusEvent && !frame.guildNexusEvent && !frame.noField && !frame.alliedNoField && !frame.source,
|
||||
!frame.nexusEvent && !frame.guildNexusEvent && !frame.alliedNoField && !frame.source &&
|
||||
(!frame.noField || record.kind === 'cross' && record.receipt.owner &&
|
||||
getPlayer(g,record.receipt.owner).faction === 'richese'),
|
||||
'Guild Secret Ally has changed its original shipment source.');
|
||||
if (frame.noField) {
|
||||
const marker = frame.noField as Record<string,unknown>;
|
||||
const owner = getPlayer(g,record.receipt.owner);
|
||||
requireRule(marker && typeof marker === 'object' && !Array.isArray(marker) &&
|
||||
Object.keys(marker).sort().join(',') === 'event,from,tokenId' &&
|
||||
typeof marker.tokenId === 'string' && typeof marker.event === 'string' && marker.event.length > 0 &&
|
||||
typeof marker.from === 'string' && marker.from.length > 0 &&
|
||||
owner.noField?.tokens.some(token => token.id === marker.tokenId) &&
|
||||
location(splitLocation(marker.from).territory,splitLocation(marker.from).sector) === marker.from &&
|
||||
splitLocation(marker.from).territory === frame.origin &&
|
||||
Array.isArray(frame.group) && frame.group.every((entry:unknown) => Array.isArray(entry) &&
|
||||
entry.length === 2 && typeof entry[0] === 'string' && Number.isSafeInteger(entry[1]) && entry[1] > 0 &&
|
||||
splitLocation(entry[0]).territory === frame.origin) &&
|
||||
frame.group.reduce((sum:number,entry:[string,number]) => sum + entry[1],0) + 1 === frame.amount,
|
||||
'Guild Secret Ally lost its original concealed marker and physical group.');
|
||||
}
|
||||
const destination = record.kind === 'homeworld' ? 'homeworld' :
|
||||
record.kind === 'cross' && frame.to === 'reserves' ? 'reserves' : territory(String(record.kind === 'cross' ? frame.to : frame.territory)).type;
|
||||
const quote = nexusRule(() => quoteNexusGuildSecretShipment(destination,frame.amount as number));
|
||||
const quote = nexusRule(() => quoteNexusGuildSecretShipment(destination,frame.amount as number,!!frame.noField));
|
||||
requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) &&
|
||||
Number(frame.elite) >= 0 && Number(frame.elite) <= quote.physicalAmount,
|
||||
'Guild Secret Ally has changed its original payment or typed allocation.');
|
||||
@@ -42296,8 +42340,8 @@ function applyActionInner(
|
||||
}
|
||||
if (t === 'guildShip') {
|
||||
const guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus);
|
||||
if (guildSecretEvent) requireRule(Object.keys(action).every(k => ['type','from','forces','eliteForces','amount','elite','territory','sector','allyPayment','nexus'].includes(k)),
|
||||
'Guild Secret Ally cross-shipment needs explicit physical forces.');
|
||||
if (guildSecretEvent) requireRule(Object.keys(action).every(k => ['type','from','forces','eliteForces','amount','elite','territory','sector','allyPayment','nexus',...(action.noField !== undefined ? ['noField','event'] : [])].includes(k)),
|
||||
'Guild Secret Ally cross-shipment needs its selected physical group and optional current No-Field.');
|
||||
const homeworldTarget = action.territory ?? 'reserves';
|
||||
if (g.homeworlds?.custody && p.faction === 'guild' &&
|
||||
(homeworldTarget === 'reserves' || (typeof homeworldTarget === 'string' && homeworldTarget.startsWith('homeworld:')))) {
|
||||
@@ -42320,10 +42364,6 @@ function applyActionInner(
|
||||
? { territory: String(action.territory), amount: Number(action.amount) }
|
||||
: null,
|
||||
);
|
||||
requireRule(
|
||||
action.noField === undefined,
|
||||
'Guild transport of a concealed No-Field awaits its specific return and custody rules.',
|
||||
);
|
||||
requireRule(
|
||||
g.phase === 5 &&
|
||||
g.active === id &&
|
||||
@@ -42334,18 +42374,22 @@ function applyActionInner(
|
||||
const fromReserves = action.from === 'reserves';
|
||||
requireRule(!guildSecretEvent || !fromReserves, 'Use the reserve shipment selector for paid Guild Secret Ally reinforcements.');
|
||||
requireRule(
|
||||
!fromReserves || p.faction === 'fremen',
|
||||
'Only allied Fremen may cross-ship from their southern reserves.',
|
||||
!fromReserves || p.faction === 'fremen' && action.noField === undefined,
|
||||
'Only allied Fremen physical reserves may use this source.',
|
||||
);
|
||||
const {
|
||||
group,
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin,
|
||||
noField,
|
||||
sourceKeys,
|
||||
total: n,
|
||||
} = fromReserves
|
||||
? {
|
||||
origin: 'reserves',
|
||||
noField: undefined,
|
||||
sourceKeys: [] as string[],
|
||||
group: [] as [string, number][],
|
||||
eliteGroup: {} as Record<string, number>,
|
||||
elite: eliteChoice(
|
||||
@@ -42358,7 +42402,7 @@ function applyActionInner(
|
||||
}
|
||||
: forceGroup(g, p, action);
|
||||
requireRule(
|
||||
group.every(([key]) => splitLocation(key).sector !== g.storm),
|
||||
sourceKeys.every(key => splitLocation(key).sector !== g.storm),
|
||||
'The source is in storm.',
|
||||
);
|
||||
const to = stringField(action.territory ?? 'reserves');
|
||||
@@ -42383,8 +42427,10 @@ function applyActionInner(
|
||||
to !== MOBILE_STRONGHOLD || p.faction === 'ixians',
|
||||
'Only Ixians may ship directly into the mobile stronghold.',
|
||||
);
|
||||
requireRule(!noField || to === 'reserves' || !to.startsWith('homeworld:'),
|
||||
'A concealed No-Field cannot be placed on a Homeworld.');
|
||||
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
|
||||
const halfRate = g.karamaShipping?.player === id ||
|
||||
const halfRate = !!guildSecretEvent || g.karamaShipping?.player === id ||
|
||||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id);
|
||||
const cost = guildShipmentCost(
|
||||
to === 'reserves' ? 'reserves' : territory(to).type, n, halfRate);
|
||||
@@ -42397,6 +42443,7 @@ function applyActionInner(
|
||||
const frame: GuildTransportOrder = {
|
||||
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
|
||||
amount: n, elite, cost, allyPayment, advisors,
|
||||
...(noField ? {noField:{...noField}} : {}),
|
||||
...(advisorArrivalSource !== undefined ? {advisorArrivalSource} : {}),
|
||||
};
|
||||
if(execution==='live'&&openAdvisorTransportMatching(g,{kind:'guild',frame,fromReserves,...(guildSecretEvent?{secret:guildSecretEvent}:{})}))return g;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Entitled identity and public location only. Native transport owns legality;
|
||||
* the concealed denomination and reserve population do not grant permission. */
|
||||
export function guildNoFieldSelection(g: GameView, action: Action) {
|
||||
const info = g.richeseNoField;
|
||||
const marker = info?.public.deployed;
|
||||
const owned = info?.private?.deployed;
|
||||
if (action.type !== 'guildShip' || typeof action.noField !== 'string' ||
|
||||
!info || info.owner !== g.me || action.event !== info.event ||
|
||||
!marker || marker.controller !== g.me || !owned ||
|
||||
owned.controller !== g.me || owned.tokenId !== action.noField)
|
||||
return null;
|
||||
return { token: owned.tokenId, event: info.event, location: { ...marker.location } };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { nexusRicheseQuote } from './nexus-richese-options';
|
||||
import { quoteRicheseHomeworldPair } from './richese-homeworld-shipment';
|
||||
import { reserveShipmentCost } from './shipment-price';
|
||||
import { territory } from './board';
|
||||
import { guildTransportQuote } from './transport-quote';
|
||||
|
||||
|
||||
export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): boolean {
|
||||
@@ -31,6 +32,8 @@ export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action):
|
||||
} catch { return false; }
|
||||
}
|
||||
const guildSource = action.nexus !== undefined && action.nexus === g.nexusGuildSecretAlly?.event;
|
||||
if (guildSource && action.noField !== undefined)
|
||||
return action.type === 'guildShip' && !guildTransportQuote(g, action).unavailableReasons.length;
|
||||
const nexusQuote = action.nexus === undefined ? null : guildSource ? nexusGuildSecretAllyQuote(g, to, amount) : nexusRicheseQuote(g, to, amount);
|
||||
if (action.nexus !== undefined && (!nexusQuote || (!guildSource && (action.nexus !== g.nexusRichese?.event || action.type !== 'ship')) || action.noField !== undefined)) return false;
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import { gameTerritories, MOBILE_STRONGHOLD } from './board';
|
||||
import { gameTerritories, MOBILE_STRONGHOLD, splitLocation, validGameLocation } from './board';
|
||||
import { quoteNexusGuildSecretShipment } from './nexus-guild-secret-ally';
|
||||
import { guildNoFieldSelection } from './guild-no-field-selection';
|
||||
|
||||
/** Consume only the current actor's private offer and public faction roster. */
|
||||
export function nexusGuildSecretAllyCanAct(g: GameView): boolean {
|
||||
@@ -36,29 +37,31 @@ export function nexusGuildSecretAllyQuote(
|
||||
g: GameView,
|
||||
destination: string,
|
||||
amount: number,
|
||||
noField = false,
|
||||
) {
|
||||
if (
|
||||
!nexusGuildSecretAllyCanAct(g) ||
|
||||
typeof noField !== 'boolean' ||
|
||||
!Number.isSafeInteger(amount) ||
|
||||
amount < 1 ||
|
||||
amount > 20
|
||||
amount > (noField ? 21 : 20)
|
||||
)
|
||||
return null;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
if (destination === 'reserves') {
|
||||
// User ruling 7 October 2026: the Secret Ally return grant overrides the
|
||||
// Arrakis-to-native-Homeworld restriction for the holder's own reserves.
|
||||
return quoteNexusGuildSecretShipment('reserves', amount);
|
||||
return quoteNexusGuildSecretShipment('reserves', amount, noField);
|
||||
}
|
||||
const world = g.homeworlds?.worlds?.find((w) => w.id === destination);
|
||||
if (world) {
|
||||
if (world.native === me.id || world.native === me.ally) return null;
|
||||
return quoteNexusGuildSecretShipment('homeworld', amount);
|
||||
if (noField || world.native === me.id || world.native === me.ally) return null;
|
||||
return quoteNexusGuildSecretShipment('homeworld', amount, noField);
|
||||
}
|
||||
const to = gameTerritories(g).find((t) => t.id === destination);
|
||||
if (!to || (destination === MOBILE_STRONGHOLD && me.faction !== 'ixians'))
|
||||
return null;
|
||||
return quoteNexusGuildSecretShipment(to.type, amount);
|
||||
return quoteNexusGuildSecretShipment(to.type, amount, noField);
|
||||
}
|
||||
|
||||
/** Add a selected source, never infer card use just from possession. */
|
||||
@@ -72,12 +75,31 @@ export function nexusGuildSecretAllyAction(
|
||||
event !== g.nexusGuildSecretAlly!.event ||
|
||||
!['ship', 'guildShip', 'homeworldShip'].includes(action.type) ||
|
||||
action.nexus !== undefined ||
|
||||
action.noField !== undefined ||
|
||||
(action.noField !== undefined && action.type !== 'guildShip') ||
|
||||
(action.type === 'guildShip' && action.from === 'reserves')
|
||||
)
|
||||
return null;
|
||||
const amount =
|
||||
action.type === 'homeworldShip'
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const noField = action.noField !== undefined;
|
||||
let amount: number;
|
||||
if (noField) {
|
||||
const marker = guildNoFieldSelection(g, action);
|
||||
if (!marker || !action.forces || typeof action.forces !== 'object' || Array.isArray(action.forces) ||
|
||||
!validGameLocation(g, marker.location.territory, marker.location.sector) || marker.location.sector === g.storm)
|
||||
return null;
|
||||
let physical = 0;
|
||||
for (const [key, count] of Object.entries(action.forces)) {
|
||||
const source = splitLocation(key);
|
||||
if (!validGameLocation(g, source.territory, source.sector) ||
|
||||
typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0 ||
|
||||
count > (me.forces[key] ?? 0)) return null;
|
||||
if (!count) continue;
|
||||
if (source.territory !== marker.location.territory || source.sector === g.storm) return null;
|
||||
physical += count;
|
||||
}
|
||||
if (physical > 20) return null;
|
||||
amount = physical + 1;
|
||||
} else amount = action.type === 'homeworldShip'
|
||||
? Object.values(
|
||||
(action.sources ?? {}) as Record<
|
||||
string,
|
||||
@@ -93,8 +115,7 @@ export function nexusGuildSecretAllyAction(
|
||||
const destination = String(
|
||||
action.type === 'homeworldShip' ? action.destination : action.territory,
|
||||
);
|
||||
if (!nexusGuildSecretAllyQuote(g, destination, amount)) return null;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
if (!nexusGuildSecretAllyQuote(g, destination, amount, noField)) return null;
|
||||
if (
|
||||
action.type === 'ship' &&
|
||||
(amount > me.reserves ||
|
||||
|
||||
@@ -30,12 +30,15 @@ const plain = (value: unknown): value is Record<string, unknown> =>
|
||||
!Array.isArray(value) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value));
|
||||
|
||||
/** The caller validates route permission and actual physical custody. */
|
||||
export function quoteNexusGuildSecretShipment(destinationType: string, amount: number) {
|
||||
/** Amount is billable units; a selected concealed marker contributes one unit
|
||||
* but no physical counter. The caller validates permission and actual custody. */
|
||||
export function quoteNexusGuildSecretShipment(destinationType: string, amount: number, noField = false) {
|
||||
requireGuild(['stronghold','sand','rock','polar','reserves','homeworld'].includes(destinationType) &&
|
||||
Number.isSafeInteger(amount) && amount > 0 && amount <= 20,
|
||||
'Guild Secret Ally requires a valid destination and one to twenty physical forces.');
|
||||
return {physicalAmount:amount,cost:guildShipmentCost(destinationType === 'homeworld' ? 'reserves' : destinationType,amount),source:'nexusGuildSecretAlly' as const};
|
||||
typeof noField === 'boolean' && (!noField || destinationType !== 'homeworld') &&
|
||||
Number.isSafeInteger(amount) && amount > 0 && amount <= (noField ? 21 : 20),
|
||||
noField ? 'Guild Secret Ally requires a valid destination, one concealed marker and up to twenty physical forces.'
|
||||
: 'Guild Secret Ally requires a valid destination and one to twenty physical forces.');
|
||||
return {physicalAmount:amount - (noField ? 1 : 0),cost:guildShipmentCost(destinationType === 'homeworld' ? 'reserves' : destinationType,amount),source:'nexusGuildSecretAlly' as const};
|
||||
}
|
||||
function roster(context: NexusGuildSecretAllyContext) {
|
||||
requireGuild(
|
||||
|
||||
+12
-7
@@ -1110,6 +1110,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'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, and marker Homeworld placement remains unavailable. Select the marker separately from physical counters; ordinary movement remains available.',
|
||||
'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.',
|
||||
@@ -1139,29 +1140,29 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range or two-origin gathering uses current trainer/token/event and exact per-origin route/entry, preserving one movement, denomination and physical stocks. Special transport remains separate.',
|
||||
evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts'],
|
||||
'Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range or two-origin gathering uses current trainer/token/event and exact per-origin route/entry. Provisional Guild cross and actual Secret Ally cross/face-down return keep marker custody/history distinct from physical counters, with one rounded price and native response continuations. Broader special transports remain separate.',
|
||||
evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts', 'game/guild-no-field-selection.ts', 'game/transport-quote.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-guild-secret-ally.ts'],
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Original inventory/card/inspection and current-fact controls remain. Planetologist exposes the owner’s marker separately from physical counters in either movement mode, with provisional guidance, exact origin counts and guarded actual submission. The marker creates no physical force count.',
|
||||
'Original inventory/card/inspection and current-fact controls remain. Planetologist exposes the owner’s marker separately from physical counters in either movement mode. Existing Guild transport controls now quote marker1 separately from physical0..20, with persistent provisional cross/face-down-return guidance and the actual Secret Ally selector. The marker creates no physical force count.',
|
||||
evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx', 'components/planetologist-movement.tsx', 'components/planetologist-movement-action.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx', 'components/private-battle-plan.tsx', 'components/mixed-shipment-fields.tsx', 'components/truthtrance-no-field-value.tsx'],
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Existing own/allied, inspection and truthAnswer policies remain. All four minimal profiles may offer boosted own-marker routes or marker-plus-other-origin gathering through shared legal geometry/entry and a living trainer. Existing mixed-marker reveal preference, strategy and calibration remain separate.',
|
||||
evidence: ['game/bots.ts'],
|
||||
'Existing own/allied, inspection and truthAnswer policies remain. All four minimal profiles may offer boosted own-marker routes or marker-plus-other-origin gathering through shared legal geometry/entry and a living trainer. A shared-quote adapter also offers current owned Guild cross or actual Secret Ally cross/return without using denomination for price. Strategy and calibration remain deferred.',
|
||||
evidence: ['game/bots.ts', 'game/bot-guild-no-field-transport.ts'],
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Current-observation, printed value and physical counter distinctions remain recorded. Marker-as-one-force plus either ordinary Planetologist movement alternative is explicitly provisional; no joint FAQ or complete combinations are claimed.',
|
||||
evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md', 'docs/PLANETOLOGIST_RULES.md', 'docs/ATREIDES_FULL_PLAN_TIMING.md', 'docs/TRUTHTRANCE_FORCE_FACTS.md'],
|
||||
'Current-observation, printed value and physical counter distinctions remain recorded. Marker-as-one-force with Planetologist movement, Guild cross and actual Secret Ally face-down return is explicitly provisional. Native ally return stays prohibited; last-shipment history survives recall. No joint FAQ or complete combinations are claimed.',
|
||||
evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/NEXUS_GUILD_SECRET_ALLY_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md', 'docs/PLANETOLOGIST_RULES.md', 'docs/ATREIDES_FULL_PLAN_TIMING.md', 'docs/TRUTHTRANCE_FORCE_FACTS.md'],
|
||||
},
|
||||
{
|
||||
area: 'Verification',
|
||||
@@ -1169,6 +1170,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
detail:
|
||||
'Native trained-leader/shipment/movement cases and controlled submit callbacks cover concealed Planetologist range and gather routes plus legal continuation. Earlier scoped Truthtrance, inspection, allied and recovery evidence remains historical. Complete modes, broader combinations, browser/mobile/deployed and final assurance remain due.',
|
||||
evidence: [
|
||||
'tests/fixture-guild-no-field-transport.ts',
|
||||
'tests/guild-no-field-transport-engine.test.ts',
|
||||
'tests/guild-no-field-transport-quote.test.ts',
|
||||
'tests/bot-guild-no-field-transport.test.ts',
|
||||
'tests/fixture-planetologist-no-field-gather.ts',
|
||||
'tests/planetologist-no-field-gather-engine.test.ts',
|
||||
'tests/planetologist-no-field-gather-controls.test.tsx',
|
||||
|
||||
@@ -284,7 +284,7 @@ export function revealRicheseNoField(
|
||||
};
|
||||
}
|
||||
|
||||
/** Provisional Ecaz Betrayal composition: return the marker face down, without
|
||||
/** Provisional Ecaz Betrayal / authorized Guild-return composition: return face down, without
|
||||
* revealing or materializing its denomination. The last-shipment ban remains. */
|
||||
export function recallRicheseNoField(previous: RicheseNoField, tokenId: string): RicheseNoField {
|
||||
validateRicheseNoField(previous);
|
||||
|
||||
+36
-12
@@ -5,6 +5,7 @@ import { splitLocation, validGameLocation } from './board';
|
||||
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
|
||||
import { shipmentPaymentBounds } from './shipment-price';
|
||||
import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching';
|
||||
import { guildNoFieldSelection } from './guild-no-field-selection';
|
||||
|
||||
/** Preview the submitted Guild transport using only this seat's entitled view.
|
||||
* The authoritative action still revalidates the selection when it arrives.
|
||||
@@ -22,10 +23,27 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
if (g.guildTransportCanceled && !nexusAllowed)
|
||||
unavailableReasons.push('Karama prevents native Guild cross/return transport for this shipment opportunity.');
|
||||
const fromReserves = action.from === 'reserves';
|
||||
const namesNoField = action.noField !== undefined;
|
||||
const noField = namesNoField ? guildNoFieldSelection(g, action) : null;
|
||||
let total = 0;
|
||||
let origin: string | undefined;
|
||||
let origin = noField?.location.territory;
|
||||
const matchingGroup:Record<string,number>={};
|
||||
let validForces = true;
|
||||
let validForces = !namesNoField || !!noField;
|
||||
if (namesNoField && !noField)
|
||||
unavailableReasons.push('Choose your currently deployed No-Field and its current owner event.');
|
||||
if (namesNoField && (fromReserves || !action.forces ||
|
||||
typeof action.forces !== 'object' || Array.isArray(action.forces))) {
|
||||
validForces = false;
|
||||
unavailableReasons.push('Select the concealed No-Field from its board location with a physical force group, which may be empty.');
|
||||
}
|
||||
if (noField && !fromReserves) {
|
||||
if (!validGameLocation(g, noField.location.territory, noField.location.sector)) {
|
||||
validForces = false;
|
||||
unavailableReasons.push('The current No-Field source location is unavailable.');
|
||||
}
|
||||
if (noField.location.sector === g.storm)
|
||||
unavailableReasons.push('The selected No-Field source sector is in the storm.');
|
||||
}
|
||||
const unitSelection = (
|
||||
value: unknown,
|
||||
available: number,
|
||||
@@ -58,10 +76,6 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
);
|
||||
}
|
||||
};
|
||||
if (action.noField !== undefined)
|
||||
unavailableReasons.push(
|
||||
'Guild transport of a concealed No-Field is unavailable. Reveal the marker first or use its movement action.',
|
||||
);
|
||||
if (fromReserves) {
|
||||
if (nexus)
|
||||
unavailableReasons.push(
|
||||
@@ -71,6 +85,7 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
unavailableReasons.push(
|
||||
'Only allied Fremen may cross-ship southern reserves.',
|
||||
);
|
||||
if (!namesNoField)
|
||||
unitSelection(
|
||||
action.amount,
|
||||
p.reserves,
|
||||
@@ -113,16 +128,21 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
unavailableReasons.push('A selected source sector is in the storm.');
|
||||
}
|
||||
}
|
||||
if (!validForces || !total)
|
||||
if (total > 20) validForces = false;
|
||||
if (!validForces || (!total && !noField))
|
||||
unavailableReasons.push(
|
||||
fromReserves
|
||||
? 'Choose a whole number of available southern reserves and a valid elite allocation.'
|
||||
: namesNoField
|
||||
? 'Choose one current concealed marker and zero to twenty available physical forces from its territory.'
|
||||
: 'Choose available physical forces and a valid elite allocation from one territory.',
|
||||
);
|
||||
const to =
|
||||
typeof action.territory === 'string' ? action.territory : 'reserves';
|
||||
const sector = action.sector ?? 0;
|
||||
const noFieldWorld = !!noField && !!g.homeworlds?.worlds?.some(world => world.id === to);
|
||||
const validDestination =
|
||||
!noFieldWorld &&
|
||||
(action.territory == null || typeof action.territory === 'string') &&
|
||||
typeof sector === 'number' &&
|
||||
Number.isInteger(sector) &&
|
||||
@@ -132,7 +152,9 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
if (to === 'reserves' && p.faction !== 'guild' && !nexusAllowed)
|
||||
unavailableReasons.push('Only the Guild may return forces to reserves.');
|
||||
if (!validDestination)
|
||||
unavailableReasons.push('Choose a sector belonging to the destination.');
|
||||
unavailableReasons.push(noFieldWorld
|
||||
? 'A concealed No-Field may cross to an Arrakis location or return behind your shield, not enter a Homeworld.'
|
||||
: 'Choose a sector belonging to the destination.');
|
||||
else if (
|
||||
to !== 'reserves' &&
|
||||
!botEntryAllowed(g, p, to, sector as number, 'guildShip', origin)
|
||||
@@ -157,12 +179,13 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
unavailableReasons.push(
|
||||
'Guild transport requires your unused shipment opportunity.',
|
||||
);
|
||||
const billable = total + (noField ? 1 : 0);
|
||||
const cost =
|
||||
validForces && total > 0 && validDestination
|
||||
validForces && billable > 0 && validDestination
|
||||
? guildTransportCost(
|
||||
to,
|
||||
total,
|
||||
g.karamaShipping?.player === g.me || !g.guildRateCanceled,
|
||||
billable,
|
||||
nexusAllowed || g.karamaShipping?.player === g.me || !g.guildRateCanceled,
|
||||
)
|
||||
: null;
|
||||
const ownSpice = p.spice ?? 0;
|
||||
@@ -200,7 +223,8 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
}catch(error){if(!(error instanceof AdvisorTransportMatchingError))throw error;unavailableReasons.push(error.message);}
|
||||
}
|
||||
return {
|
||||
physicalForces: validForces && total > 0 ? total : undefined,
|
||||
physicalForces: validForces && (total > 0 || noField) ? total : undefined,
|
||||
...(noField ? { noField: true as const } : {}),
|
||||
quote: validShare
|
||||
? {
|
||||
cost,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { guildNoFieldTransportActions as actions } from '../game/bot-guild-no-field-transport';
|
||||
import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { guildTransportQuote } from '../game/transport-quote';
|
||||
import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options';
|
||||
|
||||
/** Public/owner-entitled projection fixtures for this bot adapter only.
|
||||
* These are not played-state or physical card/force acquisition programmes.
|
||||
*/
|
||||
function fixture(mode: 'native' | 'secret' = 'native', value: 0 | 3 | 5 = 0): GameView {
|
||||
const source = createGame('BOT-NF-GUILD', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
|
||||
source.players.push(newPlayer(mode === 'native' ? 'g' : 'f', 'Partner', mode === 'native' ? 'guild' : 'fremen'),
|
||||
newPlayer('a', 'Atreides', 'atreides'), newPlayer('e', 'Emperor', 'emperor'));
|
||||
const view = viewGame(source, 'r');
|
||||
Object.assign(view, { status: 'playing', phase: 5, turn: 2, active: 'r',
|
||||
order: source.players.map(player => player.id),
|
||||
storm: 18, response: null, decision: null, truthtrance: null, phaseOpening: null,
|
||||
automaticContinuationPending: false });
|
||||
for (const seat of view.players) {
|
||||
seat.forces = {};
|
||||
seat.ally = null;
|
||||
seat.shipped = false;
|
||||
}
|
||||
const own = view.players[0];
|
||||
own.spice = 2;
|
||||
own.reserves = 0;
|
||||
if (mode === 'native') {
|
||||
own.ally = 'g';
|
||||
view.players[1].ally = 'r';
|
||||
}
|
||||
view.aid.available = 0;
|
||||
const location = { territory: 'imperial_basin', sector: 10 };
|
||||
const tokenId = `nf-${value}`;
|
||||
const publicMarker = { deployed: { controller: 'r', location: { ...location }, effectiveForces: 1 as const }, lastUsed: null };
|
||||
own.noField = publicMarker;
|
||||
view.richeseNoField = {
|
||||
owner: 'r', event: 'owned-marker-view-source', public: publicMarker,
|
||||
private: { tokens: [{ id: 'nf-0', value: 0 }, { id: 'nf-3', value: 3 }, { id: 'nf-5', value: 5 }],
|
||||
lastShipped: tokenId, deployed: { tokenId, controller: 'r', location: { ...location } } },
|
||||
canShip: false, homeworldForcesEnabled: false, shipBlock: 'A marker is already deployed.',
|
||||
canReveal: true, revealBlock: null, canOfferAlly: false, allyOfferBlock: 'No ordinary allied offer.',
|
||||
allyPricing: null, allyDeclined: false, allyOffer: null,
|
||||
};
|
||||
if (mode === 'secret') {
|
||||
view.nexusCards = { card: 'guild', deckCount: 11, discardCount: 0,
|
||||
held: Object.fromEntries(view.players.map(player => [player.id, player.id === 'r'])),
|
||||
turn: null, choices: [], waiting: [] };
|
||||
view.nexusGuildSecretAlly = { event: JSON.stringify(['nexusGuildSecretAlly', view.turn, view.me]), blocked: null };
|
||||
}
|
||||
return view;
|
||||
}
|
||||
function legal(view: GameView, choices: Action[]): void {
|
||||
assert.ok(choices.length > 0);
|
||||
for (const choice of choices) {
|
||||
const quote = guildTransportQuote(view, choice);
|
||||
assert.deepEqual(quote.unavailableReasons, [], JSON.stringify(choice));
|
||||
assert.equal(botHomeworldShipmentPaymentAllowed(view, choice), true);
|
||||
assert.ok(quote.quote);
|
||||
assert.equal(quote.physicalForces, 0);
|
||||
assert.equal(choice.type, 'guildShip');
|
||||
assert.equal(choice.noField, view.richeseNoField!.private!.deployed!.tokenId);
|
||||
assert.equal(choice.event, view.richeseNoField!.event);
|
||||
assert.deepEqual(choice.forces, {});
|
||||
assert.equal(choice.allyPayment, quote.quote.pledgedPayment);
|
||||
assert.equal(choice.amount, undefined);
|
||||
assert.equal(choice.from, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
void test('a reciprocal Guild ally chooses quoted marker-only cross routes without revelation or native reserve return', () => {
|
||||
const view = fixture(), before = structuredClone(view), choices = actions(view);
|
||||
legal(view, choices);
|
||||
assert.ok(choices.every(choice => choice.territory !== 'imperial_basin' && choice.territory !== 'reserves'));
|
||||
assert.ok(choices.every(choice => choice.nexus === undefined));
|
||||
assert.deepEqual(actions(view), choices, 'enumeration remains stable');
|
||||
assert.deepEqual(view, before);
|
||||
const variant = fixture('native', 5);
|
||||
variant.players[0].reserves = 20;
|
||||
assert.deepEqual(actions(variant).map(choice => ({ ...choice, noField: undefined })),
|
||||
choices.map(choice => ({ ...choice, noField: undefined })), 'hidden value and reserves do not choose permission or destinations');
|
||||
view.players[1].ally = null;
|
||||
assert.deepEqual(actions(view), [], 'a one-sided alliance supplies no Guild permission');
|
||||
});
|
||||
|
||||
void test('an actual absent-Guild Secret Ally adds its original Nexus source and permits a concealed reserve return', () => {
|
||||
const view = fixture('secret'), choices = actions(view);
|
||||
legal(view, choices);
|
||||
const returned = choices.find(choice => choice.territory === 'reserves');
|
||||
assert.ok(returned);
|
||||
assert.equal(returned.sector, 0);
|
||||
assert.ok(choices.every(choice => choice.nexus === view.nexusGuildSecretAlly!.event));
|
||||
const wrongCard = structuredClone(view);
|
||||
wrongCard.nexusCards!.card = 'ecaz';
|
||||
assert.deepEqual(actions(wrongCard), []);
|
||||
const allied = structuredClone(view);
|
||||
allied.players[0].ally = 'f';
|
||||
allied.players[1].ally = 'r';
|
||||
assert.deepEqual(actions(allied), [], 'Secret Ally does not manufacture a real alliance');
|
||||
});
|
||||
|
||||
void test('stale or missing owned sources and closed shipment windows produce no marker transport packet', () => {
|
||||
const view = fixture(), old = actions(view);
|
||||
legal(view, old);
|
||||
view.richeseNoField!.event = 'renewed-owned-marker-source';
|
||||
assert.ok(actions(view).every(choice => choice.event === view.richeseNoField!.event));
|
||||
assert.ok(guildTransportQuote(view, old[0]).unavailableReasons.length > 0);
|
||||
const absent = fixture();
|
||||
absent.richeseNoField = null;
|
||||
assert.deepEqual(actions(absent), []);
|
||||
const foreign = fixture();
|
||||
foreign.richeseNoField!.private!.deployed!.controller = 'g';
|
||||
assert.deepEqual(actions(foreign), []);
|
||||
const used = fixture();
|
||||
used.players[0].shipped = true;
|
||||
assert.deepEqual(actions(used), []);
|
||||
const waiting = fixture();
|
||||
waiting.phaseOpening = { passed: [] };
|
||||
assert.deepEqual(actions(waiting), []);
|
||||
const otherTurn = fixture();
|
||||
otherTurn.active = 'g';
|
||||
assert.deepEqual(actions(otherTurn), []);
|
||||
});
|
||||
|
||||
void test('shared quotes exclude storm and occupied destinations and supply only the minimum pledged funding', () => {
|
||||
const sourceStorm = fixture();
|
||||
sourceStorm.storm = 10;
|
||||
assert.deepEqual(actions(sourceStorm), []);
|
||||
const view = fixture();
|
||||
const carthag = territory('carthag').sectors[0], arrakeen = territory('arrakeen').sectors[0];
|
||||
view.players[1].forces[`carthag:${carthag}`] = 1;
|
||||
view.players[2].forces[`arrakeen:${arrakeen}`] = 1;
|
||||
view.players[3].forces[`arrakeen:${arrakeen}`] = 1;
|
||||
const choices = actions(view);
|
||||
legal(view, choices);
|
||||
assert.ok(choices.every(choice => choice.territory !== 'carthag' && choice.territory !== 'arrakeen'));
|
||||
view.players[0].spice = 0;
|
||||
assert.deepEqual(actions(view), []);
|
||||
view.aid.available = 1;
|
||||
const funded = actions(view);
|
||||
legal(view, funded);
|
||||
assert.ok(funded.every(choice => choice.allyPayment === 1));
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import { actFamilyAtomicsNoField, familyAtomicsNoFieldPlayer, familyAtomicsNoFieldReload,
|
||||
familyAtomicsNoFieldInventory, familyAtomicsNoFieldClean, nextFamilyAtomicsNoFieldStep,
|
||||
type FamilyAtomicsNoFieldStep } from './fixture-family-atomics-no-field';
|
||||
|
||||
export type GuildNoFieldTransportStep = FamilyAtomicsNoFieldStep;
|
||||
export const actGuildNoFieldTransport = actFamilyAtomicsNoField;
|
||||
export const guildNoFieldTransportPlayer = familyAtomicsNoFieldPlayer;
|
||||
export const guildNoFieldTransportReload = familyAtomicsNoFieldReload;
|
||||
export const guildNoFieldTransportClean = (game: Game) => familyAtomicsNoFieldClean(game) &&
|
||||
!game.pendingGuildTransport && !game.pendingGuildBetrayal;
|
||||
export function guildNoFieldTransportInventory(game: Game) {
|
||||
const original = familyAtomicsNoFieldInventory(game), cards = game.nexusCards?.cards;
|
||||
if (cards) {
|
||||
validateNexusCards(cards, game.players);
|
||||
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(),
|
||||
[...NEXUS_FACTIONS].sort());
|
||||
}
|
||||
return original;
|
||||
}
|
||||
/** Quiet native decisions preserve the original shipment opportunity and held
|
||||
* Nexus. No optional future transport, redraw or allied token offer is chosen.
|
||||
*/
|
||||
export function nextGuildNoFieldTransport(game: Game): GuildNoFieldTransportStep | null {
|
||||
const betrayal = game.pendingGuildBetrayal;
|
||||
if (betrayal) {
|
||||
const actor = betrayal.required.find(actor => !betrayal.passed.includes(actor)); assert.ok(actor);
|
||||
return { actor, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
|
||||
if (decision?.kind === 'richeseAllyOpportunity') return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'nexusIxianReplacement')
|
||||
return { actor: decision.player, action: { type: 'nexusIxianReplacementPass', event: decision.event } };
|
||||
const phase = game.nexusCards?.phase, cards = game.nexusCards?.cards;
|
||||
if (phase?.stage === 'drawing' && cards) {
|
||||
const actor = phase.eligible.find(actor => !phase.done.includes(actor)); assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: phase.turn, card: cards.hands[actor], choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
}
|
||||
return nextFamilyAtomicsNoFieldStep(game);
|
||||
}
|
||||
export function advanceGuildNoFieldTransport(game: Game, until: (state: Game) => boolean,
|
||||
actions: GuildNoFieldTransportStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original transport chronology ended before its requested boundary');
|
||||
const next = nextGuildNoFieldTransport(game); assert.ok(next, 'Finish the actual native pending control');
|
||||
game = actGuildNoFieldTransport(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Guild/No-Field chronology did not reach its finite boundary');
|
||||
}
|
||||
export function settleGuildNoFieldTransport(game: Game, actions: GuildNoFieldTransportStep[] = []): Game {
|
||||
return advanceGuildNoFieldTransport(game, guildNoFieldTransportClean, actions);
|
||||
}
|
||||
export function passGuildNoFieldPermission(game: Game, actions: GuildNoFieldTransportStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'guildTransportPermission');
|
||||
while (game.response?.kind === 'guildTransportPermission') {
|
||||
const next = nextGuildNoFieldTransport(game); assert.ok(next); assert.equal(next.action.type, 'passResponse');
|
||||
game = actGuildNoFieldTransport(game, next.actor, next.action, actions);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
type SpiceSelection = { position: number; from: number; card: Game['spiceDeck'][number] };
|
||||
function selectSpice(game: Game, position: number, worm = false): SpiceSelection {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && (worm
|
||||
? 'worm' in card && !card.greatMaker && !card.suppressed
|
||||
: 'territory' in card && !card.discovery && card.sector !== game.storm &&
|
||||
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory)));
|
||||
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
}
|
||||
export function guildNoFieldTransportAction(game: Game, route: 'cross' | 'return' = 'cross'): Action {
|
||||
const own = guildNoFieldTransportPlayer(game, 'richese'), marker = own.noField?.deployed; assert.ok(marker && own.noFieldEvent);
|
||||
const forces = Object.fromEntries(Object.entries(own.forces).filter(([key, amount]) =>
|
||||
amount > 0 && key.startsWith(`${marker.location.territory}:`)));
|
||||
const nexus = viewGame(game, own.id).nexusGuildSecretAlly;
|
||||
return { type: 'guildShip', noField: marker.tokenId, event: own.noFieldEvent, forces,
|
||||
territory: route === 'return' ? 'reserves' : 'polar_sink', sector: 0, allyPayment: 0,
|
||||
...(nexus && !nexus.blocked ? { nexus: nexus.event } : {}) };
|
||||
}
|
||||
|
||||
/** Fresh public setup, original dealt Karama, real marker/army shipment and a
|
||||
* real next worm. Secret selects the original Guild face BEFORE its native
|
||||
* closing draw; no held hand is exchanged or direct draw helper invoked.
|
||||
*/
|
||||
export function createGuildNoFieldTransportFixture(options: {
|
||||
source?: 'native' | 'secret'; value?: NoFieldValue; companions?: boolean;
|
||||
} = {}) {
|
||||
const source = options.source ?? 'native', value = options.value ?? 0, companions = options.companions ?? false;
|
||||
const owner = 'richese', guild = source === 'native' ? 'guild' : null, canceler = 'atreides';
|
||||
let game = createGame('GUILDNFTRANSPORT', newPlayer(owner, 'Richese', 'richese'), true, ['choam']);
|
||||
joinGame(game, newPlayer(canceler, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(guild ?? 'emperor', guild ? 'Guild' : 'Emperor', guild ? 'guild' : 'emperor'));
|
||||
const initial = structuredClone(game), actions: GuildNoFieldTransportStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actGuildNoFieldTransport(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceGuildNoFieldTransport(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), from = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
};
|
||||
if (source === 'secret') act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2); assert.ok(karama); assert.equal(ordinary.length, 2);
|
||||
const deal = [ordinary[0], karama, ordinary[1]];
|
||||
const dealOrder = deal.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal.map(card => card.id));
|
||||
const original = guildNoFieldTransportInventory(game), firstSpiceOrder = [selectSpice(game, 0), selectSpice(game, 1)];
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && guildNoFieldTransportClean(state));
|
||||
let physicalShipment: ReturnType<typeof transition> | null = null;
|
||||
let markerShipment: ReturnType<typeof transition> | null = null;
|
||||
let token = '';
|
||||
const shipMarker = () => {
|
||||
const own = guildNoFieldTransportPlayer(game, owner), selected = own.noField!.tokens.find(token => token.value === value);
|
||||
assert.ok(selected && own.noFieldEvent); token = selected.id;
|
||||
markerShipment = transition(() => {
|
||||
act(owner, { type: 'ship', noField: token, event: own.noFieldEvent, territory: 'imperial_basin', sector: 9 });
|
||||
reach(guildNoFieldTransportClean);
|
||||
});
|
||||
assert.equal(guildNoFieldTransportPlayer(game, owner).reserves, guildNoFieldTransportPlayer(markerShipment.before, owner).reserves);
|
||||
};
|
||||
if (companions) {
|
||||
physicalShipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(guildNoFieldTransportClean);
|
||||
});
|
||||
assert.equal(guildNoFieldTransportPlayer(game, owner).forces['imperial_basin:9'], 1);
|
||||
} else shipMarker();
|
||||
reach(state => state.turn === 2 && state.phase === 0 && guildNoFieldTransportClean(state));
|
||||
const beforeWorm = structuredClone(game), nexusSpiceOrder = [selectSpice(game, 0, true), selectSpice(game, 1), selectSpice(game, 2)];
|
||||
let alliance: ReturnType<typeof transition> | null = null;
|
||||
let draw: ReturnType<typeof transition> | null = null;
|
||||
let nexusSelection: { from: number; card: 'guild' } | null = null;
|
||||
if (source === 'native') {
|
||||
reach(state => state.turn === 2 && state.phase === 1 && !!state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && guildNoFieldTransportClean(state));
|
||||
alliance = transition(() => {
|
||||
act(owner, { type: 'alliance', target: guild! }); act(guild!, { type: 'alliance', target: owner });
|
||||
});
|
||||
} else {
|
||||
reach(state => state.turn === 2 && state.phase === 1 && !!state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && guildNoFieldTransportClean(state));
|
||||
alliance = transition(() => {
|
||||
act(canceler, { type: 'alliance', target: 'emperor' }); act('emperor', { type: 'alliance', target: canceler });
|
||||
});
|
||||
assert.equal(guildNoFieldTransportPlayer(game, owner).ally, null);
|
||||
reach(state => state.turn === 2 && (state.phase > 1 || state.nexusCards?.phase?.stage === 'drawing'));
|
||||
assert.equal(game.phase, 1, 'The actual closing Nexus must offer unallied Richese its draw');
|
||||
const cards = game.nexusCards!.cards!, from = cards.deck.indexOf('guild'); assert.ok(from >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(from, 1)[0]); nexusSelection = { from, card: 'guild' };
|
||||
draw = transition(() => act(owner, { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 }));
|
||||
assert.equal(game.nexusCards!.cards!.hands[owner], 'guild');
|
||||
}
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && guildNoFieldTransportClean(state));
|
||||
const secondMovement = structuredClone(game);
|
||||
let thirdSpiceOrder: SpiceSelection[] = [];
|
||||
if (companions) {
|
||||
shipMarker();
|
||||
reach(state => state.turn === 3 && state.phase === 0 && guildNoFieldTransportClean(state));
|
||||
thirdSpiceOrder = [selectSpice(game, 0), selectSpice(game, 1)];
|
||||
reach(state => state.turn === 3 && state.phase === 5 && state.active === owner && guildNoFieldTransportClean(state));
|
||||
}
|
||||
assert.ok(markerShipment);
|
||||
const shipment = markerShipment as { before: Game; steps: GuildNoFieldTransportStep[]; after: Game };
|
||||
const own = guildNoFieldTransportPlayer(game, owner), event = own.noFieldEvent; assert.ok(event);
|
||||
assert.equal(own.noField!.deployed!.tokenId, token); assert.equal(own.shipped, false);
|
||||
assert.equal(own.noFieldEvent, guildNoFieldTransportPlayer(shipment.after, owner).noFieldEvent);
|
||||
assert.equal(own.forces['imperial_basin:9'] ?? 0, companions ? 1 : 0);
|
||||
assert.equal(own.tanks, 0); assert.equal(own.ally, guild);
|
||||
assert.equal(guildNoFieldTransportPlayer(game, canceler).hand.some(card => card.id === karama.id), true);
|
||||
if (source === 'secret') assert.equal(game.nexusCards!.cards!.hands[owner], 'guild');
|
||||
assert.deepEqual(guildNoFieldTransportInventory(game), original);
|
||||
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, source, value, companions,
|
||||
owner, actor: owner, guild, canceler, karama: karama.id, token, event, marker: structuredClone(own.noField),
|
||||
action: guildNoFieldTransportAction(game), returnAction: guildNoFieldTransportAction(game, 'return'),
|
||||
markerShipment: shipment, physicalShipment, beforeWorm, alliance, draw, nexusSelection, secondMovement,
|
||||
original, dealOrder, firstSpiceOrder, nexusSpiceOrder, thirdSpiceOrder, setupActions, actions,
|
||||
sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { setup: setupActions.length, markerShipment: shipment.steps.length,
|
||||
physicalShipment: physicalShipment?.steps.length ?? 0, alliance: alliance?.steps.length ?? 0,
|
||||
draw: draw?.steps.length ?? 0, source: actions.length },
|
||||
sourceDisclosure: `Actual public Advanced preview Richese/Atreides/${guild ? 'Guild' : 'Emperor'} +CHOAM-deck setup; ` +
|
||||
(source === 'secret' ? 'Nexus Cards is actually opted in before readiness/start. At the actual turn2 worm Nexus, Atreides and Emperor mutually ally while Richese remains unallied; this real settled alliance enables its native closing draw. The original Guild card is selected in the unopened Nexus deck before that actual draw; no held-card exchange or direct draw helper is used. '
|
||||
: 'The actual turn2 worm opens a Nexus and Richese/Guild both consent to their ordinary alliance; native Guild timing always WAITs. ') +
|
||||
'Only original unopened starting-card/Spice/Nexus positions and scalar lotteries are chosen. Atreides receives original Karama; all counters, cards, income and windows remain native. ' +
|
||||
(companions ? 'Actual ordinary1 shipment on turn1 precedes actual marker shipment on turn2 into the same Basin9; the original marker and physical companion remain until the unused turn3 shipment. '
|
||||
: 'Actual paid original marker shipment to empty Basin9 on turn1 remains until the unused turn2 shipment. ') +
|
||||
'Counts derive from recorded arrays. No hand, purse, army, phase, alliance, marker, source, receipt or transport is installed.' };
|
||||
}
|
||||
export type GuildNoFieldTransportFixture = ReturnType<typeof createGuildNoFieldTransportFixture>;
|
||||
@@ -18,8 +18,11 @@ export type NexusGuildCunningFixture = {
|
||||
observer: string;
|
||||
karama?: string;
|
||||
};
|
||||
/** Reuse genuine final native Guild setup, then exchange two physical Nexus
|
||||
* identities before any Cunning declaration; no grant or progress is invented. */
|
||||
/** CONTROLLED historical Movement boundary after genuine Guild setup. The
|
||||
* inherited fixture stages wallets, reserve pools and phase5; retire its old
|
||||
* phase2 Charity frame consistently with that declared jump. Exchange physical
|
||||
* Nexus identities before Cunning; no grant, progress or completed Charity turn
|
||||
* is presented as native history. */
|
||||
export function nexusGuildCunningFixture(
|
||||
options: Pick<
|
||||
NexusRicheseFixtureOptions,
|
||||
@@ -32,6 +35,8 @@ export function nexusGuildCunningFixture(
|
||||
> = {},
|
||||
): NexusGuildCunningFixture {
|
||||
const f = nexusRicheseFixture({ ...options, ownerFaction: 'guild' });
|
||||
assert.equal(f.g.phase, 5);
|
||||
delete f.g.charityOrder;
|
||||
const cards = f.g.nexusCards!.cards!,
|
||||
index = cards.deck.indexOf('guild');
|
||||
assert.ok(index >= 0);
|
||||
|
||||
@@ -25,8 +25,11 @@ export type NexusGuildSecretAllyFixtureOptions = Omit<
|
||||
'guild'
|
||||
>;
|
||||
};
|
||||
/** Genuine final native setup without Guild. Exchange physical Nexus identities
|
||||
* before any declaration; no effect receipt or shipment allowance is staged. */
|
||||
/** CONTROLLED historical Movement boundary after genuine setup: the inherited
|
||||
* Richese fixture stages its wallets, reserve pools and phase5 rather than
|
||||
* playing the intervening phases. Retire its old phase2 Charity frame with that
|
||||
* declared jump. Physical Nexus identities are exchanged before declaration;
|
||||
* no transport receipt or completed native Charity opportunity is invented. */
|
||||
export function nexusGuildSecretAllyFixture(
|
||||
options: NexusGuildSecretAllyFixtureOptions = {},
|
||||
) {
|
||||
@@ -36,6 +39,8 @@ export function nexusGuildSecretAllyFixture(
|
||||
options.opponentFaction ??
|
||||
(options.ownerFaction === 'harkonnen' ? 'beneGesserit' : 'harkonnen'),
|
||||
});
|
||||
assert.equal(f.g.phase, 5);
|
||||
delete f.g.charityOrder;
|
||||
const cards = f.g.nexusCards!.cards!,
|
||||
index = cards.deck.indexOf('guild');
|
||||
assert.ok(index >= 0);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { guildTransportQuote } from '../game/transport-quote';
|
||||
import { actGuildNoFieldTransport as act, advanceGuildNoFieldTransport as advance,
|
||||
createGuildNoFieldTransportFixture as fixture, guildNoFieldTransportClean as clean,
|
||||
guildNoFieldTransportInventory as inventory, guildNoFieldTransportPlayer as p,
|
||||
guildNoFieldTransportReload as reload, passGuildNoFieldPermission as passPermission,
|
||||
settleGuildNoFieldTransport as settle } from './fixture-guild-no-field-transport';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = reload(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function body(game: Game, actor: string) {
|
||||
const own = p(game, actor); return { reserves: own.reserves, tanks: own.tanks, forces: structuredClone(own.forces) };
|
||||
}
|
||||
|
||||
void test('native alliance and Secret closing draw sources retain original paid markers and actual arrays', () => {
|
||||
for (const source of ['native', 'secret'] as const) {
|
||||
const f = fixture({ source });
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.equal(f.sourceCounts.setup, f.setupActions.length); assert.equal(f.sourceCounts.source, f.sourceActions.length);
|
||||
assert.equal(f.sourceCounts.markerShipment, f.markerShipment.steps.length);
|
||||
assert.ok(f.markerShipment.steps.some(step => step.action.type === 'ship' && step.action.noField === f.token));
|
||||
assert.equal(p(f.markerShipment.before, f.owner).spice - p(f.markerShipment.after, f.owner).spice, 2);
|
||||
assert.equal(p(f.markerShipment.before, f.owner).reserves, p(f.game, f.owner).reserves);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.active, f.owner); assert.equal(p(f.game, f.owner).shipped, false);
|
||||
if (source === 'native') {
|
||||
assert.ok(f.alliance); assert.equal(f.sourceCounts.alliance, f.alliance.steps.length);
|
||||
assert.deepEqual(f.alliance.steps.map(step => step.action.type), ['alliance', 'alliance']);
|
||||
assert.ok(f.sourceActions.some(step => step.action.type === 'decision' && step.action.take === false));
|
||||
} else {
|
||||
assert.ok(f.draw && f.nexusSelection && f.alliance); assert.equal(f.sourceCounts.draw, f.draw.steps.length);
|
||||
assert.deepEqual(f.alliance.steps.map(step => [step.actor, step.action.type, step.action.target]),
|
||||
[['atreides', 'alliance', 'emperor'], ['emperor', 'alliance', 'atreides']]);
|
||||
assert.equal(p(f.draw.before, f.owner).ally, null);
|
||||
assert.deepEqual(f.draw.before.nexusCards!.phase!.eligible, [f.owner]);
|
||||
assert.equal(f.draw.before.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'guild');
|
||||
assert.ok(f.sourceActions.some(step => step.action.type === 'moduleOption' && step.action.module === 'nexusCards'));
|
||||
}
|
||||
assert.deepEqual(inventory(f.game), f.original);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native allied CROSS accepts concealed zero three and five through real permission and rate without materializing', () => {
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const f = fixture({ value }), before = body(f.game, f.owner), spice = p(f.game, f.owner).spice, guildSpice = p(f.game, f.guild!).spice;
|
||||
const quote = guildTransportQuote(viewGame(f.game, f.owner), f.action);
|
||||
assert.deepEqual(quote.unavailableReasons, []); assert.equal(quote.quote?.cost, 1);
|
||||
let game = act(reload(f.game), f.owner, f.action);
|
||||
assert.equal(game.response?.kind, 'guildTransportPermission'); assert.deepEqual(body(game, f.owner), before);
|
||||
assert.deepEqual(p(game, f.owner).noField, f.marker); assert.equal(p(game, f.owner).noFieldEvent, f.event);
|
||||
game = passPermission(game); assert.equal(game.response?.kind, 'guildRate');
|
||||
game = settle(game);
|
||||
assert.deepEqual(body(game, f.owner), before); assert.equal(p(game, f.owner).spice, spice - 1);
|
||||
assert.equal(p(game, f.guild!).spice, guildSpice + 1);
|
||||
assert.deepEqual(p(game, f.owner).noField!.deployed!.location, { territory: 'polar_sink', sector: 0 });
|
||||
assert.notEqual(p(game, f.owner).noFieldEvent, f.event);
|
||||
assert.equal(p(game, f.owner).noField!.lastShipped, f.token); assert.deepEqual(p(game, f.owner).noField!.tokens, f.marker!.tokens);
|
||||
assert.equal(p(game, f.owner).shipped, true); assert.equal(p(game, f.owner).moved, 0);
|
||||
reject(game, f.owner, f.action); assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual native permission cancellation spends only original Karama and retains concealed custody and unused shipment', () => {
|
||||
const f = fixture({ value: 5 }), before = body(f.game, f.owner), spice = p(f.game, f.owner).spice;
|
||||
let game = act(reload(f.game), f.owner, f.action);
|
||||
assert.equal(game.response?.kind, 'guildTransportPermission');
|
||||
game = settle(act(game, f.canceler, { type: 'card', mode: 'cancel', card: f.karama }));
|
||||
assert.deepEqual(body(game, f.owner), before); assert.equal(p(game, f.owner).spice, spice);
|
||||
assert.deepEqual(p(game, f.owner).noField, f.marker); assert.equal(p(game, f.owner).noFieldEvent, f.event);
|
||||
assert.equal(p(game, f.owner).shipped, false); assert.equal(viewGame(game, f.owner).guildTransportCanceled, true);
|
||||
assert.equal(viewGame(game, f.owner).guildRateCanceled, false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('actual rate cancellation pays full price once or withdraws an unfunded marker quote after a real bribe', () => {
|
||||
for (const unfunded of [false, true]) {
|
||||
const f = fixture({ value: 3 }); let game = reload(f.game);
|
||||
if (unfunded) game = act(game, f.owner, { type: 'bribe', target: f.canceler, amount: p(game, f.owner).spice - 1 });
|
||||
const before = body(game, f.owner), spice = p(game, f.owner).spice, guildSpice = p(game, f.guild!).spice;
|
||||
game = passPermission(act(game, f.owner, f.action)); assert.equal(game.response?.kind, 'guildRate');
|
||||
game = settle(act(game, f.canceler, { type: 'card', mode: 'cancel', card: f.karama }));
|
||||
assert.deepEqual(body(game, f.owner), before); assert.equal(viewGame(game, f.owner).guildRateCanceled, unfunded);
|
||||
if (unfunded) {
|
||||
assert.equal(p(game, f.owner).spice, spice); assert.equal(p(game, f.guild!).spice, guildSpice);
|
||||
assert.deepEqual(p(game, f.owner).noField, f.marker); assert.equal(p(game, f.owner).noFieldEvent, f.event);
|
||||
assert.equal(p(game, f.owner).shipped, false);
|
||||
} else {
|
||||
assert.equal(p(game, f.owner).spice, spice - 2); assert.equal(p(game, f.guild!).spice, guildSpice + 2);
|
||||
assert.equal(p(game, f.owner).shipped, true);
|
||||
assert.deepEqual(p(game, f.owner).noField!.deployed!.location, { territory: 'polar_sink', sector: 0 });
|
||||
}
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale foreign invalid physical and ordinary ally RETURN selections reject before transport costs', () => {
|
||||
const f = fixture();
|
||||
reject(f.game, f.owner, { ...f.action, event: 'old-owned-marker-event' });
|
||||
reject(f.game, f.owner, { ...f.action, noField: 'foreign-token' }); reject(f.game, f.canceler, f.action);
|
||||
reject(f.game, f.owner, { ...f.action, forces: { 'imperial_basin:9': 1 } });
|
||||
reject(f.game, f.owner, f.returnAction);
|
||||
assert.deepEqual(p(f.game, f.owner).noField, f.marker); assert.deepEqual(inventory(f.game), f.original);
|
||||
});
|
||||
|
||||
void test('actual absent-Guild Secret CROSS and RETURN handle concealed zero three and five and spend one original Nexus', () => {
|
||||
for (const value of [0, 3, 5] as const) for (const returns of [false, true]) {
|
||||
const f = fixture({ source: 'secret', value }), before = body(f.game, f.owner), spice = p(f.game, f.owner).spice;
|
||||
const proposed = returns ? f.returnAction : f.action;
|
||||
const quote = guildTransportQuote(viewGame(f.game, f.owner), proposed);
|
||||
assert.deepEqual(quote.unavailableReasons, []); assert.equal(quote.quote?.cost, 1);
|
||||
const game = settle(act(reload(f.game), f.owner, proposed));
|
||||
assert.deepEqual(body(game, f.owner), before); assert.equal(p(game, f.owner).spice, spice - 1);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
|
||||
assert.equal(p(game, f.owner).noField!.lastShipped, f.token); assert.notEqual(p(game, f.owner).noFieldEvent, f.event);
|
||||
assert.deepEqual(p(game, f.owner).noField!.tokens, f.marker!.tokens);
|
||||
if (returns) { assert.equal(p(game, f.owner).noField!.deployed, null); assert.equal(p(game, f.owner).noField!.hiddenReturn, true); }
|
||||
else assert.deepEqual(p(game, f.owner).noField!.deployed!.location, { territory: 'polar_sink', sector: 0 });
|
||||
assert.equal(p(game, f.owner).shipped, true); reject(game, f.owner, proposed);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a genuine earlier physical companion returns separately from its later concealed five marker', () => {
|
||||
const f = fixture({ source: 'secret', value: 5, companions: true }); assert.ok(f.physicalShipment);
|
||||
assert.equal(f.physicalShipment.before.turn, 1); assert.equal(f.markerShipment.before.turn, 2); assert.equal(f.game.turn, 3);
|
||||
assert.equal(p(f.game, f.owner).forces['imperial_basin:9'], 1);
|
||||
assert.deepEqual(f.returnAction.forces, { 'imperial_basin:9': 1 });
|
||||
const before = p(f.game, f.owner), quote = guildTransportQuote(viewGame(f.game, f.owner), f.returnAction);
|
||||
assert.deepEqual(quote.unavailableReasons, []); assert.equal(quote.quote?.cost, 1);
|
||||
const game = settle(act(reload(f.game), f.owner, f.returnAction)), after = p(game, f.owner);
|
||||
assert.equal(after.reserves, before.reserves + 1); assert.equal(after.tanks, before.tanks);
|
||||
assert.deepEqual(after.forces, {}); assert.equal(after.noField!.deployed, null); assert.equal(after.noField!.hiddenReturn, true);
|
||||
assert.equal(after.noField!.lastShipped, f.token); assert.deepEqual(after.noField!.tokens, f.marker!.tokens);
|
||||
assert.equal(after.spice, before.spice - 1); assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('Secret hidden return preserves the same-token next-shipment ban at an actual later unused opportunity', () => {
|
||||
const f = fixture({ source: 'secret', value: 0 });
|
||||
let game = settle(act(reload(f.game), f.owner, f.returnAction));
|
||||
const turn = game.turn;
|
||||
// The remaining original unopened land faces are selected before their real draws.
|
||||
game = advance(game, state => state.turn === turn + 1 && state.phase === 0 && clean(state));
|
||||
for (const position of [0, 1]) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && card.territory !== 'imperial_basin');
|
||||
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
|
||||
}
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.owner && clean(state));
|
||||
assert.equal(p(game, f.owner).shipped, false); assert.equal(p(game, f.owner).noField!.lastShipped, f.token);
|
||||
reject(game, f.owner, { type: 'ship', noField: f.token, event: p(game, f.owner).noFieldEvent, territory: 'polar_sink', sector: 0 });
|
||||
const other = p(game, f.owner).noField!.tokens.find(token => token.id !== f.token)!;
|
||||
game = settle(act(game, f.owner, { type: 'ship', noField: other.id, event: p(game, f.owner).noFieldEvent, territory: 'polar_sink', sector: 0 }));
|
||||
assert.equal(p(game, f.owner).noField!.deployed!.tokenId, other.id); assert.equal(p(game, f.owner).reserves, 20);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles propose a legal owned native or Secret marker transport without reading its value', () => {
|
||||
for (const source of ['native', 'secret'] as const) {
|
||||
const f = fixture({ source, value: 0 });
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
|
||||
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const proposed = botActions(view).find(action => action.type === 'guildShip' && action.noField === f.token);
|
||||
assert.ok(proposed, `${source}/${difficulty} supplies the actual owned marker transport`);
|
||||
const game = settle(act(reload(f.game), f.owner, proposed));
|
||||
assert.equal(p(game, f.owner).shipped, true); assert.equal(p(game, f.owner).reserves, p(f.game, f.owner).reserves);
|
||||
assert.equal(p(game, f.owner).tanks, 0); assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { createRicheseNoField, deployRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
import { guildTransportQuote } from '../game/transport-quote';
|
||||
import { quoteNexusGuildSecretShipment } from '../game/nexus-guild-secret-ally';
|
||||
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '../game/nexus-guild-secret-ally-options';
|
||||
import { ShipmentQuote } from '../components/shipment-quote';
|
||||
|
||||
/** Controlled unit source, not an original-game chronology: initialize public
|
||||
* phase/armies and owned marker custody, then consume the real view projection.
|
||||
* Secret Ally offers below are explicitly public-view construction supplements;
|
||||
* native declaration, disposal and transport outcomes belong to engine tests. */
|
||||
function projected({ value = 0, forces = {}, secret = false }: {
|
||||
value?: NoFieldValue;
|
||||
forces?: Record<string, number>;
|
||||
secret?: boolean;
|
||||
} = {}) {
|
||||
const game = createGame('GUILDNFFORCEQUOTE', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
|
||||
game.players.push(newPlayer('other', secret ? 'Atreides' : 'Guild', secret ? 'atreides' : 'guild'),
|
||||
newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, { status: 'playing', phase: 5, turn: 2, active: 'r',
|
||||
order: ['r', 'other', 'h'], movementRemaining: ['r', 'other', 'h'], storm: 18 });
|
||||
const owner = game.players.find(player => player.id === 'r')!;
|
||||
owner.spice = 30;
|
||||
owner.forces = { ...forces };
|
||||
owner.reserves = 20 - Object.values(forces).reduce((sum, count) => sum + count, 0);
|
||||
const stock = createRicheseNoField(['controlled-zero', 'controlled-three', 'controlled-five']);
|
||||
const token = stock.tokens.find(candidate => candidate.value === value)!;
|
||||
owner.noField = deployRicheseNoField(stock, { tokenId: token.id, controller: owner.id,
|
||||
location: { territory: 'imperial_basin', sector: 10 } });
|
||||
owner.noFieldEvent = 'controlled-current-owner-event';
|
||||
if (!secret) {
|
||||
owner.ally = 'other';
|
||||
game.players.find(player => player.id === 'other')!.ally = owner.id;
|
||||
}
|
||||
const view = viewGame(game, owner.id);
|
||||
if (secret) {
|
||||
view.nexusCards = { card: 'guild', held: { r: true, other: false, h: false },
|
||||
deckCount: 11, discardCount: 0, turn: view.turn, choices: [], waiting: [] };
|
||||
view.nexusGuildSecretAlly = { event: JSON.stringify(['nexusGuildSecretAlly', view.turn, view.me]), blocked: null };
|
||||
}
|
||||
assert.ok(view.richeseNoField?.private?.deployed);
|
||||
const action: Action = { type: 'guildShip', from: 'imperial_basin:10', forces: { ...forces },
|
||||
territory: 'carthag', sector: 11, noField: token.id, event: view.richeseNoField.event };
|
||||
return { view, action };
|
||||
}
|
||||
function secretAction(view: GameView, action: Action) {
|
||||
const event = view.nexusGuildSecretAlly?.event; assert.ok(event);
|
||||
return nexusGuildSecretAllyAction(view, event, action);
|
||||
}
|
||||
|
||||
void test('Secret Ally prices one marker separately from zero to twenty physical forces and preserves physical-only bounds', () => {
|
||||
assert.deepEqual(quoteNexusGuildSecretShipment('stronghold', 1, true), {
|
||||
physicalAmount: 0, cost: 1, source: 'nexusGuildSecretAlly',
|
||||
});
|
||||
assert.deepEqual(quoteNexusGuildSecretShipment('stronghold', 21, true), {
|
||||
physicalAmount: 20, cost: 11, source: 'nexusGuildSecretAlly',
|
||||
});
|
||||
assert.deepEqual(quoteNexusGuildSecretShipment('reserves', 20), {
|
||||
physicalAmount: 20, cost: 10, source: 'nexusGuildSecretAlly',
|
||||
});
|
||||
assert.deepEqual(quoteNexusGuildSecretShipment('homeworld', 1), {
|
||||
physicalAmount: 1, cost: 1, source: 'nexusGuildSecretAlly',
|
||||
});
|
||||
assert.throws(() => quoteNexusGuildSecretShipment('homeworld', 1, true));
|
||||
assert.throws(() => quoteNexusGuildSecretShipment('stronghold', 21));
|
||||
for (const amount of [0, -1, 1.5, 22])
|
||||
assert.throws(() => quoteNexusGuildSecretShipment('stronghold', amount, true));
|
||||
});
|
||||
|
||||
void test('a concealed marker including zero quotes one billable unit and displays zero physical forces plus one marker', () => {
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const { view, action } = projected({ value });
|
||||
const quote = guildTransportQuote(view, action);
|
||||
assert.deepEqual(quote.unavailableReasons, []);
|
||||
assert.equal(quote.physicalForces, 0);
|
||||
assert.equal(quote.noField, true);
|
||||
assert.equal(quote.quote?.cost, 1);
|
||||
const html = renderToStaticMarkup(createElement(ShipmentQuote, { label: 'Guild cross-shipment', ...quote }));
|
||||
assert.match(html, /Physical forces selected<\/dt><dd[^>]*>0<\/dd>/);
|
||||
assert.match(html, /Concealed No-Field selected<\/dt><dd[^>]*>1 marker<\/dd>/);
|
||||
assert.match(html, /excluded from the physical force count/);
|
||||
assert.doesNotMatch(html, /Reveal|Materialize|5 forces|3 forces/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a mixed same-territory group bills twenty physical counters plus its marker once', () => {
|
||||
const { view, action } = projected({ forces: { 'imperial_basin:10': 12, 'imperial_basin:11': 8 } });
|
||||
const quote = guildTransportQuote(view, action);
|
||||
assert.deepEqual(quote.unavailableReasons, []);
|
||||
assert.equal(quote.physicalForces, 20);
|
||||
assert.equal(quote.quote?.cost, 11);
|
||||
const ordinary = { ...action }; delete ordinary.noField; delete ordinary.event;
|
||||
const physical = guildTransportQuote(view, ordinary);
|
||||
assert.equal(physical.physicalForces, 20);
|
||||
assert.equal(physical.quote?.cost, 10);
|
||||
assert.equal(physical.noField, undefined);
|
||||
const html = renderToStaticMarkup(createElement(ShipmentQuote, physical));
|
||||
assert.doesNotMatch(html, /Concealed No-Field selected|one shipment unit/);
|
||||
});
|
||||
|
||||
void test('current marker identity event source storm and physical quantities reject forged selections in quote and adapter', () => {
|
||||
const source = projected({ secret: true, forces: { 'imperial_basin:10': 2, 'imperial_basin:11': 1 } });
|
||||
const invalid: Action[] = [
|
||||
{ ...source.action, noField: 'another-token' },
|
||||
{ ...source.action, event: 'old-owner-event' },
|
||||
{ ...source.action, forces: { 'imperial_basin:10': 3 } },
|
||||
{ ...source.action, forces: { 'imperial_basin:10': -1 } },
|
||||
{ ...source.action, forces: { 'imperial_basin:10': 0.5 } },
|
||||
{ ...source.action, forces: [] },
|
||||
{ ...source.action, from: 'reserves' },
|
||||
];
|
||||
for (const action of invalid) {
|
||||
assert.equal(secretAction(source.view, action), null);
|
||||
assert.ok(guildTransportQuote(source.view, { ...action, nexus: source.view.nexusGuildSecretAlly!.event }).unavailableReasons.length);
|
||||
}
|
||||
for (const storm of [10, 11]) {
|
||||
const view = structuredClone(source.view); view.storm = storm;
|
||||
assert.equal(secretAction(view, source.action), null);
|
||||
assert.match(guildTransportQuote(view, { ...source.action, nexus: view.nexusGuildSecretAlly!.event }).unavailableReasons.join(' '), /source sector is in the storm/);
|
||||
}
|
||||
const foreign = structuredClone(source.view);
|
||||
foreign.richeseNoField!.public.deployed!.controller = 'other';
|
||||
assert.equal(secretAction(foreign, source.action), null);
|
||||
const mixedOrigin = structuredClone(source.view);
|
||||
mixedOrigin.players.find(player => player.id === mixedOrigin.me)!.forces['red_chasm:7'] = 1;
|
||||
const outside = { ...source.action, forces: { 'red_chasm:7': 1 } };
|
||||
assert.equal(secretAction(mixedOrigin, outside), null);
|
||||
assert.match(guildTransportQuote(mixedOrigin, { ...outside, nexus: mixedOrigin.nexusGuildSecretAlly!.event }).unavailableReasons.join(' '), /one territory/);
|
||||
});
|
||||
|
||||
void test('ordinary Guild alliance grants cross-only while an explicit current absent-Guild source grants return at its own rate', () => {
|
||||
const native = projected({ forces: { 'imperial_basin:10': 2 } });
|
||||
const returned = { ...native.action, territory: 'reserves', sector: 0 };
|
||||
assert.match(guildTransportQuote(native.view, returned).unavailableReasons.join(' '), /Only the Guild may return/);
|
||||
native.view.guildRateCanceled = true;
|
||||
assert.equal(guildTransportQuote(native.view, native.action).quote?.cost, 3);
|
||||
const source = projected({ secret: true, forces: { 'imperial_basin:10': 2 } });
|
||||
source.view.guildRateCanceled = true;
|
||||
assert.equal(nexusGuildSecretAllyQuote(source.view, 'reserves', 21, true)?.physicalAmount, 20);
|
||||
assert.equal(nexusGuildSecretAllyQuote(source.view, 'reserves', 21), null);
|
||||
for (const territory of ['carthag', 'reserves']) {
|
||||
const original = { ...source.action, territory, sector: territory === 'reserves' ? 0 : 11 };
|
||||
const selected = secretAction(source.view, original); assert.ok(selected);
|
||||
assert.deepEqual(selected, { ...original, nexus: source.view.nexusGuildSecretAlly!.event });
|
||||
const quote = guildTransportQuote(source.view, selected);
|
||||
assert.deepEqual(quote.unavailableReasons, []);
|
||||
assert.equal(quote.physicalForces, 2);
|
||||
assert.equal(quote.quote?.cost, 2);
|
||||
}
|
||||
assert.equal(secretAction(source.view, { ...source.action, type: 'ship', amount: 1 }), null);
|
||||
assert.equal(secretAction(source.view, { ...source.action, type: 'homeworldShip', sources: {} }), null);
|
||||
assert.equal(nexusGuildSecretAllyAction(source.view, 'old-nexus-event', source.action), null);
|
||||
});
|
||||
Reference in new issue
Block a user