Integrate full funded Guild Nexus Betrayal

This commit is contained in:
Quintine committed 2026-10-01 10:57:05 +10:00
1 parent d3bf12fcd8
commit 8a56299155
28 files changed
+3005 -58

No files matched your search

+10
View File
@@ -158,6 +158,16 @@ other-buyer cache/Black Market sale diversion with unchanged delivery and price.
See the [cost, private timing, recovery evidence and remaining boundaries](docs/NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026).
This does not enable public expansion starts or certify the full Nexus family.
For local **Guild Nexus Betrayal** testing, use `node --import tsx tools/start-prototype.ts --profile guild-betrayal`
on a fresh ready Basic/Advanced classic lobby with native Guild, base decks
and Nexus. Enable Homeworlds in the lobby before readiness only when exercising
Homeworld/Junction routes; the tool does not add that module to an ordinary room.
The exact-version/private-backup safeguards apply. The holder takes one whole
funded shipment payment, including their own or income overridden at Junction;
this is not a second shipment, free upfront financing or extra Guild income.
See [payment custody and source boundaries](docs/NEXUS_GUILD_RULES.md#betrayal-payment-replacement).
Existing games and public start gates remain unchanged.
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
counts separately from hands, auction cards and discards. See
[draw-pile counts](docs/DRAW_PILES.md).
+28 -5
View File
@@ -79,6 +79,7 @@ import { NullentropyBox, NullentropySearch } from './nullentropy-box';
import { SemutaReaction } from './semuta-reaction';
import { ChoamKull } from './choam-kull';
import { NexusRicheseBetrayal } from './nexus-richese-betrayal';
import { NexusGuildBetrayal } from './nexus-guild-betrayal';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { PlanetologistMovement } from './planetologist-movement';
@@ -302,8 +303,8 @@ export function GameTable({
);
const reactionBusy =
transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot;
const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction;
const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction;
const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction;
const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction;
const canActivateKarama = (card: Card) =>
!g.karamaBlocked && canUseAsKaramaRole(g, me, card);
const traitorBattle = g.battle;
@@ -779,7 +780,16 @@ export function GameTable({
count > 0 &&
!botGroundMoveAllowed(g, me, key, boardLocation(selected, sector), 0),
);
const act = (a: Action) => void send(a);
const act = (a: Action) => {
const guildReaction = g.guildBetrayalReaction;
if (guildReaction) {
if (reactionBusy || g.status !== 'playing' || !guildReaction.canPass || guildReaction.hasPassed ||
(a.type !== 'guildBetrayalPass' && a.type !== 'guildBetrayalUse') ||
a.event !== guildReaction.event ||
(a.type === 'guildBetrayalUse' && (!guildReaction.canUse || guildReaction.blocked))) return;
}
void send(a);
};
const choose = (id: string) => {
const location = gameTerritories(g).find((candidate) => candidate.id === id);
if (!location) return;
@@ -1009,6 +1019,8 @@ export function GameTable({
faction(g.players.find((p) => p.id === id)!.faction).name,
)
.join(' + ') || 'No winner'
: g.guildBetrayalReaction
? 'Shipment payment acknowledgement'
: g.richeseBetrayalReaction
? 'Richese auction acknowledgement'
: g.kullReaction
@@ -1054,6 +1066,15 @@ export function GameTable({
<a href="/rules?topic=nexus-richese-betrayal#nexus-richese-betrayal">Preview rules and limits</a>
</p>
)}
{g.guildBetrayalPreview && (
<p className="notice" role="status">
Guild Nexus Betrayal development preview · whole funded shipment
payment replacement, including your own payment and occupied Junction.
Classic factions with native Guild and Nexus; optional genuinely
seeded Homeworlds only. Not complete Guild or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-guild-betrayal#nexus-guild-betrayal">Preview rules and limits</a>
</p>
)}
{g.status !== 'lobby' && (
<nav className="phase-track" aria-label="Turn phases">
{PHASES.map((p, i) => (
@@ -1086,7 +1107,7 @@ export function GameTable({
<SpiceCardInspector card={g.spicePeek} context="Private foresight" />
</section>
)}
<SeatAutopilot game={g} act={act} busy={transportBusy} />
<SeatAutopilot game={g} act={act} busy={transportBusy || !!g.guildBetrayalReaction} />
<DukeVidal game={g} />
<EcazLoyaltyCard loyalty={g.ecazLoyalty} />
<MoritaniTerrorSupply game={g} />
@@ -1692,7 +1713,9 @@ export function GameTable({
onSectorChange={setSector}
/>
</>}
{g.richeseBetrayalReaction ? (
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
<NexusRicheseBetrayal game={g} act={act} busy={reactionBusy} />
) : g.kullReaction ? (
<ChoamKull game={g} act={act} busy={reactionBusy} />
+61
View File
@@ -0,0 +1,61 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { nexusCardMode } from '@/game/nexus-cards';
import { NexusCardFace } from './nexus-cards';
import { Button } from './ui/button';
export function NexusGuildBetrayal({ game, act, busy }: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const reaction = game.guildBetrayalReaction;
if (!reaction) return null;
const shipper = game.players.find((player) => player.id === reaction.shipper);
const canRespond = !!reaction.event && reaction.canPass && !reaction.hasPassed;
const canUse = canRespond && reaction.canUse && !reaction.blocked;
const disabled = busy || game.status !== 'playing';
const own = game.players.find((player) => player.id === game.me);
const ownGuildMode = game.nexusCards?.card === 'guild' && own
? nexusCardMode('guild', own.faction, game.players.map((player) => player.faction))
: undefined;
return (
<section aria-label="Guild Nexus Betrayal response" className="notice min-w-0 space-y-3">
<h3>Guild Nexus Betrayal · before shipment payment</h3>
<p>{shipper?.name ?? 'The shipper'} has a pending shipment payment. No payment or force delivery has happened yet.</p>
<p className="fine">
Every publicly possible unallied responder acknowledges this boundary, regardless of their secret Nexus identity.
Passing allows the original shipment after all required acknowledgements. Betrayal takes the whole original funded payment,
including your own payment, instead of the Guild, Spice Bank or Junction occupier receiving it. It does not create another shipment.
Your own refund cannot fund the upfront cost. This response reveals no private price, route or source.{' '}
<a href="/rules?topic=nexus-guild-betrayal#nexus-guild-betrayal">Betrayal timing and preview limits</a>
</p>
{game.nexusCards?.card === 'guild' && <NexusCardFace card="guild" mode={ownGuildMode} />}
{!canRespond ? (
<p aria-live="polite">{reaction.hasPassed ? 'You have passed. Waiting for the remaining acknowledgements.' : 'Waiting for the required acknowledgements.'}</p>
) : (
<>
{reaction.blocked && <p className="fine">{reaction.blocked}</p>}
<Button
className="game-action min-h-11 whitespace-normal"
disabled={disabled}
onClick={() => { if (!disabled) act({ type: 'guildBetrayalPass', event: reaction.event }); }}
>
Pass · allow the original shipment payment
</Button>
{canUse && (
<Button
className="game-action min-h-11 whitespace-normal"
disabled={disabled}
onClick={() => { if (!disabled) act({ type: 'guildBetrayalUse', event: reaction.event }); }}
>
Use Guild Nexus · take the whole shipment payment
</Button>
)}
</>
)}
</section>
);
}
+34
View File
@@ -4,8 +4,42 @@ Updated 1 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable and the six classic factions have a user-authorized Advanced preview.
Expansion starts and full rules/publication acceptance remain gated.**
The user-supplied `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf` is now
an [authorized Advanced source of truth](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026),
including its identified unofficial rulings. The 44-page file has 2.3
metadata but a visibly retained 2.2 editorial heading; the index records its
SHA-256 and physical-page citations. Current Guild payment/source passages
were compared with the wave. Wider Advanced reconciliation is not yet
implemented, and adoption does not reset games, convert Basic, republish
the local PDF or open mode gates.
## Current checkpoint and work
Five implementation lanes connected [Guild Nexus Betrayal](NEXUS_GUILD_RULES.md#nexus-guild-betrayal):
full funded shipment-payment replacement, including own upfront payment
and the explicit occupied-Junction override. Fresh `guild-betrayal` admits
classic/native-Guild plus base decks/Nexus, optionally genuine lobby-enabled
Homeworlds; no in-play retrofit or public start. Four typed native source
adapters retain exact payer/escrow, force custody and one shipment suffix.
Neutral public acknowledgements reveal no otherwise private fee/routes;
only own canonical eligibility exposes Use. Both scoped final reviews have
no finding after fixing precommitted Richese parents and independently
revalidating the native transport grant. Unsupported Cunning/Richese producer
overlays block before spending; outside-profile behavior stays unchanged.
Current-quote fixture progression, durable SQL rejection and same-source
private-face comparisons replace random-route/SQL-text/independent-seed pins.
Types, lint, **6,114 offline tests**, build and **147 selected cases** pass.
Eighteen standalone Basic/Advanced paid/free scenarios, eight exact-version
backed-up real CLI entries, 390-pixel Use/Pass/inspector/context-link/refresh
and read-only saved-state checks prove exact native forces, whole-fee ledger,
single donor escrow consumption and physical cards. Original authenticated
seats and all unrelated games remain preserved.
Local built-worker HTTP is **48/55**, five POST 503s plus two timeouts,
not green. Current immutable-code container verification/push are pending;
prior workflows and the last live footer do not verify this new code.
Full modes, strategic AI, public starts and live deployment stay gated.
Five implementation lanes connect [CHOAM Nexus Cunning Kull](NEXUS_CHOAM_RUNTIME.md#bounded-nexus-kull-profile)
through the new fresh `nexus-kull` profile. Actual canonical Treachery fuel
and physical Nexus are distinct costs: Nexus spends at accepted declaration,
+10
View File
@@ -15,6 +15,16 @@ The [expansion-faction profile](EXPANSION_FACTIONS_PROTOTYPE.md) extends genuine
setup to selected faction sets without optional modules, including Ecaz starting
placement. Remaining combined-play guards still apply.
For rules work, use the [authorized source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026).
The user-supplied root PDF `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`
is now an authorized Advanced source of truth, including identified
unofficial rulings. Cite physical pages and distinguish its rulings from
publisher provenance; retain explicit user decisions and use components/GF9
for omitted details. Its metadata says 2.3 while its editorial heading says
2.2; the decision index binds the exact supplied file by SHA-256.
Do not silently change Basic, enable unfinished modes, reset games or
republish the local PDF merely because a new reference is available.
## OMP harness workflow
When continuing the full project goal, explicitly read `GOAL.local.md` if it is
+6
View File
@@ -59,6 +59,12 @@ The unoccupied calculation leaves the missing half in the bank. An occupied face
Future integration must preserve the original eligible receipt, present force state and qualification history separately. Ordinary income cancellation, retained low effects, occupied percentage income and immediate ally sharing must compose from their own rules; the current unoccupied branch is not authority for a future occupied Karama outcome.
### Guild Nexus Betrayal is a full-fee exception
The [original Guild Nexus face and bounded payment contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal) expressly take one whole shipment payment, including one's own, and override occupied Junction income. For an accepted use, preserve the original **charged total**, not only this document's eligible `gross` or `ceil(gross / 2)`: the holder receives all of that fee once, and ordinary Guild/bank/occupier settlement for this invoice is suppressed. A four-spice fee yields four to the holder, not two plus two to an occupier. The original payer and authorized escrow still fund it once before any own-payment refund; future receipt is not upfront funding.
This exception does not resolve ordinary two-odd-contributor rounding, occupation owner/expiry or retained penalties. All-pass/no-use settlement retains its native routing and guards; no hypothetical card play may admit an otherwise unresolved ordinary invoice. Free or stopped transport has no positive fee to redirect. The explicit fresh classic/Guild/Nexus profile uses genuinely seeded Homeworld custody and existing legal shipment/Junction adapters; it grants no new route or sponsorship. Its [direct bounded local verification](NEXUS_GUILD_RULES.md#verification-and-release-boundary) includes full occupied low-Junction fees, independently of this document's historical unoccupied-income evidence and broader Homeworld gates.
## Required verification before a runtime claim
Meaningful regressions cover odd/even and zero receipts, rejection of two eligible odd contributions before declaration or payment, coincident 2 + 1 rounding, both contributor identities, protected original contribution evidence, module-off parity, current 4-to-5 and 5-to-4 changes across legal Ghola interruptions, cancellation without refund, all listed Emperor sources, all admitted shipment adapters and exact parent restoration. Save/recovery tests should prove no repeat debit, card acquisition, force move or faction credit, and reject altered gross/source/owner binding without persistence. Public previews may show the current numeric split but must not expose the purchased card's private face.
+60
View File
@@ -1,5 +1,65 @@
# Dune implementation status
## 1 October 2026 — full funded Guild Nexus Betrayal payment
Five implementation lanes integrated engine, pure funding/custody/history,
actual controls and four minimal legal bots, authenticated SQLite recovery
and source guidance. Fresh exact-version/private-backup `guild-betrayal`
entry supports classic 2..6-seat Basic/Advanced with native Guild, base decks
and Nexus, optionally genuine lobby-enabled Homeworlds; no retrofit/public gate.
Typed reserve, native Guild transport, Homeworld and Junction adapters pause
before the original fee/force commit. Actual eligible singleton Guild Nexus
spends once; the same payer/authorized escrow funds the full original amount
and the holder replaces bank/Guild/occupied-Junction income. Own payment
requires existing funds before the later refund; it is not free financing.
All-pass leaves native routing unchanged, while free/stopped transport
creates no award or Nexus spend. Public possible held-card presence controls
neutral acknowledgements, not secret identity; event-only private Use adds
no otherwise private price/source or inspector entitlement.
Independent review found an accepted Advanced Richese Secret Ally parent
whose resumed shipment failed. Actual reproduction proved spent Nexus and
undelivered source; its unsupported offer now blocks before card/parent
commitment. A shared native Guild/reciprocal-ally grant is independently
revalidated on declaration, restore and commit, separate from purchased
rates and protected returns. Both final scoped re-reviews have no finding.
Fixture current quotes/native turn progression preserve actual storm rules;
real SQLite total_changes excludes durable rejection writes while permitting
credential-fenced zero-row CAS. Same-source private-face swaps and existing
physical-card allocation repair randomized fixture assumptions.
Final `npm run check`: types, lint and **6,114 offline tests** pass;
production build passes. Selected related checks pass **147/147** in nine
files. Eighteen standalone Basic/Advanced paid/free cases prove full fee,
upfront own refund, donor escrow once, exact native forces and JSON repeat
rejection. Authenticated SQL covers all adapters, self-re-signed revoked
native grants, corrupt/orphan receipts, privacy, races and later recycling.
Eight actual ready QA rooms were privately backed up and entered once by
CLI, retaining the same authenticated seats and admitted code/roster/setup.
Real setup/native phases and source declarations preceded 390-pixel controls:
Basic own refund, occupied low-Junction whole fee, Guild return and all-pass;
Advanced Homeworld bank fee, donor escrow, native Junction and free Fremen.
Non-holder Pass has no private Guild inspector/Use. Own inspector, actual
contextual guide link and refresh pass. Read-only database proof matches all
eight original native force suffixes, exact spice/escrow/card ledger and
closed receipt. No existing human games or credentials were reset.
The user supplied the root unofficial Advanced 2.3 PDF as a source of truth.
Its 44-page metadata says 2.3 while the rendered editorial heading still
says 2.2; exact SHA/physical-page authority and material future Advanced
reconciliation targets are recorded in RULE_DECISIONS/DEVELOPMENT and local
goal guidance. Relevant Guild/payment/Homeworld/Nexus clauses were compared;
this does not certify that all Advanced differences are implemented.
The user-owned local PDF is preserved, not republished as an app asset.
Local built-worker HTTP is **48/55**, five POST 503s and two timeouts:
known Wrangler-dev limitation, not green acceptance. Current code still
needs exact immutable-container publication proof; prior CI and live
deployment are separate. Full Guild/Nexus/Homeworld, wider combinations,
AI calibration and public/live release remain open.
## 1 October 2026 — source-aware CHOAM Nexus Cunning Kull
Five implementation lanes connected runtime, canonical fuel/receipt policy,
+15 -1
View File
@@ -61,7 +61,7 @@ The private holder offer contains CHOAM's name; it includes the hand count only
## Effect integration boundaries
Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md), and [Richese's separate shipment and auction panels](NEXUS_RICHESE_RULES.md). These audits distinguish implemented work from remaining source/timing questions; the current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md). The bounded Richese Betrayal wave retains its own **Planned** verification rather than inheriting earlier auction or shipment evidence.
Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md), and [Richese's separate shipment and auction panels](NEXUS_RICHESE_RULES.md). These audits distinguish implemented work from remaining source/timing questions; current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md). Bounded Richese auction and Guild shipment Betrayal checkpoints have their own direct local verification below; neither inherits certification of its complete faction, Nexus family or live deployment from earlier evidence.
**Atreides is the first complete effect family to implement.** It has independent rulebook and physical-face evidence, and the engine already has private plan-element inspection. The Nexus effect needs its own cause and receipt rather than temporarily changing the inspecting faction. The second element must differ from the already inspected element. Preserve the original disclosed value if an allowed later battle edit occurs; follow the existing plan commitment rules instead of refreshing an old observation. The card specifies an element, not the entire hand or battle plan. A weapon/defense request uses actual played-card classification, including the published Reinforcements/Harass exclusions, rather than leaking a card merely because it occupies that slot.
@@ -114,3 +114,17 @@ To avoid identifying a secret card through timing, every unallied non-Richese se
The reaction projects only public target/buyer/source/price/event plus own pass/use eligibility. Existing public cache inspection does not entitle anyone to the concealed Black Market face or to the Richese-family identity of a normal hidden lot; claims and printed catalog faces are not custody disclosures. Normal-deck self-purchases, special-Karama veto, unsupported counter/any-time overlays and broader modules remain outside this connected boundary. The private receipt owns exact source, invoice and continuation; independent pending/completed cursor evidence must reject missing/orphaned frames before read or SQL rather than auto-settling.
The profile is explicit, fresh Basic/Advanced CHOAM/Richese plus classic seats, Nexus and the physical CHOAM deck only; it does not upgrade earlier games or open ordinary expansion starts. The dedicated internal `nexus-richese-betrayal` checklist records bounded engine, authenticated SQLite, bot, real controls and refreshed phone-browser evidence, with broader verification **Partial**. Shipment Cunning, ordinary auction, native special-Karama and a complete twelve-face catalog are not substitute verification. Full-family, combined-module and deployed acceptance remain open.
## Guild Betrayal funded-payment protocol
The [printed Guild face and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal) take one **full original positive shipment payment**, including the holder's own payment, and expressly override occupied Junction income. Use requires the actual singleton Guild Nexus held by an unallied non-Guild seat while native Guild is seated. The physical Nexus is the only activation cost: no Treachery slot/card or extra spice is spent.
Redirect the already quoted fee once, not reduced Guild income or an additional bank award. Preserve the original payer, own remainder and authorized donor escrow; `pledgeAid` has already debited the donor, so settlement consumes escrow rather than debiting it again. Own-payment use must be fully funded before the later refund. Zero/free, declined or stopped shipments create no fee to take and keep Nexus held. Native applicable stop/rate responses precede this bounded payment gate; no new universal priority or immunity follows.
The original `reserve`, `guildTransport`, `homeworld` or `junction` continuation preserves physical force/source/destination custody, tariff, shipment-used state and applicable native response/arrival suffixes once. Full occupied-Junction replacement suppresses the ordinary Guild/bank/occupier receipt for that invoice; it does not resolve ordinary contribution rounding or occupation entitlement/expiry. No extra shipment, movement, route or sponsor is granted.
All publicly possible unallied non-Guild held-Nexus seats acknowledge the same event and shipper, even with irrelevant hidden faces; only canonical private eligibility exposes Use or an own blocked reason. Pass keeps the card; all required passes settle the original payment unchanged once. Projection is exactly `{event, shipper, canPass, hasPassed, canUse, blocked}` and Pass/Use actions carry event only. Unlike the public auction fields in the Richese protocol, this gate adds no fee, source/family/world, destination, forces, donor, resources or saved-receipt disclosure. Neutral acknowledgement adds no secret-price/source log or new inspection entitlement. This is an application privacy protocol, not publisher-prescribed timing.
The fresh `guild-betrayal` profile is classic Basic/Advanced with native Guild, physical base decks and Nexus, optionally with genuinely seeded Homeworlds. Wider overlays and in-play retrofit are excluded. Typed producer/invoice ownership and independent pending/completed cursor evidence preserve authenticated restart and reject stale, foreign, malformed or orphaned continuations without a repeat payment, donor debit, force move or card spend. The dedicated internal `nexus-guild-betrayal` guide now records bounded local verification: 147 selected cases, 6,114 offline checks, build, actual backed-up CLI/phone actions and exact saved custody. Local HTTP 48/55 retains five 503s and two timeouts. Broad faction/module/full-game, calibrated AI, public-start and live-release gates remain closed; container publication and live deployment require separate current-revision evidence.
This profile admits original native shipment producers, not Guild Cunning's second shipment or Richese Secret Ally's discounted shipment: both offers are blocked before Nexus spending or a native parent is committed, while those effects outside this profile remain unchanged. Guild Secret Ally is unavailable under the existing native-Guild-seated mode test; no new printed-effect restriction is inferred.
+59 -2
View File
@@ -1,6 +1,6 @@
# Spacing Guild Nexus: shipment source contract
**Later runtime checkpoints:** [Guild Cunning](NEXUS_GUILD_CUNNING_RUNTIME.md) implements the extra-shipment continuation. [Guild Secret Ally](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) implements supported paid routes using the [focused source resolution](NEXUS_GUILD_SECRET_ALLY_RULES.md). The original audit below remains historical source evidence; its Homeworld-return question has now been sent to the user.
**Later runtime checkpoints:** [Guild Cunning](NEXUS_GUILD_CUNNING_RUNTIME.md) implements the extra-shipment continuation. [Guild Secret Ally](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) implements supported paid routes using the [focused source resolution](NEXUS_GUILD_SECRET_ALLY_RULES.md). The [bounded Betrayal checkpoint](#nexus-guild-betrayal) below records direct local full-payment verification, separately from wider and deployed acceptance. The original audit remains historical source evidence; revisit its Secret Ally Homeworld-return question against the newly authorized Advanced reference before reopening research.
Source audit, 10 September 2026. This documents the three printed panels and their integration boundaries. It does not implement them or certify complete Guild, Homeworld, Nexus or expansion compliance. [Common Nexus lifecycle](NEXUS_CARD_RULES.md) and existing release gates continue to apply. No user question was sent during this audit.
@@ -50,7 +50,7 @@ Bind one actual shipment payment and redirect that payment in full to the Nexus
The face expressly supersedes occupied Junction collection for that payment. Preserve the original charged amount before any native low-Junction division; do not hand the card holder only the Guild's reduced half while paying the remainder to an occupier. This follows the component's full-payment override, not the [unoccupied low-Junction runtime quote](HOMEWORLD_PAYMENT_INCOME_RULES.md). The existing contributor-rounding and occupation-lifecycle questions are not resolved generally by this one card.
An implementation needs an original payment identity, exact charged contributions and a committed replacement recipient before ordinary Guild/bank/occupier settlement. Preserve hidden held Nexus identities until an actual play. The already pending private-reaction timing policy applies; this audit does not invent another pause, automatic response reveal, or duplicate user question. A zero-cost Guild Ambassador shipment cannot manufacture a Betrayal award.
An implementation needs an original payment identity, exact charged contributions and a committed replacement recipient before ordinary Guild/bank/occupier settlement. Preserve hidden held Nexus identities until an actual play. The [bounded protocol below](#nexus-guild-betrayal) is an application privacy choice, not a resolution of the publisher timing gap. A zero-cost Guild Ambassador shipment cannot manufacture a Betrayal award.
## Existing integration points and required state
@@ -63,3 +63,60 @@ The pure [Cunning receipt module](../game/nexus-guild-cunning.ts) records the or
`nexusGuildCunningMoves(receipt, currentHajrUsed, movesAfterSecond)` returns only the remaining Hajr extra allowance. Ending ordinary movement forgoes at least one ordinary slot, so original movement counts zero and one both permit at most one Hajr move; an original count of two permits none. A newly played Hajr supplies that same one extra slot, while losing a previously recorded Hajr or claiming more moves than that allowance rejects. This bounded helper excludes the separate physical Ornithopter-card composition; it does not reset or mutate the original movement counter. Its [pure tests](../tests/nexus-guild-cunning.test.ts) cover declined and used first actions, Hajr before and after, spent allowances, native ownership, exact signatures, JSON restoration and private-field getter traps.
Integrated verification should exercise first shipment followed by moved/declined ordinary movement, all three second-shipment forms, Hajr before/after and already consumed, cancellation of only the selected native advantage, paid BG/entry continuations, native Homeworld routes, private offers, stale requests and actual SQLite restoration. Passing the pure receipt tests does not establish those engine or persistence behaviors.
## Nexus Guild Betrayal
1 October 2026 bounded prototype contract. The authority for the effect is the [original printed Guild face](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), transcribed in [nexus-card-reference.ts](../game/nexus-card-reference.ts). The internal player guide is `/rules?topic=nexus-guild-betrayal#nexus-guild-betrayal`; source links stay in developer documentation, not the player-facing guide.
### Entry, physical cost and funding
The explicit fresh `guild-betrayal` development profile admits two to six ready classic-faction seats in Basic or Advanced, including native Guild and at least one other faction. It uses physical base decks and Nexus; Homeworlds are optional only when genuinely enabled and seeded during setup. It does not retrofit games, silently add Homeworlds, admit other expansion factions/decks or combine Leader Skills, Discoveries, Tech Tokens, Stronghold Cards, Semuta, Kull or Richese previews.
Only original native shipment producers are admitted here. Native Guild Nexus Cunning's second shipment and Richese Nexus Secret Ally's discounted shipment are blocked in this profile **before** spending either singleton or creating a native parent. Those effects outside this profile remain unchanged. Guild Secret Ally is already unavailable because native Guild is seated, not because this preview establishes a new prohibition on its printed panel.
Use requires an unallied non-Guild player holding the actual singleton Guild Nexus while another seated player is native Guild. The accepted Use discards that physical Nexus once. It spends no Treachery Card, Treachery hand slot or extra activation spice. Cunning and Secret Ally remain distinct modes; temporary leader control, a borrowed price or an empty Guild board does not change native roster identity.
Bind the **full original positive charged fee**, its payer, authorized contributions and exact source quote before settlement. Redirect that one fee to the holder instead of any ordinary Guild, bank or occupied-Junction receipt. Do not reduce the award to `guildIncome`, add a bank award alongside normal income, or charge a second invoice.
Own payment must already be affordable from personal spice and any actually authorized ally funding. Pay the original cost first, then receive the entire redirected fee; a solely self-funded holder has net zero spice change, not a free declaration financed by future income. `pledgeAid` has already removed the donor's spice into escrow: settlement consumes the original `g.aid` contribution once rather than debiting the donor again. The payer's own remainder and original donor allocation do not change because the recipient changes. Current voluntary-spend promises and committed-resource restrictions still apply.
### Original native shipment adapters
| Saved producer | Original adapter and facts to retain |
| --- | --- |
| `reserve` | `commitShipment`: ordinary physical reserves onto Arrakis, including native elite/advisor classification and valid explicitly selected native Homeworld reserve sources; original destination, tariff and own/authorized-ally contributions. |
| `guildTransport` | `commitGuildTransport`: the exact native Guild cross-planet or return group, typed force identities, tariff and contributions. |
| `homeworld` | `commitHomeworldShipment`: an already legal native Homeworld route, its exact source custody, destination, physical groups and paid quote. This grants no foreign route or Secret Ally Homeworld-return permission. |
| `junction` | `performJunctionTransport`: the original accepted Junction sponsor/offer, route, physical sources, quote and contributions. Replacement does not create another sponsor or transport opportunity. |
Source feasibility and full funding are rechecked before the gate. Existing native special-stop and applicable native rate responses occur first; a stopped, declined, invalid or zero-cost declaration supplies no positive payment and cannot spend Nexus or create an award. Ordinary free Fremen reinforcement and free Guild Ambassador transport stay free. This sequencing is the bounded application's settlement boundary, not a new general Nexus/Karama priority or immunity ruling.
For every admitted producer, the original native payment and physical shipment complete once. Preserve reserve withdrawal, elite/advisor classification, source and destination custody, storm/entry checks, shipment-used flag and applicable arrival/response suffixes. Ordinary movement afterward remains the shipper's own opportunity; Betrayal grants no extra shipment, no extra move, no altered rate and no rerouted force.
### Occupied Junction: full-payment override only
The printed face expressly overrides income for occupying Junction. On accepted Use, deliver **all** of the original charged payment to the holder and suppress the ordinary Guild/bank/occupier allocation for that invoice. A four-spice fee produces four spice for the holder, not two while an occupier receives the other two. Preserve the full fee before any low-Junction division; neither an ordinary eligible Guild subtotal nor its rounded half is the card's award.
This source-clear exception does not decide first-versus-current occupier entitlement, departure/replacement/contest expiry, retained occupation penalties or ordinary contributor rounding. Existing [Homeworld payment](HOMEWORLD_PAYMENT_INCOME_RULES.md) and [occupation](HOMEWORLD_OCCUPATION_RULES.md) restrictions remain for non-overridden settlement. Passing does not select a disputed owner or arithmetic interpretation; any source that cannot settle unchanged must retain its native pre-offer guard rather than use a hypothetical Betrayal play to bypass it.
### Neutral acknowledgement, private Use and saved continuation
Every publicly possible responder sees the same event and shipper: unallied non-Guild seats with public held-Nexus presence, including the shipper when eligible. A seat with an irrelevant secret face still acknowledges with Pass; only the privately eligible Guild-card holder sees Use and its own unavailable reason. Public membership never depends on the hidden face. No publicly possible responders means automatic ordinary settlement. This is an **application privacy protocol**, not printed timing authority, a new user ruling or blanket Karama immunity.
The fixed projection is `{event, shipper, canPass, hasPassed, canUse, blocked}`. It reveals no new price, force count, route, source family/world, destination, donor, balances, recipient, raw signature or private receipt. Existing own-source and public inspection entitlements remain unchanged. The neutral wait/acknowledgement log does not disclose secret fee or source; only actual Use and the source's normal committed-result logging may report information already appropriate after shipment. A printed reference inspector is not evidence of anybody's held face.
`guildBetrayalPass` and `guildBetrayalUse` carry only the offered event. The server binds the payment and recipient; a client cannot choose them. Pass keeps Nexus and spends nothing; a recorded pass cannot be undone for that event. Every required pass resumes the original settlement once with its original receiver. Accepted Use closes the gate, spends one physical Nexus and resumes the same native delivery with the fee receiver replaced. Underlying shipping, movement, pledging, cards and other decisions remain suspended until resolution.
Save the typed producer continuation, original private invoice, pass set and independent pending/completed cursor/receipt. Restore must authenticate the acting seat and retain the same source, event and once-only outcome; never replay a raw shipment Action or infer permission from a missing frame. Stale/foreign events, malformed or orphaned ownership, competing Use/final-Pass and historical card recycling require unchanged rejection or one authenticated committed winner, not a repeat payment, donor debit, Nexus spend or force move.
### Verification and release boundary
**Bounded verification: locally verified; broader acceptance Partial.** `npm run check` passes types, lint and **6,114 offline tests**; the production build passes. The selected Guild/profile/turn/control and related Kull recovery set passes **147/147** across nine files. Engine and authenticated SQLite cases exercise all four native adapters, source-consistent revoked-grant rejection, own pre-funded refund, other/escrow payment, all-pass, occupied low Junction, singleton/force custody, neutral projections, JSON/restart, races and stale/orphan rejection. Source feasibility uses actual current native quotes rather than fixed storm-sensitive routes; durable rejection checks real SQLite changes, not attempted SQL text.
Independent review and actual before/after reproduction repaired an accepted Advanced Richese Secret Ally parent that could not resume: the bounded profile now blocks that unsupported offer before card expenditure. Native transport permission is revalidated independently at declaration, restore and commit; a purchased rate is not a transport grant. Final scoped rules/persistence re-reviews report no actionable finding. Unrelated native Cunning/Secret Ally behavior remains outside this profile's guard.
Eight actual ready QA lobbies entered via exact-version `guild-betrayal` CLI with private backups and their original authenticated seats. Preparation reused each admitted setup/code/roster, completed genuine native setup/phases and source actions, and arranged conserved pre-action resources; no saved room was replaced by a separately constructed game. At 390 pixels, actual controls and own printed-card inspector prove Basic own refund, occupied low-Junction full fee, native Guild return and all-pass; Advanced Homeworld bank fee, authorized escrow, native Junction and free Fremen; irrelevant-face Pass, private Use, source locking, contextual rules navigation and refresh. Read-only saved-state verification matches the original native force suffix, exact whole-fee money ledger, authorized escrow, physical Treachery/Nexus inventory and closed receipt across all eight rooms.
Local built-worker HTTP is **48/55**: five POST 503 responses and two timeouts retain the known Wrangler-dev limitation, not green HTTP acceptance. The exact immutable code checkpoint still needs its mandatory isolated container verifier and separate live deployment evidence; earlier successful workflows do not verify this new source.
The new preview does not inherit verification from Guild Cunning/Secret Ally, ordinary shipment tests or the twelve-face catalog. Complete Guild/Nexus/Homeworld families, wider factions/modules, strategic AI and difficulty calibration, full games, public starts and live deployment remain gated.
+38 -1
View File
@@ -1,6 +1,42 @@
# Rule decision index
Updated 30 September 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
Updated 1 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
## Authorized source amendment — 1 October 2026
The user added `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf` at the
checkout root and authorized it as a source of truth going forward.
Use this supplied revision for Advanced rules and clarifications, including
its explicitly identified unofficial rulings; the former publisher-only
restriction does not exclude this document. Preserve specific recorded user
decisions unless changed, and use printed components and GF9 material for
details the supplied revision does not cover. Record material differences
and their implementation cutovers explicitly rather than silently blending
editions or describing unofficial rulings as publisher rulings.
Source identity: 44 physical PDF pages; metadata title
`UNOFFICIAL Revised Dune Rulebook v.2.3.pages`; the visible editorial heading
on physical page 3 still says **Version 2.2**. Cite the supplied filename and
physical PDF page, not an assumed corrected heading.
SHA-256: `7077aa87a973221d86d4bd2da945b40c4dec3d371caa6a68cb209758ddf1e516`.
The user-owned local PDF is preserved, not republished as an application asset.
The current Guild Betrayal wave was compared with shipment/payment rules
(physical page 10), separate Guild Karama targets (19), Homeworld transport
and occupation (22), Nexus lifecycle/modes (23), and native Guild tariffs
and special stop (37). Those passages do not replace the specific printed
Guild Nexus full-payment/own-payment/Junction override or supply a new
response order; its bounded prepayment contract remains source-consistent.
This is a source comparison, not runtime verification.
Future Advanced reconciliation must distinguish the revision's Storm Deck
simplification even without native Fremen (6), its explicitly revised
alliance-constraint timing and advisor coexistence (15, 40), secrecy of
spice/discards and hand counts outside Bidding (16), and Duke Vidal's
Tleilaxu-ghola exception (30). These are source-led reconciliation targets,
not claims that existing behavior has already changed. Optional variants
remain optional, Basic is not silently converted to Advanced, and no saved
game is reset or redealt by adopting a source.
## Recorded contracts and implementation boundaries
@@ -36,6 +72,7 @@ Updated 30 September 2026. This is the authoritative navigation index for existi
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
| Richese Nexus Betrayal auctions | Pause at a valid original prepayment quote. Public self-cache veto discards the exact card without debit/acquisition; another buyer's positive cache/Black Market invoice keeps price/contributions/delivery and earned Harkonnen bonus while overriding the recipient to bank. All publicly possible unallied other-faction held-Nexus seats acknowledge uniformly; private identity controls only own Use. This neutral protocol is an application choice, not publisher timing. Normal hidden-family self-purchases, special-Karama veto and unsupported overlays remain excluded; no native Karama exception certifies them. Bounded engine/SQL/bot/phone scenarios are verified locally; broader verification remains Partial and public starts/full Nexus gates stay closed. | [Printed face, profile, API and saved continuation](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026), [common privacy protocol](NEXUS_CARD_RULES.md#richese-betrayal-prepayment-protocol), [auction source boundaries](RICHESE_AUCTION_RULES.md) |
| Guild Nexus Betrayal shipment payment | Replace one original funded positive fee in full, including own pre-funded payment and the printed full occupied-Junction override; preserve original payer/contributions, consume already-debited donor escrow once and deliver the original physical shipment once. Native applicable stop/rate responses precede the bounded gate; zero/stopped shipments supply no award or Nexus cost. Public possible held-Nexus seats acknowledge uniformly; private Use changes no public price/source disclosure. Fresh classic/native-Guild/Nexus with optional genuinely seeded Homeworlds only; unsupported native Cunning/Richese shipment overlays block before card/parent commitment, and restored transport independently requires the native Guild grant. Bounded engine/SQL/bot/CLI/phone custody and refresh are locally verified; no new priority/immunity, ordinary occupation/rounding ruling or extra shipment. Broad/public/live gates remain closed. | [Printed source and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal), [common protocol](NEXUS_CARD_RULES.md#guild-betrayal-funded-payment-protocol), [internal checklist](../game/reference.ts) |
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
| Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) |
+15
View File
@@ -0,0 +1,15 @@
import type { Action, GameView } from './engine';
/** Public acknowledgement never depends on a hidden card or another seat's
* resources. Only the server's own-seat offer authorizes taking the payment. */
export function botNexusGuildBetrayalActions(game: GameView): Action[] {
const reaction = game.guildBetrayalReaction;
if (game.status !== 'playing' || !reaction?.event ||
!reaction.canPass || reaction.hasPassed) return [];
const own = game.players.find((player) => player.id === game.me);
if (!(own?.bot ?? own?.autopilot)) return [];
return [{
type: reaction.canUse && !reaction.blocked ? 'guildBetrayalUse' : 'guildBetrayalPass',
event: reaction.event,
}];
}
+2
View File
@@ -104,6 +104,7 @@ import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival';
import { botSemutaActions } from './bot-semuta';
import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull';
import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal';
import { botNexusGuildBetrayalActions } from './bot-nexus-guild-betrayal';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
@@ -3827,6 +3828,7 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
if (g.guildBetrayalReaction) return botNexusGuildBetrayalActions(g);
if (g.richeseBetrayalReaction) return botNexusRicheseBetrayalActions(g);
if (g.kullReaction) return botChoamKullActions(g);
if (g.kullCounterEvent)
+372 -18
View File
@@ -46,6 +46,7 @@ import { truthKnowledgeOf } from './truthtrance-knowledge';
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
import { shipmentAvailable } from './shipment-opportunity';
import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
@@ -1070,6 +1071,20 @@ type PendingGuildTransport = {
advisors: boolean;
};
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves'>;
type JunctionTransportDeclaration = {
player: string;
turn: number;
event: string;
offer: string;
destination: string;
sources: HomeworldShipmentIntent['sources'];
allyPayment: number;
};
type GuildBetrayalContinuation =
| {source: 'reserve'; shipment: PendingShipment}
| {source: 'guildTransport'; frame: GuildTransportOrder; fromReserves: boolean}
| {source: 'homeworld'; shipment: PendingHomeworldShipment}
| {source: 'junction'; transport: JunctionTransportDeclaration};
type RicheseAllyOffer = {
event: string;
owner: string;
@@ -1081,6 +1096,18 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
guildBetrayalPreview?: boolean;
guildBetrayal?: GuildBetrayalCursor;
nexusGuildBetrayalHistory?: GuildBetrayalReceipt[];
guildBetrayalSourceReceipts?: {event: string; turn: number; shipper: string; sourceSignature: string; receipt: string}[];
pendingGuildBetrayal?: {
invoice: GuildBetrayalInvoice;
continuation: GuildBetrayalContinuation;
required: string[];
passed: string[];
signature: string;
} | null;
/** Explicit bounded Nexus/Richese auction profile; never inferred for old saves. */
richeseBetrayalPreview?: boolean;
richeseBetrayal?: RicheseBetrayalCursor;
@@ -8079,6 +8106,253 @@ export function initializeNexusGameForAudit(state: Game): Game {
'Enable Nexus cards in a fresh audit lobby first.');
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
}
/** Opt-in fresh classic setup through the real physical Nexus/Homeworld setup. */
export function initializeGuildBetrayalGameForAudit(state: Game): Game {
requireRule(!state.guildBetrayalPreview && !state.guildBetrayal && !state.pendingGuildBetrayal &&
!state.nexusGuildBetrayalHistory && !state.guildBetrayalSourceReceipts &&
guildBetrayalModeSupported(state),
'Guild Betrayal needs a fresh classic native-Guild lobby with only Nexus and optional Homeworlds.');
const g = initializeNexusGameForAudit(state);
g.guildBetrayalPreview = true;
g.guildBetrayal = {sequence: 0, event: null, turn: null, sourceSignature: null, status: 'completed'};
g.nexusGuildBetrayalHistory = [];
g.guildBetrayalSourceReceipts = [];
g.pendingGuildBetrayal = null;
return g;
}
function guildBetrayalModeSupported(g: Game): boolean {
return !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 &&
g.players.length <= 6 && !!byFaction(g, 'guild') &&
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) &&
!g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.techTokens &&
!g.strongholdCards && !g.ecazTreachery && !g.kullPreview && !g.nexusKullPreview &&
!g.richeseBetrayalPreview && !g.mobileStronghold;
}
function guildBetrayalParentSignature(g: Game, continuation: GuildBetrayalContinuation): string {
return JSON.stringify({
continuation, status: g.status, turn: g.turn, phase: g.phase, active: g.active,
order: g.order, storm: g.storm, movementRemaining: g.movementRemaining,
players: g.players.map(p => ({id: p.id, faction: p.faction, ally: p.ally,
spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved,
hand: p.hand, specialKaramaUsed: p.specialKaramaUsed})),
aid: g.aid, homeworlds: g.homeworlds, nexus: g.nexusCards,
promises: g.shipmentPromises, karama: g.karamaShipping, rate: g.guildRateBlocked,
offer: g.junctionOffer, decision: g.decision, response: g.response,
phaseOpening: g.phaseOpening, truthtrance: g.truthtrance,
pendingShipment: g.pendingShipment, pendingHomeworldShipment: g.pendingHomeworldShipment,
pendingGuildTransport: g.pendingGuildTransport,
});
}
function guildBetrayalCleanParent(g: Game): void {
requireRule(guildBetrayalModeSupported(g) && g.status === 'playing' && g.phase === 5 &&
!g.decision && !g.response && !g.phaseOpening && !g.truthtrance && !g.battle &&
!g.pendingNullentropy && !g.pendingTreacheryDiscard && !g.pendingExchange &&
!g.pendingRicheseGift && !g.pendingKarama && !g.pendingKull && !pendingNexusTraitors(g) &&
!g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingGuildTransport &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingRicheseBetrayal &&
!g.bureaucratPayments?.pending,
'Guild Betrayal requires the original clean native shipment before funded payment.');
}
function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation): GuildBetrayalAuthority {
requireRule(continuation && typeof continuation === 'object' && !Array.isArray(continuation),
'The saved Guild shipment producer is malformed.');
const producerKeys: Record<GuildBetrayalContinuation['source'], string> = {
reserve: 'shipment,source', guildTransport: 'frame,fromReserves,source',
homeworld: 'shipment,source', junction: 'source,transport',
};
requireRule(Object.hasOwn(producerKeys, continuation.source) &&
Object.keys(continuation).sort().join(',') === producerKeys[continuation.source],
'The saved Guild shipment producer contains foreign action fields.');
let shipper: string, price: number, allyPayment: number, originalReceiver: string | null;
const guild = byFaction(g, 'guild')!;
switch (continuation.source) {
case 'reserve': {
const shipment = continuation.shipment;
requireRule(shipment && shipment.turn === g.turn &&
Object.keys(shipment).every(key => ['turn', 'player', 'territory', 'sector', 'amount', 'elite',
'cost', 'allyPayment', 'advisors', 'homeworldSources', 'guildRateEvent',
'smuggler', 'smugglerCompanion', 'guildSecretEvent', 'guildNexusEvent', 'nexusEvent',
'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof'].includes(key)),
'The saved reserve shipment contains foreign source fields.');
requireRule(!shipment.source && !shipment.noField && !shipment.alliedNoField &&
!shipment.smuggler && !shipment.smugglerCompanion && !shipment.richesePair &&
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent,
'This Guild Betrayal profile supports native physical reserve shipments only.');
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
checkShipmentIncomeRounding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
checkShipmentPromises(g, getPlayer(g, shipment.player), shipment);
shipper = shipment.player; price = shipment.cost; allyPayment = shipment.allyPayment;
const p = getPlayer(g, shipper);
originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally,
cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper}) > 0 ? guild.id : null;
break;
}
case 'guildTransport': {
requireRule(typeof continuation.fromReserves === 'boolean', 'The Guild transport source flag is malformed.');
const frame = continuation.frame;
requireRule(frame && Object.keys(frame).sort().join(',') ===
'advisors,allyPayment,amount,cost,elite,eliteGroup,group,origin,player,sector,to,turn',
'The saved Guild transport frame is malformed.');
const p = getPlayer(g, frame.player);
validateGuildTransportForCommit(g, p, frame, continuation.fromReserves);
shipper = p.id; price = frame.cost; allyPayment = frame.allyPayment;
originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally,
cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper}) > 0 ? guild.id : null;
break;
}
case 'homeworld': {
const shipment = continuation.shipment;
requireRule(shipment && Object.keys(shipment).every(key =>
['player', 'destination', 'sources', 'route', 'event', 'turn', 'amount', 'elite', 'cost',
'allyPayment', 'pools', 'guildSecretEvent', 'guildNexusEvent'].includes(key)),
'The saved Homeworld shipment contains foreign source fields.');
requireRule(!shipment.guildSecretEvent && !shipment.guildNexusEvent,
'This Guild Betrayal profile supports native Homeworld shipment routes only.');
const quote = validateHomeworldShipment(g, shipment);
shipper = shipment.player; price = quote.cost; allyPayment = shipment.allyPayment;
originalReceiver = getPlayer(g, shipper).ally !== guild.id && allyPayment > 0 ? guild.id : null;
const income = getPlayer(g, shipper).ally !== guild.id ? allyPayment : 0;
if (income > 0) validateGuildPaymentRounding(g, guild.id, income, [income]);
break;
}
case 'junction': {
const declaration = continuation.transport;
requireRule(declaration && Object.keys(declaration).sort().join(',') ===
'allyPayment,destination,event,offer,player,sources,turn',
'The saved Junction declaration is malformed.');
const quote = quoteJunctionDeclaration(g, getPlayer(g, declaration.player), declaration);
shipper = declaration.player; price = quote.quote.cost; allyPayment = declaration.allyPayment;
originalReceiver = quote.option.owner;
break;
}
default: throw new RuleError('The saved Guild Betrayal producer is unsupported.');
}
const p = getPlayer(g, shipper);
contribution(g, p, price, allyPayment);
return {source: continuation.source, sourceSignature: guildBetrayalParentSignature(g, continuation),
shipper, price, allyPayment, ownSpice: p.spice, allyEscrow: aidFor(g, p)?.amount ?? 0,
donor: allyPayment ? p.ally! : null, originalReceiver};
}
function guildBetrayalFrameSignature(frame: NonNullable<Game['pendingGuildBetrayal']>): string {
return JSON.stringify([frame.invoice.signature, frame.invoice.sourceSignature, frame.required, frame.passed]);
}
function guildBetrayalIntegrity(g: Game): void {
const cursor = g.guildBetrayal, frame = g.pendingGuildBetrayal;
if (!g.guildBetrayalPreview) {
requireRule(!cursor && !frame && !g.nexusGuildBetrayalHistory && !g.guildBetrayalSourceReceipts,
'Guild Betrayal lost its explicit native profile.');
return;
}
requireRule(guildBetrayalModeSupported(g) && cursor &&
Array.isArray(g.nexusGuildBetrayalHistory) && Array.isArray(g.guildBetrayalSourceReceipts),
'The Guild Betrayal profile lost its cursor or independent native receipts.');
nexusCardsIntegrity(g);
nexusRule(() => validateGuildBetrayalHistory({turn: g.turn, players: g.players},
g.nexusGuildBetrayalHistory!, cursor!, frame?.invoice ?? null));
requireRule(g.guildBetrayalSourceReceipts!.length === g.nexusGuildBetrayalHistory!.length,
'A completed Guild invoice lost its native shipment receipt.');
for (let i = 0; i < g.nexusGuildBetrayalHistory!.length; i++) {
const receipt = g.nexusGuildBetrayalHistory![i], source = g.guildBetrayalSourceReceipts![i];
requireRule(source && source.event === receipt.invoice.event && source.turn === receipt.invoice.turn &&
source.shipper === receipt.invoice.shipper && source.sourceSignature === receipt.invoice.sourceSignature &&
source.receipt === receipt.sourceReceipt,
'A completed Guild invoice differs from its independently committed shipment.');
if (source.turn === g.turn && g.phase === 5)
requireRule(getPlayer(g, source.shipper).shipped,
'A completed Guild invoice reopened its already used shipment.');
}
if (!frame) return;
requireRule(Object.keys(frame).sort().join(',') === 'continuation,invoice,passed,required,signature' &&
frame.continuation && Array.isArray(frame.required) && Array.isArray(frame.passed),
'The saved Guild Betrayal continuation is malformed.');
guildBetrayalCleanParent(g);
const authority = guildBetrayalAuthority(g, frame.continuation);
nexusRule(() => validateGuildBetrayalInvoice({turn: g.turn, players: g.players},
frame.invoice, authority, cursor!.sequence));
requireRule(frame.signature === guildBetrayalFrameSignature(frame) &&
JSON.stringify(frame.required) === JSON.stringify(guildBetrayalResponders(g.nexusCards!.cards!, g.players)) &&
new Set(frame.passed).size === frame.passed.length &&
frame.passed.every(id => frame.required.includes(id)) && frame.passed.length < frame.required.length,
'The saved Guild invoice lost its original custody, funding, acknowledgements or parent.');
}
function beginGuildBetrayal(g: Game, continuation: GuildBetrayalContinuation): boolean {
if (!g.guildBetrayalPreview) return false;
// Release only the already accepted native declaration. No fee or custody
// changes have happened, and no suspended decision can authorize a new gate.
if (continuation.source === 'homeworld') g.pendingHomeworldShipment = null;
guildBetrayalCleanParent(g);
const authority = guildBetrayalAuthority(g, continuation);
if (authority.price === 0) return false;
const required = guildBetrayalResponders(g.nexusCards!.cards!, g.players);
if (!required.length) return false;
const sequence = g.guildBetrayal!.sequence + 1;
const invoice = nexusRule(() => createGuildBetrayalInvoice({turn: g.turn, players: g.players}, authority, sequence));
g.pendingGuildBetrayal = {invoice, continuation: structuredClone(continuation), required,
passed: [], signature: ''};
g.pendingGuildBetrayal.signature = guildBetrayalFrameSignature(g.pendingGuildBetrayal);
g.guildBetrayal = {sequence, event: invoice.event, turn: g.turn,
sourceSignature: invoice.sourceSignature, status: 'pending'};
return true;
}
function guildBetrayalUseBlock(g: Game, id: string): string | null {
const frame = g.pendingGuildBetrayal;
if (!frame || !frame.required.includes(id) || frame.passed.includes(id))
return 'This seat has no pending shipment acknowledgement.';
if (!nexusRule(() => guildBetrayalEligible(g.nexusCards!.cards!, g.players, id)))
return 'Your held Nexus card cannot take this shipment payment.';
const promises = liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn);
if (promises.length && id !== frame.invoice.shipper) {
const projected: Game = {...g, nexusCards: {...g.nexusCards!,
cards: nexusRule(() => discardNexusCard(g.nexusCards!.cards!, id, g.players))}};
if (!findShipmentCompletion(projected, getPlayer(projected, id), promises))
return 'Spending this Nexus card would prevent your committed shipment answer.';
}
return null;
}
function decideGuildBetrayal(g: Game, p: Player, action: Action): void {
const frame = g.pendingGuildBetrayal!;
requireRule(['guildBetrayalPass', 'guildBetrayalUse'].includes(action?.type) &&
action.event === frame.invoice.event &&
Object.keys(action).every(key => key === 'type' || key === 'event') &&
frame.required.includes(p.id) && !frame.passed.includes(p.id),
'Choose your exact current Guild Betrayal acknowledgement.');
const use = action.type === 'guildBetrayalUse';
if (use) {
const blocked = guildBetrayalUseBlock(g, p.id);
requireRule(!blocked, blocked ?? 'This Nexus card cannot be used.');
} else {
frame.passed.push(p.id);
frame.signature = guildBetrayalFrameSignature(frame);
if (frame.passed.length < frame.required.length) return;
}
const discardIndex = use ? g.nexusCards!.cards!.discard.length : null;
if (use) g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
g.pendingGuildBetrayal = null;
const settlement = {recipient: use ? p.id : null};
const continuation = frame.continuation;
switch (continuation.source) {
case 'reserve': commitShipment(g, continuation.shipment, settlement); break;
case 'guildTransport': commitGuildTransport(g, getPlayer(g, continuation.frame.player),
continuation.frame, continuation.fromReserves, undefined, settlement); break;
case 'homeworld': commitHomeworldShipment(g, continuation.shipment, settlement); break;
case 'junction': commitJunctionTransport(g, getPlayer(g, continuation.transport.player),
continuation.transport, settlement); break;
}
requireRule(getPlayer(g, frame.invoice.shipper).shipped, 'The funded native shipment did not complete.');
const sourceReceipt = nexusGuildBetrayalSourceReceipt(frame.invoice);
const receipt = nexusRule(() => createGuildBetrayalReceipt({turn: g.turn, players: g.players},
frame.invoice, {recipient: settlement.recipient, holderFaction: use ? p.faction : null,
holderUnallied: use ? !p.ally : null, nexusDiscardIndex: discardIndex, sourceReceipt}));
g.nexusGuildBetrayalHistory!.push(receipt);
g.guildBetrayalSourceReceipts!.push({event: frame.invoice.event, turn: g.turn,
shipper: frame.invoice.shipper, sourceSignature: frame.invoice.sourceSignature, receipt: sourceReceipt});
g.guildBetrayal = {sequence: frame.invoice.sequence, event: frame.invoice.event, turn: g.turn,
sourceSignature: frame.invoice.sourceSignature, status: 'completed'};
if (use) log(g, `${p.name} spent Guild Nexus Betrayal to receive the full ${frame.invoice.price}-spice shipment payment. The original funded shipment completed once.`,
{faction: 'guild', name: 'Betrayal'});
}
/** Offline paired-faction Nexus setup; public expansion/module starts remain gated. */
export function initializePairedNexusGameForAudit(state: Game): Game {
const expansion = state.expansions[0];
@@ -22300,22 +22574,23 @@ function junctionTransportWindow(g: Game) {
canOffer: !blocked, offerEvent: junctionOfferEvent(g),
event: JSON.stringify([homeworldShipmentEvent(g, recipient, 'arrakis'), offer])};
}
function performJunctionTransport(g: Game, p: Player, action: Action) {
function quoteJunctionDeclaration(g: Game, p: Player, declaration: JunctionTransportDeclaration, automaticFunding = false) {
requireRule(declaration.player === p.id && declaration.turn === g.turn,
'This Junction shipment lost its original player or turn.');
const action = declaration;
const option = junctionTransportWindow(g);
requireRule(option && option.recipient === p.id && !option.blocked && option.offer,
option?.blocked ?? 'Wait for Guild to offer Junction transport during your unused shipment.');
requireRule(action.event === option.event && action.offer === option.offer.event,
'This Junction offer or physical source selection has changed.');
requireRule(Object.keys(action).every((key) =>
['type', 'event', 'offer', 'destination', 'sources', 'allyPayment'].includes(key)),
'Use the offered tariff and explicit physical forces without concealed tokens.');
const rate = option.offer.rate;
const destination = stringField(action.destination);
const context = {...homeworldContext(g), storm: g.storm, discoveries:g.discoveries,
players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})),
mobileStronghold: g.mobileStronghold?.location ?? null,
board: {[p.id]: {forces: p.forces, eliteForces: p.elites?.forces ?? {}, advisors: p.advisors}}};
const quote = homeworldRule(() => quoteJunctionTransport(context, g.homeworlds!.custody!,
{player: p.id, sponsor: option.owner, rate: option.offer!.rate, destination,
{player: p.id, sponsor: option.owner, rate, destination,
sources: action.sources as HomeworldShipmentIntent['sources']}));
const arrival = quote.destinationKind === 'arrakis' ? splitLocation(destination) : null;
const nativeDeparture = quote.originKind === 'homeworld' && quote.sources.every((source) =>
@@ -22333,10 +22608,24 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
'New advisors cannot become fighters this turn.');
allowedEntry(g, p, arrival.territory, arrival.sector, false, advisors);
}
const allyPayment = contribution(g, p, quote.cost, action.allyPayment);
const allyPayment = contribution(g, p, quote.cost, automaticFunding ? undefined : action.allyPayment);
requireRule(automaticFunding || allyPayment === declaration.allyPayment, 'The original Junction funding changed.');
const ordinaryIncome = !!arrival && nativeDeparture && p.faction !== 'fremen';
const contributions = [ordinaryIncome ? quote.cost - allyPayment : 0,
p.ally === option.owner ? 0 : allyPayment];
validateGuildPaymentRounding(g, option.owner,
contributions.reduce((sum, amount) => sum + amount, 0), contributions);
return {option, rate, quote, arrival, nativeDeparture, promise, sourceLock, advisors, allyPayment, destination};
}
function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransportDeclaration,
settlement?: {recipient: string | null}) {
const {option, rate, quote, arrival, nativeDeparture, promise, sourceLock, advisors, allyPayment, destination} =
quoteJunctionDeclaration(g, p, declaration);
if (!settlement && beginGuildBetrayal(g, {source: 'junction', transport: declaration})) return;
// Junction permission and tariff are Homeworld effects and resist Karama.
// All validation precedes this atomic settlement; there is no special-stop frame.
payWithAlly(g, p, quote.cost, allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost;
g.homeworlds!.custody = quote.state;
p.forces = quote.boardForces;
if (p.elites) p.elites.forces = quote.boardEliteForces;
@@ -22363,11 +22652,11 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
const ordinaryIncome = !!arrival && nativeDeparture && p.faction !== 'fremen';
const income = (ordinaryIncome ? quote.cost - allyPayment : 0) +
(p.ally === option.owner ? 0 : allyPayment);
if (income > 0) g.response = guildPaymentResponse(g, option.owner,
if (!settlement?.recipient && income > 0) g.response = guildPaymentResponse(g, option.owner,
[ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment]);
const sourceName = quote.originKind === 'arrakis' ? territory(quote.origin).name :
quote.sources.map((s) => combatLocationName(g, s.key)).join(' and ');
log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${option.offer.rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`,
log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`,
{faction: 'guild', name: 'Junction transport'});
if (arrival) {
const bg = byFaction(g, 'beneGesserit');
@@ -22378,6 +22667,22 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
openTerritoryEntry(g, p, arrival.territory, arrival.sector, quote.amount, quote.elite, 'shipment');
}
}
function performJunctionTransport(g: Game, p: Player, action: Action) {
requireRule(Object.keys(action).every(key =>
['type', 'event', 'offer', 'destination', 'sources', 'allyPayment'].includes(key)),
'Use the offered tariff and explicit physical forces without concealed tokens.');
// Decode once into a source-specific declaration. Saved continuations are
// never actions and never dispatch through the action engine.
const declaration: JunctionTransportDeclaration = {
player: p.id, turn: g.turn, event: stringField(action.event), offer: stringField(action.offer),
destination: stringField(action.destination), sources: action.sources as HomeworldShipmentIntent['sources'],
allyPayment: typeof action.allyPayment === 'number' ? action.allyPayment : 0,
};
const quote = quoteJunctionDeclaration(g, p, declaration, action.allyPayment === undefined);
declaration.allyPayment = action.allyPayment === undefined ? quote.allyPayment :
contribution(g, p, quote.quote.cost, action.allyPayment);
commitJunctionTransport(g, p, declaration);
}
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
const context = {
@@ -22436,12 +22741,14 @@ function homeworldShipmentIntegrity(g: Game) {
d.amount === shipment.amount && d.event === shipment.event,
'The saved Guild decision differs from its Homeworld declaration.');
}
function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, settlement?: {recipient: string | null}) {
const quote = validateHomeworldShipment(g, shipment);
if (!settlement && beginGuildBetrayal(g, {source: 'homeworld', shipment})) return;
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'homeworld',shipment);
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment);
const p = getPlayer(g, shipment.player);
payWithAlly(g, p, quote.cost, shipment.allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost;
g.homeworlds!.custody = quote.state;
if (shipment.route === 'arrakis') {
p.forces = quote.boardForces!;
@@ -22461,7 +22768,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
// November FAQ contributor routing remains independent of that trigger.
const guild = byFaction(g, 'guild');
const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0;
if (guild && income > 0)
if (!settlement?.recipient && guild && income > 0)
g.response = guildPaymentResponse(g, guild.id, [income]);
finishShipmentPromises(g, p, null);
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
@@ -22868,6 +23175,11 @@ function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) {
'The saved Guild transport lost its original physical rate response.');
return { intent, shipper, fullCost };
}
function nativeGuildTransportPermission(g: Game, p: Player): boolean {
if (p.faction === 'guild') return true;
const guild = byFaction(g, 'guild');
return !!guild && p.ally === guild.id && guild.ally === p.id;
}
function validateGuildTransportForCommit(
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
) {
@@ -22875,6 +23187,8 @@ function validateGuildTransportForCommit(
requireRule(g.status === 'playing' && g.phase === 5 && frame.turn === g.turn &&
frame.player === p.id && g.active === p.id && shipmentAvailable(g, p),
'This Guild transport no longer belongs to an unused shipment opportunity.');
requireRule(nativeGuildTransportPermission(g, p),
'Native Guild transport requires the Guild or its reciprocal ally.');
if (fromReserves) {
requireRule(p.faction === 'fremen' && origin === 'reserves' &&
group.length === 0 && Object.keys(eliteGroup).length === 0,
@@ -22913,19 +23227,25 @@ function validateGuildTransportForCommit(
function commitGuildTransport(
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
guildSecretEvent?: string,
settlement?: {recipient: string | null},
) {
requireRule(!g.guildBetrayalPreview || !guildSecretEvent,
'Guild Betrayal native transport does not combine with a Guild Secret Ally producer.');
if (!guildSecretEvent) validateGuildTransportForCommit(g, p, frame, fromReserves);
if (!settlement && beginGuildBetrayal(g, {source: 'guildTransport', frame, fromReserves})) return;
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
const sourceLock = p.advisors?.[origin]?.lockedTurn;
if (guildSecretEvent) recordGuildSecretShipment(g, p.id, guildSecretEvent, 'cross',
{ ...frame, guildSecretEvent });
bindGuildCunningShipment(g, p, 'guild', frame);
payWithAlly(g, p, cost, allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost;
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment,
bankOnly: g.karamaShipping?.player === p.id,
});
if (guild && guildPayment > 0)
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id,
shipmentIncomeContributions(g, p, cost, allyPayment),
allyPayment ? undefined : p.id);
@@ -23016,13 +23336,14 @@ function offerShipment(g: Game, shipment: PendingShipment) {
);
} else if (!openGuildRate(g, shipment)) commitShipment(g, shipment);
}
function commitShipment(g: Game, shipment: PendingShipment) {
function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipient: string | null}) {
if (shipment.source === 'ambassador') {
commitAmbassadorShipment(g, shipment);
return;
}
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
if (!settlement && beginGuildBetrayal(g, {source: 'reserve', shipment})) return;
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment);
finishNexusRicheseShipment(g,shipment,'shipped');
finishRichesePairShipment(g, shipment, 'shipped');
@@ -23104,6 +23425,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
owner.spice -= quote.ownerPayment;
p.spice -= quote.recipientPayment;
} else payWithAlly(g, p, cost, allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost;
if (pairRevealed) {
p.reserves -= pairRevealed.forces;
if (pairRevealed.forces) place(p, to, s, pairRevealed.forces);
@@ -23146,7 +23468,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
allyPayment,
bankOnly: g.karamaShipping?.player === p.id,
});
if (guild && guildPayment > 0)
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id);
g.karamaShipping = null;
log(
@@ -23283,7 +23605,8 @@ function projectedNexusGuildCunning(g: Game, owner: string) {
hajrAvailable:record.stage !== 'pending' && !g.hajr.includes(owner)}};
if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null;
let blocked: string | null = null;
if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.';
if (g.guildBetrayalPreview) blocked = 'This payment audit profile supports the original native shipment, not a Cunning second-shipment overlay.';
else if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner)
blocked = 'Use Cunning when finishing your own Shipment and Movement turn.';
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard ||
@@ -23470,7 +23793,8 @@ function nexusRicheseOffer(g: Game, owner: string) {
const p = g.players.find(p => p.id === owner);
if (!p || g.nexusCards?.cards?.hands[owner] !== 'richese' || byFaction(g,'richese')) return null;
let blocked: string | null = null;
if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.';
if (g.guildBetrayalPreview) blocked = 'This payment audit profile supports the original native shipment, not a Richese Secret Ally shipment overlay.';
else if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || p.shipped)
blocked = 'Use Secret Ally for your unused reserve shipment before moving.';
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama ||
@@ -24817,6 +25141,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
kullIntegrity(state);
@@ -24902,6 +25227,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;}
if (g.pendingKull) {
settleAutomaticContinuations(g);
@@ -25030,9 +25356,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
moritaniExtortionIntegrity(g);
guildRateIntegrity(g);
arrivalOverlapIntegrity(g);
guildBetrayalIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingKull) return;
finishLeaderSkillCustody(g);
@@ -25096,11 +25424,13 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingKull?.stage === 'offer') return;
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
const response = g.response;
@@ -25123,6 +25453,7 @@ function settleAutomaticContinuations(g: Game) {
finishResponse(g, false);
} else if (!finishAutomaticDecision(g)) return;
finishActionContinuations(g);
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
// Opposing/automatic consequences can release a now-impossible promise;
// they are not the original actor voluntarily spending a promised resource.
@@ -25139,6 +25470,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
kullIntegrity(state);
@@ -25174,6 +25506,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
if (g.pendingGuildBetrayal) return g;
if (g.pendingRicheseBetrayal) return g;
if (g.pendingKull?.stage === 'offer') return g;
ornithopterIntegrity(g);
@@ -25225,6 +25558,12 @@ function applyActionInner(
): Game {
ecazTreacheryIntegrity(state);
const g = structuredClone(state);
if (g.pendingGuildBetrayal) {
const player = getPlayer(g, id);
if (action?.type === 'advanceBots') return g;
decideGuildBetrayal(g, player, action);
return g;
}
if (g.pendingRicheseBetrayal) {
const p = getPlayer(g,id);
if (action?.type === 'advanceBots') return g;
@@ -25293,6 +25632,8 @@ function applyActionInner(
);
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
requireRule(t !== 'guildBetrayalPass' && t !== 'guildBetrayalUse',
'This Guild Betrayal opportunity is no longer pending.');
requireRule(t !== 'richeseBetrayalPass' && t !== 'richeseBetrayalUse',
'This Richese Betrayal opportunity is no longer pending.');
if (g.pendingKull) {
@@ -28283,7 +28624,7 @@ function applyActionInner(
g.phase === 5 &&
g.active === id &&
shipmentAvailable(g,p) &&
(p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally || !!guildSecretEvent),
(nativeGuildTransportPermission(g, p) || !!guildSecretEvent),
'Guild shipment is not available.',
);
const fromReserves = action.from === 'reserves';
@@ -29053,6 +29394,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
kullIntegrity(state);
@@ -29556,6 +29898,18 @@ export function viewGame(state: Game, id: string) {
? g.stormDials
: null,
me: id,
guildBetrayalPreview: !!g.guildBetrayalPreview,
guildBetrayalReaction: g.pendingGuildBetrayal
? (() => {
const frame = g.pendingGuildBetrayal;
const hasPassed = frame.passed.includes(id);
const canPass = frame.required.includes(id) && !hasPassed;
const eligible = nexusRule(() => guildBetrayalEligible(g.nexusCards!.cards!, g.players, id));
const blocked = eligible ? guildBetrayalUseBlock(g, id) : null;
return {event: frame.invoice.event, shipper: frame.invoice.shipper,
canPass, hasPassed, canUse: canPass && eligible && !blocked, blocked};
})()
: null,
richeseBetrayalPreview: !!g.richeseBetrayalPreview,
richeseBetrayalReaction: g.pendingRicheseBetrayal
? (() => {
@@ -29706,7 +30060,7 @@ export function viewGame(state: Game, id: string) {
g.moritaniRetention.keep,
) ?? null)
: null,
decision: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null :
decision: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null :
g.decision?.kind === 'capturedLeader' &&
!g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract<Decision,{kind:'capturedLeader'}>).leader) &&
![g.decision.player, g.decision.controller ?? g.decision.owner].includes(
@@ -29718,7 +30072,7 @@ export function viewGame(state: Game, id: string) {
: g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined
? { ...g.decision, noFieldSkillProof: undefined }
: (g.decision ?? null),
response: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.response
response: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.response
? {
...g.response,
...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}),
+272
View File
@@ -0,0 +1,272 @@
import type { FactionId } from './catalog';
import {
nexusCardMode, validateNexusCards, validateNexusPlayers,
type NexusPlayer, type NexusState,
} from './nexus-cards';
export type GuildBetrayalSource = 'reserve' | 'guildTransport' | 'homeworld' | 'junction';
export type GuildBetrayalContext = { turn: number; players: readonly NexusPlayer[] };
export type GuildBetrayalFunding = {
shipper: string;
price: number;
ownSpice: number;
allyPayment: number;
allyEscrow: number;
donor: string | null;
players: readonly NexusPlayer[];
};
/** Produced by the native adapter, never reconstructed from the saved invoice. */
export type GuildBetrayalAuthority = Omit<GuildBetrayalFunding, 'players'> & {
source: GuildBetrayalSource;
sourceSignature: string;
originalReceiver: string | null;
};
export type GuildBetrayalInvoice = GuildBetrayalAuthority & {
event: string;
sequence: number;
turn: number;
phase: 5;
ownPayment: number;
signature: string;
};
export type GuildBetrayalCursor = {
sequence: number;
event: string | null;
turn: number | null;
sourceSignature: string | null;
status: 'pending' | 'completed';
};
export type GuildBetrayalReceipt = {
invoice: GuildBetrayalInvoice;
recipient: string | null;
holderFaction: FactionId | null;
holderUnallied: boolean | null;
card: 'guild' | null;
mode: 'betrayal' | null;
nexusDiscardIndex: number | null;
sourceReceipt: string;
signature: string;
};
export type GuildBetrayalOutcome = Pick<GuildBetrayalReceipt,
'recipient' | 'holderFaction' | 'holderUnallied' | 'nexusDiscardIndex' | 'sourceReceipt'>;
export class GuildBetrayalError extends Error {
constructor(message: string) {
super(message);
this.name = 'GuildBetrayalError';
}
}
function requireBetrayal(condition: unknown, message: string): asserts condition {
if (!condition) throw new GuildBetrayalError(message);
}
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const positive = (value: unknown): value is number => whole(value) && value > 0;
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value);
const plain = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value));
const exact = (value: unknown, fields: string): boolean =>
plain(value) && Object.keys(value).sort().join(',') === fields;
const sources: readonly GuildBetrayalSource[] = ['reserve', 'guildTransport', 'homeworld', 'junction'];
function validateContext(context: GuildBetrayalContext): void {
requireBetrayal(plain(context) && positive(context.turn), 'Guild Betrayal needs a valid current turn.');
validateNexusPlayers(context.players);
requireBetrayal(context.players.some(player => player.faction === 'guild'),
'Guild Betrayal needs the native Spacing Guild seated.');
}
/** Public waits depend only on held-card presence, not its hidden face. */
export function guildBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] {
validateNexusCards(cards, players);
if (!players.some(player => player.faction === 'guild')) return [];
return players.filter(player => !player.ally && player.faction !== 'guild' &&
cards.hands[player.id] !== null).map(player => player.id);
}
/** A saved faction, mode, or owner object is never its own identity authority. */
export function guildBetrayalEligible(
cards: NexusState, players: readonly NexusPlayer[], holder: string,
): boolean {
validateNexusCards(cards, players);
const seat = players.find(player => player.id === holder);
return !!seat && !seat.ally && seat.faction !== 'guild' && cards.hands[seat.id] === 'guild' &&
nexusCardMode('guild', seat.faction, players.map(player => player.faction)) === 'betrayal';
}
function funding(input: GuildBetrayalFunding, liveAlliance: boolean): void {
requireBetrayal(plain(input) && positive(input.price) && whole(input.ownSpice) &&
whole(input.allyPayment) && input.allyPayment <= input.price && whole(input.allyEscrow) &&
input.allyPayment <= input.allyEscrow && input.ownSpice >= input.price - input.allyPayment,
'The original whole shipment payment must be fully funded before any Guild Betrayal receipt.');
const shipper = input.players.find(player => player.id === input.shipper);
const donor = input.players.find(player => player.id === input.donor);
requireBetrayal(shipper && (input.allyPayment === 0
? input.donor === null
: donor && donor.id !== shipper.id && (!liveAlliance ||
(shipper.ally === donor.id && donor.ally === shipper.id))),
'The original shipment contribution needs its authorized seated escrow donor.');
}
/** Escrow has already left the donor's spice. A refund is never upfront funding. */
export function quoteGuildBetrayalFunding(input: GuildBetrayalFunding): void {
requireBetrayal(plain(input), 'Guild Betrayal needs an original funded invoice.');
validateNexusPlayers(input.players);
funding(input, true);
}
/** Opaque public identifier: no route, source, cost, donor, or card face. */
export function nexusGuildBetrayalEvent(turn: number, sequence: number, shipper: string): string {
requireBetrayal(positive(turn) && positive(sequence) && text(shipper),
'Guild Betrayal needs a positive event sequence and seated shipper identity.');
return JSON.stringify(['nexusGuildBetrayal', turn, sequence, shipper]);
}
/** Consistency signatures bind server facts; they are not cryptographic authentication. */
export function signGuildBetrayalInvoice(invoice: GuildBetrayalInvoice): string {
return JSON.stringify(['guildBetrayalInvoice', invoice.event, invoice.sequence, invoice.turn,
invoice.phase, invoice.shipper, invoice.source, invoice.sourceSignature, invoice.price,
invoice.ownPayment, invoice.allyPayment, invoice.donor, invoice.ownSpice,
invoice.allyEscrow, invoice.originalReceiver]);
}
function validateInvoiceFacts(context: GuildBetrayalContext, invoice: GuildBetrayalInvoice): void {
requireBetrayal(exact(invoice,
'allyEscrow,allyPayment,donor,event,originalReceiver,ownPayment,ownSpice,phase,price,sequence,shipper,signature,source,sourceSignature,turn') &&
positive(invoice.turn) && invoice.turn <= context.turn && invoice.phase === 5 &&
positive(invoice.sequence) && text(invoice.shipper) && sources.includes(invoice.source) &&
text(invoice.sourceSignature) && whole(invoice.ownPayment) &&
invoice.ownPayment === invoice.price - invoice.allyPayment &&
(invoice.originalReceiver === null || context.players.some(player => player.id === invoice.originalReceiver)),
'Guild Betrayal lost its original positive native shipment invoice.');
funding({ ...invoice, players: context.players }, false);
requireBetrayal(invoice.event === nexusGuildBetrayalEvent(invoice.turn, invoice.sequence, invoice.shipper) &&
invoice.signature === signGuildBetrayalInvoice(invoice),
'Guild Betrayal lost its event or original funded source signature.');
}
export function createGuildBetrayalInvoice(
context: GuildBetrayalContext, authority: GuildBetrayalAuthority, sequence: number,
): GuildBetrayalInvoice {
validateContext(context);
quoteGuildBetrayalFunding({ ...authority, players: context.players });
const invoice: GuildBetrayalInvoice = {
shipper: authority.shipper, price: authority.price, ownSpice: authority.ownSpice,
allyPayment: authority.allyPayment, allyEscrow: authority.allyEscrow, donor: authority.donor,
source: authority.source, sourceSignature: authority.sourceSignature,
originalReceiver: authority.originalReceiver,
event: nexusGuildBetrayalEvent(context.turn, sequence, authority.shipper),
sequence, turn: context.turn, phase: 5, ownPayment: authority.price - authority.allyPayment,
signature: '',
};
invoice.signature = signGuildBetrayalInvoice(invoice);
validateInvoiceFacts(context, invoice);
return invoice;
}
/** Re-quote native custody/route/tariff separately before passing authority here. */
export function validateGuildBetrayalInvoice(
context: GuildBetrayalContext, invoice: GuildBetrayalInvoice,
authority: GuildBetrayalAuthority, sequence: number,
): void {
validateContext(context);
validateInvoiceFacts(context, invoice);
quoteGuildBetrayalFunding({ ...authority, players: context.players });
requireBetrayal(invoice.turn === context.turn && invoice.sequence === sequence &&
(['shipper', 'price', 'ownSpice', 'allyPayment', 'allyEscrow', 'donor',
'source', 'sourceSignature', 'originalReceiver'] as const)
.every(key => invoice[key] === authority[key]),
'Guild Betrayal no longer matches the independent native shipment and funding.');
}
/** Runtime emits this only after the original native fee and delivery suffix succeeds. */
export function nexusGuildBetrayalSourceReceipt(invoice: GuildBetrayalInvoice): string {
return JSON.stringify(['guildBetrayalSourceCompleted', invoice.event, invoice.sequence,
invoice.turn, invoice.shipper, invoice.source, invoice.sourceSignature,
invoice.price, invoice.ownPayment, invoice.allyPayment, invoice.donor]);
}
export function signGuildBetrayalReceipt(receipt: GuildBetrayalReceipt): string {
return JSON.stringify(['guildBetrayalReceipt', signGuildBetrayalInvoice(receipt.invoice),
receipt.recipient, receipt.holderFaction, receipt.holderUnallied, receipt.card,
receipt.mode, receipt.nexusDiscardIndex, receipt.sourceReceipt]);
}
function validateReceipt(context: GuildBetrayalContext, receipt: GuildBetrayalReceipt): void {
requireBetrayal(exact(receipt,
'card,holderFaction,holderUnallied,invoice,mode,nexusDiscardIndex,recipient,signature,sourceReceipt'),
'Guild Betrayal has an incomplete or foreign completed receipt.');
validateInvoiceFacts(context, receipt.invoice);
const holder = context.players.find(player => player.id === receipt.recipient);
requireBetrayal(receipt.recipient === null
? receipt.holderFaction === null && receipt.holderUnallied === null && receipt.card === null &&
receipt.mode === null && receipt.nexusDiscardIndex === null
: holder && holder.faction === receipt.holderFaction && holder.faction !== 'guild' &&
receipt.holderUnallied === true && receipt.card === 'guild' && receipt.mode === 'betrayal' &&
whole(receipt.nexusDiscardIndex) &&
nexusCardMode('guild', holder.faction, context.players.map(player => player.faction)) === 'betrayal',
'Guild Betrayal history lost the canonical native Guild or original unallied non-native holder.');
requireBetrayal(receipt.sourceReceipt === nexusGuildBetrayalSourceReceipt(receipt.invoice) &&
receipt.signature === signGuildBetrayalReceipt(receipt),
'Guild Betrayal lost its completed original source receipt.');
}
export function createGuildBetrayalReceipt(
context: GuildBetrayalContext, invoice: GuildBetrayalInvoice, outcome: GuildBetrayalOutcome,
): GuildBetrayalReceipt {
validateContext(context);
validateInvoiceFacts(context, invoice);
requireBetrayal(invoice.turn === context.turn && plain(outcome),
'Only the current original shipment may issue a new completed Guild receipt.');
const holder = context.players.find(player => player.id === outcome.recipient);
requireBetrayal(outcome.recipient === null || (holder && !holder.ally),
'Guild Betrayal can only be used by the actual unallied holder.');
const receipt: GuildBetrayalReceipt = {
invoice: { ...invoice }, recipient: outcome.recipient, holderFaction: outcome.holderFaction,
holderUnallied: outcome.holderUnallied, nexusDiscardIndex: outcome.nexusDiscardIndex,
sourceReceipt: outcome.sourceReceipt,
card: outcome.recipient === null ? null : 'guild', mode: outcome.recipient === null ? null : 'betrayal',
signature: '',
};
receipt.signature = signGuildBetrayalReceipt(receipt);
validateReceipt(context, receipt);
return receipt;
}
/** Intrinsic historic facts, not today's forces/spice/alliances/discard or Nexus custody. */
export function validateGuildBetrayalHistory(
context: GuildBetrayalContext, history: readonly GuildBetrayalReceipt[],
cursor: GuildBetrayalCursor, pendingInvoice: GuildBetrayalInvoice | null,
): void {
validateContext(context);
requireBetrayal(Array.isArray(history) && exact(cursor,
'event,sequence,sourceSignature,status,turn') && whole(cursor.sequence) &&
(cursor.status === 'pending' || cursor.status === 'completed'),
'Guild Betrayal lost its independent pending/completed cursor.');
let previousTurn = 0;
for (let index = 0; index < history.length; index++) {
const receipt = history[index];
validateReceipt(context, receipt);
requireBetrayal(receipt.invoice.sequence === index + 1 && receipt.invoice.turn >= previousTurn,
'Guild Betrayal has a duplicate, stale or out-of-order completed source.');
previousTurn = receipt.invoice.turn;
}
if (cursor.sequence === 0) {
requireBetrayal(cursor.status === 'completed' && cursor.event === null && cursor.turn === null &&
cursor.sourceSignature === null && history.length === 0 && pendingInvoice === null,
'Guild Betrayal has an orphan source at its initial cursor.');
return;
}
requireBetrayal(history.length === cursor.sequence - (cursor.status === 'pending' ? 1 : 0) &&
(cursor.status === 'pending' ? pendingInvoice !== null : pendingInvoice === null),
'Guild Betrayal has an orphan, missing or double-settled original source.');
const invoice = cursor.status === 'pending' ? pendingInvoice! : history[history.length - 1].invoice;
validateInvoiceFacts(context, invoice);
requireBetrayal(invoice.sequence === cursor.sequence && invoice.event === cursor.event &&
invoice.turn === cursor.turn && invoice.sourceSignature === cursor.sourceSignature &&
(cursor.status !== 'pending' || (invoice.turn === context.turn && invoice.turn >= previousTurn)),
'Guild Betrayal cursor no longer identifies its original event, turn or native source.');
}
+38 -4
View File
@@ -4113,6 +4113,39 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: '80 focused cases and 5,953 offline checks cover both alternatives/all-pass, exact custody/escrow/bonus, identity parity, four legal bot profiles, real table controls and authenticated SQLite restart/races/orphan rejection. Actual CLI/runtime and phone controls prove Basic veto, Advanced all-pass and cache/Black Market diversion with refresh. Local HTTP 51/55 retains three 503s and one timeout; full-family, wider-module and deployed acceptance remain open.', evidence: ['tests/nexus-richese-betrayal.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-controls.test.tsx', 'tests/bot-nexus-richese-betrayal.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'docs/NEXUS_RICHESE_RULES.md'] },
],
},
{
id: 'nexus-guild-betrayal',
title: 'Guild Nexus Betrayal: take one funded shipment payment',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'In the explicit Guild Betrayal preview, an unallied rival holding Guild Nexus may take one full original positive shipment payment, including their own pre-funded payment, instead of Guild, bank or occupied Junction income.',
steps: [
'This is the fresh local guild-betrayal development profile, not a public module start or an upgrade to saved games. It admits two to six ready classic-faction seats in Basic or Advanced, with native Guild and at least one other faction, physical base decks and Nexus. Homeworlds are optional only when genuinely enabled and seeded at setup. Other expansion factions/decks, Leader Skills, Discoveries, Tech Tokens, Stronghold Cards, Semuta, Kull and Richese previews remain outside this boundary.',
'Remain unallied and hold the actual singleton Guild Nexus while another seated player is native Guild. Use spends that physical Nexus once, with no Treachery Card, hand slot or extra activation spice. Cunning and absent-Guild Secret Ally are separate modes. An empty Guild board or temporary leader control does not change the mode.',
'Only original native shipment producers are admitted in this profile. Guild Nexus Cunning’s second shipment and Richese Nexus Secret Ally’s discounted shipment are blocked before spending a card or creating a native parent; those effects outside this profile remain unchanged. Guild Secret Ally is already unavailable because native Guild is seated, not because Betrayal adds a new prohibition to that panel.',
'Wait for a legal positive shipment fee after any applicable native stop and rate responses. The original source, tariff and physical forces must already be legal and fully funded. Stopped, declined, invalid or zero-cost shipments cannot award spice or spend this Nexus; ordinary free Fremen reinforcement and free Guild Ambassador transport stay distinct.',
'Use takes the full original charged payment, not merely eligible Guild income or its reduced half. The original payer and authorized ally contribution pay once, but Guild, bank and any occupied-Junction receiver get none of that invoice. This is replacement of one fee, not an extra award or second charge.',
'You may take your own payment, but you must afford it before receiving it back. A wholly self-funded payment is paid and returned for net zero spice; future receipt cannot finance the declaration. An ally’s pledge has already debited that donor into escrow: the original escrow is consumed once, not charged to the donor again. Taking the fee does not change the payer’s own remainder or donor split. Your live voluntary-spend promises and reservations still apply.',
'The printed occupied-Junction override delivers the entire fee: a four-spice payment gives the holder four, not two while an occupier gets two. It does not decide ordinary low-Junction contribution rounding, occupation entitlement after departure/replacement/contest or retained penalties. Non-overridden settlement keeps its native restrictions; a hypothetical Nexus play cannot bypass an unresolved original invoice.',
'Supported original producers are ordinary physical reserve shipment onto Arrakis, native Guild cross-planet/return transport, existing native Homeworld shipment and an accepted Junction-sponsored transport. Keep the same tariff, physical elite/advisor group, selected native Homeworld reserve sources, source/destination custody and sponsor/offer. No new foreign Homeworld route or Secret Ally return permission is granted.',
'The original native shipment completes once: pay the bound fee, move the exact physical forces, mark that shipment used and finish applicable native arrival and response decisions. Ordinary movement afterward remains available under the shipper’s own rules. Betrayal does not grant another shipment, move, rate or sponsorship.',
'Every publicly possible unallied non-Guild seat with held-Nexus presence receives the same event and shipper, including an eligible shipper. A secret irrelevant face still uses Pass; only a privately eligible Guild-card holder sees Use and their own unavailable reason. This neutral acknowledgement is an application privacy protocol, not publisher timing authority, blanket Karama immunity or a new general response priority.',
'The response adds no price, source/family/world, destination, force count, donor, balance, recipient or saved-receipt disclosure. Existing own-source and public inspector entitlements do not expand. Neutral acknowledgement logs no secret fee or source; the original committed shipment retains its normal result logging. Inspecting a printed Nexus reference is not evidence of another player’s held card.',
'Pass spends nothing and keeps Nexus. A recorded pass cannot be undone for this event. All required passes settle the original shipment once with its original receiver; with no publicly possible responder it settles automatically. Accepted Use spends the physical Nexus and redirects the same fee. Both actions submit only the offered event: the server binds payment and recipient. Finish this gate before shipping, moving, pledging, playing cards or opening another decision.',
'Refresh resumes the same typed producer, original invoice, passes and pending/completed ownership without a second donor debit, fee, card spend or force move. Stale or foreign events, malformed/orphaned saves and competing Use/final-Pass requests cannot select a new shipment or replay a raw action. Bounded direct engine, authenticated SQLite and actual Basic/Advanced CLI/phone source paths are verified locally; this does not certify complete Guild or Nexus rules.',
'Easy, Medium, Hard and Brutal share a minimal legal projected Use/Pass path; this is not Guild strategy or calibrated difficulty. Full Guild/Nexus/Homeworld families, wider compositions, full games, public starts and live deployment remain separate closed gates.',
],
example: 'Your original shipment costs four spice and you already have four available: Use pays that fee once and returns all four to you, spending Guild Nexus but granting no extra shipment. If another shipper pays four, you receive four instead of Guild, bank or an occupied Junction receiver. Passing leaves the original routing unchanged.',
related: ['nexus-cards', 'movement', 'alliance-funding', 'homeworlds', 'privacy', 'faction-guild', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Bounded reserve, native Guild transport, Homeworld and Junction producers bind the full funded invoice and original typed continuation with independent pending/completed ownership. Own refund, donor escrow and occupied-Junction replacement retain once-only native payment/delivery; wider producers remain excluded.', evidence: ['game/nexus-guild-betrayal.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'The bounded contract separates uniform acknowledgement from private Use and own blocked reason, with event-only actions, unchanged inspector entitlements and no new fee/source disclosure. Broader presentation and acceptance remain open.', evidence: ['components/nexus-guild-betrayal.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use only canonical projected legal Use/Pass choices while the payment gate suspends underlying actions. Full strategy and calibration remain unfinished.', evidence: ['game/bot-nexus-guild-betrayal.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed full-fee and own-payment clauses, escrow funding, explicit occupied-Junction override, four native adapters, privacy protocol and unchanged open occupation/rounding/priority boundaries are recorded. Full-family acceptance remains open.', evidence: ['docs/NEXUS_GUILD_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/HOMEWORLD_PAYMENT_INCOME_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: '147 selected offline cases and 6,114 full offline checks cover four native adapters, funded own refunds, original escrow, full low-Junction override, face-neutral acknowledgement, four legal bot profiles, actual handlers, authenticated restart/races and independent native-grant/orphan rejection. Eight real backed-up CLI entries and 390-pixel controls prove own/other/escrow/Guild-return/Homeworld/Junction Use, all-pass, free Fremen and refresh with exact saved force/card/spice custody. Local HTTP 48/55 has five 503s and two timeouts; broader and deployed acceptance remain open.', evidence: ['tests/nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-engine.test.ts', 'tests/nexus-guild-betrayal-controls.test.tsx', 'tests/bot-nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-recovery.test.ts', 'docs/NEXUS_GUILD_RULES.md'] },
],
},
{
id: 'nexus-cards',
title: 'Nexus Cards',
@@ -4161,18 +4194,19 @@ export const RULE_TOPICS: RuleTopic[] = [
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.',
'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
'Guild Betrayal has a separate fresh classic/native-Guild development preview. Before a fully funded positive shipment settles, an unallied holder may spend the physical Guild Nexus to take the full original fee, including their own pre-funded payment, instead of Guild, bank or occupied Junction income. Original donor escrow is consumed once and original physical delivery is unchanged. Neutral public acknowledgements reveal no fee/source; only private eligibility exposes Use. Zero/stopped shipments create no award or Nexus cost. See the dedicated guide; new verification remains Planned and ordinary Homeworld occupation/rounding questions stay local.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal'],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. New Guild Betrayal verification is separately Planned pending frozen direct evidence; these earlier results do not certify it. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
{
+2 -2
View File
@@ -12,13 +12,13 @@ type TurnView = Pick<
| 'phaseOpening'
| 'automaticContinuationPending'
> &
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction'>>;
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction'>>;
/** The single current decision owner for the table banner and seat highlight.
* Shared response windows have no single acting seat. Only public view fields
* participate; this does not decide which gameplay actions are legal. */
export function tableActionOwner(g: TurnView): string | null {
if (g.richeseBetrayalReaction) return null;
if (g.richeseBetrayalReaction || g.guildBetrayalReaction) return null;
if (g.kullReaction && g.status === 'playing')
return g.players.some(p => p.id === g.kullReaction!.player)
? g.kullReaction.player : null;
+136
View File
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
void test('all four legal bot profiles replace the original payment for every supported shipment adapter', () => {
for (const profile of DIFFICULTIES) {
const scenarios = [
{ source: 'reserve' },
{ source: 'guildTransport' },
{ source: 'homeworld' },
{ source: 'junction' },
{ source: 'reserve', homeworlds: true, occupiedJunction: true },
] as const;
for (const options of scenarios) {
const fixture = createGuildBetrayalFixture(options);
const game = structuredClone(fixture.game);
const own = viewGame(game, fixture.holder);
own.players.find(player => player.id === fixture.holder)!.bot = profile;
// Bots must not reconstruct eligibility or price from hidden opponents.
for (const rival of own.players) {
if (rival.id !== own.me) {
delete rival.hand;
delete rival.spice;
}
}
const reaction = own.guildBetrayalReaction!;
assert.equal(reaction.canUse, true);
const actions = botActions(own);
assert.deepEqual(actions, [{ type: 'guildBetrayalUse', event: fixture.event }]);
const result = applyAction(game, fixture.holder, actions[0]);
assert.equal(viewGame(result, fixture.holder).guildBetrayalReaction, null);
assert.equal(result.players.find(player => player.id === fixture.holder)!.spice,
game.players.find(player => player.id === fixture.holder)!.spice + fixture.price);
assert.equal(result.players.find(player => player.id === fixture.shipper)!.spice,
game.players.find(player => player.id === fixture.shipper)!.spice - fixture.price);
assert.equal(result.nexusCards?.cards?.hands[fixture.holder], null);
assert.ok(result.nexusCards?.cards?.discard.includes('guild'));
assert.deepEqual(result.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves, elites: player.elites, shipped: player.shipped })),
fixture.original.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves, elites: player.elites, shipped: player.shipped })));
assert.deepEqual(result.homeworlds?.custody, fixture.original.homeworlds?.custody);
}
}
});
void test('all profiles legally take their own fully funded invoice without treating a refund as payment funding', () => {
for (const profile of DIFFICULTIES) {
const fixture = createGuildBetrayalFixture({ payer: 'holder' });
const own = viewGame(fixture.game, fixture.holder);
own.players.find(player => player.id === fixture.holder)!.bot = profile;
const actions = botActions(own);
assert.deepEqual(actions, [{ type: 'guildBetrayalUse', event: fixture.event }]);
const result = applyAction(fixture.game, fixture.holder, actions[0]);
assert.equal(result.players.find(player => player.id === fixture.holder)!.spice,
fixture.game.players.find(player => player.id === fixture.holder)!.spice);
assert.equal(result.players.find(player => player.id === fixture.holder)!.reserves,
fixture.game.players.find(player => player.id === fixture.holder)!.reserves - 2);
assert.equal(result.players.find(player => player.id === fixture.holder)!.shipped, true);
assert.equal(result.nexusCards?.cards?.hands[fixture.holder], null);
assert.equal(viewGame(result, fixture.holder).guildBetrayalReaction, null);
}
});
void test('every profile acknowledges irrelevant faces until the original settlement completes, without fallthrough', () => {
for (const profile of DIFFICULTIES) {
for (const payer of ['holder', 'other'] as const) {
const fixture = createGuildBetrayalFixture({ payer, nexusFace: 'choam' });
let game = structuredClone(fixture.game);
for (const player of game.players) {
const own = viewGame(game, player.id);
own.players.find(seat => seat.id === player.id)!.bot = profile;
const reaction = own.guildBetrayalReaction;
if (!reaction) break;
const actions = botActions(own);
if (!reaction.canPass || reaction.hasPassed) {
assert.deepEqual(actions, []);
continue;
}
assert.deepEqual(actions, [{ type: 'guildBetrayalPass', event: fixture.event }]);
game = applyAction(game, player.id, actions[0]);
const after = viewGame(game, player.id);
after.players.find(seat => seat.id === player.id)!.bot = profile;
if (after.guildBetrayalReaction) assert.deepEqual(botActions(after), []);
}
assert.equal(viewGame(game, fixture.holder).guildBetrayalReaction, null);
assert.deepEqual(game.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces, reserves: player.reserves, shipped: player.shipped })),
fixture.original.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces, reserves: player.reserves, shipped: player.shipped })));
assert.equal(game.nexusCards?.cards?.hands[fixture.holder], 'choam');
}
}
});
void test('seat-private blocked and response ownership gates override visible held-card hints for all profiles', () => {
for (const profile of DIFFICULTIES) {
const fixture = createGuildBetrayalFixture();
const own = viewGame(fixture.game, fixture.holder);
own.players.find(player => player.id === fixture.holder)!.bot = profile;
assert.equal(own.nexusCards?.card, 'guild');
const reaction = own.guildBetrayalReaction!;
reaction.blocked = 'The committed promise prevents spending this Nexus card.';
assert.deepEqual(botActions(own), [{ type: 'guildBetrayalPass', event: fixture.event }]);
reaction.blocked = null;
reaction.canUse = false;
assert.deepEqual(botActions(own), [{ type: 'guildBetrayalPass', event: fixture.event }]);
reaction.canUse = true;
reaction.hasPassed = true;
assert.deepEqual(botActions(own), []);
reaction.hasPassed = false;
reaction.canPass = false;
assert.deepEqual(botActions(own), []);
reaction.canPass = true;
own.status = 'finished';
assert.deepEqual(botActions(own), []);
own.status = 'playing';
reaction.event = '';
assert.deepEqual(botActions(own), []);
}
});
void test('human seats require delegation while autopilot obeys the same private response offer', () => {
const fixture = createGuildBetrayalFixture({ payer: 'holder' });
const own = viewGame(fixture.game, fixture.holder);
const seat = own.players.find(player => player.id === fixture.holder)!;
delete seat.bot;
delete seat.autopilot;
assert.deepEqual(botActions(own), []);
seat.autopilot = 'Easy';
const actions = botActions(own);
assert.deepEqual(actions, [{ type: 'guildBetrayalUse', event: fixture.event }]);
const result = applyAction(fixture.game, fixture.holder, actions[0]);
assert.equal(result.nexusCards?.cards?.hands[fixture.holder], null);
assert.equal(result.players.find(player => player.id === fixture.holder)!.spice,
fixture.game.players.find(player => player.id === fixture.holder)!.spice);
});
+339
View File
@@ -0,0 +1,339 @@
import assert from 'node:assert/strict';
import {applyAction, createGame, initializeGuildBetrayalGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game} from '../game/engine';
import {botActions} from '../game/bots';
import type {FactionId} from '../game/catalog';
import {TERRITORIES, splitLocation} from '../game/board';
import {botEntryAllowed, fremenReserveEntry} from '../game/bot-mobility';
import {guildTransportQuote} from '../game/transport-quote';
import {junctionTransportChoice} from '../game/junction-transport-options';
import {homeworldShipmentChoice} from '../game/homeworld-shipment-options';
import {drawNexusCard, validateNexusCards} from '../game/nexus-cards';
import {homeworldGameIntegrity} from '../game/homeworld-game';
import {orderNexusSpice} from './fixture-nexus-cards';
export type GuildBetrayalFixtureOptions = {
/** Newly admitted saved setup; preserve its identities and physical setup. */
initial?: Game;
advanced?: boolean;
homeworlds?: boolean;
source?: 'reserve' | 'guildTransport' | 'homeworld' | 'junction';
payer?: 'holder' | 'guild' | 'other';
allyPayment?: number;
seatIds?: string[];
nexusFace?: 'guild' | 'choam';
zero?: boolean;
occupiedJunction?: boolean;
};
export type GuildBetrayalFixture = {
game: Game;
before: Game;
original: Game;
declaration: Action;
holder: string;
guild: string;
shipper: string;
donor: string | null;
event: string;
price: number;
source: 'reserve' | 'guildTransport' | 'homeworld' | 'junction';
};
/** Complete only already declared native permission, acknowledgement and income
* windows. Never creates or replays a shipment action. */
export function settleGuildBetrayalFixture(state: Game): Game {
let game = state;
for (let step = 0; step < 100; step++) {
const frame = game.pendingGuildBetrayal;
if (frame) {
const id = frame.required.find(seat => !frame.passed.includes(seat))!;
game = applyAction(game, id, {type: 'guildBetrayalPass', event: frame.invoice.event});
} else if (game.decision?.kind === 'guildShipment') {
game = applyAction(game, game.decision.player, {type: 'decision', allow: true});
} else if (game.decision?.kind === 'homeworldShipmentGuild') {
game = applyAction(game, game.decision.player,
{type: 'decision', allow: true, event: game.decision.event});
} else if (game.response) {
const id = game.players.find(p => !game.response!.passed.includes(p.id))!.id;
game = applyAction(game, id, {type: 'passResponse'});
} else return game;
}
throw new Error('Native Guild fixture settlement did not complete.');
}
function stepGuildFixture(game: Game): Game {
for (const p of game.players) {
const view = viewGame(game, p.id);
view.players.find(seat => seat.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) return applyAction(game, p.id, action);
}
throw new Error(`Native fixture has no legal action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
}
function enterGuildFixtureMovement(state: Game, minimumTurn = 1): Game {
let game = state;
let orderedTurn = 0;
for (let step = 0; step < 700; step++) {
if (game.status === 'playing' && game.turn >= minimumTurn && game.phase === 5 &&
!game.phaseOpening && !game.response && !game.decision) return game;
if (game.status === 'playing' && game.phase === 1 && orderedTurn !== game.turn) {
orderNexusSpice(game, ['land', 'land']);
orderedTurn = game.turn;
}
game = stepGuildFixture(game);
}
throw new Error('Genuine Guild fixture did not reach Shipment and Movement.');
}
function holdGuildFixtureFace(game: Game, holder: string, face: 'guild' | 'choam'): void {
// Arrange only the conserved physical Nexus inventory, before any invoice.
// Public held-card presence is identical across hidden-face fixture variants.
let cards = game.nexusCards!.cards!;
for (const p of game.players) {
const held = cards.hands[p.id];
if (held !== null) {cards.deck.push(held); cards.hands[p.id] = null;}
}
for (const p of game.players.filter(seat => !seat.ally)) {
const desired = p.id === holder ? face : p.faction === 'guild' ? 'atreides' : 'fremen';
let index = cards.deck.indexOf(desired);
if (index < 0) {
index = cards.discard.indexOf(desired);
assert.ok(index >= 0, 'The actual singleton face is available.');
cards.deck.unshift(cards.discard.splice(index, 1)[0]);
} else cards.deck.unshift(cards.deck.splice(index, 1)[0]);
cards = drawNexusCard(cards, p.id, game.players, () => 0);
}
game.nexusCards!.cards = cards;
validateNexusCards(cards, game.players);
}
function clearGuildFixtureHands(game: Game): void {
for (const p of game.players) game.deck.push(...p.hand.splice(0));
}
function reserveDeclaration(game: Game, shipper: string, amount: number, allyPayment = 0): Action {
const view = viewGame(game, shipper);
const payer = view.players.find(p => p.id === shipper)!;
const candidates = TERRITORIES.filter(t => payer.faction === 'fremen' ?
fremenReserveEntry(t.id) : t.type === 'stronghold');
// Prefer a peaceful destination if this shipment prepares a later turn.
candidates.sort((a, b) => Number(game.players.some(p => p.id !== shipper &&
Object.keys(p.forces).some(key => splitLocation(key).territory === a.id && p.forces[key] > 0))) -
Number(game.players.some(p => p.id !== shipper &&
Object.keys(p.forces).some(key => splitLocation(key).territory === b.id && p.forces[key] > 0))));
for (const t of candidates) for (const sector of t.sectors) {
if (sector !== game.storm && botEntryAllowed(view, payer, t.id, sector, 'ship'))
return {type: 'ship', territory: t.id, sector, amount, allyPayment};
}
throw new Error('No current storm-safe native reserve destination.');
}
function fundGuildFixtureSpice(game: Game, id: string, minimum: number, excluded: string | null = null): void {
const recipient = game.players.find(p => p.id === id)!;
for (const giver of game.players.filter(p => p.id !== id && p.id !== excluded)) {
const amount = Math.min(Math.max(0, minimum - recipient.spice), giver.spice);
giver.spice -= amount;
recipient.spice += amount;
}
assert.ok(recipient.spice >= minimum, 'Upfront spice is transferred, not minted.');
}
function transportDeclaration(game: Game, shipper: string, amount: number, allyPayment: number): Action | null {
const payer = game.players.find(p => p.id === shipper)!;
const view = viewGame(game, shipper);
for (const [key, count] of Object.entries(payer.forces)) {
if (count < amount || splitLocation(key).sector === game.storm) continue;
const forces = {[key]: amount};
if (!game.homeworlds && payer.faction === 'guild') {
const action: Action = {type: 'guildShip', forces, territory: 'reserves', allyPayment};
if (!guildTransportQuote(view, action).unavailableReasons.length) return action;
} else for (const t of TERRITORIES.filter(t => t.type !== 'stronghold')) {
for (const sector of t.sectors) {
const action: Action = {type: 'guildShip', forces, territory: t.id, sector, allyPayment};
if (!guildTransportQuote(view, action).unavailableReasons.length) return action;
}
}
}
return null;
}
function prepareGuildFixtureTransport(state: Game, shipper: string, amount: number): Game {
let game = state;
for (let opportunity = 0; opportunity < 10; opportunity++) {
while (game.active !== shipper) game = applyAction(game, game.active!, {type: 'endMovement'});
// Native bidding on a later real turn may spend the earlier preparation
// budget. Re-establish conserved upfront funds before quoting transport.
fundGuildFixtureSpice(game, shipper, amount);
if (transportDeclaration(game, shipper, amount, 0)) return game;
// The actual storm may cover every initial group. Establish a real source
// with an ordinary paid shipment, then advance the actual turn and storm.
const turn = game.turn;
game = settleGuildBetrayalFixture(applyAction(game, shipper,
reserveDeclaration(game, shipper, amount)));
while (game.phase === 5) game = applyAction(game, game.active!, {type: 'endMovement'});
game = enterGuildFixtureMovement(game, turn + 1);
clearGuildFixtureHands(game);
}
throw new Error('Native turn progression did not produce a legal Guild transport source.');
}
/** Final identities precede actual setup. All source frames below are declared
* through native actions; no fee, card, force, signed frame or escrow is minted. */
export function createGuildBetrayalFixture(options: GuildBetrayalFixtureOptions = {}): GuildBetrayalFixture {
const source = options.source ?? 'reserve';
const ids = options.seatIds ?? options.initial?.players.map(p => p.id) ?? ['holder', 'guild', 'other', 'donor'];
const guild = options.initial?.players.find(p => p.faction === 'guild')?.id ?? ids[1];
const holder = options.initial?.players.find(p => p.id !== guild && (!options.zero || p.faction !== 'fremen'))?.id ??
options.initial?.players.find(p => p.id !== guild)?.id ?? ids[0];
const other = options.initial ? options.initial.players.find(p => options.zero ? p.faction === 'fremen' :
p.id !== guild && p.id !== holder)?.id ?? holder : ids[2];
assert.ok(holder && guild && other);
const donor = (options.allyPayment ?? 0) > 0 ? options.initial?.players.find(p =>
p.id !== holder && p.id !== guild && p.id !== other)?.id ?? ids[3] ?? 'donor' : null;
const roster: [string, FactionId][] = options.initial ? options.initial.players.map(p => [p.id, p.faction]) :
[[holder, 'atreides'], [guild, 'guild'], [other, options.zero ? 'fremen' : 'emperor'],
...(donor ? [[donor, 'harkonnen'] as [string, FactionId]] : [])];
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.equal(game.status, 'setup', 'Only the newly admitted setup can be prepared.');
assert.equal(game.guildBetrayalPreview, true);
assert.equal(game.guildBetrayal?.sequence, 0);
assert.equal(game.pendingGuildBetrayal, null);
if (options.seatIds) assert.deepEqual(game.players.map(p => p.id), options.seatIds);
if (donor) assert.ok(game.players.some(p => p.id === donor), 'The admitted roster must contain the actual donor.');
if (options.zero) assert.equal(game.players.find(p => p.id === other)!.faction, 'fremen');
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
if (options.homeworlds !== undefined) assert.equal(!!game.homeworlds, options.homeworlds);
if (options.occupiedJunction || source === 'homeworld' || source === 'junction')
assert.ok(game.homeworlds, 'This adapter needs genuinely seeded Homeworlds.');
for (const p of game.players) viewGame(game, p.id);
} else {
game = createGame('GUILDBETRAYALAUDIT', newPlayer(holder, 'Atreides', 'atreides'),
options.advanced ?? false, []);
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, faction, faction));
if (options.homeworlds || options.occupiedJunction || source === 'homeworld' || source === 'junction')
game = applyAction(game, holder, {type: 'homeworlds', enabled: true});
game.nexusCards = {cards: null, phase: null};
for (const p of game.players) game = applyAction(game, p.id, {type: 'ready'});
game = initializeGuildBetrayalGameForAudit(game);
}
game = enterGuildFixtureMovement(game);
// Returning dealt physical hands to the same deck removes unrelated optional
// card responses, without changing setup, physical identities or deck size.
clearGuildFixtureHands(game);
if (options.occupiedJunction) {
assert.notEqual(source, 'junction', 'Low occupied Junction cannot invent a high-population sponsorship.');
// Native Guild empties Junction through a real reserve shipment, and the
// holder physically enters through a real Homeworld shipment. No preplaced
// invader or forged occupation record is used.
const guildPlayer = game.players.find(p => p.id === guild)!;
const giver = game.players.find(p => p.id === other)!;
const needed = Math.max(0, Math.ceil(guildPlayer.reserves / 2) - guildPlayer.spice);
assert.ok(giver.spice >= needed);
giver.spice -= needed;
guildPlayer.spice += needed;
const turn = game.turn;
while (game.phase === 5) {
const actor = game.active!;
if (actor === guild) {
game = settleGuildBetrayalFixture(applyAction(game, actor,
reserveDeclaration(game, actor, guildPlayer.reserves)));
} else if (actor === holder) {
const invader = game.players.find(p => p.id === actor)!;
const choice = homeworldShipmentChoice(viewGame(game, actor), 'homeworld:guild',
{[`homeworld:${invader.faction}`]: {normal: 1, elite: 0}});
assert.ok(choice.action, choice.blocked ?? 'A genuine Guild invasion must be legal.');
game = settleGuildBetrayalFixture(applyAction(game, actor, choice.action));
}
game = applyAction(game, actor, {type: 'endMovement'});
}
game = enterGuildFixtureMovement(game, turn + 1);
clearGuildFixtureHands(game);
assert.equal(game.players.find(p => p.id === guild)!.reserves, 0);
assert.equal(game.homeworlds!.custody!.visitors['homeworld:guild'][holder].normal, 1);
homeworldGameIntegrity(game);
}
const shipper = source === 'guildTransport' ? options.payer === 'other' ? other : guild : options.zero ? other :
options.payer === 'holder' ? holder : options.payer === 'guild' ? guild : other;
const amount = options.occupiedJunction ? 3 : 2;
if (source === 'guildTransport' && shipper !== guild) {
assert.equal(donor, null, 'A native Guild ally uses the actual Guild grant, not a second alliance.');
game.players.find(p => p.id === shipper)!.ally = guild;
game.players.find(p => p.id === guild)!.ally = shipper;
const cards = game.nexusCards!.cards!;
for (const id of [shipper, guild]) {
const face = cards.hands[id];
if (face !== null) {cards.discard.push(face); cards.hands[id] = null;}
}
}
for (const [id, minimum] of [[shipper, options.zero ? 0 : amount],
...(donor ? [[donor, options.allyPayment ?? 0]] : [])] as [string, number][])
fundGuildFixtureSpice(game, id, minimum, donor);
if (source === 'guildTransport') game = prepareGuildFixtureTransport(game, shipper, amount);
else while (game.active !== shipper) game = applyAction(game, game.active!, {type: 'endMovement'});
if (donor) {
assert.notEqual(shipper, holder, 'An allied own payer cannot hold a Nexus card.');
// Arrange a conserved reciprocal alliance before creating any source frame.
// The native pledge action performs the actual donor debit/escrow custody.
const payer = game.players.find(p => p.id === shipper)!;
const ally = game.players.find(p => p.id === donor)!;
payer.ally = donor; ally.ally = shipper;
const cards = game.nexusCards!.cards!;
for (const id of [shipper, donor]) {
const face = cards.hands[id];
if (face !== null) {cards.discard.push(face); cards.hands[id] = null;}
}
game = applyAction(game, donor, {type: 'pledgeAid', amount: options.allyPayment});
}
holdGuildFixtureFace(game, holder, options.nexusFace ?? 'guild');
if (source === 'junction') {
const option = viewGame(game, guild).junctionTransport!;
game = applyAction(game, guild, {type: 'offerJunctionTransport', event: option.offerEvent, rate: 'full'});
}
const payment = options.allyPayment ?? 0;
const payer = game.players.find(p => p.id === shipper)!;
let declaration: Action;
if (source === 'reserve') declaration = reserveDeclaration(game, shipper, amount, payment);
else if (source === 'guildTransport') {
const action = transportDeclaration(game, shipper, amount, payment);
assert.ok(action, 'The current native Guild quote must authorize the exact source and route.');
declaration = action;
} else if (source === 'homeworld') {
const choice = homeworldShipmentChoice(viewGame(game, shipper),
shipper === guild ? 'homeworld:atreides' : 'homeworld:guild',
{[`homeworld:${payer.faction}`]: {normal: amount, elite: 0}}, payment);
assert.ok(choice.action, choice.blocked ?? 'The current paid Homeworld route must be legal.');
declaration = choice.action;
} else {
const view = viewGame(game, shipper);
let action: Action | null = null;
for (const t of TERRITORIES.filter(t => t.type === 'stronghold')) {
for (const sector of t.sectors) {
if (sector === game.storm) continue;
const choice = junctionTransportChoice(view, `${t.id}:${sector}`,
{[`homeworld:${payer.faction}`]: {normal: amount, elite: 0}}, payment);
if (choice.action) {action = choice.action; break;}
}
if (action) break;
}
assert.ok(action, 'A current storm-safe Junction quote must authorize the route.');
declaration = action;
}
const before = structuredClone(game);
game = applyAction(game, shipper, declaration);
for (let step = 0; !game.pendingGuildBetrayal && !game.players.find(p => p.id === shipper)!.shipped && step < 20; step++) {
const decision = game.decision;
assert.ok(decision && ['guildShipment', 'homeworldShipmentGuild'].includes(decision.kind), 'Native permission precedes payment.');
game = applyAction(game, decision.player, {type: 'decision', allow: true,
...(decision.kind === 'homeworldShipmentGuild' ? {event: decision.event} : {})});
}
const event = game.pendingGuildBetrayal?.invoice.event ?? '';
const price = game.pendingGuildBetrayal?.invoice.price ?? 0;
assert.equal(!!game.pendingGuildBetrayal, !options.zero);
validateNexusCards(game.nexusCards!.cards!, game.players);
if (game.homeworlds) homeworldGameIntegrity(game);
const original = settleGuildBetrayalFixture(game);
return {game, before, original, declaration, holder, guild, shipper, donor, event, price, source};
}
+1 -21
View File
@@ -5,7 +5,7 @@ import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { FACTIONS, type FactionId } from '../game/catalog';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { NEXUS_CARD_REFERENCE, NEXUS_PANEL_NAMES, nexusCardReference } from '../game/nexus-card-reference';
import { NEXUS_CARD_REFERENCE, NEXUS_PANEL_NAMES } from '../game/nexus-card-reference';
// Match Vinext's actual next/image alias in this isolated Node test process;
// render the production image implementation, not a component mock.
@@ -38,26 +38,6 @@ void test('the original inventory renders twelve identities, all36 readable pane
for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const)
assert.equal(html.split(`aria-label="${NEXUS_PANEL_NAMES[mode]}"`).length - 1, 12);
assert.equal((html.match(/aria-label="Inspect [^"]+ Nexus card"/g) ?? []).length, 12);
for (const card of NEXUS_CARD_REFERENCE) {
for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const) {
assert.ok(card[mode].length > 30);
assert.ok(html.includes(card[mode]));
}
}
});
void test('reference preserves materially different physical effects and original numerical limits', () => {
assert.match(nexusCardReference('guild').betrayal, /whole shipment payment.*own payment.*Junction/);
assert.match(nexusCardReference('guild').cunning, /second shipment.*cannot move unless.*Hajr/);
assert.match(nexusCardReference('richese').cunning, /two No-Field.*one force.*reveal one/);
assert.match(nexusCardReference('tleilaxu').secretAlly, /leader.*free.*five.*one spice each/);
assert.match(nexusCardReference('emperor').secretAlly, /three extra forces free beyond/);
assert.match(nexusCardReference('ecaz').cunning, /capture.*Tanks.*Ghola.*Moritani.*end of the turn/);
assert.match(nexusCardReference('moritani').betrayal, /Return.*without revealing/);
assert.match(nexusCardReference('harkonnen').betrayal, /replacement during Mentat Pause/);
assert.match(nexusCardReference('choam').secretAlly, /not used in that battle/);
assert.match(nexusCardReference('ixians').betrayal, /cannot prevent both/);
assert.doesNotMatch(JSON.stringify(NEXUS_CARD_REFERENCE), /INTERNAL AUDIT|ALTERNATE SUPPLY CHAIN|entire Battle Plan/);
});
void test('draw, replacement and keep actions bind the exact offered turn and card without mutation', () => {
+2 -3
View File
@@ -8,6 +8,7 @@ import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { createNexusChoamKullFixture, createNexusKullNativeParent,
createNexusKullNestedParent, createNexusKullAuctionParent } from './fixture-nexus-choam-kull';
import { takeKullCard } from './fixture-choam-kull';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
@@ -624,9 +625,7 @@ void test('SQLite a new Nexus-profile printed counter without a selection is cor
const f = await persisted(t);
const { choam, actor, event } = f;
const beforeDeclaration = structuredClone(f.initial);
const cost = beforeDeclaration.deck.find(card => card.id === 'ix-kull-wahad')!;
assert.ok(cost);
beforeDeclaration.deck.splice(beforeDeclaration.deck.indexOf(cost), 1);
const cost = takeKullCard(beforeDeclaration, 'ix-kull-wahad');
player(beforeDeclaration, choam).hand.push(cost);
f.save(beforeDeclaration);
const declared = await act(f, beforeDeclaration, choam,
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { Children, createElement, isValidElement } from 'react';
import type { ReactNode, ComponentProps } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame } from '../game/engine';
import type { Action, Game, GameView } from '../game/engine';
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
// These UI modules require the Node-only Next/CSS loading hooks above;
// static imports would resolve them before the hooks are registered.
const { NexusGuildBetrayal } = await import('../components/nexus-guild-betrayal');
const { GameTable } = await import('../components/game-table');
function markup(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(NexusGuildBetrayal, { game, act() {}, busy }));
}
function click(node: ReactNode, prefix: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{ children?: ReactNode; onClick?: () => void }>(child)) continue;
if (typeof child.props.children === 'string' && child.props.children.startsWith(prefix) && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children, prefix)) return true;
}
return false;
}
function select(game: GameView, prefix: string, busy = false): Action | undefined {
let selected: Action | undefined;
click(NexusGuildBetrayal({ game, busy, act(action) { selected = action; } }), prefix);
return selected;
}
function allPass(original: Game): Game {
let game = structuredClone(original);
for (const player of game.players) {
const action = select(viewGame(game, player.id), 'Pass');
if (action) game = applyAction(game, player.id, action);
}
return game;
}
void test('private eligibility grants Use and own Guild inspector without revealing a rival face or source', () => {
const fixture = createGuildBetrayalFixture();
const own = viewGame(fixture.game, fixture.holder);
assert.match(markup(own), /<button[^>]*>Use Guild Nexus/);
assert.match(markup(own), /Inspect Spacing Guild Nexus card/);
const nonholder = createGuildBetrayalFixture({ nexusFace: 'choam' });
const neutral = viewGame(nonholder.game, nonholder.holder);
assert.ok(select(neutral, 'Pass'));
assert.equal(select(neutral, 'Use Guild Nexus'), undefined);
assert.doesNotMatch(markup(neutral), /Inspect Spacing Guild Nexus card/);
const outsider = viewGame(fixture.game, fixture.guild);
assert.equal(select(outsider, 'Pass'), undefined);
assert.equal(select(outsider, 'Use Guild Nexus'), undefined);
assert.doesNotMatch(markup(outsider), /Inspect Spacing Guild Nexus card/);
// The response itself gets only the fixed private projection, not a quote or
// an extra entitlement to a shipper's hand, balance, source or route.
assert.deepEqual(Object.keys(own.guildBetrayalReaction!).sort(), ['blocked', 'canPass', 'canUse', 'event', 'hasPassed', 'shipper']);
});
void test('actual event-only human Use replaces other and own funded reserve payments with identical force delivery', () => {
for (const payer of ['other', 'holder'] as const) {
const fixture = createGuildBetrayalFixture({ payer });
const before = structuredClone(fixture.game);
const action = select(viewGame(before, fixture.holder), 'Use Guild Nexus');
assert.deepEqual(action, { type: 'guildBetrayalUse', event: fixture.event });
assert.ok(action);
const result = applyAction(before, fixture.holder, action);
const passed = allPass(fixture.game);
assert.equal(viewGame(result, fixture.holder).guildBetrayalReaction, null);
const holderBefore = fixture.game.players.find(player => player.id === fixture.holder)!;
const holderAfter = result.players.find(player => player.id === fixture.holder)!;
const shipperBefore = fixture.game.players.find(player => player.id === fixture.shipper)!;
const shipperAfter = result.players.find(player => player.id === fixture.shipper)!;
assert.equal(holderAfter.spice, holderBefore.spice + (payer === 'holder' ? 0 : fixture.price));
assert.equal(shipperAfter.spice, shipperBefore.spice - (payer === 'holder' ? 0 : fixture.price));
assert.equal(result.players.find(player => player.id === fixture.guild)!.spice,
fixture.game.players.find(player => player.id === fixture.guild)!.spice);
assert.equal(shipperAfter.shipped, true);
assert.equal(shipperAfter.reserves, shipperBefore.reserves - 2);
assert.deepEqual(result.players.map(player => ({ id: player.id, reserves: player.reserves, forces: player.forces, elites: player.elites })),
passed.players.map(player => ({ id: player.id, reserves: player.reserves, forces: player.forces, elites: player.elites })));
assert.equal(result.nexusCards?.cards?.hands[fixture.holder], null);
assert.ok(result.nexusCards?.cards?.discard.includes('guild'));
assert.equal(passed.nexusCards?.cards?.hands[fixture.holder], 'guild');
assert.equal(passed.players.find(player => player.id === fixture.guild)!.spice,
fixture.game.players.find(player => player.id === fixture.guild)!.spice + fixture.price);
}
});
void test('busy, stopped, passed, ineligible and privately blocked responses cannot emit Use', () => {
const fixture = createGuildBetrayalFixture();
const view = viewGame(fixture.game, fixture.holder);
assert.equal(select(view, 'Use Guild Nexus', true), undefined);
assert.equal(select(view, 'Pass', true), undefined);
view.guildBetrayalReaction!.blocked = 'An existing promise reserves this Nexus card.';
assert.equal(select(view, 'Use Guild Nexus'), undefined);
assert.deepEqual(select(view, 'Pass'), { type: 'guildBetrayalPass', event: fixture.event });
assert.match(markup(view), /Inspect Spacing Guild Nexus card/);
view.guildBetrayalReaction!.blocked = null;
view.guildBetrayalReaction!.canUse = false;
assert.equal(select(view, 'Use Guild Nexus'), undefined);
view.guildBetrayalReaction!.canUse = true;
view.guildBetrayalReaction!.hasPassed = true;
assert.equal(select(view, 'Use Guild Nexus'), undefined);
assert.equal(select(view, 'Pass'), undefined);
view.guildBetrayalReaction!.hasPassed = false;
view.guildBetrayalReaction!.canPass = false;
assert.equal(select(view, 'Use Guild Nexus'), undefined);
view.guildBetrayalReaction!.canPass = true;
view.status = 'finished';
assert.equal(select(view, 'Use Guild Nexus'), undefined);
assert.equal(select(view, 'Pass'), undefined);
view.guildBetrayalReaction = null;
assert.equal(markup(view), '');
});
void test('saved human actions cannot spend against future, foreign or completed shipment events', () => {
const fixture = createGuildBetrayalFixture();
const action = select(viewGame(fixture.game, fixture.holder), 'Use Guild Nexus');
assert.ok(action);
for (const event of ['foreign-shipment', `${fixture.event}:future`]) {
const game = structuredClone(fixture.game);
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, fixture.holder, { ...action, event }));
assert.equal(JSON.stringify(game), before);
}
const settled = applyAction(structuredClone(fixture.game), fixture.holder, action);
const before = JSON.stringify(settled);
assert.throws(() => applyAction(settled, fixture.holder, action));
assert.equal(JSON.stringify(settled), before);
});
void test('actual GameTable reserves underlying shipment, voluntary and hand controls and respects room ownership', () => {
const fixture = createGuildBetrayalFixture({ payer: 'holder' });
const own = viewGame(fixture.game, fixture.holder);
const table = (game: ComponentProps<typeof GameTable>['game'], busy = false) => renderToStaticMarkup(createElement(GameTable, {
game, send: async () => {}, busy, onExit() {},
}));
const html = table(own);
assert.match(html, /aria-label="Guild Nexus Betrayal response"/);
assert.match(html, /Guild Nexus Betrayal development preview/);
assert.match(html, /href="\/rules\?topic=nexus-guild-betrayal#nexus-guild-betrayal"/);
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Ship |Confirm shipment|End movement|Play Karama|Make a bribe|Pledge|Search)/i);
assert.match(table(own, true), /disabled=""[^>]*>Use Guild Nexus/);
for (const restriction of ['paused', 'closed', 'autopilot'] as const) {
const restricted: ComponentProps<typeof GameTable>['game'] = structuredClone(own);
if (restriction === 'autopilot') restricted.players.find(player => player.id === own.me)!.autopilot = 'Easy';
else restricted.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
assert.match(table(restricted), /disabled=""[^>]*>Use Guild Nexus/);
assert.match(table(restricted), /disabled=""[^>]*>Pass/);
}
const continued = viewGame(allPass(fixture.game), fixture.holder);
assert.doesNotMatch(table(continued), /aria-label="Guild Nexus Betrayal response"/);
assert.equal(continued.players.find(player => player.id === own.me)!.shipped, true);
});
aliases.deregister();
+368
View File
@@ -0,0 +1,368 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction, initializeGuildBetrayalGameForAudit, normalizeAutomaticGame, viewGame,
type Action, type Game} from '../game/engine';
import {validateNexusCards} from '../game/nexus-cards';
import {homeworldGameIntegrity} from '../game/homeworld-game';
import {signGuildBetrayalInvoice} from '../game/nexus-guild-betrayal';
import {createGuildBetrayalFixture, settleGuildBetrayalFixture,
type GuildBetrayalFixture, type GuildBetrayalFixtureOptions} from './fixture-nexus-guild-betrayal';
function physicalShipment(game: Game) {
return {custody: game.homeworlds?.custody,
forces: game.players.map(p => ({id: p.id, forces: p.forces, reserves: p.reserves,
tanks: p.tanks, elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved})),
active: game.active, remaining: game.movementRemaining};
}
function rejects(game: Game, actor: string, action: Action) {
const saved = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, saved, 'Rejected actions cannot alter the saved funded declaration.');
}
function take(fixture: GuildBetrayalFixture): Game {
return applyAction(JSON.parse(JSON.stringify(fixture.game)) as Game, fixture.holder,
{type: 'guildBetrayalUse', event: fixture.event});
}
function proveFullPayment(fixture: GuildBetrayalFixture): void {
const done = take(fixture);
assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original), 'The same native source/delivery suffix runs exactly once.');
assert.equal(done.pendingGuildBetrayal, null);
const invoice = fixture.game.pendingGuildBetrayal!.invoice;
for (const p of done.players) {
const prior = fixture.before.players.find(seat => seat.id === p.id)!;
assert.equal(p.spice, prior.spice - (p.id === fixture.shipper ? invoice.ownPayment : 0) +
(p.id === fixture.holder ? fixture.price : 0), `Full fee has exactly one receiver: ${p.id}`);
}
if (fixture.donor) assert.equal(done.aid[fixture.donor].amount,
fixture.before.aid[fixture.donor].amount - invoice.allyPayment);
validateNexusCards(done.nexusCards!.cards!, done.players);
assert.equal(done.nexusCards!.cards!.hands[fixture.holder], null);
assert.equal(done.nexusCards!.cards!.discard.at(-1), 'guild');
assert.equal(done.guildBetrayal!.status, 'completed');
const receipt = done.nexusGuildBetrayalHistory!.at(-1)!;
assert.equal(receipt.invoice.price, fixture.price);
assert.equal(receipt.recipient, fixture.holder);
assert.equal(receipt.mode, 'betrayal');
assert.equal(done.guildBetrayalSourceReceipts!.at(-1)!.receipt, receipt.sourceReceipt);
if (done.homeworlds) homeworldGameIntegrity(done);
rejects(done, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event});
rejects(done, fixture.shipper, fixture.declaration);
}
for (const advanced of [false, true]) {
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${source}: full original funded fee replaces native bank/Guild income and preserves native custody`, () => {
proveFullPayment(createGuildBetrayalFixture({advanced, source}));
});
}
for (const source of ['reserve', 'homeworld', 'junction'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${source}: own payment is funded upfront and then fully refunded`, () => {
const fixture = createGuildBetrayalFixture({advanced, source, payer: 'holder'});
proveFullPayment(fixture);
const prior = fixture.before.players.find(p => p.id === fixture.holder)!.spice;
assert.equal(take(fixture).players.find(p => p.id === fixture.holder)!.spice, prior);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} all-pass preserves the actual default payment and keeps every Nexus card`, () => {
const fixture = createGuildBetrayalFixture({advanced, source: 'reserve'});
let done = fixture.game;
const hands = structuredClone(done.nexusCards!.cards!.hands);
for (const id of fixture.game.pendingGuildBetrayal!.required)
done = applyAction(done, id, {type: 'guildBetrayalPass', event: fixture.event});
done = settleGuildBetrayalFixture(done);
assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original));
assert.deepEqual(done.players.map(p => p.spice), fixture.original.players.map(p => p.spice));
assert.deepEqual(done.nexusCards!.cards!.hands, hands);
assert.equal(done.nexusGuildBetrayalHistory!.at(-1)!.recipient, null);
assert.equal(done.players.find(p => p.id === fixture.guild)!.spice,
fixture.before.players.find(p => p.id === fixture.guild)!.spice + fixture.price);
});
}
for (const options of [
{source: 'reserve', allyPayment: 1},
{source: 'homeworld', allyPayment: 1},
{source: 'junction', allyPayment: 1},
{source: 'guildTransport', allyPayment: 1},
{source: 'reserve', homeworlds: true, occupiedJunction: true},
{source: 'reserve', homeworlds: true, occupiedJunction: true, allyPayment: 1},
{source: 'guildTransport', homeworlds: true},
] satisfies GuildBetrayalFixtureOptions[]) {
void test(`Funded native ${options.source} preserves ally escrow/low-Junction full fee ${JSON.stringify(options)}`, () => {
const fixture = createGuildBetrayalFixture(options);
proveFullPayment(fixture);
if ('occupiedJunction' in options) {
const oldGuild = fixture.before.players.find(p => p.id === fixture.guild)!.spice;
assert.equal(fixture.original.players.find(p => p.id === fixture.guild)!.spice - oldGuild,
Math.ceil(fixture.price / 2));
assert.equal(fixture.game.homeworlds!.custody!.visitors['homeworld:guild'][fixture.holder].normal, 1);
}
});
}
void test('Own refund cannot supply missing upfront spice; zero Fremen reinforcement has no payment gate or Nexus cost', () => {
const own = createGuildBetrayalFixture({payer: 'holder'});
const unfunded = structuredClone(own.before);
unfunded.players.find(p => p.id === own.holder)!.spice = 0;
rejects(unfunded, own.holder, own.declaration);
assert.equal(unfunded.nexusCards!.cards!.hands[own.holder], 'guild');
for (const advanced of [false, true]) {
const free = createGuildBetrayalFixture({advanced, zero: true});
assert.equal(free.price, 0);
assert.equal(free.game.pendingGuildBetrayal, null);
assert.equal(free.game.nexusCards!.cards!.hands[free.holder], 'guild');
assert.equal(free.game.players.find(p => p.id === free.shipper)!.spice,
free.before.players.find(p => p.id === free.shipper)!.spice);
const destination = `${String(free.declaration.territory)}:${Number(free.declaration.sector)}`;
assert.notEqual(Number(free.declaration.sector), free.before.storm);
assert.equal(free.game.players.find(p => p.id === free.shipper)!.forces[destination],
(free.before.players.find(p => p.id === free.shipper)!.forces[destination] ?? 0) + 2);
}
});
void test('Advanced Guild special Karama stops before any positive invoice, refund or Nexus cost', () => {
const fixture = createGuildBetrayalFixture({advanced: true});
let game = structuredClone(fixture.before);
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
game.players.find(p => p.id === fixture.guild)!.hand.push(card);
const originalSpice = game.players.map(p => p.spice);
const originalForces = game.players.map(p => ({forces: p.forces, reserves: p.reserves}));
game = applyAction(game, fixture.shipper, fixture.declaration);
assert.equal(game.decision?.kind, 'guildShipment');
game = applyAction(game, fixture.guild, {type: 'card', mode: 'special', card: card.id});
game = settleGuildBetrayalFixture(game);
assert.equal(game.pendingGuildBetrayal, null);
assert.equal(game.nexusCards!.cards!.hands[fixture.holder], 'guild');
assert.deepEqual(game.players.map(p => p.spice), originalSpice);
assert.deepEqual(game.players.map(p => ({forces: p.forces, reserves: p.reserves})), originalForces);
});
void test('A purchased native Karama rate binds its actual bank-funded fee, not the ordinary tariff', () => {
const fixture = createGuildBetrayalFixture({payer: 'other'});
let game = structuredClone(fixture.before);
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
game.players.find(p => p.id === fixture.shipper)!.hand.push(card);
game = applyAction(game, fixture.shipper, {type: 'card', mode: 'shipment', card: card.id, target: fixture.shipper});
game = settleGuildBetrayalFixture(game);
const before = structuredClone(game);
game = applyAction(game, fixture.shipper, {...fixture.declaration, amount: 3});
game = applyAction(game, fixture.holder,
{type: 'guildBetrayalUse', event: game.pendingGuildBetrayal!.invoice.event});
assert.equal(game.players.find(p => p.id === fixture.shipper)!.spice,
before.players.find(p => p.id === fixture.shipper)!.spice - 2);
assert.equal(game.players.find(p => p.id === fixture.holder)!.spice,
before.players.find(p => p.id === fixture.holder)!.spice + 2);
assert.equal(game.players.find(p => p.id === fixture.guild)!.spice,
before.players.find(p => p.id === fixture.guild)!.spice);
assert.equal(game.karamaShipping, null);
});
void test('The fixed neutral projection exposes only event/shipper and private legal choices; pending payment preempts every underlying action', () => {
const fixture = createGuildBetrayalFixture({payer: 'holder'});
const otherFace = structuredClone(fixture.before);
const cards = otherFace.nexusCards!.cards!;
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = 'guild'; cards.hands[fixture.holder] = 'choam';
const hidden = applyAction(otherFace, fixture.shipper, fixture.declaration);
for (const p of fixture.game.players) {
const left = viewGame(fixture.game, p.id).guildBetrayalReaction!;
const right = viewGame(hidden, p.id).guildBetrayalReaction!;
assert.deepEqual(Object.keys(left).sort(), ['blocked', 'canPass', 'canUse', 'event', 'hasPassed', 'shipper']);
assert.deepEqual({event: left.event, shipper: left.shipper, canPass: left.canPass, hasPassed: left.hasPassed},
{event: right.event, shipper: right.shipper, canPass: right.canPass, hasPassed: right.hasPassed});
if (p.id !== fixture.holder) assert.deepEqual(left, right);
}
assert.equal(viewGame(fixture.game, fixture.holder).guildBetrayalReaction!.canUse, true);
assert.equal(viewGame(hidden, fixture.holder).guildBetrayalReaction!.canUse, false);
for (const action of [fixture.declaration, {type: 'endMovement'}, {type: 'ready'},
{type: 'pledgeAid', amount: 0}, {type: 'nexusGuildCunning'}, {type: 'guildBetrayalUse', event: 'old'},
{type: 'guildBetrayalUse', event: fixture.event, recipient: fixture.holder},
{type: 'guildBetrayalUse', event: fixture.event, amount: fixture.price}])
rejects(fixture.game, fixture.holder, action);
const passed = applyAction(fixture.game, fixture.holder, {type: 'guildBetrayalPass', event: fixture.event});
assert.equal(viewGame(passed, fixture.holder).guildBetrayalReaction!.hasPassed, true);
rejects(passed, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event});
assert.deepEqual(normalizeAutomaticGame(passed), passed);
});
void test('Malformed, stale, orphaned and suspended native parents reject before apply, projection or automatic normalization', () => {
const fixture = createGuildBetrayalFixture();
const mutations: ((game: Game) => void)[] = [
game => {delete game.pendingGuildBetrayal;},
game => {delete game.guildBetrayal;},
game => {game.guildBetrayal!.event = 'foreign';},
game => {game.pendingGuildBetrayal!.invoice.price += 1;},
game => {game.pendingGuildBetrayal!.invoice.ownPayment = 0;},
game => {game.pendingGuildBetrayal!.passed.push(fixture.holder);},
game => {game.pendingGuildBetrayal!.required = [fixture.holder];},
game => {game.pendingGuildBetrayal!.signature = 'forged';},
game => {game.players.find(p => p.id === fixture.shipper)!.spice = 0;},
game => {game.players.find(p => p.id === fixture.shipper)!.reserves -= 1;},
game => {game.active = fixture.guild;},
game => {game.turn += 1;},
game => {game.decision = {kind: 'guildShipment', player: fixture.guild,
shipper: fixture.shipper, territory: 'arrakeen', sector: 10, amount: 2};},
];
for (const mutate of mutations) {
const game = structuredClone(fixture.game);
mutate(game);
const saved = structuredClone(game);
assert.throws(() => viewGame(game, fixture.holder));
assert.throws(() => normalizeAutomaticGame(game));
rejects(game, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event});
assert.deepEqual(game, saved);
}
const completed = take(fixture);
for (const mutate of [
(game: Game) => {game.nexusGuildBetrayalHistory!.pop();},
(game: Game) => {game.guildBetrayalSourceReceipts!.pop();},
(game: Game) => {game.nexusGuildBetrayalHistory![0].signature = 'forged';},
(game: Game) => {game.players.find(p => p.id === fixture.shipper)!.shipped = false;},
]) {
const game = structuredClone(completed); mutate(game);
assert.throws(() => viewGame(game, fixture.holder));
assert.throws(() => normalizeAutomaticGame(game));
}
assert.throws(() => initializeGuildBetrayalGameForAudit(fixture.game), /fresh/);
});
void test('No publicly possible holder auto-settles the native fee without a fabricated Nexus payment window', () => {
const fixture = createGuildBetrayalFixture();
const before = structuredClone(fixture.before);
const cards = before.nexusCards!.cards!;
for (const p of before.players) {
const face = cards.hands[p.id];
if (face !== null) {cards.deck.push(face); cards.hands[p.id] = null;}
}
const done = settleGuildBetrayalFixture(applyAction(before, fixture.shipper, fixture.declaration));
assert.equal(done.pendingGuildBetrayal, null);
assert.equal(done.players.find(p => p.id === fixture.guild)!.spice,
before.players.find(p => p.id === fixture.guild)!.spice + fixture.price);
assert.equal(done.players.find(p => p.id === fixture.holder)!.spice,
before.players.find(p => p.id === fixture.holder)!.spice);
assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original));
validateNexusCards(done.nexusCards!.cards!, done.players);
});
void test('Unsupported Cunning producer is visibly blocked before its singleton is spent in the bounded payment profile', () => {
const fixture = createGuildBetrayalFixture({payer: 'guild'});
const before = structuredClone(fixture.before);
const cards = before.nexusCards!.cards!;
cards.hands[fixture.holder] = cards.hands[fixture.guild];
cards.hands[fixture.guild] = 'guild';
const offer = viewGame(before, fixture.guild).nexusGuildCunning!.offer!;
assert.match(offer.blocked!, /second-shipment overlay/);
rejects(before, fixture.guild, {type: 'endMovement', nexus: offer.event});
assert.equal(before.nexusCards!.cards!.hands[fixture.guild], 'guild');
assert.equal(before.nexusGuildCunningHistory, undefined);
});
function outsidePaymentProfile(game: Game): Game {
const native = structuredClone(game);
delete native.guildBetrayalPreview;
delete native.guildBetrayal;
delete native.pendingGuildBetrayal;
delete native.nexusGuildBetrayalHistory;
delete native.guildBetrayalSourceReceipts;
return native;
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} Richese overlay rejects before spend while ordinary and outside-profile shipments finish`, () => {
const fixture = createGuildBetrayalFixture({advanced, payer: 'holder'});
const before = structuredClone(fixture.before);
const cards = before.nexusCards!.cards!;
const index = cards.deck.indexOf('richese');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[fixture.holder]!;
cards.hands[fixture.holder] = 'richese';
validateNexusCards(cards, before.players);
const offer = viewGame(before, fixture.holder).nexusRichese!;
assert.match(offer.blocked!, /Richese Secret Ally shipment overlay/);
const action: Action = {...fixture.declaration, amount: 3, nexus: offer.event};
const saved = structuredClone(before);
assert.throws(() => applyAction(before, fixture.holder, action), /Richese Secret Ally shipment overlay/);
assert.deepEqual(before, saved, 'No singleton spend, parent, history, fee or forces change on rejection.');
assert.equal(before.nexusRicheseHistory, undefined);
const ordinary = settleGuildBetrayalFixture(applyAction(before, fixture.holder,
{...fixture.declaration, amount: 3}));
const prior = before.players.find(p => p.id === fixture.holder)!;
const shipped = ordinary.players.find(p => p.id === fixture.holder)!;
assert.equal(shipped.reserves, prior.reserves - 3);
assert.equal(shipped.spice, prior.spice - 3);
assert.equal(shipped.shipped, true);
assert.equal(ordinary.nexusCards!.cards!.hands[fixture.holder], 'richese');
const native = outsidePaymentProfile(before);
const nativeOffer = viewGame(native, fixture.holder).nexusRichese!;
assert.equal(nativeOffer.blocked, null);
const done = settleGuildBetrayalFixture(applyAction(native, fixture.holder,
{...action, nexus: nativeOffer.event}));
const recipient = done.players.find(p => p.id === fixture.holder)!;
assert.equal(recipient.reserves, prior.reserves - 3);
assert.equal(recipient.spice, prior.spice - 1);
assert.equal(recipient.shipped, true);
assert.equal(done.nexusCards!.cards!.hands[fixture.holder], null);
assert.equal(done.nexusRicheseHistory!.at(-1)!.stage, 'shipped');
assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'richese').length, 1);
});
}
void test('Saved native cross permission is independently required even for a self-consistent paid frame or purchased Karama rate', () => {
for (const purchasedRate of [false, true]) {
const fixture = createGuildBetrayalFixture({source: 'guildTransport', payer: 'other'});
let game = structuredClone(fixture.game);
if (purchasedRate) {
game = structuredClone(fixture.before);
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
game.players.find(p => p.id === fixture.shipper)!.hand.push(card);
game = settleGuildBetrayalFixture(applyAction(game, fixture.shipper,
{type: 'card', mode: 'shipment', card: card.id, target: fixture.shipper}));
game = applyAction(game, fixture.shipper, fixture.declaration);
}
// Re-sign only the changed saved alliance facts, not a copied native quote.
// All counters, source, destination, tariff and full funding remain genuine.
game.players.find(p => p.id === fixture.shipper)!.ally = null;
game.players.find(p => p.id === fixture.guild)!.ally = null;
const frame = game.pendingGuildBetrayal!;
const proof = JSON.parse(frame.invoice.sourceSignature) as {players: {id: string; ally: string | null}[]};
for (const seat of proof.players) seat.ally = game.players.find(p => p.id === seat.id)!.ally;
frame.invoice.sourceSignature = JSON.stringify(proof);
frame.invoice.originalReceiver = purchasedRate ? null : fixture.guild;
frame.invoice.signature = signGuildBetrayalInvoice(frame.invoice);
frame.signature = JSON.stringify([frame.invoice.signature, frame.invoice.sourceSignature, frame.required, frame.passed]);
game.guildBetrayal!.sourceSignature = frame.invoice.sourceSignature;
const saved = structuredClone(game);
for (const operation of [
() => viewGame(game, fixture.holder),
() => normalizeAutomaticGame(game),
() => applyAction(game, fixture.holder, {type: 'guildBetrayalUse', event: frame.invoice.event}),
]) {
assert.throws(operation, /Native Guild transport requires the Guild or its reciprocal ally/);
assert.deepEqual(game, saved, 'A saved paid rate cannot self-authorize native cross transport or move resources.');
}
}
});
void test('Native Guild and reciprocal Guild ally cross shipments remain legal outside the bounded profile', () => {
for (const payer of ['guild', 'other'] as const) {
const fixture = createGuildBetrayalFixture({source: 'guildTransport', payer});
proveFullPayment(fixture);
const before = outsidePaymentProfile(fixture.before);
const done = settleGuildBetrayalFixture(applyAction(before, fixture.shipper, fixture.declaration));
const prior = before.players.find(p => p.id === fixture.shipper)!;
const shipper = done.players.find(p => p.id === fixture.shipper)!;
assert.equal(shipper.shipped, true);
assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original));
assert.equal(shipper.spice, prior.spice - fixture.price);
assert.equal(done.nexusCards!.cards!.hands[fixture.holder], 'guild');
}
});
+715
View File
@@ -0,0 +1,715 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, type GameView } from '../game/engine';
import { botActions } from '../game/bots';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { NEXUS_FACTIONS, replaceNexusCard, drawNexusCard, validateNexusCards } from '../game/nexus-cards';
import { guildBetrayalResponders, signGuildBetrayalInvoice } from '../game/nexus-guild-betrayal';
import { guildShipmentIncome } from '../game/shipment-price';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import type * as Rooms from '../db/rooms';
import type { FactionId } from '../game/catalog';
import { unitStore } from './fixture-nexus-room-store';
import { createGuildBetrayalFixture, type GuildBetrayalFixture, type GuildBetrayalFixtureOptions } from './fixture-nexus-guild-betrayal';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
type SqlRow = Record<string, SQLOutputValue>;
const use = (event: string): Action => ({ type: 'guildBetrayalUse', event });
const pass = (event: string): Action => ({ type: 'guildBetrayalPass', event });
const forged = (payload: object): Action => payload as Action;
const sum = (counts: Record<string, number>) => Object.values(counts).reduce((a, b) => a + b, 0);
function player(g: Game, id: string) {
const found = g.players.find(p => p.id === id);
assert.ok(found, `missing authenticated seat ${id}`);
return found;
}
function credentials(sqlite: DatabaseSync) {
return {
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(),
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
recoveries: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
};
}
function physical(g: Game) {
const ids = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
...(g.auction?.cards.slice(g.auction.index) ?? [])].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'every physical Treachery card has one custodian');
return ids;
}
function forceInventory(g: Game) {
homeworldGameIntegrity(g);
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
return g.players.map(p => {
const visitors = homes.filter(home => home.native !== p.id)
.map(home => home.forces[p.id] ?? { normal: 0, elite: 0 });
return {
id: p.id,
total: p.reserves + p.tanks + sum(p.forces) + visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0),
elite: p.elites ? p.elites.reserves + p.elites.tanks + sum(p.elites.forces) +
visitors.reduce((n, pool) => n + pool.elite, 0) : 0,
};
});
}
function delivery(g: Game) {
return {
players: g.players.map(p => ({ id: p.id, reserves: p.reserves, tanks: p.tanks,
forces: p.forces, elites: p.elites ?? null, advisors: p.advisors ?? null,
shipped: p.shipped, moved: p.moved })),
custody: g.homeworlds?.custody ?? null,
};
}
interface Fixture extends GuildBetrayalFixture {
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
writes: { expected: number; changes: number }[];
code: string;
initial: Game;
tokens: string[];
auths: SeatAuth[];
stock: string[];
forceStock: { id: string; total: number; elite: number }[];
credentialRows: { seats: SqlRow[]; entries: SqlRow[]; keys: SqlRow[]; recoveries: SqlRow[] };
unrelatedCode: string;
unrelatedToken: string;
unrelatedRow: SqlRow | undefined;
save(this: void, g: Game): void;
}
async function fixture(t: test.TestContext, options: GuildBetrayalFixtureOptions = {}): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Guild Betrayal SQL', 'atreides', options.advanced ?? false, []);
const code = made.view.code;
const tokens = [made.token];
const factions: FactionId[] = ['guild', options.zero ? 'fremen' : 'emperor', ...(options.allyPayment ? ['harkonnen' as const] : [])];
for (const faction of factions)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const madeGame = createGuildBetrayalFixture({ ...options, seatIds: auths.map(auth => auth.playerId) });
const initial = madeGame.game;
initial.code = code;
initial.host = auths[0].playerId;
initial.version = (await store.rooms.readRoom(code)).version;
const save = (g: Game) => {
assert.equal(store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code).changes, 1);
};
save(initial);
const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'harkonnen', false, []);
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code);
const credentialRows = credentials(store.sqlite);
const stock = physical(initial);
const forceStock = forceInventory(initial);
return { ...store, ...madeGame, code, initial, tokens, auths, save, stock, forceStock, credentialRows,
unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, unrelatedRow };
}
const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code);
function totalChanges(f: Fixture) {
const changes = f.sqlite.prepare('SELECT total_changes() AS changes').get()?.changes;
assert.ok(typeof changes === 'number');
return changes;
}
function protectedRows(f: Fixture) {
assert.deepEqual(credentials(f.sqlite), f.credentialRows, 'saved authorization hashes and recovery records stay unchanged');
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode), f.unrelatedRow);
}
function inventory(f: Fixture, g: Game) {
assert.deepEqual(physical(g), f.stock);
assert.deepEqual(forceInventory(g), f.forceStock, 'normal and elite physical force inventory is conserved');
const cards = g.nexusCards?.cards;
assert.ok(cards);
validateNexusCards(cards, g.players);
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(),
[...NEXUS_FACTIONS].sort(), 'one singleton of every Nexus face stays in custody');
protectedRows(f);
}
async function authenticate(f: Fixture, id: string) {
const index = f.auths.findIndex(auth => auth.playerId === id);
assert.ok(index >= 0);
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
assert.equal(auth.playerId, id);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
return auth;
}
async function act(f: Fixture, g: Game, id: string, action: Action) {
await f.restart().act(f.code, await authenticate(f, id), g.version, action, clock);
return f.restart().readRoom(f.code);
}
async function restored(f: Fixture, g: Game) {
const before = row(f), changes = totalChanges(f);
assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(g));
assert.deepEqual(plain(normalizeAutomaticGame(g)), plain(g), 'normalization cannot answer a human fee choice');
const views: GameView[] = [];
for (const auth of f.auths) {
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId)));
for (const key of ['pendingGuildBetrayal', 'guildBetrayal', 'nexusGuildBetrayalHistory', 'guildBetrayalSourceReceipts'])
assert.equal(Object.hasOwn(view, key), false, 'source, invoice and durable receipt remain private');
for (const rival of view.players.filter(p => p.id !== auth.playerId)) {
for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
}
const reaction = view.guildBetrayalReaction;
if (reaction) {
assert.deepEqual(Object.keys(reaction).sort(), ['event', 'shipper', 'canPass', 'hasPassed', 'canUse', 'blocked'].sort());
const first = views[0]?.guildBetrayalReaction;
if (first) assert.deepEqual({ event: reaction.event, shipper: reaction.shipper },
{ event: first.event, shipper: first.shipper });
assert.ok(g.players.some(p => p.id === reaction.shipper));
assert.equal(reaction.canUse, auth.playerId === f.holder &&
g.nexusCards!.cards!.hands[f.holder] === 'guild' && !reaction.hasPassed);
if (auth.playerId !== f.holder) assert.equal(reaction.blocked, null);
}
views.push(view);
}
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), before, 'refresh/restart cannot consume or settle a held human opportunity');
assert.equal(totalChanges(f), changes, 'refresh/restart does not write any durable table');
inventory(f, g);
return views;
}
async function rejected(
f: Fixture, g: Game, auth: SeatAuth, action: Action, version = g.version, expected?: RegExp | { message: string },
) {
const before = row(f), changes = totalChanges(f), state = plain(g);
const attempt = f.restart().act(f.code, auth, version, action, clock);
if (expected) await assert.rejects(attempt, expected);
else await assert.rejects(attempt);
assert.equal(totalChanges(f), changes, 'denied mutations cannot change any durable table, including a zero-row credential CAS');
assert.deepEqual(row(f), before, 'saved room state, version and timestamps stay unchanged');
assert.deepEqual(plain(g), state, 'rejected mutation does not alter the supplied source state');
protectedRows(f);
}
async function passOthers(f: Fixture, g: Game) {
for (const p of g.players) {
if (p.id === f.holder) continue;
const reaction = viewGame(g, p.id).guildBetrayalReaction;
if (reaction?.canPass) g = await act(f, g, p.id, pass(reaction.event));
}
assert.equal(viewGame(g, f.holder).guildBetrayalReaction?.canPass, true);
return g;
}
async function settleNativeResponses(f: Fixture, g: Game) {
const originalDelivery = structuredClone(delivery(g));
for (let step = 0; g.response && step < 16; step++) {
const response = g.response;
const responder = g.players.find(p => !response.passed.includes(p.id));
assert.ok(responder);
g = await act(f, g, responder.id, { type: 'passResponse' });
assert.deepEqual(delivery(g), originalDelivery, 'native income allowance does not replay already delivered forces');
await restored(f, g);
}
assert.equal(g.response, null, 'all original native response suffixes finish');
return g;
}
async function allPass(f: Fixture, g: Game) {
g = await passOthers(f, g);
await restored(f, g);
return settleNativeResponses(f, await act(f, g, f.holder, pass(f.event)));
}
function diverted(f: Fixture, g: Game, allyPayment = 0) {
assert.ok(f.price > 0, 'only an already-funded positive fee can be taken');
for (const p of g.players) {
const debit = p.id === f.shipper ? f.price - allyPayment : 0;
const credit = p.id === f.holder ? f.price : 0;
assert.equal(p.spice, player(f.initial, p.id).spice - debit + credit,
'replace the full original receiver, without a second payer or native-income credit');
}
if (allyPayment) {
const donor = f.donor;
assert.ok(donor, 'the split quote retains its authorized donor');
assert.equal(player(g, donor).spice, player(f.initial, donor).spice, 'pledged escrow is not withdrawn twice');
assert.equal(g.aid[donor].amount, f.initial.aid[donor].amount - allyPayment);
}
const budget = (state: Game) => state.players.reduce((total, p) => total + p.spice, 0) +
Object.values(state.aid).reduce((total, aid) => total + aid.amount, 0);
assert.equal(budget(g), budget(f.initial), 'full bank/default recipient replacement conserves paid spice plus escrow');
assert.deepEqual(delivery(g), delivery(f.original), 'the exact original cohort is delivered once with native turn flags');
assert.equal(g.nexusCards!.cards!.hands[f.holder], null);
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
assert.equal(g.nexusGuildBetrayalHistory!.length, 1);
assert.equal(g.nexusGuildBetrayalHistory![0].recipient, f.holder);
assert.equal(g.guildBetrayalSourceReceipts!.length, 1);
assert.equal(g.guildBetrayal!.status, 'completed');
assert.equal(viewGame(g, f.holder).guildBetrayalReaction, null);
inventory(f, g);
}
for (const advanced of [false, true]) {
for (const scenario of [
{ source: 'reserve', payer: 'holder' },
{ source: 'reserve', payer: 'other' },
{ source: 'reserve', payer: 'guild', allyPayment: 1 },
{ source: 'guildTransport', payer: 'guild' },
{ source: 'guildTransport', payer: 'guild', homeworlds: true },
{ source: 'homeworld', payer: 'holder', homeworlds: true },
{ source: 'homeworld', payer: 'other', homeworlds: true, allyPayment: 1 },
{ source: 'junction', payer: 'other', homeworlds: true },
{ source: 'reserve', payer: 'other', homeworlds: true, occupiedJunction: true },
] as const) {
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${scenario.source} ${scenario.payer}${'homeworlds' in scenario ? ' Homeworld' : ''} funded full fee replacement${'occupiedJunction' in scenario ? ' overrides occupied Junction' : ''} survives authenticated restart`, async t => {
const f = await fixture(t, { ...scenario, advanced });
await restored(f, f.initial);
const done = await act(f, f.initial, f.holder, use(f.event));
assert.equal(done.version, f.initial.version + 1);
diverted(f, done, 'allyPayment' in scenario ? scenario.allyPayment : 0);
if ('occupiedJunction' in scenario) {
assert.equal(f.initial.homeworlds!.custody!.visitors['homeworld:guild'][f.holder].normal, 1);
const nativeIncome = player(f.original, f.guild).spice - player(f.initial, f.guild).spice;
assert.ok(nativeIncome > 0 && nativeIncome < f.price, 'the genuine low-Junction native quote only receives part of this fee');
assert.equal(player(f.original, f.holder).spice, player(f.initial, f.holder).spice);
assert.equal(player(done, f.guild).spice, player(f.initial, f.guild).spice, 'Betrayal suppresses native low-Junction income');
assert.equal(done.response?.kind === 'guildIncome', false, 'no second income allowance survives the replacement');
}
await restored(f, done);
const auth = await authenticate(f, f.holder);
for (const action of [use(f.event), pass(f.event)]) await rejected(f, done, auth, action);
});
}
}
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
void test(`SQLite restored ${source} all-pass retains the Nexus and commits the original native payment and custody once`, async t => {
const f = await fixture(t, { source, payer: source === 'guildTransport' ? 'guild' : 'other',
homeworlds: source === 'homeworld' || source === 'junction' });
await restored(f, f.initial);
const done = await allPass(f, f.initial);
assert.deepEqual(done.players.map(p => [p.id, p.spice]), f.original.players.map(p => [p.id, p.spice]));
assert.deepEqual(done.aid, f.original.aid);
assert.deepEqual(delivery(done), delivery(f.original));
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'guild');
assert.equal(done.nexusCards!.cards!.discard.includes('guild'), false);
assert.equal(viewGame(done, f.holder).guildBetrayalReaction, null);
assert.equal(done.nexusGuildBetrayalHistory!.length, 1);
assert.equal(done.nexusGuildBetrayalHistory![0].recipient, null);
assert.equal(done.guildBetrayalSourceReceipts!.length, 1);
assert.equal(done.guildBetrayal!.status, 'completed');
await restored(f, done);
await rejected(f, done, await authenticate(f, f.holder), pass(f.event));
});
}
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
void test(`SQLite simultaneous ${source} Use/final Pass commits one fee and one physical delivery through CAS`, async t => {
const f = await fixture(t, { source, payer: source === 'guildTransport' ? 'guild' : 'other',
homeworlds: source === 'homeworld' || source === 'junction' });
const pending = await passOthers(f, f.initial);
await restored(f, pending);
f.writes.length = 0;
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
let results: PromiseSettledResult<unknown>[];
try {
const auth = await authenticate(f, f.holder);
results = await Promise.allSettled([use(f.event), pass(f.event)].map(action =>
f.restart().act(f.code, auth, pending.version, action, clock).finally(release)));
} finally { delete f.hooks.beforeWrite; }
assert.equal(arrivals, 2, 'both authenticated mutations reached the actual SQL version fence');
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
let done = await f.restart().readRoom(f.code);
assert.equal(done.version, pending.version + 1);
if (results[0].status === 'fulfilled') diverted(f, done);
else {
done = await settleNativeResponses(f, done);
assert.deepEqual(done.players.map(p => [p.id, p.spice]), f.original.players.map(p => [p.id, p.spice]));
assert.deepEqual(delivery(done), delivery(f.original));
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'guild');
}
await restored(f, done);
});
}
void test('SQLite credential reauthentication rejects stale, foreign, unsupported and client-owned fee choices without a write', async t => {
const f = await fixture(t);
const holder = await authenticate(f, f.holder);
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
const before = row(f), changes = totalChanges(f);
await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
await assert.rejects(f.restart().readSeatView(f.code, foreign));
await assert.rejects(f.restart().readSeatView(f.code, { playerId: f.holder, tokenHash: foreign.tokenHash }));
assert.equal(totalChanges(f), changes, 'denied authentication and private reads write no durable table');
assert.deepEqual(row(f), before);
protectedRows(f);
await rejected(f, f.initial, foreign, use(f.event));
await rejected(f, f.initial, { playerId: f.holder, tokenHash: foreign.tokenHash }, use(f.event));
await rejected(f, f.initial, holder, use(f.event), f.initial.version - 1);
await rejected(f, f.initial, await authenticate(f, f.guild), use(f.event));
for (const action of [
use(`${f.event}:foreign`), pass(`${f.event}:foreign`),
forged({ ...use(f.event), amount: f.price }), forged({ ...use(f.event), recipient: f.holder }),
forged({ ...use(f.event), price: 0 }), forged({ ...use(f.event), source: f.source }),
forged({ ...pass(f.event), card: 'guild' }), forged({ ...pass(f.event), ignored: true }),
{ type: 'pledgeAid', amount: 1 } as Action,
f.declaration,
]) await rejected(f, f.initial, holder, action);
const pending = await passOthers(f, f.initial);
if (pending.version !== f.initial.version)
await rejected(f, pending, holder, use(f.event), f.initial.version);
for (const p of pending.players) {
const reaction = viewGame(pending, p.id).guildBetrayalReaction;
if (reaction?.hasPassed) await rejected(f, pending, await authenticate(f, p.id), pass(f.event));
}
await restored(f, pending);
});
void test('SQLite public fee acknowledgement does not identify the held Nexus face or reveal source, route or cost', async t => {
const f = await fixture(t, { source: 'homeworld', payer: 'other', homeworlds: true });
const originalViews = await restored(f, f.initial);
const before = structuredClone(f.before);
before.code = f.code;
before.host = f.initial.host;
before.version = f.initial.version;
const cards = before.nexusCards!.cards!;
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
assert.equal(cards.hands[f.holder], 'guild');
cards.deck[index] = 'guild';
cards.hands[f.holder] = 'choam';
validateNexusCards(cards, before.players);
// Change only the private face in the same funded source, not another
// randomly played setup with a different payer balance or force history.
let alternative = applyAction(before, f.shipper, f.declaration);
for (let step = 0; !alternative.pendingGuildBetrayal && step < 20; step++) {
const decision = alternative.decision;
assert.ok(decision && ['guildShipment', 'homeworldShipmentGuild'].includes(decision.kind));
alternative = applyAction(alternative, decision.player, {
type: 'decision', allow: true,
...(decision.kind === 'homeworldShipmentGuild' ? { event: decision.event } : {}),
});
}
assert.ok(alternative.pendingGuildBetrayal);
f.save(alternative);
const changedViews = await restored(f, alternative);
for (const [index, auth] of f.auths.entries()) {
const original = originalViews[index].guildBetrayalReaction;
const changed = changedViews[index].guildBetrayalReaction;
assert.ok(original);
assert.ok(changed);
assert.equal(changed.event, original.event, 'the event is selected from the public producer, not the private face');
assert.equal(changed.shipper, original.shipper);
assert.equal(changed.canPass, original.canPass);
assert.equal(changed.hasPassed, original.hasPassed);
assert.equal(changed.canUse, false);
if (auth.playerId !== f.holder) assert.deepEqual(plain(changed), plain(original));
}
await rejected(f, alternative, await authenticate(f, f.holder), use(f.event));
const done = await allPass(f, alternative);
assert.deepEqual(delivery(done), delivery(f.original));
assert.deepEqual(done.players.map(p => [p.id, p.spice]), f.original.players.map(p => [p.id, p.spice]));
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'choam');
await restored(f, done);
});
for (const advanced of [false, true]) {
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} a re-signed paid cross-planet source cannot replace the native Guild grant`, async t => {
const f = await fixture(t, { advanced, source: 'guildTransport', payer: 'other' });
assert.notEqual(f.shipper, f.guild);
assert.equal(player(f.initial, f.shipper).ally, f.guild);
assert.equal(player(f.initial, f.guild).ally, f.shipper);
await restored(f, f.initial);
const corrupt = structuredClone(f.initial);
player(corrupt, f.shipper).ally = null;
player(corrupt, f.guild).ally = null;
const pending = corrupt.pendingGuildBetrayal;
assert.ok(pending?.continuation.source === 'guildTransport');
assert.ok(pending.invoice.price > 0);
assert.equal(pending.continuation.fromReserves, false);
assert.notEqual(pending.continuation.frame.to, 'reserves');
assert.deepEqual(pending.continuation, f.initial.pendingGuildBetrayal!.continuation,
'the physically available paid native group, route and tariff are unchanged');
assert.deepEqual(guildBetrayalResponders(corrupt.nexusCards!.cards!, corrupt.players), pending.required);
// Amend the saved consistency proof itself, rather than leaving a stale
// signature that would reject even without an independent native grant.
// No private validator or reconstruction of its source quote is involved.
const proof: { players: { id: string; ally: string | null }[] } =
JSON.parse(pending.invoice.sourceSignature);
for (const seat of proof.players) seat.ally = player(corrupt, seat.id).ally;
pending.invoice.sourceSignature = JSON.stringify(proof);
const native = pending.continuation.frame;
pending.invoice.originalReceiver = guildShipmentIncome({
guild: f.guild, shipper: f.shipper, ally: player(corrupt, f.shipper).ally,
cost: native.cost, allyPayment: native.allyPayment,
bankOnly: corrupt.karamaShipping?.player === f.shipper,
}) > 0 ? f.guild : null;
pending.invoice.signature = signGuildBetrayalInvoice(pending.invoice);
corrupt.guildBetrayal!.sourceSignature = pending.invoice.sourceSignature;
const binding: unknown[] = JSON.parse(pending.signature);
binding[0] = pending.invoice.signature;
binding[1] = pending.invoice.sourceSignature;
pending.signature = JSON.stringify(binding);
f.save(corrupt);
const before = row(f), changes = totalChanges(f), state = plain(corrupt);
const denied = { message: 'Native Guild transport requires the Guild or its reciprocal ally.' };
assert.deepEqual(plain(await f.restart().readRoom(f.code)), state, 'raw restore preserves the damaged source for diagnosis');
assert.throws(() => normalizeAutomaticGame(corrupt), denied);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), denied);
for (const action of [pass(f.event), use(f.event)])
await rejected(f, corrupt, await authenticate(f, f.holder), action, corrupt.version, denied);
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.equal(totalChanges(f), changes, 'failed read, normalization and authenticated settlement write no durable table');
assert.deepEqual(row(f), before);
const saved = await f.restart().readRoom(f.code);
assert.deepEqual(plain(saved), state, 'fee, forces, held cards, escrow, histories and room version remain untouched');
assert.deepEqual(plain(corrupt), state);
inventory(f, saved);
});
}
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
void test(`SQLite malformed or detached ${source} invoice/source rejects all-seat disclosure, normalization and settlement without writes`, async t => {
const f = await fixture(t, { source, payer: source === 'guildTransport' ? 'guild' : 'other',
homeworlds: source === 'homeworld' || source === 'junction' });
const mutations: { name: string; change(this: void, g: Game): void }[] = [
{ name: 'deleted continuation with pending cursor', change: g => { delete g.pendingGuildBetrayal; } },
{ name: 'deleted cursor with orphan invoice', change: g => { delete g.guildBetrayal; } },
{ name: 'deleted native source receipt collection', change: g => { delete g.guildBetrayalSourceReceipts; } },
{ name: 'deleted independent receipt history', change: g => { delete g.nexusGuildBetrayalHistory; } },
{ name: 'detached event', change: g => { g.guildBetrayal!.event = null; } },
{ name: 'lost pending sequence', change: g => { g.guildBetrayal!.sequence = 0; } },
{ name: 'false completion', change: g => { g.guildBetrayal!.status = 'completed'; } },
{ name: 'changed charged price', change: g => { g.pendingGuildBetrayal!.invoice.price++; } },
{ name: 'changed payer split', change: g => { g.pendingGuildBetrayal!.invoice.ownPayment++; } },
{ name: 'foreign payer', change: g => { g.pendingGuildBetrayal!.invoice.shipper = f.holder; } },
{ name: 'changed original receiver', change: g => { g.pendingGuildBetrayal!.invoice.originalReceiver = f.shipper; } },
{ name: 'changed source signature', change: g => { g.pendingGuildBetrayal!.invoice.sourceSignature += ':forged'; } },
{ name: 'changed producer parent', change: g => { g.pendingGuildBetrayal!.signature += ':forged'; } },
{ name: 'extra invoice key', change: g => { Object.assign(g.pendingGuildBetrayal!.invoice, { recipient: f.holder }); } },
{ name: 'extra continuation key', change: g => { Object.assign(g.pendingGuildBetrayal!.continuation, { action: f.declaration }); } },
{ name: 'foreign acknowledgement', change: g => { g.pendingGuildBetrayal!.required.push(f.guild); } },
{ name: 'fabricated saved pass', change: g => { g.pendingGuildBetrayal!.passed.push(f.holder); } },
{ name: 'missing original upfront funding', change: g => { player(g, f.shipper).spice = 0; } },
{ name: 'source custody moved without delivery', change: g => {
const p = player(g, f.shipper);
if (source === 'guildTransport') {
const location = Object.keys(p.forces).find(key => p.forces[key] > 0);
assert.ok(location);
p.forces[location]--;
p.tanks++;
} else {
assert.ok(p.reserves > 0);
p.reserves--;
p.tanks++;
}
} },
{ name: 'changed native quoted route', change: g => {
const continuation = g.pendingGuildBetrayal!.continuation;
switch (continuation.source) {
case 'reserve': continuation.shipment.amount++; break;
case 'guildTransport': continuation.frame.amount++; break;
case 'homeworld': continuation.shipment.amount++; break;
case 'junction': continuation.transport.destination += ':foreign'; break;
}
} },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(f.initial);
change(corrupt);
f.save(corrupt);
const before = row(f), changes = totalChanges(f);
const stock = physical(corrupt);
assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(corrupt), 'raw read does not rewrite a damaged save');
assert.throws(() => normalizeAutomaticGame(corrupt), name);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
for (const action of [pass(f.event), use(f.event)])
await rejected(f, corrupt, await authenticate(f, f.holder), action);
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), before, name);
assert.equal(totalChanges(f), changes, name);
const saved = await f.restart().readRoom(f.code);
assert.deepEqual(physical(saved), stock, name);
assert.deepEqual(saved.players.map(p => [p.id, p.reserves, p.forces, p.tanks]), corrupt.players.map(p => [p.id, p.reserves, p.forces, p.tanks]), name);
protectedRows(f);
}
});
}
void test('SQLite own-payment refund requires already available original funds and cannot bootstrap its upfront debit', async t => {
const f = await fixture(t, { payer: 'holder' });
assert.equal(f.shipper, f.holder);
assert.ok(player(f.initial, f.holder).spice >= f.price);
const corrupt = structuredClone(f.initial);
player(corrupt, f.holder).spice = f.price - 1;
f.save(corrupt);
const before = row(f), changes = totalChanges(f);
await rejected(f, corrupt, await authenticate(f, f.holder), use(f.event));
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)));
assert.deepEqual(row(f), before);
assert.equal(totalChanges(f), changes, 'failed upfront funding cannot write any durable table');
protectedRows(f);
});
void test('SQLite altered authorized aid escrow cannot charge its donor again or settle an unfunded original invoice', async t => {
const f = await fixture(t, { payer: 'guild', allyPayment: 1 });
const donor = f.donor;
assert.ok(donor);
const mutations: { name: string; change(this: void, g: Game): void }[] = [
{ name: 'deleted escrow', change: g => { delete g.aid[donor]; } },
{ name: 'empty escrow', change: g => { g.aid[donor].amount = 0; } },
{ name: 'foreign donor', change: g => { g.pendingGuildBetrayal!.invoice.donor = f.holder; } },
{ name: 'lost authorization', change: g => { player(g, f.shipper).ally = null; } },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(f.initial);
change(corrupt);
f.save(corrupt);
const before = row(f), changes = totalChanges(f);
await rejected(f, corrupt, await authenticate(f, f.holder), use(f.event));
assert.deepEqual(row(f), before, name);
assert.equal(totalChanges(f), changes, name);
const saved = await f.restart().readRoom(f.code);
assert.equal(player(saved, donor).spice, player(corrupt, donor).spice, name);
protectedRows(f);
}
});
void test('SQLite independent completed receipt survives a later actual shipment and conserved Nexus recycling, but cannot be deleted or detached', async t => {
const f = await fixture(t, { advanced: false, payer: 'holder' });
let done = await act(f, f.initial, f.holder, use(f.event));
diverted(f, done);
await restored(f, done);
const firstReceipt = structuredClone(done.nexusGuildBetrayalHistory![0]);
const firstSource = structuredClone(done.guildBetrayalSourceReceipts![0]);
// Exercise the physical component's real draw/replacement lifecycle, not a
// fabricated card or amended invoice. Earlier receipts must not retain a
// permanent claim to the present discard position or held face.
const recycled = structuredClone(done);
assert.ok(recycled.nexusCards?.cards);
recycled.nexusCards.cards = drawNexusCard(recycled.nexusCards.cards, f.holder, recycled.players, () => 0);
for (let step = 0; step < 48 && recycled.nexusCards.cards.hands[f.holder] !== 'guild'; step++)
recycled.nexusCards.cards = replaceNexusCard(recycled.nexusCards.cards, f.holder, recycled.players, () => 0);
assert.equal(recycled.nexusCards.cards.hands[f.holder], 'guild');
assert.equal(recycled.nexusCards.cards.discard.includes('guild'), false);
f.save(recycled);
await restored(f, recycled);
await rejected(f, recycled, await authenticate(f, f.holder), use(f.event));
done = await act(f, recycled, f.holder, { type: 'endMovement' });
for (let step = 0; step < 500; step++) {
const actor = done.active ? player(done, done.active) : null;
const cleanMovement = done.phase === 5 && !done.phaseOpening && !done.response && !done.decision;
if (cleanMovement && actor && actor.id !== f.holder && !actor.shipped && actor.reserves > 0 && actor.spice >= 2) break;
if (cleanMovement && actor) {
done = await act(f, done, actor.id, { type: 'endMovement' });
continue;
}
let request: { id: string; action: Action } | null = null;
for (const p of done.players) {
const view = viewGame(done, p.id);
const own = view.players.find(seat => seat.id === p.id);
assert.ok(own);
own.bot = 'Easy';
let action = botActions(view)[0];
if (p.id === f.holder && action?.type === 'nexusCardChoice' && view.nexusCards?.choices.includes('keep'))
action = { type: 'nexusCardChoice', turn: view.turn, card: view.nexusCards.card, choice: 'keep', ownRedraws: 0 };
if (action) { request = { id: p.id, action }; break; }
}
assert.ok(request, 'genuine intervening phases retain a legal authenticated continuation');
done = await act(f, done, request.id, request.action);
}
assert.equal(done.phase, 5);
assert.ok(!done.phaseOpening && !done.response && !done.decision,
'the later producer starts only after genuine intervening opportunities finish');
const laterShipper = done.active;
assert.ok(laterShipper, 'the next real movement turn retains its seated actor');
assert.notEqual(laterShipper, f.holder);
assert.equal(player(done, laterShipper).shipped, false);
const beforeSecond = structuredClone(done);
done = await act(f, done, laterShipper, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1 });
for (let step = 0; !done.pendingGuildBetrayal && step < 16; step++) {
const decision = done.decision;
if (decision?.kind === 'guildShipment') {
done = await act(f, done, decision.player, { type: 'decision', allow: true });
} else {
const response = done.response;
assert.ok(response, 'the later declaration retains its native permission/rate opportunity');
const responder = done.players.find(p => !response.passed.includes(p.id));
assert.ok(responder);
done = await act(f, done, responder.id, { type: 'passResponse' });
}
}
const reaction = viewGame(done, f.holder).guildBetrayalReaction;
assert.ok(reaction, 'a later genuinely declared funded shipment opens its own distinct invoice');
assert.notEqual(reaction.event, f.event);
assert.equal(reaction.shipper, laterShipper);
assert.equal(done.guildBetrayal!.sequence, 2);
assert.deepEqual(done.nexusGuildBetrayalHistory, [firstReceipt]);
assert.deepEqual(done.guildBetrayalSourceReceipts, [firstSource]);
await restored(f, done);
await rejected(f, done, await authenticate(f, f.holder), use(f.event));
const price = done.pendingGuildBetrayal!.invoice.price;
const later = await act(f, done, f.holder, use(reaction.event));
assert.equal(later.nexusGuildBetrayalHistory!.length, 2);
assert.equal(later.guildBetrayalSourceReceipts!.length, 2);
assert.deepEqual(later.nexusGuildBetrayalHistory![0], firstReceipt);
assert.deepEqual(later.guildBetrayalSourceReceipts![0], firstSource);
assert.equal(player(later, laterShipper).spice, player(beforeSecond, laterShipper).spice - price);
assert.equal(player(later, f.holder).spice, player(beforeSecond, f.holder).spice + price);
assert.equal(player(later, laterShipper).reserves, player(beforeSecond, laterShipper).reserves - 1);
assert.equal(player(later, laterShipper).forces['polar_sink:0'],
(player(beforeSecond, laterShipper).forces['polar_sink:0'] ?? 0) + 1);
await restored(f, later);
for (const event of [f.event, reaction.event])
await rejected(f, later, await authenticate(f, f.holder), use(event));
const mutations: { name: string; change(this: void, g: Game): void }[] = [
{ name: 'deleted completed receipt', change: g => { g.nexusGuildBetrayalHistory!.shift(); } },
{ name: 'deleted independent source receipt', change: g => { g.guildBetrayalSourceReceipts!.shift(); } },
{ name: 'deleted receipt cursor', change: g => { delete g.guildBetrayal; } },
{ name: 'deleted receipt collection', change: g => { delete g.nexusGuildBetrayalHistory; } },
{ name: 'altered historical charged fee', change: g => { g.nexusGuildBetrayalHistory![0].invoice.price++; } },
{ name: 'altered historical recipient', change: g => { g.nexusGuildBetrayalHistory![0].recipient = f.guild; } },
{ name: 'detached native source event', change: g => { g.guildBetrayalSourceReceipts![0].event += ':foreign'; } },
{ name: 'detached native source binding', change: g => { g.guildBetrayalSourceReceipts![0].receipt += ':foreign'; } },
{ name: 'reopened completed physical delivery', change: g => { player(g, laterShipper).shipped = false; } },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(later);
change(corrupt);
f.save(corrupt);
const before = row(f), changes = totalChanges(f);
assert.throws(() => normalizeAutomaticGame(corrupt), name);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
await rejected(f, corrupt, await authenticate(f, f.holder), use(reaction.event));
assert.deepEqual(row(f), before, name);
assert.equal(totalChanges(f), changes, name);
protectedRows(f);
}
});
for (const advanced of [false, true]) {
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} actual free Fremen shipment has no fee opportunity, award or Nexus consumption`, async t => {
const f = await fixture(t, { advanced, zero: true, payer: 'other' });
const before = structuredClone(f.before);
before.code = f.code;
before.host = f.initial.host;
before.version = f.initial.version;
f.save(before);
const done = await act(f, before, f.shipper, f.declaration);
assert.equal(done.pendingGuildBetrayal, null);
assert.equal(done.guildBetrayal!.sequence, before.guildBetrayal!.sequence);
assert.deepEqual(done.players.map(p => [p.id, p.spice]), before.players.map(p => [p.id, p.spice]));
assert.deepEqual(done.nexusCards!.cards, before.nexusCards!.cards);
assert.deepEqual(delivery(done), delivery(f.original));
assert.equal(player(done, f.shipper).shipped, true);
assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves - 2);
await restored(f, done);
await rejected(f, done, await authenticate(f, f.holder), use(f.event));
});
}
+185
View File
@@ -0,0 +1,185 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createNexusCards, discardNexusCard, type NexusPlayer, type NexusState,
} from '../game/nexus-cards';
import {
createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible,
guildBetrayalResponders, nexusGuildBetrayalEvent, nexusGuildBetrayalSourceReceipt,
quoteGuildBetrayalFunding, signGuildBetrayalInvoice, signGuildBetrayalReceipt,
validateGuildBetrayalHistory, validateGuildBetrayalInvoice,
type GuildBetrayalAuthority, type GuildBetrayalContext, type GuildBetrayalCursor,
type GuildBetrayalInvoice, type GuildBetrayalReceipt,
} from '../game/nexus-guild-betrayal';
const players: NexusPlayer[] = [
{ id: 'holder', faction: 'atreides' },
{ id: 'guild', faction: 'guild' },
{ id: 'shipper', faction: 'emperor', ally: 'donor' },
{ id: 'donor', faction: 'harkonnen', ally: 'shipper' },
];
const context: GuildBetrayalContext = { turn: 2, players };
function cards(face: 'guild' | 'choam' = 'guild'): NexusState {
const inventory = createNexusCards(players, () => 0.5);
inventory.deck.splice(inventory.deck.indexOf(face), 1);
inventory.hands.holder = face;
return inventory;
}
function authority(patch: Partial<GuildBetrayalAuthority> = {}): GuildBetrayalAuthority {
return {
shipper: 'shipper', price: 8, ownSpice: 5, allyPayment: 3, allyEscrow: 6,
donor: 'donor', source: 'reserve', sourceSignature: 'native-force-group-and-quote',
originalReceiver: 'guild', ...patch,
};
}
function cursor(invoice: GuildBetrayalInvoice, status: 'pending' | 'completed'): GuildBetrayalCursor {
return { sequence: invoice.sequence, event: invoice.event, turn: invoice.turn,
sourceSignature: invoice.sourceSignature, status };
}
function completed(invoice: GuildBetrayalInvoice, used = true): GuildBetrayalReceipt {
return createGuildBetrayalReceipt(context, invoice, {
recipient: used ? 'holder' : null, holderFaction: used ? 'atreides' : null,
holderUnallied: used ? true : null, nexusDiscardIndex: used ? 4 : null,
sourceReceipt: nexusGuildBetrayalSourceReceipt(invoice),
});
}
void test('only the canonical unallied non-native holder of the singleton Guild card is eligible', () => {
const inventory = cards();
assert.equal(guildBetrayalEligible(inventory, players, 'holder'), true);
assert.equal(guildBetrayalEligible(inventory, players, 'guild'), false);
assert.equal(guildBetrayalEligible(inventory, players, 'outsider'), false);
assert.equal(guildBetrayalEligible(inventory, players,
{ id: 'holder', faction: 'atreides', ally: null } as never), false);
assert.equal(guildBetrayalEligible(cards('choam'), players, 'holder'), false);
const absentGuild = players.map(player => player.id === 'guild'
? { ...player, faction: 'fremen' as const } : player);
assert.equal(guildBetrayalEligible(inventory, absentGuild, 'holder'), false);
const allied = players.map(player => player.id === 'holder' ? { ...player, ally: 'guild' }
: player.id === 'guild' ? { ...player, ally: 'holder' } : player);
assert.equal(guildBetrayalEligible(inventory, allied, 'holder'), false);
const counterfeit = structuredClone(inventory);
counterfeit.hands.shipper = 'guild';
assert.throws(() => guildBetrayalEligible(counterfeit, players, 'holder'));
assert.deepEqual(guildBetrayalResponders(inventory, players), ['holder']);
assert.deepEqual(guildBetrayalResponders(cards('choam'), players), ['holder']);
assert.equal(guildBetrayalEligible(discardNexusCard(inventory, 'holder', players), players, 'holder'), false);
});
void test('full original fee requires existing own funds or authorized prepaid ally escrow, never a later refund', () => {
quoteGuildBetrayalFunding({ ...authority(), players });
quoteGuildBetrayalFunding({ ...authority({ ownSpice: 0, allyPayment: 8, allyEscrow: 8 }), players });
quoteGuildBetrayalFunding({ ...authority({ price: Number.MAX_SAFE_INTEGER,
ownSpice: Number.MAX_SAFE_INTEGER, allyPayment: 0, allyEscrow: 0, donor: null }), players });
const invalid: Partial<GuildBetrayalAuthority>[] = [
{ price: 0 }, { price: -1 }, { price: 0.5 }, { price: NaN }, { price: Infinity },
{ price: Number.MAX_SAFE_INTEGER + 1 }, { ownSpice: 4 }, { ownSpice: -1 },
{ ownSpice: Infinity }, { allyEscrow: 2 }, { allyEscrow: -1 },
{ allyPayment: 9 }, { allyPayment: -1 }, { allyPayment: 1.5 },
{ donor: 'holder' }, { donor: 'shipper' }, { donor: 'outsider' }, { donor: null },
{ allyPayment: 0 },
];
for (const patch of invalid) {
const quote = { ...authority(patch), players };
const before = JSON.stringify(quote);
assert.throws(() => quoteGuildBetrayalFunding(quote));
assert.equal(JSON.stringify(quote), before);
}
// An unallied holder paying its own invoice cannot borrow the future receipt.
const own = authority({ shipper: 'holder', ownSpice: 7, allyPayment: 0, allyEscrow: 0, donor: null });
assert.throws(() => createGuildBetrayalInvoice(context, own, 1));
const funded = createGuildBetrayalInvoice(context, { ...own, ownSpice: 8 }, 1);
assert.equal(funded.ownPayment, 8);
assert.equal(completed(funded).invoice.price, 8);
});
void test('a re-signed saved invoice cannot authorize its own stale price, route, payer or escrow', () => {
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
const native = authority({ source });
const invoice = createGuildBetrayalInvoice(context, native, 1);
validateGuildBetrayalInvoice(context, invoice, native, 1);
for (const patch of [
{ price: 7, ownPayment: 4 }, { sourceSignature: 'different-native-custody' },
{ source: source === 'homeworld' ? 'reserve' as const : 'homeworld' as const },
{ ownSpice: 8 }, { allyEscrow: 7 }, { originalReceiver: null },
{ shipper: 'donor' },
]) {
const changed = { ...invoice, ...patch };
changed.event = nexusGuildBetrayalEvent(changed.turn, changed.sequence, changed.shipper);
changed.signature = signGuildBetrayalInvoice(changed);
const before = JSON.stringify(changed);
assert.throws(() => validateGuildBetrayalInvoice(context, changed, native, 1));
assert.equal(JSON.stringify(changed), before);
}
assert.throws(() => validateGuildBetrayalInvoice({ ...context, turn: 3 }, invoice, native, 1));
assert.throws(() => validateGuildBetrayalInvoice(context, invoice, native, 2));
assert.throws(() => validateGuildBetrayalInvoice(context, { ...invoice, extra: true } as never, native, 1));
const brokenAlliance = players.map(player => ({ ...player, ally: null }));
assert.throws(() => validateGuildBetrayalInvoice({ ...context, players: brokenAlliance }, invoice, native, 1));
}
});
void test('independent cursor rejects incomplete, orphan, stale, duplicate and foreign completed sources immutably', () => {
const invoice = createGuildBetrayalInvoice(context, authority(), 1);
const receipt = completed(invoice);
const pending = cursor(invoice, 'pending');
const settled = cursor(invoice, 'completed');
validateGuildBetrayalHistory(context, [], pending, invoice);
validateGuildBetrayalHistory(context, [receipt], settled, null);
const invalid: [GuildBetrayalReceipt[], GuildBetrayalCursor, GuildBetrayalInvoice | null][] = [
[[], pending, null], [[], settled, null], [[receipt], pending, invoice],
[[receipt], settled, invoice], [[receipt, receipt], settled, null],
[[receipt], { ...settled, sourceSignature: 'foreign-source' }, null],
[[receipt], { ...settled, turn: 1 }, null],
[[receipt], { ...settled, event: 'other-event' }, null],
[[receipt], { ...settled, sequence: 2 }, null],
[[receipt], { ...settled, extra: true } as never, null],
[[], { sequence: 0, status: 'completed', event: null, turn: null, sourceSignature: null }, invoice],
];
for (const [history, savedCursor, frame] of invalid) {
const before = JSON.stringify([history, savedCursor, frame]);
assert.throws(() => validateGuildBetrayalHistory(context, history, savedCursor, frame));
assert.equal(JSON.stringify([history, savedCursor, frame]), before);
}
assert.throws(() => createGuildBetrayalReceipt({ ...context, turn: 3 }, invoice, {
recipient: null, holderFaction: null, holderUnallied: null, nexusDiscardIndex: null,
sourceReceipt: nexusGuildBetrayalSourceReceipt(invoice),
}));
for (const patch of [
{ recipient: 'guild', holderFaction: 'guild' as const },
{ recipient: 'outsider' }, { holderFaction: 'emperor' as const },
{ holderUnallied: false }, { card: null }, { nexusDiscardIndex: -1 },
{ sourceReceipt: '' }, { sourceReceipt: 'foreign-completed-source' },
]) {
const corrupt = { ...receipt, ...patch };
corrupt.signature = signGuildBetrayalReceipt(corrupt);
assert.throws(() => validateGuildBetrayalHistory(context, [corrupt], settled, null));
}
assert.throws(() => validateGuildBetrayalHistory(context, [receipt], null as never, null));
});
void test('completed source facts survive later legitimate alliances, Nexus recycling and a second native invoice', () => {
const first = createGuildBetrayalInvoice(context, authority(), 1);
const receipt = completed(first);
const laterPlayers = players.map(player => player.id === 'holder' ? { ...player, ally: 'guild' }
: player.id === 'guild' ? { ...player, ally: 'holder' }
: { ...player, ally: null });
const later = { turn: 4, players: laterPlayers };
const ownNative = authority({ shipper: 'shipper', price: 2, ownSpice: 2,
allyPayment: 0, allyEscrow: 0, donor: null, source: 'homeworld',
sourceSignature: 'later-physical-source', originalReceiver: null });
const second = createGuildBetrayalInvoice(later, ownNative, 2);
validateGuildBetrayalHistory(later, [receipt], cursor(second, 'pending'), second);
const pass = createGuildBetrayalReceipt(later, second, {
recipient: null, holderFaction: null, holderUnallied: null, nexusDiscardIndex: null,
sourceReceipt: nexusGuildBetrayalSourceReceipt(second),
});
validateGuildBetrayalHistory(later, [receipt, pass], cursor(second, 'completed'), null);
// Recycled discard position may shrink; original historic holder need not remain unallied.
const recycled = createGuildBetrayalReceipt({ turn: 4, players }, second, {
recipient: 'holder', holderFaction: 'atreides', holderUnallied: true, nexusDiscardIndex: 0,
sourceReceipt: nexusGuildBetrayalSourceReceipt(second),
});
validateGuildBetrayalHistory(later, [receipt, recycled], cursor(second, 'completed'), null);
assert.equal(guildBetrayalEligible(cards(), laterPlayers, 'holder'), false);
});
+36
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import {
applyAction,
createGame,
joinGame,
newPlayer,
@@ -329,3 +330,38 @@ for (const advanced of [false, true]) {
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
for (const advanced of [false, true]) {
for (const homeworlds of [false, true]) {
void test(`Guild Betrayal ${advanced ? 'Advanced' : 'Basic'} ${homeworlds ? 'Homeworld' : 'base'} entry preserves explicit modules and rejects redealing without touching other saves`, (t) => {
const { db } = fixture(t);
let game = createGame('PROTOTYP', newPlayer('g', 'Guild', 'guild'), advanced);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
if (homeworlds)
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
db.prepare('UPDATE seats SET player_id=? WHERE room_code=?').run('g', game.code);
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(game.code, 'e', 'unchanged-emperor-session');
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const result = startPrototypeRoom(db, game.code, 7, 'guild-betrayal');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(result.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.guildBetrayalPreview, true);
assert.equal(!!saved.homeworlds, homeworlds);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort((a,b) => a.localeCompare(b)),
[...NEXUS_FACTIONS].sort((a,b) => a.localeCompare(b)));
assert.deepEqual(saved.expansions, []);
for (const p of game.players)
assert.equal(saved.players.find(seat => seat.id === p.id)!.name, p.name);
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'guild-betrayal'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'guild-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
}
+17
View File
@@ -11,6 +11,7 @@ import {
import { tableActionOwner } from '../game/table-turn';
import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull';
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
function battle(advanced = false) {
let g = createGame(
@@ -235,3 +236,19 @@ void test('neutral Richese Betrayal acknowledgement preempts an underlying buyer
}
}
});
void test('neutral Guild Betrayal acknowledgement has no sole turn owner for own or another payer', () => {
for (const payer of ['holder', 'other'] as const) {
const fixture = createGuildBetrayalFixture({ payer });
for (const player of fixture.game.players) {
const view = viewGame(fixture.game, player.id);
assert.ok(view.guildBetrayalReaction);
assert.equal(tableActionOwner(view), null);
}
const settled = applyAction(fixture.game, fixture.holder, {
type: 'guildBetrayalUse', event: fixture.event,
});
for (const player of settled.players)
assert.equal(tableActionOwner(viewGame(settled, player.id)), fixture.shipper);
}
});
+7
View File
@@ -11,6 +11,7 @@ import {
initializeKullGameForAudit,
initializeRicheseBetrayalGameForAudit,
initializeNexusKullGameForAudit,
initializeGuildBetrayalGameForAudit,
viewGame,
type Game,
} from '../game/engine';
@@ -27,6 +28,7 @@ export const PROTOTYPE_PROFILES = [
'kull',
'richese-betrayal',
'nexus-kull',
'guild-betrayal',
] as const;
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
export function isPrototypeProfile(value: string): value is PrototypeProfile {
@@ -87,6 +89,11 @@ export function startPrototypeRoom(
...initial,
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
})
: profile === 'guild-betrayal'
? initializeGuildBetrayalGameForAudit({
...initial,
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
})
: profile === 'factions'
? initializeFactionExpansionsGameForAudit(initial)
: profile === 'nexus'
+1 -1
View File
@@ -20,7 +20,7 @@ async function main() {
});
if (values.help) {
console.log(
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
);
return;
}