Support owned Box and Kulon movement-promise preparation
Extend bounded Basic ground-movement reachability to held native CHOAM Kulon and owned paid Box recovery. Before payment, use only known current-discard identities. Preserve actual paid event, full inspection, one two-spice fee, original physical card replay and existing provisional full-hand exchange. Reject a Yes-breaking selection before the server shuffle without losing the paid search. Keep No-only searches on their normal decision before movement can end. Record movement-relevant current-discard certainty at actual disposal, paid search, mixed claim and deck rebuild boundaries without projecting raw metadata or erasing historical memory. Use deterministic preparation on private copies, include preparation state in search memoization, protect fee-funded routes from voluntary spending and keep native Kulon counter semantics. Persistent busy-safe guidance and existing legal profiles use the actual next paid action; no strategy tuning. Verify 17 files231/231, types/lint/build, three original-action control/table-rendering programmes and four existing profiles with40 accepted actions including neutral passes. Original public sources44/25 retain actual purchases1+1, shipment costs6/1, exhausted Hajr and native Kulon route4. Separate full-hand, legacy certainty, fee-loss and acquired-counter edges disclose conserved staging. Two prior-engine Auditor fixture failures are repaired by actual battle-result acknowledgements; test-only const lint is corrected. Other timing, preparations/counter mitigation, unknown private possibilities, optional/Advanced, full modes, comprehensive assurance and deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
This commit is contained in:
1 parent
edbcbe28de
commit
b5a53da712
16 files changed
+772
-41
No files matched your search
+110
-16
@@ -1,3 +1,4 @@
|
||||
import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge';
|
||||
import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises';
|
||||
import { findMovementCompletion, type MovementCompletion } from './movement-promise-search';
|
||||
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
@@ -1973,6 +1974,8 @@ export type Game = {
|
||||
truthHistory?: TruthRecord[];
|
||||
shipmentPromises?: ShipmentPromise[];
|
||||
movementPromises?: MovementPromise[];
|
||||
/** Server-only certainty of movement-relevant cards still in the discard. */
|
||||
movementDiscardKnowledge?: MovementDiscardKnowledge;
|
||||
ixSetupCards?: Card[] | null;
|
||||
ixAuction?: { count: number; cards: Card[]; authority?: string } | null;
|
||||
ixAuctionKnown?: { turn: number; cards: Card[]; controller?: string } | null;
|
||||
@@ -4559,6 +4562,14 @@ function finishNullentropy(
|
||||
preview.ownerHand,
|
||||
{ type: 'card', card: pending.box },
|
||||
);
|
||||
const movementPreview = structuredClone(context);
|
||||
getPlayer(movementPreview, player).hand = preview.ownerHand;
|
||||
movementPreview.discard = preview.discard;
|
||||
setMovementDiscardKnowledge(movementPreview, recordMovementBoxKnowledge(movementPreview.movementDiscardKnowledge,
|
||||
movementPreview.players.map(p => p.id), player, preview.discard));
|
||||
requireRule(!liveMovementPromises(g.movementPromises ?? [], player, g.turn).some(promise => promise.answer) ||
|
||||
groundMovementCompletion(movementPreview, player) !== null,
|
||||
'Choose a card that preserves your accepted ground-movement promises. The paid search remains available.');
|
||||
// All rejection paths precede this single server shuffle. The paid record and
|
||||
// final shuffled order are each persisted by the room's normal CAS fence.
|
||||
const order = shuffle(remaining).map((c) => c.id);
|
||||
@@ -4573,6 +4584,8 @@ function finishNullentropy(
|
||||
);
|
||||
owner.hand = result.ownerHand;
|
||||
g.discard = result.discard;
|
||||
setMovementDiscardKnowledge(g, recordMovementBoxKnowledge(g.movementDiscardKnowledge,
|
||||
g.players.map(p => p.id), player, result.discard));
|
||||
g.pendingNullentropy = null;
|
||||
g.decision = null;
|
||||
log(
|
||||
@@ -5210,6 +5223,14 @@ export function joinGame(g: Game, p: Player) {
|
||||
g.players.forEach((x) => (x.ready = !!x.bot));
|
||||
log(g, `${p.name} joined as ${faction(p.faction).name}.`);
|
||||
}
|
||||
function setMovementDiscardKnowledge(g: Game, knowledge: MovementDiscardKnowledge | undefined) {
|
||||
if (knowledge === undefined) delete g.movementDiscardKnowledge;
|
||||
else g.movementDiscardKnowledge = knowledge;
|
||||
}
|
||||
function rememberMovementDiscards(g: Game, entries: FreshDiscardBatch['entries']) {
|
||||
setMovementDiscardKnowledge(g, recordMovementDiscardKnowledge(g.movementDiscardKnowledge,
|
||||
g.players.map(p => p.id), entries));
|
||||
}
|
||||
function draw(g: Game) {
|
||||
requireRule(
|
||||
!g.pendingTreacheryDiscard,
|
||||
@@ -5222,6 +5243,7 @@ function draw(g: Game) {
|
||||
if (!g.deck.length) {
|
||||
g.deck = shuffle(g.discard);
|
||||
g.discard = [];
|
||||
setMovementDiscardKnowledge(g, clearMovementDiscardKnowledge(g.movementDiscardKnowledge));
|
||||
}
|
||||
return g.deck.shift();
|
||||
}
|
||||
@@ -5250,6 +5272,7 @@ function discard(g: Game, p: Player, id: string, role?: Omit<EcazPoisonDiscard,
|
||||
(g.ecazPoisonIncome ??= []).push({ ...income, turn: g.turn, phase: g.phase });
|
||||
}
|
||||
g.discard.push(c);
|
||||
rememberMovementDiscards(g, [{ card: c, discardedBy: p.id, publicFace: false }]);
|
||||
p.hand = p.hand.filter((c) => c.id !== id);
|
||||
return c;
|
||||
}
|
||||
@@ -7877,6 +7900,7 @@ function stageTreacheryDiscard(
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
) {
|
||||
requireRule(!g.pendingTreacheryDiscard, 'Finish the prior discard first.');
|
||||
rememberMovementDiscards(g, entries);
|
||||
treacheryDiscardIntegrity(g);
|
||||
const sequence = (g.treacheryDiscardSequence ?? 0) + 1;
|
||||
g.treacheryDiscardSequence = sequence;
|
||||
@@ -7918,6 +7942,10 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
if (result && owner) {
|
||||
owner.hand = result.ownerHand;
|
||||
g.discard = result.discard;
|
||||
setMovementDiscardKnowledge(g, recordMovementClaimKnowledge(g.movementDiscardKnowledge,
|
||||
g.players.map(p => p.id), owner.id, pending.batch.entries.filter(entry => entry.discardedBy !== owner.id), claim!.card));
|
||||
rememberMovementDiscards(g, pending.batch.entries.filter(entry => entry.discardedBy === owner.id)
|
||||
.map(entry => ({ ...entry, publicFace: false })));
|
||||
log(g, `${owner.name} played Semuta Drug and recovered one freshly discarded Treachery Card. The acquired card stays private.`,
|
||||
{ faction: owner.faction, name: 'Semuta Drug' });
|
||||
}
|
||||
@@ -13572,15 +13600,17 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
|
||||
const battlePromises = g.battle?.truthPromises?.some(promise =>
|
||||
promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]);
|
||||
const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0;
|
||||
const movementPromises = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer);
|
||||
// Reuse the authoritative promise checks on an isolated resource-only copy.
|
||||
// No payment, receipt or live continuation is created by this projection.
|
||||
const maximum = !blocked && (battlePromises || shipmentPromises)
|
||||
const maximum = !blocked && (battlePromises || shipmentPromises || movementPromises)
|
||||
? maximumBribe(available, amount => {
|
||||
const probe = structuredClone(g);
|
||||
getPlayer(probe, p.id).spice -= amount;
|
||||
try {
|
||||
if (battlePromises) reconcileBattlePromises(probe, { actor: p.id, action: { type: 'bribe' } });
|
||||
if (shipmentPromises) reconcileShipmentPromises(probe, { actor: p.id, action: { type: 'bribe' } });
|
||||
if (movementPromises && !groundMovementCompletion(probe, p.id)) return false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof RuleError) return false;
|
||||
@@ -16406,7 +16436,8 @@ function groundMovementProbe(source: Game): Game {
|
||||
game.pendingKarama = null; game.pendingTreacheryDiscard = null;
|
||||
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
|
||||
game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null;
|
||||
game.pendingArrivalReaction = []; game.pendingExchange = null; game.pendingNullentropy = null;
|
||||
game.pendingArrivalReaction = []; game.pendingExchange = null;
|
||||
if (game.pendingNullentropy && source.decision?.kind === 'nullentropy') game.decision = structuredClone(source.decision);
|
||||
return game;
|
||||
}
|
||||
function groundMovementEvent(move: MovementOrder) {
|
||||
@@ -16420,23 +16451,71 @@ function projectGroundMovement(source: Game, move: MovementOrder): Game {
|
||||
flight.completed++;
|
||||
if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
|
||||
if (flight.mode === 'twoGroups') player.ornithopterExtraMoves = (player.ornithopterExtraMoves ?? 0) + 1;
|
||||
game.discard.push(flight.card); game.ornithopter = null;
|
||||
game.discard.push(flight.card);
|
||||
rememberMovementDiscards(game, [{ card: flight.card, discardedBy: player.id, publicFace: true }]);
|
||||
game.ornithopter = null;
|
||||
}
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function projectMovementBoxRecovery(source: Game, actor: string, box: string, selected: string, paid: boolean): Game | null {
|
||||
const game = structuredClone(source), owner = getPlayer(game, actor);
|
||||
try {
|
||||
if (paid) requireRule(game.pendingNullentropy?.player === actor && !nullentropyIntegrity(game),
|
||||
'Retain the original paid search.');
|
||||
else { validateNullentropyBegin(game, owner, box); owner.spice -= 2; }
|
||||
const context = nullentropyContext(game), player = getPlayer(context, actor);
|
||||
let result: ReturnType<typeof resolveNullentropyBox>;
|
||||
try { result = resolveNullentropyBox(player.hand, box, context.discard, selected,
|
||||
context.discard.filter(card => card.id !== selected).map(card => card.id), handLimit(player), true); }
|
||||
catch (error) { throw new RuleError(error instanceof Error ? error.message : 'Invalid owned Box recovery.'); }
|
||||
validateTransferCompletion(context, player, player, result.ownerHand, result.ownerHand, { type: 'card', card: box });
|
||||
player.hand = result.ownerHand; context.discard = result.discard;
|
||||
setMovementDiscardKnowledge(context, recordMovementBoxKnowledge(context.movementDiscardKnowledge,
|
||||
context.players.map(p => p.id), actor, result.discard));
|
||||
return groundMovementProbe(context);
|
||||
} catch (error) {
|
||||
if (error instanceof RuleError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function groundMovementCompletion(source: Game, actor: string,
|
||||
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null {
|
||||
const game = groundMovementProbe(source);
|
||||
if (!movementPromiseTargetAvailable(game, actor)) return null;
|
||||
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
|
||||
return findMovementCompletion(game, actor, promises, {
|
||||
allowance: movesAllowed,
|
||||
prepareHajr(state, owner) {
|
||||
const player = getPlayer(state, owner);
|
||||
const card = player.hand.find(card => card.effect === 'hajr');
|
||||
if (!card || !hajrAvailable(state, player) || transferCardBlock(state, player, card)) return null;
|
||||
try { return groundMovementProbe(applyActionInner(state, owner, { type: 'card', card: card.id }, 'movementPreparation')); }
|
||||
catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||
*preparations(state, owner) {
|
||||
const player = getPlayer(state, owner), paid = state.pendingNullentropy;
|
||||
const relevant = (card: Card) => card.effect === 'hajr' || card.effect === 'ornithopter' ||
|
||||
(player.faction === 'choam' && card.kind === 'worthless' && card.name === 'Kulon');
|
||||
if (paid) {
|
||||
if (paid.player !== owner || nullentropyIntegrity(state)) return;
|
||||
for (const card of eligibleNullentropyCards(state.discard).filter(relevant)) {
|
||||
const projected = projectMovementBoxRecovery(state, owner, paid.box, card.id, true);
|
||||
if (projected) yield { game: projected, actions: [{ type: 'decision', event: paid.event, card: card.id }] };
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const card of player.hand) {
|
||||
if (card.effect === 'hajr' && hajrAvailable(state, player) && !transferCardBlock(state, player, card)) {
|
||||
try { yield { game: groundMovementProbe(applyActionInner(state, owner,
|
||||
{ type: 'card', card: card.id }, 'movementPreparation')), actions: [{ type: 'card', card: card.id }] }; }
|
||||
catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
if (player.faction === 'choam' && card.kind === 'worthless' && card.name === 'Kulon') {
|
||||
const action: Action = { type: 'card', mode: 'choam', card: card.id };
|
||||
try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation')), actions: [action] }; }
|
||||
catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
if (richeseCardDefinition(card)?.card.effect === 'nullentropyBox') {
|
||||
// Never inspect unconfirmed private faces to advertise a pre-payment route.
|
||||
for (const known of knownMovementDiscards(state.movementDiscardKnowledge, owner, state.discard).filter(relevant)) {
|
||||
const projected = projectMovementBoxRecovery(state, owner, card.id, known.id, false);
|
||||
if (projected) yield { game: projected, actions: [{ type: 'card', card: card.id }] };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
reachable(state, player, origin, destination, sector) {
|
||||
// Include current native range (notably CHOAM Kulon) and the card's
|
||||
@@ -16455,8 +16534,10 @@ function groundMovementCompletion(source: Game, actor: string,
|
||||
});
|
||||
}
|
||||
function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null {
|
||||
if (g.truthtrance || !movementPromiseTargetAvailable(g, player.id) ||
|
||||
!liveMovementPromises(g.movementPromises ?? [], player.id, g.turn).length) return null;
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn);
|
||||
const paid = g.pendingNullentropy?.player === player.id;
|
||||
if (g.truthtrance || !live.length || (paid && !live.some(promise => promise.answer)) ||
|
||||
!movementPromiseTargetAvailable(paid ? { ...g, decision: null } : g, player.id)) return null;
|
||||
const completion = groundMovementCompletion(g, player.id);
|
||||
return completion ? completion.actions.length ? completion.actions.slice(0, 1) : [{ type: 'endMovement' }] : null;
|
||||
}
|
||||
@@ -27065,6 +27146,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
return;
|
||||
}
|
||||
if (pending.effect === 'kulon') {
|
||||
rememberMovementDiscards(g, [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]);
|
||||
if (g.semutaPreview && (used.name === 'Kulon' || pending.nexusEvent) &&
|
||||
!pending.storm && !pending.revival && !pending.movement &&
|
||||
!pending.mentat) {
|
||||
@@ -29990,7 +30072,7 @@ function gamontAvailable(
|
||||
e = p.elites?.forces[key] ?? 0;
|
||||
return elite === 1 ? e > 0 : elite === 0 && n > e;
|
||||
}
|
||||
function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
function playChoamWorthless(g: Game, p: Player, action: Action, movementPreparation = false) {
|
||||
const storming =
|
||||
g.decision?.kind === 'choamStorm' && g.decision.player === p.id;
|
||||
const reactive =
|
||||
@@ -30134,6 +30216,13 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
'That player is already prevented from free revival.',
|
||||
);
|
||||
}
|
||||
if (movementPreparation) {
|
||||
requireRule(effect === 'kulon' && !nexus, 'Only native Kulon is an owned movement preparation.');
|
||||
const used = discard(g, p, card.id);
|
||||
rememberMovementDiscards(g, [{ card: used, discardedBy: p.id, publicFace: true }]);
|
||||
completeChoamKulon(g, p);
|
||||
return;
|
||||
}
|
||||
g.pendingChoamWorthless = {
|
||||
owner: p.id,
|
||||
card: card.id,
|
||||
@@ -30609,8 +30698,13 @@ function refundStoppedShipmentKarama(g: Game, shipper: Player) {
|
||||
if (benefit?.player !== shipper.id) return;
|
||||
if (benefit.card && !benefit.costClaimed) {
|
||||
const index = g.discard.findIndex(card => card.id === benefit.card);
|
||||
if (index >= 0)
|
||||
getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1));
|
||||
if (index >= 0) {
|
||||
const [card] = g.discard.splice(index, 1);
|
||||
getPlayer(g, benefit.owner).hand.push(card);
|
||||
setMovementDiscardKnowledge(g, recordMovementClaimKnowledge(g.movementDiscardKnowledge,
|
||||
g.players.map(p => p.id), benefit.owner,
|
||||
[{ card, discardedBy: benefit.owner, publicFace: true }], card.id));
|
||||
}
|
||||
}
|
||||
g.karamaShipping = null;
|
||||
}
|
||||
@@ -31777,7 +31871,7 @@ function applyActionInner(
|
||||
// Conversion spends the selected card as fuel; its printed effect must not
|
||||
// intercept that explicit mode (including Recruits and other special faces).
|
||||
if (t === 'card' && action.mode === 'choam') {
|
||||
playChoamWorthless(g, p, action);
|
||||
playChoamWorthless(g, p, action, execution === 'movementPreparation');
|
||||
return g;
|
||||
}
|
||||
if (t === 'card' && action.card === RECRUITS_CARD_ID) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Card } from './cards';
|
||||
import type { FreshDiscardBatch } from './semuta-drug';
|
||||
|
||||
/** Remembered movement-relevant discard identities, never a public pile view. */
|
||||
export type MovementDiscardKnowledge = Record<string, string[]>;
|
||||
type Entries = FreshDiscardBatch['entries'];
|
||||
type Knowledge = MovementDiscardKnowledge | undefined;
|
||||
|
||||
const relevant = (card: Card): boolean =>
|
||||
(card.kind === 'special' && card.effect === 'hajr') ||
|
||||
(card.kind === 'special' && card.id === 'richese-ornithopter' && card.effect === 'ornithopter') ||
|
||||
(card.kind === 'worthless' && card.name === 'Kulon');
|
||||
|
||||
function viewers(seats: readonly string[]): string[] {
|
||||
return [...new Set(seats)].sort();
|
||||
}
|
||||
function remembered(state: Knowledge, seats: readonly string[]) {
|
||||
return new Map(viewers(seats).map(player => [player, new Set(state?.[player] ?? [])]));
|
||||
}
|
||||
function finish(knowledge: Map<string, Set<string>>): Knowledge {
|
||||
const entries = [...knowledge].filter(([, ids]) => ids.size)
|
||||
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
||||
.map(([player, ids]) => [player, [...ids].sort()] as const);
|
||||
return entries.length ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
/** Public disposal teaches every seat; private disposal teaches its actual
|
||||
* former holder only. Unrelated cards create no new preparation knowledge. */
|
||||
export function recordMovementDiscardKnowledge(
|
||||
state: Knowledge, seats: readonly string[], entries: Entries,
|
||||
): Knowledge {
|
||||
const knowledge = remembered(state, seats);
|
||||
for (const entry of entries) {
|
||||
if (!relevant(entry.card)) continue;
|
||||
for (const [player, known] of knowledge)
|
||||
if (entry.publicFace || entry.discardedBy === player) known.add(entry.card.id);
|
||||
}
|
||||
return finish(knowledge);
|
||||
}
|
||||
|
||||
/** Current remembered faces only. An unknown face cannot become a preparation
|
||||
* witness merely because the authoritative pile happens to contain it. */
|
||||
export function knownMovementDiscards(
|
||||
state: Knowledge, player: string, discard: readonly Card[],
|
||||
): Card[] {
|
||||
const known = new Set(state?.[player] ?? []), selected = new Set<string>();
|
||||
return structuredClone(discard.filter(card => {
|
||||
if (!known.has(card.id) || !relevant(card) || selected.has(card.id)) return false;
|
||||
selected.add(card.id); return true;
|
||||
}));
|
||||
}
|
||||
|
||||
/** A private Box choice may remove any remembered card. The paid search owner
|
||||
* alone knows the remaining eligible faces after the search has settled. */
|
||||
export function recordMovementBoxKnowledge(
|
||||
_state: Knowledge, seats: readonly string[], owner: string, remainingDiscard: readonly Card[],
|
||||
): Knowledge {
|
||||
const knowledge = remembered(undefined, seats);
|
||||
const own = knowledge.get(owner);
|
||||
if (own) for (const card of remainingDiscard) if (relevant(card)) own.add(card.id);
|
||||
return finish(knowledge);
|
||||
}
|
||||
|
||||
/** Others cannot infer the privately selected member of a fresh mixed batch.
|
||||
* The committed claimant has inspected that batch and knows its remainder. */
|
||||
export function recordMovementClaimKnowledge(
|
||||
state: Knowledge, seats: readonly string[], owner: string,
|
||||
batchEntries: Entries, claimedId: string,
|
||||
): Knowledge {
|
||||
const knowledge = remembered(state, seats);
|
||||
const possible = new Set(batchEntries.map(entry => entry.card.id));
|
||||
for (const known of knowledge.values()) for (const id of possible) known.delete(id);
|
||||
const own = knowledge.get(owner);
|
||||
if (own) for (const { card } of batchEntries)
|
||||
if (card.id !== claimedId && relevant(card)) own.add(card.id);
|
||||
return finish(knowledge);
|
||||
}
|
||||
|
||||
/** Rebuilding the draw deck removes all remembered discard preparation faces. */
|
||||
export function clearMovementDiscardKnowledge(_state: Knowledge): Knowledge {
|
||||
return undefined;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { liveMovementPromises, movementEventAllowed, settleMovementEvent, type M
|
||||
|
||||
export type MovementPromiseAdapter = {
|
||||
allowance: (game: Game, player: Player) => number;
|
||||
prepareHajr: (game: Game, player: string) => Game | null;
|
||||
preparations: (game: Game, player: string) => Iterable<{ game: Game; actions: Action[] }>;
|
||||
reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean;
|
||||
project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null;
|
||||
};
|
||||
@@ -31,7 +31,8 @@ function* groups(player: Player, keys: string[], amount: number, index = 0,
|
||||
}
|
||||
|
||||
/** Finite owned-action reachability. Each move consumes a real entitlement;
|
||||
* Hajr and Ornithopter are existing physical cards, never invented future draws.
|
||||
* Hajr, Ornithopter, Kulon and Box use owned physical cards and known or
|
||||
* already-paid recovery choices, never invented unknown future draws.
|
||||
* The adapter shares authoritative movement validation/physical commitment.
|
||||
* Opposing arrival outcomes are not simulated or assumed. */
|
||||
export function findMovementCompletion(source: Game, actor: string,
|
||||
@@ -44,13 +45,15 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
const physical = Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
|
||||
if (live.some(promise => promise.answer && promise.minimum > physical)) return null;
|
||||
const key = JSON.stringify([player.forces, player.elites, player.moved, player.hand.map(card => card.id),
|
||||
player.hajrPlays, player.ornithopterExtraMoves, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
|
||||
player.hajrPlays, player.ornithopterExtraMoves, player.spice, game.choamMovement,
|
||||
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
|
||||
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
|
||||
live.map(promise => [promise.territory, promise.minimum, promise.answer])]);
|
||||
if (failed.has(key)) return null;
|
||||
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
|
||||
destinations.sort((a, b) => Number(live.some(p => p.answer && p.territory === b.id)) -
|
||||
Number(live.some(p => p.answer && p.territory === a.id)));
|
||||
if (player.moved < adapter.allowance(game, player)) {
|
||||
if (!game.pendingNullentropy && player.moved < adapter.allowance(game, player)) {
|
||||
const origins = [...new Set(Object.keys(player.forces).filter(key => player.forces[key] > 0)
|
||||
.map(key => splitLocation(key).territory))];
|
||||
const card = !game.ornithopter ? player.hand.find(card => card.effect === 'ornithopter') : null;
|
||||
@@ -80,10 +83,8 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
}
|
||||
}
|
||||
}
|
||||
const prepared = adapter.prepareHajr(game, actor);
|
||||
if (prepared) {
|
||||
const card = player.hand.find(card => card.effect === 'hajr')!;
|
||||
const result = search(prepared, live, [...actions, { type: 'card', card: card.id }]);
|
||||
for (const prepared of adapter.preparations(game, actor)) {
|
||||
const result = search(prepared.game, live, [...actions, ...prepared.actions]);
|
||||
if (result) return result;
|
||||
}
|
||||
failed.add(key); return null;
|
||||
|
||||
+6
-5
@@ -2933,17 +2933,18 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Ask the active Basic player after shipment and finish pending decisions first. Selected native families are supported; optional modules, Advanced and earlier or waiting turns remain unfinished.',
|
||||
'Yes requires one qualifying ground movement from now until that player’s Shipment and Movement turn ends, while possible. Smaller groups do not add together and earlier moves do not count. Shipment, HMS relocation and Ambassador relocation do not fulfill this statement; a concealed No-Field marker itself is not a physical on-board force counter.',
|
||||
'A nonmatching move leaves Yes pending when an owned later route remains. One matching physical commitment fulfills it before card disposal and later Intrusion, Ambassador or Terror effects. You are promising the move, not survival afterward. No forbids every later matching move, including Hajr and both Ornithopter groups, until the actual turn closes.',
|
||||
'Private feasible answers and the next owned step use actual movement legality, typed groups, held Hajr, both Ornithopter modes and original cohorts, current city access and native bonuses such as Kulon. Unknown future draws/gifts are not invented. Other preparation families, including Box recovery and native relocation preparation, remain unfinished.',
|
||||
'Private feasible answers and the next owned step use actual movement legality, typed groups, held Hajr, both Ornithopter modes and original cohorts, current city access, held native Kulon and owned Box recovery. Before paying the two-spice Box fee, only a known card still in discard supplies a route; unknown future draws/gifts are not invented. Native relocation and wider preparation remain unfinished.',
|
||||
'A paid Box search retains its full private choice and original event. A guided choice preserves Yes; an incompatible selection rejects before shuffle and keeps that paid search available without another fee. No-only promises retain ordinary selection before movement can end. The existing provisional atomic full-hand exchange remains.',
|
||||
'Voluntary actions cannot erase the last required route. Native counter responses finish before impossibility is decided. An opposing loss releases only incompatible answers, retaining No constraints and still-possible Yes answers in accepted order. This owned-route and release priority is a provisional software composition of the general current-turn rule, not a publisher movement-specific FAQ.',
|
||||
'Persistent answer and next-step controls do not time out. All four profiles use the same private legal guidance. Broader Truthtrance questions, complete modes, full combinations and final acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'A pure ledger and finite owned-action search reuse authoritative declaration and physical commitment. Yes settles before arrivals/disposal; No closes only at actual turn end. Shared native movement, current Kulon and original card cohorts remain. Earlier timing, other preparations and optional/Advanced profiles stay unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Dedicated destination/minimum, exact public preview, private feasible answers and persistent busy-safe next actions have current Basic controls. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-movement-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles make twelve accepted answer/move/end proposals from real public Basic source views. This is minimal legal participation without strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-engine.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'A pure ledger and finite owned-action search reuse authoritative declaration and physical commitment. Add held native Kulon and paid Box recovery using known current-discard identities before payment. Original paid event, fee, full choice, physical replay and provisional full-hand policy remain; wrong Yes selections reject before shuffle. Earlier timing, unknown private possibilities, wider preparations/counter mitigation and optional/Advanced stay unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Dedicated destination/minimum, public preview, private feasible answers and persistent busy-safe next actions include Box payment, original paid selection and held Kulon. Full paid inspection remains; No-only searches cannot offer an invalid End action. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-controls.test.tsx', 'tests/truth-movement-preparation-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles prioritize the compliant original paid selection and finish real Box/Hajr/move/end continuations with40 accepted actions including neutral passes. Earlier twelve answer/move/end proposals remain historical. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-engine.test.ts', 'tests/truth-movement-preparation-engine.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, existential single-event meaning, explicit software composition, source selection and unfinished scope are documented.', evidence: ['docs/TRUTHTRANCE_MOVEMENT_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Seventeen-file218/218, types/lint/build and seven original-action promise-handler/table-rendering programmes pass. Public classic source20, native paid Ornithopter source58 and genuine Kulon source26 retain actual pieces/cards/payments; only unopened positions and scalar lotteries are selected. Separate extra-card/force-loss edges disclose conserved staging. Review corrected Kulon reachability/selective release; private preparation and old fixture feedback were repaired. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-promises.test.ts', 'tests/truth-movement-engine.test.ts', 'tests/truth-movement-controls.test.tsx', 'tests/fixture-truth-movement.ts', 'tests/fixture-truth-movement-choam.ts', 'tests/fixture-basic-semuta-ambassador.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Current seventeen-file231/231, types/lint/build and three original-action promise/paid-choice-handler/table-rendering programmes pass. Public Box source44 retains purchases1+1, shipment6 and exhausted real Hajr; held-Kulon source25 precedes route4. Separate full-hand/legacy-knowledge/fee-loss/counter edges disclose controlled conservation. Two old Auditor fixtures reproduce on the previous engine and now acknowledge actual battle results. Earlier218/218 movement evidence remains historical. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-discard-knowledge.test.ts', 'tests/truth-movement-preparation-engine.test.ts', 'tests/truth-movement-preparation-controls.test.tsx', 'tests/fixture-truth-movement-box.ts', 'tests/fixture-hajr-replay.ts', 'tests/fixture-truth-movement-choam.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user