Connect optional Smuggler reserve shipment and saved interception
Prototype one free accompanying force into an empty territory with explicit opt-out, shared human/AI pricing, physical elite custody and Guild recipient accounting. Bind pending quotes to their unique saved Guild decision; preserve legacy unstamped full-price declarations. Smuggler battle collection and combined modules remain unfinished and gated. Repair a randomly dealt-out Karama fixture by relocating its actual physical card without changing inventory. Validation: 70 focused tests; typecheck, lint, 4477 offline tests, production build and 43 HTTP tests passed. Independent rules/persistence review completed, including interruption and cancellation. Browser checked the price toggle, three arrivals for two spice, and refresh with unchanged private cards. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not use Smuggler. Preserved all 427 opening rooms and original seat/recovery rows (461 rooms after QA/HTTP). SQLite integrity and foreign keys passed. Server remained healthy after the reported power outage; no restart or reset. Verified source tree: bf6e02bf6873ca6d6b01c83481489f55b2c7db0184a7056364343440dae88803
This commit is contained in:
1 parent
06f46c6f79
commit
9381ef140d
18 files changed
+1015
-25
No files matched your search
@@ -124,6 +124,8 @@ import {
|
||||
import { LeaderPortrait } from './leader-portrait';
|
||||
import { ShipmentQuote } from './shipment-quote';
|
||||
import { reserveShipmentCost } from '@/game/shipment-price';
|
||||
import { quoteSmugglerShipment } from '@/game/smuggler-shipment';
|
||||
import { SmugglerShipmentChoice } from './smuggler-shipment';
|
||||
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '@/game/nexus-guild-secret-ally-options';
|
||||
import { nexusRicheseAction, nexusRicheseQuote } from '@/game/nexus-richese-options';
|
||||
import { guildTransportQuote } from '@/game/transport-quote';
|
||||
@@ -304,6 +306,7 @@ export function GameTable({
|
||||
const [amount, setAmount] = useState(1);
|
||||
const [richeseShipmentEvent, setRicheseShipmentEvent] = useState('');
|
||||
const useRicheseShipment = !!g.nexusRichese && richeseShipmentEvent === g.nexusRichese.event;
|
||||
const [useSmuggler, setUseSmuggler] = useState(true);
|
||||
const [bidDraft, setBidDraft] = useState({ auction: '', value: 1 });
|
||||
const auctionKey = `${g.code}/${g.turn}/${g.auction?.remaining ?? 0}`;
|
||||
const minimumBid = (g.auction?.bid ?? 0) + 1;
|
||||
@@ -507,10 +510,13 @@ export function GameTable({
|
||||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally) ||
|
||||
g.karamaShipping?.player === me.id,
|
||||
};
|
||||
const smugglerOffer = validShipmentAmount && !useGuildSecret && !useRicheseShipment
|
||||
? quoteSmugglerShipment(g, me.id, selected, amount) : null;
|
||||
const smugglerShipment = useSmuggler ? smugglerOffer : null;
|
||||
const shipmentCost = validShipmentAmount
|
||||
? useGuildSecret ? (nexusGuildSecretAllyQuote(g, selected, amount)?.cost ?? null) : useRicheseShipment
|
||||
? (nexusRicheseQuote(g, selected, amount)?.cost ?? null)
|
||||
: reserveShipmentCost(shipmentRate, territory(selected).type, amount)
|
||||
: reserveShipmentCost(shipmentRate, territory(selected).type, amount - (smugglerShipment ? 1 : 0))
|
||||
: null;
|
||||
if (useGuildSecret && shipmentCost === null && !g.nexusGuildSecretAlly?.blocked) shipmentProblems.push('Choose a current Guild Secret Ally shipment.');
|
||||
if (useRicheseShipment && shipmentCost === null && !g.nexusRichese?.blocked)
|
||||
@@ -3808,7 +3814,7 @@ export function GameTable({
|
||||
/>
|
||||
<ShipmentQuote
|
||||
id="reserve-shipment-quote"
|
||||
physicalForces={useRicheseShipment ? amount : undefined}
|
||||
physicalForces={useRicheseShipment || smugglerShipment ? amount : undefined}
|
||||
quote={shipmentQuote}
|
||||
funding={{
|
||||
ownSpice: me.spice ?? 0,
|
||||
@@ -3816,6 +3822,7 @@ export function GameTable({
|
||||
}}
|
||||
unavailableReasons={shipmentProblems}
|
||||
/>
|
||||
<SmugglerShipmentChoice quote={smugglerOffer} use={useSmuggler} busy={busy || !!g.truthtrance} onChange={setUseSmuggler} />
|
||||
<Button
|
||||
className="game-action"
|
||||
aria-describedby="reserve-shipment-quote"
|
||||
@@ -3828,6 +3835,7 @@ export function GameTable({
|
||||
const shipment: Action = {
|
||||
type: 'ship', elite: eliteAmount, ...payment,
|
||||
territory: selected, sector, amount,
|
||||
...(smugglerOffer ? { smuggler: useSmuggler } : {}),
|
||||
...(homeworldSources ? { homeworldSources } : {}),
|
||||
};
|
||||
const action = useGuildSecret ? nexusGuildSecretAllyAction(g, guildSecretEvent, shipment) : useRicheseShipment
|
||||
|
||||
@@ -113,7 +113,7 @@ export function LeaderSkillsPanel({
|
||||
modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz,
|
||||
Killer Medic and Prana-Bindu Adept, plus Planetologist movement and
|
||||
Special-card battle use, Suk Graduate force rescue, Mentat and Bureaucrat
|
||||
battle scores, Sandmaster victory spice and Rihani inspections/exchanges. Other Leader Skill effects are
|
||||
battle scores, Sandmaster victory spice, Rihani inspections/exchanges and optional Smuggler reserve shipping. Other Leader Skill effects are
|
||||
unfinished.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SmugglerShipment } from '../game/smuggler-shipment';
|
||||
|
||||
export function SmugglerShipmentChoice({
|
||||
quote,
|
||||
use,
|
||||
busy,
|
||||
onChange,
|
||||
}: {
|
||||
quote: SmugglerShipment | null;
|
||||
use: boolean;
|
||||
busy: boolean;
|
||||
onChange: (use: boolean) => void;
|
||||
}) {
|
||||
if (!quote) return null;
|
||||
return (
|
||||
<section aria-label="Smuggler shipment choice" className="notice space-y-2">
|
||||
<label className="decision-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={use}
|
||||
disabled={busy}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
Use Smuggler for one free accompanying force
|
||||
</label>
|
||||
<output className="fine block">
|
||||
All {quote.amount} selected forces leave reserves.
|
||||
{use
|
||||
? ` The price is for ${quote.amount - 1}; the free force is included in your selected total.`
|
||||
: ' The ordinary price applies to every selected force.'}{' '}
|
||||
The destination is currently empty.
|
||||
</output>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ Planetologist now also has movement and base green-Special battle controls;
|
||||
[Suk Graduate](SUK_GRADUATE_RULES.md) redirects actual ordinary/elite casualties
|
||||
through readable rescue choices without creating counters. [Battle effects](LEADER_BATTLE_EFFECTS.md)
|
||||
now connect Rihani inspection/exchange, Mentat/Bureaucrat scoring and Sandmaster
|
||||
victory spice. Their other bands and Spice Banker, Diplomat and Smuggler remain
|
||||
victory spice. [Smuggler shipping](SMUGGLER_SHIPMENT.md) now connects its normal reserve bonus
|
||||
without adding any physical component. Other unfinished bands, Spice Banker and Diplomat remain
|
||||
missing. This supersedes the historical missing-inventory
|
||||
rows below only for Leader Skills. See [source inventory](LEADER_SKILLS_RULES.md),
|
||||
[capture interpretation](LEADER_SKILLS_CAPTURE.md) and [prototype evidence](LEADER_SKILLS_RUNTIME.md).
|
||||
|
||||
@@ -4,6 +4,14 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
|
||||
|
||||
## Evidence and source of truth
|
||||
|
||||
- [Smuggler reserve shipment](SMUGGLER_SHIPMENT.md) connects the optional free
|
||||
companion, total physical count, Guild rounding, controls, all four AI profiles
|
||||
and saved interception. Independent review added missing-decision rejection.
|
||||
Browser opt-out, actual shipment and refresh are exercised in a new room.
|
||||
Battle collection and combined routes remain missing. After the latest outage,
|
||||
the server was healthy and all 427 opening rooms remained unchanged; a fresh
|
||||
backup captured 428. Broad results and final preservation are in the commit.
|
||||
|
||||
- [Leader battle effects](LEADER_BATTLE_EFFECTS.md) now connect both Rihani bands
|
||||
and lower Mentat, Bureaucrat and Sandmaster effects. Private controls, all four
|
||||
AI profiles, JSON/SQLite continuation and concurrent exchanges are covered.
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 September 2026 — Smuggler reserve shipment prototype
|
||||
|
||||
[Smuggler shipping](SMUGGLER_SHIPMENT.md) connects one optional free accompanying
|
||||
force for an empty-territory off-planet shipment. The shared quote preserves
|
||||
actual reserve/elite custody, Guild rounding and real recipient payments. UI
|
||||
controls and all four AI profiles explicitly choose the bonus; declining and
|
||||
legacy unstamped declarations preserve ordinary pricing.
|
||||
|
||||
Independent review found saved quotes could lose their required Guild decision.
|
||||
Read/action integrity now binds the declaration to its unique current or saved
|
||||
interception, rejecting missing/mismatched decisions or removed discounts.
|
||||
The first broad run also exposed a random Discovery fixture that searched only
|
||||
the undealt deck for Karama. It now relocates an existing card from its actual
|
||||
zone and asserts the complete physical card inventory is unchanged.
|
||||
SQLite restart, concurrent allowance, stale replay and old quote recovery pass.
|
||||
The browser exercised the price toggle, three physical arrivals for two spice
|
||||
and refresh with the same private hand. Two Basic/Advanced samples finished
|
||||
without rejected candidates but did not use Smuggler; targeted checks cover it.
|
||||
|
||||
The latest outage left the existing server healthy. All 427 opening rooms were
|
||||
unchanged and a new backup captured 428 rooms. No restart or reset was needed.
|
||||
Broad results and final preservation are in the checkpoint commit and private
|
||||
source-bound report. The lower battle effect and combined routes remain missing;
|
||||
no rules-mode or publication gate is opened.
|
||||
|
||||
## 13 September 2026 — Leader battle effects prototype
|
||||
|
||||
[Battle effects](LEADER_BATTLE_EFFECTS.md) connects lower Mentat +2 strength,
|
||||
|
||||
@@ -52,7 +52,8 @@ publisher instruction to announce a name.
|
||||
[Battle-effects follow-up](LEADER_BATTLE_EFFECTS.md) connects both Rihani bands
|
||||
and the lower Mentat, Bureaucrat and Sandmaster effects. Their normal questioning,
|
||||
payments and movement collection remain missing, along with all Spice Banker,
|
||||
Diplomat and Smuggler effects. Planetologist and Suk Graduate have the connected
|
||||
Diplomat effects and Smuggler battle collection. [Smuggler shipping](SMUGGLER_SHIPMENT.md)
|
||||
connects its optional normal reserve bonus. Planetologist and Suk Graduate have the connected
|
||||
prototypes described below.
|
||||
|
||||
Captured-card replacement entitlement remains unresolved. The prototype rejects
|
||||
@@ -64,8 +65,8 @@ Before enabling them, add response-driven automatic revival continuation
|
||||
coverage; the current before/after revival observer does not certify deferred
|
||||
Tleilaxu response chains. The other source-contract timing questions also remain
|
||||
open. Planetologist now has bounded movement and battle controls as described below.
|
||||
Remaining normal-band payments, questions, collection and the three missing
|
||||
skill families still need connected effects.
|
||||
Remaining normal-band payments, questions, collection and the remaining
|
||||
skill bands still need connected effects.
|
||||
|
||||
## Planetologist follow-up
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
|
||||
|
||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||
| --- | --- | --- |
|
||||
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. No-Field plus a separate free force is officially permitted but still awaits integration. Legacy unstamped declarations keep their quoted full price. | [Source contract and prototype](SMUGGLER_SHIPMENT.md) |
|
||||
| Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Multiple piles and Face Dancer composition stay guarded. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md) |
|
||||
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
|
||||
| Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) |
|
||||
@@ -17,7 +18,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
|
||||
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
|
||||
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
|
||||
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
|
||||
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; seven skills and combinations remain missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
|
||||
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster and normal Smuggler now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
|
||||
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine have bounded prototypes; Orgiz and contested/mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
|
||||
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
|
||||
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Smuggler normal shipment prototype
|
||||
|
||||
13 September 2026. **Prototyped**, with **Partial** rules coverage. Public module
|
||||
starts and publication remain gated. The lower battle effect and combined
|
||||
expansion routes are unfinished.
|
||||
|
||||
## Source contract
|
||||
|
||||
The physical Smuggler card supplies one optional free accompanying force when
|
||||
shipping from off planet into an empty territory. The selected total includes
|
||||
that force: ship three actual counters, price two. At least one paid companion
|
||||
is needed. Empty strongholds qualify; existing forces anywhere in the territory,
|
||||
including friendly forces, advisors and No-Field presence, prevent the benefit.
|
||||
Spice alone does not occupy a territory. Determine emptiness before arrival.
|
||||
See the [publisher CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf),
|
||||
pages 8–9 and 12, and the [designer walkthrough](https://www.youtube.com/watch?v=XT_azRVLq_0&t=724s),
|
||||
12:04–12:19.
|
||||
|
||||
Apply ordinary Guild/ally rounding after removing the free counter from the
|
||||
priced count. Classic Fremen reserves are on planet, so their ordinary Great
|
||||
Flat reinforcements retain their existing rule. See the
|
||||
[base rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf),
|
||||
pages 9–11 and 13, and [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf), page 5.
|
||||
|
||||
The CHOAM & Richese FAQ explicitly permits a Richese No-Field with a separate
|
||||
free Smuggler force into empty territory. That valid combination still needs
|
||||
concealed-counter integration here; its current guard is an implementation gap.
|
||||
No new material user ruling was required for the ordinary reserve route.
|
||||
|
||||
## Connected behavior
|
||||
|
||||
A shared public-custody quote powers the engine, UI, bots and shipment-promise
|
||||
completion search. Only a living native trainer grants the normal effect;
|
||||
captured and foreign-ghola leaders do not. The selected amount remains the
|
||||
actual reserve withdrawal and board arrival, including ordinary/elite identity.
|
||||
The existing payment pipeline receives the actual discounted cost.
|
||||
|
||||
The checkbox starts selected when an eligible shipment is chosen. Declining
|
||||
preserves the full payment, which can matter to recipients and other payment
|
||||
powers. API omission and explicit false also retain ordinary pricing. All four
|
||||
AI profiles have a legal path that explicitly chooses the bonus, including a
|
||||
two-force shipment when only one force can be afforded at the ordinary rate.
|
||||
This policy is not full skill strategy or difficulty calibration.
|
||||
|
||||
Advanced Guild interception stores the trainer, physical total and original
|
||||
price before payment or departure. Views, automatic normalization and actions
|
||||
validate the pending declaration against its unique current or saved Guild
|
||||
decision. Missing/mismatched decisions, stale leaders, changed occupancy and
|
||||
prices no longer explained by the saved discount record reject before mutation. Actual legacy unstamped
|
||||
shipments retain their full price and may omit the older optional turn field.
|
||||
|
||||
## Verification and remaining scope
|
||||
|
||||
Focused tests cover total-versus-priced counts, desert costs, Guild rounding,
|
||||
Fremen reinforcement, advisors, hidden/captured custody, opt-out, all four bots,
|
||||
JSON recovery, malformed saved decisions, elite conservation, private SQLite
|
||||
views, concurrent allowance, nested decision restoration, cancellation and stale replay. Independent review identified the
|
||||
saved-decision binding gap, which was fixed before the checkpoint checks.
|
||||
|
||||
A fresh genuine two-seat setup assigned Smuggler to Hasimir Fenring in an
|
||||
isolated browser room. A documented fixture advanced only that room to a clean
|
||||
shipment boundary with two human spice. The browser checked ordinary cost three
|
||||
and its disabled action, then enabled Smuggler and shipped three actual forces
|
||||
for two spice. Refresh restored seventeen reserves, three Arrakeen forces,
|
||||
zero Tanks, zero spice and the same private hand and traitor. This is a targeted
|
||||
scenario, not a complete human game.
|
||||
|
||||
Two genuine four-profile Basic/Advanced samples completed with 99/212 actions,
|
||||
3/6 JSON restorations and no rejected candidates. Neither sample used Smuggler;
|
||||
these samples establish continuation only. Targeted tests and browser play
|
||||
exercise the new effect. Required broad check/build/HTTP results and final
|
||||
preservation totals are recorded in the checkpoint commit and private report.
|
||||
|
||||
Smuggler battle collection still needs its reveal/survival settlement. Combined
|
||||
No-Field, Homeworld, Nexus, Discovery, Stronghold and technology routes remain
|
||||
outside this prototype. Mentat questioning, Bureaucrat payments, Sandmaster
|
||||
movement collection, Spice Banker and Diplomat also remain incomplete. Reuse
|
||||
this ordinary route when integrating those dependencies.
|
||||
+6
-3
@@ -65,6 +65,7 @@ import {
|
||||
shrineTruthtranceBotAction,
|
||||
} from './shrine';
|
||||
import { reserveShipmentCost } from './shipment-price';
|
||||
import { quoteSmugglerShipment } from './smuggler-shipment';
|
||||
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
|
||||
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
|
||||
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
|
||||
@@ -3010,7 +3011,7 @@ function policyActions(g: GameView): Action[] {
|
||||
if (marker?.location.territory === to.t) continue;
|
||||
const desired = Math.min(
|
||||
me.reserves,
|
||||
level === 0 ? 1 : Math.max([1, 3, 4, 5][level], to.enemy + 2),
|
||||
level === 0 ? (quoteSmugglerShipment(g, me.id, to.t, 2) ? 2 : 1) : Math.max([1, 3, 4, 5][level], to.enemy + 2),
|
||||
);
|
||||
// Quote the discounted candidate independently of ordinary affordability.
|
||||
const richeseAmount = Math.min(me.reserves, 5, g.nexusRichese?.maxForces ?? 0);
|
||||
@@ -3024,12 +3025,13 @@ function policyActions(g: GameView): Action[] {
|
||||
});
|
||||
if (candidate) actions.push(candidate);
|
||||
}
|
||||
for (const amount of new Set([desired, Math.min(desired, 3), 1])) {
|
||||
for (const amount of new Set([desired, Math.min(desired, 3),
|
||||
...(me.reserves >= 2 && quoteSmugglerShipment(g, me.id, to.t, 2) ? [2] : []), 1])) {
|
||||
if (
|
||||
reserveShipmentCost(
|
||||
{ faction: me.faction, halfRate },
|
||||
territory(to.t).type,
|
||||
amount,
|
||||
amount - (quoteSmugglerShipment(g, me.id, to.t, amount) ? 1 : 0),
|
||||
) > shipmentBudget
|
||||
)
|
||||
continue;
|
||||
@@ -3038,6 +3040,7 @@ function policyActions(g: GameView): Action[] {
|
||||
territory: to.t,
|
||||
sector: to.s,
|
||||
amount,
|
||||
...(quoteSmugglerShipment(g, me.id, to.t, amount) ? { smuggler: true } : {}),
|
||||
...(level > 0
|
||||
? { elite: Math.min(amount, me.elites?.reserves ?? 0) }
|
||||
: {}),
|
||||
|
||||
+35
-3
@@ -10,6 +10,7 @@ import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGro
|
||||
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
|
||||
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
|
||||
import { planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
|
||||
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
|
||||
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
|
||||
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
|
||||
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
|
||||
@@ -891,6 +892,8 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
|
||||
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
|
||||
};
|
||||
type PendingShipment = {
|
||||
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
|
||||
smuggler?: SmugglerShipment;
|
||||
guildSecretEvent?: string;
|
||||
guildNexusEvent?: string;
|
||||
nexusEvent?: string;
|
||||
@@ -4914,6 +4917,12 @@ function leaderSkillAssignmentUnavailable(
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
sukRescueIntegrity(g);
|
||||
rihaniIntegrity(g);
|
||||
if (g.leaderSkills || g.pendingShipment?.smuggler) {
|
||||
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment');
|
||||
requireRule(decisions.length === (g.pendingShipment ? 1 : 0),
|
||||
'The saved Leader Skills shipment lost its unique Guild interception decision.');
|
||||
for (const decision of decisions) validateGuildShipmentDecision(g, decision);
|
||||
}
|
||||
if (!g.leaderSkills) return;
|
||||
requireRule(g.leaderSkills.assignments.every((assignment) =>
|
||||
g.players.find((p) => p.id === assignment.owner)?.leaders.some((leader) => leader.id === assignment.leader && !leader.dead)),
|
||||
@@ -13504,9 +13513,10 @@ function findShipmentCompletion(
|
||||
for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : []), ...(guildOffer && !guildOffer.blocked ? [guildOffer.event] : [])]) {
|
||||
const guildSecretEvent = nexus && nexus === guildOffer?.event ? nexus : undefined;
|
||||
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
|
||||
const smuggler = nexus ? null : quoteSmugglerShipment(g, p.id, destination, amount);
|
||||
const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(destination).type,amount)).cost : nexus
|
||||
? nexusRule(() => quoteNexusRicheseShipment(price,territory(destination).type,amount)).cost
|
||||
: reserveShipmentCost(price,territory(destination).type,amount);
|
||||
: reserveShipmentCost(price,territory(destination).type,amount - (smuggler ? 1 : 0));
|
||||
try {
|
||||
const allyPayment = contribution(g, p, cost);
|
||||
const elite = eliteChoice(
|
||||
@@ -13525,6 +13535,7 @@ function findShipmentCompletion(
|
||||
: null;
|
||||
const nativeSources = sources ? { homeworldSources: sources } : {};
|
||||
const shipment: PendingShipment = {
|
||||
...(smuggler ? { smuggler } : {}),
|
||||
...(guildSecretEvent ? {guildSecretEvent} : nexus ? {nexusEvent:nexus} : {}),
|
||||
...nativeSources,
|
||||
turn: g.turn,
|
||||
@@ -13545,6 +13556,7 @@ function findShipmentCompletion(
|
||||
...actions,
|
||||
{
|
||||
type: 'ship',
|
||||
...(smuggler ? { smuggler: true } : {}),
|
||||
...(nexus ? {nexus} : {}),
|
||||
territory: destination,
|
||||
sector,
|
||||
@@ -17067,6 +17079,12 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
|
||||
} else commitHomeworldShipment(g, shipment);
|
||||
}
|
||||
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
|
||||
if (shipment.smuggler) requireRule(
|
||||
!shipment.source && !shipment.noField && !shipment.alliedNoField &&
|
||||
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
|
||||
!g.expansions.length && !g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens,
|
||||
'Smuggler shipping with expansion modules is still being integrated.',
|
||||
);
|
||||
if (shipment.guildSecretEvent) {
|
||||
validateGuildSecretDeclaration(g,shipment.player,shipment.guildSecretEvent);
|
||||
requireRule(!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.noField && !shipment.alliedNoField && !shipment.source,
|
||||
@@ -17143,9 +17161,14 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
|
||||
'Fremen reinforcements must arrive within two territories of the Great Flat.',
|
||||
);
|
||||
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
|
||||
if (shipment.smuggler) requireRule(
|
||||
!shipment.guildSecretEvent && !shipment.nexusEvent &&
|
||||
JSON.stringify(shipment.smuggler) === JSON.stringify(quoteSmugglerShipment(g, p.id, shipment.territory, n)),
|
||||
'The saved Smuggler shipment lost its living trainer, empty destination or physical amount.',
|
||||
);
|
||||
const cost = shipment.guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(shipment.territory).type,n)).cost : shipment.nexusEvent
|
||||
? nexusRule(() => quoteNexusRicheseShipment(price,territory(shipment.territory).type,n)).cost
|
||||
: reserveShipmentCost(price,territory(shipment.territory).type,n);
|
||||
: reserveShipmentCost(price,territory(shipment.territory).type,n - (shipment.smuggler ? 1 : 0));
|
||||
requireRule(
|
||||
Number.isSafeInteger(shipment.cost) && shipment.cost === cost,
|
||||
'The declared shipment price is no longer current.',
|
||||
@@ -17341,6 +17364,9 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
? { faction: 'richese', name: 'No-Field shipment' }
|
||||
: shipment.guildSecretEvent ? {faction:'guild',name:'Secret Ally shipment'} : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : undefined,
|
||||
);
|
||||
if (shipment.smuggler) log(g,
|
||||
`${p.name}'s Smuggler included one free accompanying force: ${n} physical forces left reserves, priced as ${n - 1} for ${cost} spice. The destination was empty before arrival.`,
|
||||
{ faction: p.faction, name: 'Smuggler shipment' });
|
||||
const bg = byFaction(g, 'beneGesserit');
|
||||
const followup =
|
||||
p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0
|
||||
@@ -21648,9 +21674,14 @@ function applyActionInner(
|
||||
? 0
|
||||
: eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, action.elite);
|
||||
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
|
||||
requireRule(action.smuggler === undefined || typeof action.smuggler === 'boolean', 'Choose whether to use Smuggler.');
|
||||
const smuggler = action.smuggler === true && !noField && !guildSecretEvent && !nexusEvent
|
||||
? quoteSmugglerShipment(g, id, to, n) : null;
|
||||
requireRule(action.smuggler !== true || smuggler,
|
||||
'Smuggler needs a living face-up native trainer and at least two off-planet forces shipping into an empty territory.');
|
||||
const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(to).type,n)).cost : nexusEvent
|
||||
? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost
|
||||
: reserveShipmentCost(price,territory(to).type,n);
|
||||
: reserveShipmentCost(price,territory(to).type,n - (smuggler ? 1 : 0));
|
||||
if (p.faction === 'fremen' && !guildSecretEvent) {
|
||||
requireRule(
|
||||
territory('the_great_flat').sectors.some((fs) =>
|
||||
@@ -21668,6 +21699,7 @@ function applyActionInner(
|
||||
);
|
||||
const allyPayment = contribution(g, p, cost, action.allyPayment);
|
||||
const shipment: PendingShipment = {
|
||||
...(smuggler ? { smuggler } : {}),
|
||||
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
|
||||
...(nexusEvent ? {nexusEvent} : {}),
|
||||
...(guildSecretEvent ? {guildSecretEvent} : {}),
|
||||
|
||||
+8
-7
@@ -3563,7 +3563,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring, Sandmaster victory spice and Rihani exchanges in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring, Sandmaster victory spice, Rihani exchanges and optional Smuggler shipping in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
|
||||
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
||||
@@ -3575,16 +3575,17 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
|
||||
'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.',
|
||||
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
|
||||
'Normal Mentat questioning, Bureaucrat payments and Sandmaster movement collection, plus all Spice Banker, Diplomat and Smuggler effects remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
|
||||
'Normal Mentat questioning, Bureaucrat payments and Sandmaster movement collection, Smuggler battle collection, plus all Spice Banker and Diplomat effects remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
related: ['setup','choam-modules','implementation-checklist'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Their other bands, Spice Banker, Diplomat, Smuggler and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges and shared battle scoring. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery and battle-effect regressions cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx'] },
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping is connected. Remaining bands, Spice Banker, Diplomat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has an explicit shipping opt-out and total-versus-priced force counts. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring and Smuggler reserve shipments. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/SMUGGLER_SHIPMENT.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Game, GameView } from './engine';
|
||||
import { presenceAt } from './force-presence';
|
||||
|
||||
/** The amount is the entire physical shipment, including its one free companion. */
|
||||
export type SmugglerShipment = { leader: string; amount: number };
|
||||
|
||||
/** Public leader custody and whole-territory presence suffice; no private hand is read. */
|
||||
export function quoteSmugglerShipment(
|
||||
game: Game | GameView,
|
||||
player: string,
|
||||
territory: string,
|
||||
amount: number,
|
||||
): SmugglerShipment | null {
|
||||
if (!Number.isSafeInteger(amount) || amount < 2) return null;
|
||||
// Classic Fremen reserves are on-planet; their normal reinforcement is not off-planet shipping.
|
||||
if (game.players.find((p) => p.id === player)?.faction === 'fremen')
|
||||
return null;
|
||||
const assignment = game.leaderSkills?.assignments.find(
|
||||
(a) => a.owner === player && a.skill === 'smuggler',
|
||||
);
|
||||
if (
|
||||
!assignment ||
|
||||
('controller' in assignment && assignment.controller !== player) ||
|
||||
('faceUp' in assignment && !assignment.faceUp)
|
||||
)
|
||||
return null;
|
||||
const leader = game.players
|
||||
.find((p) => p.id === player)
|
||||
?.leaders.find((l) => l.id === assignment.leader);
|
||||
if (
|
||||
!leader ||
|
||||
leader.dead ||
|
||||
leader.capturedBy ||
|
||||
leader.gholaBy ||
|
||||
game.players.some((p) => presenceAt(p, territory) > 0)
|
||||
)
|
||||
return null;
|
||||
return { leader: leader.id, amount };
|
||||
}
|
||||
@@ -20,6 +20,29 @@ import { enterDiscoveryCollection } from './fixture-discovery';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
|
||||
function giveExistingCard(
|
||||
game: Game,
|
||||
owner: string,
|
||||
predicate: (card: Game['deck'][number]) => boolean,
|
||||
) {
|
||||
const recipient = game.players.find((player) => player.id === owner)!;
|
||||
const zones = [game.deck, game.discard, ...game.players.map((p) => p.hand)];
|
||||
for (const zone of zones) {
|
||||
const index = zone.findIndex(predicate);
|
||||
if (index < 0) continue;
|
||||
const [card] = zone.splice(index, 1);
|
||||
recipient.hand.push(card);
|
||||
return card;
|
||||
}
|
||||
assert.fail('The initialized physical deck has no Karama card.');
|
||||
}
|
||||
|
||||
function physicalCardIds(game: Game) {
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand)]
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function initialized(
|
||||
factions: [FactionId, FactionId, FactionId],
|
||||
expansions: string[] = [],
|
||||
@@ -116,10 +139,9 @@ function pendingAdvisorEntry(
|
||||
const beneGesserit = game.players[1];
|
||||
beneGesserit.reserves--;
|
||||
beneGesserit.forces['cistern:0'] = 1;
|
||||
const karama = game.deck.find((card) => card.effect === 'karama');
|
||||
assert.ok(karama);
|
||||
game.deck = game.deck.filter((card) => card.id !== karama.id);
|
||||
actor.hand.push(karama);
|
||||
const custody = physicalCardIds(game);
|
||||
giveExistingCard(game, actor.id, (card) => card.effect === 'karama');
|
||||
assert.deepEqual(physicalCardIds(game), custody);
|
||||
game = nextTurn(game);
|
||||
game = applyAction(game, actor.id, entryAction(game));
|
||||
assert.equal(game.discoveryEntry?.stage, 'arrival');
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SmugglerShipmentChoice } from '../components/smuggler-shipment';
|
||||
|
||||
void test('Smuggler controls preserve a real opt-out and distinguish total physical forces from the priced count', () => {
|
||||
const quote = { leader: 'emperor-0', amount: 3 };
|
||||
const render = (use: boolean, busy = false) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(SmugglerShipmentChoice, {
|
||||
quote,
|
||||
use,
|
||||
busy,
|
||||
onChange() {},
|
||||
}),
|
||||
);
|
||||
const enabled = render(true);
|
||||
assert.match(enabled, /checked=""/);
|
||||
assert.match(enabled, /price is for 2/);
|
||||
assert.match(enabled, /All 3 selected forces leave reserves/);
|
||||
assert.doesNotMatch(render(false), /checked=""/);
|
||||
assert.match(render(false), /ordinary price applies to every selected force/);
|
||||
assert.match(render(true, true), /disabled=""/);
|
||||
assert.equal(
|
||||
renderToStaticMarkup(
|
||||
createElement(SmugglerShipmentChoice, {
|
||||
quote: null,
|
||||
use: true,
|
||||
busy: false,
|
||||
onChange() {},
|
||||
}),
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mock } from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
joinGame,
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
/** Genuine module setup followed by a conserved, explicitly staged shipment turn. */
|
||||
export function smugglerShipmentGame(
|
||||
faction: FactionId = 'emperor',
|
||||
advanced = false,
|
||||
): Game {
|
||||
let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced);
|
||||
joinGame(
|
||||
g,
|
||||
newPlayer('h', 'Opponent', faction === 'guild' ? 'emperor' : 'guild'),
|
||||
);
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === 'smuggler');
|
||||
let cursor = LEADER_SKILL_CARDS.length - 1;
|
||||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||
array[0] = cursor-- === index ? 0 : 0xffffffff;
|
||||
return array;
|
||||
});
|
||||
try {
|
||||
g = initializeLeaderSkillsGameForAudit(g);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
for (let i = 0; g.status === 'setup' && i < 60; i++) {
|
||||
let accepted = false;
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
const skills = view.leaderSkills!;
|
||||
const options =
|
||||
g.setupStage === 'leaderSkills' && skills.offer
|
||||
? [
|
||||
{
|
||||
type: 'leaderSkill',
|
||||
event: skills.offer.event,
|
||||
skill: p.id === 'p' ? 'smuggler' : skills.offer.cards[0],
|
||||
leader: skills.eligibleLeaders[0].id,
|
||||
},
|
||||
]
|
||||
: g.setupStage === 'traitors' && p.traitorChoices.length
|
||||
? [{ type: 'traitor', leader: p.traitorChoices[0] }]
|
||||
: g.setupStage === 'prediction' && p.faction === 'beneGesserit'
|
||||
? [{ type: 'predict', faction: g.players[1].faction, turn: 3 }]
|
||||
: g.setupStage === 'forces' &&
|
||||
p.faction === 'fremen' &&
|
||||
p.reserves === 20
|
||||
? [
|
||||
{
|
||||
type: 'fremenSetup',
|
||||
placements: {
|
||||
sietch_tabr: 10,
|
||||
false_wall_south: 0,
|
||||
false_wall_west: 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
: g.setupStage === 'forces' &&
|
||||
p.faction === 'beneGesserit' &&
|
||||
!p.advisorSetup
|
||||
? [
|
||||
{
|
||||
type: 'advisorSetup',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
},
|
||||
]
|
||||
: botActions(view);
|
||||
for (const a of options) {
|
||||
try {
|
||||
g = applyAction(g, p.id, a);
|
||||
accepted = true;
|
||||
break;
|
||||
} catch {
|
||||
/* Other seats can be waiting. */
|
||||
}
|
||||
}
|
||||
if (accepted) break;
|
||||
}
|
||||
assert.ok(accepted, `setup ${g.setupStage}`);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
Object.assign(g, {
|
||||
phase: 5,
|
||||
turn: 2,
|
||||
storm: 18,
|
||||
active: 'p',
|
||||
order: ['p', 'h'],
|
||||
movementRemaining: ['p', 'h'],
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand);
|
||||
p.hand = [];
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.tanks = 0;
|
||||
p.shipped = false;
|
||||
p.moved = 0;
|
||||
p.advisors = {};
|
||||
if (p.elites) p.elites = { ...p.elites, forces: {}, reserves: 5, tanks: 0 };
|
||||
}
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Action } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function fixture(card: 'box' | 'karama' | null = null, ownerSpice = 3) {
|
||||
const store = unitStore();
|
||||
const sqlite: DatabaseSync = store.sqlite;
|
||||
const created = await store.rooms.createRoom(
|
||||
'Smuggler',
|
||||
'emperor',
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const joined = await store.rooms.joinRoom(
|
||||
created.view.code,
|
||||
'Guild',
|
||||
'guild',
|
||||
);
|
||||
const code = created.view.code;
|
||||
const tokens = [created.token!, joined.token!];
|
||||
const original = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
for (const [index, seat] of original.entries())
|
||||
sqlite
|
||||
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
|
||||
.run(index === 0 ? 'p' : 'h', code, seat.playerId);
|
||||
const seats = await Promise.all(
|
||||
tokens.map((token) => store.restart().authenticate(code, token)),
|
||||
);
|
||||
const state = smugglerShipmentGame('emperor', true);
|
||||
state.code = code;
|
||||
state.version = (await store.rooms.readRoom(code)).version;
|
||||
state.players[0].spice = ownerSpice;
|
||||
state.players[1].spice = 10;
|
||||
let stagedCard = null;
|
||||
if (card === 'box') {
|
||||
stagedCard = richeseCards().find(
|
||||
(candidate) => candidate.effect === 'nullentropyBox',
|
||||
)!;
|
||||
state.players[0].hand.push(stagedCard);
|
||||
state.discard.push(...state.deck.splice(0, 4));
|
||||
} else if (card === 'karama') {
|
||||
const index = state.deck.findIndex(
|
||||
(candidate) => candidate.effect === 'karama',
|
||||
);
|
||||
assert.ok(index >= 0);
|
||||
stagedCard = state.deck.splice(index, 1)[0];
|
||||
state.players[1].hand.push(stagedCard);
|
||||
}
|
||||
sqlite
|
||||
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(state), state.version, code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, sqlite, code, seats, state, stagedCard };
|
||||
}
|
||||
|
||||
async function declare(
|
||||
f: Awaited<ReturnType<typeof fixture>>,
|
||||
smuggler: boolean | undefined,
|
||||
) {
|
||||
const action: Action = {
|
||||
type: 'ship',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 3,
|
||||
elite: 2,
|
||||
...(smuggler === undefined ? {} : { smuggler }),
|
||||
};
|
||||
await f.restart().act(f.code, f.seats[0], f.state.version, action, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
|
||||
void test('SQLite restart preserves a Smuggler quote and concurrent Guild allows commit it once', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const pending = await declare(f, true);
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(pending.decision?.player, 'h');
|
||||
assert.deepEqual(pending.pendingShipment?.smuggler, {
|
||||
leader: pending.leaderSkills!.assignments.find(
|
||||
(assignment) => assignment.owner === 'p',
|
||||
)!.leader,
|
||||
amount: 3,
|
||||
});
|
||||
assert.equal(pending.pendingShipment?.cost, 2);
|
||||
assert.equal(pending.players[0].spice, 3);
|
||||
assert.equal(pending.players[0].reserves, 20);
|
||||
|
||||
for (const seat of f.seats) {
|
||||
const view = await f.restart().readSeatView(f.code, seat);
|
||||
assert.deepEqual(view.decision, pending.decision);
|
||||
assert.equal('pendingShipment' in view, false);
|
||||
const other = view.players.find((player) => player.id !== seat.playerId)!;
|
||||
assert.equal('spice' in other, false);
|
||||
assert.equal('hand' in other, false);
|
||||
}
|
||||
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const barrier = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await barrier;
|
||||
};
|
||||
f.writes.length = 0;
|
||||
const allow: Action = { type: 'decision', allow: true };
|
||||
const attempts = await Promise.allSettled([
|
||||
f.rooms.act(f.code, f.seats[1], pending.version, allow, clock),
|
||||
f.restart().act(f.code, f.seats[1], pending.version, allow, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(
|
||||
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
attempts.filter((attempt) => attempt.status === 'rejected').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.writes.map((write) => write.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, pending.version + 1);
|
||||
assert.equal(done.pendingShipment, null);
|
||||
assert.equal(done.decision, null);
|
||||
assert.equal(done.players[0].spice, 1);
|
||||
assert.equal(done.players[0].reserves, 17);
|
||||
assert.equal(done.players[0].elites!.reserves, 3);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], 3);
|
||||
assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2);
|
||||
assert.equal(done.players[1].spice, 12);
|
||||
assert.equal(
|
||||
done.log.filter((entry) => entry.automatic?.name === 'Smuggler shipment')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
const writes = f.writes.length;
|
||||
await assert.rejects(() =>
|
||||
f.restart().act(f.code, f.seats[1], pending.version, allow, clock),
|
||||
);
|
||||
assert.equal(f.writes.length, writes);
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), done);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a restored shipment without the new Smuggler stamp retains its full quoted price', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const pending = await declare(f, undefined);
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(pending.pendingShipment?.smuggler, undefined);
|
||||
assert.equal(pending.pendingShipment?.cost, 3);
|
||||
delete pending.pendingShipment!.turn;
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(pending), pending.version, f.code);
|
||||
const restored = await f.restart().readRoom(f.code);
|
||||
assert.equal(restored.pendingShipment?.turn, undefined);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
restored.version,
|
||||
{ type: 'decision', allow: true },
|
||||
clock,
|
||||
);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.players[0].spice, 0);
|
||||
assert.equal(done.players[0].reserves, 17);
|
||||
assert.equal(done.players[1].spice, 13);
|
||||
assert.ok(
|
||||
!done.log.some((entry) => entry.automatic?.name === 'Smuggler shipment'),
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a paid Box search can suspend and restore the saved Guild decision before shipment', async () => {
|
||||
const f = await fixture('box', 5);
|
||||
try {
|
||||
const pending = await declare(f, true);
|
||||
const source = structuredClone(pending.pendingShipment);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
pending.version,
|
||||
{ type: 'card', card: f.stagedCard!.id },
|
||||
clock,
|
||||
);
|
||||
const searching = await f.restart().readRoom(f.code);
|
||||
assert.equal(searching.decision?.kind, 'nullentropy');
|
||||
assert.equal(
|
||||
searching.pendingNullentropy?.resume.decision?.kind,
|
||||
'guildShipment',
|
||||
);
|
||||
assert.deepEqual(searching.pendingShipment, source);
|
||||
assert.equal(searching.players[0].spice, 3);
|
||||
assert.equal(searching.players[0].reserves, 20);
|
||||
for (const seat of f.seats) await f.restart().readSeatView(f.code, seat);
|
||||
|
||||
await f.restart().act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
searching.version,
|
||||
{
|
||||
type: 'decision',
|
||||
event: searching.pendingNullentropy!.event,
|
||||
card: searching.discard[0].id,
|
||||
},
|
||||
clock,
|
||||
);
|
||||
const restored = await f.restart().readRoom(f.code);
|
||||
assert.equal(restored.pendingNullentropy, null);
|
||||
assert.equal(restored.decision?.kind, 'guildShipment');
|
||||
assert.deepEqual(restored.pendingShipment, source);
|
||||
assert.equal(restored.players[0].reserves, 20);
|
||||
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
restored.version,
|
||||
{ type: 'decision', allow: true },
|
||||
clock,
|
||||
);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.pendingShipment, null);
|
||||
assert.equal(done.players[0].spice, 1);
|
||||
assert.equal(done.players[0].reserves, 17);
|
||||
assert.equal(done.players[1].spice, 12);
|
||||
assert.equal(
|
||||
done.log.filter((entry) => entry.automatic?.name === 'Smuggler shipment')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('Guild cancellation survives restart without charging or moving the Smuggler shipment', async () => {
|
||||
const f = await fixture('karama');
|
||||
try {
|
||||
const pending = await declare(f, true);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
pending.version,
|
||||
{ type: 'card', mode: 'special', card: f.stagedCard!.id },
|
||||
clock,
|
||||
);
|
||||
const stopped = await f.restart().readRoom(f.code);
|
||||
assert.equal(stopped.version, pending.version + 1);
|
||||
assert.equal(stopped.pendingShipment, null);
|
||||
assert.equal(stopped.decision, null);
|
||||
assert.equal(stopped.players[0].shipped, true);
|
||||
assert.equal(stopped.players[0].spice, 3);
|
||||
assert.equal(stopped.players[0].reserves, 20);
|
||||
assert.equal(stopped.players[0].forces['arrakeen:10'], undefined);
|
||||
assert.equal(stopped.players[1].spice, 10);
|
||||
assert.ok(stopped.discard.some((card) => card.id === f.stagedCard!.id));
|
||||
assert.ok(
|
||||
!stopped.log.some(
|
||||
(entry) => entry.automatic?.name === 'Smuggler shipment',
|
||||
),
|
||||
);
|
||||
const writes = f.writes.length;
|
||||
await assert.rejects(() =>
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
pending.version,
|
||||
{ type: 'card', mode: 'special', card: f.stagedCard!.id },
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.equal(f.writes.length, writes);
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), stopped);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
viewGame,
|
||||
normalizeAutomaticGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { quoteSmugglerShipment } from '../game/smuggler-shipment';
|
||||
import { reserveShipmentCost } from '../game/shipment-price';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
|
||||
import { territory } from '../game/board';
|
||||
|
||||
const ship: Action = {
|
||||
type: 'ship',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 3,
|
||||
smuggler: true,
|
||||
};
|
||||
function conserve(g: Game) {
|
||||
validateLeaderSkills(g.leaderSkills!, g.players);
|
||||
for (const p of g.players) {
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
);
|
||||
if (p.elites)
|
||||
assert.equal(
|
||||
p.elites.reserves +
|
||||
p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
||||
5,
|
||||
);
|
||||
}
|
||||
}
|
||||
function reject(g: Game, actor: string, action: Action) {
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(() => applyAction(g, actor, action));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
|
||||
void test('Smuggler prices the total physical shipment as one fewer force and sends the actual payment to Guild', () => {
|
||||
const game = smugglerShipmentGame();
|
||||
game.players[0].spice = 2;
|
||||
const before = game.players[1].spice;
|
||||
const sent = applyAction(game, 'p', ship);
|
||||
assert.equal(sent.players[0].spice, 0);
|
||||
assert.equal(sent.players[0].reserves, 17);
|
||||
assert.equal(sent.players[0].forces['arrakeen:10'], 3);
|
||||
assert.equal(sent.players[1].spice, before + 2);
|
||||
assert.ok(sent.log.some((l) => l.automatic?.name === 'Smuggler shipment'));
|
||||
assert.deepEqual(
|
||||
normalizeAutomaticGame(JSON.parse(JSON.stringify(sent))),
|
||||
sent,
|
||||
);
|
||||
reject(sent, 'p', ship);
|
||||
conserve(sent);
|
||||
const desert = smugglerShipmentGame();
|
||||
desert.players[0].spice = 4;
|
||||
const arrived = applyAction(desert, 'p', {
|
||||
...ship,
|
||||
territory: 'wind_pass',
|
||||
sector: 14,
|
||||
});
|
||||
assert.equal(arrived.players[0].spice, 0);
|
||||
conserve(arrived);
|
||||
});
|
||||
|
||||
void test('Smuggler requires an accompanying force and an entirely empty territory including advisors', () => {
|
||||
const game = smugglerShipmentGame();
|
||||
assert.equal(quoteSmugglerShipment(game, 'p', 'arrakeen', 1), null);
|
||||
game.players[0].spice = 0;
|
||||
reject(game, 'p', { ...ship, amount: 1 });
|
||||
for (const owner of ['p', 'h']) {
|
||||
const occupied = smugglerShipmentGame();
|
||||
const p = occupied.players.find((p) => p.id === owner)!;
|
||||
p.forces['wind_pass:15'] = 1;
|
||||
p.reserves--;
|
||||
assert.equal(quoteSmugglerShipment(occupied, 'p', 'wind_pass', 3), null);
|
||||
}
|
||||
const advisors = smugglerShipmentGame('beneGesserit', true);
|
||||
advisors.players[0].forces['arrakeen:10'] = 1;
|
||||
advisors.players[0].reserves--;
|
||||
advisors.players[0].advisors = { arrakeen: { lockedTurn: advisors.turn } };
|
||||
assert.equal(quoteSmugglerShipment(advisors, 'p', 'arrakeen', 3), null);
|
||||
const hidden = viewGame(smugglerShipmentGame(), 'p');
|
||||
hidden.leaderSkills!.assignments.find((a) => a.owner === 'p')!.faceUp = false;
|
||||
assert.equal(quoteSmugglerShipment(hidden, 'p', 'arrakeen', 3), null);
|
||||
for (const unavailable of [
|
||||
{ dead: true },
|
||||
{ capturedBy: 'h' },
|
||||
{ gholaBy: 'h' },
|
||||
]) {
|
||||
const absent = smugglerShipmentGame();
|
||||
const leader = absent.players[0].leaders.find(
|
||||
(l) =>
|
||||
l.id ===
|
||||
absent.leaderSkills!.assignments.find((a) => a.owner === 'p')!.leader,
|
||||
)!;
|
||||
Object.assign(leader, unavailable);
|
||||
assert.equal(quoteSmugglerShipment(absent, 'p', 'arrakeen', 3), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Guild rounding follows the free force, while on-planet Fremen reinforcement has no Smuggler bonus', () => {
|
||||
const guild = smugglerShipmentGame('guild');
|
||||
guild.players[0].spice = 1;
|
||||
const done = applyAction(guild, 'p', ship);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], 3);
|
||||
assert.equal(done.players[0].spice, 0);
|
||||
conserve(done);
|
||||
const fremen = smugglerShipmentGame('fremen');
|
||||
assert.equal(quoteSmugglerShipment(fremen, 'p', 'the_great_flat', 5), null);
|
||||
const reinforced = applyAction(fremen, 'p', {
|
||||
...ship,
|
||||
smuggler: false,
|
||||
territory: 'the_great_flat',
|
||||
sector: territory('the_great_flat').sectors.find((s) => s !== fremen.storm),
|
||||
amount: 5,
|
||||
});
|
||||
assert.equal(reinforced.players[0].reserves, 15);
|
||||
assert.ok(
|
||||
!reinforced.log.some((l) => l.automatic?.name === 'Smuggler shipment'),
|
||||
);
|
||||
conserve(reinforced);
|
||||
});
|
||||
|
||||
void test('Advanced pending shipment freezes the free companion through JSON and preserves elite counters', () => {
|
||||
const game = smugglerShipmentGame('emperor', true);
|
||||
game.players[0].spice = 2;
|
||||
const pending = applyAction(game, 'p', { ...ship, elite: 2 });
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(pending.pendingShipment?.smuggler?.amount, 3);
|
||||
assert.equal(pending.players[0].reserves, 20);
|
||||
assert.equal(pending.players[0].spice, 2);
|
||||
for (const corrupt of [
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.smuggler!.amount = 4;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.smuggler!.leader = 'stale';
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.pendingShipment!.smuggler;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.decision = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.decision!.player = 'p';
|
||||
},
|
||||
(g: Game) => {
|
||||
if (g.decision?.kind === 'guildShipment') g.decision.amount = 4;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingShipment = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.players[1].forces['arrakeen:10'] = 1;
|
||||
g.players[1].reserves--;
|
||||
},
|
||||
]) {
|
||||
const bad = structuredClone(pending);
|
||||
corrupt(bad);
|
||||
assert.throws(() => viewGame(bad, 'p'));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
reject(bad, 'h', { type: 'decision', allow: true });
|
||||
}
|
||||
const done = applyAction(JSON.parse(JSON.stringify(pending)), 'h', {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.equal(done.players[0].reserves, 17);
|
||||
assert.equal(done.players[0].elites!.reserves, 3);
|
||||
assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2);
|
||||
assert.equal(done.players[0].spice, 0);
|
||||
conserve(done);
|
||||
});
|
||||
|
||||
void test('all four bots use the free companion through the shared public quote without hidden resources', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const g = smugglerShipmentGame();
|
||||
g.players[0].spice = 1;
|
||||
const view = viewGame(g, 'p');
|
||||
view.players[0].bot = difficulty;
|
||||
const offers = botActions(view).filter(
|
||||
(a) => a.type === 'ship' && Number(a.amount) >= 2,
|
||||
);
|
||||
assert.ok(offers.length, difficulty);
|
||||
for (const action of offers) {
|
||||
const quote = quoteSmugglerShipment(
|
||||
view,
|
||||
'p',
|
||||
String(action.territory),
|
||||
Number(action.amount),
|
||||
);
|
||||
assert.ok(quote);
|
||||
assert.equal(
|
||||
reserveShipmentCost(
|
||||
{ faction: 'emperor', halfRate: false },
|
||||
'stronghold',
|
||||
Number(action.amount) - 1,
|
||||
),
|
||||
1,
|
||||
);
|
||||
const sent = applyAction(g, 'p', action);
|
||||
conserve(sent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('declining the optional free companion preserves ordinary payments and old pending quotes', () => {
|
||||
for (const value of [false, undefined]) {
|
||||
const game = smugglerShipmentGame();
|
||||
game.players[0].spice = 3;
|
||||
const guildBefore = game.players[1].spice;
|
||||
const done = applyAction(game, 'p', { ...ship, smuggler: value });
|
||||
assert.equal(done.players[0].spice, 0);
|
||||
assert.equal(done.players[1].spice, guildBefore + 3);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], 3);
|
||||
assert.ok(!done.log.some((l) => l.automatic?.name === 'Smuggler shipment'));
|
||||
conserve(done);
|
||||
}
|
||||
const game = smugglerShipmentGame('emperor', true);
|
||||
game.players[0].spice = 3;
|
||||
const old = applyAction(game, 'p', { ...ship, smuggler: undefined });
|
||||
assert.equal(old.pendingShipment?.smuggler, undefined);
|
||||
assert.equal(old.pendingShipment?.cost, 3);
|
||||
const resumed = applyAction(JSON.parse(JSON.stringify(old)), 'h', {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.equal(resumed.players[0].spice, 0);
|
||||
conserve(resumed);
|
||||
});
|
||||
|
||||
void test('a truthful shipment promise uses the free companion in its executable completion', () => {
|
||||
let g = smugglerShipmentGame();
|
||||
g.players[0].spice = 2;
|
||||
const index = g.deck.findIndex((card) => card.effect === 'truthtrance');
|
||||
assert.ok(index >= 0);
|
||||
const card = g.deck.splice(index, 1)[0];
|
||||
g.players[1].hand.push(card);
|
||||
g = applyAction(g, 'h', { type: 'card', card: card.id });
|
||||
while (g.truthtrance?.stage === 'priority') {
|
||||
const actor = g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!;
|
||||
g = applyAction(g, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
g = applyAction(g, 'h', {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'shipment',
|
||||
target: 'p',
|
||||
territory: 'arrakeen',
|
||||
minimum: 3,
|
||||
},
|
||||
});
|
||||
assert.ok(viewGame(g, 'p').truthShipmentAnswers?.includes('yes'));
|
||||
g = applyAction(g, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
const action = viewGame(JSON.parse(JSON.stringify(g)), 'p').shipmentCompletion
|
||||
?.actions[0];
|
||||
assert.equal(action?.type, 'ship');
|
||||
assert.equal(action?.smuggler, true);
|
||||
const done = applyAction(g, 'p', action!);
|
||||
assert.equal(done.shipmentPromises?.[0].fulfilled, true);
|
||||
assert.equal(done.players[0].spice, 0);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], 3);
|
||||
conserve(done);
|
||||
});
|
||||
Reference in new issue
Block a user