Support Basic No-Field Guild Ambassador shipments

Connect native concealment and allied supplier/recipient consent to the independent grant, with explicit provisional cap and transport-fee policy. Preserve actual tokens, ordinary allowances, source-scoped Karama prevention and original income/arrival continuation.

Add genuine three/four-seat sources, focused quote/control/engine cases and bounded runtime/minimal-bot proof. Advanced and optional profiles retain their existing guards.
This commit is contained in:
Quintine committed 2026-10-24 17:36:12 +11:00
1 parent 4b2f4f3fa3
commit 659e2a4a9a
19 files changed
+2221 -56

No files matched your search

+6
View File
@@ -1950,6 +1950,12 @@ function policyActions(g: GameView): Action[] {
const entry = g.ambassadorEntry;
if (!entry) return [];
const action = { type: 'decision' as const, event: entry.event };
if (entry.noField && (entry.noField.stage === 'supply' || entry.noField.stage === 'consent' ||
(entry.noField.stage === 'own' && !entry.shipment?.maximum))) {
const choice = entry.noField.options.find(option => !option.blocked);
if (choice) return [choice.action];
if (entry.noField.decline) return [entry.noField.decline];
}
const canBuyRichese =
(me.spice ?? 0) >= 3 &&
(me.hand?.length ?? me.handLimit) < me.handLimit;
+267 -20
View File
@@ -143,6 +143,8 @@ import {
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt';
import { arrivalOverlapSignature, overlapOwnsAmbassadorDiscard, overlapOwnsTerrorDiscard, overlapOwnsWormTerror } from './arrival-overlap';
import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuildAmbassadorMarker,
type GuildAmbassadorMarker, type GuildAmbassadorMarkerQuote } from './guild-ambassador-no-field';
import {
homeworldLowBonus,
homeworldRevivalKaramaBlock,
@@ -1068,7 +1070,7 @@ export type ResponseWindow = {
occupiedPayment?: { event: string; contributions: { payer: string; amount: number }[] };
/** Bank-paid portion of a frozen Homeworld charity claim. */
charityHomeworld?: number;
source?: 'ambassador' | 'ambassadorRevival';
source?: 'ambassador' | 'ambassadorRevival' | 'ambassadorNoField' | 'ambassadorNoFieldIncome';
intent?: string;
advisors?: boolean;
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
@@ -1188,6 +1190,7 @@ type PendingShipment = {
homeworldSources?: NativeReserveSelections;
source?: 'ambassador';
ambassadorEvent?: string;
ambassadorMarker?: GuildAmbassadorMarker;
/** Absent in legacy rooms; new declarations cannot resume in a later turn. */
turn?: number;
player: string;
@@ -1759,13 +1762,19 @@ export type Game = {
| 'revival'
| 'arrival';
nexusPurchase?: { event: string; signature: string };
guildMarkerBlocked?: true;
guildMarkerRequest?: {
owner: string; territory: string; sector: number; tokenEvent: string;
offer?: GuildAmbassadorMarkerQuote;
};
relocation?: {
order: FremenAmbassadorMove;
next: 'flip' | 'intrusion' | 'terror' | 'finish';
};
shipmentReceipt?: {
order: GuildAmbassadorShipment;
next: 'intrusion' | 'terror' | 'accompany' | 'advisor' | 'finish';
next: 'income' | 'intrusion' | 'terror' | 'accompany' | 'advisor' | 'finish';
markerCommitSignature?: string;
advisorArrival?: {
player: string;
territory: string;
@@ -14631,6 +14640,10 @@ function choamSaleCancellationQuote(g: Game, response: ResponseWindow) {
}
function noFieldCancellationQuote(g: Game, response: ResponseWindow) {
if (response.kind !== 'richeseNoField') return null;
if (response.source === 'ambassadorNoField') {
const shipment = validateAmbassadorMarkerResponse(g,response);
return {kind:'own' as const,player:shipment.ambassadorMarker!.owner,blockedTurn:g.turn,pendingShipment:null};
}
try {
return quoteNoFieldCancellation(g, response);
} catch (error) {
@@ -15463,7 +15476,7 @@ function finishAutomaticDecision(g: Game): boolean {
const choices = guildAmbassadorRule(() =>
guildAmbassadorShipments(g, beneficiary.id),
);
if (!choices.maximum || !choices.destinations.length) {
if ((!choices.maximum || !choices.destinations.length) && !guildMarkerAvailable(g,beneficiary.id)) {
g.decision = null;
log(
g,
@@ -17559,7 +17572,8 @@ function ambassadorRelocationIntegrity(g: Game) {
if (entry?.effect === 'guild' && entry.stage === 'arrival') {
const next = entry.shipmentReceipt?.next;
requireRule(
next === 'terror' ||
next === 'income' ||
next === 'terror' ||
next === 'accompany' ||
next === 'advisor' ||
next === 'finish',
@@ -17620,7 +17634,10 @@ function ambassadorRelocationIntegrity(g: Game) {
'This saved Terror does not match its Guild Ambassador arrival.',
);
requireRule(
next === 'accompany' || next === 'finish'
next === 'income'
? responses.some(response => response.kind === 'guildIncome' && response.source === 'ambassadorNoFieldIncome' &&
response.intent === entry.event && response.owner === byFaction(g,'guild')?.id && response.amount === order.cost)
: next === 'accompany' || next === 'finish'
? relatedTerror
: relatedResponses.length +
relatedDecisions.length +
@@ -17641,6 +17658,7 @@ function ambassadorRelocationIntegrity(g: Game) {
if (entry?.stage === 'move') currentFremenAmbassador(g);
if (entry?.stage === 'ship') {
const { beneficiary } = currentGuildAmbassador(g);
markerRequestIntegrity(g);
const selections = decisions.filter(
(decision) => decision.kind === 'ecazAmbassador',
);
@@ -17648,19 +17666,22 @@ function ambassadorRelocationIntegrity(g: Game) {
(decision) => decision.kind === 'guildShipment',
);
if (g.pendingShipment) {
requireRule(
g.pendingShipment.source === 'ambassador' &&
g.phase === 5 &&
!selections.length &&
stops.length > 0,
'The pending Guild Ambassador shipment has lost its shipment decision.',
);
for (const stop of stops) validateGuildShipmentDecision(g, stop);
if (g.pendingShipment.ambassadorMarker && !stops.length) {
const markerResponses = responses.filter(response => response.source === 'ambassadorNoField');
requireRule(!selections.length && markerResponses.length > 0,
'The independent marker shipment has lost its original response.');
for (const response of markerResponses) validateAmbassadorMarkerResponse(g,response);
} else {
requireRule(g.pendingShipment.source === 'ambassador' && g.phase === 5 &&
!selections.length && stops.length > 0,
'The pending Guild Ambassador shipment has lost its shipment decision.');
for (const stop of stops) validateGuildShipmentDecision(g, stop);
}
} else
requireRule(
!stops.length &&
selections.length > 0 &&
selections.every((decision) => decision.player === beneficiary.id),
selections.every((decision) => decision.player === (entry.guildMarkerRequest && !entry.guildMarkerRequest.offer ? entry.guildMarkerRequest.owner : beneficiary.id)),
'The Guild Ambassador has lost its beneficiary shipment selection.',
);
}
@@ -17844,11 +17865,164 @@ function guildAmbassadorRule<T>(calculate: () => T): T {
try {
return calculate();
} catch (error) {
if (error instanceof GuildAmbassadorShipmentError)
if (error instanceof GuildAmbassadorShipmentError || error instanceof GuildAmbassadorMarkerError)
throw new RuleError(error.message);
throw error;
}
}
const GUILD_MARKER_POLICY = 'Provisional Guild Ambassador composition: a concealed native No-Field counts as one and ships free. An allied marker reveals immediately, must fit the four-force grant, and retains the printed one/two-spice transport payment to Guild or Bank. No ordinary shipment is spent.';
type GuildMarkerView = {
stage: 'own' | 'request' | 'supply' | 'consent'; policy: string;
options: {key: string; label: string; territory?: string; sector?: number; action: Action; blocked: string | null}[];
decline?: Action; declineLabel?: string;
};
function guildMarkerAvailable(g: Game, beneficiary: string): boolean {
return bareBasicSemutaProfile(g) && !g.pendingAmbassador?.guildMarkerBlocked && !!markerOwner(g, beneficiary);
}
function quoteAmbassadorMarker(g: Game, beneficiary: string, fields: {
tokenId: string; tokenEvent: string; territory: string; sector: number; payer?: string; elite?: number;
}, checkFunds = true): GuildAmbassadorMarkerQuote {
requireRule(guildMarkerAvailable(g, beneficiary), 'This independent No-Field grant is unavailable.');
const quote = guildAmbassadorRule(() => quoteGuildAmbassadorMarker(g, beneficiary, fields, checkFunds));
requireRule(uncommittedSpice(g, getPlayer(g, quote.marker.owner)) >= quote.marker.ownerPayment,
'The supplier needs enough uncommitted spice for this transport payment.');
if (checkFunds) requireRule(uncommittedSpice(g, getPlayer(g, beneficiary)) >= quote.marker.recipientPayment,
'The recipient needs enough uncommitted spice for this transport payment.');
return quote;
}
function markerRequestIntegrity(g: Game) {
const entry = g.pendingAmbassador, request = entry?.guildMarkerRequest;
if (!request) return;
const owner = entry.beneficiary && markerOwner(g, entry.beneficiary);
requireRule(entry.stage === 'ship' && entry.effect === 'guild' && owner &&
owner.id !== entry.beneficiary && owner.id === request.owner && owner.noFieldEvent === request.tokenEvent &&
guildMarkerAvailable(g, entry.beneficiary!) && validGameLocation(g, request.territory, request.sector),
'This allied No-Field request has lost its original supplier or grant.');
if (request.offer) {
const order = request.offer;
requireRule(order.player === entry.beneficiary && order.territory === request.territory &&
order.sector === request.sector && order.marker.owner === request.owner &&
order.marker.tokenEvent === request.tokenEvent,
'The allied marker offer differs from its requested arrival.');
}
}
function ambassadorMarkerView(g: Game, id: string): GuildMarkerView | null {
const entry = g.pendingAmbassador;
if (!entry || entry.stage !== 'ship' || entry.effect !== 'guild' ||
g.decision?.kind !== 'ecazAmbassador' || g.decision.player !== id ||
!entry.beneficiary || !guildMarkerAvailable(g, entry.beneficiary)) return null;
markerRequestIntegrity(g);
const beneficiary = getPlayer(g, entry.beneficiary), supplier = markerOwner(g, beneficiary.id)!;
const request = entry.guildMarkerRequest;
const view: GuildMarkerView = {stage: request ? request.offer ? 'consent' : 'supply' :
supplier.id === beneficiary.id ? 'own' : 'request', policy: GUILD_MARKER_POLICY, options: []};
const add = (label: string, action: Action, at?: {territory: string; sector: number}, blocked: string | null = null) =>
view.options.push({key: JSON.stringify(action), label, action, blocked, ...at});
if (request?.offer) {
const offer = request.offer;
let blocked: string | null = null;
try { quoteAmbassadorMarker(g, beneficiary.id, {...offer.marker, territory: offer.territory,
sector: offer.sector, elite: offer.elite}); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; }
add(`Accept No-Field ${offer.marker.value}: ${offer.amount} physical forces to ${territory(offer.territory).name}, sector ${offer.sector}. ${getPlayer(g, offer.marker.payer === 'both' ? supplier.id : offer.marker.payer).name}${offer.marker.payer === 'both' ? ' and recipient each pay 1' : ` pays ${offer.cost}`} spice to Guild or Bank.`,
{type: 'decision', event: entry.event, acceptNoField: true}, undefined, blocked);
} else if (supplier.noField?.deployed && supplier.id === beneficiary.id) {
add('Reveal your existing No-Field before choosing another marker', {type:'decision', event:entry.event, revealNoField:true});
} else {
let destinations: ReturnType<typeof markerDestinations> = [];
try { destinations = guildAmbassadorRule(() => markerDestinations(g, beneficiary.id)); } catch (error) { if (!(error instanceof RuleError)) throw error; }
if (view.stage === 'request') {
for (const at of destinations) add(`Ask Richese for a No-Field shipment to ${territory(at.territory).name}, sector ${at.sector}`,
{type:'decision', event:entry.event, requestNoField:true, territory:at.territory, sector:at.sector}, at);
} else for (const at of request ? [{territory:request.territory,sector:request.sector}] : destinations) {
for (const token of supplier.noField!.tokens.filter(token => token.id !== supplier.noField!.lastShipped)) {
const cost = territory(at.territory).type === 'stronghold' ? 1 : 2;
for (const payer of view.stage === 'own' ? [supplier.id] : [supplier.id,beneficiary.id,...(cost === 2 ? ['both'] : [])]) {
try {
const order = quoteAmbassadorMarker(g, beneficiary.id, {tokenId:token.id, tokenEvent:supplier.noFieldEvent!, ...at, payer}, view.stage === 'own');
add(view.stage === 'own' ? `Ship concealed No-Field ${token.value} for free to ${territory(at.territory).name}, sector ${at.sector}` :
`Offer No-Field ${token.value}: ${order.amount} forces; ${payer === 'both' ? 'each ally pays 1 spice' : `${getPlayer(g,payer).name} pays ${order.cost} spice`}`,
view.stage === 'own' ? {type:'decision',event:entry.event,amount:1,noField:token.id,noFieldEvent:supplier.noFieldEvent, ...at} :
{type:'decision',event:entry.event,offerNoField:token.id,noFieldEvent:supplier.noFieldEvent,payer,elite:order.elite}, at);
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
}
}
}
if (request) { view.decline = {type:'decision',event:entry.event,declineNoField:true}; view.declineLabel = 'Return to the original shipment choice'; }
return view;
}
function declareAmbassadorMarker(g: Game, order: GuildAmbassadorMarkerQuote) {
const entry = g.pendingAmbassador!;
validateAmbassadorShipmentArrival(g, order);
const {marker,...shipment} = order;
g.pendingShipment = {...shipment, ambassadorMarker:marker, turn:g.turn, source:'ambassador', ambassadorEvent:entry.event};
g.response = {kind:'richeseNoField',owner:order.marker.owner,passed:[],source:'ambassadorNoField',intent:entry.event};
g.decision = null;
log(g, 'A No-Field substitution was declared for the independent Guild Ambassador shipment. Its payment, token and forces wait for the response.');
}
function validateAmbassadorMarkerResponse(g: Game, response: ResponseWindow) {
const shipment = g.pendingShipment, entry = g.pendingAmbassador;
requireRule(response.kind === 'richeseNoField' && response.source === 'ambassadorNoField' &&
entry?.stage === 'ship' && entry.effect === 'guild' && response.intent === entry.event &&
shipment?.source === 'ambassador' && shipment.ambassadorEvent === entry.event &&
shipment.ambassadorMarker?.owner === response.owner,
'The No-Field response has lost its original independent grant.');
validateAmbassadorShipmentOrder(g, shipment);
return shipment;
}
function decideAmbassadorMarker(g: Game, actor: Player, action: Action): boolean {
const {entry,beneficiary} = currentGuildAmbassador(g, stringField(action.event));
const request = entry.guildMarkerRequest;
markerRequestIntegrity(g);
if (request) {
if (action.declineNoField === true) {
delete entry.guildMarkerRequest;
g.decision = {kind:'ecazAmbassador',player:beneficiary.id};
return true;
}
if (!request.offer) {
requireRule(actor.id === request.owner, 'Richese chooses its own physical marker and payment.');
request.offer = quoteAmbassadorMarker(g, beneficiary.id, {tokenId:stringField(action.offerNoField),
tokenEvent:stringField(action.noFieldEvent), territory:request.territory, sector:request.sector,
payer:stringField(action.payer), elite:action.elite === undefined ? undefined : integer(action.elite,0,4,'Elite forces')}, false);
g.decision = {kind:'ecazAmbassador',player:beneficiary.id};
} else {
requireRule(actor.id === beneficiary.id && action.acceptNoField === true, 'Accept or decline this allied No-Field offer.');
const offer = request.offer;
const quoted = quoteAmbassadorMarker(g,beneficiary.id,{...offer.marker, territory:offer.territory,sector:offer.sector,elite:offer.elite});
requireRule(JSON.stringify(quoted) === JSON.stringify(offer), 'The offered token, physical forces or payment changed. Request a fresh offer.');
declareAmbassadorMarker(g,quoted);
}
return true;
}
if (action.requestNoField === true) {
const supplier = markerOwner(g,beneficiary.id);
requireRule(guildMarkerAvailable(g,beneficiary.id) && supplier && supplier.id !== beneficiary.id,
'A current Richese ally must choose and authorize its own marker.');
const to = stringField(action.territory), sector = integer(action.sector,0,18,'Sector');
requireRule(guildAmbassadorRule(() => markerDestinations(g,beneficiary.id)).some(at => at.territory === to && at.sector === sector), 'Choose an available independent shipment destination.');
entry.guildMarkerRequest = {owner:supplier.id,tokenEvent:supplier.noFieldEvent!,territory:to,sector};
g.decision = {kind:'ecazAmbassador',player:supplier.id};
return true;
}
if (action.revealNoField === true) {
requireRule(guildMarkerAvailable(g,beneficiary.id) && actor.id === beneficiary.id && actor.faction === 'richese' && actor.noField?.deployed,
'Only Richese may reveal its existing marker before its independent shipment.');
revealPlayerNoField(g,actor,'voluntary');
g.decision = {kind:'ecazAmbassador',player:beneficiary.id};
return true;
}
if (action.noField !== undefined) {
requireRule(actor.id === beneficiary.id && actor.faction === 'richese' && action.amount === 1 &&
(action.elite === undefined || action.elite === 0) &&
(action.allyPayment === undefined || action.allyPayment === 0) && action.alliedNoField === undefined,
'Substitute one native marker for the whole grant, without additional counters.');
declareAmbassadorMarker(g,quoteAmbassadorMarker(g,beneficiary.id,{tokenId:stringField(action.noField),
tokenEvent:stringField(action.noFieldEvent),territory:stringField(action.territory),sector:integer(action.sector,0,18,'Sector')}));
return true;
}
return false;
}
function quoteAmbassadorShipment(g: Game, action: Action) {
const { beneficiary } = currentGuildAmbassador(g, stringField(action.event));
return guildAmbassadorRule(() =>
@@ -17862,7 +18036,7 @@ function offerAmbassadorShipment(g: Game) {
const choices = guildAmbassadorRule(() =>
guildAmbassadorShipments(g, beneficiary.id),
);
if (choices.maximum && choices.destinations.length)
if ((choices.maximum && choices.destinations.length) || guildMarkerAvailable(g,beneficiary.id))
g.decision = { kind: 'ecazAmbassador', player: beneficiary.id };
else {
log(
@@ -17899,6 +18073,20 @@ function validateAmbassadorShipmentOrder(g: Game, shipment: PendingShipment) {
!shipment.alliedNoField,
'This declaration does not match its independent Guild Ambassador shipment.',
);
if (shipment.ambassadorMarker) {
const marker = shipment.ambassadorMarker;
const order = quoteAmbassadorMarker(g,beneficiary.id,{...marker,territory:shipment.territory,
sector:shipment.sector,elite:shipment.elite});
requireRule(shipment.amount === order.amount && shipment.elite === order.elite &&
shipment.cost === order.cost && shipment.allyPayment === 0 && shipment.advisors === order.advisors &&
JSON.stringify(marker) === JSON.stringify(order.marker),
'The declared independent marker changed its original token, payment or force allocation.');
if (marker.mode === 'allied') requireRule(entry.guildMarkerRequest?.offer &&
JSON.stringify(entry.guildMarkerRequest.offer) === JSON.stringify(order),
'This allied marker has no matching supplier offer and recipient consent.');
validateAmbassadorShipmentArrival(g,order);
return order;
}
const order = quoteAmbassadorShipment(g, {
type: 'decision',
event: entry.event,
@@ -17916,8 +18104,46 @@ function validateAmbassadorShipmentOrder(g: Game, shipment: PendingShipment) {
validateAmbassadorShipmentArrival(g, order);
return order;
}
function commitAmbassadorMarker(g: Game, order: GuildAmbassadorShipment & {marker: GuildAmbassadorMarker}) {
const entry = g.pendingAmbassador!, p = getPlayer(g,order.player), marker = order.marker;
const supplier = getPlayer(g,marker.owner);
if (marker.mode === 'allied' && supplier.noField!.deployed) revealPlayerNoField(g,supplier,'beforeAlly');
if (marker.mode === 'own') supplier.noField = noFieldRule(() => deployRicheseNoField(supplier.noField!,{
tokenId:marker.tokenId,controller:p.id,location:{territory:order.territory,sector:order.sector},
}));
else {
const result = noFieldRule(() => shipAlliedRicheseNoField(supplier.noField!,{
tokenId:marker.tokenId,controller:p.id,location:{territory:order.territory,sector:order.sector},
},p.reserves));
requireRule(result.forces === order.amount, 'The approved marker no longer matches its physical arrival.');
supplier.noField = result.state;
p.reserves -= order.amount;
if (p.elites) p.elites.reserves -= order.elite;
if (order.amount) place(p,order.territory,order.sector,order.amount,order.elite);
}
supplier.noFieldEvent = crypto.randomUUID();
supplier.spice -= marker.ownerPayment;
p.spice -= marker.recipientPayment;
observeOccupation(g);
delete entry.guildMarkerRequest;
entry.stage = 'arrival';
const guild = byFaction(g,'guild');
entry.shipmentReceipt = {order,markerCommitSignature:JSON.stringify(order),next:order.cost > 0 && guild ? 'income' : 'intrusion'};
log(g, marker.mode === 'own'
? `${p.name} shipped one concealed No-Field for free through the Guild Ambassador. Its value remains private and ordinary shipment is unchanged.`
: `${supplier.name}'s No-Field ${marker.value} immediately revealed ${order.amount} ${p.name} forces through the Guild Ambassador. The allies paid ${order.cost} spice to ${guild ? 'Guild' : 'Bank'} under the provisional independent-grant policy; ordinary shipment is unchanged.`,
{faction:p.faction,name:'Guild Ambassador No-Field shipment'});
if (entry.shipmentReceipt.next === 'income') {
g.response = {...guildPaymentResponse(g,guild!.id,[marker.ownerPayment,marker.recipientPayment],undefined,
[{payer:supplier.id,amount:marker.ownerPayment},{payer:p.id,amount:marker.recipientPayment}]),
source:'ambassadorNoFieldIncome',intent:entry.event};
return;
}
continueGuildAmbassadorShipment(g,entry.event);
}
function commitAmbassadorShipment(g: Game, shipment: PendingShipment) {
const order = validateAmbassadorShipmentOrder(g, shipment);
if (order.marker) { commitAmbassadorMarker(g,{...order,marker:order.marker}); return; }
const { entry, beneficiary: p } = currentGuildAmbassador(
g,
shipment.ambassadorEvent,
@@ -17956,6 +18182,7 @@ function continueGuildAmbassadorShipment(g: Game, event: string) {
const receipt = entry.shipmentReceipt;
const { order } = guildAmbassadorArrival(g, event, receipt.next);
const p = getPlayer(g, order.player);
if (receipt.next === 'income') return;
if (receipt.next === 'intrusion') {
receipt.next = 'terror';
if (intrusion(g, p, order.territory, { ambassadorEvent: event })) return;
@@ -17968,7 +18195,7 @@ function continueGuildAmbassadorShipment(g: Game, event: string) {
p,
order.territory,
order.sector,
order.amount,
order.marker ? Math.max(1,order.amount) : order.amount,
order.elite,
'ambassador',
'ambassador',
@@ -19099,7 +19326,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
const owner = getPlayer(g, entry.owner),
entrant = getPlayer(g, entry.entrant);
requireRule(
p.id === (entry.stage === 'offer' ? owner.id : entry.beneficiary),
p.id === (entry.stage === 'offer' ? owner.id : entry.stage === 'ship' && entry.guildMarkerRequest && !entry.guildMarkerRequest.offer ? entry.guildMarkerRequest.owner : entry.beneficiary),
'The Ambassador choice belongs to another player.',
);
if (entry.stage === 'loan') {
@@ -19374,6 +19601,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
resolveAmbassadorEffect(g);
} else if (entry.stage === 'ship') {
currentGuildAmbassador(g, stringField(action.event));
if (decideAmbassadorMarker(g,p,action)) return;
if (action.amount === 0) {
requireRule(
action.noField === undefined && action.alliedNoField === undefined,
@@ -27116,6 +27344,15 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
g.decision = resume.decision;
g.pendingKarama = resume.pendingKarama;
g.phaseOpening = resume.phaseOpening;
} else if (response.kind === 'richeseNoField' && response.source === 'ambassadorNoField') {
const shipment = validateAmbassadorMarkerResponse(g,response), entry = g.pendingAmbassador!;
g.pendingShipment = null;
if (canceled) {
entry.guildMarkerBlocked = true;
delete entry.guildMarkerRequest;
g.decision = {kind:'ecazAmbassador',player:entry.beneficiary!};
log(g,'Karama prevented No-Field substitution for this independent Guild Ambassador grant. No payment, force or token changed; physical reserve shipment remains available.');
} else offerShipment(g,shipment);
} else if (response.kind === 'richeseNoField') {
const quote = canceled ? noFieldCancellationQuote(g, response) : null;
const shipment = g.pendingShipment;
@@ -27983,6 +28220,15 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
name: canceled ? 'Shipment income prevented' : 'Shipment income',
},
);
if (response.source === 'ambassadorNoFieldIncome') {
const entry = g.pendingAmbassador!;
requireRule(entry?.stage === 'arrival' && entry.shipmentReceipt?.next === 'income' &&
response.intent === entry.event && response.owner === byFaction(g,'guild')?.id &&
response.amount === entry.shipmentReceipt.order.cost,
'This Guild income has lost its committed No-Field shipment.');
entry.shipmentReceipt.next = 'intrusion';
continueGuildAmbassadorShipment(g,entry.event);
}
} else if (response.kind === 'harkonnenTraitor') {
const b = g.battle!;
if (canceled) b.traitorCalls[response.owner] = false;
@@ -29188,11 +29434,11 @@ function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, can
function offerShipment(g: Game, shipment: PendingShipment) {
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
if (!shipment.ambassadorMarker) checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
const p = getPlayer(g, shipment.player),
id = p.id;
const { territory: to, sector: s } = shipment;
const n = shipment.alliedNoField ? 1 : shipment.amount;
const n = shipment.alliedNoField || shipment.ambassadorMarker ? 1 : shipment.amount;
const guild = byFaction(g, 'guild');
if (
g.advanced &&
@@ -36057,6 +36303,7 @@ export function viewGame(state: Game, id: string) {
stage: entry.stage,
effect: entry.effect ?? token.effect,
beneficiary: entry.beneficiary ?? null,
noField: ambassadorMarkerView(g, id),
shipment:
entry.stage === 'ship' && id === entry.beneficiary
? (() => {
+19 -3
View File
@@ -26,6 +26,7 @@ export type GuildAmbassadorArrivalContext = Pick<
| 'lastBattleContext'
>;
export type GuildAmbassadorArrivalNext =
| 'income'
| 'intrusion'
| 'terror'
| 'accompany'
@@ -78,7 +79,7 @@ export function validateGuildAmbassadorArrivalContext(
entry.phase === g.phase &&
id(event) &&
entry.event === event &&
['intrusion', 'terror', 'accompany', 'advisor', 'finish'].includes(
['income', 'intrusion', 'terror', 'accompany', 'advisor', 'finish'].includes(
next,
) &&
record(entry.shipmentReceipt) &&
@@ -134,11 +135,11 @@ export function validateGuildAmbassadorArrivalContext(
record(order) &&
order.player === beneficiary.id &&
whole(order.amount) &&
order.amount >= 1 &&
(order.amount >= 1 || !!order.marker) &&
order.amount <= 4 &&
whole(order.elite) &&
order.elite <= order.amount &&
order.cost === 0 &&
(order.marker ? [0,1,2].includes(order.cost) : order.cost === 0) &&
order.allyPayment === 0 &&
typeof order.advisors === 'boolean' &&
boardLocation(order.territory, order.sector) &&
@@ -146,6 +147,21 @@ export function validateGuildAmbassadorArrivalContext(
!Object.hasOwn(order, 'alliedNoField'),
'The saved Guild Ambassador receipt is not a physical free reserve shipment.',
);
if (order.marker) {
const marker = order.marker, supplier = g.players.find(p => p.id === marker.owner);
requireArrival(entry.shipmentReceipt.markerCommitSignature === JSON.stringify(order) &&
supplier?.faction === 'richese' && id(marker.tokenEvent) && id(marker.stateSignature) &&
supplier.noField?.tokens.some(token => token.id === marker.tokenId && token.value === marker.value) &&
(marker.mode === 'own' ? marker.owner === beneficiary.id && order.amount === 1 && order.elite === 0 &&
order.cost === 0 && marker.ownerPayment === 0 && marker.recipientPayment === 0 :
marker.mode === 'allied' && marker.owner !== beneficiary.id &&
order.amount <= marker.value && order.cost === (territory(order.territory).type === 'stronghold' ? 1 : 2) &&
whole(marker.ownerPayment) && whole(marker.recipientPayment) &&
marker.ownerPayment + marker.recipientPayment === order.cost) &&
(next !== 'income' || marker.mode === 'allied' && order.cost > 0),
'The committed independent marker shipment has lost its original receipt.');
} else requireArrival(next !== 'income' && entry.shipmentReceipt.markerCommitSignature === undefined,
'An ordinary free shipment has no marker payment continuation.');
const advisorArrival = entry.shipmentReceipt.advisorArrival;
requireArrival(
next === 'finish' ? record(advisorArrival) : advisorArrival === undefined,
+353
View File
@@ -0,0 +1,353 @@
import type { Game, Player } from './engine';
import { arrivalAsAdvisor } from './advisors';
import {
gameTerritories,
location,
MOBILE_STRONGHOLD,
splitLocation,
TERRITORIES,
territory,
validGameLocation,
} from './board';
import { FACTIONS } from './catalog';
import { presenceAt, type ForcePresence } from './force-presence';
import { territoryEntryBlock } from './occupancy';
import {
deployRicheseNoField,
revealRicheseNoField,
shipAlliedRicheseNoField,
validateRicheseNoField,
} from './richese-no-field';
export class GuildAmbassadorMarkerError extends Error {
constructor(message: string) {
super(message);
this.name = 'GuildAmbassadorMarkerError';
}
}
export type GuildAmbassadorMarkerSelection = {
tokenId: string;
tokenEvent: string;
territory: string;
sector: number;
payer?: string;
elite?: number;
};
export type GuildAmbassadorMarker = {
mode: 'own' | 'allied';
owner: string;
tokenId: string;
tokenEvent: string;
value: 0 | 3 | 5;
payer: string;
ownerPayment: number;
recipientPayment: number;
stateSignature: string;
};
export type GuildAmbassadorMarkerQuote = {
player: string;
amount: number;
elite: number;
territory: string;
sector: number;
advisors: boolean;
cost: 0 | 1 | 2;
allyPayment: 0;
marker: GuildAmbassadorMarker;
};
type BoardSeat = Pick<Player, 'id' | 'faction' | 'ally' | 'advisors'> & ForcePresence;
type MarkerContext = {
owner: Player;
recipient: Player;
mode: 'own' | 'allied';
seats: BoardSeat[];
};
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const count = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireMarker(condition: unknown, message: string): asserts condition {
if (!condition) throw new GuildAmbassadorMarkerError(message);
}
function noFieldRule<T>(operation: () => T): T {
try {
return operation();
} catch (error) {
if (error instanceof GuildAmbassadorMarkerError) throw error;
throw new GuildAmbassadorMarkerError(
error instanceof Error ? error.message : 'Invalid No-Field custody.',
);
}
}
function usableLocation(g: Game, key: string) {
const at = splitLocation(key);
return location(at.territory, at.sector) === key &&
validGameLocation(g, at.territory, at.sector) &&
(at.territory !== MOBILE_STRONGHOLD || !!g.mobileStronghold?.location);
}
function printedLocation(to: string, sector: number) {
return TERRITORIES.some((candidate) =>
candidate.id === to && candidate.sectors.includes(sector));
}
/** Identify only the native owner or the beneficiary's current mutual Richese
* ally. Inventory, event and physical custody are validated by the quote. */
export function markerOwner(g: Game, beneficiary: string): Player | null {
const recipient = g.players.find((candidate) => candidate.id === beneficiary);
if (!recipient) return null;
if (recipient.faction === 'richese') return recipient;
return g.players.find((candidate) => candidate.faction === 'richese' &&
candidate.id === recipient.ally && candidate.ally === recipient.id) ?? null;
}
function validateReserves(p: Player) {
requireMarker(count(p.reserves) && count(p.elites?.reserves ?? 0) &&
(p.elites?.reserves ?? 0) <= p.reserves,
'The physical or elite reserves are invalid.');
}
function context(g: Game, beneficiary: string): MarkerContext {
requireMarker(Array.isArray(g.players) && g.players.length > 0 &&
g.players.every((p) => record(p) && text(p.id) &&
FACTIONS.some((faction) => faction.id === p.faction)) &&
new Set(g.players.map((p) => p.id)).size === g.players.length &&
new Set(g.players.map((p) => p.faction)).size === g.players.length,
'The No-Field shipment requires unique valid seated identities and factions.');
requireMarker(count(g.storm) && g.storm <= 18,
'The storm sector is invalid.');
if (g.mobileStronghold?.location) {
const pointer = splitLocation(g.mobileStronghold.location);
requireMarker(usableLocation(g, g.mobileStronghold.location) &&
printedLocation(pointer.territory, pointer.sector),
'The mobile stronghold must point to a printed board location.');
}
const seats: BoardSeat[] = [];
for (const p of g.players) {
requireMarker(p.ally === null || p.ally === undefined ||
(text(p.ally) && g.players.some((other) => other.id === p.ally &&
other.id !== p.id && other.ally === p.id)),
'The shipment requires current mutual seated alliances.');
requireMarker(record(p.forces) && (!p.elites ||
(record(p.elites) && record(p.elites.forces))),
'The board force custody is invalid.');
const totals: Record<string, number> = {};
for (const [key, amount] of Object.entries(p.forces)) {
requireMarker(usableLocation(g, key) && count(amount),
'Invalid board force location or quantity.');
const to = splitLocation(key).territory;
totals[to] = (totals[to] ?? 0) + amount;
requireMarker(count(totals[to]), 'The territory force quantity is invalid.');
}
for (const [key, amount] of Object.entries(p.elites?.forces ?? {}))
requireMarker(usableLocation(g, key) && count(amount) &&
amount <= (p.forces[key] ?? 0),
'Elite forces must be part of the physical force inventory.');
const deployed = p.noField?.deployed;
requireMarker(!p.noField || p.faction === 'richese',
'Only Richese may hold a No-Field inventory.');
requireMarker(deployed === undefined || deployed === null ||
(record(deployed) && record(deployed.location) &&
text(deployed.location.territory) && count(deployed.location.sector) &&
usableLocation(g, location(deployed.location.territory,
deployed.location.sector)) &&
count((totals[deployed.location.territory] ?? 0) + 1)),
'The concealed marker has an invalid public location.');
requireMarker(!p.advisors || record(p.advisors),
'The advisor inventory is invalid.');
for (const [to, stance] of Object.entries(p.advisors ?? {}))
requireMarker(gameTerritories(g).some((candidate) => candidate.id === to) &&
record(stance) && (stance.lockedTurn === undefined || count(stance.lockedTurn)),
'The advisor stance is invalid.');
// Copy public custody explicitly: no hands, balances or opponent token
// denominations are needed to forecast the compulsory prior reveal.
seats.push({
id: p.id,
faction: p.faction,
ally: p.ally,
forces: { ...p.forces },
advisors: p.advisors,
noField: deployed ? { deployed: {
location: { ...deployed.location },
} } : undefined,
});
}
const recipient = g.players.find((p) => p.id === beneficiary);
const owner = markerOwner(g, beneficiary);
requireMarker(recipient && owner,
'Choose Richese or its current mutual ally for the No-Field shipment.');
requireMarker(owner.noField && text(owner.noFieldEvent),
'The No-Field inventory and selection event must be initialized.');
noFieldRule(() => validateRicheseNoField(owner.noField!));
requireMarker(!owner.noField.deployed ||
owner.noField.deployed.controller === owner.id,
'The existing concealed marker must be controlled by its Richese owner.');
validateReserves(owner);
validateReserves(recipient);
const mode = owner.id === recipient.id ? 'own' : 'allied';
requireMarker(mode === 'own' || recipient.faction !== 'fremen',
'Fremen reserves are on the planet; allied No-Field shipment needs off-planet reserves.');
return { owner, recipient, mode, seats };
}
function forecastPriorReveal(c: MarkerContext) {
if (c.mode === 'own') {
requireMarker(!c.owner.noField!.deployed,
'Reveal the existing No-Field explicitly before using this grant.');
return c.owner.noField!;
}
const previous = c.owner.noField!;
if (!previous.deployed) return previous;
const revealed = noFieldRule(() => revealRicheseNoField(previous, {
tokenId: previous.deployed!.tokenId,
reserves: c.owner.reserves,
cause: 'beforeAlly',
}));
const owner = c.seats.find((seat) => seat.id === c.owner.id)!;
const key = location(revealed.location.territory, revealed.location.sector);
const total = (owner.forces[key] ?? 0) + revealed.forces;
requireMarker(count(total), 'The prior reveal produces an invalid force quantity.');
if (revealed.forces) owner.forces = { ...owner.forces, [key]: total };
owner.noField = undefined;
requireMarker(count(presenceAt(owner, revealed.location.territory)),
'The prior reveal produces an invalid territory force quantity.');
return revealed.state;
}
function destination(g: Game, c: MarkerContext, to: string, sector: number) {
requireMarker(text(to) && count(sector) && printedLocation(to, sector) &&
to !== MOBILE_STRONGHOLD,
'Choose a printed planet territory and sector for the No-Field shipment.');
requireMarker(sector === 0 || sector !== g.storm,
'The shipment destination is in storm.');
const recipient = c.seats.find((seat) => seat.id === c.recipient.id)!;
const advisors = arrivalAsAdvisor({ advanced: g.advanced, players: c.seats },
recipient, to);
const blocked = noFieldRule(() => territoryEntryBlock(c.seats, recipient.id,
to, advisors));
requireMarker(!blocked, blocked ?? 'This destination cannot be entered.');
requireMarker(c.mode !== 'own' || count(presenceAt(recipient, to) + 1),
'The resulting marker presence is invalid.');
return advisors;
}
/** Whole-grant provisional composition: native Richese substitutes one hidden
* marker for the free grant. An allied marker reveals immediately and must
* materialize at most four actual recipient counters. Its compulsory printed
* one/two-spice fee remains payable; the grant adds no other counters or rate.
* The caller binds the live Ambassador, parent event, consent, prevention and
* uncommitted budgets. Nothing here moves tokens, forces, cards or spice. */
export function quoteGuildAmbassadorMarker(
g: Game,
beneficiary: string,
selection: GuildAmbassadorMarkerSelection,
checkFunds = true,
): GuildAmbassadorMarkerQuote {
requireMarker(record(selection) && text(selection.tokenId) &&
text(selection.tokenEvent) && text(selection.territory) &&
count(selection.sector) &&
(selection.payer === undefined || text(selection.payer)) &&
(selection.elite === undefined || count(selection.elite)),
'Choose a valid No-Field token, selection event, destination and force type.');
const c = context(g, beneficiary), { owner, recipient, mode } = c;
requireMarker(selection.tokenEvent === owner.noFieldEvent,
'The No-Field selection event is stale.');
const previous = forecastPriorReveal(c);
const deployment = {
tokenId: selection.tokenId,
controller: recipient.id,
location: { territory: selection.territory, sector: selection.sector },
};
let amount: number;
let value: 0 | 3 | 5;
if (mode === 'own') {
noFieldRule(() => deployRicheseNoField(previous, deployment));
value = previous.tokens.find((token) => token.id === selection.tokenId)!.value;
amount = 1;
} else {
const revealed = noFieldRule(() => shipAlliedRicheseNoField(previous,
deployment, recipient.reserves));
amount = revealed.forces;
value = revealed.value;
requireMarker(amount <= 4,
'The allied No-Field must materialize at most four available reserve forces.');
}
const advisors = destination(g, c, selection.territory, selection.sector);
const eliteReserves = recipient.elites?.reserves ?? 0;
const minimum = mode === 'own' ? 0 :
Math.max(0, amount - (recipient.reserves - eliteReserves));
const elite = selection.elite ?? minimum;
requireMarker(count(elite) && (mode === 'own' ? elite === 0 :
elite >= minimum && elite <= Math.min(amount, eliteReserves)),
'Choose available elites within the materialized allied forces; native markers contain no elites.');
const at = location(selection.territory, selection.sector);
const boardRecipient = c.seats.find((seat) => seat.id === recipient.id)!;
requireMarker(count((boardRecipient.forces[at] ?? 0) + amount) &&
count(presenceAt(boardRecipient, selection.territory) + amount) &&
count((recipient.elites?.forces[at] ?? 0) + elite),
'The resulting force quantity is invalid.');
const cost: 0 | 1 | 2 = mode === 'own' ? 0 :
territory(selection.territory).type === 'stronghold' ? 1 : 2;
const payer = selection.payer ?? owner.id;
requireMarker(mode === 'own' ? payer === owner.id :
payer === owner.id || payer === recipient.id || payer === 'both',
'Choose Richese, the recipient, or an equal split as the transport payer.');
requireMarker(payer !== 'both' || cost === 2,
'An equal split requires the two-spice transport fee.');
const ownerPayment = payer === owner.id ? cost : payer === 'both' ? 1 : 0;
const recipientPayment = cost - ownerPayment;
if (ownerPayment)
requireMarker(count(owner.spice) && owner.spice >= ownerPayment,
'Richese needs enough spice for its transport payment.');
if (checkFunds && recipientPayment)
requireMarker(count(recipient.spice) && recipient.spice >= recipientPayment,
'The recipient needs enough spice for its transport payment.');
return {
player: recipient.id,
amount,
elite,
territory: selection.territory,
sector: selection.sector,
advisors,
cost,
allyPayment: 0,
marker: {
mode,
owner: owner.id,
tokenId: selection.tokenId,
tokenEvent: selection.tokenEvent,
value,
payer,
ownerPayment,
recipientPayment,
stateSignature: JSON.stringify({
owner: { id: owner.id, noField: owner.noField, event: owner.noFieldEvent,
reserves: owner.reserves, elites: owner.elites?.reserves ?? 0 },
recipient: { id: recipient.id, reserves: recipient.reserves,
elites: recipient.elites?.reserves ?? 0 },
storm: g.storm,
advanced: g.advanced,
seats: c.seats,
}),
},
};
}
/** Destination choices use public board legality and the required old-marker
* reveal, independently of token denomination, physical reserves and funding.
* The final quote always checks the selected token's actual grant amount. */
export function markerDestinations(g: Game, beneficiary: string):
{ territory: string; sector: number; advisors: boolean }[] {
const c = context(g, beneficiary);
forecastPriorReveal(c);
const result: { territory: string; sector: number; advisors: boolean }[] = [];
for (const t of TERRITORIES)
for (const sector of t.sectors) {
try {
result.push({ territory: t.id, sector,
advisors: destination(g, c, t.id, sector) });
} catch (error) {
if (!(error instanceof GuildAmbassadorMarkerError)) throw error;
}
}
return result;
}
+5 -3
View File
@@ -11,6 +11,7 @@ import {
import { presenceAt } from './force-presence';
import { territoryEntryBlock } from './occupancy';
import { homeworldSpiritualAdvisorQuote } from './homeworld-mobility';
import type { GuildAmbassadorMarker } from './guild-ambassador-no-field';
export class GuildAmbassadorShipmentError extends Error {
constructor(message: string) {
@@ -25,8 +26,9 @@ export type GuildAmbassadorShipment = {
territory: string;
sector: number;
advisors: boolean;
cost: 0;
cost: 0 | 1 | 2;
allyPayment: 0;
marker?: GuildAmbassadorMarker;
};
export type GuildAmbassadorShipments = {
maximum: number;
@@ -283,11 +285,11 @@ export function quoteGuildAmbassadorAdvisor(
shipper.faction !== 'fremen' &&
record(shipment) &&
count(shipment.amount) &&
shipment.amount >= 1 &&
(shipment.amount >= 1 || shipment.marker?.mode === 'allied') &&
shipment.amount <= 4 &&
count(shipment.elite) &&
shipment.elite <= shipment.amount &&
shipment.cost === 0 &&
(shipment.marker ? [0,1,2].includes(shipment.cost) : shipment.cost === 0) &&
shipment.allyPayment === 0 &&
typeof shipment.advisors === 'boolean' &&
typeof shipment.territory === 'string' &&
+3 -2
View File
@@ -117,9 +117,10 @@ const cancellationSources = {
: auctionSource(g),
richesePurchaseIncome: (g) => g.pendingRichesePurchaseIncome ?? null,
richeseGift: (g) => g.pendingRicheseGift ?? null,
richeseNoField: (g) => ({
richeseNoField: (g, response) => ({
...movementSource(g),
shipment: g.pendingShipment ?? null,
...(response.source === 'ambassadorNoField' ? { ambassador: g.pendingAmbassador ?? null } : {}),
}),
harkonnenBonus: (g, response) =>
response.source === 'ambassador'
@@ -133,7 +134,7 @@ const cancellationSources = {
plans: g.battle?.plans ?? null,
calls: g.battle?.traitorCalls ?? null,
}),
guildIncome: () => null,
guildIncome: (g,response) => response.source === 'ambassadorNoFieldIncome' ? {ambassador:g.pendingAmbassador ?? null} : null,
guildTransportPermission: g => ({
...(g.pendingGuildTransport ? { transport: g.pendingGuildTransport } : {}),
...(g.pendingHomeworldShipment ? { homeworld: g.pendingHomeworldShipment } : {}),
+14 -9
View File
@@ -1541,7 +1541,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The mobile stronghold can receive a non-Ixian group only from the territory it points to. Ecaz and its ally may co-occupy under Occupy. Advisors keep their applicable stance and accompaniment locks; an eligible arrival can request fighters. CHOAM may use Baliset during Shipment and Movement; if allowed, choose another legal destination. Intrusion or Terror resolves before the original entrant continues. A destination that causes both settles the Intrusion first and then offers the committed Terror entry; no payment or force transfer replays. There is no extra decline after triggering; if no legal move remains, the effect finishes without moving pieces and its token stays consumed.',
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created child arrivals retain their own contracts. The native-module extension now composes ordinary paid arrival order with Atomics, Homeworlds and Nexus; other combinations and complete rules remain unfinished.',
'In bare Basic Richese/Ecaz tables, No-Field may replace the whole Guild grant under an explicit provisional policy: concealed native0/3/5 counts as one and ships free; allied use reveals immediately within four available counters and keeps the one/two-spice transport payment to Guild or Bank. Richese chooses its token/payer and the beneficiary must accept before transfer. Prior native revelation, shared token history and physical fallback after scoped prevention remain; ordinary allowances are unchanged. Advanced/optional profiles remain separate. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created child arrivals retain their own contracts. The native-module extension now composes ordinary paid arrival order with Atomics, Homeworlds and Nexus; other combinations and complete rules remain unfinished.',
'The Tleilaxu Ambassador returns one eligible own leader or up to four physical forces free. First-death leaders and the first-killed Atreides KH need not wait for the ordinary cohort. Under the provisional repeat interpretation, later native leaders must satisfy their ordinary death condition, and later KH returns must meet their saved next-cohort marker. Preserve the actual opening before resurrection; KH advances its marker beyond both owner and old KH cycles without resetting activation losses or adding a native disc. Ecaz retains native identity bookkeeping. Ecaz may instead return the shared Duke alive and unclaimed under his independent permission; its ally cannot inherit that permission. Native and BG copied grants share eligibility and exclusive alternatives. A positive return provisionally earns seated Tleilaxu one bank spice per beneficiary/turn, sharing ordinary free revival; Ghola stays separate. Canceling income keeps the return and spent entitlement. Ordinary allowance independence and wider custody/module combinations remain provisional.',
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Normal public Ecaz starts remain development previews; complete rules and unsupported combinations stay gated.',
],
@@ -1550,40 +1550,45 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'A shared ordinary death-cohort quote admits eligible native/KH repeats without importing normal phase/slot/price or native early-service gates into the independent grant. Preserve actual openings before native resurrection, retain Ecaz identity tracking and advance KH above its actual cycle while keeping five native discs and loss activation. Existing Duke, source-bound income and original entrant continuation remain; wider custody/modules stay partial.',
evidence: ['game/engine.ts', 'game/revival.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/tleilaxu-ambassador-income.ts', 'game/karama-context.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'],
'Basic Guild No-Field now connects native concealment, allied request/supply/consent, immediate materialization, provisional transport payment, source-scoped prevention and original arrival. A shared ordinary death-cohort quote admits eligible native/KH repeats without importing normal phase/slot/price or native early-service gates into the independent grant. Preserve actual openings before native resurrection, retain Ecaz identity tracking and advance KH above its actual cycle while keeping five native discs and loss activation. Existing Duke, source-bound income and original entrant continuation remain; wider custody/modules stay partial.',
evidence: ['game/engine.ts', 'game/guild-ambassador-no-field.ts', 'game/guild-ambassador-continuation.ts', 'game/revival.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/tleilaxu-ambassador-income.ts', 'game/karama-context.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'],
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Persistent free-return guidance explains first-return and later-cohort eligibility, KH’s separate marker, ordinary-slot independence and the provisional shared income. Original exact native/KH/Duke buttons, busy controls, force alternatives and committed-return/income continuation remain. Earlier mobile arrival evidence stays separate.',
evidence: ['components/ecaz-entry.tsx', 'components/game-table.tsx'],
'Persistent No-Field controls show exact marker/destination/payment choices, provisional guidance, busy reasons and both refusal routes, including a native zero-reserve grant. Persistent free-return guidance explains first-return and later-cohort eligibility, KH’s separate marker, ordinary-slot independence and the provisional shared income. Original exact native/KH/Duke buttons, busy controls, force alternatives and committed-return/income continuation remain. Earlier mobile arrival evidence stays separate.',
evidence: ['components/ecaz-entry.tsx', 'components/ambassador-shipment.tsx', 'components/ambassador-no-field.tsx', 'components/game-table.tsx'],
},
{
area: 'AI',
status: 'Partial',
detail:
'Twelve existing legal profiles select repeated native/copy/KH choices and finish the original entrant, using server-private eligibility. Earlier original Duke/income and placement/consent evidence remains separate. No strategy refinement or strength calibration.',
'Eight new existing-profile continuations accept12 legal proposals from native-empty or allied-request states, without strategy/calibration. Twelve existing legal profiles select repeated native/copy/KH choices and finish the original entrant, using server-private eligibility. Earlier original Duke/income and placement/consent evidence remains separate. No strategy refinement or strength calibration.',
evidence: ['game/bots.ts', 'tests/ambassador-revival-income-engine.test.ts', 'tests/duke-ambassador-engine.test.ts', 'docs/TLEILAXU_AMBASSADOR_RULES.md'],
},
{
area: 'Documentation',
status: 'Partial',
detail:
'Source-bound effects, copies and Duke guides remain. Native own-arrival child-first/live-token order stays provisional. Separate native transport permission, independent purchased typed prices and own/allied marker billing preserve original sources and approvals. Immune Junction now keeps its offered tariff/payees with an explicit unused-purchase choice. Cunning opportunity scope, special-stop card return and unused-price expiry are selected interpretations; full combinations remain open.',
evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/TLEILAXU_AMBASSADOR_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'],
'Guild No-Field now distinguishes the selected Basic cap/fee policy from printed authority, independent allowances and remaining Advanced/module gates. Source-bound effects, copies and Duke guides remain. Native own-arrival child-first/live-token order stays provisional. Separate native transport permission, independent purchased typed prices and own/allied marker billing preserve original sources and approvals. Immune Junction now keeps its offered tariff/payees with an explicit unused-purchase choice. Cunning opportunity scope, special-stop card return and unused-price expiry are selected interpretations; full combinations remain open.',
evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/GUILD_AMBASSADOR_IMPLEMENTATION.md', 'docs/GUILD_AMBASSADOR_RULES.md', 'docs/TLEILAXU_AMBASSADOR_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'],
},
{
area: 'Verification',
status: 'Partial',
detail:
'Current repeat13-file114/114, types/lint/build and three controlled gameplay/rendering programmes pass: original Basic paid-entry sources plus assigned native histories reach the next actual ordinary Revival; separate Advanced actual entry returns KH with old2/cohort3 → marker4. Twelve legal policies finish original entry. First-opening, income allow/cancel and ordinary controls pass. Controlled histories are not earned repeat-battle/full-game proof. Earlier original Duke/income evidence stays separate; full modes, wider combinations, assurance and deployed acceptance remain open.',
'Current Basic Guild-marker proof passes13 files129/129, types/lint/build, fifteen gameplay/control/GameTable SSR journeys and eight minimal-policy continuations. Genuine53/71/74-action sources plus two explicitly conserved shortage arrangements cover native0/3/5, prior reveal, consent, Guild/Bank fee, prevention/refusal and physical fallback. Earlier repeat13-file114/114, types/lint/build and three controlled gameplay/rendering programmes pass: original Basic paid-entry sources plus assigned native histories reach the next actual ordinary Revival; separate Advanced actual entry returns KH with old2/cohort3 → marker4. Twelve legal policies finish original entry. First-opening, income allow/cancel and ordinary controls pass. Controlled histories are not earned repeat-battle/full-game proof. Earlier original Duke/income evidence stays separate; full modes, wider combinations, assurance and deployed acceptance remain open.',
evidence: [
'tests/ambassador-repeat-leader-engine.test.ts',
'tests/fixture-ambassador-repeat-leaders.ts',
'tests/tleilaxu-ambassador-leaders.test.ts',
'tests/fixture-ambassador-revival-income.ts',
'tests/fixture-guild-ambassador-no-field.ts',
'tests/guild-ambassador-no-field-engine.test.ts',
'tests/guild-ambassador-no-field-bank.test.ts',
'tests/guild-ambassador-no-field-controls.test.tsx',
'tests/guild-ambassador-no-field-quote.test.ts',
'tests/ambassador-revival-income-engine.test.ts',
'tests/tleilaxu-ambassador-income.test.ts',
'tests/basic-ecaz-tleilaxu-occupy.test.ts',