Support typed Advanced Truthtrance shipment commitments

Extend the existing reserve-shipment promise pipeline to base Advanced
games without Guild or optional modules. Share scope between parser, save
validation and UI. Reuse canonical Ghola normal/elite revival eligibility
and retain live BG conversion responses while proving conditional private
feasibility. Hide future-price guidance until pending responses finish.
Show exact Sardaukar/Fedaykin allocation in private executable steps.

Validation: types, lint and all 4,192 offline tests pass; production build
and all 40 HTTP tests pass. 41 focused shipment tests include 15 new cases
covering genuine Advanced setup, all five non-Guild factions and four bot
profiles, typed Ghola quota, BG allowance/cancellation/alternatives,
private views, saved continuations and production SQLite duplicate races.
Independent review found and resolved optional-module scope and pending
price-guidance issues. Browser rooms EUZZ8D4M and GWNCVCAP verified an AI No
route and human Yes with four-force/one-Sardaukar Ghola then six-force
shipment. Phone settlement controls and refresh passed. Both existing
browser room hashes/versions preserved through HTTP tests; 30 rooms added.

Full source-bound report: 20260913-advanced-shipment-final; fingerprint
6c2f29d9b3e61bf6b9544ee3a95c47538abfb05e1c3b2ae9d0a603ef0cef38dd
Broad checks ran once after focused fixes/review. No full-game AI study
or complete Advanced mode acceptance is claimed; all existing release
gates and pending Guild settlement questions remain intact.
This commit is contained in:
Quintine committed 2026-09-13 11:08:29 +10:00
1 parent 6e26ba5daa
commit 32791ed5e2
14 files changed
+902 -38

No files matched your search

+24 -9
View File
@@ -273,6 +273,7 @@ import {
import {
matchesShipment,
liveShipmentPromises,
shipmentPromiseModeSupported,
type ShipmentClaim,
type ShipmentPromise,
} from './shipment-promises';
@@ -12486,16 +12487,26 @@ function shipmentPromiseIntegrity(g: Game) {
);
if (promise.turn === g.turn && !promise.released && !promise.fulfilled)
requireRule(
!g.advanced &&
!g.expansions.length &&
shipmentPromiseModeSupported(g) &&
g.status === 'playing' &&
g.phase === 5 &&
g.active === promise.player &&
shipmentAvailable(g,getPlayer(g,promise.player)),
'The saved shipment promise does not belong to this unused Basic shipment opportunity.',
'The saved shipment promise does not belong to a supported unused shipment opportunity.',
);
}
}
/** A pending conversion may succeed. Resolve only its shipment effect in the
* private reachability copy; the live table retains every opponent response. */
function projectPendingShipmentKarama(g: Game, player: string) {
const pending = g.pendingKarama;
if (g.response?.kind !== 'worthlessKarama' || !pending ||
g.response.owner !== pending.owner || pending.use.kind !== 'shipment' ||
pending.use.recipient !== player) return;
g.response = null;
g.pendingKarama = null;
completeKarama(g, getPlayer(g, pending.owner), pending.use);
}
/** Search owned preparation and canonical shipment quotes, never the AI's scored shortlist. */
function findShipmentCompletion(
state: Game,
@@ -12517,6 +12528,7 @@ function findShipmentCompletion(
return { actions: [{ type: 'endMovement' }] };
const initial = structuredClone(state);
initial.truthtrance = null;
projectPendingShipmentKarama(initial, owner.id);
// Test choices after the interrupt, while leaving the real continuation intact.
// Every actual action still revalidates its timing, quote and physical custody.
initial.response = null;
@@ -12628,8 +12640,8 @@ function findShipmentCompletion(
}
}
}
// These Basic preparations are deterministic and use only the respondent's
// own cards and recoverable escrow. More reserves/lower cost cannot remove
// Preparations use only owned cards, recoverable escrow and already-pending
// conversion effects. More reserves/lower cost cannot remove
// a previously available choice of physical shipment count.
const preparations: Action[] = [];
if (g.aid[p.id]?.amount > 0)
@@ -12642,16 +12654,18 @@ function findShipmentCompletion(
mode: 'shipment',
target: p.id,
});
const ghola = p.hand.find((c) => c.effect === 'ghola');
if (ghola && p.tanks > 0)
const ghola = gholaOptions(g, p);
if (ghola.available && ghola.maxForces > 0)
preparations.push({
type: 'card',
card: ghola.id,
amount: Math.min(5, p.tanks),
card: ghola.cards[0],
amount: ghola.maxForces,
elite: Math.max(0, ghola.maxForces - (p.tanks - (p.elites?.tanks ?? 0))),
});
for (const action of preparations) {
try {
const next = applyActionInner(g, p.id, action, 'shipmentPreparation');
projectPendingShipmentKarama(next, p.id);
queue.push({ game: next, actions: [...actions, action] });
} catch (error) {
if (!(error instanceof RuleError)) throw error;
@@ -21194,6 +21208,7 @@ export function viewGame(state: Game, id: string) {
shipmentPromises: g.shipmentPromises ?? [],
shipmentCompletion:
!g.pendingTreacheryDiscard &&
!g.response && !g.pendingKarama &&
liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn).some(
(p) => p.answer,
)