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
+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.',
|
||||
|
||||
Reference in new issue
Block a user