Integrate Smuggler companion with owned Richese No-Field
Connect the GF9 FAQ combination: one free physical reserve force accompanies an owned concealed No-Field at its unchanged one-marker price. Preserve zero/three/five token values, actual supply at later reveal, optional human choice and all four legal AI paths. Genuine CHOAM & Richese Leader Skills development setup adds the two sourced battle cards without duplicating their Ix identities. Public mode and publication gates stay closed; allied and remaining combined routes are unfinished. Bind the private declaration across initial No-Field cancellation and later Advanced Guild interception, including saved interruptions. Validate before projection/actions/normalization, hide the internal proof, reject altered declarations immutably, and withdraw the companion once only on successful commitment. Independent rules/privacy/persistence review found no blocker. Preserve other audit setup gates despite expanded deck assembly. Repair the existing randomized recovery test with a genuine seeded Rihani offer and explicit human choice, replacing an inappropriate AI-policy call on a human seat. Initial Karama and later Guild stop retain their distinct existing opportunity outcomes. Validation: 72 focused regressions plus 10 deck tests; typecheck, lint, 4520 offline tests, production build and 43 HTTP tests passed. SQLite restart, concurrent allowance/cancellation and exact physical custody passed. Browser genuine assignment, private token selection, opt-in, one-spice shipment, refresh, five-force reveal and final refresh preserved six physical Arrakeen forces, 14 reserves, zero Tanks, four spice, Crysknife and Soo-Soo Sook. Genuine four-profile Basic/Advanced games completed in 77/116 actions with 3/4 JSON restores and no rejected candidates; Advanced exercised one companion shipment and later reveal. After the outage the existing server was healthy. Preserved all 529 opening rooms and original seat/recovery records (563 after QA/HTTP). SQLite integrity and foreign keys passed. No restart, database reset or automation. Verified source tree: c2aee4138bcb46a05814a340d62eadace3470247ca620844eb0375e78ea4d6c4
This commit is contained in:
1 parent
bf8036dfc1
commit
788453e07e
20 files changed
+1213
-51
No files matched your search
@@ -7,9 +7,42 @@ import { territory, MOBILE_STRONGHOLD } from '@/game/board';
|
||||
import { fighterCount } from '@/game/advisors';
|
||||
import { reserveShipmentCost } from '@/game/shipment-price';
|
||||
import type { RicheseNoFieldView } from '@/game/richese-no-field';
|
||||
import { quoteSmugglerNoField } from '@/game/smuggler-no-field';
|
||||
import { ShipmentQuote } from './shipment-quote';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function SmugglerNoFieldChoice({
|
||||
quote,
|
||||
use,
|
||||
busy,
|
||||
onChange,
|
||||
}: {
|
||||
quote: ReturnType<typeof quoteSmugglerNoField>;
|
||||
use: boolean;
|
||||
busy: boolean;
|
||||
onChange: (use: boolean) => void;
|
||||
}) {
|
||||
if (!quote) return null;
|
||||
return (
|
||||
<div className="rounded border border-[#8e8159] p-3">
|
||||
<label className="flex min-h-11 items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={use}
|
||||
disabled={busy}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
Use Smuggler to add 1 real reserve force at no added cost
|
||||
</label>
|
||||
<p className="fine mt-2">
|
||||
The companion force enters the empty territory immediately. The No-Field
|
||||
token still uses the normal one-force shipment price, and its hidden
|
||||
forces remain in reserves until revelation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** This renderer receives public player data only, never the private token model. */
|
||||
export function NoFieldBoardMarkers({
|
||||
players,
|
||||
@@ -76,6 +109,7 @@ export function RicheseNoFieldControls({
|
||||
}) {
|
||||
const id = useId();
|
||||
const [selection, setSelection] = useState('');
|
||||
const [useSmuggler, setUseSmuggler] = useState(false);
|
||||
const info = game.richeseNoField;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
if (!info || info.owner !== game.me || !info.private || !me) return null;
|
||||
@@ -97,6 +131,9 @@ export function RicheseNoFieldControls({
|
||||
game.karamaShipping?.player === me.id,
|
||||
};
|
||||
const cost = reserveShipmentCost(rate, selectedTerritory.type, 1);
|
||||
const smuggler = info.canShip
|
||||
? quoteSmugglerNoField(game, me.id, destination)
|
||||
: null;
|
||||
const share =
|
||||
!me.ally || allyPayment === ''
|
||||
? Math.max(0, cost - (me.spice ?? 0))
|
||||
@@ -137,8 +174,9 @@ export function RicheseNoFieldControls({
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<p className="fine">
|
||||
Your token values are private until revealed. A concealed marker
|
||||
counts as one force, even when its value is zero. Physical forces
|
||||
remain in reserves until revelation.
|
||||
counts as one force, even when its value is zero. Its hidden forces
|
||||
remain in reserves until revelation; a Smuggler companion is a
|
||||
separate real force that leaves reserves immediately.
|
||||
</p>
|
||||
<ul
|
||||
className="m-0 flex flex-wrap gap-3 p-0"
|
||||
@@ -240,6 +278,12 @@ export function RicheseNoFieldControls({
|
||||
}}
|
||||
unavailableReasons={problems}
|
||||
/>
|
||||
<SmugglerNoFieldChoice
|
||||
quote={smuggler}
|
||||
use={useSmuggler}
|
||||
busy={busy}
|
||||
onChange={setUseSmuggler}
|
||||
/>
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || problems.length > 0}
|
||||
@@ -253,10 +297,12 @@ export function RicheseNoFieldControls({
|
||||
territory: destination,
|
||||
sector,
|
||||
allyPayment: share,
|
||||
...(useSmuggler && smuggler ? { smuggler: true } : {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Ship concealed No-Field {selected?.value ?? ''} · {cost} spice
|
||||
{useSmuggler && smuggler ? ' · + 1 real force' : ''}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,14 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
|
||||
|
||||
## Evidence and source of truth
|
||||
|
||||
- [Smuggler with Richese No-Field](SMUGGLER_NO_FIELD.md) connects the officially
|
||||
permitted free physical companion, unchanged marker price, private controls,
|
||||
all four AI profiles and saved cancellation/interception. Genuine CHOAM &
|
||||
Richese Leader Skills setup now includes its two replacement/additional battle
|
||||
cards. After the latest outage the server was healthy and all 529 opening games
|
||||
and seat records matched the backup. Other combinations and public starts stay
|
||||
gated; final checks and preservation totals are recorded in the checkpoint.
|
||||
|
||||
- [Sandmaster movement collection](SANDMASTER_MOVEMENT.md) connects explicit
|
||||
ground routes, optional board-spice collection, controls, legal AI and saved
|
||||
Fremen cancellation. Special relocation and combined modules remain missing;
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 September 2026 — Smuggler with owned Richese No-Field
|
||||
|
||||
The [officially permitted combination](SMUGGLER_NO_FIELD.md) now ships one
|
||||
separate free physical force alongside a concealed marker at its unchanged price.
|
||||
The private panel, shared AI quote, both cancellation/interception stages and saved
|
||||
continuation are connected. Revealing zero/three/five tokens preserves all physical
|
||||
forces; bots can reveal a zero marker accompanied by a real force before movement.
|
||||
Genuine CHOAM & Richese Leader Skills setup uses the sourced two-card deck addition
|
||||
or replacement without duplicate Ix cards. Public starts remain gated.
|
||||
|
||||
After the outage report, the healthy existing server was reused. All 529 opening
|
||||
games and their seat/recovery records matched the private backup. Focused tests,
|
||||
independent recovery review, browser evidence, required broad checks and final
|
||||
preservation/Git results are recorded in the checkpoint commit and private report.
|
||||
Allied No-Field and remaining combined modes are unfinished.
|
||||
|
||||
The first broad check exposed two deck/gate expectations and a randomized
|
||||
recovery test incorrectly asking an unautomated human seat for an AI action.
|
||||
The deck assertions now match the sourced inventory while other audit starts
|
||||
retain their gate. The recovery test now submits an explicit legal human choice.
|
||||
The failed report is retained separately from final verification.
|
||||
|
||||
## 13 September 2026 — Sandmaster movement collection prototype
|
||||
|
||||
[Sandmaster](SANDMASTER_MOVEMENT.md) now collects optional spice from distinct
|
||||
|
||||
@@ -12,7 +12,9 @@ Treachery Cards, including Harkonnen's extra card, precede simultaneous private
|
||||
two-card skill offers. Every faction selects one physical skill and an eligible
|
||||
own leader, returns the other card to the shuffled deck, and publicly assigns
|
||||
the selected card before traitor choices. No reduced or duplicate skill deck is
|
||||
substituted. Expansion rosters and other optional modules remain separate work.
|
||||
substituted. The [No-Field follow-up](SMUGGLER_NO_FIELD.md) adds genuine CHOAM &
|
||||
Richese setup for its bounded combination; remaining expansion/skill acceptance
|
||||
and other optional modules remain separate work.
|
||||
|
||||
Human controls show each permitted card's normal and battle text, select its
|
||||
leader, choose battle posture, and handle an optional own-leader revival draw.
|
||||
@@ -54,7 +56,8 @@ and the lower Mentat, Bureaucrat and Sandmaster effects. Their normal questionin
|
||||
payments and movement collection remain missing, along with Spice Banker normal
|
||||
income, Diplomat effects and Smuggler battle collection. [Banker battle spending](SPICE_BANKER_RUNTIME.md)
|
||||
now connects its lower band with separate sealed funds and survivor strength. [Smuggler shipping](SMUGGLER_SHIPMENT.md)
|
||||
connects its optional normal reserve bonus. Planetologist and Suk Graduate have the connected
|
||||
connects its optional normal reserve bonus and the [owned No-Field companion](SMUGGLER_NO_FIELD.md).
|
||||
Planetologist and Suk Graduate have the connected
|
||||
prototypes described below.
|
||||
|
||||
Captured-card replacement entitlement remains unresolved. The prototype rejects
|
||||
|
||||
@@ -7,7 +7,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
|
||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||
| --- | --- | --- |
|
||||
| Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) |
|
||||
| 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) |
|
||||
| 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. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) |
|
||||
| Sandmaster movement | One optional board spice per distinct entered territory on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing; multiple-pile collection remains guarded. | [Source and connected prototype](SANDMASTER_MOVEMENT.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) |
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Smuggler and Richese No-Field prototype
|
||||
|
||||
13 September 2026. **Prototyped**, with **Partial** rules coverage. This connects
|
||||
one owned Richese No-Field shipment with Leader Skills in the CHOAM & Richese
|
||||
development setup. Public expansion/module starts and publication remain gated.
|
||||
|
||||
## Source and physical contract
|
||||
|
||||
The [publisher CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf),
|
||||
page 12, explicitly allows Richese to accompany its No-Field with the free
|
||||
Smuggler force when entering an empty territory. This resolves the combination
|
||||
without a new user ruling. The local source archive preserves the publisher PDF;
|
||||
the original URL returned HTTP 403 during this follow-up.
|
||||
|
||||
The marker remains a shipment of one at its ordinary price. One separate actual
|
||||
reserve force arrives alongside it for free. The hidden token keeps its value
|
||||
of zero, three or five. Revealing it later adds at most that many of the reserves
|
||||
then available; it does not withdraw or recreate the companion again. A zero
|
||||
token can therefore accompany one real force. Without a reserve force, ordinary
|
||||
No-Field shipping remains available but the Smuggler option does not.
|
||||
|
||||
As in [ordinary Smuggler shipping](SMUGGLER_SHIPMENT.md), eligibility requires a
|
||||
living native face-up trainer and a wholly empty destination territory before
|
||||
arrival. Existing friendly/enemy forces, advisors and No-Field presence count.
|
||||
The checkbox is optional and starts unchecked. Omission or false preserves the
|
||||
ordinary concealed-marker shipment. Guild pricing, recipient income and ally
|
||||
funding use the unchanged one-marker cost.
|
||||
|
||||
## Connected paths
|
||||
|
||||
A shared public-custody quote drives validation, the private No-Field panel and
|
||||
all four AI profiles. Public views display the real companion and concealed
|
||||
marker separately; neither the token identity nor the internal saved proof is
|
||||
disclosed to opponents. Bots can reveal a zero-valued marker before moving when
|
||||
their real force is alongside it, using the existing voluntary reveal action.
|
||||
|
||||
New owned No-Field declarations in Leader Skills games bind the chosen token,
|
||||
event, optional companion and payment to exactly one initial cancellation window
|
||||
or later Advanced Guild decision, including saved card interruptions. JSON/SQLite
|
||||
continuation revalidates that original declaration before action or projection.
|
||||
Initial Karama cancellation preserves reserves, payment, marker history and the
|
||||
ordinary shipment opportunity. The later Guild stop likewise transfers no
|
||||
companion; it retains the existing consumed-opportunity behavior. Malformed or
|
||||
stale declarations reject without mutating their input.
|
||||
|
||||
Genuine development setup now accepts the single CHOAM & Richese expansion with
|
||||
Leader Skills. Page 4 adds Poison Tooth and Artillery Strike when Ix is absent,
|
||||
or replaces those two when Ix is included. Deck assembly reuses their existing
|
||||
physical IDs and implemented updated effects, and retains two Karama cards.
|
||||
This deck prerequisite does not certify every CHOAM/Richese skill interaction.
|
||||
Other audit initializers retain their existing CHOAM combination gate.
|
||||
|
||||
## Evidence and remaining work
|
||||
|
||||
Focused tests cover all token values, scarce reserves, prices, optional decline,
|
||||
private projections, both pending stages, initial cancellation, saved corruption,
|
||||
all four bots and physical conservation. Independent recovery review and broad
|
||||
checkpoint evidence are recorded with the commit. Browser acceptance uses a
|
||||
fresh genuine setup and an explicitly staged shipment boundary; other saved
|
||||
games remain untouched. Targeted scenarios establish this effect, not complete
|
||||
expansion acceptance or calibrated AI strategy.
|
||||
|
||||
Two genuine four-profile Basic/Advanced samples finished with 77/116 accepted
|
||||
actions, 3/4 JSON restorations and no rejected candidates. The Advanced sample
|
||||
used one companion shipment and later revealed its token; the Basic sample did
|
||||
not use the new effect. Physical force and skill-card custody remained valid.
|
||||
|
||||
A new browser room assigned the genuinely offered Smuggler to Ein Calimar.
|
||||
After a documented shipment-boundary fixture, the human chose token five and
|
||||
enabled the unchecked companion option. One spice paid for a marker and one
|
||||
real force; refresh restored nineteen reserves. Voluntary reveal produced six
|
||||
real forces and fourteen reserves. Four spice, Crysknife and the Soo-Soo Sook
|
||||
traitor remained unchanged through the final refresh.
|
||||
|
||||
Allied No-Field Smuggler composition, Homeworlds, Nexus, Discoveries, Stronghold
|
||||
Cards, Tech Tokens and other combined modes remain outside this prototype.
|
||||
Smuggler battle collection still needs its recorded reveal/survival settlement.
|
||||
The existing [Leader Skills material questions](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources)
|
||||
remain pending.
|
||||
@@ -23,8 +23,9 @@ Flat reinforcements retain their existing rule. See the
|
||||
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.
|
||||
free Smuggler force into empty territory. The [No-Field follow-up](SMUGGLER_NO_FIELD.md)
|
||||
now connects that owned-marker combination with one separate actual reserve force
|
||||
and the unchanged marker price.
|
||||
No new material user ruling was required for the ordinary reserve route.
|
||||
|
||||
## Connected behavior
|
||||
@@ -72,7 +73,7 @@ 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
|
||||
allied 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.
|
||||
+14
-2
@@ -66,6 +66,7 @@ import {
|
||||
} from './shrine';
|
||||
import { reserveShipmentCost } from './shipment-price';
|
||||
import { quoteSmugglerShipment } from './smuggler-shipment';
|
||||
import { quoteSmugglerNoField } from './smuggler-no-field';
|
||||
import { spiceBankerBattleMaximum, spiceBankerModeSupported } from './spice-banker';
|
||||
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
|
||||
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
|
||||
@@ -2947,11 +2948,21 @@ function policyActions(g: GameView): Action[] {
|
||||
(token) => token.id === marker?.tokenId,
|
||||
);
|
||||
const materialized = Math.min(deployedToken?.value ?? 0, me.reserves);
|
||||
const physicalAlongsideMarker = marker
|
||||
? countAt(me, marker.location.territory) > 0
|
||||
: false;
|
||||
const reveal =
|
||||
noField?.canReveal && marker && materialized > 0
|
||||
noField?.canReveal &&
|
||||
marker &&
|
||||
(materialized > 0 || physicalAlongsideMarker)
|
||||
? { type: 'revealNoField', token: marker.tokenId, event: noField.event }
|
||||
: null;
|
||||
if (reveal && (!me.shipped || (me.moved ?? 0) >= (me.movesAllowed ?? 1)))
|
||||
if (
|
||||
reveal &&
|
||||
(physicalAlongsideMarker ||
|
||||
!me.shipped ||
|
||||
(me.moved ?? 0) >= (me.movesAllowed ?? 1))
|
||||
)
|
||||
return [reveal];
|
||||
const markerTargets = targets.filter(
|
||||
(to) =>
|
||||
@@ -2986,6 +2997,7 @@ function policyActions(g: GameView): Action[] {
|
||||
territory: to.t,
|
||||
sector: to.s,
|
||||
allyPayment: Math.max(0, cost - (me.spice ?? 0)),
|
||||
...(quoteSmugglerNoField(g, me.id, to.t) ? { smuggler: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -55,11 +55,16 @@ export function ixStandardCards(): Card[] {
|
||||
export function ixDeck(): Card[] {
|
||||
return [...ixBattleCards(), ...ixSpecialCards(), ...ixStandardCards()];
|
||||
}
|
||||
/** Complete currently implemented deck sets. Other expansion decks remain gated. */
|
||||
/** CHOAM & Richese p. 4 replaces these Ix cards, or adds them when Ix is absent.
|
||||
* Preserve one physical identity per card across either expansion combination. */
|
||||
export function choamDeck(): Card[] {
|
||||
return ixBattleCards().filter(c => c.kind === 'poisonTooth' || c.kind === 'artillery');
|
||||
}
|
||||
/** Deck assembly does not open the separate faction/module release gates. */
|
||||
export function treacheryDeck(expansions: readonly string[] = []): Card[] {
|
||||
if (expansions.some((id) => id !== 'ix'))
|
||||
if (expansions.some((id) => id !== 'ix' && id !== 'choam'))
|
||||
throw new Error('This expansion treachery deck is not implemented yet.');
|
||||
return [...baseDeck(), ...(expansions.includes('ix') ? ixDeck() : [])];
|
||||
return [...baseDeck(), ...(expansions.includes('ix') ? ixDeck() : expansions.includes('choam') ? choamDeck() : [])];
|
||||
}
|
||||
export type Leader = {
|
||||
/** Explicit projected custody for a shared disc; null means set aside. */
|
||||
|
||||
+113
-14
@@ -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 { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
|
||||
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
|
||||
import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
|
||||
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
|
||||
@@ -724,6 +725,8 @@ export type Decision =
|
||||
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
|
||||
| {
|
||||
kind: 'guildShipment';
|
||||
/** Private original No-Field declaration; never projected to any seat. */
|
||||
noFieldSkillProof?: string;
|
||||
player: string;
|
||||
shipper: string;
|
||||
territory: string;
|
||||
@@ -794,6 +797,7 @@ export type Decision =
|
||||
}
|
||||
| { kind: 'battleCards'; player: string; territory: string; cards: string[] };
|
||||
export type ResponseWindow = {
|
||||
noFieldSkillProof?: string;
|
||||
/** Original optional collection binds a pending ground movement, including saved responses. */
|
||||
sandmasterProof?: string;
|
||||
/** Original eligible contributor amounts; private routing evidence, not a new payment. */
|
||||
@@ -900,6 +904,8 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
|
||||
type PendingShipment = {
|
||||
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
|
||||
smuggler?: SmugglerShipment;
|
||||
smugglerCompanion?: SmugglerNoFieldCompanion;
|
||||
noFieldSkillProof?: string;
|
||||
guildSecretEvent?: string;
|
||||
guildNexusEvent?: string;
|
||||
nexusEvent?: string;
|
||||
@@ -4921,6 +4927,7 @@ function leaderSkillAssignmentUnavailable(
|
||||
: null;
|
||||
}
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
const noFieldShipment = leaderSkillNoFieldIntegrity(g);
|
||||
sandmasterIntegrity(g);
|
||||
sukRescueIntegrity(g);
|
||||
rihaniIntegrity(g);
|
||||
@@ -4933,7 +4940,7 @@ function leaderSkillsIntegrity(g: Game) {
|
||||
nexusRule(() => validateSpiceBankerSpend(battleLeaderSkills(g, p), leader?.id, plan.bankerSpice,
|
||||
p.spice, battleSupportCost(g, p, plan.support) - (plan.allyPayment ?? 0)));
|
||||
}
|
||||
if (g.leaderSkills || g.pendingShipment?.smuggler) {
|
||||
if (!noFieldShipment && (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.');
|
||||
@@ -5356,9 +5363,9 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
|
||||
const g = structuredClone(state);
|
||||
g.leaderSkills = createLeaderSkills(random);
|
||||
return initializeSetupGameForAudit(g, false, false, false, false, true);
|
||||
return initializeSetupGameForAudit(g, false, false, false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam');
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false): Game {
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false): Game {
|
||||
nexusCardsIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -5380,7 +5387,8 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
'The audit initializer requires two through six distinct ready players and an existing host.',
|
||||
);
|
||||
requireRule(
|
||||
(homeworlds || nexus || ix || g.expansions.length === 0) &&
|
||||
(homeworlds || nexus || ix || choam || g.expansions.length === 0) &&
|
||||
(choam || !g.expansions.includes('choam')) &&
|
||||
(leaderSkills || !g.leaderSkills) &&
|
||||
(discovery || !g.discoveryEnabled) &&
|
||||
(nexus || !g.nexusCards) &&
|
||||
@@ -5389,10 +5397,12 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
(homeworlds || !g.homeworlds) &&
|
||||
g.players.every((p) =>
|
||||
FACTIONS.some(
|
||||
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix')),
|
||||
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam')),
|
||||
),
|
||||
),
|
||||
ix
|
||||
choam
|
||||
? 'The Leader Skills prototype supports base, CHOAM and Richese factions without other expansions or optional modules.'
|
||||
: ix
|
||||
? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.'
|
||||
: homeworlds
|
||||
? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
|
||||
@@ -17149,7 +17159,81 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
|
||||
log(g, `${p.name} declared ${quote.amount} physical forces for ${combatLocationName(g, intent.destination)}. Payment and departure await the Guild interception decision.`);
|
||||
} else commitHomeworldShipment(g, shipment);
|
||||
}
|
||||
function noFieldSkillProof(shipment: PendingShipment): string {
|
||||
return JSON.stringify({ ...shipment, noFieldSkillProof: undefined });
|
||||
}
|
||||
function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment) {
|
||||
requireRule(g.leaderSkills && smugglerNoFieldModeSupported(g) && shipment.noField &&
|
||||
!shipment.alliedNoField && !shipment.smuggler && !shipment.source &&
|
||||
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
|
||||
shipment.homeworldSources === undefined && shipment.ambassadorEvent === undefined,
|
||||
'Smuggler No-Field shipping requires an own Richese marker without other shipment routes or modules.');
|
||||
requireRule(typeof shipment.noFieldSkillProof === 'string' &&
|
||||
shipment.noFieldSkillProof === noFieldSkillProof(shipment),
|
||||
'The saved No-Field shipment changed its original marker or optional Smuggler companion.');
|
||||
const p = getPlayer(g, shipment.player);
|
||||
requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id &&
|
||||
shipmentAvailable(g, p) && shipment.turn === g.turn && p.faction === 'richese' &&
|
||||
p.noField && p.noFieldEvent === shipment.noField.event && p.noFieldBlockedTurn !== g.turn &&
|
||||
!p.noField.deployed && shipment.amount === 1 && shipment.elite === 0 && shipment.advisors === false,
|
||||
'The saved No-Field shipment lost its current unused opportunity or concealed marker.');
|
||||
integer(shipment.sector, 0, 18, 'No-Field shipment sector');
|
||||
requireRule(shipment.territory !== MOBILE_STRONGHOLD, 'Richese cannot ship into the mobile stronghold.');
|
||||
allowedEntry(g, p, shipment.territory, shipment.sector, false, false);
|
||||
noFieldRule(() => deployRicheseNoField(p.noField!, {
|
||||
tokenId: shipment.noField!.tokenId,
|
||||
controller: p.id,
|
||||
location: { territory: shipment.territory, sector: shipment.sector },
|
||||
}));
|
||||
if (shipment.smugglerCompanion !== undefined) requireRule(
|
||||
JSON.stringify(shipment.smugglerCompanion) === JSON.stringify(quoteSmugglerNoField(g, p.id, shipment.territory)) &&
|
||||
shipment.smugglerCompanion !== null,
|
||||
'The saved Smuggler No-Field companion lost its living trainer, reserve force or empty destination.',
|
||||
);
|
||||
const cost = reserveShipmentCost({ faction: p.faction, halfRate: shipmentHalfRate(g, p) },
|
||||
territory(shipment.territory).type, 1);
|
||||
requireRule(Number.isSafeInteger(shipment.cost) && shipment.cost === cost,
|
||||
'A No-Field with an optional Smuggler force keeps the price of one marker.');
|
||||
integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current No-Field shipment spice');
|
||||
integer(shipment.allyPayment, 0, cost, 'No-Field shipment ally payment');
|
||||
const credit = aidFor(g, p);
|
||||
if (credit) integer(credit.amount, 0, Number.MAX_SAFE_INTEGER, 'Current ally pledge');
|
||||
if (shipment.allyPayment > 0) requireRule(p.ally && getPlayer(g, p.ally).ally === p.id,
|
||||
'The declared No-Field contributor is no longer your mutual ally.');
|
||||
contribution(g, p, cost, shipment.allyPayment);
|
||||
}
|
||||
/** Bind both private stages, also while a card effect has saved their controls. */
|
||||
function leaderSkillNoFieldIntegrity(g: Game): boolean {
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const responses = contexts.flatMap(context => {
|
||||
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
|
||||
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null];
|
||||
}).filter((response): response is ResponseWindow => !!response);
|
||||
const decisions = homeworldSavedDecisions(g);
|
||||
const shipment = g.pendingShipment;
|
||||
if (!(shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
|
||||
(g.leaderSkills && shipment?.noField) || responses.some(r => r.noFieldSkillProof !== undefined) ||
|
||||
decisions.some(d => 'noFieldSkillProof' in d))) return false;
|
||||
requireRule(shipment, 'The saved No-Field response lost its original shipment.');
|
||||
validateLeaderSkillNoFieldShipment(g, shipment);
|
||||
const initial = responses.filter(r => r.kind === 'richeseNoField');
|
||||
const guild = decisions.filter(d => d.kind === 'guildShipment');
|
||||
requireRule(initial.length + guild.length === 1 &&
|
||||
responses.every(r => r.noFieldSkillProof === undefined || r.kind === 'richeseNoField') &&
|
||||
decisions.every(d => !('noFieldSkillProof' in d) || d.kind === 'guildShipment'),
|
||||
'The saved No-Field shipment lost its unique cancellation response or Guild decision.');
|
||||
for (const response of initial) requireRule(response.owner === shipment.player &&
|
||||
response.noFieldSkillProof === shipment.noFieldSkillProof,
|
||||
'The No-Field response changed its original declaration.');
|
||||
for (const decision of guild) validateGuildShipmentDecision(g, decision);
|
||||
return true;
|
||||
}
|
||||
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
|
||||
if (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined ||
|
||||
(g.leaderSkills && shipment.noField)) validateLeaderSkillNoFieldShipment(g, shipment);
|
||||
if (shipment.smuggler) requireRule(
|
||||
!shipment.source && !shipment.noField && !shipment.alliedNoField &&
|
||||
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
|
||||
@@ -17271,6 +17355,7 @@ function validateGuildShipmentDecision(
|
||||
getPlayer(g, shipment.player).faction !== 'fremen' &&
|
||||
decision.territory === shipment.territory &&
|
||||
decision.sector === shipment.sector &&
|
||||
decision.noFieldSkillProof === shipment.noFieldSkillProof &&
|
||||
decision.amount === (shipment.alliedNoField ? 1 : shipment.amount),
|
||||
'The Guild decision does not match the pending shipment.',
|
||||
);
|
||||
@@ -17302,10 +17387,11 @@ function offerShipment(g: Game, shipment: PendingShipment) {
|
||||
territory: to,
|
||||
sector: s,
|
||||
amount: n,
|
||||
...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}),
|
||||
};
|
||||
log(
|
||||
g,
|
||||
`${p.name} declared a shipment of ${n} forces to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
|
||||
`${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
|
||||
);
|
||||
} else commitShipment(g, shipment);
|
||||
}
|
||||
@@ -17409,6 +17495,10 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
.join('; ');
|
||||
if (n > 0) place(p, to, s, n, elite);
|
||||
}
|
||||
if (shipment.smugglerCompanion) {
|
||||
withdrawNativeReserves(g, p, 1, 0);
|
||||
place(p, to, s, 1, 0);
|
||||
}
|
||||
if (advisors && n > 0) (p.advisors ??= {})[to] ??= {};
|
||||
p.shipped = true;
|
||||
if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
|
||||
@@ -17429,7 +17519,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
shipment.alliedNoField
|
||||
? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.`
|
||||
: shipment.noField
|
||||
? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. It counts as one force; physical reserves remain unchanged until reveal.`
|
||||
? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}`
|
||||
: `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
|
||||
shipment.noField || shipment.alliedNoField
|
||||
? { faction: 'richese', name: 'No-Field shipment' }
|
||||
@@ -17460,7 +17550,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
p,
|
||||
to,
|
||||
s,
|
||||
shipment.alliedNoField ? Math.max(1, n) : n,
|
||||
shipment.alliedNoField ? Math.max(1, n) : n + (shipment.smugglerCompanion ? 1 : 0),
|
||||
elite,
|
||||
'shipment',
|
||||
);
|
||||
@@ -21749,8 +21839,10 @@ function applyActionInner(
|
||||
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 smugglerCompanion = action.smuggler === true && noField && !guildSecretEvent && !nexusEvent
|
||||
? quoteSmugglerNoField(g, id, to) : null;
|
||||
requireRule(action.smuggler !== true || smuggler || smugglerCompanion,
|
||||
'Smuggler needs a living face-up native trainer and an empty territory, with two ordinary shipment forces or one reserve companion beside your No-Field.');
|
||||
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 - (smuggler ? 1 : 0));
|
||||
@@ -21772,6 +21864,7 @@ function applyActionInner(
|
||||
const allyPayment = contribution(g, p, cost, action.allyPayment);
|
||||
const shipment: PendingShipment = {
|
||||
...(smuggler ? { smuggler } : {}),
|
||||
...(smugglerCompanion ? { smugglerCompanion } : {}),
|
||||
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
|
||||
...(nexusEvent ? {nexusEvent} : {}),
|
||||
...(guildSecretEvent ? {guildSecretEvent} : {}),
|
||||
@@ -21792,15 +21885,18 @@ function applyActionInner(
|
||||
}),
|
||||
...(noField ? { noField } : {}),
|
||||
};
|
||||
if (noField && g.leaderSkills) shipment.noFieldSkillProof = noFieldSkillProof(shipment);
|
||||
if (noField && g.leaderSkills) validatePhysicalShipment(g, shipment);
|
||||
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
|
||||
bindGuildCunningShipment(g,p,'reserve',shipment);
|
||||
if (shipment.noField) {
|
||||
checkShipmentIncomeRounding(g, p, cost, allyPayment);
|
||||
g.pendingShipment = shipment;
|
||||
g.response = { kind: 'richeseNoField', owner: p.id, passed: [] };
|
||||
g.response = { kind: 'richeseNoField', owner: p.id, passed: [],
|
||||
...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}) };
|
||||
log(
|
||||
g,
|
||||
`${p.name} declared a concealed No-Field shipment to ${territory(to).name}, sector ${s}.`,
|
||||
`${p.name} declared a concealed No-Field shipment${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.`,
|
||||
);
|
||||
} else offerShipment(g, shipment);
|
||||
return g;
|
||||
@@ -23146,13 +23242,16 @@ export function viewGame(state: Game, id: string) {
|
||||
? { ...g.decision, leader: '', owner: '' }
|
||||
: g.decision?.kind === 'faceDance'
|
||||
? { ...g.decision, ...(faceDanceReturnBlock(g, g.decision.winner) ? {blocked: faceDanceReturnBlock(g, g.decision.winner)!} : {}) }
|
||||
: (g.decision ?? null),
|
||||
: g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined
|
||||
? { ...g.decision, noFieldSkillProof: undefined }
|
||||
: (g.decision ?? null),
|
||||
response: g.response
|
||||
? {
|
||||
...g.response,
|
||||
...(g.response.guildContributions ? { guildContributions: undefined } : {}),
|
||||
...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}),
|
||||
...(g.response.sandmasterProof ? { sandmasterProof: undefined } : {}),
|
||||
...(g.response.noFieldSkillProof ? { noFieldSkillProof: undefined } : {}),
|
||||
passed: g.response.passed.includes(id) ? [id] : [],
|
||||
...(g.response.kind === 'revivalIncome' &&
|
||||
![g.response.owner, g.response.recipient].includes(id)
|
||||
|
||||
+5
-5
@@ -3576,18 +3576,18 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Multiple piles and special relocation remain unfinished.',
|
||||
'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.',
|
||||
'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.',
|
||||
'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. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
|
||||
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income remains unfinished.',
|
||||
'Normal Mentat questioning, Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and all 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. Smuggler normal reserve shipping and optional Sandmaster ground-route collection are connected. Spice Banker lower spending is connected; normal income, other remaining bands, 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/spice-banker.ts','game/sandmaster-movement.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. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. 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/spice-banker.tsx','components/sandmaster-movement.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, Smuggler reserve shipments, Sandmaster route collection and funded Banker commitments. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ 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, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. Spice Banker lower spending is connected; normal income, other remaining bands, 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/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.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 explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. 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/spice-banker.tsx','components/sandmaster-movement.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, Smuggler reserve/owned No-Field shipments, Sandmaster route collection and funded Banker commitments. 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','docs/SPICE_BANKER_RUNTIME.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 and Banker funding, death, traitor, privacy and concurrent settlement 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','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx'] },
|
||||
{ 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 and Banker funding, death, traitor, privacy and concurrent settlement 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','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Game, GameView } from './engine';
|
||||
import { quoteSmugglerShipment } from './smuggler-shipment';
|
||||
|
||||
/** The No-Field remains the priced marker; this is one separate physical force. */
|
||||
export type SmugglerNoFieldCompanion = { leader: string; amount: 1 };
|
||||
|
||||
export function smugglerNoFieldModeSupported(g: Game | GameView): boolean {
|
||||
return (
|
||||
g.expansions.length === 1 &&
|
||||
g.expansions[0] === 'choam' &&
|
||||
!g.homeworlds &&
|
||||
!g.nexusCards &&
|
||||
!g.discoveries &&
|
||||
!('discoveryEnabled' in g && g.discoveryEnabled) &&
|
||||
!g.strongholdCards &&
|
||||
!g.techTokens
|
||||
);
|
||||
}
|
||||
|
||||
/** Public custody only: the hidden token value never affects this free companion. */
|
||||
export function quoteSmugglerNoField(
|
||||
g: Game | GameView,
|
||||
player: string,
|
||||
destination: string,
|
||||
): SmugglerNoFieldCompanion | null {
|
||||
const p = g.players.find((p) => p.id === player);
|
||||
if (
|
||||
!p ||
|
||||
p.faction !== 'richese' ||
|
||||
p.reserves < 1 ||
|
||||
!Number.isSafeInteger(p.reserves) ||
|
||||
!smugglerNoFieldModeSupported(g)
|
||||
)
|
||||
return null;
|
||||
// Reuse the normal band's living/native/face-up/whole-territory emptiness checks.
|
||||
// Two denotes one priced marker with one free companion, not a reserve withdrawal of two.
|
||||
const skill = quoteSmugglerShipment(g, player, destination, 2);
|
||||
return skill ? { leader: skill.leader, amount: 1 } : null;
|
||||
}
|
||||
@@ -277,7 +277,7 @@ void test('the eight supported base and Ix faction setup paths conserve native a
|
||||
}
|
||||
});
|
||||
|
||||
void test('the four factions requiring unfinished expansion decks reject genuine Homeworld setup without replacing decks or lobby metadata', () => {
|
||||
void test('unprototyped CHOAM/Richese and Ecaz/Moritani combinations reject genuine Homeworld setup without replacing decks or lobby metadata', () => {
|
||||
for (const roster of [
|
||||
['choam', 'richese'],
|
||||
['ecaz', 'moritani'],
|
||||
|
||||
+12
-1
@@ -109,9 +109,20 @@ void test('the Ix expansion has fourteen distinct cards and combines into a fort
|
||||
baseDeck().some((c) => c.name === 'Basilia Weapon'),
|
||||
false,
|
||||
);
|
||||
for (const expansion of ['choam', 'ecaz', 'unknown'])
|
||||
for (const expansion of ['ecaz', 'unknown'])
|
||||
assert.throws(() => treacheryDeck([expansion]), /not implemented/);
|
||||
});
|
||||
void test('CHOAM & Richese adds or replaces the same two battle cards without duplicating physical custody', () => {
|
||||
for (const expansions of [['choam'], ['ix', 'choam'], ['choam', 'ix']]) {
|
||||
const cards = treacheryDeck(expansions);
|
||||
assert.equal(cards.length, expansions.includes('ix') ? 47 : 35);
|
||||
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
|
||||
assert.equal(cards.filter(c => c.kind === 'poisonTooth').length, 1);
|
||||
assert.equal(cards.filter(c => c.kind === 'artillery').length, 1);
|
||||
assert.equal(cards.filter(c => c.effect === 'karama').length, 2);
|
||||
assert.equal(cards.find(c => c.kind === 'poisonTooth')!.id, 'ix-poison-tooth');
|
||||
}
|
||||
});
|
||||
void test('deck factories return independent physical cards and retain the earlier Ix identifiers', () => {
|
||||
const a = treacheryDeck(['ix']),
|
||||
b = treacheryDeck(['ix']);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import { mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import {
|
||||
LEADER_SKILL_CARDS,
|
||||
type LeaderSkillId,
|
||||
} from '../game/leader-skill-cards';
|
||||
import {
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
type Action,
|
||||
@@ -9,7 +13,6 @@ import {
|
||||
} from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
@@ -42,7 +45,7 @@ function concurrentBarrier() {
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
async function fixture(firstSkill?: LeaderSkillId) {
|
||||
const store = unitStore();
|
||||
const sqlite: DatabaseSync = store.sqlite;
|
||||
const created = await store.rooms.createRoom(
|
||||
@@ -69,7 +72,22 @@ async function fixture() {
|
||||
.act(code, auth, room.version, { type: 'ready' }, clock);
|
||||
}
|
||||
const ready = await store.restart().readRoom(code);
|
||||
const initialized = initializeLeaderSkillsGameForAudit(ready);
|
||||
const targetIndex = firstSkill
|
||||
? LEADER_SKILL_CARDS.findIndex((card) => card.id === firstSkill)
|
||||
: -1;
|
||||
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
|
||||
if (firstSkill)
|
||||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
|
||||
return array;
|
||||
});
|
||||
let initialized: Game;
|
||||
try {
|
||||
initialized = initializeLeaderSkillsGameForAudit(ready);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
if (firstSkill) assert.equal(initialized.leaderSkills!.deck[0], firstSkill);
|
||||
sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(initialized), initialized.version, code);
|
||||
@@ -278,13 +296,17 @@ void test('real setup completion preserves prediction, starting hands, assignmen
|
||||
});
|
||||
|
||||
void test('battle death and Ghola revival preserve one private replacement skill through restart and concurrent selection', async () => {
|
||||
const f = await fixture();
|
||||
const f = await fixture('rihani-decipherer');
|
||||
try {
|
||||
let game = await act(f, 0, {
|
||||
type: 'predict',
|
||||
faction: 'atreides',
|
||||
turn: 4,
|
||||
});
|
||||
assert.equal(
|
||||
game.leaderSkills!.offers[f.auths[0].playerId].cards[0],
|
||||
'rihani-decipherer',
|
||||
);
|
||||
for (const [index, auth] of f.auths.entries()) {
|
||||
const seat = await f.restart().readSeatView(f.code, auth);
|
||||
const offer = seat.leaderSkills!.offer!;
|
||||
@@ -410,16 +432,20 @@ void test('battle death and Ghola revival preserve one private replacement skill
|
||||
1,
|
||||
);
|
||||
assertSkillCustody(game);
|
||||
// The randomly assigned winning skill can require casualty or Traitor choices
|
||||
// before card cleanup. Resolve those real choices before testing Ghola revival.
|
||||
for (let step = 0; step < 3 && ['rihani', 'sukRescue'].includes(game.decision?.kind ?? ''); step++) {
|
||||
const owner = f.auths.findIndex((auth) => auth.playerId === game.decision!.player);
|
||||
assert.ok(owner >= 0);
|
||||
const seat = await f.restart().readSeatView(f.code, f.auths[owner]);
|
||||
const [choice] = botActions(seat);
|
||||
assert.ok(choice, 'the winning skill must have a legal saved continuation');
|
||||
game = await act(f, owner, choice);
|
||||
}
|
||||
// The seeded winner has a real saved Rihani choice before card cleanup.
|
||||
assert.equal(game.decision?.kind, 'rihani');
|
||||
const owner = f.auths.findIndex(
|
||||
(auth) => auth.playerId === game.decision!.player,
|
||||
);
|
||||
assert.ok(owner >= 0);
|
||||
const seat = await f.restart().readSeatView(f.code, f.auths[owner]);
|
||||
assert.equal(seat.automaticContinuationPending, false);
|
||||
assert.equal(seat.decision?.kind, 'rihani');
|
||||
game = await act(f, owner, {
|
||||
type: 'decision',
|
||||
event: seat.decision.event,
|
||||
draw: false,
|
||||
});
|
||||
if (game.decision?.kind === 'battleCards')
|
||||
game = await act(
|
||||
f,
|
||||
|
||||
@@ -154,7 +154,7 @@ void test('the public collection and ten full card topics are searchable, linked
|
||||
);
|
||||
});
|
||||
|
||||
void test('base, Ix and Ecaz presentations and deck activation are unchanged', () => {
|
||||
void test('base, Ix and Ecaz presentations stay unchanged as the two CHOAM deck replacements become available', () => {
|
||||
for (const card of [...baseDeck(), ...ixDeck()]) {
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.availability, undefined);
|
||||
@@ -173,7 +173,7 @@ void test('base, Ix and Ecaz presentations and deck activation are unchanged', (
|
||||
}
|
||||
assert.equal(treacheryDeck().length, 33);
|
||||
assert.equal(treacheryDeck(['ix']).length, 47);
|
||||
assert.throws(() => treacheryDeck(['choam']), /not implemented/);
|
||||
assert.equal(treacheryDeck(['choam']).length, 35);
|
||||
assert.throws(() => treacheryDeck(['richese']), /not implemented/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
RicheseNoFieldControls,
|
||||
SmugglerNoFieldChoice,
|
||||
} from '../components/richese-no-field';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import {
|
||||
smugglerNoFieldAction,
|
||||
smugglerNoFieldGame,
|
||||
} from './smuggler-no-field-fixture';
|
||||
|
||||
function markup(game: Game, viewer = 'r', destination = 'arrakeen') {
|
||||
return renderToStaticMarkup(
|
||||
createElement(RicheseNoFieldControls, {
|
||||
game: viewGame(game, viewer),
|
||||
act() {},
|
||||
busy: false,
|
||||
destination,
|
||||
sector: destination === 'arrakeen' ? 10 : 9,
|
||||
allyPayment: '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void test('the private No-Field panel offers an unchecked free real-force companion without changing the marker price', () => {
|
||||
const html = markup(smugglerNoFieldGame());
|
||||
assert.match(html, /Private No-Field inventory/);
|
||||
for (const value of [0, 3, 5])
|
||||
assert.match(html, new RegExp(`No-Field ${value}`));
|
||||
assert.match(
|
||||
html,
|
||||
/Use Smuggler to add 1 real reserve force at no added cost/,
|
||||
);
|
||||
assert.doesNotMatch(html, /type="checkbox" checked=""/);
|
||||
assert.match(
|
||||
html,
|
||||
/No-Field token still uses the normal one-force shipment price/,
|
||||
);
|
||||
assert.match(html, /Ship concealed No-Field [035] · 1 spice/);
|
||||
assert.match(
|
||||
html,
|
||||
/Smuggler companion is a separate real force that leaves reserves immediately/,
|
||||
);
|
||||
|
||||
const optedIn = renderToStaticMarkup(
|
||||
createElement(SmugglerNoFieldChoice, {
|
||||
quote: { leader: 'richese-0', amount: 1 },
|
||||
use: true,
|
||||
busy: false,
|
||||
onChange() {},
|
||||
}),
|
||||
);
|
||||
assert.match(optedIn, /type="checkbox" checked=""/);
|
||||
assert.match(optedIn, /hidden forces remain in reserves until revelation/);
|
||||
assert.match(markup(smugglerNoFieldGame(true)), /Use Smuggler/);
|
||||
});
|
||||
|
||||
void test('the companion choice follows empty-territory and real-reserve gates and remains owner-private', () => {
|
||||
const occupied = smugglerNoFieldGame();
|
||||
occupied.players.find((player) => player.id === 'h')!.forces = {
|
||||
'arrakeen:9': 1,
|
||||
};
|
||||
occupied.players.find((player) => player.id === 'h')!.reserves = 19;
|
||||
assert.doesNotMatch(markup(occupied), /Use Smuggler/);
|
||||
|
||||
const emptyReserves = smugglerNoFieldGame();
|
||||
emptyReserves.players.find((player) => player.id === 'r')!.reserves = 0;
|
||||
assert.doesNotMatch(markup(emptyReserves), /Use Smuggler/);
|
||||
|
||||
const privateGame = smugglerNoFieldGame();
|
||||
assert.equal(markup(privateGame, 'h'), '');
|
||||
assert.equal(viewGame(privateGame, 'h').richeseNoField!.private, null);
|
||||
assert.doesNotMatch(markup(privateGame, 'h'), /No-Field [035]/);
|
||||
});
|
||||
|
||||
void test('all four bot profiles add the legal companion to owned No-Field shipments', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = smugglerNoFieldGame(advanced);
|
||||
const view = viewGame(game, 'r');
|
||||
view.players.find((player) => player.id === 'r')!.bot = difficulty;
|
||||
const noFieldActions = botActions(view).filter(
|
||||
(action) => action.type === 'ship' && action.noField,
|
||||
);
|
||||
const candidates = noFieldActions.filter(
|
||||
(action) => action.smuggler === true,
|
||||
);
|
||||
assert.ok(
|
||||
candidates.length,
|
||||
`${advanced ? 'Advanced' : 'Basic'} ${difficulty} omitted the companion`,
|
||||
);
|
||||
assert.ok(
|
||||
noFieldActions.every((action) => action.smuggler === true),
|
||||
`${advanced ? 'Advanced' : 'Basic'} ${difficulty} left a legal owned No-Field candidate unaugmented`,
|
||||
);
|
||||
const destination = String(candidates[0].territory);
|
||||
let settled = applyAction(game, 'r', candidates[0]);
|
||||
if (settled.decision?.kind === 'guildShipment')
|
||||
settled = applyAction(settled, settled.decision.player, {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.equal(settled.players[0].reserves, 19);
|
||||
assert.equal(presenceAt(settled.players[0], destination), 2);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all profiles reveal a zero No-Field before moving when its real companion is alongside it', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = smugglerNoFieldGame();
|
||||
game = applyAction(game, 'r', smugglerNoFieldAction(game, 0, true));
|
||||
game.active = 'r';
|
||||
const view = viewGame(game, 'r');
|
||||
view.players.find((player) => player.id === 'r')!.bot = difficulty;
|
||||
const reveal = botActions(view)[0];
|
||||
assert.equal(reveal.type, 'revealNoField', difficulty);
|
||||
game = applyAction(game, 'r', reveal);
|
||||
assert.equal(game.players[0].noField!.deployed, null);
|
||||
assert.equal(game.players[0].reserves, 19);
|
||||
assert.equal(presenceAt(game.players[0], 'arrakeen'), 1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mock } from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
joinGame,
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
/** Genuine Richese + Leader Skills setup, then an explicitly conserved shipment scenario. */
|
||||
export function smugglerNoFieldGame(advanced = false): Game {
|
||||
let g = createGame(
|
||||
'SMUGNOFL',
|
||||
newPlayer('r', 'Richese', 'richese'),
|
||||
advanced,
|
||||
['choam'],
|
||||
);
|
||||
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(g, newPlayer('g', 'Guild', '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 < 80; i++) {
|
||||
let accepted = false;
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
const skills = view.leaderSkills!;
|
||||
const options: Action[] =
|
||||
g.setupStage === 'leaderSkills' && skills.offer
|
||||
? [
|
||||
{
|
||||
type: 'leaderSkill',
|
||||
event: skills.offer.event,
|
||||
skill: p.id === 'r' ? 'smuggler' : skills.offer.cards[0],
|
||||
leader: skills.eligibleLeaders[0].id,
|
||||
},
|
||||
]
|
||||
: g.setupStage === 'traitors' && p.traitorChoices.length
|
||||
? [{ type: 'traitor', leader: p.traitorChoices[0] }]
|
||||
: botActions(view);
|
||||
for (const a of options) {
|
||||
try {
|
||||
g = applyAction(g, p.id, a);
|
||||
accepted = true;
|
||||
break;
|
||||
} catch {
|
||||
/* Other seats may be waiting. */
|
||||
}
|
||||
}
|
||||
if (accepted) break;
|
||||
}
|
||||
assert.ok(accepted, `setup ${g.setupStage}`);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
assert.ok(g.players[0].noField);
|
||||
Object.assign(g, {
|
||||
phase: 5,
|
||||
turn: 2,
|
||||
storm: 18,
|
||||
active: 'r',
|
||||
order: ['r', 'h', 'g'],
|
||||
movementRemaining: ['r', 'h', 'g'],
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand);
|
||||
Object.assign(p, {
|
||||
hand: [],
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
spice: 10,
|
||||
shipped: false,
|
||||
moved: 0,
|
||||
advisors: {},
|
||||
});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
export function smugglerNoFieldAction(
|
||||
g: Game,
|
||||
value: 0 | 3 | 5 = 5,
|
||||
smuggler = true,
|
||||
): Action {
|
||||
const p = g.players.find((p) => p.id === 'r')!;
|
||||
return {
|
||||
type: 'ship',
|
||||
noField: p.noField!.tokens.find((t) => t.value === value)!.id,
|
||||
event: p.noFieldEvent,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
smuggler,
|
||||
};
|
||||
}
|
||||
|
||||
export function conserveNoFieldForces(g: Game) {
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
p.id,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
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 { unitStore } from './fixture-nexus-room-store';
|
||||
import { placeFixtureHand } from './fixture-hand';
|
||||
import {
|
||||
conserveNoFieldForces,
|
||||
smugglerNoFieldAction,
|
||||
smugglerNoFieldGame,
|
||||
} from './smuggler-no-field-fixture';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function fixture(advanced = false, canceler: 'h' | 'g' | null = null) {
|
||||
const store = unitStore();
|
||||
const sqlite: DatabaseSync = store.sqlite;
|
||||
const created = await store.rooms.createRoom(
|
||||
'Richese Smuggler',
|
||||
'richese',
|
||||
advanced,
|
||||
['choam'],
|
||||
);
|
||||
const joined = await store.rooms.joinRoom(
|
||||
created.view.code,
|
||||
'Harkonnen',
|
||||
'harkonnen',
|
||||
);
|
||||
const guild = await store.rooms.joinRoom(created.view.code, 'Guild', 'guild');
|
||||
const code = created.view.code;
|
||||
const tokens = [created.token!, joined.token!, guild.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(['r', 'h', 'g'][index], code, seat.playerId);
|
||||
const seats = await Promise.all(
|
||||
tokens.map((token) => store.restart().authenticate(code, token)),
|
||||
);
|
||||
|
||||
const state = smugglerNoFieldGame(advanced);
|
||||
state.code = code;
|
||||
state.version = (await store.rooms.readRoom(code)).version;
|
||||
let karama = null;
|
||||
if (canceler) {
|
||||
karama = [
|
||||
...state.deck,
|
||||
...state.discard,
|
||||
...state.players.flatMap((player) => player.hand),
|
||||
].find((card) => card.effect === 'karama')!;
|
||||
assert.ok(karama);
|
||||
placeFixtureHand(state, canceler === 'h' ? 1 : 2, [karama]);
|
||||
}
|
||||
const action = smugglerNoFieldAction(state);
|
||||
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, action, karama };
|
||||
}
|
||||
|
||||
function barrier() {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let arrivals = 0;
|
||||
return async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await ready;
|
||||
};
|
||||
}
|
||||
|
||||
function committedOnce(
|
||||
results: PromiseSettledResult<unknown>[],
|
||||
writes: { changes: number }[],
|
||||
) {
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === 'rejected').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
writes.map((write) => write.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
}
|
||||
|
||||
void test('restart preserves the concealed companion through the No-Field and Guild stages, then concurrent allowance commits it once', async () => {
|
||||
const f = await fixture(true, 'g');
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
|
||||
const initial = await f.restart().readRoom(f.code);
|
||||
assert.equal(initial.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(initial.pendingShipment?.smugglerCompanion, {
|
||||
leader: initial.leaderSkills!.assignments.find(
|
||||
(assignment) => assignment.owner === 'r',
|
||||
)!.leader,
|
||||
amount: 1,
|
||||
});
|
||||
assert.equal(initial.pendingShipment?.amount, 1);
|
||||
assert.equal(initial.pendingShipment?.cost, 1);
|
||||
assert.equal(initial.players[0].reserves, 20);
|
||||
assert.deepEqual(initial.players[0].forces, {});
|
||||
assert.equal(initial.players[0].noField!.deployed, null);
|
||||
|
||||
for (const seat of f.seats) {
|
||||
const view = await f.restart().readSeatView(f.code, seat);
|
||||
assert.equal(JSON.stringify(view).includes('noFieldSkillProof'), false);
|
||||
assert.equal('pendingShipment' in view, false);
|
||||
if (seat.playerId !== 'r')
|
||||
assert.equal(
|
||||
JSON.stringify(view).includes(String(f.action.noField)),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const passes = await Promise.allSettled([
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
initial.version,
|
||||
{ type: 'passResponse' },
|
||||
clock,
|
||||
),
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
initial.version,
|
||||
{ type: 'passResponse' },
|
||||
clock,
|
||||
),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
committedOnce(passes, f.writes);
|
||||
|
||||
const guildDecision = await f.restart().readRoom(f.code);
|
||||
assert.equal(guildDecision.decision?.kind, 'guildShipment');
|
||||
assert.deepEqual(
|
||||
guildDecision.pendingShipment?.smugglerCompanion,
|
||||
initial.pendingShipment?.smugglerCompanion,
|
||||
);
|
||||
assert.equal(guildDecision.players[0].reserves, 20);
|
||||
assert.deepEqual(guildDecision.players[0].forces, {});
|
||||
for (const seat of f.seats) {
|
||||
const view = await f.restart().readSeatView(f.code, seat);
|
||||
assert.equal(
|
||||
JSON.stringify(view.decision).includes('noFieldSkillProof'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const allow: Action = { type: 'decision', allow: true };
|
||||
const allowances = await Promise.allSettled([
|
||||
f.rooms.act(f.code, f.seats[2], guildDecision.version, allow, clock),
|
||||
f.restart().act(f.code, f.seats[2], guildDecision.version, allow, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
committedOnce(allowances, f.writes);
|
||||
|
||||
const shipped = await f.restart().readRoom(f.code);
|
||||
assert.equal(shipped.pendingShipment, null);
|
||||
assert.equal(shipped.decision, null);
|
||||
assert.equal(shipped.players[0].reserves, 19);
|
||||
assert.equal(shipped.players[0].forces['arrakeen:10'], 1);
|
||||
assert.equal(shipped.players[0].spice, 9);
|
||||
assert.equal(shipped.players[2].spice, 11);
|
||||
assert.equal(
|
||||
shipped.log.filter(
|
||||
(entry) => entry.automatic?.name === 'No-Field shipment',
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
conserveNoFieldForces(shipped);
|
||||
|
||||
await f.restart().act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
shipped.version,
|
||||
{
|
||||
type: 'revealNoField',
|
||||
event: shipped.players[0].noFieldEvent,
|
||||
token: shipped.players[0].noField!.deployed!.tokenId,
|
||||
},
|
||||
clock,
|
||||
);
|
||||
const revealed = await f.restart().readRoom(f.code);
|
||||
assert.equal(revealed.players[0].reserves, 14);
|
||||
assert.equal(revealed.players[0].forces['arrakeen:10'], 6);
|
||||
conserveNoFieldForces(revealed);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent restored No-Field cancellation leaves the marker and companion in reserve custody', async () => {
|
||||
const f = await fixture(false, 'h');
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
const cancel: Action = {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: f.karama!.id,
|
||||
};
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const cancellations = await Promise.allSettled([
|
||||
f.rooms.act(f.code, f.seats[1], pending.version, cancel, clock),
|
||||
f.restart().act(f.code, f.seats[1], pending.version, cancel, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
committedOnce(cancellations, f.writes);
|
||||
|
||||
const canceled = await f.restart().readRoom(f.code);
|
||||
assert.equal(canceled.pendingShipment, null);
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.players[0].reserves, 20);
|
||||
assert.deepEqual(canceled.players[0].forces, {});
|
||||
assert.equal(canceled.players[0].spice, 10);
|
||||
assert.equal(canceled.players[0].shipped, false);
|
||||
assert.equal(canceled.players[0].noField!.deployed, null);
|
||||
assert.equal(canceled.players[0].noField!.lastShipped, null);
|
||||
assert.equal(
|
||||
canceled.discard.filter((card) => card.id === f.karama!.id).length,
|
||||
1,
|
||||
);
|
||||
conserveNoFieldForces(canceled);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('restored Guild cancellation consumes the shipment opportunity without withdrawing the companion', async () => {
|
||||
const f = await fixture(true, 'g');
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
|
||||
let pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
pending.version,
|
||||
{ type: 'passResponse' },
|
||||
clock,
|
||||
);
|
||||
pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(pending.players[0].reserves, 20);
|
||||
await f.restart().act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
pending.version,
|
||||
{
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: f.karama!.id,
|
||||
target: 'r',
|
||||
},
|
||||
clock,
|
||||
);
|
||||
const canceled = await f.restart().readRoom(f.code);
|
||||
assert.equal(canceled.pendingShipment, null);
|
||||
assert.equal(canceled.decision, null);
|
||||
assert.equal(canceled.players[0].reserves, 20);
|
||||
assert.deepEqual(canceled.players[0].forces, {});
|
||||
assert.equal(canceled.players[0].spice, 10);
|
||||
assert.equal(canceled.players[0].shipped, true);
|
||||
assert.equal(canceled.players[0].noField!.deployed, null);
|
||||
assert.equal(canceled.players[0].noField!.lastShipped, null);
|
||||
assert.equal(canceled.players[2].specialKaramaUsed, true);
|
||||
assert.equal(
|
||||
canceled.discard.filter((card) => card.id === f.karama!.id).length,
|
||||
1,
|
||||
);
|
||||
conserveNoFieldForces(canceled);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a restored No-Field declaration without the Smuggler flag retains legacy token-only custody', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const { smuggler: _unused, ...legacy } = f.action;
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, legacy, clock);
|
||||
const shipped = await f.restart().readRoom(f.code);
|
||||
assert.equal(shipped.pendingShipment, null);
|
||||
assert.equal(shipped.players[0].reserves, 20);
|
||||
assert.deepEqual(shipped.players[0].forces, {});
|
||||
assert.equal(shipped.players[0].spice, 9);
|
||||
assert.ok(shipped.players[0].noField!.deployed);
|
||||
assert.equal(
|
||||
shipped.log.some((entry) => entry.text.includes('free Smuggler force')),
|
||||
false,
|
||||
);
|
||||
conserveNoFieldForces(shipped);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
viewGame,
|
||||
normalizeAutomaticGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { quoteSmugglerNoField } from '../game/smuggler-no-field';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import {
|
||||
smugglerNoFieldGame,
|
||||
smugglerNoFieldAction,
|
||||
conserveNoFieldForces,
|
||||
} from './smuggler-no-field-fixture';
|
||||
|
||||
function reject(g: Game, actor: string, action: Action) {
|
||||
const original = JSON.stringify(g);
|
||||
assert.throws(() => applyAction(g, actor, action));
|
||||
assert.equal(JSON.stringify(g), original);
|
||||
}
|
||||
function karama(g: Game, player = 'h') {
|
||||
const i = g.deck.findIndex((c) => c.effect === 'karama');
|
||||
assert.ok(i >= 0);
|
||||
const card = g.deck.splice(i, 1)[0];
|
||||
g.players.find((p) => p.id === player)!.hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
|
||||
void test('each hidden No-Field value ships one separate Smuggler force at the unchanged marker price', () => {
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const original = smugglerNoFieldGame();
|
||||
const action = smugglerNoFieldAction(original, value);
|
||||
const shipped = applyAction(original, 'r', action);
|
||||
assert.equal(shipped.players[0].spice, 9);
|
||||
assert.equal(shipped.players[2].spice, 11);
|
||||
assert.equal(shipped.players[0].reserves, 19);
|
||||
assert.equal(shipped.players[0].forces['arrakeen:10'], 1);
|
||||
assert.equal(presenceAt(shipped.players[0], 'arrakeen'), 2);
|
||||
assert.equal(shipped.players[0].noField!.deployed!.tokenId, action.noField);
|
||||
assert.equal(original.players[0].reserves, 20);
|
||||
conserveNoFieldForces(shipped);
|
||||
const other = JSON.stringify(viewGame(shipped, 'h'));
|
||||
assert.equal(other.includes(String(action.noField)), false);
|
||||
assert.equal(other.includes('noFieldSkillProof'), false);
|
||||
const restored = JSON.parse(JSON.stringify(shipped));
|
||||
const revealed = applyAction(restored, 'r', {
|
||||
type: 'revealNoField',
|
||||
event: restored.players[0].noFieldEvent,
|
||||
token: action.noField,
|
||||
});
|
||||
assert.equal(revealed.players[0].forces['arrakeen:10'], 1 + value);
|
||||
assert.equal(revealed.players[0].reserves, 19 - value);
|
||||
conserveNoFieldForces(revealed);
|
||||
reject(revealed, 'r', action);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a scarce reserve pays the companion once; later No-Field reveal uses only the remaining supply', () => {
|
||||
for (const reserves of [1, 2]) {
|
||||
const g = smugglerNoFieldGame();
|
||||
g.players[0].reserves = reserves;
|
||||
g.players[0].tanks = 20 - reserves;
|
||||
let done = applyAction(g, 'r', smugglerNoFieldAction(g));
|
||||
done = applyAction(done, 'r', {
|
||||
type: 'revealNoField',
|
||||
event: done.players[0].noFieldEvent,
|
||||
token: done.players[0].noField!.deployed!.tokenId,
|
||||
});
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], reserves);
|
||||
assert.equal(done.players[0].reserves, 0);
|
||||
conserveNoFieldForces(done);
|
||||
}
|
||||
const empty = smugglerNoFieldGame();
|
||||
empty.players[0].reserves = 0;
|
||||
empty.players[0].tanks = 20;
|
||||
reject(empty, 'r', smugglerNoFieldAction(empty));
|
||||
const plain = applyAction(empty, 'r', smugglerNoFieldAction(empty, 0, false));
|
||||
assert.equal(plain.players[0].reserves, 0);
|
||||
assert.equal(plain.players[0].spice, 9);
|
||||
assert.deepEqual(plain.players[0].forces, {});
|
||||
conserveNoFieldForces(plain);
|
||||
});
|
||||
|
||||
void test('optional decline and desert pricing preserve the full one-marker price', () => {
|
||||
for (const smuggler of [false, undefined]) {
|
||||
const g = smugglerNoFieldGame();
|
||||
const done = applyAction(g, 'r', { ...smugglerNoFieldAction(g), smuggler });
|
||||
assert.equal(done.players[0].reserves, 20);
|
||||
assert.equal(done.players[0].spice, 9);
|
||||
assert.deepEqual(done.players[0].forces, {});
|
||||
}
|
||||
const g = smugglerNoFieldGame();
|
||||
const done = applyAction(g, 'r', {
|
||||
...smugglerNoFieldAction(g),
|
||||
territory: 'wind_pass',
|
||||
sector: 14,
|
||||
});
|
||||
assert.equal(done.players[0].spice, 8);
|
||||
assert.equal(done.players[2].spice, 12);
|
||||
assert.equal(done.players[0].forces['wind_pass:14'], 1);
|
||||
conserveNoFieldForces(done);
|
||||
});
|
||||
|
||||
void test('Smuggler companion requires native living public custody and a wholly empty territory', () => {
|
||||
const g = smugglerNoFieldGame();
|
||||
assert.ok(quoteSmugglerNoField(g, 'r', 'arrakeen'));
|
||||
for (const owner of ['r', 'h']) {
|
||||
const occupied = smugglerNoFieldGame();
|
||||
const p = occupied.players.find((p) => p.id === owner)!;
|
||||
p.reserves--;
|
||||
p.forces['wind_pass:15'] = 1;
|
||||
assert.equal(quoteSmugglerNoField(occupied, 'r', 'wind_pass'), null);
|
||||
reject(occupied, 'r', {
|
||||
...smugglerNoFieldAction(occupied),
|
||||
territory: 'wind_pass',
|
||||
sector: 14,
|
||||
});
|
||||
}
|
||||
const hidden = viewGame(g, 'r');
|
||||
hidden.leaderSkills!.assignments.find((a) => a.owner === 'r')!.faceUp = false;
|
||||
assert.equal(quoteSmugglerNoField(hidden, 'r', 'arrakeen'), null);
|
||||
for (const absent of [
|
||||
{ dead: true },
|
||||
{ capturedBy: 'h' },
|
||||
{ gholaBy: 'h' },
|
||||
]) {
|
||||
const unavailable = smugglerNoFieldGame();
|
||||
const leader = unavailable.players[0].leaders.find(
|
||||
(l) =>
|
||||
l.id ===
|
||||
unavailable.leaderSkills!.assignments.find((a) => a.owner === 'r')!
|
||||
.leader,
|
||||
)!;
|
||||
Object.assign(leader, absent);
|
||||
assert.equal(quoteSmugglerNoField(unavailable, 'r', 'arrakeen'), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('initial Karama cancellation preserves the companion, spice, token history and ordinary shipment opportunity', () => {
|
||||
const g = smugglerNoFieldGame();
|
||||
const card = karama(g);
|
||||
const pending = applyAction(g, 'r', smugglerNoFieldAction(g));
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
assert.equal(pending.pendingShipment?.amount, 1);
|
||||
assert.equal(pending.pendingShipment?.smugglerCompanion?.amount, 1);
|
||||
const canceled = applyAction(JSON.parse(JSON.stringify(pending)), 'h', {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card,
|
||||
});
|
||||
assert.equal(canceled.players[0].reserves, 20);
|
||||
assert.equal(canceled.players[0].spice, 10);
|
||||
assert.equal(canceled.players[0].noField!.lastShipped, null);
|
||||
assert.equal(canceled.players[0].shipped, false);
|
||||
const fallback = applyAction(canceled, 'r', {
|
||||
type: 'ship',
|
||||
amount: 1,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
});
|
||||
assert.equal(fallback.players[0].reserves, 19);
|
||||
conserveNoFieldForces(fallback);
|
||||
});
|
||||
|
||||
void test('Advanced Guild allows the saved companion once and hides its private declaration proof', () => {
|
||||
const g = smugglerNoFieldGame(true);
|
||||
const action = smugglerNoFieldAction(g);
|
||||
const pending = applyAction(g, 'r', action);
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(pending.pendingShipment?.amount, 1);
|
||||
assert.equal(pending.players[0].reserves, 20);
|
||||
for (const p of pending.players) {
|
||||
const view = JSON.stringify(viewGame(pending, p.id));
|
||||
assert.equal(view.includes('noFieldSkillProof'), false);
|
||||
if (p.id !== 'r')
|
||||
assert.equal(view.includes(String(action.noField)), false);
|
||||
}
|
||||
const done = applyAction(JSON.parse(JSON.stringify(pending)), 'g', {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.equal(done.players[0].reserves, 19);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], 1);
|
||||
conserveNoFieldForces(done);
|
||||
reject(done, 'g', { type: 'decision', allow: true });
|
||||
});
|
||||
|
||||
void test('both saved No-Field stages reject changed companion, proof, destination and missing continuation before mutation', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const g = smugglerNoFieldGame(advanced);
|
||||
if (!advanced) karama(g);
|
||||
const pending = applyAction(g, 'r', smugglerNoFieldAction(g));
|
||||
const corruptions: ((g: Game) => void)[] = [
|
||||
(x) => {
|
||||
delete x.pendingShipment!.smugglerCompanion;
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.smugglerCompanion!.leader = 'stale';
|
||||
},
|
||||
(x) => {
|
||||
delete x.pendingShipment!.noFieldSkillProof;
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.noFieldSkillProof = 'stale';
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.amount = 2;
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.cost = 0;
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.territory = 'carthag';
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment = null;
|
||||
},
|
||||
(x) => {
|
||||
x.response = null;
|
||||
x.decision = null;
|
||||
},
|
||||
(x) => {
|
||||
x.players[0].reserves = 0;
|
||||
x.players[0].tanks = 20;
|
||||
},
|
||||
(x) => {
|
||||
x.players[1].reserves--;
|
||||
x.players[1].forces['arrakeen:10'] = 1;
|
||||
},
|
||||
];
|
||||
for (const corrupt of corruptions) {
|
||||
const bad = structuredClone(pending);
|
||||
corrupt(bad);
|
||||
assert.throws(() => viewGame(bad, 'r'));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
reject(
|
||||
bad,
|
||||
advanced ? 'g' : 'h',
|
||||
advanced ? { type: 'decision', allow: true } : { type: 'pass' },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user