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