Connect bounded Fremen Nexus worm-riding Betrayal
This commit is contained in:
1 parent
66a427ab55
commit
54cbf4d06f
14 files changed
+457
-55
No files matched your search
@@ -3581,6 +3581,14 @@ export function GameTable({
|
||||
: ' This blow is closed to Harvester.'}
|
||||
</p>
|
||||
)}
|
||||
{g.phase === 1 &&
|
||||
g.players.some((player) => player.faction === 'fremen' && player.fremenNexusWormBlocked) && (
|
||||
<p className="notice">
|
||||
Fremen Nexus Betrayal was spent before the spice blow.
|
||||
Fremen cannot ride worms this turn; ordinary worm effects
|
||||
and Nexus negotiations still resolve.
|
||||
</p>
|
||||
)}
|
||||
{g.phase === 1 && g.nexus && !g.spiceWindow && (
|
||||
<>
|
||||
<p className="muted">
|
||||
|
||||
@@ -16,11 +16,17 @@ export function NexusFremenBetrayal({ game, act, busy }: Props) {
|
||||
<section aria-label="Fremen Nexus Betrayal" className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<h3>Fremen Nexus · Betrayal</h3>
|
||||
<p>{nexusCardReference('fremen').betrayal}</p>
|
||||
{fremen && <p className="fine">This bounded use suppresses {fremen.name}’s native two-territory movement advantage for the rest of this turn. Independent ornithopters remain available. Suppressing worm riding is not yet connected.</p>}
|
||||
{fremen && <p className="fine">
|
||||
{offer.mode === 'worm'
|
||||
? `Declare before the first spice blow: ${fremen.name} cannot take ordinary or Cunning worm rides this turn. Worm destruction, survival, protection and Nexus still resolve.`
|
||||
: `This use suppresses ${fremen.name}’s native two-territory movement advantage for the rest of this turn. Independent ornithopters remain available.`}
|
||||
</p>}
|
||||
{offer.blocked && <p className="notice">{offer.blocked}</p>}
|
||||
<Button className="game-action min-h-11 whitespace-normal" disabled={busy || !action}
|
||||
onClick={() => { if (!busy && action) act(action); }}>
|
||||
Use Betrayal: suppress Fremen movement range
|
||||
{offer.mode === 'worm'
|
||||
? 'Use Betrayal: prevent worm riding this turn'
|
||||
: 'Use Betrayal: suppress Fremen movement range'}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
|
||||
+10
-8
@@ -106,14 +106,16 @@ protection and complete Nexus games remain gated. The bounded Atomics reveal
|
||||
is a separate classic-plus-Moritani path, not a playable Nexus Terror effect;
|
||||
its later-alliance hand-limit policy is unresolved.
|
||||
|
||||
The bounded [Fremen Nexus Betrayal movement alternative](NEXUS_FREMEN_RULES.md#bounded-betrayal-movement-range)
|
||||
lets an unallied holder spend the physical card before Fremen move in
|
||||
Shipment and Movement. The native two-territory advantage stays suppressed
|
||||
through that turn, while independent ornithopters and ordinary moves remain
|
||||
legal. Owner controls, a prioritized legal bot play, Basic/Advanced JSON
|
||||
continuation and an authenticated concurrent SQLite CAS test are connected.
|
||||
The card's separate worm-riding suppression alternative and combined modules
|
||||
remain unfinished; no public Nexus gate changes.
|
||||
The bounded [Fremen Nexus Betrayal alternatives](NEXUS_FREMEN_RULES.md#bounded-betrayal-movement-and-worm-riding)
|
||||
let an unallied holder spend the physical card either before the first Spice
|
||||
Blow to prevent Fremen worm rides for that turn, or before Fremen move in
|
||||
Shipment and Movement to suppress their native two-territory advantage.
|
||||
Worm destruction/protection and Nexus still resolve; independent movement
|
||||
bonuses remain available. One saved card cannot use both modes, even if
|
||||
recycled later that turn. Owner controls, prioritized legal bot play,
|
||||
Basic/Advanced JSON and authenticated concurrent SQLite continuation are
|
||||
connected. Post-appearance Betrayal timing and combined modules remain gated;
|
||||
no public Nexus gate changes.
|
||||
|
||||
The bounded [Ecaz Nexus Betrayal](NEXUS_ECAZ_RULES.md#bounded-betrayal-runtime)
|
||||
path lets a card holder select one public territory shared by Ecaz and its
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 29 September 2026 — Fremen Nexus Betrayal pre-blow worm suppression
|
||||
|
||||
The second printed Betrayal alternative now spends the physical Fremen Nexus
|
||||
card at a clean phase-one boundary **before the first spice blow or worm**.
|
||||
For that turn ordinary Fremen worm rides and native Cunning remote rides do
|
||||
not open; worm destruction, survival/protection, spice placement and Nexus
|
||||
still resolve. This advance-declaration timing is a conservative product
|
||||
boundary, not a publisher priority ruling or a reactive post-appearance use.
|
||||
The existing phase-five movement-range alternative remains independent; a
|
||||
card recycled at the current turn's Nexus cannot activate both alternatives.
|
||||
|
||||
Sixteen focused Basic/Advanced, actual-worm, empty-Cunning, late-rejection,
|
||||
owner/rival control, all-profile bot-priority and authenticated concurrent
|
||||
SQLite continuation cases passed. A direct non-test third-turn worm smoke
|
||||
showed one spent card, a real Nexus, four surviving Fremen forces and no ride.
|
||||
A read-only browser preview rendered the owner-only pre-blow button with no
|
||||
rival or spent-card control; no room was written. Independent review found
|
||||
that the first movement-only checkpoint's six-field receipt would have
|
||||
locked saved seats on direct GET. Its original event/signature are now
|
||||
validated alongside new mode-bound receipts; JSON and authenticated SQL
|
||||
reads/actions accept genuine old records and reject tampering. The
|
||||
[source-bound Fremen contract](NEXUS_FREMEN_RULES.md#bounded-betrayal-movement-and-worm-riding)
|
||||
marks the timing and combined-mode limits. Typecheck, lint, the app build,
|
||||
**5,756/5,756 offline tests** and **54/54 local HTTP integration cases**
|
||||
passed. This does not open public Nexus starts or prove deployment.
|
||||
|
||||
## 29 September 2026 — bounded Moritani Atomics Aftermath
|
||||
|
||||
In classic-plus-Moritani tables without Guild, Ecaz or optional modules, a
|
||||
|
||||
@@ -12,7 +12,7 @@ The [November 2020 FAQ, pp.2–3](https://www.gf9games.com/dune/wp-content/uploa
|
||||
|
||||
The [E1 FAQ, p.11](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=11) describes Sandtrout's anti-Nexus and immediate replacement-card behavior. Its Karama table distinguishes destroying Fremen at a worm from stopping additional-worm placement. The [E3 rules, pp.11–12 and 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=12), reread in the cached publisher-authored PDF text and fresh publisher-indexed excerpts, give common Nexus timing and distinguish Great Maker's reserve ride from ordinary board-origin riding. Great Maker remains a separate integration boundary.
|
||||
|
||||
## Bounded Betrayal: movement range
|
||||
## Bounded Betrayal: movement and worm riding
|
||||
|
||||
An unallied non-Fremen holder of the physical Fremen Nexus card may spend it
|
||||
during Shipment and Movement **before Fremen move** to suppress their native
|
||||
@@ -28,10 +28,29 @@ physical card enters the Nexus discard once; a saved receipt and player turn
|
||||
marker preserve suppression through JSON/SQLite recovery and reject replay or
|
||||
malformed ownership. The public movement view shows the restriction without
|
||||
revealing who held the card before play. All four AI profiles can spend the
|
||||
card before an active Fremen bot moves; the owner control labels this
|
||||
alternative and keeps the printed worm-riding suppression alternative visibly
|
||||
unavailable. The latter is **not** covered by this prototype. This does not
|
||||
open the complete Nexus module or public expansion starts.
|
||||
card before an active Fremen bot moves; the owner control labels the currently
|
||||
available alternative.
|
||||
|
||||
The same unallied holder may instead spend that one physical card at a clean
|
||||
**pre-first-blow** Spice Blow and Nexus boundary. For the remainder of that
|
||||
turn, Fremen cannot take ordinary or native Cunning worm rides. Worm
|
||||
destruction, survival, allied protection, spice placement, additional worm
|
||||
appearances and Nexus negotiations still resolve. The card must be declared
|
||||
before any worm or spice blow, including a special-Karama summon; this
|
||||
conservative advance-declaration window avoids a holder-dependent hidden
|
||||
reaction, but is a product timing choice rather than an explicit printed
|
||||
priority ruling. No reactive post-appearance Betrayal is claimed.
|
||||
|
||||
Both alternatives use one mode-bound saved receipt, public turn marker and
|
||||
physical discard. A later Nexus draw may recycle that card during the same
|
||||
turn; even if it returns to a hand, the second alternative is unavailable.
|
||||
Validated six-field movement-only receipts from the previous checkpoint
|
||||
remain readable and executable after JSON or SQL restoration; malformed old
|
||||
events or signatures still reject. No game reset or public backfill is needed.
|
||||
Basic/Advanced natural appearances, empty-worm Cunning exclusion, actual
|
||||
saved worm continuation, duplicate authenticated SQL action and all four bot
|
||||
profiles have focused checks. The module remains release-gated; Great Maker,
|
||||
Sandtrout, combined expansions and after-appearance timing are separate.
|
||||
|
||||
## Cunning: source-backed scope and implementation composition
|
||||
|
||||
|
||||
+1
-1
@@ -4050,7 +4050,7 @@ export function runBots(state: Game, limit = 96): Game {
|
||||
? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'ecaz' &&
|
||||
ecazBetrayalOffer(g, actor.id)?.blocked === null)
|
||||
: undefined;
|
||||
const fremenHolder = g.phase === 5 && g.nexusCards?.cards
|
||||
const fremenHolder = (g.phase === 1 || g.phase === 5) && g.nexusCards?.cards
|
||||
? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'fremen' &&
|
||||
fremenBetrayalOffer(g, actor.id)?.blocked === null)
|
||||
: undefined;
|
||||
|
||||
+46
-17
@@ -57,7 +57,7 @@ import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceip
|
||||
import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani';
|
||||
import { moritaniBetrayalOffer } from './nexus-moritani-betrayal';
|
||||
import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal';
|
||||
import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal } from './nexus-fremen-betrayal';
|
||||
import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal, type StoredFremenNexusBetrayal } from './nexus-fremen-betrayal';
|
||||
import { atomicsAllianceChangeBlocked, atomicsAllianceStatus, atomicsShipmentBlocked, quoteMoritaniAtomics, type AtomicsAftermath, type AtomicsQuote } from './moritani-atomics';
|
||||
import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location';
|
||||
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
|
||||
@@ -592,6 +592,7 @@ export type Player = {
|
||||
ixMovementBlocked?: { turn: number; move: number };
|
||||
fremenMovementBlocked?: { turn: number; move: number };
|
||||
fremenNexusMovementBlockedTurn?: number;
|
||||
fremenNexusWormBlockedTurn?: number;
|
||||
/** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */
|
||||
atomicsHandLimitPenalty?: boolean;
|
||||
elites?: {
|
||||
@@ -1569,8 +1570,8 @@ export type Game = {
|
||||
}[];
|
||||
/** A spent Ecaz card binds one exact allied force group until Karama settles. */
|
||||
nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[];
|
||||
/** A spent Fremen card suppresses the native two-territory advantage through its turn. */
|
||||
nexusFremenBetrayalHistory?: FremenNexusBetrayal[];
|
||||
/** Spent Fremen card receipts include the earlier movement-only saved form. */
|
||||
nexusFremenBetrayalHistory?: StoredFremenNexusBetrayal[];
|
||||
/** One actual Sardaukar battle advantage suppressed by a spent Emperor card. */
|
||||
nexusEmperorBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string }[];
|
||||
/** Original native Voice canceled by a spent Bene Gesserit Nexus card. */
|
||||
@@ -2135,41 +2136,60 @@ function nexusFremenBetrayalIntegrity(g: Game) {
|
||||
const history = g.nexusFremenBetrayalHistory;
|
||||
const fremen = byFaction(g, 'fremen');
|
||||
if (history === undefined) {
|
||||
requireRule(fremen?.fremenNexusMovementBlockedTurn === undefined,
|
||||
'Fremen movement suppression has no spent Nexus card receipt.');
|
||||
requireRule(fremen?.fremenNexusMovementBlockedTurn === undefined &&
|
||||
fremen?.fremenNexusWormBlockedTurn === undefined,
|
||||
'Fremen Betrayal restrictions require a spent Nexus card receipt.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && fremen && Array.isArray(history) &&
|
||||
history.length > 0, 'Fremen Betrayal has lost its physical card module.');
|
||||
const turns = new Set<number>();
|
||||
let latestMovement: number | undefined;
|
||||
let latestWorm: number | undefined;
|
||||
let lastMode: FremenNexusBetrayal['mode'] = 'movement';
|
||||
for (const record of history) {
|
||||
nexusRule(() => validateFremenBetrayal(g, record));
|
||||
const mode = nexusRule(() => validateFremenBetrayal(g, record));
|
||||
requireRule(!turns.has(record.turn),
|
||||
'Fremen Betrayal cannot suppress movement twice in one turn.');
|
||||
'Fremen Betrayal cannot suppress two advantages in one turn.');
|
||||
turns.add(record.turn);
|
||||
if (mode === 'worm') latestWorm = record.turn;
|
||||
else latestMovement = record.turn;
|
||||
lastMode = mode;
|
||||
}
|
||||
const last = history.at(-1)!;
|
||||
requireRule(fremen.fremenNexusMovementBlockedTurn === last.turn,
|
||||
'Fremen Betrayal has lost its turn-long movement suppression.');
|
||||
if (last.turn === g.turn)
|
||||
requireRule(g.nexusCards.cards.discard.includes('fremen'),
|
||||
'Fremen Betrayal has lost its spent physical card.');
|
||||
requireRule(fremen.fremenNexusMovementBlockedTurn === latestMovement &&
|
||||
fremen.fremenNexusWormBlockedTurn === latestWorm,
|
||||
'Fremen Betrayal has lost its turn-long restriction.');
|
||||
if (last.turn === g.turn) {
|
||||
requireRule(lastMode !== 'worm' || (g.nexusCards.phase?.turn === g.turn &&
|
||||
!g.wormRides.length && !g.nexusFremenCunningOffer &&
|
||||
!g.nexusFremenCunningRides?.some(row =>
|
||||
row.occurrence.turn === g.turn && row.stage !== 'complete')),
|
||||
'Fremen Betrayal cannot leave a current worm ride or Cunning offer.');
|
||||
if (lastMode === 'movement' || g.nexusCards.phase?.stage === 'spice')
|
||||
requireRule(g.nexusCards.cards.discard.includes('fremen'),
|
||||
'Fremen Betrayal has lost its spent physical card.');
|
||||
}
|
||||
}
|
||||
function playNexusFremenBetrayal(g: Game, p: Player, action: Action) {
|
||||
const offer = fremenBetrayalOffer(g, p.id);
|
||||
requireRule(offer && !offer.blocked && action.event === offer.event &&
|
||||
Object.keys(action).sort().join(',') === 'event,type',
|
||||
'Use Fremen Betrayal before Fremen movement begins.');
|
||||
'Use Fremen Betrayal before its current printed advantage begins.');
|
||||
const record: FremenNexusBetrayal = {
|
||||
event: offer.event, owner: p.id, target: offer.target, turn: g.turn,
|
||||
phase: 5, signature: '',
|
||||
phase: offer.mode === 'worm' ? 1 : 5, mode: offer.mode, signature: '',
|
||||
};
|
||||
record.signature = fremenBetrayalSignature(record);
|
||||
g.nexusCards!.cards = nexusRule(() =>
|
||||
discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
getPlayer(g, offer.target).fremenNexusMovementBlockedTurn = g.turn;
|
||||
const fremen = getPlayer(g, offer.target);
|
||||
if (offer.mode === 'worm') fremen.fremenNexusWormBlockedTurn = g.turn;
|
||||
else fremen.fremenNexusMovementBlockedTurn = g.turn;
|
||||
(g.nexusFremenBetrayalHistory ??= []).push(record);
|
||||
log(g, `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`,
|
||||
log(g, offer.mode === 'worm'
|
||||
? `${p.name} spent Fremen Nexus Betrayal before the first blow to suppress Fremen worm riding for this turn. Worm appearances and protection still resolve; ordinary and remote rides do not.`
|
||||
: `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`,
|
||||
{ faction: p.faction, name: 'Fremen Nexus Betrayal' });
|
||||
}
|
||||
function bgBetrayalWindow(g: Game) {
|
||||
@@ -6105,6 +6125,7 @@ function requireFreshSetup(g: Game, allowIxElites = false) {
|
||||
p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined &&
|
||||
p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined &&
|
||||
p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined &&
|
||||
p.fremenNexusWormBlockedTurn === undefined &&
|
||||
p.atomicsHandLimitPenalty === undefined && p.faceDancers === undefined &&
|
||||
p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
|
||||
}), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.');
|
||||
@@ -7413,7 +7434,8 @@ function devour(
|
||||
for (const k of Object.keys(g.spice))
|
||||
if (splitLocation(k).territory === t) delete g.spice[k];
|
||||
const fremen = byFaction(g, 'fremen');
|
||||
if (fremen && at(fremen, t)) g.wormRides.push(t);
|
||||
if (fremen && fremen.fremenNexusWormBlockedTurn !== g.turn && at(fremen, t))
|
||||
g.wormRides.push(t);
|
||||
log(g, `Shai-Hulud appeared in ${territory(t).name}.`);
|
||||
}
|
||||
function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summoned' = 'natural') {
|
||||
@@ -7429,6 +7451,7 @@ function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summo
|
||||
!g.techTokens && !g.strongholdCards &&
|
||||
g.players.every(seat => ['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen'].includes(seat.faction)) &&
|
||||
fremen && !fremen.ally && g.phase === 1 &&
|
||||
fremen.fremenNexusWormBlockedTurn !== g.turn &&
|
||||
Object.entries(fremen.forces).some(([key, amount]) => {
|
||||
if (amount <= 0) return false;
|
||||
const source = splitLocation(key);
|
||||
@@ -8021,6 +8044,7 @@ function nextWormRide(g: Game) {
|
||||
return;
|
||||
}
|
||||
const fremen = byFaction(g, 'fremen');
|
||||
if (fremen?.fremenNexusWormBlockedTurn === g.turn) g.wormRides.length = 0;
|
||||
while (g.wormRides.length) {
|
||||
const t = g.wormRides.shift()!;
|
||||
if (
|
||||
@@ -8038,6 +8062,10 @@ function nextWormRide(g: Game) {
|
||||
}
|
||||
for (const ride of g.nexusFremenCunningRides ?? []) {
|
||||
if (ride.stage !== 'queued' || ride.occurrence.turn !== g.turn) continue;
|
||||
if (fremen?.fremenNexusWormBlockedTurn === g.turn) {
|
||||
ride.stage = 'complete';
|
||||
continue;
|
||||
}
|
||||
const source = nexusRule(() => quoteFremenCunningRide(
|
||||
g, ride.authorization.owner, ride.occurrence, ride.authorization,
|
||||
));
|
||||
@@ -27734,6 +27762,7 @@ export function viewGame(state: Game, id: string) {
|
||||
(p.fremenMovementBlocked?.turn === g.turn &&
|
||||
p.fremenMovementBlocked.move === p.moved),
|
||||
fremenNexusMovementBlocked: p.fremenNexusMovementBlockedTurn === g.turn,
|
||||
fremenNexusWormBlocked: p.fremenNexusWormBlockedTurn === g.turn,
|
||||
ixMovementBlocked:
|
||||
p.ixMovementBlocked?.turn === g.turn &&
|
||||
p.ixMovementBlocked.move === p.moved,
|
||||
|
||||
@@ -6,45 +6,78 @@ export type FremenNexusBetrayal = {
|
||||
owner: string;
|
||||
target: string;
|
||||
turn: number;
|
||||
phase: 5;
|
||||
phase: 1 | 5;
|
||||
mode: 'worm' | 'movement';
|
||||
signature: string;
|
||||
};
|
||||
export const fremenBetrayalEvent = (turn: number, owner: string, target: string) =>
|
||||
JSON.stringify(['nexusFremenBetrayal', turn, owner, target]);
|
||||
/** The first movement-only checkpoint wrote this six-field physical receipt. */
|
||||
export type StoredFremenNexusBetrayal = FremenNexusBetrayal |
|
||||
(Omit<FremenNexusBetrayal, 'mode' | 'phase'> & { phase: 5; mode?: never });
|
||||
export type FremenBetrayalOffer = {
|
||||
event: string;
|
||||
blocked: string | null;
|
||||
target: string;
|
||||
mode: FremenNexusBetrayal['mode'];
|
||||
};
|
||||
export const fremenBetrayalEvent = (
|
||||
turn: number, owner: string, target: string, mode: FremenNexusBetrayal['mode'],
|
||||
) => JSON.stringify(['nexusFremenBetrayal', turn, owner, target, mode]);
|
||||
export const fremenBetrayalSignature = (record: FremenNexusBetrayal) =>
|
||||
JSON.stringify({ ...record, signature: undefined });
|
||||
|
||||
/** Clean pre-movement use prevents retroactive suppression and never tests
|
||||
* secret card custody of another seat when constructing public turn state. */
|
||||
export function fremenBetrayalOffer(g: Game, owner: string, automaticPending = false) {
|
||||
/** Clean advance declarations avoid a holder-dependent worm interruption
|
||||
* while preserving both mutually exclusive printed alternatives. */
|
||||
export function fremenBetrayalOffer(
|
||||
g: Game, owner: string, automaticPending = false,
|
||||
): FremenBetrayalOffer | null {
|
||||
const holder = g.players.find(p => p.id === owner);
|
||||
const fremen = g.players.find(p => p.faction === 'fremen');
|
||||
if (!holder || !fremen || g.nexusCards?.cards?.hands[owner] !== 'fremen' ||
|
||||
holder.faction === 'fremen') return null;
|
||||
const mode = g.phase === 1 ? 'worm' : 'movement';
|
||||
let blocked: string | null = null;
|
||||
if (holder.ally) blocked = 'An allied player cannot use a Nexus card.';
|
||||
else if (g.status !== 'playing' || g.phase !== 5)
|
||||
blocked = 'Use Fremen Betrayal before Fremen move during Shipment and Movement.';
|
||||
else if (g.status !== 'playing' || (g.phase !== 1 && g.phase !== 5))
|
||||
blocked = 'Use Fremen Betrayal before the first Spice Blow or before Fremen movement.';
|
||||
else if (g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
|
||||
g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery ||
|
||||
g.techTokens || g.strongholdCards ||
|
||||
g.players.some(p => !['atreides','harkonnen','emperor','fremen','guild','beneGesserit'].includes(p.faction)))
|
||||
blocked = 'Combined expansion and module movement interactions remain unavailable.';
|
||||
blocked = 'Combined expansion and module interactions remain unavailable.';
|
||||
else if (nexusCleanPlayBlocked(g, automaticPending))
|
||||
blocked = 'Finish the current interaction before using Fremen Betrayal.';
|
||||
else if (!g.movementRemaining?.includes(fremen.id) || fremen.moved !== 0 ||
|
||||
else if (g.nexusFremenBetrayalHistory?.some(record => record.turn === g.turn))
|
||||
blocked = 'Fremen Betrayal already spent its one physical card this turn.';
|
||||
else if (mode === 'worm') {
|
||||
if (g.spiceSequence || g.spiceResolution || g.spiceWindow || g.nexus ||
|
||||
g.wormRides.length || g.summonedBeforeBlow || g.nexusFremenCunningOffer ||
|
||||
g.nexusFremenCunningRides?.some(row => row.stage !== 'complete'))
|
||||
blocked = 'Declare worm-riding prevention before the first worm or spice blow this turn.';
|
||||
} else if (!g.movementRemaining?.includes(fremen.id) || fremen.moved !== 0 ||
|
||||
fremen.fremenNexusMovementBlockedTurn === g.turn)
|
||||
blocked = 'Fremen have already moved or completed their turn.';
|
||||
return { event: fremenBetrayalEvent(g.turn,owner,fremen.id), blocked, target: fremen.id };
|
||||
return { event: fremenBetrayalEvent(g.turn, owner, fremen.id, mode),
|
||||
blocked, target: fremen.id, mode };
|
||||
}
|
||||
|
||||
export function validateFremenBetrayal(g: Game, record: FremenNexusBetrayal) {
|
||||
if (!record || Object.keys(record).sort().join(',') !== 'event,owner,phase,signature,target,turn' ||
|
||||
export function validateFremenBetrayal(
|
||||
g: Game, record: StoredFremenNexusBetrayal,
|
||||
): FremenNexusBetrayal['mode'] {
|
||||
const keys = record && Object.keys(record).sort().join(',');
|
||||
const legacy = keys === 'event,owner,phase,signature,target,turn';
|
||||
const mode = legacy ? 'movement' : record?.mode;
|
||||
if (!record ||
|
||||
(!legacy && keys !== 'event,mode,owner,phase,signature,target,turn') ||
|
||||
!Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn ||
|
||||
record.phase !== 5 || record.owner === record.target ||
|
||||
(mode !== 'worm' && mode !== 'movement') ||
|
||||
record.phase !== (mode === 'worm' ? 1 : 5) ||
|
||||
record.owner === record.target ||
|
||||
!g.players.some(p => p.id === record.owner && p.faction !== 'fremen') ||
|
||||
!g.players.some(p => p.id === record.target && p.faction === 'fremen') ||
|
||||
record.event !== fremenBetrayalEvent(record.turn,record.owner,record.target) ||
|
||||
record.signature !== fremenBetrayalSignature(record))
|
||||
throw new Error('Fremen Betrayal has lost its original movement restriction.');
|
||||
record.event !== (legacy
|
||||
? JSON.stringify(['nexusFremenBetrayal', record.turn, record.owner, record.target])
|
||||
: fremenBetrayalEvent(record.turn, record.owner, record.target, mode)) ||
|
||||
record.signature !== JSON.stringify({ ...record, signature: undefined }))
|
||||
throw new Error('Fremen Betrayal has lost its original turn-long restriction.');
|
||||
return mode;
|
||||
}
|
||||
+1
-1
@@ -4047,7 +4047,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
|
||||
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.',
|
||||
'Ecaz Cunning lets an unallied native holder spend the physical Nexus card at a quiet Battle boundary to take the existing living Duke Vidal for the turn, even from Moritani. The Duke can be selected through ordinary Battle plans and is set aside after use or at turn end. This first version is limited to paired Ecaz/Moritani with other classic factions and no combined optional modules; Advanced Harkonnen is excluded because the existing battle path cannot select Duke there. Taking Duke from the Tanks, capture or Ghola custody remains unimplemented. The Battle-only timing is a product boundary, not printed text.',
|
||||
'Fremen Betrayal currently supports its movement-range alternative for an unallied holder in a classic Nexus table. Spend the card before Fremen move during Shipment and Movement to suppress their native two-territory advantage for that turn; ordinary one-territory movement and independent ornithopters remain. The separate printed worm-riding prevention is not yet connected. The owner-only control shows this boundary and a spent card cannot be used again.',
|
||||
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
|
||||
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
|
||||
'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.',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action } from '../game/engine';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
|
||||
import {
|
||||
finishNexusSpice, nexusInventory, nexusPlayer, nexusReady, nexusTurnTwo,
|
||||
orderNexusSpice,
|
||||
enterNexusSpice, finishNexusSpice, nexusInventory, nexusPlayer, nexusReady,
|
||||
nexusTurnTwo, orderNexusSpice,
|
||||
} from './fixture-nexus-cards';
|
||||
|
||||
export const fremenBetrayalSource = 'red_chasm:7';
|
||||
@@ -76,3 +76,44 @@ export function fremenBetrayalFixture(
|
||||
assert.equal(offer.blocked, null);
|
||||
return { g, play: { type: 'nexusFremenBetrayal', event: offer.event } as Action };
|
||||
}
|
||||
|
||||
/** Preserve the genuinely drawn card into the next turn's clean pre-blow window. */
|
||||
export function fremenBetrayalWormFixture(
|
||||
advanced = false,
|
||||
ids: [string, string, string] = ['f', 'a', 'h'],
|
||||
) {
|
||||
const previous = fremenBetrayalFixture(advanced, ids);
|
||||
const g = enterNexusSpice(previous.g, true);
|
||||
assert.equal(g.phase, 1);
|
||||
const offer = viewGame(g, ids[2]).nexusFremenBetrayal!;
|
||||
assert.equal(offer.mode, 'worm');
|
||||
assert.equal(offer.blocked, null);
|
||||
return { g, play: { type: 'nexusFremenBetrayal', event: offer.event } as Action };
|
||||
}
|
||||
|
||||
/** Preserve the physical Spice Deck census while choosing a real next appearance. */
|
||||
export function fremenBetrayalNextWorm(g: Game, target: string) {
|
||||
const cards = [...g.spiceDeck, ...g.spiceDiscard.flat()];
|
||||
const landIndex = cards.findIndex(card => 'territory' in card && card.territory === target);
|
||||
assert.ok(landIndex >= 0, `Spice deck needs ${target}`);
|
||||
const land = cards.splice(landIndex, 1)[0];
|
||||
const wormIndex = cards.findIndex(card => 'worm' in card && !card.suppressed);
|
||||
assert.ok(wormIndex >= 0, 'Spice deck needs a natural worm');
|
||||
const worm = cards.splice(wormIndex, 1)[0];
|
||||
g.spiceDeck = [worm, ...cards];
|
||||
g.spiceDiscard = [[land], []];
|
||||
}
|
||||
|
||||
/** Recreate the exact six-field receipt persisted by the movement-only checkpoint. */
|
||||
export function useLegacyFremenMovementReceipt(g: Game) {
|
||||
const history = g.nexusFremenBetrayalHistory!;
|
||||
const record = history.at(-1)!;
|
||||
assert.equal(record.mode, 'movement');
|
||||
const legacy = {
|
||||
event: JSON.stringify(['nexusFremenBetrayal', record.turn, record.owner, record.target]),
|
||||
owner: record.owner, target: record.target, turn: record.turn,
|
||||
phase: 5 as const, signature: '',
|
||||
};
|
||||
legacy.signature = JSON.stringify({ ...legacy, signature: undefined });
|
||||
history[history.length - 1] = legacy;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { NexusFremenBetrayal } from '../components/nexus-fremen-betrayal';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { fremenBetrayalWormFixture } from './fixture-nexus-fremen-betrayal';
|
||||
|
||||
void test('pre-blow owner sees worm prevention while rival cannot see the held card or offer', () => {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
const owner = renderToStaticMarkup(createElement(NexusFremenBetrayal, {
|
||||
game: viewGame(g, 'h'), act() {}, busy: false,
|
||||
}));
|
||||
const rival = renderToStaticMarkup(createElement(NexusFremenBetrayal, {
|
||||
game: viewGame(g, 'f'), act() {}, busy: false,
|
||||
}));
|
||||
assert.match(owner, /Use Betrayal: prevent worm riding this turn/);
|
||||
assert.match(owner, /Worm destruction, survival, protection and Nexus still resolve/);
|
||||
assert.equal(rival, '');
|
||||
const spent = applyAction(g, 'h', play);
|
||||
assert.equal(renderToStaticMarkup(createElement(NexusFremenBetrayal, {
|
||||
game: viewGame(spent, 'h'), act() {}, busy: false,
|
||||
})), '');
|
||||
assert.equal(viewGame(spent, 'f').players.find(player => player.id === 'f')!.fremenNexusWormBlocked, true);
|
||||
});
|
||||
@@ -2,7 +2,11 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { nexusPlayer } from './fixture-nexus-cards';
|
||||
import { fremenBetrayalFixture, fremenBetrayalMovement } from './fixture-nexus-fremen-betrayal';
|
||||
import {
|
||||
fremenBetrayalFixture, fremenBetrayalMovement,
|
||||
fremenBetrayalNextWorm, fremenBetrayalWormFixture,
|
||||
useLegacyFremenMovementReceipt,
|
||||
} from './fixture-nexus-fremen-betrayal';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 45000, sleep: async () => {} };
|
||||
@@ -48,4 +52,74 @@ void test('concurrent authenticated Fremen Betrayal commits one physical card an
|
||||
await assert.rejects(store.restart().act(code, freshAuth, saved.version, fremenBetrayalMovement(2), clock));
|
||||
await assert.rejects(store.restart().act(code, auth[2], saved.version, play, clock));
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), stable);
|
||||
const legacy = await store.restart().readRoom(code);
|
||||
useLegacyFremenMovementReceipt(legacy);
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(legacy), legacy.version, code);
|
||||
const oldRow = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
|
||||
const recoveredView = await store.restart().readSeatView(code, freshAuth);
|
||||
assert.equal(recoveredView.players.find(player => player.id === fremen)!.fremenMovementBlocked, true);
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), oldRow);
|
||||
await store.restart().act(code, freshAuth, legacy.version, fremenBetrayalMovement(1), clock);
|
||||
const progressed = await store.restart().readRoom(code);
|
||||
assert.equal(nexusPlayer(progressed, fremen).moved, 1);
|
||||
});
|
||||
|
||||
void test('pre-blow Fremen Betrayal survives concurrent SQL spend and an actual worm without a ride', async t => {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const created = await store.rooms.createRoom('Fremen Worm SQL', 'fremen', false, []);
|
||||
const code = created.view.code;
|
||||
const atreides = await store.rooms.joinRoom(code, 'Atreides', 'atreides');
|
||||
const harkonnen = await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen');
|
||||
const tokens = [created.token, atreides.token!, harkonnen.token!];
|
||||
const auth = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
const ids = auth.map(seat => seat.playerId) as [string, string, string];
|
||||
const [fremen, , holder] = ids;
|
||||
const { g, play } = fremenBetrayalWormFixture(false, ids);
|
||||
fremenBetrayalNextWorm(g, 'red_chasm');
|
||||
g.code = code;
|
||||
g.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
store.writes.length = 0;
|
||||
assert.equal((await store.rooms.readSeatView(code, auth[0])).nexusFremenBetrayal, null);
|
||||
assert.equal((await store.rooms.readSeatView(code, auth[2])).nexusFremenBetrayal?.mode, 'worm');
|
||||
const outcomes = await Promise.allSettled([
|
||||
store.restart().act(code, auth[2], g.version, play, clock),
|
||||
store.restart().act(code, auth[2], g.version, play, clock),
|
||||
]);
|
||||
assert.equal(outcomes.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(outcomes.filter(result => result.status === 'rejected').length, 1);
|
||||
let saved = await store.restart().readRoom(code);
|
||||
assert.equal(saved.nexusFremenBetrayalHistory?.[0].mode, 'worm');
|
||||
assert.equal(nexusPlayer(saved, fremen).fremenNexusWormBlockedTurn, saved.turn);
|
||||
assert.equal(saved.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
for (let i = 0; i < auth.length; i++) {
|
||||
if (!saved.ready.includes(auth[i].playerId)) {
|
||||
await store.restart().act(code, auth[i], saved.version, { type: 'ready' }, clock);
|
||||
saved = await store.restart().readRoom(code);
|
||||
}
|
||||
}
|
||||
for (let step = 0; saved.response && step < 8; step++) {
|
||||
const id = saved.players.find(player => !saved.response!.passed.includes(player.id))!.id;
|
||||
const index = auth.findIndex(seat => seat.playerId === id);
|
||||
await store.restart().act(code, auth[index], saved.version, { type: 'passResponse' }, clock);
|
||||
saved = await store.restart().readRoom(code);
|
||||
}
|
||||
assert.equal(saved.nexus, true);
|
||||
assert.deepEqual(saved.wormRides, []);
|
||||
assert.notEqual(saved.decision?.kind, 'wormRide');
|
||||
assert.equal(saved.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(nexusPlayer(saved, fremen).forces['red_chasm:7'], 4);
|
||||
for (const token of tokens) {
|
||||
const seat = await store.restart().authenticate(code, token);
|
||||
const view = await store.restart().readSeatView(code, seat);
|
||||
assert.equal(view.players.find(player => player.id === fremen)!.fremenNexusWormBlocked, true);
|
||||
assert.equal(view.nexusFremenBetrayal, null);
|
||||
}
|
||||
const before = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
|
||||
await assert.rejects(store.restart().act(code, auth[2], saved.version, play, clock));
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), before);
|
||||
assert.equal(saved.nexusCards!.cards!.hands[holder], null);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, normalizeAutomaticGame, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
finishNexusSpice, nexusAllow, nexusInventory, nexusPlayer, nexusReady, nexusReload,
|
||||
} from './fixture-nexus-cards';
|
||||
import { fremenBetrayalNextWorm as nextWorm, fremenBetrayalWormFixture } from './fixture-nexus-fremen-betrayal';
|
||||
|
||||
const fremen = 'f';
|
||||
const holder = 'h';
|
||||
function rejected(g: Game, id: string, action: Action, pattern?: RegExp) {
|
||||
const before = structuredClone(g);
|
||||
if (pattern) assert.throws(() => applyAction(g, id, action), pattern);
|
||||
else assert.throws(() => applyAction(g, id, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} pre-blow Betrayal prevents native ride but retains worm destruction and Nexus`, () => {
|
||||
const { g, play } = fremenBetrayalWormFixture(advanced);
|
||||
assert.equal(viewGame(g, holder).nexusFremenBetrayal?.mode, 'worm');
|
||||
assert.equal(viewGame(g, fremen).nexusFremenBetrayal, null);
|
||||
rejected(g, fremen, play);
|
||||
rejected(g, holder, { ...play, event: 'stale' });
|
||||
nextWorm(g, 'red_chasm');
|
||||
let state = applyAction(nexusReload(g), holder, play);
|
||||
assert.equal(state.nexusCards!.cards!.hands[holder], null);
|
||||
assert.equal(state.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(nexusPlayer(state, fremen).fremenNexusWormBlockedTurn, state.turn);
|
||||
assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenNexusWormBlocked, true);
|
||||
nexusInventory(state);
|
||||
rejected(state, holder, play);
|
||||
state = nexusReady(nexusReload(state));
|
||||
if (state.response) state = nexusAllow(state);
|
||||
assert.equal(state.nexus, true);
|
||||
assert.equal(state.nexusCards!.phase!.occurred, true);
|
||||
assert.deepEqual(state.wormRides, []);
|
||||
assert.notEqual(state.decision?.kind, 'wormRide');
|
||||
assert.equal(state.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(nexusPlayer(state, fremen).forces['red_chasm:7'], 4);
|
||||
const restored = normalizeAutomaticGame(nexusReload(state));
|
||||
assert.equal(restored.nexusFremenBetrayalHistory?.[0].mode, 'worm');
|
||||
assert.equal(nexusPlayer(restored, fremen).fremenNexusWormBlockedTurn, restored.turn);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a later empty worm offers no remote Cunning ride while Betrayal is active', () => {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
nextWorm(g, 'hagga_basin');
|
||||
let state = applyAction(g, holder, play);
|
||||
state = nexusReady(state);
|
||||
if (state.response) state = nexusAllow(state);
|
||||
assert.equal(state.nexus, true);
|
||||
assert.equal(state.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(state.decision?.kind === 'nexusFremenCunningOffer', false);
|
||||
assert.deepEqual(state.wormRides, []);
|
||||
});
|
||||
|
||||
void test('an unspent holder cannot retroactively stop a worm after its appearance', () => {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
nextWorm(g, 'hagga_basin');
|
||||
let state = nexusReady(g);
|
||||
if (state.response) state = nexusAllow(state);
|
||||
assert.equal(state.nexus, true);
|
||||
assert.equal(viewGame(state, holder).nexusFremenBetrayal?.mode, 'worm');
|
||||
assert.match(String(viewGame(state, holder).nexusFremenBetrayal?.blocked), /before the first worm/);
|
||||
rejected(state, holder, play);
|
||||
assert.equal(state.nexusCards!.cards!.hands[holder], 'fremen');
|
||||
});
|
||||
|
||||
void test('post-blow play is too late, and a recycled current-turn card cannot suppress movement again', () => {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
nextWorm(g, 'hagga_basin');
|
||||
let state = applyAction(g, holder, play);
|
||||
state = nexusReady(state);
|
||||
if (state.response) state = nexusAllow(state);
|
||||
state = finishNexusSpice(state);
|
||||
assert.equal(state.nexusCards!.phase!.stage, 'drawing');
|
||||
// The physical card may be recycled during this phase's Nexus draw.
|
||||
const cards = state.nexusCards!.cards!;
|
||||
cards.discard = cards.discard.filter(card => card !== 'fremen');
|
||||
cards.deck.push('fremen');
|
||||
state = applyAction(state, holder, {
|
||||
type: 'nexusCardChoice', turn: state.turn, card: null, choice: 'keep', ownRedraws: 0,
|
||||
});
|
||||
assert.equal(state.nexusCards!.phase!.stage, 'complete');
|
||||
state.phase = 5;
|
||||
state.active = fremen;
|
||||
state.movementRemaining = [...state.order];
|
||||
nexusPlayer(state, fremen).moved = 0;
|
||||
const recycled = state.nexusCards!.cards!;
|
||||
const old = recycled.hands[holder];
|
||||
if (old) recycled.discard.push(old);
|
||||
recycled.deck = recycled.deck.filter(card => card !== 'fremen');
|
||||
recycled.hands[holder] = 'fremen';
|
||||
nexusInventory(state);
|
||||
assert.match(String(viewGame(state, holder).nexusFremenBetrayal?.blocked), /already spent/);
|
||||
rejected(state, holder, { type: 'nexusFremenBetrayal', event: play.event }, /before|current/);
|
||||
assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenNexusWormBlocked, true);
|
||||
});
|
||||
|
||||
void test('all four bot holders act before a Fremen bot readies the first blow', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
nexusPlayer(g, holder).bot = difficulty;
|
||||
nexusPlayer(g, fremen).bot = difficulty;
|
||||
assert.deepEqual(botActions(viewGame(g, holder))[0], play);
|
||||
const first = runBots(g, 1);
|
||||
assert.equal(first.nexusCards!.cards!.hands[holder], null);
|
||||
assert.equal(nexusPlayer(first, fremen).fremenNexusWormBlockedTurn, first.turn);
|
||||
assert.equal(first.ready.includes(fremen), false);
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { nexusInventory, nexusPlayer, nexusReload } from './fixture-nexus-cards'
|
||||
import {
|
||||
fremenBetrayalFixture as setup,
|
||||
fremenBetrayalMovement as movement,
|
||||
useLegacyFremenMovementReceipt,
|
||||
} from './fixture-nexus-fremen-betrayal';
|
||||
|
||||
const fremen = 'f';
|
||||
@@ -105,3 +106,24 @@ void test('changed saved turn, target or suppression rejects before advancing mo
|
||||
rejected(damaged, fremen, movement(1));
|
||||
}
|
||||
});
|
||||
|
||||
void test('validated movement-only receipts remain readable and executable after JSON restoration', () => {
|
||||
const { g, play } = setup();
|
||||
const played = applyAction(g, holder, play);
|
||||
useLegacyFremenMovementReceipt(played);
|
||||
const saved = nexusReload(played);
|
||||
assert.equal(viewGame(saved, fremen).players.find(p => p.id === fremen)!.fremenMovementBlocked, true);
|
||||
const restored = normalizeAutomaticGame(nexusReload(saved));
|
||||
assert.deepEqual(restored.nexusFremenBetrayalHistory, saved.nexusFremenBetrayalHistory);
|
||||
const moved = applyAction(restored, fremen, movement(1));
|
||||
assert.equal(nexusPlayer(moved, fremen).moved, 1);
|
||||
for (const corrupt of [
|
||||
(state: Game) => { state.nexusFremenBetrayalHistory![0].event = 'forged'; },
|
||||
(state: Game) => { state.nexusFremenBetrayalHistory![0].signature = 'forged'; },
|
||||
]) {
|
||||
const damaged = nexusReload(saved);
|
||||
corrupt(damaged);
|
||||
assert.throws(() => viewGame(damaged, fremen));
|
||||
rejected(damaged, fremen, movement(1));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user