Complete forced Harkonnen returns with private saved inspection
Automatically return the full combined hand when it is the only legal special-Karama return. Preserve real choices and restore the suspended response or auction payment through the same settlement. Record the actual blind draw as owner-only historical card faces, with a bound integrity receipt; legacy forced saves describe held-card history truthfully. Add readable private history and enlarged inspectors. Both manual and forced returns publish only a faction/action completion tag. Wake idle legacy exchanges through production recovery without writes for real choices. Keep provisional auction funding rules and release gates. Validation: types, lint, 5150 offline tests, build and 49 HTTP tests passed. Independent review resolved history substitution and root review completed idle recovery wiring. New tests cover 1-4 forced draws, real choices, overlays, exact saved history, immutable rejection, all four legal AI paths, production competing actions/recovery, private views and custody. One genuine Advanced four-player sample completed 158 actions with 4 JSON restores and no rejected action; no difficulty tuning. Browser QA QUTHVPTT used genuine Advanced setup and conserved Bidding positions. The actual power returned four cards without a prompt; private history/enlargement and exact refresh worked. Phone panel fit without horizontal overflow with 44px inspector controls. Screenshot capture timed out; complete visual acceptance and transient notice capture remain open. Preserved every prior row across all ten tables, including 1586 rooms and 3354 seats. New QA/integration records bring totals to 1626 rooms and 3442 seats. No reset, restart or automation.
This commit is contained in:
1 parent
a3bc0ac837
commit
0f79f28ccb
21 files changed
+847
-52
No files matched your search
@@ -112,6 +112,10 @@ for activation, background behavior and prototype limits.
|
||||
Bursts summarize older notices while retaining their details in the chronicle;
|
||||
see [completed-action feedback](docs/ACTION_FEEDBACK.md).
|
||||
|
||||
In Advanced development games, **Your Harkonnen inspection** preserves the cards
|
||||
seen during the special-Karama exchange. A return with only one legal selection
|
||||
completes automatically; see [private exchange history](docs/HARKONNEN_EXCHANGE.md).
|
||||
|
||||
## Verify changes
|
||||
|
||||
Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests
|
||||
|
||||
@@ -90,6 +90,7 @@ import { VictoryProgress } from './victory-progress';
|
||||
import { tableActionOwner } from '@/game/table-turn';
|
||||
import { DukeVidal } from './duke-vidal';
|
||||
import { CardInspector, CardRules } from './card-inspector';
|
||||
import { HarkonnenExchangeInspection } from './harkonnen-exchange-inspection';
|
||||
import { Recruits } from './recruits';
|
||||
import { ecazTreacheryDefinition } from '@/game/ecaz-cards';
|
||||
import { ordinaryCardAvailability } from '@/game/card-availability';
|
||||
@@ -4842,6 +4843,7 @@ export function GameTable({
|
||||
)}
|
||||
<Bribes game={g} act={act} busy={busy} />
|
||||
<TruthHistory game={g} />
|
||||
<HarkonnenExchangeInspection game={g} />
|
||||
<section className="player-console">
|
||||
<nav className="console-tabs">
|
||||
{(['hand', 'leaders', 'log', 'rules'] as const).map((p) => (
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { CardInspector, CardRules } from './card-inspector';
|
||||
|
||||
/** Historical observations arrive only in the inspecting player's projection. */
|
||||
export function HarkonnenExchangeInspection({ game }: { game: GameView }) {
|
||||
const record = game.harkonnenExchangeInspection;
|
||||
if (!record || record.owner !== game.me) return null;
|
||||
const target = game.players.find(player => player.id === record.target);
|
||||
return (
|
||||
<details className="notice harkonnen-exchange-inspection">
|
||||
<summary>Your Harkonnen inspection · Turn {record.turn}</summary>
|
||||
<p>
|
||||
{record.kind === 'draw'
|
||||
? `Cards you took and inspected from ${target?.name ?? 'the other player'} during Bidding.`
|
||||
: `Cards in your hand before the automatic return to ${target?.name ?? 'the other player'} during Bidding.`}
|
||||
{' '}This is a record of what you saw then. These cards may have changed hands since.
|
||||
</p>
|
||||
<ul className="grid list-none gap-3 p-0 sm:grid-cols-2">
|
||||
{record.cards.map(card => (
|
||||
<li key={card.id} className="min-w-0 rounded border border-white/15 p-3">
|
||||
<strong>{card.name}</strong>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -486,6 +486,7 @@ export function needsAutomaticRoomRecovery(
|
||||
state.decision?.kind === 'ecazAmbassador' ||
|
||||
state.decision?.kind === 'homeworldShipmentGuild' ||
|
||||
state.decision?.kind === 'fullPlanRead' ||
|
||||
state.decision?.kind === 'handExchange' ||
|
||||
state.decision?.kind === 'auctionPayment'
|
||||
);
|
||||
}
|
||||
|
||||
+14
-16
@@ -6,23 +6,21 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest delivered checkpoint: `7136c8d`, [Truthtrance physical-force questions](TRUTHTRANCE_FORCE_FACTS.md).
|
||||
Types, lint, **5,122 offline tests** and the production build passed; six
|
||||
complete Basic/Advanced samples had no rejected actions across 1,551 actions
|
||||
and 39 JSON restores. Browser compound questions, AI responses, mobile controls
|
||||
and exact human private-answer refresh were checked. All 1,584 prior rooms and
|
||||
3,352 seats remained intact; one QA room/seat brought the baseline to 1,585 rooms
|
||||
and 3,353 human seats. Full visual acceptance remains open.
|
||||
Latest delivered checkpoint: `a3bc0ac`, [completed-action feedback](ACTION_FEEDBACK.md).
|
||||
Types, lint, **5,137 offline tests** and the production build passed. Two complete
|
||||
Basic/Advanced samples had no rejected actions across 821 actions and 21 JSON
|
||||
restores. Browser burst notices, local interaction, muted updates and exact refresh
|
||||
were checked. All 1,585 prior rooms and 3,353 seats remained intact; one QA
|
||||
room/seat brought the baseline to 1,586 rooms and 3,354 human seats. Full visual
|
||||
acceptance remains open.
|
||||
|
||||
Current work connects [completed-action feedback](ACTION_FEEDBACK.md) for core
|
||||
movement, shipment, revival, collection and battle results. Existing cosmetic
|
||||
notices coalesce bursts into at most three visible/pending notices, with complete
|
||||
chronicle details retained; hidden pages clear and silently consume feedback.
|
||||
|
||||
A bounded follow-up candidate is the forced Harkonnen special-Karama return
|
||||
when every held card must go back. Automatic settlement must preserve a private
|
||||
saved record of the randomly inspected cards and the original interruption.
|
||||
This is unfinished; larger hands must keep their real return choice.
|
||||
Current work connects the [forced Harkonnen special-Karama return](HARKONNEN_EXCHANGE.md)
|
||||
when every held card must go back. Automatic settlement preserves a private saved
|
||||
record of the inspected cards and the original interruption. Larger hands keep
|
||||
their real return choice; legacy records identify held cards without inventing
|
||||
which were originally drawn. The server also schedules older saved exchanges for
|
||||
automatic recovery while preserving real choices. Independent review is complete;
|
||||
final source-bound checks and browser evidence accompany the Git checkpoint.
|
||||
|
||||
The [Sandmaster/HMS source comparison](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility)
|
||||
found no official clarification that passengers remaining in the interior
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Harkonnen special-Karama inspection and forced returns
|
||||
|
||||
The existing Advanced development power draws one through four random, unseen
|
||||
cards from another player during Bidding. Harkonnen spends its Karama and
|
||||
once-per-game use, inspects the combined hand, and returns the same number of
|
||||
cards. Newly drawn cards may be returned. Larger hands keep their real choice.
|
||||
|
||||
When the combined hand contains exactly the number owed, every held card must
|
||||
return. The engine completes that return automatically and emits a house-colored
|
||||
notice explaining the lack of a choice. A manual return also announces the
|
||||
completed exchange. The table's existing cosmetic toggle,
|
||||
reduced-motion behavior and short notice queue apply.
|
||||
|
||||
## Private saved observation
|
||||
|
||||
**Your Harkonnen inspection** below the board retains the actual randomly drawn
|
||||
cards, including their readable rules and enlarged inspectors. It identifies the
|
||||
target and turn and describes historical knowledge: the cards may have changed
|
||||
hands since. Only Harkonnen's owning seat receives this record. Allies, the target
|
||||
and other seats receive no inspection record or suspended private auction data.
|
||||
|
||||
The copied card descriptions are observations, outside physical card custody.
|
||||
They persist through ordinary returns, later card movement and JSON recovery.
|
||||
An internal integrity receipt binds the original card faces and provenance;
|
||||
changed history is rejected before disclosure. The receipt is not projected.
|
||||
Existing forced-return saves without a recorded draw retain a truthful snapshot
|
||||
of the cards held before returning them; the interface does not claim these were
|
||||
all originally drawn from the target. Legacy exchanges with a real choice keep
|
||||
their selector without inventing prior knowledge.
|
||||
|
||||
Both manual and automatic returns use the same settlement, restore the suspended
|
||||
decision/response and keep the existing auction funding recovery. Higher-priority
|
||||
interruptions finish first. Restoring a completed exchange must never redraw,
|
||||
repeat the return, spend a second Karama or consume the power twice.
|
||||
|
||||
## Authority and limits
|
||||
|
||||
This composes the existing contract recorded under **Emperor and Harkonnen
|
||||
special Karama checkpoint** in [implementation history](IMPLEMENTATION_STATUS.md):
|
||||
GF9 base rules page 14; November 2020 FAQ page 3 (phase and once-per-game limit)
|
||||
and page 6 (blind draw, inspection, equal return and temporary hand limits).
|
||||
Automatic resolution of a uniquely determined action follows the user's explicit
|
||||
interface requirement. Persisting information already inspected does not reveal
|
||||
the target's current hand.
|
||||
|
||||
The existing recovery when an exchange removes the only Karama backing an
|
||||
unfunded auction bid remains provisional. This checkpoint does not resolve that
|
||||
ruling or certify all timing and expansion combinations. Advanced and publication
|
||||
gates remain closed.
|
||||
|
||||
## Verification scope
|
||||
|
||||
Focused engine tests cover forced and real choices, saved interruption priority,
|
||||
legacy observations, private projection, rejected-action immutability, physical
|
||||
custody and minimal legal AI continuation. An authenticated in-memory SQL test
|
||||
races two real special-card actions, accepts one, reloads every seat and rejects
|
||||
stale replay without changing the inspection or cards. It also races two restored
|
||||
legacy continuations through the production recovery scheduler: one return is
|
||||
saved with no new random draw. Genuine choices remain waiting without writes.
|
||||
Component tests distinguish
|
||||
drawn cards from legacy held-card history and exclude non-owner output.
|
||||
|
||||
Required checks and browser evidence are recorded in the private source-bound
|
||||
checkpoint report. These checks establish bounded behavior, not full Advanced
|
||||
acceptance or AI difficulty calibration.
|
||||
|
||||
Browser QA used genuine Advanced setup in a new isolated development room,
|
||||
then conserved Bidding positions. Playing the real special-Karama control drew
|
||||
and returned four cards with no return prompt; bidding stayed available. The
|
||||
private history and enlarged Shield inspector survived refresh, which retained
|
||||
the exact saved row hash. At a 390-pixel viewport, the 339-pixel history panel
|
||||
fit within the 375-pixel document without horizontal overflow; each inspector
|
||||
button was 44 pixels high. The viewport was reset. Screenshot capture timed out,
|
||||
so these interaction and DOM checks do not establish full visual acceptance.
|
||||
@@ -1,5 +1,14 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 21 September 2026 — Harkonnen forced exchange return
|
||||
|
||||
Special-Karama exchanges now return automatically when the entire combined hand
|
||||
is owed. A private saved inspection retains the actual drawn card faces; legacy
|
||||
forced saves describe their held-card observation without inventing draw origin.
|
||||
Real choices remain interactive. Shared settlement preserves suspended responses,
|
||||
auction recovery and physical custody. See [scope and evidence](HARKONNEN_EXCHANGE.md).
|
||||
The existing unfunded-bid ruling and Advanced/publication gates remain open.
|
||||
|
||||
## 21 September 2026 — Completed-action feedback
|
||||
|
||||
Core committed movement, shipping, revival, collection and battle results now
|
||||
|
||||
@@ -6,6 +6,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
|
||||
|
||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||
| --- | --- | --- |
|
||||
| Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) |
|
||||
| Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) |
|
||||
| Ecaz Loyalty | Before initial Traitor dealing, randomly set aside one native Ecaz card face up permanently. No Karama effect; no change to its leader disc. Resolving after existing pre-Traitor setup is an explicit sequencing composition. Older dealt saves are not retrofitted; combined Skills/assassination rosters remain gated. | [Official sources and runtime](ECAZ_LOYALTY.md) |
|
||||
| Sapho battle order | First/last reorders remaining Battle-phase choosers at clean boundaries. Phase priority persists through aftermath; actual legal pairs determine eligibility. Separating the chooser from physical attacker/tie advantage is an explicit implementation interpretation of the card's distinct alternatives, not a dedicated FAQ ruling. Last does not prevent others from choosing the holder as an opponent. | [Runtime scope and source interpretation](SAPHO_BATTLE_ORDER.md) |
|
||||
|
||||
+141
-26
@@ -1496,6 +1496,16 @@ export type Game = {
|
||||
response: ResponseWindow | null;
|
||||
decision: Decision | null;
|
||||
} | null;
|
||||
/** Private knowledge copies, never an additional physical card zone. */
|
||||
harkonnenExchangeInspection?: {
|
||||
owner: string;
|
||||
target: string;
|
||||
turn: number;
|
||||
phase: 3;
|
||||
kind: 'draw' | 'return';
|
||||
cards: Card[];
|
||||
signature: string;
|
||||
};
|
||||
/** Purchase is already committed; only the Emperor's receipt remains pending. */
|
||||
pendingRichesePurchaseIncome?: {
|
||||
owner: string;
|
||||
@@ -8776,10 +8786,128 @@ function recordFullPlanInspection(g: Game, owner: string) {
|
||||
{ faction: p.faction, name: 'Full plan inspection' },
|
||||
);
|
||||
}
|
||||
function harkonnenInspectionSignature(
|
||||
inspection: Omit<NonNullable<Game['harkonnenExchangeInspection']>, 'signature'>,
|
||||
) {
|
||||
return JSON.stringify(['harkonnenExchangeInspection', inspection.owner, inspection.target,
|
||||
inspection.turn, inspection.phase, inspection.kind,
|
||||
inspection.cards.map(card => [card.id, card.name, card.kind, card.effect ?? null])]);
|
||||
}
|
||||
function recordHarkonnenInspection(g: Game, owner: string, target: string, kind: 'draw' | 'return', cards: Card[]) {
|
||||
const inspection = { owner, target, turn: g.turn, phase: 3 as const, kind, cards: structuredClone(cards) };
|
||||
g.harkonnenExchangeInspection = { ...inspection, signature: harkonnenInspectionSignature(inspection) };
|
||||
}
|
||||
function projectedHarkonnenInspection(g: Game, owner: string) {
|
||||
const inspection = g.harkonnenExchangeInspection;
|
||||
if (inspection?.owner !== owner) return null;
|
||||
return { owner: inspection.owner, target: inspection.target, turn: inspection.turn,
|
||||
phase: inspection.phase, kind: inspection.kind, cards: inspection.cards };
|
||||
}
|
||||
function harkonnenExchangeIntegrity(g: Game) {
|
||||
const inspection = g.harkonnenExchangeInspection;
|
||||
const physical = inspection || g.pendingExchange ? physicalTreacheryCards(g) : [];
|
||||
if (inspection !== undefined) {
|
||||
requireRule(
|
||||
inspection && typeof inspection === 'object' &&
|
||||
Object.keys(inspection).sort().join(',') === 'cards,kind,owner,phase,signature,target,turn' &&
|
||||
g.advanced &&
|
||||
g.players.some(p => p.id === inspection.owner && p.faction === 'harkonnen' && p.specialKaramaUsed === true) &&
|
||||
g.players.some(p => p.id === inspection.target && p.id !== inspection.owner) &&
|
||||
Number.isSafeInteger(inspection.turn) && inspection.turn >= 1 && inspection.turn <= g.turn &&
|
||||
inspection.phase === 3 && ['draw', 'return'].includes(inspection.kind) &&
|
||||
Array.isArray(inspection.cards) && inspection.cards.length >= 1 && inspection.cards.length <= 4 &&
|
||||
inspection.cards.every(card => card && typeof card === 'object' &&
|
||||
Object.keys(card).every(key => ['id', 'name', 'kind', 'effect'].includes(key)) &&
|
||||
typeof card.id === 'string' && typeof card.name === 'string' &&
|
||||
physical.filter(current => current.id === card.id && current.name === card.name &&
|
||||
current.kind === card.kind && current.effect === card.effect).length === 1) &&
|
||||
new Set(inspection.cards.map(card => card.id)).size === inspection.cards.length &&
|
||||
inspection.signature === harkonnenInspectionSignature(inspection),
|
||||
'The private Harkonnen exchange inspection is malformed.',
|
||||
);
|
||||
}
|
||||
const exchanges = homeworldSavedDecisions(g).filter(decision => decision.kind === 'handExchange');
|
||||
const pending = g.pendingExchange;
|
||||
if (!pending) {
|
||||
requireRule(!exchanges.length, 'The Harkonnen exchange has lost its suspended continuation.');
|
||||
return;
|
||||
}
|
||||
requireRule(
|
||||
g.status === 'playing' && g.advanced && g.phase === 3 &&
|
||||
Object.keys(pending).sort().join(',') === 'decision,response' &&
|
||||
exchanges.length === 1 && pending.decision?.kind !== 'handExchange' &&
|
||||
(pending.decision === null || (pending.decision && typeof pending.decision.kind === 'string' &&
|
||||
g.players.some(p => p.id === pending.decision!.player))) &&
|
||||
(pending.response === null || (pending.response && typeof pending.response.kind === 'string' &&
|
||||
g.players.some(p => p.id === pending.response!.owner) && Array.isArray(pending.response.passed) &&
|
||||
new Set(pending.response.passed).size === pending.response.passed.length &&
|
||||
pending.response.passed.every(id => g.players.some(p => p.id === id)))),
|
||||
'The saved Harkonnen exchange has an invalid Bidding continuation.',
|
||||
);
|
||||
const decision = exchanges[0];
|
||||
const owner = g.players.find(p => p.id === decision.player);
|
||||
const target = g.players.find(p => p.id === decision.target);
|
||||
requireRule(
|
||||
Object.keys(decision).sort().join(',') === 'count,kind,player,target' &&
|
||||
owner?.faction === 'harkonnen' && owner.specialKaramaUsed === true && target && target.id !== owner.id &&
|
||||
Number.isSafeInteger(decision.count) && decision.count >= 1 && decision.count <= 4 &&
|
||||
owner.hand.length >= decision.count && owner.hand.length - decision.count <= handLimit(owner) &&
|
||||
target.hand.length + decision.count <= handLimit(target),
|
||||
'The saved Harkonnen exchange has an invalid owner, target or return count.',
|
||||
);
|
||||
requireRule(
|
||||
[...owner.hand, ...target.hand].every(card => typeof card.id === 'string' &&
|
||||
physical.filter(current => current.id === card.id).length === 1),
|
||||
'The Harkonnen exchange requires distinct physical cards in their original custody.',
|
||||
);
|
||||
if (inspection) requireRule(
|
||||
inspection.owner === owner.id && inspection.target === target.id && inspection.turn === g.turn &&
|
||||
inspection.cards.length === decision.count,
|
||||
'The pending Harkonnen return differs from its private inspection.',
|
||||
);
|
||||
}
|
||||
function finishHarkonnenExchange(
|
||||
g: Game,
|
||||
decision: Extract<Decision, { kind: 'handExchange' }>,
|
||||
returnCards: unknown,
|
||||
automatic = false,
|
||||
) {
|
||||
const owner = getPlayer(g, decision.player), target = getPlayer(g, decision.target);
|
||||
requireRule(
|
||||
Array.isArray(returnCards) && returnCards.length === decision.count &&
|
||||
new Set(returnCards).size === decision.count &&
|
||||
returnCards.every(id => typeof id === 'string' && owner.hand.some(card => card.id === id)),
|
||||
'Return exactly the required number of distinct cards from your hand.',
|
||||
);
|
||||
const returned = owner.hand.filter(card => returnCards.includes(card.id));
|
||||
if (automatic && !g.harkonnenExchangeInspection) {
|
||||
// An old save records no original draw. Its forced return is still known exactly.
|
||||
recordHarkonnenInspection(g, owner.id, target.id, 'return', returned);
|
||||
}
|
||||
owner.hand = owner.hand.filter(card => !returnCards.includes(card.id));
|
||||
target.hand.push(...returned);
|
||||
requireRule(owner.hand.length <= handLimit(owner) && target.hand.length <= handLimit(target),
|
||||
'The completed exchange must respect hand limits.');
|
||||
const pending = g.pendingExchange!;
|
||||
g.pendingExchange = null;
|
||||
g.response = pending.response;
|
||||
g.decision = pending.decision;
|
||||
if (!g.response && g.decision?.kind === 'auctionPayment')
|
||||
recoverAuctionPayment(g, getPlayer(g, g.decision.player));
|
||||
log(g, `${owner.name} returned ${decision.count} cards to ${target.name}${automatic ? ' automatically; every held card was required' : ''}.`,
|
||||
{ faction: 'harkonnen', name: 'Harkonnen exchange' });
|
||||
}
|
||||
function finishAutomaticDecision(g: Game): boolean {
|
||||
if (g.biddingEnd && biddingEndQuiet(g)) return finishBiddingEnd(g);
|
||||
if (advanceHomeworldReveal(g)) return true;
|
||||
const decision = g.decision;
|
||||
if (decision?.kind === 'handExchange' && !g.response && !g.truthtrance &&
|
||||
!g.phaseOpening && !g.pendingNullentropy && !g.pendingRicheseGift && !g.pendingTreacheryDiscard &&
|
||||
getPlayer(g, decision.player).hand.length === decision.count) {
|
||||
harkonnenExchangeIntegrity(g);
|
||||
finishHarkonnenExchange(g, decision, getPlayer(g, decision.player).hand.map(card => card.id), true);
|
||||
return true;
|
||||
}
|
||||
if (homeworldShipmentAutomatic(g)) {
|
||||
const shipment = g.pendingHomeworldShipment!;
|
||||
g.decision = null;
|
||||
@@ -20187,6 +20315,10 @@ export function prepareSpecialKaramaIntent(
|
||||
);
|
||||
const target = getPlayer(g, stringField(action.target));
|
||||
requireRule(target.id !== p.id, 'Choose another player’s hand.');
|
||||
const physical = physicalTreacheryCards(g);
|
||||
requireRule([...p.hand, ...target.hand].every(held =>
|
||||
physical.filter(current => current.id === held.id).length === 1),
|
||||
'The Harkonnen exchange requires distinct physical cards in their original custody.');
|
||||
requireRule(
|
||||
g.pendingRicheseGift?.intent.owner !== target.id,
|
||||
'Resolve the reserved Richese gift before randomly exchanging that hand.',
|
||||
@@ -20481,6 +20613,7 @@ export function executeSpecialKaramaIntent(
|
||||
const taken = shuffle(target.hand).slice(0, count);
|
||||
target.hand = target.hand.filter((c) => !taken.some((t) => t.id === c.id));
|
||||
p.hand.push(...taken);
|
||||
recordHarkonnenInspection(g, p.id, target.id, 'draw', taken);
|
||||
g.pendingExchange = { response: g.response, decision: g.decision };
|
||||
g.response = null;
|
||||
g.decision = {
|
||||
@@ -20589,6 +20722,7 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
harkonnenExchangeIntegrity(state);
|
||||
validateEcazLoyalty(state);
|
||||
ecazTreacheryIntegrity(state);
|
||||
ixRicheseTechnologyIntegrity(state);
|
||||
@@ -20744,6 +20878,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
}
|
||||
}
|
||||
finishActionContinuations(g);
|
||||
harkonnenExchangeIntegrity(g);
|
||||
finishLeaderSkillCustody(g, state);
|
||||
leaderSkillsIntegrity(g);
|
||||
reconcileBattlePromises(g, { actor: id, action });
|
||||
@@ -20760,6 +20895,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
homeworldDefenseIntegrity(g);
|
||||
homeworldShipmentIntegrity(g);
|
||||
ixRicheseTechnologyIntegrity(g);
|
||||
harkonnenExchangeIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
function finishActionContinuations(g: Game) {
|
||||
@@ -20854,6 +20990,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
harkonnenExchangeIntegrity(state);
|
||||
validateEcazLoyalty(state);
|
||||
ecazTreacheryIntegrity(state);
|
||||
ixRicheseTechnologyIntegrity(state);
|
||||
@@ -20898,6 +21035,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
homeworldDefenseIntegrity(g);
|
||||
homeworldShipmentIntegrity(g);
|
||||
ixRicheseTechnologyIntegrity(g);
|
||||
harkonnenExchangeIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -22118,32 +22256,7 @@ function applyActionInner(
|
||||
'No matching paid search was saved; restore the saved search before continuing.',
|
||||
);
|
||||
} else if (decision.kind === 'handExchange') {
|
||||
requireRule(
|
||||
Array.isArray(action.returnCards) &&
|
||||
action.returnCards.length === decision.count &&
|
||||
new Set(action.returnCards).size === decision.count &&
|
||||
action.returnCards.every(
|
||||
(id) => typeof id === 'string' && p.hand.some((c) => c.id === id),
|
||||
),
|
||||
'Return exactly the required number of distinct cards from your hand.',
|
||||
);
|
||||
const target = getPlayer(g, decision.target);
|
||||
const returnIds = action.returnCards as string[];
|
||||
const returned = p.hand.filter((c) => returnIds.includes(c.id));
|
||||
p.hand = p.hand.filter((c) => !returnIds.includes(c.id));
|
||||
target.hand.push(...returned);
|
||||
requireRule(
|
||||
p.hand.length <= handLimit(p) &&
|
||||
target.hand.length <= handLimit(target),
|
||||
'The completed exchange must respect hand limits.',
|
||||
);
|
||||
const pending = g.pendingExchange!;
|
||||
g.pendingExchange = null;
|
||||
g.response = pending.response;
|
||||
g.decision = pending.decision;
|
||||
if (!g.response && g.decision?.kind === 'auctionPayment')
|
||||
recoverAuctionPayment(g, getPlayer(g, g.decision.player));
|
||||
log(g, `${p.name} returned ${decision.count} cards to ${target.name}.`);
|
||||
finishHarkonnenExchange(g, decision, action.returnCards);
|
||||
} else if (decision.kind === 'captureOffer') {
|
||||
requireRule(
|
||||
typeof action.accept === 'boolean',
|
||||
@@ -24516,6 +24629,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
harkonnenExchangeIntegrity(state);
|
||||
validateEcazLoyalty(state);
|
||||
ecazTreacheryIntegrity(state);
|
||||
ixRicheseTechnologyIntegrity(state);
|
||||
@@ -24569,6 +24683,7 @@ export function viewGame(state: Game, id: string) {
|
||||
? findReachableBattlePlan(g, me)
|
||||
: null;
|
||||
return {
|
||||
harkonnenExchangeInspection: projectedHarkonnenInspection(g, id),
|
||||
ecazPoisonIncome: (g.ecazPoisonIncome ?? []).filter((income) => income.player === id)
|
||||
.map(({turn, phase, amount, count}) => ({turn, phase, amount, count})),
|
||||
biddingEnd: g.biddingEnd ? { event: g.biddingEnd.event,
|
||||
|
||||
@@ -3215,6 +3215,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'During Revival, Emperor may revive up to three of its own forces or one of its own dead leaders for free. This is separate from normal revival allowances. The shared one-Sardaukar-per-turn limit still applies.',
|
||||
'During Bidding, Harkonnen chooses another player and takes up to four unseen cards. Inspect the combined hand, then return exactly the same number of cards. Newly taken cards may be included in the return.',
|
||||
'Harkonnen may temporarily exceed eight cards during this choice. The return restores normal limits. Other players see the exchange count, but only each hand’s owner sees its cards.',
|
||||
'If every card in the combined hand must return, the return happens automatically with a brief notice. Larger hands retain their return choice. Your Harkonnen inspection keeps the cards actually drawn privately readable after the exchange and after refresh; this records what you saw then, not the target’s current hand. Older saved forced returns record the cards held before returning them without claiming which were originally drawn.',
|
||||
'A hand exchange pauses the current auction action or response and resumes it after the return. If the exchange removes the only Karama backing an unfunded bid, the current auction recovery remains provisional pending primary-source confirmation.',
|
||||
'During Shipment and Movement, the Guild may stop a declared off-planet shipment by spending its once-per-game special Karama. The decision happens before payment or arrival, so a stopped shipment earns no Guild income and triggers no accompanying advisor. Fremen’s on-planet reinforcements and cross-planet movement are excluded.',
|
||||
'The stopped shipment uses the shipper’s shipment opportunity while retaining forces, spice, allied credit and any shipment-rate card; ordinary movement remains available. These settlement details remain provisional pending primary-source clarification. The Guild decision appears regardless of its hidden hand.',
|
||||
|
||||
@@ -311,13 +311,15 @@ void test('promise-bound Worthless candidate is conservatively retained but an i
|
||||
|
||||
void test('hand-exchange suspension waits; restored response uses newly returned card and original passes', () => {
|
||||
let g = fixture(['harkonnen', 'emperor', 'guild']);
|
||||
g.phase = 3;
|
||||
g.players[0].specialKaramaUsed = true;
|
||||
g.pendingExchange = {
|
||||
response: { kind: 'guildIncome', owner: 'c', amount: 3, passed: ['a'] },
|
||||
decision: null,
|
||||
};
|
||||
g.response = null;
|
||||
g.decision = { kind: 'handExchange', player: 'a', target: 'b', count: 1 };
|
||||
g.players[0].hand = [karamas[0]];
|
||||
placeFixtureHand(g, 0, [karamas[0], shield]);
|
||||
g = normalizeAutomaticGame(g);
|
||||
assert.equal(g.players[2].spice, 10);
|
||||
assert.equal(g.decision?.kind, 'handExchange');
|
||||
|
||||
@@ -154,6 +154,14 @@ void test('owner can retake control through Truthtrance, phase opening, response
|
||||
};
|
||||
},
|
||||
(g) => {
|
||||
// A valid legacy Advanced exchange with a genuine return choice.
|
||||
g.advanced = true; g.phase = 3;
|
||||
g.deck = baseDeck();
|
||||
const take = (kind: string) => g.deck.splice(g.deck.findIndex(card => card.kind === kind || card.effect === kind), 1)[0];
|
||||
g.discard = [take('karama')];
|
||||
g.players[0] = { ...newPlayer('a', 'Owner', 'harkonnen'),
|
||||
autopilot: 'Medium', specialKaramaUsed: true, hand: [take('shield'), take('projectile')] };
|
||||
g.pendingExchange = { response: null, decision: null };
|
||||
g.decision = { kind: 'handExchange', player: 'a', target: 'e', count: 1 };
|
||||
},
|
||||
];
|
||||
|
||||
@@ -194,6 +194,8 @@ void test('real Harkonnen extraction reserves future recipient slots and enough
|
||||
hold(g, 'r', 'Distrans');
|
||||
const give = hold(g, 'r', 'Shield');
|
||||
const k = hold(g, 'e', 'Karama');
|
||||
// Keep a real return choice after the blind draw; a Karama-only hand now returns automatically.
|
||||
const retained = hold(g, 'e', 'Truthtrance');
|
||||
for (const name of ['Maula Pistol', 'Lasgun', 'Baliset', 'Snooper'].slice(
|
||||
0,
|
||||
initialSize,
|
||||
@@ -208,6 +210,8 @@ void test('real Harkonnen extraction reserves future recipient slots and enough
|
||||
target: 'a',
|
||||
amount: 2,
|
||||
});
|
||||
assert.equal(g.decision?.kind, 'handExchange');
|
||||
assert.equal(g.players[2].hand.length, 3);
|
||||
if (initialSize === 4) {
|
||||
const snapshot = structuredClone(g);
|
||||
assert.throws(() => transfer(g, 'r', 'a', give), /room|exchange/);
|
||||
@@ -220,6 +224,7 @@ void test('real Harkonnen extraction reserves future recipient slots and enough
|
||||
.map((c) => c.id),
|
||||
});
|
||||
assert.equal(g.players[1].hand.length, 4);
|
||||
assert.deepEqual(g.players[2].hand.map(card => card.id), [retained]);
|
||||
assert.deepEqual(inventory(g), before);
|
||||
}
|
||||
let g = fixture();
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, initializeBaseGameForAudit, joinGame, newPlayer,
|
||||
normalizeAutomaticGame, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
const player = (game: Game, id: string) => game.players.find(candidate => candidate.id === id)!;
|
||||
const inventory = (game: Game) => [
|
||||
...game.deck, ...game.discard, ...game.players.flatMap(candidate => candidate.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
|
||||
].map(card => card.id).sort();
|
||||
function act(game: Game, owner: string, action: Action) {
|
||||
const before = structuredClone(game), cards = inventory(game);
|
||||
const next = reload(applyAction(game, owner, action));
|
||||
assert.deepEqual(game, before);
|
||||
assert.deepEqual(inventory(next), cards);
|
||||
assert.equal(new Set(cards).size, cards.length);
|
||||
return next;
|
||||
}
|
||||
function hold(game: Game, owner: string, kind: string) {
|
||||
const index = game.deck.findIndex(card => card.effect === kind || card.kind === kind);
|
||||
assert.ok(index >= 0, kind);
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
player(game, owner).hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
/** Genuine Advanced setup followed by a conserved later Bidding position. */
|
||||
function fixture(extra: string[] = [], count = 4) {
|
||||
let game = createGame('HARKAUTO', newPlayer('h', 'Harkonnen', 'harkonnen'), true);
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = initializeBaseGameForAudit(game);
|
||||
for (const p of game.players)
|
||||
if (player(game, p.id).traitorChoices.length)
|
||||
game = applyAction(game, p.id, { type: 'traitor', leader: player(game, p.id).traitorChoices[0] });
|
||||
assert.equal(game.status, 'playing');
|
||||
Object.assign(game, { phase: 3, active: 'e', storm: 18, phaseOpening: null,
|
||||
response: null, decision: null, ready: [] });
|
||||
for (const p of game.players) {
|
||||
game.deck.push(...p.hand); p.hand = []; p.spice = 20;
|
||||
}
|
||||
const karama = hold(game, 'h', 'karama');
|
||||
const blocker = hold(game, 'g', 'karama');
|
||||
const extras = extra.map(kind => hold(game, 'h', kind));
|
||||
for (let index = 0; index < count; index++) hold(game, 'e', index % 2 ? 'poison' : 'projectile');
|
||||
game.auction = { cards: [game.deck.shift()!], index: 0, bid: 0, bidder: null,
|
||||
active: 'e', passed: [], opener: game.order.indexOf('e') };
|
||||
assert.equal(inventory(game).length, 33);
|
||||
return { game, karama, blocker, extras };
|
||||
}
|
||||
const exchange = (game: Game, karama: string, amount: number) =>
|
||||
act(game, 'h', { type: 'card', mode: 'special', card: karama, target: 'e', amount });
|
||||
const exchangeNotices = (game: Game) => game.log.filter(entry => entry.automatic?.name === 'Harkonnen exchange');
|
||||
function stable(game: Game) {
|
||||
assert.deepEqual(reload(normalizeAutomaticGame(reload(game))), game);
|
||||
assert.equal(new Set(inventory(game)).size, inventory(game).length);
|
||||
}
|
||||
function privateInspection(game: Game) {
|
||||
assert.ok(game.harkonnenExchangeInspection);
|
||||
const { signature, ...inspection } = game.harkonnenExchangeInspection;
|
||||
assert.equal(typeof signature, 'string');
|
||||
assert.ok(signature.length);
|
||||
assert.deepEqual(viewGame(game, 'h').harkonnenExchangeInspection, inspection);
|
||||
for (const id of ['e', 'g']) {
|
||||
const view = viewGame(game, id);
|
||||
assert.equal(view.harkonnenExchangeInspection, null);
|
||||
assert.equal(view.players.find(p => p.id === 'h')!.hand, undefined);
|
||||
assert.equal('pendingExchange' in view, false);
|
||||
}
|
||||
for (const card of game.harkonnenExchangeInspection.cards)
|
||||
for (const entry of game.log.filter(line => /unseen cards|returned .*cards/.test(line.text))) {
|
||||
assert.equal(entry.text.includes(card.id), false);
|
||||
assert.equal(entry.text.includes(card.name), false);
|
||||
}
|
||||
}
|
||||
function reject(game: Game, owner: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, owner, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function rejectSaved(game: Game) {
|
||||
const before = structuredClone(game);
|
||||
for (const check of [
|
||||
() => applyAction(game, 'h', { type: 'advanceBots' }),
|
||||
() => normalizeAutomaticGame(game),
|
||||
() => viewGame(game, 'h'),
|
||||
() => viewGame(game, 'g'),
|
||||
]) { assert.throws(check); assert.deepEqual(game, before); }
|
||||
}
|
||||
|
||||
void test('one to four forced cards return automatically and their actual blind draw remains private after JSON restore', t => {
|
||||
for (const count of [1, 2, 3, 4]) {
|
||||
const { game, karama } = fixture([], count);
|
||||
const target = player(game, 'e').hand.map(card => card.id).sort();
|
||||
const done = exchange(game, karama, count);
|
||||
assert.equal(done.pendingExchange, null);
|
||||
assert.equal(done.decision, null);
|
||||
assert.equal(player(done, 'h').hand.length, 0);
|
||||
assert.deepEqual(player(done, 'e').hand.map(card => card.id).sort(), target);
|
||||
assert.equal(done.discard.filter(card => card.id === karama).length, 1);
|
||||
assert.deepEqual(done.harkonnenExchangeInspection, {
|
||||
owner: 'h', target: 'e', turn: game.turn, phase: 3, kind: 'draw',
|
||||
cards: done.harkonnenExchangeInspection!.cards,
|
||||
signature: done.harkonnenExchangeInspection!.signature,
|
||||
});
|
||||
assert.deepEqual(done.harkonnenExchangeInspection!.cards.map(card => card.id).sort(), target);
|
||||
assert.deepEqual(exchangeNotices(done).map(entry => entry.automatic), [{ faction: 'harkonnen', name: 'Harkonnen exchange' }]);
|
||||
privateInspection(done);
|
||||
const ownView = viewGame(done, 'h');
|
||||
ownView.harkonnenExchangeInspection!.cards[0].name = 'Changed view copy';
|
||||
assert.notEqual(done.harkonnenExchangeInspection!.cards[0].name, 'Changed view copy');
|
||||
const noDraw = t.mock.method(globalThis.crypto, 'getRandomValues', () => { throw new Error('A saved exchange must not draw again.'); });
|
||||
stable(done);
|
||||
privateInspection(reload(done));
|
||||
noDraw.mock.restore();
|
||||
const later = reload(done);
|
||||
later.turn++; later.phase = 4; later.auction = null;
|
||||
later.deck.push(...done.auction!.cards);
|
||||
later.discard.push(player(later, 'e').hand.shift()!);
|
||||
assert.deepEqual(later.harkonnenExchangeInspection, done.harkonnenExchangeInspection,
|
||||
'Historical knowledge does not freeze a card in the hand it returned to.');
|
||||
privateInspection(later);
|
||||
stable(later);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a real partial draw keeps the return choice and its inspection records drawn cards rather than selected returns', () => {
|
||||
const { game, karama, extras } = fixture(['shield', 'snooper']);
|
||||
const pending = exchange(game, karama, 2);
|
||||
assert.equal(pending.decision?.kind, 'handExchange');
|
||||
assert.equal(player(pending, 'h').hand.length, 4);
|
||||
assert.equal(player(pending, 'e').hand.length, 2);
|
||||
assert.equal(exchangeNotices(pending).length, 0);
|
||||
const inspection = structuredClone(pending.harkonnenExchangeInspection!);
|
||||
assert.ok(inspection.cards.every(card => player(game, 'e').hand.some(original => original.id === card.id)));
|
||||
privateInspection(pending);
|
||||
stable(pending);
|
||||
reject(pending, 'e', { type: 'decision', returnCards: extras });
|
||||
reject(pending, 'h', { type: 'decision', returnCards: [extras[0], extras[0]] });
|
||||
const done = act(pending, 'h', { type: 'decision', returnCards: [extras[0], inspection.cards[0].id] });
|
||||
assert.equal(done.pendingExchange, null);
|
||||
assert.deepEqual(done.harkonnenExchangeInspection, inspection);
|
||||
assert.equal(exchangeNotices(done).length, 1);
|
||||
assert.equal(exchangeNotices(done)[0].text.includes('automatically'), false);
|
||||
privateInspection(done);
|
||||
stable(done);
|
||||
});
|
||||
|
||||
void test('automatic return restores a real private gift response with its prior passes and spends its income only once', () => {
|
||||
const fixtureState = fixture([], 2);
|
||||
let game = fixtureState.game;
|
||||
player(game, 'e').ally = 'h'; player(game, 'h').ally = 'e';
|
||||
game = act(game, 'e', { type: 'emperorGift', amount: 7 });
|
||||
game = act(game, 'h', { type: 'passResponse' });
|
||||
const saved = structuredClone(game.response);
|
||||
assert.equal(saved?.kind, 'emperorGift');
|
||||
const done = exchange(game, fixtureState.karama, 2);
|
||||
assert.deepEqual(done.response, saved);
|
||||
assert.equal(player(done, 'h').spice, 20);
|
||||
assert.equal(viewGame(done, 'g').response!.amount, undefined);
|
||||
assert.equal(exchangeNotices(done).length, 1);
|
||||
privateInspection(done);
|
||||
stable(done);
|
||||
const paid = act(done, 'g', { type: 'passResponse' });
|
||||
assert.equal(player(paid, 'h').spice, 27);
|
||||
assert.equal(player(paid, 'e').spice, 13);
|
||||
assert.equal(exchangeNotices(paid).length, 1);
|
||||
stable(paid);
|
||||
const canceled = act(done, 'g', { type: 'card', mode: 'cancel', card: fixtureState.blocker });
|
||||
assert.equal(player(canceled, 'h').spice, 20);
|
||||
assert.equal(player(canceled, 'e').spice, 20);
|
||||
assert.equal(exchangeNotices(canceled).length, 1);
|
||||
stable(canceled);
|
||||
});
|
||||
|
||||
void test('automatic return restores an actually won unfunded auction payment after giving its Karama back', () => {
|
||||
const fixtureState = fixture([], 1);
|
||||
let game = fixtureState.game;
|
||||
const [targetCard] = player(game, 'e').hand.splice(0);
|
||||
game.deck.push(targetCard);
|
||||
player(game, 'e').hand.push(player(game, 'g').hand.splice(0)[0]);
|
||||
player(game, 'e').spice = 0;
|
||||
game = act(game, 'e', { type: 'bid', amount: 30 });
|
||||
for (let step = 0; game.auction?.active && !game.decision && step < 8; step++)
|
||||
game = act(game, game.auction.active, { type: 'passBid' });
|
||||
assert.deepEqual(game.decision, { kind: 'auctionPayment', player: 'e' });
|
||||
const auction = structuredClone(game.auction);
|
||||
const done = exchange(game, fixtureState.karama, 1);
|
||||
assert.deepEqual(done.decision, game.decision);
|
||||
assert.deepEqual(done.auction, auction);
|
||||
assert.equal(player(done, 'e').hand[0].effect, 'karama');
|
||||
assert.equal(exchangeNotices(done).length, 1);
|
||||
stable(done);
|
||||
});
|
||||
|
||||
void test('Truthtrance keeps priority when its spend makes a real pending exchange forced, then the return finishes once', () => {
|
||||
const { game, karama, extras } = fixture(['truthtrance'], 2);
|
||||
let pending = exchange(game, karama, 2);
|
||||
const inspection = structuredClone(pending.harkonnenExchangeInspection);
|
||||
pending = act(pending, 'h', { type: 'card', card: extras[0] });
|
||||
assert.equal(player(pending, 'h').hand.length, 3, 'The committed Truthtrance stays physically held until its answer completes.');
|
||||
assert.ok(pending.truthtrance);
|
||||
assert.equal(pending.decision?.kind, 'handExchange');
|
||||
assert.equal(exchangeNotices(pending).length, 0);
|
||||
stable(pending);
|
||||
privateInspection(pending);
|
||||
reject(pending, 'h', { type: 'decision', returnCards: player(pending, 'h').hand.map(card => card.id) });
|
||||
for (let step = 0; pending.truthtrance?.stage === 'priority' && step < 8; step++) {
|
||||
const voter = pending.players.find(p => !pending.truthtrance!.passed.includes(p.id))!;
|
||||
pending = act(pending, voter.id, { type: 'truthPass' });
|
||||
}
|
||||
pending = act(pending, 'h', { type: 'truthAsk', question: { kind: 'fact', target: 'e',
|
||||
fact: { kind: 'spice', compare: 'eq', value: 20 } } });
|
||||
assert.equal(exchangeNotices(pending).length, 0);
|
||||
const done = act(pending, 'e', { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(done.truthtrance, null);
|
||||
assert.equal(done.pendingExchange, null);
|
||||
assert.deepEqual(done.harkonnenExchangeInspection, inspection);
|
||||
assert.equal(exchangeNotices(done).length, 1);
|
||||
assert.equal(done.discard.filter(card => card.id === extras[0]).length, 1);
|
||||
stable(done);
|
||||
});
|
||||
|
||||
void test('a legacy forced save records the known returned hand without claiming to reconstruct its original blind draw', t => {
|
||||
const { game, karama, extras } = fixture(['shield'], 2);
|
||||
const legacy = exchange(game, karama, 2);
|
||||
// Conserved old saved position: only the mandatory return remains, with no historical inspection field.
|
||||
const other = player(legacy, 'h').hand.find(card => card.id === extras[0])!;
|
||||
player(legacy, 'h').hand = player(legacy, 'h').hand.filter(card => card.id !== other.id);
|
||||
legacy.deck.push(other);
|
||||
delete legacy.harkonnenExchangeInspection;
|
||||
const known = structuredClone(player(legacy, 'h').hand);
|
||||
const before = structuredClone(legacy);
|
||||
t.mock.method(globalThis.crypto, 'getRandomValues', () => { throw new Error('Legacy return must not reroll the draw.'); });
|
||||
const done = reload(normalizeAutomaticGame(legacy));
|
||||
assert.deepEqual(legacy, before);
|
||||
assert.deepEqual(done.harkonnenExchangeInspection, {
|
||||
owner: 'h', target: 'e', turn: game.turn, phase: 3, kind: 'return', cards: known,
|
||||
signature: done.harkonnenExchangeInspection!.signature,
|
||||
});
|
||||
assert.deepEqual(inventory(done), inventory(legacy));
|
||||
assert.equal(done.pendingExchange, null);
|
||||
assert.equal(exchangeNotices(done).length, 1);
|
||||
privateInspection(done);
|
||||
stable(done);
|
||||
});
|
||||
|
||||
void test('all four legal AI paths retain real choices and can continue after an automatic forced return', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const f = fixture(['shield'], 2);
|
||||
player(f.game, 'h').bot = difficulty;
|
||||
const pending = exchange(f.game, f.karama, 2);
|
||||
const actions = botActions(viewGame(pending, 'h'));
|
||||
assert.equal(actions.length, 1);
|
||||
assert.equal(actions[0].type, 'decision');
|
||||
assert.equal(act(pending, 'h', actions[0]).pendingExchange, null);
|
||||
const forced = fixture([], 2);
|
||||
player(forced.game, 'e').bot = difficulty;
|
||||
const done = exchange(forced.game, forced.karama, 2);
|
||||
const next = botActions(viewGame(done, 'e'));
|
||||
assert.ok(next.length);
|
||||
assert.ok(next.every(action => action.type !== 'decision'));
|
||||
act(done, 'e', next[0]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('malformed saved counts, custody, inspection and continuation reject actions, views and normalization immutably', () => {
|
||||
const f = fixture(['shield'], 2);
|
||||
const pending = exchange(f.game, f.karama, 2);
|
||||
const changes: ((game: Game) => void)[] = [
|
||||
game => { if (game.decision?.kind === 'handExchange') game.decision.count = 0; },
|
||||
game => { if (game.decision?.kind === 'handExchange') game.decision.count = 1.5; },
|
||||
game => { if (game.decision?.kind === 'handExchange') game.decision.count = 1; },
|
||||
game => { if (game.decision?.kind === 'handExchange') game.decision.target = 'h'; },
|
||||
game => { if (game.decision?.kind === 'handExchange') game.decision.player = 'e'; },
|
||||
game => { game.pendingExchange = null; },
|
||||
game => { game.pendingExchange!.decision = structuredClone(game.decision); },
|
||||
game => { delete (game.pendingExchange as Partial<NonNullable<Game['pendingExchange']>>).response; },
|
||||
game => { game.decision = null; },
|
||||
game => { game.phase = 4; },
|
||||
game => { game.turn++; },
|
||||
game => { player(game, 'h').specialKaramaUsed = false; },
|
||||
game => { player(game, 'h').hand.push(structuredClone(player(game, 'h').hand[0])); },
|
||||
game => { game.deck.push(structuredClone(player(game, 'e').hand[0])); },
|
||||
game => { game.harkonnenExchangeInspection!.owner = 'e'; },
|
||||
game => { game.harkonnenExchangeInspection!.target = 'g'; },
|
||||
game => { game.harkonnenExchangeInspection!.cards[0].name = 'Invented'; },
|
||||
game => { game.harkonnenExchangeInspection!.cards[1] = structuredClone(game.harkonnenExchangeInspection!.cards[0]); },
|
||||
];
|
||||
for (const change of changes) { const malformed = reload(pending); change(malformed); rejectSaved(malformed); }
|
||||
const duplicateDraw = fixture([], 2);
|
||||
player(duplicateDraw.game, 'e').hand.push(structuredClone(player(duplicateDraw.game, 'e').hand[0]));
|
||||
reject(duplicateDraw.game, 'h', { type: 'card', mode: 'special', card: duplicateDraw.karama, target: 'e', amount: 1 });
|
||||
});
|
||||
|
||||
void test('completed inspection binds its original canonical faces, order, target, turn and draw provenance', () => {
|
||||
const f = fixture([], 2);
|
||||
const completed = exchange(f.game, f.karama, 2);
|
||||
completed.turn++;
|
||||
stable(completed);
|
||||
const changes: ((game: Game) => void)[] = [
|
||||
game => { game.harkonnenExchangeInspection!.cards[0] = structuredClone(game.deck[0]); },
|
||||
game => { game.harkonnenExchangeInspection!.cards.reverse(); },
|
||||
game => { game.harkonnenExchangeInspection!.target = 'g'; },
|
||||
game => { game.harkonnenExchangeInspection!.turn = game.turn; },
|
||||
game => { game.harkonnenExchangeInspection!.kind = 'return'; },
|
||||
game => { game.harkonnenExchangeInspection!.signature += 'changed'; },
|
||||
game => { delete (game.harkonnenExchangeInspection as Partial<NonNullable<Game['harkonnenExchangeInspection']>>).signature; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const malformed = reload(completed);
|
||||
change(malformed);
|
||||
assert.deepEqual(inventory(malformed), inventory(completed), 'Only the historical knowledge payload changed.');
|
||||
rejectSaved(malformed);
|
||||
}
|
||||
privateInspection(completed);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { HarkonnenExchangeInspection } from '../components/harkonnen-exchange-inspection';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
|
||||
function view() {
|
||||
const game = createGame('INSPECTION', newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
const result = viewGame(game, 'h');
|
||||
result.harkonnenExchangeInspection = {
|
||||
owner: 'h', target: 'e', turn: 3, phase: 3, kind: 'draw',
|
||||
cards: baseDeck().filter(card => card.name === 'Shield').slice(0, 1),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
const render = (game: GameView) => renderToStaticMarkup(createElement(HarkonnenExchangeInspection, { game }));
|
||||
|
||||
void test('private exchange record offers readable cards and inspection without a return confirmation', () => {
|
||||
const html = render(view());
|
||||
assert.match(html, /Your Harkonnen inspection · Turn 3/);
|
||||
assert.match(html, /Cards you took and inspected from Emperor/);
|
||||
assert.match(html, /may have changed hands since/);
|
||||
assert.match(html, /Shield/);
|
||||
assert.match(html, /Inspect card: Shield/);
|
||||
assert.doesNotMatch(html, /Return cards|Confirm|Acknowledge/);
|
||||
});
|
||||
|
||||
void test('legacy history describes held cards without inventing their source', () => {
|
||||
const game = view(); game.harkonnenExchangeInspection!.kind = 'return';
|
||||
const html = render(game);
|
||||
assert.match(html, /Cards in your hand before the automatic return to Emperor/);
|
||||
assert.doesNotMatch(html, /Cards you took/);
|
||||
});
|
||||
|
||||
void test('absent and mismatched-owner records render no private card information', () => {
|
||||
const game = view(); game.me = 'e'; assert.equal(render(game), '');
|
||||
game.me = 'h'; game.harkonnenExchangeInspection = null; assert.equal(render(game), '');
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, initializeBaseGameForAudit, type Action, type Game } from '../game/engine';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
||||
|
||||
const clock = { now: () => 30000, sleep: async () => {} };
|
||||
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
|
||||
void test('authenticated forced exchange races once and restores the same owner-only inspection without redrawing cards', async t => {
|
||||
const f = unitStore();
|
||||
try {
|
||||
const made = await f.rooms.createRoom('Harkonnen inspection', 'harkonnen', true, []);
|
||||
const code = made.view.code;
|
||||
const joined = [await f.rooms.joinRoom(code, 'Exchange target', 'guild'), await f.rooms.joinRoom(code, 'Observer', 'emperor')];
|
||||
const auths = await Promise.all([made.token!, ...joined.map(p => p.token!)].map(token => f.restart().authenticate(code, token)));
|
||||
const act = async (index: number, action: Action) => {
|
||||
const before = await f.restart().readRoom(code);
|
||||
await f.restart().act(code, auths[index], before.version, action, clock);
|
||||
return f.restart().readRoom(code);
|
||||
};
|
||||
for (let i = 0; i < auths.length; i++) await act(i, { type: 'ready' });
|
||||
let game = await f.restart().readRoom(code);
|
||||
const seedVersion = game.version;
|
||||
// The audit seam runs genuine Advanced setup; no public start gate is changed.
|
||||
game = initializeBaseGameForAudit(game);
|
||||
game.version = seedVersion + 1;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(game), game.version, code, seedVersion).changes, 1);
|
||||
for (let i = 0; i < auths.length; i++) {
|
||||
const choices = player(game, auths[i].playerId).traitorChoices;
|
||||
if (choices.length) game = await act(i, { type: 'traitor', leader: choices[0] });
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const inventory = sampleInventory(game), seats = f.sqlite.prepare('SELECT * FROM seats').all();
|
||||
const owner = auths[0].playerId, target = auths[1].playerId;
|
||||
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
|
||||
const give = (id: string, effect: string) => {
|
||||
const at = game.deck.findIndex(card => card.effect === effect || card.kind === effect); assert.ok(at >= 0);
|
||||
const card = game.deck.splice(at, 1)[0]; player(game, id).hand.push(card); return card.id;
|
||||
};
|
||||
const karama = give(owner, 'karama');
|
||||
give(target, 'shield'); give(target, 'snooper'); give(target, 'projectile'); give(target, 'poison');
|
||||
Object.assign(game, {
|
||||
phase: 3, phaseOpening: null, active: target, ready: [], response: null, decision: null,
|
||||
auction: { cards: game.deck.splice(0, 1), index: 0, bid: 0, bidder: null, active: target, passed: [], opener: 0 },
|
||||
});
|
||||
verifySampleCustody(game, inventory);
|
||||
const stagedVersion = game.version; game.version++;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(game), game.version, code, stagedVersion).changes, 1);
|
||||
const targetCards = structuredClone(player(game, target).hand), parentAuction = structuredClone(game.auction);
|
||||
const version = game.version;
|
||||
const action: Action = { type: 'card', card: karama, mode: 'special', target, amount: 4 };
|
||||
const before = f.sqlite.prepare('SELECT state,version FROM rooms').all();
|
||||
await assert.rejects(f.restart().act(code, auths[2], version, action, clock));
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms').all(), before);
|
||||
let arrivals = 0; let release!: () => void;
|
||||
const barrier = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
|
||||
const results = await Promise.allSettled([
|
||||
f.rooms.act(code, auths[0], version, action, clock),
|
||||
f.restart().act(code, auths[0], version, action, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
||||
game = await f.restart().readRoom(code);
|
||||
assert.equal(game.version, version + 1);
|
||||
assert.equal(game.decision, null); assert.equal(game.pendingExchange, null);
|
||||
assert.equal(player(game, owner).hand.length, 0);
|
||||
assert.equal(player(game, owner).specialKaramaUsed, true);
|
||||
assert.equal(game.discard.filter(card => card.id === karama).length, 1);
|
||||
assert.deepEqual(player(game, target).hand.map(card => card.id).sort(), targetCards.map(card => card.id).sort());
|
||||
assert.deepEqual(game.auction, parentAuction);
|
||||
const inspection = structuredClone(game.harkonnenExchangeInspection);
|
||||
assert.ok(inspection); assert.equal(inspection.kind, 'draw'); assert.equal(inspection.owner, owner);
|
||||
const { signature, ...visibleInspection } = inspection;
|
||||
assert.equal(typeof signature, 'string'); assert.ok(signature.length);
|
||||
assert.deepEqual(inspection.cards.map(card => card.id).sort(), targetCards.map(card => card.id).sort());
|
||||
const committed = f.sqlite.prepare('SELECT state,version FROM rooms').all();
|
||||
for (let i = 0; i < auths.length; i++) {
|
||||
const view = await f.restart().readSeatView(code, auths[i]);
|
||||
assert.deepEqual(view.harkonnenExchangeInspection, i === 0 ? visibleInspection : null);
|
||||
if (view.harkonnenExchangeInspection) assert.equal('signature' in view.harkonnenExchangeInspection, false);
|
||||
assert.equal('pendingExchange' in view, false);
|
||||
for (const other of view.players.filter(p => p.id !== auths[i].playerId)) assert.equal(other.hand, undefined);
|
||||
}
|
||||
await assert.rejects(f.restart().act(code, auths[0], version, action, clock));
|
||||
await assert.rejects(f.restart().act(code, auths[0], game.version, { type: 'decision', returnCards: inspection.cards.map(card => card.id) }, clock));
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms').all(), committed);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
|
||||
verifySampleCustody(game, inventory);
|
||||
|
||||
// Independent legacy scenario in the same disposable store: a real exchange
|
||||
// loses its extra choice before a worker restart, predating inspection records.
|
||||
let legacy = JSON.parse(before[0].state as string) as Game;
|
||||
const extra = legacy.deck.shift()!;
|
||||
player(legacy, owner).hand.push(extra);
|
||||
legacy = applyAction(legacy, owner, action);
|
||||
assert.equal(legacy.decision?.kind, 'handExchange');
|
||||
assert.equal(f.rooms.needsAutomaticRoomRecovery(legacy), true);
|
||||
legacy.version = game.version + 1;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(legacy), legacy.version, code, game.version).changes, 1);
|
||||
const optional = f.sqlite.prepare('SELECT state,version FROM rooms').all();
|
||||
await f.restart().continueRoomAutomatic(code, clock);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms').all(), optional,
|
||||
'A genuine return choice remains waiting without a write.');
|
||||
player(legacy, owner).hand = player(legacy, owner).hand.filter(card => card.id !== extra.id);
|
||||
legacy.deck.push(extra);
|
||||
delete legacy.harkonnenExchangeInspection;
|
||||
const legacyCards = structuredClone(player(legacy, owner).hand);
|
||||
const optionalVersion = legacy.version; legacy.version++;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(legacy), legacy.version, code, optionalVersion).changes, 1);
|
||||
const noDraw = t.mock.method(globalThis.crypto, 'getRandomValues', () => { throw new Error('Legacy recovery must not draw again.'); });
|
||||
arrivals = 0;
|
||||
const recoveryBarrier = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await recoveryBarrier; };
|
||||
await Promise.all([f.rooms.continueRoomAutomatic(code, clock), f.restart().continueRoomAutomatic(code, clock)]);
|
||||
delete f.hooks.beforeWrite; noDraw.mock.restore();
|
||||
assert.equal(arrivals, 2);
|
||||
const restored = await f.restart().readRoom(code);
|
||||
assert.equal(restored.version, legacy.version + 1);
|
||||
assert.equal(restored.decision, null); assert.equal(restored.pendingExchange, null);
|
||||
assert.equal(restored.harkonnenExchangeInspection?.kind, 'return');
|
||||
assert.deepEqual(restored.harkonnenExchangeInspection?.cards, legacyCards);
|
||||
assert.equal(restored.discard.filter(card => card.id === karama).length, 1);
|
||||
for (let i = 0; i < auths.length; i++) {
|
||||
const view = await f.restart().readSeatView(code, auths[i]);
|
||||
if (i === 0) { assert.equal(view.harkonnenExchangeInspection?.kind, 'return'); assert.deepEqual(view.harkonnenExchangeInspection?.cards, legacyCards); }
|
||||
else assert.equal(view.harkonnenExchangeInspection, null);
|
||||
}
|
||||
const recovered = f.sqlite.prepare('SELECT state,version FROM rooms').all();
|
||||
await f.restart().continueRoomAutomatic(code, clock);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms').all(), recovered);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
|
||||
verifySampleCustody(restored, inventory);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Decision,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
@@ -362,15 +361,9 @@ void test('forged or missing saved Ixian lot decisions fail before projection, a
|
||||
(game) => {
|
||||
game.ixRicheseTechnologyEvent += '-forged';
|
||||
},
|
||||
(game) => {
|
||||
const duplicate = structuredClone(game.decision) as Decision;
|
||||
game.pendingExchange = { response: null, decision: duplicate };
|
||||
},
|
||||
];
|
||||
|
||||
for (const corrupt of corruptions) {
|
||||
const game = structuredClone(genuine);
|
||||
corrupt(game);
|
||||
async function rejectSaved(game: Game) {
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), game.version, f.code);
|
||||
@@ -387,6 +380,36 @@ void test('forged or missing saved Ixian lot decisions fail before projection, a
|
||||
);
|
||||
assert.deepEqual(snapshot(f, f.code), before);
|
||||
}
|
||||
for (const corrupt of corruptions) {
|
||||
const game = structuredClone(genuine);
|
||||
corrupt(game);
|
||||
await rejectSaved(game);
|
||||
}
|
||||
|
||||
// A real paid Box search holds this exact Ix decision. Duplicating the live
|
||||
// decision now exercises Ix uniqueness without fabricating a Harkonnen parent.
|
||||
const staged = structuredClone(genuine);
|
||||
const boxIndex = staged.richeseCache!.findIndex(card => card.effect === 'nullentropyBox');
|
||||
assert.ok(boxIndex >= 0);
|
||||
const box = staged.richeseCache!.splice(boxIndex, 1)[0];
|
||||
player(staged, f.observerAuth.playerId).hand.push(box);
|
||||
// Two eligible physical cards keep the paid search as a genuine private choice.
|
||||
staged.discard.push(take(staged, card => card.kind === 'worthless'),
|
||||
take(staged, card => card.kind === 'worthless'));
|
||||
assert.deepEqual(physicalCards(staged), physicalCards(genuine));
|
||||
f.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(staged), staged.version, f.code);
|
||||
await f.act(f.observerAuth, { type: 'card', card: box.id });
|
||||
const suspended = await f.restart().readRoom(f.code);
|
||||
assert.equal(suspended.decision?.kind, 'nullentropy');
|
||||
assert.equal(player(suspended, f.observerAuth.playerId).spice,
|
||||
player(staged, f.observerAuth.playerId).spice - 2);
|
||||
assert.deepEqual(suspended.pendingNullentropy!.resume.decision, genuine.decision);
|
||||
assert.deepEqual(physicalCards(suspended), physicalCards(genuine));
|
||||
assert.doesNotThrow(() => viewGame(suspended, f.ixAuth.playerId));
|
||||
const duplicated = structuredClone(suspended);
|
||||
duplicated.decision = structuredClone(genuine.decision);
|
||||
await rejectSaved(duplicated);
|
||||
});
|
||||
|
||||
void test('concurrent authenticated declines persist one Richese lot continuation behind the room CAS', async (t) => {
|
||||
|
||||
@@ -240,6 +240,8 @@ for (const form of ['printed', 'bg'] as const)
|
||||
});
|
||||
void test('a gift restores a real Harkonnen hand exchange with its inherited BG declaration and saved response', () => {
|
||||
const f = parent(fixture(true, 3));
|
||||
// Keep a real choice after spending the Karama; a sole held card returns automatically.
|
||||
player(f.g, 'h').hand.push(...f.g.deck.splice(0, 1));
|
||||
const hark = player(f.g, 'h').hand.find((c) => c.effect === 'karama')!;
|
||||
let g = applyAction(f.g, 'h', {
|
||||
type: 'card',
|
||||
|
||||
@@ -675,6 +675,8 @@ void test('a real Harkonnen hand-exchange return decision survives Truthtrance w
|
||||
initial.advanced = true;
|
||||
initial.phase = 3;
|
||||
const karama = hold(initial, 'p', 'Karama');
|
||||
// Retain a real return choice even after the intervening Truthtrance is spent.
|
||||
hold(initial, 'p', 'Shield');
|
||||
player(initial, 'q').hand.push(...initial.deck.splice(0, 2));
|
||||
initial = applyAction(initial, 'p', {
|
||||
type: 'card',
|
||||
|
||||
@@ -128,7 +128,15 @@ void test('physical force facts include Bene Gesserit advisors without exposing
|
||||
void test('a force fact combined with a private hand fact projects only its aggregate answer to the respondent', () => {
|
||||
const initial = typedGame();
|
||||
const target = player(initial, 'f');
|
||||
const privateName = target.hand[0].name;
|
||||
// Setup may move the target's only opening Truthtrance to the asker.
|
||||
// Stage a definite held card without changing the physical inventory.
|
||||
const inventory = knowledgePhysical(initial);
|
||||
initial.deck.push(...target.hand);
|
||||
const privateCard = initial.deck.shift();
|
||||
assert.ok(privateCard);
|
||||
target.hand = [privateCard];
|
||||
assert.deepEqual(knowledgePhysical(initial), inventory);
|
||||
const privateName = privateCard.name;
|
||||
const combined: TruthFact = { kind: 'or', terms: [
|
||||
fact({ kind: 'reserves' }, 'total', 11), { kind: 'hand', name: privateName },
|
||||
] };
|
||||
|
||||
@@ -173,6 +173,10 @@ void test('any-time question overlays and restores pending windows without reapp
|
||||
};
|
||||
}
|
||||
if (mode === 'decision') {
|
||||
initial.advanced = true;
|
||||
initial.phase = 3;
|
||||
initial.players.find(p => p.id === 'h')!.specialKaramaUsed = true;
|
||||
hold(initial, 'h', 'Shield'); // Preserve a real return choice during the overlay.
|
||||
initial.decision = {
|
||||
kind: 'handExchange',
|
||||
player: 'h',
|
||||
|
||||
Reference in new issue
Block a user