Counter BG matching before fresh Guild and Junction transport
Preserve original grants, private funding and unused shipments while matching is pending. Restore legal replacement choices after prevention and resume the original permission, price and transfer once.
This commit is contained in:
1 parent
c999054ae0
commit
b2a20226d7
17 files changed
+1444
-17
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import { gameTerritories, splitLocation, type MobileBoard } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
import type { AdvisorStance } from './advisor-automatic-flip';
|
||||
|
||||
/** The selected incoming group, before its transport or payment is committed. */
|
||||
export type AdvisorTransportMatchingSource = {
|
||||
event: string;
|
||||
producer: 'guild' | 'junction';
|
||||
owner: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
origin: string;
|
||||
destination: string;
|
||||
sector: number;
|
||||
advisors: boolean;
|
||||
group: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AdvisorTransportMatchingIntent = Omit<AdvisorTransportMatchingSource, 'event' | 'turn' | 'phase'>;
|
||||
export type AdvisorTransportMatchingQuote = Omit<AdvisorTransportMatchingSource, 'event'>;
|
||||
export type AdvisorTransportMatchingPlayer = {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
forces: Readonly<Record<string, number>>;
|
||||
advisors?: Readonly<Record<string, AdvisorStance>>;
|
||||
shipped?: boolean;
|
||||
};
|
||||
export type AdvisorTransportMatchingWorld = {
|
||||
id: string;
|
||||
forces: Readonly<Record<string, { normal: number; elite: number }>>;
|
||||
};
|
||||
export type AdvisorTransportMatchingContext = MobileBoard & {
|
||||
advanced: boolean;
|
||||
status: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
active: string | null;
|
||||
players: readonly AdvisorTransportMatchingPlayer[];
|
||||
worlds?: readonly AdvisorTransportMatchingWorld[];
|
||||
/** Private native opportunity identity, never copied into the public source. */
|
||||
advisorTransportMatchingOpportunity?: string;
|
||||
advisorTransportMatchingDenied?: readonly string[];
|
||||
};
|
||||
|
||||
export class AdvisorTransportMatchingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AdvisorTransportMatchingError';
|
||||
}
|
||||
}
|
||||
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
const nonnegative = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
const positive = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && (value as number) > 0;
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
function requireMatching(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new AdvisorTransportMatchingError(message);
|
||||
}
|
||||
|
||||
/** A fresh counter UUID or destination sector cannot retry the same native
|
||||
* opportunity. The private scope belongs only to this local prevention key. */
|
||||
export function advisorTransportMatchingKey(
|
||||
source: AdvisorTransportMatchingSource | AdvisorTransportMatchingQuote,
|
||||
opportunity: string,
|
||||
): string {
|
||||
requireMatching(identifier(opportunity), 'Transport matching needs its original native opportunity.');
|
||||
const group = Object.entries(source.group).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
||||
return JSON.stringify([
|
||||
'advisorTransportMatching', opportunity, source.turn, source.owner, source.producer,
|
||||
source.origin, source.destination, source.advisors, group,
|
||||
]);
|
||||
}
|
||||
|
||||
function hasResidents(player: AdvisorTransportMatchingPlayer, destination: string) {
|
||||
return Object.entries(player.forces).some(([key, count]) =>
|
||||
splitLocation(key).territory === destination && count > 0);
|
||||
}
|
||||
|
||||
function selectedGroup(
|
||||
ctx: AdvisorTransportMatchingContext,
|
||||
owner: AdvisorTransportMatchingPlayer,
|
||||
intent: AdvisorTransportMatchingIntent,
|
||||
originSectors: readonly number[] | undefined,
|
||||
): Record<string, number> {
|
||||
requireMatching(record(intent.group) && Object.keys(intent.group).length > 0,
|
||||
'Select a nonempty physical BG packet for transport matching.');
|
||||
const keys = Object.keys(intent.group);
|
||||
if (!originSectors) {
|
||||
requireMatching(intent.producer === 'junction' && Array.isArray(ctx.worlds) &&
|
||||
intent.origin === [...keys].sort()[0],
|
||||
'Junction Homeworld matching needs its canonical first selected source world.');
|
||||
}
|
||||
const group: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const key of keys) {
|
||||
let held: number | undefined;
|
||||
if (originSectors) {
|
||||
const point = splitLocation(key);
|
||||
requireMatching(key === `${point.territory}:${point.sector}` && point.territory === intent.origin &&
|
||||
originSectors.includes(point.sector),
|
||||
'Select the physical transport packet from one current board territory.');
|
||||
held = owner.forces[key];
|
||||
} else {
|
||||
const worlds = ctx.worlds!.filter((world) => world.id === key);
|
||||
const world = worlds[0];
|
||||
requireMatching(ctx.players.some((player) => key === `homeworld:${player.faction}` ||
|
||||
(player.faction === 'emperor' && key === 'homeworld:emperor:salusa')) &&
|
||||
worlds.length === 1 && record(world.forces) && Object.hasOwn(world.forces, owner.id) &&
|
||||
record(world.forces[owner.id]),
|
||||
'Select BG counters actually held on current source Homeworlds.');
|
||||
// Only normal physical BG counters participate; other owners and special
|
||||
// counter counts are unrelated to this incoming selected packet.
|
||||
held = world.forces[owner.id].normal;
|
||||
}
|
||||
const count = intent.group[key];
|
||||
requireMatching(positive(count) && nonnegative(held) && held >= count,
|
||||
'The incoming transport packet is no longer held at its original physical sources.');
|
||||
total += count;
|
||||
requireMatching(positive(total), 'The incoming transport packet exceeds the supported physical count.');
|
||||
group[key] = count;
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/** The native transport keeps grant, route, occupancy, rate and funding
|
||||
* permission. This quote only opens an incoming-group stance change. */
|
||||
export function quoteAdvisorTransportMatching(
|
||||
ctx: AdvisorTransportMatchingContext,
|
||||
intent: AdvisorTransportMatchingIntent,
|
||||
): AdvisorTransportMatchingQuote | null {
|
||||
if (ctx.advanced !== true || ctx.status !== 'playing' || ctx.phase !== 5 || !positive(ctx.turn)) return null;
|
||||
requireMatching(record(intent) && identifier(intent.owner), 'Choose the current BG transport owner.');
|
||||
const owners = ctx.players.filter((player) => player.id === intent.owner);
|
||||
const owner = owners[0];
|
||||
if (owners.length !== 1 || owner.faction !== 'beneGesserit' || ctx.active !== owner.id || owner.shipped === true) return null;
|
||||
requireMatching((intent.producer === 'guild' || intent.producer === 'junction') &&
|
||||
identifier(intent.origin) && identifier(intent.destination) && nonnegative(intent.sector) &&
|
||||
typeof intent.advisors === 'boolean',
|
||||
'Choose the native transport, original packet and resident destination stance.');
|
||||
// Returning to a Homeworld or reserves never matches an Arrakis resident.
|
||||
if (intent.destination === 'reserves' || intent.destination.startsWith('homeworld:')) return null;
|
||||
const places = gameTerritories(ctx);
|
||||
const destination = places.find((place) => place.id === intent.destination && place.sectors.includes(intent.sector));
|
||||
const origin = places.find((place) => place.id === intent.origin);
|
||||
requireMatching(destination && (origin ||
|
||||
(intent.producer === 'junction' && intent.origin.startsWith('homeworld:'))),
|
||||
'Transport matching needs its current board destination and physical board or Junction Homeworld origin.');
|
||||
if (intent.origin === intent.destination || !hasResidents(owner, intent.destination)) return null;
|
||||
const targetAdvisors = !!owner.advisors?.[intent.destination];
|
||||
requireMatching(intent.advisors === targetAdvisors,
|
||||
'The resident BG group no longer has the declared transport matching stance.');
|
||||
// A Homeworld packet provisionally has the ordinary incoming fighter default.
|
||||
const incomingAdvisors = origin ? !!owner.advisors?.[intent.origin] : false;
|
||||
if (incomingAdvisors === targetAdvisors) return null;
|
||||
const group = selectedGroup(ctx, owner, intent, origin?.sectors);
|
||||
requireMatching(identifier(ctx.advisorTransportMatchingOpportunity),
|
||||
'Transport matching needs its original native opportunity.');
|
||||
const quote: AdvisorTransportMatchingQuote = {
|
||||
producer: intent.producer, owner: owner.id, turn: ctx.turn, phase: ctx.phase,
|
||||
origin: intent.origin, destination: intent.destination, sector: intent.sector,
|
||||
advisors: targetAdvisors, group,
|
||||
};
|
||||
requireMatching(ctx.advisorTransportMatchingDenied === undefined ||
|
||||
(Array.isArray(ctx.advisorTransportMatchingDenied) &&
|
||||
ctx.advisorTransportMatchingDenied.every((key) => typeof key === 'string')),
|
||||
'The transport matching prevention history is invalid.');
|
||||
requireMatching(!ctx.advisorTransportMatchingDenied?.includes(
|
||||
advisorTransportMatchingKey(quote, ctx.advisorTransportMatchingOpportunity)),
|
||||
'Karama already prevented this selected packet from matching in that territory during the original transport opportunity.');
|
||||
return quote;
|
||||
}
|
||||
|
||||
/** Validate the still-uncommitted packet before either counter outcome. */
|
||||
export function validateAdvisorTransportMatching(
|
||||
ctx: AdvisorTransportMatchingContext,
|
||||
source: AdvisorTransportMatchingSource,
|
||||
): void {
|
||||
requireMatching(record(source) &&
|
||||
Object.keys(source).sort().join(',') === 'advisors,destination,event,group,origin,owner,phase,producer,sector,turn' &&
|
||||
identifier(source.event) && positive(source.turn) && source.turn === ctx.turn &&
|
||||
source.phase === 5 && source.phase === ctx.phase,
|
||||
'Transport matching lost its original current-turn public source.');
|
||||
const quote = quoteAdvisorTransportMatching(ctx, {
|
||||
producer: source.producer, owner: source.owner, origin: source.origin, destination: source.destination,
|
||||
sector: source.sector, advisors: source.advisors, group: source.group,
|
||||
});
|
||||
requireMatching(quote, 'There is no current incoming BG stance change for this transport matching source.');
|
||||
}
|
||||
+3
-1
@@ -118,6 +118,7 @@ import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
|
||||
import { fighterCount, isAdvisor } from './advisors';
|
||||
import { quoteAdvisorMatching, AdvisorMatchingError } from './advisor-matching';
|
||||
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from './advisor-ambassador-matching';
|
||||
import { guildTransportQuote } from './transport-quote';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||
import { reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
import type { NativeReserveSelections } from './homeworld-native-reserves';
|
||||
@@ -4278,7 +4279,8 @@ export function botActions(g: GameView): Action[] {
|
||||
if (action.type === 'battlePlan' && g.ecazSpecialKarama?.declared &&
|
||||
(action.weapon || action.defense)) return [];
|
||||
const sourced = withNativeShipmentSources(g, action);
|
||||
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) && botAdvisorMatchingAllowed(g,sourced) ? [sourced] : [];
|
||||
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) && botAdvisorMatchingAllowed(g,sourced) &&
|
||||
(sourced.type!=='guildShip'||!g.advisorTransportMatchingDenied?.length||!guildTransportQuote(g,sourced).unavailableReasons.length) ? [sourced] : [];
|
||||
});
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const advancedDepartures = g.advancedAllySeparation?.territories;
|
||||
|
||||
+135
-7
@@ -611,6 +611,7 @@ import { canUseAsKaramaRole } from './shrine';
|
||||
import { quoteAdvisorMatching, validateAdvisorMatching, advisorMatchingKey,
|
||||
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
|
||||
import { quoteAdvisorAmbassadorMatching, validateAdvisorAmbassadorMatching, advisorAmbassadorMatchingKey, AdvisorAmbassadorMatchingError, type AdvisorAmbassadorMatchingSource } from './advisor-ambassador-matching';
|
||||
import { quoteAdvisorTransportMatching, validateAdvisorTransportMatching, advisorTransportMatchingKey, AdvisorTransportMatchingError, type AdvisorTransportMatchingSource } from './advisor-transport-matching';
|
||||
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival, validateSpiritualAdvisorSource,
|
||||
spiritualAdvisorMatchingKey, SpiritualAdvisorError, type SpiritualAdvisorSource } from './spiritual-advisor';
|
||||
import {
|
||||
@@ -1074,6 +1075,7 @@ export type ResponseWindow = {
|
||||
advisorPreparationSource?: AdvisorPreparationSource;
|
||||
advisorMatchingSource?: AdvisorMatchingSource;
|
||||
advisorAmbassadorMatchingSource?: AdvisorAmbassadorMatchingSource;
|
||||
advisorTransportMatchingSource?: AdvisorTransportMatchingSource;
|
||||
spiritualAdvisorSource?: SpiritualAdvisorSource;
|
||||
fremenGrant?: FremenRevivalGrantSource;
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
@@ -1303,6 +1305,14 @@ export type Game = {
|
||||
signature: string;
|
||||
parentSignature: string;
|
||||
};
|
||||
advisorTransportMatchingDenied?: {owner:string;turn:number;key:string}[];
|
||||
pendingAdvisorTransportMatching?: {
|
||||
receipt: AdvisorTransportMatchingSource;
|
||||
opportunity: string;
|
||||
signature: string;
|
||||
intent: {kind:'guild';frame:GuildTransportOrder;fromReserves:boolean;secret?:string}
|
||||
| {kind:'junction';declaration:JunctionTransportDeclaration};
|
||||
};
|
||||
pendingSpiritualAdvisor?: {
|
||||
event: string;
|
||||
turn: number;
|
||||
@@ -14632,6 +14642,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
}
|
||||
}
|
||||
function movementCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
if (response.advisorTransportMatchingSource) { currentAdvisorTransportMatching(g,response); return null; }
|
||||
if (response.advisorAmbassadorMatchingSource) { currentAdvisorAmbassadorMatching(g,response); return null; }
|
||||
if (response.spiritualAdvisorSource) { currentSpiritualAdvisorResponse(g,response); return null; }
|
||||
if (response.advisorMatchingSource) { currentAdvisorMatching(g, response); return null; }
|
||||
@@ -16592,6 +16603,7 @@ function groundMovementProbe(source: Game, actor: string): Game {
|
||||
game.pendingKarama = null; game.pendingTreacheryDiscard = null;
|
||||
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
|
||||
game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null;
|
||||
delete game.pendingAdvisorTransportMatching;
|
||||
game.pendingArrivalReaction = []; game.pendingExchange = null;
|
||||
if (game.pendingShipment?.player === game.active && recoverableUnsupportedShipment(game.pendingShipment)) {
|
||||
const shipment = game.pendingShipment; game.pendingShipment = null;
|
||||
@@ -20589,6 +20601,113 @@ function completeSpiritualAdvisorArrival(g:Game){
|
||||
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor');
|
||||
}
|
||||
}
|
||||
type TransportMatchingIntent=NonNullable<Game['pendingAdvisorTransportMatching']>['intent'];
|
||||
function transportMatchingRule<T>(quote:()=>T):T{
|
||||
try{return quote();}catch(error){if(error instanceof AdvisorTransportMatchingError)throw new RuleError(error.message);throw error;}
|
||||
}
|
||||
function transportMatchingScopes(g:Game,owner:string){
|
||||
return {guild:JSON.stringify(['guild',g.turn,owner,guildTransportOpportunity(g,owner)]),
|
||||
secret:JSON.stringify(['guildSecret',g.turn,owner]),
|
||||
junction:JSON.stringify(['junction',g.turn,owner,g.junctionOffer?.recipient===owner?g.junctionOffer.event:null])};
|
||||
}
|
||||
function transportMatchingContext(g:Game,owner:string,opportunity:string){
|
||||
return {...g,worlds:g.homeworlds?.custody?(homeworldRule(()=>homeworldTable(g))??undefined):undefined,
|
||||
advisorTransportMatchingOpportunity:opportunity,
|
||||
advisorTransportMatchingDenied:g.advisorTransportMatchingDenied?.filter(r=>r.owner===owner&&r.turn===g.turn).map(r=>r.key)};
|
||||
}
|
||||
function quoteTransportMatching(g:Game,intent:TransportMatchingIntent){
|
||||
const owner=intent.kind==='guild'?intent.frame.player:intent.declaration.player;
|
||||
const scopes=transportMatchingScopes(g,owner);
|
||||
if(intent.kind==='guild'){
|
||||
if(intent.fromReserves)return null;
|
||||
const order=intent.frame,opportunity=intent.secret?scopes.secret:scopes.guild;
|
||||
const quote=transportMatchingRule(()=>quoteAdvisorTransportMatching(transportMatchingContext(g,owner,opportunity),
|
||||
{producer:'guild',owner,origin:order.origin,destination:order.to,sector:order.sector,
|
||||
advisors:order.advisors,group:Object.fromEntries(order.group)}));
|
||||
return quote?{quote,opportunity}:null;
|
||||
}
|
||||
const {quote:order,arrival,advisors}=quoteJunctionDeclaration(g,getPlayer(g,owner),intent.declaration);
|
||||
if(!arrival)return null;
|
||||
const group=Object.fromEntries(order.sources.map(source=>[source.key,
|
||||
source.before.normal+source.before.elite-source.after.normal-source.after.elite]));
|
||||
const origin=order.originKind==='homeworld'?Object.keys(group).sort()[0]:order.origin;
|
||||
const quote=transportMatchingRule(()=>quoteAdvisorTransportMatching(transportMatchingContext(g,owner,scopes.junction),
|
||||
{producer:'junction',owner,origin,destination:arrival.territory,sector:arrival.sector,advisors,group}));
|
||||
return quote?{quote,opportunity:scopes.junction}:null;
|
||||
}
|
||||
function openAdvisorTransportMatching(g:Game,intent:TransportMatchingIntent){
|
||||
const matching=quoteTransportMatching(g,intent);if(!matching)return false;
|
||||
requireRule(!g.response&&!g.decision&&!g.pendingAdvisorTransportMatching,
|
||||
'Finish the original incoming transport matching first.');
|
||||
const receipt={event:crypto.randomUUID(),...matching.quote};
|
||||
g.pendingAdvisorTransportMatching={receipt,intent,signature:JSON.stringify(intent),opportunity:matching.opportunity};
|
||||
g.response={kind:'advisorFlip',owner:receipt.owner,location:receipt.destination,advisors:receipt.advisors,
|
||||
amount:Object.values(receipt.group).reduce((n,count)=>n+count,0),passed:[],advisorTransportMatchingSource:receipt};
|
||||
return true;
|
||||
}
|
||||
function transportMatchingShadow(g:Game,event:string){
|
||||
const shadow=structuredClone(g);
|
||||
const owned=(control:unknown)=>{
|
||||
const c=control as {response?:ResponseWindow|null;pendingKarama?:Game['pendingKarama']}|null|undefined;
|
||||
return c?.response?.advisorTransportMatchingSource?.event===event||
|
||||
(c?.pendingKarama?.use.kind==='cancel'&&c.pendingKarama.use.response.advisorTransportMatchingSource?.event===event);
|
||||
};
|
||||
if(owned(shadow.pendingExchange))shadow.pendingExchange=null;
|
||||
if(owned(shadow.pendingNullentropy?.resume))shadow.pendingNullentropy=null;
|
||||
if(owned(shadow.pendingRicheseGift?.resume))shadow.pendingRicheseGift=null;
|
||||
if(owned(shadow.pendingRichesePurchaseIncome?.resume))shadow.pendingRichesePurchaseIncome=null;
|
||||
const continuation=shadow.pendingTreacheryDiscard?.continuation;
|
||||
if(continuation&&'resume'in continuation&&owned(continuation.resume))shadow.pendingTreacheryDiscard=null;
|
||||
shadow.response=null;shadow.decision=null;shadow.pendingKarama=null;shadow.truthtrance=null;
|
||||
return shadow;
|
||||
}
|
||||
function currentAdvisorTransportMatching(g:Game,response:ResponseWindow){
|
||||
const parent=g.pendingAdvisorTransportMatching,source=response.advisorTransportMatchingSource;
|
||||
requireRule(parent&&source&&response.kind==='advisorFlip'&&response.owner===source.owner&&
|
||||
response.location===source.destination&&response.advisors===source.advisors&&
|
||||
response.amount===Object.values(source.group).reduce((n,count)=>n+count,0)&&
|
||||
!response.advisorResume&&!response.advisorMatchingSource&&!response.advisorAmbassadorMatchingSource&&
|
||||
!response.spiritualAdvisorSource&&!response.automaticAdvisorFlip&&!response.advisorPreparationSource&&
|
||||
JSON.stringify(parent.receipt)===JSON.stringify(source)&&parent.signature===JSON.stringify(parent.intent)&&
|
||||
source.producer===(parent.intent.kind==='guild'?'guild':'junction'),
|
||||
'The transport matching counter lost its original uncommitted declaration.');
|
||||
const quiet=transportMatchingShadow(g,source.event),owner=getPlayer(quiet,source.owner);
|
||||
if(parent.intent.kind==='guild')validateGuildTransportForCommit(quiet,owner,parent.intent.frame,
|
||||
parent.intent.fromReserves,parent.intent.secret);
|
||||
const matching=quoteTransportMatching(quiet,parent.intent);
|
||||
requireRule(matching&&matching.opportunity===parent.opportunity&&
|
||||
JSON.stringify(matching.quote)===JSON.stringify(Object.fromEntries(Object.entries(source).filter(([key])=>key!=='event'))),
|
||||
'The transport matching counter no longer owns its original packet or grant.');
|
||||
transportMatchingRule(()=>validateAdvisorTransportMatching(transportMatchingContext(quiet,source.owner,parent.opportunity),source));
|
||||
return parent;
|
||||
}
|
||||
function advisorTransportMatchingIntegrity(g:Game){
|
||||
if(!g.pendingAdvisorTransportMatching)return;
|
||||
const continuation=g.pendingTreacheryDiscard?.continuation;
|
||||
const controls=[g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume,g.pendingKull?.resume,
|
||||
continuation&&'resume'in continuation?continuation.resume:null];
|
||||
const responses=controls.flatMap(c=>c?.response?[c.response]:[]);
|
||||
for(const {pending}of savedKaramaContexts(g))if(pending.use.kind==='cancel')responses.push(pending.use.response);
|
||||
const matching=responses.filter(r=>r.advisorTransportMatchingSource);
|
||||
requireRule(matching.length>0,'Restore the original transport matching response.');
|
||||
for(const response of matching)currentAdvisorTransportMatching(g,response);
|
||||
}
|
||||
function finishAdvisorTransportMatching(g:Game,response:ResponseWindow,canceled:boolean){
|
||||
const parent=currentAdvisorTransportMatching(g,response),source=parent.receipt;
|
||||
delete g.pendingAdvisorTransportMatching;
|
||||
if(canceled){
|
||||
const key=advisorTransportMatchingKey(source,parent.opportunity);
|
||||
if(!g.advisorTransportMatchingDenied?.some(r=>r.key===key))
|
||||
(g.advisorTransportMatchingDenied??=[]).push({owner:source.owner,turn:g.turn,key});
|
||||
log(g,`${getPlayer(g,source.owner).name}'s incoming transport matching in ${territory(source.destination).name} was prevented before payment or transfer. Choose another legal packet or destination; the original shipment opportunity remains unused.`,
|
||||
{faction:'beneGesserit',name:'Transport group matching prevented'});
|
||||
}else if(parent.intent.kind==='guild'){
|
||||
const intent=parent.intent,owner=getPlayer(g,source.owner);
|
||||
if(intent.secret)commitGuildTransport(g,owner,intent.frame,intent.fromReserves,intent.secret);
|
||||
else openGuildTransportPermission(g,owner,intent.frame,intent.fromReserves);
|
||||
}else commitJunctionTransport(g,getPlayer(g,source.owner),parent.intent.declaration);
|
||||
}
|
||||
function ambassadorMatchingRule<T>(quote:()=>T):T{
|
||||
try{return quote();}catch(error){if(error instanceof AdvisorAmbassadorMatchingError)throw new RuleError(error.message);throw error;}
|
||||
}
|
||||
@@ -23186,7 +23305,7 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
|
||||
function victory(g: Game, quote?: VictoryQuote) {
|
||||
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport &&
|
||||
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport &&
|
||||
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingSpiritualAdvisor,
|
||||
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingAdvisorTransportMatching && !g.pendingSpiritualAdvisor,
|
||||
'Resolve mandatory advisor conversions before checking victory.');
|
||||
quote ??= currentVictoryQuote(g);
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
@@ -28978,7 +29097,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
);
|
||||
} else if (response.kind === 'advisorFlip') {
|
||||
const bg = getPlayer(g, response.owner);
|
||||
if (response.advisorAmbassadorMatchingSource) finishAdvisorAmbassadorMatching(g,response,canceled);
|
||||
if (response.advisorTransportMatchingSource) finishAdvisorTransportMatching(g,response,canceled);
|
||||
else if (response.advisorAmbassadorMatchingSource) finishAdvisorAmbassadorMatching(g,response,canceled);
|
||||
else if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
|
||||
else if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
|
||||
else if (response.advisorResume === 'planetologistGather') {
|
||||
@@ -29555,7 +29675,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
|
||||
const quote = quoteJunctionDeclaration(g, p, declaration, action.allyPayment === undefined);
|
||||
declaration.allyPayment = action.allyPayment === undefined ? quote.allyPayment :
|
||||
contribution(g, p, quote.quote.cost, action.allyPayment);
|
||||
commitJunctionTransport(g, p, declaration);
|
||||
if(!openAdvisorTransportMatching(g,{kind:'junction',declaration}))commitJunctionTransport(g,p,declaration);
|
||||
}
|
||||
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
|
||||
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
|
||||
@@ -30350,14 +30470,15 @@ function nativeGuildTransportPermission(g: Game, p: Player): boolean {
|
||||
return !!guild && p.ally === guild.id && guild.ally === p.id;
|
||||
}
|
||||
function validateGuildTransportForCommit(
|
||||
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
|
||||
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, guildSecretEvent?:string,
|
||||
) {
|
||||
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
|
||||
requireRule(g.status === 'playing' && g.phase === 5 && frame.turn === g.turn &&
|
||||
frame.player === p.id && g.active === p.id && shipmentAvailable(g, p),
|
||||
'This Guild transport no longer belongs to an unused shipment opportunity.');
|
||||
requireRule(nativeGuildTransportPermission(g, p),
|
||||
requireRule(guildSecretEvent?validateGuildSecretDeclaration(g,p.id,guildSecretEvent):nativeGuildTransportPermission(g,p),
|
||||
'Native Guild transport requires the Guild or its reciprocal ally.');
|
||||
requireRule(!guildSecretEvent||!fromReserves,'Use the reserve shipment selector for Guild Secret Ally reinforcements.');
|
||||
if (fromReserves) {
|
||||
requireRule(p.faction === 'fremen' && origin === 'reserves' &&
|
||||
group.length === 0 && Object.keys(eliteGroup).length === 0,
|
||||
@@ -30375,7 +30496,7 @@ function validateGuildTransportForCommit(
|
||||
'The original Guild transport force group changed or entered the storm.');
|
||||
}
|
||||
requireRule((fromReserves || to === 'reserves' || to !== origin) &&
|
||||
(to !== 'reserves' || p.faction === 'guild') &&
|
||||
(to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent) &&
|
||||
(to !== MOBILE_STRONGHOLD || p.faction === 'ixians') &&
|
||||
advisors === (to !== 'reserves' && arrivalAsAdvisor(g, p, to)),
|
||||
'The original Guild transport destination or advisor stance is unavailable.');
|
||||
@@ -30384,7 +30505,7 @@ function validateGuildTransportForCommit(
|
||||
!g.players.some(other => other.id !== p.id && at(other, to)),
|
||||
'New advisors cannot become fighters this turn.');
|
||||
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
|
||||
const halfRate = g.karamaShipping?.player === p.id ||
|
||||
const halfRate = !!guildSecretEvent || g.karamaShipping?.player === p.id ||
|
||||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id);
|
||||
requireRule(cost === guildShipmentCost(
|
||||
to === 'reserves' ? 'reserves' : territory(to).type, amount, halfRate),
|
||||
@@ -32663,6 +32784,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
advisorTransportMatchingIntegrity(state);
|
||||
advisorAmbassadorMatchingIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
@@ -33075,6 +33197,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
advisorTransportMatchingIntegrity(state);
|
||||
advisorAmbassadorMatchingIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
@@ -36583,6 +36706,7 @@ function applyActionInner(
|
||||
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
|
||||
amount: n, elite, cost, allyPayment, advisors,
|
||||
};
|
||||
if(execution==='live'&&openAdvisorTransportMatching(g,{kind:'guild',frame,fromReserves,...(guildSecretEvent?{secret:guildSecretEvent}:{})}))return g;
|
||||
if (!guildSecretEvent) {
|
||||
openGuildTransportPermission(g, p, frame, fromReserves);
|
||||
return g;
|
||||
@@ -37162,6 +37286,7 @@ export function viewGame(state: Game, id: string) {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
advisorTransportMatchingIntegrity(state);
|
||||
advisorAmbassadorMatchingIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
@@ -37633,6 +37758,9 @@ export function viewGame(state: Game, id: string) {
|
||||
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
|
||||
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
|
||||
homeworldRevivalBlocks: homeworldRevivalChoiceBlocks(g, me),
|
||||
...(g.advisorTransportMatchingDenied?.some(r=>r.owner===id&&r.turn===g.turn)?{
|
||||
advisorTransportMatchingDenied:g.advisorTransportMatchingDenied.filter(r=>r.owner===id&&r.turn===g.turn).map(r=>r.key),
|
||||
advisorTransportMatchingScopes:transportMatchingScopes(g,id)}:{}),
|
||||
homeworlds: g.homeworlds
|
||||
? { worlds: homeworldRule(() => homeworldTable(g)) }
|
||||
: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type HomeworldShipmentSources,
|
||||
} from './homeworld-shipment-options';
|
||||
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
|
||||
import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching';
|
||||
|
||||
export function junctionTransportOrigins(view: GameView) {
|
||||
const me = view.players.find((player) => player.id === view.me);
|
||||
@@ -219,6 +220,16 @@ export function junctionTransportChoice(
|
||||
arrival.territory,
|
||||
quote.originKind === 'arrakis' ? quote.origin : undefined,
|
||||
);
|
||||
if(view.advisorTransportMatchingDenied?.length){
|
||||
const group=Object.fromEntries(quote.sources.map(source=>[source.key,
|
||||
source.before.normal+source.before.elite-source.after.normal-source.after.elite]));
|
||||
try{
|
||||
quoteAdvisorTransportMatching({...view,worlds:view.homeworlds?.worlds??undefined,
|
||||
advisorTransportMatchingOpportunity:view.advisorTransportMatchingScopes?.junction},
|
||||
{producer:'junction',owner:me.id,origin:quote.originKind==='homeworld'?Object.keys(group).sort()[0]:quote.origin,
|
||||
destination:arrival.territory,sector:arrival.sector,advisors:result.advisors,group});
|
||||
}catch(error){if(!(error instanceof AdvisorTransportMatchingError))throw error;result.blocked=error.message;}
|
||||
}
|
||||
const entryBlock = territoryEntryBlock(
|
||||
view.players,
|
||||
me.id,
|
||||
|
||||
@@ -194,6 +194,8 @@ const cancellationSources = {
|
||||
...(response.automaticAdvisorFlip.producer === 'spiritualArrival'
|
||||
? { spiritual:g.pendingSpiritualAdvisor?.source??null } : {}),
|
||||
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
|
||||
: response.advisorTransportMatchingSource
|
||||
? {matching:response.advisorTransportMatchingSource,pending:g.pendingAdvisorTransportMatching?.receipt??null}
|
||||
: response.advisorAmbassadorMatchingSource
|
||||
? {matching:response.advisorAmbassadorMatchingSource,pending:g.pendingAdvisorAmbassadorMatching?.receipt??null}
|
||||
: response.advisorMatchingSource
|
||||
|
||||
@@ -308,6 +308,7 @@ export function quoteMovementCancellation(
|
||||
if (response.kind === 'advisorFlip' && response.advisorResume === 'preparation') return null;
|
||||
if (response.kind === 'advisorFlip' && response.advisorMatchingSource) return null;
|
||||
if (response.kind === 'advisorFlip' && response.advisorAmbassadorMatchingSource) return null;
|
||||
if (response.kind === 'advisorFlip' && response.advisorTransportMatchingSource) return null;
|
||||
if (response.kind === 'advisorFlip' && response.spiritualAdvisorSource) return null;
|
||||
if (
|
||||
![
|
||||
|
||||
+34
-5
@@ -4316,6 +4316,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'After Fremen setup, choose the starting advisor’s territory and sector. Setup conversions remain automatic. During play, advisors alone must become fighters. A ground move into an empty territory or the last outsider’s ground departure now offers Karama prevention after the physical move, before fighter benefits. Allow to convert once; cancellation leaves the completed movement intact.',
|
||||
'A normal shipment enters as fighters unless joining existing advisors. Opposite-type normal ground arrivals and normal reserves joining advisors now offer Karama matching prevention before movement or payment. Residents keep their type. Provisionally stop a prevented packet into this territory for that action opportunity, leaving a legal replacement and the unused action available. Same-type moves need no flip. Advisors entering another occupied territory may keep their existing optional fighter choice.',
|
||||
'An allied Fremen Ambassador relocation also offers incoming matching before any selected force moves or collects spice. Residents keep their type. Provisionally, prevention restores the same relocation chooser while denying that same packet here for this Ambassador event. The already-triggered token and ordinary movement/shipment remain unchanged; another legal packet or destination stays available.',
|
||||
'Fresh Guild cross-transport and Junction board arrivals also offer incoming matching before their original permission, price, payment or transfer. Junction Homeworld groups provisionally enter fighter-side. Prevention leaves the original shipment and grant unused while that same packet here stays unavailable for this producer opportunity; choose another legal packet or destination. Junction permission and tariff remain immune. Secret Ally keeps its actual Nexus until the shipment commits.',
|
||||
'After Trip to Gamont completes during Mentat, the last outsider’s return or empty No-Field reveal can leave advisors alone. Karama can then prevent their conversion without undoing the completed effect or card use. The final CHOAM opportunity resumes afterward; victory waits for its original confirmation. Ordinary Mentat does not gain an extra final chooser.',
|
||||
'A committed Guild cross-shipment or return to reserves can also leave advisors alone. Original transport permission, rate and any Guild income resolve first; payment, force transfer and shipment use stay committed if the later conversion is prevented. The ordinary ground move remains unused. Cross-shipment arrival follows the conversion once; a reserve return has no arrival.',
|
||||
'After actual battle losses leave surviving bystanding advisors alone, Karama may prevent their conversion without undoing losses or costs. Mandatory card disposal, original skills and substitution finish first. Provisionally resolve this before optional winner-card cleanup and later aftermath, then resume the original cleanup once. An explosion that destroys the advisors creates no conversion.',
|
||||
@@ -4334,8 +4335,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"detail": "Fremen Ambassador incoming matching now owns a separate precommit counter tied to the actual allied grant, with scoped denial and one original CHOAM/transfer/arrival suffix. Residents, consumed trigger and ordinary allowances are retained. Fresh Advanced Spiritual declarations share target legality and distinct shipping/matching/committed-alone stages. A public seven-field source binds a private original chooser/parent/order; late unavailability restores choice before placement, and committed callbacks never recheck withdrawn reserves. Ordinary/Guild continuation and prior alone holds stay intact. Basic and already-recorded legacy responses keep their old protocol.",
|
||||
"detail": "Fresh Guild/native Secret Ally and Junction board/Homeworld arrival declarations now own precommit matching before their original permission/rate/settlement suffix. Original scope, funding and order stay private; prevention retains unused shipment and grant. Fremen Ambassador incoming matching now owns a separate precommit counter tied to the actual allied grant, with scoped denial and one original CHOAM/transfer/arrival suffix. Residents, consumed trigger and ordinary allowances are retained. Fresh Advanced Spiritual declarations share target legality and distinct shipping/matching/committed-alone stages. A public seven-field source binds a private original chooser/parent/order; late unavailability restores choice before placement, and committed callbacks never recheck withdrawn reserves. Ordinary/Guild continuation and prior alone holds stay intact. Basic and already-recorded legacy responses keep their old protocol.",
|
||||
"evidence": [
|
||||
"game/advisor-transport-matching.ts",
|
||||
"game/transport-quote.ts",
|
||||
"game/junction-transport-options.ts",
|
||||
"tests/advisor-transport-matching.test.ts",
|
||||
"tests/advisor-transport-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-transport-matching.ts",
|
||||
"tests/fixture-advisor-secret-transport-matching.ts",
|
||||
"game/advisor-ambassador-matching.ts",
|
||||
"tests/advisor-ambassador-matching.test.ts",
|
||||
"tests/advisor-ambassador-matching-engine.test.ts",
|
||||
@@ -4359,8 +4367,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"detail": "Ambassador relocation drafts and persistent matching notices reuse the shared packet denial quote. Six native declaration/response/replacement GameTable SSR journeys pass; callback/hydration and browser acceptance remain separate. Shared projected target choices drive ordinary/Guild buttons and minimal bots. Persistent notices distinguish uncommitted shipping/matching from an already-arrived force awaiting conversion. Same prevented matching packet stays blocked when the original chooser returns. Fifteen native GameTable SSR journeys and two controlled late-stance cases pass; callback/hydration and browser acceptance remain separate.",
|
||||
"detail": "Transport and Junction quotes share scoped packet denials and persistent before-payment matching guidance; twenty native GameTable SSR journeys and two controlled Truthtrance cases pass. Ambassador relocation drafts and persistent matching notices reuse the shared packet denial quote. Six native declaration/response/replacement GameTable SSR journeys pass; callback/hydration and browser acceptance remain separate. Shared projected target choices drive ordinary/Guild buttons and minimal bots. Persistent notices distinguish uncommitted shipping/matching from an already-arrived force awaiting conversion. Same prevented matching packet stays blocked when the original chooser returns. Fifteen native GameTable SSR journeys and two controlled late-stance cases pass; callback/hydration and browser acceptance remain separate.",
|
||||
"evidence": [
|
||||
"game/advisor-transport-matching.ts",
|
||||
"game/transport-quote.ts",
|
||||
"game/junction-transport-options.ts",
|
||||
"tests/advisor-transport-matching.test.ts",
|
||||
"tests/advisor-transport-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-transport-matching.ts",
|
||||
"tests/fixture-advisor-secret-transport-matching.ts",
|
||||
"components/ambassador-movement.tsx",
|
||||
"docs/SPIRITUAL_ADVISORS.md",
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
@@ -4373,8 +4388,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"detail": "Fremen Ambassador candidates reuse the public packet quote to omit prevented retries. Eight native existing-profile paths accept sixteen response/replacement actions after Ambassador matching; no strategy refinement. Minimal policies consume projected legal targets, including all-blocked matching fallback to decline. Twenty-four paths across four existing profiles, two native parents and three stances accept72 choice/response actions. This is legal participation; no strategy refinement or difficulty calibration.",
|
||||
"detail": "Transport policies filter prevented packets through the same quotes; sixteen native transport paths across existing profiles accept32 response/replacement actions. No strategic refinement or difficulty calibration. Fremen Ambassador candidates reuse the public packet quote to omit prevented retries. Eight native existing-profile paths accept sixteen response/replacement actions after Ambassador matching; no strategy refinement. Minimal policies consume projected legal targets, including all-blocked matching fallback to decline. Twenty-four paths across four existing profiles, two native parents and three stances accept72 choice/response actions. This is legal participation; no strategy refinement or difficulty calibration.",
|
||||
"evidence": [
|
||||
"game/advisor-transport-matching.ts",
|
||||
"game/transport-quote.ts",
|
||||
"game/junction-transport-options.ts",
|
||||
"tests/advisor-transport-matching.test.ts",
|
||||
"tests/advisor-transport-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-transport-matching.ts",
|
||||
"tests/fixture-advisor-secret-transport-matching.ts",
|
||||
"game/advisor-ambassador-matching.ts",
|
||||
"tests/advisor-ambassador-matching.test.ts",
|
||||
"tests/advisor-ambassador-matching-engine.test.ts",
|
||||
@@ -4410,7 +4432,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Documentation",
|
||||
"status": "Partial",
|
||||
"detail": "Supplied physical28 redirects retained non-Polar fighters and starts Spiritual Advisors advisor-side, with no alone-conversion Polar exemption. Distinct physical17 counters, provisional Polar matching/restored-choice remedy, continuously-alone duration and the legacy source cutover are explicit. Wider combinations remain open.",
|
||||
"detail": "Guild/Junction/World/Secret matching records provisional response priority, immune Junction tariff, private scope and fresh-declaration cutover. Source and native/control evidence remain separate from broader mode acceptance. Supplied physical28 redirects retained non-Polar fighters and starts Spiritual Advisors advisor-side, with no alone-conversion Polar exemption. Distinct physical17 counters, provisional Polar matching/restored-choice remedy, continuously-alone duration and the legacy source cutover are explicit. Wider combinations remain open.",
|
||||
"evidence": [
|
||||
"docs/SPIRITUAL_ADVISORS.md",
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
@@ -4423,8 +4445,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"detail": "The new Fremen Ambassador batch passes17 files358/358, types/lint/build, six native declaration/response/replacement GameTable SSR journeys and eight existing-profile paths. Genuine81-action sources earn a Nexus alliance and actual allied grant; both directions and responses retain trigger/ordinary allowances. One zero/absence assertion is repaired. The following earlier frozen results retain their original scope. The frozen batch passes22 files390/390, types/lint/build, fifteen native ordinary/Guild declaration/response/GameTable SSR journeys, two controlled late-stance cases and twenty-four existing-profile paths. Public35/37-action ordinary and45/48-action Guild sources earn actual shipment/Intrusion; a second genuine grant preserves the held group. Four older matching/projection expectations are updated. Controlled legacy responses are format compatibility, not saved captures. Wider Homeworld/Tech/Nexus/No-Field combinations, other matching producers, full modes and final assurance remain open.",
|
||||
"detail": "The fresh transport batch passes25 files437/437, types/lint/build, twenty native Guild/Junction/World/Secret GameTable SSR journeys, two controlled Truthtrance cases and sixteen existing-profile paths. Genuine85/82/36/74-action sources earn original grants; permission and actual-discount responses remain separate, and Secret spends Nexus once at commitment. Review repairs the owned Truthtrance shadow and price consistency; smoke/type feedback is repaired. Earlier results below retain their original scope. The new Fremen Ambassador batch passes17 files358/358, types/lint/build, six native declaration/response/replacement GameTable SSR journeys and eight existing-profile paths. Genuine81-action sources earn a Nexus alliance and actual allied grant; both directions and responses retain trigger/ordinary allowances. One zero/absence assertion is repaired. The following earlier frozen results retain their original scope. The frozen batch passes22 files390/390, types/lint/build, fifteen native ordinary/Guild declaration/response/GameTable SSR journeys, two controlled late-stance cases and twenty-four existing-profile paths. Public35/37-action ordinary and45/48-action Guild sources earn actual shipment/Intrusion; a second genuine grant preserves the held group. Four older matching/projection expectations are updated. Controlled legacy responses are format compatibility, not saved captures. Wider Homeworld/Tech/Nexus/No-Field combinations, other matching producers, full modes and final assurance remain open.",
|
||||
"evidence": [
|
||||
"game/advisor-transport-matching.ts",
|
||||
"game/transport-quote.ts",
|
||||
"game/junction-transport-options.ts",
|
||||
"tests/advisor-transport-matching.test.ts",
|
||||
"tests/advisor-transport-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-transport-matching.ts",
|
||||
"tests/fixture-advisor-secret-transport-matching.ts",
|
||||
"game/advisor-ambassador-matching.ts",
|
||||
"tests/advisor-ambassador-matching.test.ts",
|
||||
"tests/advisor-ambassador-matching-engine.test.ts",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Action, GameView } from './engine';
|
||||
import { splitLocation, validGameLocation } from './board';
|
||||
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
|
||||
import { shipmentPaymentBounds } from './shipment-price';
|
||||
import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching';
|
||||
|
||||
/** Preview the submitted Guild transport using only this seat's entitled view.
|
||||
* The authoritative action still revalidates the selection when it arrives.
|
||||
@@ -23,6 +24,7 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
const fromReserves = action.from === 'reserves';
|
||||
let total = 0;
|
||||
let origin: string | undefined;
|
||||
const matchingGroup:Record<string,number>={};
|
||||
let validForces = true;
|
||||
const unitSelection = (
|
||||
value: unknown,
|
||||
@@ -101,6 +103,7 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
: action.elite,
|
||||
);
|
||||
if (typeof count !== 'number' || count <= 0) continue;
|
||||
matchingGroup[key]=count;
|
||||
if (origin && origin !== source.territory) {
|
||||
validForces = false;
|
||||
unavailableReasons.push('Select forces from one territory.');
|
||||
@@ -188,6 +191,14 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if(g.advisorTransportMatchingDenied?.length&&origin&&!fromReserves){
|
||||
try{
|
||||
quoteAdvisorTransportMatching({...g,worlds:g.homeworlds?.worlds??undefined,
|
||||
advisorTransportMatchingOpportunity:g.advisorTransportMatchingScopes?.[nexus?'secret':'guild']},
|
||||
{producer:'guild',owner:p.id,origin,destination:to,sector:Number(action.sector??0),
|
||||
advisors:!!p.advisors?.[to],group:matchingGroup});
|
||||
}catch(error){if(!(error instanceof AdvisorTransportMatchingError))throw error;unavailableReasons.push(error.message);}
|
||||
}
|
||||
return {
|
||||
physicalForces: validForces && total > 0 ? total : undefined,
|
||||
quote: validShare
|
||||
|
||||
Reference in new issue
Block a user