Support concealed No-Field Guild transport

This commit is contained in:
Quintine committed 2026-10-27 12:44:34 +11:00
1 parent e41abdaf2e
commit 933a9c1379
24 files changed
+1138 -74

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import { gameTerritories } from './board';
import type { Action, GameView } from './engine';
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct } from './nexus-guild-secret-ally-options';
import { guildTransportQuote } from './transport-quote';
/** Minimal legal choices for the owned concealed marker, before auto-revelation.
* The shared quotes own timing, entry, pricing and original source validation.
*/
export function guildNoFieldTransportActions(g: GameView): Action[] {
const own = g.players.find(player => player.id === g.me);
const inventory = g.richeseNoField;
const marker = inventory?.owner === g.me ? inventory.private?.deployed : null;
const publicMarker = inventory?.public.deployed;
if (!own || own.shipped || !marker || marker.controller !== own.id || !publicMarker ||
publicMarker.controller !== own.id || !inventory?.event ||
!nexusGuildShipmentAvailable(g)) return [];
const guild = g.players.find(player => player.faction === 'guild');
const native = !!guild && own.ally === guild.id && guild.ally === own.id;
const secret = nexusGuildSecretAllyCanAct(g);
if (!native && !secret) return [];
const destinations = gameTerritories(g).flatMap(territory => territory.id === publicMarker.location.territory
? [] : territory.sectors.map(sector => ({ territory: territory.id, sector })));
if (secret) destinations.push({ territory: 'reserves', sector: 0 });
const actions: Action[] = [];
for (const destination of destinations) {
const packet: Action = { type: 'guildShip', noField: marker.tokenId, event: inventory.event,
forces: {}, ...destination };
const action = secret ? nexusGuildSecretAllyAction(g, g.nexusGuildSecretAlly!.event, packet) : packet;
if (!action) continue;
const quote = guildTransportQuote(g, action);
if (!quote.unavailableReasons.length && quote.quote)
actions.push({ ...action, allyPayment: quote.quote.pledgedPayment });
}
return actions;
}
+2 -1
View File
@@ -123,6 +123,7 @@ import { fighterCount, isAdvisor } from './advisors';
import { quoteAdvisorMatching, AdvisorMatchingError } from './advisor-matching';
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from './advisor-ambassador-matching';
import { guildTransportQuote } from './transport-quote';
import { guildNoFieldTransportActions } from './bot-guild-no-field-transport';
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
import { reinforcementsBattleForces } from './reinforcements-homeworld';
@@ -4445,7 +4446,7 @@ export function botActions(g: GameView): Action[] {
if (shrineTruth) return [shrineTruth];
const intelligence = tupileIntelligenceActions(g);
if (intelligence.length) return intelligence;
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
const actions = [...junctionTransportActions(g, rank(g)), ...guildNoFieldTransportActions(g), ...policyActions(g)].flatMap((action) => {
if (action.type === 'move' && nexusGuildCunningActive(g) && !nexusGuildMovementAvailable(g)) return [];
if (action.type === 'battlePlan' && g.ecazSpecialKarama?.declared &&
(action.weapon || action.defense)) return [];
+70 -23
View File
@@ -1345,6 +1345,8 @@ type PendingGuildTransport = {
/** Fresh board transports retain their actual source stance at foreign arrivals.
* Absent on declarations recorded before this source cutover. */
advisorArrivalSource?: string;
/** A concealed marker prices as one but is never a physical counter. */
noField?: { tokenId: string; event: string; from: string };
};
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves' | 'permission'>;
type JunctionTransportDeclaration = {
@@ -11830,7 +11832,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
case 'guildTransport': {
requireRule(typeof continuation.fromReserves === 'boolean', 'The Guild transport source flag is malformed.');
const frame = continuation.frame;
requireRule(frame && Object.keys(frame).filter(key => key !== 'advisorArrivalSource').sort().join(',') ===
requireRule(frame && Object.keys(frame).filter(key => key !== 'advisorArrivalSource' && key !== 'noField').sort().join(',') ===
'advisors,allyPayment,amount,cost,elite,eliteGroup,group,origin,player,sector,to,turn',
'The saved Guild transport frame is malformed.');
const p = getPlayer(g, frame.player);
@@ -35930,11 +35932,12 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
}
}
function guildTransportSignature(intent: PendingGuildTransport): string {
const fields = [intent.event, intent.turn, intent.player, intent.fromReserves,
const fields: unknown[] = [intent.event, intent.turn, intent.player, intent.fromReserves,
intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector,
intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors];
if (intent.permission !== undefined) fields.push(intent.permission);
if (intent.advisorArrivalSource !== undefined) fields.push(intent.advisorArrivalSource);
if (intent.noField !== undefined) fields.push(intent.noField);
return JSON.stringify(fields);
}
function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null {
@@ -35971,6 +35974,8 @@ function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
const intent = g.pendingGuildTransport;
const guild = byFaction(g, 'guild');
const shipper = intent && g.players.find(p => p.id === intent.player);
const marker = intent?.noField;
const deployed = shipper?.noField?.deployed;
const permission = response.kind === 'guildTransportPermission';
requireRule(intent && guild && shipper &&
(permission ? intent.permission === true && response.guildTransportEvent === intent.event
@@ -35988,18 +35993,26 @@ function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
typeof intent.to === 'string' && intent.to.length > 0 &&
Number.isSafeInteger(intent.sector) &&
Number.isSafeInteger(intent.amount) && intent.amount > 0 &&
Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount &&
Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount - Number(!!marker) &&
Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 &&
intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' &&
(intent.advisorArrivalSource === undefined || g.advanced && shipper.faction === 'beneGesserit' &&
!intent.fromReserves && intent.to !== 'reserves' && intent.advisorArrivalSource === intent.origin) &&
(marker === undefined || typeof marker === 'object' && marker !== null &&
Object.keys(marker).sort().join(',') === 'event,from,tokenId' &&
shipper.faction === 'richese' && !intent.fromReserves && !intent.advisors &&
typeof marker.tokenId === 'string' && typeof marker.event === 'string' &&
typeof marker.from === 'string' && deployed?.tokenId === marker.tokenId &&
deployed.controller === shipper.id && shipper.noFieldEvent === marker.event &&
location(deployed.location.territory,deployed.location.sector) === marker.from &&
deployed.location.territory === intent.origin) &&
(intent.fromReserves
? intent.origin === 'reserves' && shipper.faction === 'fremen' &&
intent.group.length === 0 && Object.keys(intent.eliteGroup).length === 0 &&
intent.to !== 'reserves'
: intent.group.length > 0 && intent.group.every(([key]) =>
: (intent.group.length > 0 || !!marker) && intent.group.every(([key]) =>
splitLocation(key).territory === intent.origin) &&
intent.group.reduce((sum, [, n]) => sum + n, 0) === intent.amount &&
intent.group.reduce((sum, [, n]) => sum + n, 0) + Number(!!marker) === intent.amount &&
Object.keys(intent.eliteGroup).length === intent.group.length &&
intent.group.every(([key, n]) =>
Number.isSafeInteger(intent.eliteGroup[key]) &&
@@ -36026,6 +36039,7 @@ function guildTransportOrder(intent: PendingGuildTransport, cost = intent.cost):
group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to,
sector: intent.sector, amount: intent.amount, elite: intent.elite,
cost, allyPayment: intent.allyPayment, advisors: intent.advisors,
...(intent.noField ? {noField:{...intent.noField}} : {}),
...(intent.advisorArrivalSource !== undefined ? {advisorArrivalSource:intent.advisorArrivalSource} : {}),
};
}
@@ -36093,11 +36107,13 @@ function validateGuildTransportForCommit(
'The original southern-reserve elite allocation is unavailable.');
} else {
const quoted = forceGroup(g, p, { type: 'guildShip',
forces: Object.fromEntries(group), eliteForces: eliteGroup });
forces: Object.fromEntries(group), eliteForces: eliteGroup,
...(frame.noField ? {noField:frame.noField.tokenId,event:frame.noField.event} : {}) });
requireRule(JSON.stringify(quoted.group) === JSON.stringify(group) &&
JSON.stringify(quoted.eliteGroup) === JSON.stringify(eliteGroup) &&
quoted.origin === origin && quoted.elite === elite && quoted.total === amount &&
group.every(([key]) => splitLocation(key).sector !== g.storm),
JSON.stringify(quoted.noField) === JSON.stringify(frame.noField) &&
quoted.sourceKeys.every(key => splitLocation(key).sector !== g.storm),
'The original Guild transport force group changed or entered the storm.');
}
requireRule((fromReserves || to === 'reserves' || to !== origin) &&
@@ -36111,6 +36127,8 @@ function validateGuildTransportForCommit(
requireRule(to === 'reserves' || advisors || sourceLock !== g.turn ||
!g.players.some(other => other.id !== p.id && at(other, to)),
'New advisors cannot become fighters this turn.');
requireRule(!frame.noField || to === 'reserves' || !to.startsWith('homeworld:'),
'A concealed No-Field cannot be placed on a Homeworld.');
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
const halfRate = !!guildSecretEvent || g.karamaShipping?.player === p.id ||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id);
@@ -36129,9 +36147,10 @@ function commitGuildTransport(
) {
requireRule(!g.guildBetrayalPreview || !guildSecretEvent,
'Guild Betrayal native transport does not combine with a Guild Secret Ally producer.');
if (!guildSecretEvent) validateGuildTransportForCommit(g, p, frame, fromReserves);
if (!guildSecretEvent || frame.noField) validateGuildTransportForCommit(g, p, frame, fromReserves, guildSecretEvent);
if (!settlement && beginGuildBetrayal(g, {source: 'guildTransport', frame, fromReserves})) return;
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
const physicalAmount = amount - Number(!!frame.noField);
const sourceLock = p.advisors?.[origin]?.lockedTurn;
const advisorArrival = advisors || (!movementPreparation && !fromReserves && to !== 'reserves' &&
g.advanced && p.faction === 'beneGesserit' && isAdvisor(p, origin) && !at(p, to) &&
@@ -36157,12 +36176,19 @@ function commitGuildTransport(
p.reserves -= amount;
if (p.elites) p.elites.reserves -= elite;
} else removeGroup(p, group, eliteGroup);
if (frame.noField) {
const marker = frame.noField;
p.noField = noFieldRule(() => to === 'reserves'
? recallRicheseNoField(p.noField!,marker.tokenId)
: moveRicheseNoField(p.noField!,marker.tokenId,{territory:to,sector}));
p.noFieldEvent = crypto.randomUUID();
}
if (to === 'reserves') {
// User ruling 7 October 2026: the Nexus Secret Ally reserve return is an
// authorized native-reserve return, so it deposits into Homeworld custody.
addNativeReserves(g, p, amount, elite);
if (physicalAmount) addNativeReserves(g, p, physicalAmount, elite);
} else {
place(p, to, sector, amount, elite);
if (physicalAmount) place(p, to, sector, physicalAmount, elite);
if (advisorArrival) {
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
(p.advisors ??= {})[to] = { ...p.advisors?.[to], ...(lockedTurn ? { lockedTurn } : {}) };
@@ -36180,7 +36206,7 @@ function commitGuildTransport(
closeAdvisorPreparationOnShipment(g, p.id, 'guildTransport');
g.karamaShipping = null;
if (movementPreparation) return;
log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
log(g, `${p.name} used Guild transport for ${physicalAmount} physical forces${frame.noField ? ` and one concealed No-Field. Provisional ${to === 'reserves' ? 'face-down return preserves its last-shipment restriction without materializing forces' : 'cross-shipment preserves concealment and token history'}` : ''}.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
guildSecretEvent
? { faction: 'guild', name: 'Secret Ally transport' }
: { faction: p.faction, name: 'Guild transport' });
@@ -36759,11 +36785,29 @@ function nexusGuildSecretIntegrity(g: Game) {
const frame = nexusRule(() => JSON.parse(record.frame)) as Record<string,unknown>;
requireRule(frame && typeof frame === 'object' && frame.guildSecretEvent === record.receipt.event &&
frame.player === record.receipt.owner && frame.turn === record.receipt.turn &&
!frame.nexusEvent && !frame.guildNexusEvent && !frame.noField && !frame.alliedNoField && !frame.source,
!frame.nexusEvent && !frame.guildNexusEvent && !frame.alliedNoField && !frame.source &&
(!frame.noField || record.kind === 'cross' && record.receipt.owner &&
getPlayer(g,record.receipt.owner).faction === 'richese'),
'Guild Secret Ally has changed its original shipment source.');
if (frame.noField) {
const marker = frame.noField as Record<string,unknown>;
const owner = getPlayer(g,record.receipt.owner);
requireRule(marker && typeof marker === 'object' && !Array.isArray(marker) &&
Object.keys(marker).sort().join(',') === 'event,from,tokenId' &&
typeof marker.tokenId === 'string' && typeof marker.event === 'string' && marker.event.length > 0 &&
typeof marker.from === 'string' && marker.from.length > 0 &&
owner.noField?.tokens.some(token => token.id === marker.tokenId) &&
location(splitLocation(marker.from).territory,splitLocation(marker.from).sector) === marker.from &&
splitLocation(marker.from).territory === frame.origin &&
Array.isArray(frame.group) && frame.group.every((entry:unknown) => Array.isArray(entry) &&
entry.length === 2 && typeof entry[0] === 'string' && Number.isSafeInteger(entry[1]) && entry[1] > 0 &&
splitLocation(entry[0]).territory === frame.origin) &&
frame.group.reduce((sum:number,entry:[string,number]) => sum + entry[1],0) + 1 === frame.amount,
'Guild Secret Ally lost its original concealed marker and physical group.');
}
const destination = record.kind === 'homeworld' ? 'homeworld' :
record.kind === 'cross' && frame.to === 'reserves' ? 'reserves' : territory(String(record.kind === 'cross' ? frame.to : frame.territory)).type;
const quote = nexusRule(() => quoteNexusGuildSecretShipment(destination,frame.amount as number));
const quote = nexusRule(() => quoteNexusGuildSecretShipment(destination,frame.amount as number,!!frame.noField));
requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) &&
Number(frame.elite) >= 0 && Number(frame.elite) <= quote.physicalAmount,
'Guild Secret Ally has changed its original payment or typed allocation.');
@@ -42296,8 +42340,8 @@ function applyActionInner(
}
if (t === 'guildShip') {
const guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus);
if (guildSecretEvent) requireRule(Object.keys(action).every(k => ['type','from','forces','eliteForces','amount','elite','territory','sector','allyPayment','nexus'].includes(k)),
'Guild Secret Ally cross-shipment needs explicit physical forces.');
if (guildSecretEvent) requireRule(Object.keys(action).every(k => ['type','from','forces','eliteForces','amount','elite','territory','sector','allyPayment','nexus',...(action.noField !== undefined ? ['noField','event'] : [])].includes(k)),
'Guild Secret Ally cross-shipment needs its selected physical group and optional current No-Field.');
const homeworldTarget = action.territory ?? 'reserves';
if (g.homeworlds?.custody && p.faction === 'guild' &&
(homeworldTarget === 'reserves' || (typeof homeworldTarget === 'string' && homeworldTarget.startsWith('homeworld:')))) {
@@ -42320,10 +42364,6 @@ function applyActionInner(
? { territory: String(action.territory), amount: Number(action.amount) }
: null,
);
requireRule(
action.noField === undefined,
'Guild transport of a concealed No-Field awaits its specific return and custody rules.',
);
requireRule(
g.phase === 5 &&
g.active === id &&
@@ -42334,18 +42374,22 @@ function applyActionInner(
const fromReserves = action.from === 'reserves';
requireRule(!guildSecretEvent || !fromReserves, 'Use the reserve shipment selector for paid Guild Secret Ally reinforcements.');
requireRule(
!fromReserves || p.faction === 'fremen',
'Only allied Fremen may cross-ship from their southern reserves.',
!fromReserves || p.faction === 'fremen' && action.noField === undefined,
'Only allied Fremen physical reserves may use this source.',
);
const {
group,
eliteGroup,
elite,
origin,
noField,
sourceKeys,
total: n,
} = fromReserves
? {
origin: 'reserves',
noField: undefined,
sourceKeys: [] as string[],
group: [] as [string, number][],
eliteGroup: {} as Record<string, number>,
elite: eliteChoice(
@@ -42358,7 +42402,7 @@ function applyActionInner(
}
: forceGroup(g, p, action);
requireRule(
group.every(([key]) => splitLocation(key).sector !== g.storm),
sourceKeys.every(key => splitLocation(key).sector !== g.storm),
'The source is in storm.',
);
const to = stringField(action.territory ?? 'reserves');
@@ -42383,8 +42427,10 @@ function applyActionInner(
to !== MOBILE_STRONGHOLD || p.faction === 'ixians',
'Only Ixians may ship directly into the mobile stronghold.',
);
requireRule(!noField || to === 'reserves' || !to.startsWith('homeworld:'),
'A concealed No-Field cannot be placed on a Homeworld.');
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
const halfRate = g.karamaShipping?.player === id ||
const halfRate = !!guildSecretEvent || g.karamaShipping?.player === id ||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id);
const cost = guildShipmentCost(
to === 'reserves' ? 'reserves' : territory(to).type, n, halfRate);
@@ -42397,6 +42443,7 @@ function applyActionInner(
const frame: GuildTransportOrder = {
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
amount: n, elite, cost, allyPayment, advisors,
...(noField ? {noField:{...noField}} : {}),
...(advisorArrivalSource !== undefined ? {advisorArrivalSource} : {}),
};
if(execution==='live'&&openAdvisorTransportMatching(g,{kind:'guild',frame,fromReserves,...(guildSecretEvent?{secret:guildSecretEvent}:{})}))return g;
+15
View File
@@ -0,0 +1,15 @@
import type { Action, GameView } from './engine';
/** Entitled identity and public location only. Native transport owns legality;
* the concealed denomination and reserve population do not grant permission. */
export function guildNoFieldSelection(g: GameView, action: Action) {
const info = g.richeseNoField;
const marker = info?.public.deployed;
const owned = info?.private?.deployed;
if (action.type !== 'guildShip' || typeof action.noField !== 'string' ||
!info || info.owner !== g.me || action.event !== info.event ||
!marker || marker.controller !== g.me || !owned ||
owned.controller !== g.me || owned.tokenId !== action.noField)
return null;
return { token: owned.tokenId, event: info.event, location: { ...marker.location } };
}
+3
View File
@@ -4,6 +4,7 @@ import { nexusRicheseQuote } from './nexus-richese-options';
import { quoteRicheseHomeworldPair } from './richese-homeworld-shipment';
import { reserveShipmentCost } from './shipment-price';
import { territory } from './board';
import { guildTransportQuote } from './transport-quote';
export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): boolean {
@@ -31,6 +32,8 @@ export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action):
} catch { return false; }
}
const guildSource = action.nexus !== undefined && action.nexus === g.nexusGuildSecretAlly?.event;
if (guildSource && action.noField !== undefined)
return action.type === 'guildShip' && !guildTransportQuote(g, action).unavailableReasons.length;
const nexusQuote = action.nexus === undefined ? null : guildSource ? nexusGuildSecretAllyQuote(g, to, amount) : nexusRicheseQuote(g, to, amount);
if (action.nexus !== undefined && (!nexusQuote || (!guildSource && (action.nexus !== g.nexusRichese?.event || action.type !== 'ship')) || action.noField !== undefined)) return false;
return true;
+32 -11
View File
@@ -1,6 +1,7 @@
import type { Action, GameView } from './engine';
import { gameTerritories, MOBILE_STRONGHOLD } from './board';
import { gameTerritories, MOBILE_STRONGHOLD, splitLocation, validGameLocation } from './board';
import { quoteNexusGuildSecretShipment } from './nexus-guild-secret-ally';
import { guildNoFieldSelection } from './guild-no-field-selection';
/** Consume only the current actor's private offer and public faction roster. */
export function nexusGuildSecretAllyCanAct(g: GameView): boolean {
@@ -36,29 +37,31 @@ export function nexusGuildSecretAllyQuote(
g: GameView,
destination: string,
amount: number,
noField = false,
) {
if (
!nexusGuildSecretAllyCanAct(g) ||
typeof noField !== 'boolean' ||
!Number.isSafeInteger(amount) ||
amount < 1 ||
amount > 20
amount > (noField ? 21 : 20)
)
return null;
const me = g.players.find((p) => p.id === g.me)!;
if (destination === 'reserves') {
// User ruling 7 October 2026: the Secret Ally return grant overrides the
// Arrakis-to-native-Homeworld restriction for the holder's own reserves.
return quoteNexusGuildSecretShipment('reserves', amount);
return quoteNexusGuildSecretShipment('reserves', amount, noField);
}
const world = g.homeworlds?.worlds?.find((w) => w.id === destination);
if (world) {
if (world.native === me.id || world.native === me.ally) return null;
return quoteNexusGuildSecretShipment('homeworld', amount);
if (noField || world.native === me.id || world.native === me.ally) return null;
return quoteNexusGuildSecretShipment('homeworld', amount, noField);
}
const to = gameTerritories(g).find((t) => t.id === destination);
if (!to || (destination === MOBILE_STRONGHOLD && me.faction !== 'ixians'))
return null;
return quoteNexusGuildSecretShipment(to.type, amount);
return quoteNexusGuildSecretShipment(to.type, amount, noField);
}
/** Add a selected source, never infer card use just from possession. */
@@ -72,12 +75,31 @@ export function nexusGuildSecretAllyAction(
event !== g.nexusGuildSecretAlly!.event ||
!['ship', 'guildShip', 'homeworldShip'].includes(action.type) ||
action.nexus !== undefined ||
action.noField !== undefined ||
(action.noField !== undefined && action.type !== 'guildShip') ||
(action.type === 'guildShip' && action.from === 'reserves')
)
return null;
const amount =
action.type === 'homeworldShip'
const me = g.players.find((p) => p.id === g.me)!;
const noField = action.noField !== undefined;
let amount: number;
if (noField) {
const marker = guildNoFieldSelection(g, action);
if (!marker || !action.forces || typeof action.forces !== 'object' || Array.isArray(action.forces) ||
!validGameLocation(g, marker.location.territory, marker.location.sector) || marker.location.sector === g.storm)
return null;
let physical = 0;
for (const [key, count] of Object.entries(action.forces)) {
const source = splitLocation(key);
if (!validGameLocation(g, source.territory, source.sector) ||
typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0 ||
count > (me.forces[key] ?? 0)) return null;
if (!count) continue;
if (source.territory !== marker.location.territory || source.sector === g.storm) return null;
physical += count;
}
if (physical > 20) return null;
amount = physical + 1;
} else amount = action.type === 'homeworldShip'
? Object.values(
(action.sources ?? {}) as Record<
string,
@@ -93,8 +115,7 @@ export function nexusGuildSecretAllyAction(
const destination = String(
action.type === 'homeworldShip' ? action.destination : action.territory,
);
if (!nexusGuildSecretAllyQuote(g, destination, amount)) return null;
const me = g.players.find((p) => p.id === g.me)!;
if (!nexusGuildSecretAllyQuote(g, destination, amount, noField)) return null;
if (
action.type === 'ship' &&
(amount > me.reserves ||
+8 -5
View File
@@ -30,12 +30,15 @@ const plain = (value: unknown): value is Record<string, unknown> =>
!Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value));
/** The caller validates route permission and actual physical custody. */
export function quoteNexusGuildSecretShipment(destinationType: string, amount: number) {
/** Amount is billable units; a selected concealed marker contributes one unit
* but no physical counter. The caller validates permission and actual custody. */
export function quoteNexusGuildSecretShipment(destinationType: string, amount: number, noField = false) {
requireGuild(['stronghold','sand','rock','polar','reserves','homeworld'].includes(destinationType) &&
Number.isSafeInteger(amount) && amount > 0 && amount <= 20,
'Guild Secret Ally requires a valid destination and one to twenty physical forces.');
return {physicalAmount:amount,cost:guildShipmentCost(destinationType === 'homeworld' ? 'reserves' : destinationType,amount),source:'nexusGuildSecretAlly' as const};
typeof noField === 'boolean' && (!noField || destinationType !== 'homeworld') &&
Number.isSafeInteger(amount) && amount > 0 && amount <= (noField ? 21 : 20),
noField ? 'Guild Secret Ally requires a valid destination, one concealed marker and up to twenty physical forces.'
: 'Guild Secret Ally requires a valid destination and one to twenty physical forces.');
return {physicalAmount:amount - (noField ? 1 : 0),cost:guildShipmentCost(destinationType === 'homeworld' ? 'reserves' : destinationType,amount),source:'nexusGuildSecretAlly' as const};
}
function roster(context: NexusGuildSecretAllyContext) {
requireGuild(
+12 -7
View File
@@ -1110,6 +1110,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return, and marker Homeworld placement remains unavailable. Select the marker separately from physical counters; ordinary movement remains available.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
'The revealed token stays face up until another token is placed. Revealing it does not clear the previous-shipment restriction. An allied No-Field shipment reveals immediately and uses the ally’s reserves; an existing token must be revealed before another shipment.',
'Before an eligible ally uses its shipment, the game holds an offer opportunity for Richese. Richese may pass or privately propose a token, destination and payment. The ally accepts the exact offer and chooses its permitted regular/elite mix, or declines without payment. Success consumes the ally’s shipment, not Richese’s shipment or either player’s movement.',
@@ -1139,29 +1140,29 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range or two-origin gathering uses current trainer/token/event and exact per-origin route/entry, preserving one movement, denomination and physical stocks. Special transport remains separate.',
evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts'],
'Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range or two-origin gathering uses current trainer/token/event and exact per-origin route/entry. Provisional Guild cross and actual Secret Ally cross/face-down return keep marker custody/history distinct from physical counters, with one rounded price and native response continuations. Broader special transports remain separate.',
evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts', 'game/guild-no-field-selection.ts', 'game/transport-quote.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-guild-secret-ally.ts'],
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Original inventory/card/inspection and current-fact controls remain. Planetologist exposes the owner’s marker separately from physical counters in either movement mode, with provisional guidance, exact origin counts and guarded actual submission. The marker creates no physical force count.',
'Original inventory/card/inspection and current-fact controls remain. Planetologist exposes the owner’s marker separately from physical counters in either movement mode. Existing Guild transport controls now quote marker1 separately from physical0..20, with persistent provisional cross/face-down-return guidance and the actual Secret Ally selector. The marker creates no physical force count.',
evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx', 'components/planetologist-movement.tsx', 'components/planetologist-movement-action.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx', 'components/private-battle-plan.tsx', 'components/mixed-shipment-fields.tsx', 'components/truthtrance-no-field-value.tsx'],
},
{
area: 'AI',
status: 'Partial',
detail:
'Existing own/allied, inspection and truthAnswer policies remain. All four minimal profiles may offer boosted own-marker routes or marker-plus-other-origin gathering through shared legal geometry/entry and a living trainer. Existing mixed-marker reveal preference, strategy and calibration remain separate.',
evidence: ['game/bots.ts'],
'Existing own/allied, inspection and truthAnswer policies remain. All four minimal profiles may offer boosted own-marker routes or marker-plus-other-origin gathering through shared legal geometry/entry and a living trainer. A shared-quote adapter also offers current owned Guild cross or actual Secret Ally cross/return without using denomination for price. Strategy and calibration remain deferred.',
evidence: ['game/bots.ts', 'game/bot-guild-no-field-transport.ts'],
},
{
area: 'Documentation',
status: 'Partial',
detail:
'Current-observation, printed value and physical counter distinctions remain recorded. Marker-as-one-force plus either ordinary Planetologist movement alternative is explicitly provisional; no joint FAQ or complete combinations are claimed.',
evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md', 'docs/PLANETOLOGIST_RULES.md', 'docs/ATREIDES_FULL_PLAN_TIMING.md', 'docs/TRUTHTRANCE_FORCE_FACTS.md'],
'Current-observation, printed value and physical counter distinctions remain recorded. Marker-as-one-force with Planetologist movement, Guild cross and actual Secret Ally face-down return is explicitly provisional. Native ally return stays prohibited; last-shipment history survives recall. No joint FAQ or complete combinations are claimed.',
evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/NEXUS_GUILD_SECRET_ALLY_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md', 'docs/PLANETOLOGIST_RULES.md', 'docs/ATREIDES_FULL_PLAN_TIMING.md', 'docs/TRUTHTRANCE_FORCE_FACTS.md'],
},
{
area: 'Verification',
@@ -1169,6 +1170,10 @@ export const RULE_TOPICS: RuleTopic[] = [
detail:
'Native trained-leader/shipment/movement cases and controlled submit callbacks cover concealed Planetologist range and gather routes plus legal continuation. Earlier scoped Truthtrance, inspection, allied and recovery evidence remains historical. Complete modes, broader combinations, browser/mobile/deployed and final assurance remain due.',
evidence: [
'tests/fixture-guild-no-field-transport.ts',
'tests/guild-no-field-transport-engine.test.ts',
'tests/guild-no-field-transport-quote.test.ts',
'tests/bot-guild-no-field-transport.test.ts',
'tests/fixture-planetologist-no-field-gather.ts',
'tests/planetologist-no-field-gather-engine.test.ts',
'tests/planetologist-no-field-gather-controls.test.tsx',
+1 -1
View File
@@ -284,7 +284,7 @@ export function revealRicheseNoField(
};
}
/** Provisional Ecaz Betrayal composition: return the marker face down, without
/** Provisional Ecaz Betrayal / authorized Guild-return composition: return face down, without
* revealing or materializing its denomination. The last-shipment ban remains. */
export function recallRicheseNoField(previous: RicheseNoField, tokenId: string): RicheseNoField {
validateRicheseNoField(previous);
+43 -19
View File
@@ -5,6 +5,7 @@ import { splitLocation, validGameLocation } from './board';
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
import { shipmentPaymentBounds } from './shipment-price';
import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching';
import { guildNoFieldSelection } from './guild-no-field-selection';
/** Preview the submitted Guild transport using only this seat's entitled view.
* The authoritative action still revalidates the selection when it arrives.
@@ -22,10 +23,27 @@ export function guildTransportQuote(g: GameView, action: Action) {
if (g.guildTransportCanceled && !nexusAllowed)
unavailableReasons.push('Karama prevents native Guild cross/return transport for this shipment opportunity.');
const fromReserves = action.from === 'reserves';
const namesNoField = action.noField !== undefined;
const noField = namesNoField ? guildNoFieldSelection(g, action) : null;
let total = 0;
let origin: string | undefined;
let origin = noField?.location.territory;
const matchingGroup:Record<string,number>={};
let validForces = true;
let validForces = !namesNoField || !!noField;
if (namesNoField && !noField)
unavailableReasons.push('Choose your currently deployed No-Field and its current owner event.');
if (namesNoField && (fromReserves || !action.forces ||
typeof action.forces !== 'object' || Array.isArray(action.forces))) {
validForces = false;
unavailableReasons.push('Select the concealed No-Field from its board location with a physical force group, which may be empty.');
}
if (noField && !fromReserves) {
if (!validGameLocation(g, noField.location.territory, noField.location.sector)) {
validForces = false;
unavailableReasons.push('The current No-Field source location is unavailable.');
}
if (noField.location.sector === g.storm)
unavailableReasons.push('The selected No-Field source sector is in the storm.');
}
const unitSelection = (
value: unknown,
available: number,
@@ -58,10 +76,6 @@ export function guildTransportQuote(g: GameView, action: Action) {
);
}
};
if (action.noField !== undefined)
unavailableReasons.push(
'Guild transport of a concealed No-Field is unavailable. Reveal the marker first or use its movement action.',
);
if (fromReserves) {
if (nexus)
unavailableReasons.push(
@@ -71,12 +85,13 @@ export function guildTransportQuote(g: GameView, action: Action) {
unavailableReasons.push(
'Only allied Fremen may cross-ship southern reserves.',
);
unitSelection(
action.amount,
p.reserves,
p.elites?.reserves ?? 0,
action.elite,
);
if (!namesNoField)
unitSelection(
action.amount,
p.reserves,
p.elites?.reserves ?? 0,
action.elite,
);
} else {
const combined = action.forces !== undefined;
const entries = combined
@@ -113,16 +128,21 @@ export function guildTransportQuote(g: GameView, action: Action) {
unavailableReasons.push('A selected source sector is in the storm.');
}
}
if (!validForces || !total)
if (total > 20) validForces = false;
if (!validForces || (!total && !noField))
unavailableReasons.push(
fromReserves
? 'Choose a whole number of available southern reserves and a valid elite allocation.'
: 'Choose available physical forces and a valid elite allocation from one territory.',
: namesNoField
? 'Choose one current concealed marker and zero to twenty available physical forces from its territory.'
: 'Choose available physical forces and a valid elite allocation from one territory.',
);
const to =
typeof action.territory === 'string' ? action.territory : 'reserves';
const sector = action.sector ?? 0;
const noFieldWorld = !!noField && !!g.homeworlds?.worlds?.some(world => world.id === to);
const validDestination =
!noFieldWorld &&
(action.territory == null || typeof action.territory === 'string') &&
typeof sector === 'number' &&
Number.isInteger(sector) &&
@@ -132,7 +152,9 @@ export function guildTransportQuote(g: GameView, action: Action) {
if (to === 'reserves' && p.faction !== 'guild' && !nexusAllowed)
unavailableReasons.push('Only the Guild may return forces to reserves.');
if (!validDestination)
unavailableReasons.push('Choose a sector belonging to the destination.');
unavailableReasons.push(noFieldWorld
? 'A concealed No-Field may cross to an Arrakis location or return behind your shield, not enter a Homeworld.'
: 'Choose a sector belonging to the destination.');
else if (
to !== 'reserves' &&
!botEntryAllowed(g, p, to, sector as number, 'guildShip', origin)
@@ -157,12 +179,13 @@ export function guildTransportQuote(g: GameView, action: Action) {
unavailableReasons.push(
'Guild transport requires your unused shipment opportunity.',
);
const billable = total + (noField ? 1 : 0);
const cost =
validForces && total > 0 && validDestination
validForces && billable > 0 && validDestination
? guildTransportCost(
to,
total,
g.karamaShipping?.player === g.me || !g.guildRateCanceled,
billable,
nexusAllowed || g.karamaShipping?.player === g.me || !g.guildRateCanceled,
)
: null;
const ownSpice = p.spice ?? 0;
@@ -200,7 +223,8 @@ export function guildTransportQuote(g: GameView, action: Action) {
}catch(error){if(!(error instanceof AdvisorTransportMatchingError))throw error;unavailableReasons.push(error.message);}
}
return {
physicalForces: validForces && total > 0 ? total : undefined,
physicalForces: validForces && (total > 0 || noField) ? total : undefined,
...(noField ? { noField: true as const } : {}),
quote: validShare
? {
cost,