Offer Karama against mandatory advisor ground conversions
Keep completed moves while allowing native advisor-to-fighter conversion to be prevented after empty arrival or the last outsider's ground departure. Bind each queued response to its actual whole group and movement. Shared settlement honors the explicit provisional continuously-alone cancellation duration; setup remains automatic and non-ground producers remain open. Validation: 16 related files199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Native34/36-action public Advanced sources earn original setup/counters/Karama. Existing standard controls render; no new component-callback, browser/deployed or full-mode certificate. No saved-room/D1/restart/deployment operation.
This commit is contained in:
1 parent
1a505525a8
commit
3aca64412f
15 files changed
+874
-34
No files matched your search
@@ -0,0 +1,139 @@
|
||||
import type { FactionId } from './catalog';
|
||||
import { splitLocation } from './board';
|
||||
import { presenceAt, type ForcePresence } from './force-presence';
|
||||
|
||||
export type AdvisorStance = {
|
||||
lockedTurn?: number;
|
||||
aloneFlipHold?: string;
|
||||
};
|
||||
|
||||
export type AutomaticAdvisorFlipSource = {
|
||||
event: string;
|
||||
owner: string;
|
||||
territory: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
producer: 'groundMovement';
|
||||
mover: string;
|
||||
move: number;
|
||||
forces: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
advisors?: Readonly<Record<string, AdvisorStance>>;
|
||||
moved?: number;
|
||||
};
|
||||
|
||||
export type AdvisorSettlementContext = {
|
||||
advanced: boolean;
|
||||
players: readonly AutomaticAdvisorFlipPlayer[];
|
||||
};
|
||||
|
||||
export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & {
|
||||
status: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
};
|
||||
|
||||
export class AutomaticAdvisorFlipError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AutomaticAdvisorFlipError';
|
||||
}
|
||||
}
|
||||
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 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 requireFlip(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new AutomaticAdvisorFlipError(message);
|
||||
}
|
||||
|
||||
function physicalGroup(player: AutomaticAdvisorFlipPlayer, territory: string): Record<string, number> {
|
||||
return Object.fromEntries(Object.entries(player.forces).filter(
|
||||
([key, count]) => splitLocation(key).territory === territory && count > 0,
|
||||
));
|
||||
}
|
||||
|
||||
function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFlipPlayer, territory: string) {
|
||||
return ctx.players.some((other) => other.id !== player.id && presenceAt(other, territory) > 0);
|
||||
}
|
||||
|
||||
function currentWindow(ctx: AutomaticAdvisorFlipContext) {
|
||||
return ctx.advanced === true && ctx.status === 'playing' && ctx.phase === 5 && positive(ctx.turn);
|
||||
}
|
||||
|
||||
/** Only the ground-movement producer opens a response for these current groups.
|
||||
* No cards, resources, leaders or hidden marker values determine eligibility. */
|
||||
export function quoteAutomaticAdvisorFlips(ctx: AutomaticAdvisorFlipContext): {
|
||||
player: string;
|
||||
territory: string;
|
||||
forces: Record<string, number>;
|
||||
}[] {
|
||||
if (!currentWindow(ctx)) return [];
|
||||
const flips: { player: string; territory: string; forces: Record<string, number> }[] = [];
|
||||
for (const player of ctx.players) {
|
||||
if (player.faction !== 'beneGesserit') continue;
|
||||
for (const [territory, stance] of Object.entries(player.advisors ?? {})) {
|
||||
if (stance.aloneFlipHold !== undefined || otherPresence(ctx, player, territory)) continue;
|
||||
const forces = physicalGroup(player, territory);
|
||||
if (Object.keys(forces).length) flips.push({ player: player.id, territory, forces });
|
||||
}
|
||||
}
|
||||
return flips;
|
||||
}
|
||||
|
||||
/** Keep a prevented alone occurrence until physical emptiness or foreign entry.
|
||||
* With no occurrence hold, retain the legacy automatic conversion, including setup. */
|
||||
export function advisorStanceAfterSettlement(
|
||||
ctx: AdvisorSettlementContext,
|
||||
player: AutomaticAdvisorFlipPlayer | string,
|
||||
territory: string,
|
||||
): AdvisorStance | undefined {
|
||||
const current = ctx.players.find((candidate) => candidate.id === (typeof player === 'string' ? player : player.id));
|
||||
const stance = current?.advisors?.[territory];
|
||||
if (!current || current.faction !== 'beneGesserit' || !stance) return undefined;
|
||||
if (!ctx.advanced) return { ...stance };
|
||||
if (!Object.keys(physicalGroup(current, territory)).length) return undefined;
|
||||
if (otherPresence(ctx, current, territory)) {
|
||||
const remaining = { ...stance };
|
||||
delete remaining.aloneFlipHold;
|
||||
return remaining;
|
||||
}
|
||||
return stance.aloneFlipHold !== undefined ? { ...stance } : undefined;
|
||||
}
|
||||
|
||||
/** Validate the same committed move and exact held group before either response outcome. */
|
||||
export function validateAutomaticAdvisorFlip(
|
||||
ctx: AutomaticAdvisorFlipContext,
|
||||
source: AutomaticAdvisorFlipSource,
|
||||
): void {
|
||||
requireFlip(record(source) &&
|
||||
Object.keys(source).sort().join(',') === 'event,forces,move,mover,owner,phase,producer,territory,turn' &&
|
||||
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
|
||||
identifier(source.mover) && source.producer === 'groundMovement' &&
|
||||
positive(source.turn) && source.turn === ctx.turn && source.phase === 5 &&
|
||||
currentWindow(ctx) && source.phase === ctx.phase,
|
||||
'Automatic advisor conversion lost its original current-turn ground movement.');
|
||||
const owners = ctx.players.filter((player) => player.id === source.owner);
|
||||
const owner = owners[0];
|
||||
requireFlip(owners.length === 1 && owner?.faction === 'beneGesserit' &&
|
||||
owner.advisors?.[source.territory]?.aloneFlipHold === source.event,
|
||||
'Automatic advisor conversion needs its original native owner and held occurrence.');
|
||||
requireFlip(!otherPresence(ctx, owner, source.territory),
|
||||
'Automatic advisor conversion requires the original group to remain alone.');
|
||||
const movers = ctx.players.filter((player) => player.id === source.mover);
|
||||
requireFlip(movers.length === 1 && positive(source.move) && movers[0].moved === source.move,
|
||||
'Automatic advisor conversion lost its original completed movement.');
|
||||
const forces = physicalGroup(owner, source.territory);
|
||||
requireFlip(record(source.forces) && Object.keys(forces).length > 0 &&
|
||||
Object.values(forces).every(positive) && Object.values(source.forces).every(positive) &&
|
||||
Object.keys(forces).sort().join(',') === Object.keys(source.forces).sort().join(',') &&
|
||||
Object.entries(forces).every(([key, count]) => source.forces[key] === count),
|
||||
'Automatic advisor conversion needs the same complete physical advisor group.');
|
||||
}
|
||||
+5
-6
@@ -1,4 +1,5 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
|
||||
import { presenceAt, type ForcePresence } from './force-presence';
|
||||
|
||||
/** Effective presence includes a concealed No-Field; physical units stay in forces. */
|
||||
@@ -18,11 +19,9 @@ export function settleAdvisors(g: Game) {
|
||||
const bg = g.players.find((p) => p.faction === 'beneGesserit');
|
||||
if (!bg?.advisors) return;
|
||||
for (const t of Object.keys(bg.advisors)) {
|
||||
if (
|
||||
!forceCount(bg, t) ||
|
||||
!g.players.some((p) => p.id !== bg.id && forceCount(p, t))
|
||||
)
|
||||
delete bg.advisors[t];
|
||||
const stance = advisorStanceAfterSettlement(g, bg, t);
|
||||
if (stance) bg.advisors[t] = stance;
|
||||
else delete bg.advisors[t];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +36,8 @@ export function arrivalAsAdvisor(
|
||||
accompanying = false,
|
||||
) {
|
||||
if (!g.advanced || p.faction !== 'beneGesserit') return false;
|
||||
if (forceCount(p, to)) return isAdvisor(p, to);
|
||||
if (!g.players.some((other) => other.id !== p.id && forceCount(other, to)))
|
||||
return false;
|
||||
if (forceCount(p, to)) return isAdvisor(p, to);
|
||||
return accompanying || (!!source && isAdvisor(p, source));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Player } from './engine';
|
||||
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
|
||||
import type { ForcePresence } from './force-presence';
|
||||
import { presenceAt, presenceByLocation } from './force-presence';
|
||||
import { presenceByLocation } from './force-presence';
|
||||
import { fighterCount, isAdvisor } from './advisors';
|
||||
import { quoteAidRefunds, PhaseResourceError } from './phase-resource-quote';
|
||||
import { ecazOccupancyIdentity } from './ecaz-occupy';
|
||||
@@ -81,14 +82,14 @@ export function settledBoard(g: BoardContext) {
|
||||
const players = g.players.map((p) => {
|
||||
if (!g.advanced || p.faction !== 'beneGesserit' || !p.advisors) return p;
|
||||
const advisors = { ...p.advisors };
|
||||
for (const t of Object.keys(advisors))
|
||||
if (
|
||||
!presenceAt(p, t) ||
|
||||
!g.players.some((other) => other.id !== p.id && presenceAt(other, t))
|
||||
) {
|
||||
for (const t of Object.keys(advisors)) {
|
||||
const stance = advisorStanceAfterSettlement(g, p, t);
|
||||
if (stance) advisors[t] = stance;
|
||||
else {
|
||||
delete advisors[t];
|
||||
released.push({ player: p.id, territory: t });
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
|
||||
+62
-11
@@ -12,6 +12,7 @@ import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, h
|
||||
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
|
||||
import { quoteAtreidesFullPlan } from './atreides-full-plan';
|
||||
import { quoteTleilaxuSpecialKarama } from './tleilaxu-special-karama';
|
||||
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource } from './advisor-automatic-flip';
|
||||
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
|
||||
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
|
||||
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
|
||||
@@ -664,7 +665,7 @@ export type Player = {
|
||||
noField?: RicheseNoField;
|
||||
noFieldEvent?: string;
|
||||
noFieldBlockedTurn?: number;
|
||||
advisors?: Record<string, { lockedTurn?: number }>;
|
||||
advisors?: Record<string, AdvisorStance>;
|
||||
advisorSetup?: boolean;
|
||||
charityTurn?: number;
|
||||
specialKaramaUsed?: boolean;
|
||||
@@ -1064,6 +1065,7 @@ type SpiceBankerIncomePayment = {
|
||||
};
|
||||
|
||||
export type ResponseWindow = {
|
||||
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
|
||||
advisorPreparationSource?: AdvisorPreparationSource;
|
||||
fremenGrant?: FremenRevivalGrantSource;
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
@@ -1260,6 +1262,7 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[];
|
||||
advisorPreparation?: AdvisorPreparationWindow;
|
||||
ecazOccupyPreview?: boolean;
|
||||
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
|
||||
@@ -12041,7 +12044,7 @@ function decideDiscoveryEntry(g: Game, p: Player, action: Action) {
|
||||
removeGroup(p,arrival.groups.map(group => [group.source,group.normal+group.elite]),
|
||||
Object.fromEntries(arrival.groups.map(group => [group.source,group.elite])));
|
||||
place(p,arrival.destination,0,arrival.amount,arrival.elite);
|
||||
if (advisors) (p.advisors ??= {})[arrival.destination] = {};
|
||||
if (advisors) (p.advisors ??= {})[arrival.destination] ??= {};
|
||||
else if (p.advisors) delete p.advisors[arrival.destination];
|
||||
log(g, `${p.name} moved ${arrival.amount} ${arrival.amount === 1 ? 'force' : 'forces'}${arrival.elite ? `, including ${arrival.elite} elite ${arrival.elite === 1 ? 'force' : 'forces'},` : ''} from ${territory(arrival.parent).name} into ${territory(arrival.destination).name} before the storm. This one-time Discovery entry spends no spice or normal movement.`);
|
||||
const child: DiscoveryEntryArrivalChild = {owner:p.id,territory:arrival.destination,sector:0,
|
||||
@@ -14554,6 +14557,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
}
|
||||
function movementCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
try {
|
||||
if (response.automaticAdvisorFlip) { currentAutomaticAdvisorFlip(g, response); return null; }
|
||||
if (response.advisorResume === 'preparation') { currentAdvisorPreparationResponse(g, response); return null; }
|
||||
return quoteMovementCancellation(g, response);
|
||||
} catch (error) {
|
||||
@@ -16139,12 +16143,15 @@ function movementStance(g: Game, p: Player, origins: string[], to: string, fight
|
||||
requireRule(!gather.blocked, gather.blocked ?? 'The mixed gather cannot flip its advisors.');
|
||||
requireRule(!gather.requiresFlip || fighters,
|
||||
'Declare a common fighter arrival for a mixed advisor/fighter gather.');
|
||||
const advisors = !gather.requiresFlip && arrivalAsAdvisor(g, p, to, origins[0]);
|
||||
const aloneArrival = g.advanced && p.faction === 'beneGesserit' && !gather.requiresFlip &&
|
||||
isAdvisor(p, origins[0]) && !at(p, to) &&
|
||||
!g.players.some(other => other.id !== p.id && at(other, to));
|
||||
const advisors = !gather.requiresFlip && (aloneArrival || arrivalAsAdvisor(g, p, to, origins[0]));
|
||||
const lockedTurn = Math.max(0, ...origins.map(origin => p.advisors?.[origin]?.lockedTurn ?? 0)) || undefined;
|
||||
requireRule(advisors || lockedTurn !== g.turn ||
|
||||
!g.players.some(other => other.id !== p.id && at(other, to)),
|
||||
'New advisors cannot become fighters this turn.');
|
||||
const wantsFighters = advisors && fighters;
|
||||
const wantsFighters = advisors && fighters && !aloneArrival;
|
||||
requireRule(!wantsFighters || !advisorFlipMovementBlocked(g, p),
|
||||
'Advisor flipping was canceled for this movement. Choose a legal replacement without flipping advisors.');
|
||||
requireRule(!wantsFighters || (lockedTurn !== g.turn && !at(p, to) &&
|
||||
@@ -17016,7 +17023,7 @@ function commitGroundMovementPieces(g: Game, move: MovementOrder) {
|
||||
place(p, to, s, n - (move.noField ? 1 : 0), elite);
|
||||
if (advisors) {
|
||||
const arrivalLock = Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[to] = arrivalLock ? { lockedTurn: arrivalLock } : {};
|
||||
(p.advisors ??= {})[to] = { ...p.advisors?.[to], ...(arrivalLock ? { lockedTurn: arrivalLock } : {}) };
|
||||
} else if (p.advisors) delete p.advisors[to];
|
||||
if (wantsFighters)
|
||||
g.response = {
|
||||
@@ -17049,6 +17056,7 @@ function completeMove(g: Game, move: MovementOrder) {
|
||||
const flight = g.ornithopter;
|
||||
commitGroundMovementPieces(g, move);
|
||||
recordGroundMovementPromises(g, p, move);
|
||||
prepareAutomaticAdvisorFlips(g, p);
|
||||
const cunning = currentGuildCunning(g,id);
|
||||
if (cunning) { requireRule(cunning.stage === 'extraMove', 'Guild Cunning permits no movement before the second shipment.'); cunning.movesAfter++; saveGuildCunning(g,cunning); }
|
||||
const completed: CompletedMovement = {
|
||||
@@ -18385,7 +18393,7 @@ function finishGuildAmbassadorAdvisor(
|
||||
place(bg, quote.territory, quote.sector, quote.amount);
|
||||
techIncome(g, 'heighliners', bg);
|
||||
if (quote.advisors)
|
||||
(bg.advisors ??= {})[quote.territory] = { lockedTurn: g.turn };
|
||||
(bg.advisors ??= {})[quote.territory] = { ...bg.advisors?.[quote.territory], lockedTurn: g.turn };
|
||||
const receipt = g.pendingAmbassador!.shipmentReceipt!;
|
||||
receipt.next = 'finish';
|
||||
receipt.advisorArrival = {
|
||||
@@ -18568,7 +18576,7 @@ function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
|
||||
if (physical) place(p, move.to, move.sector, physical, move.elite);
|
||||
if (move.advisors) {
|
||||
const lockedTurn = Math.max(move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[move.to] = lockedTurn ? { lockedTurn } : {};
|
||||
(p.advisors ??= {})[move.to] = { ...p.advisors?.[move.to], ...(lockedTurn ? { lockedTurn } : {}) };
|
||||
} else if (p.advisors) delete p.advisors[move.to];
|
||||
entry.stage = 'arrival';
|
||||
entry.relocation = { order: move, next: 'flip' };
|
||||
@@ -20270,6 +20278,40 @@ function boardResolution<T>(quote: () => T): T {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function automaticAdvisorRule<T>(quote: () => T): T {
|
||||
try { return quote(); }
|
||||
catch (error) { if (error instanceof AutomaticAdvisorFlipError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
|
||||
const source = response.automaticAdvisorFlip;
|
||||
requireRule(response.kind === 'advisorFlip' && source && response.advisors === false &&
|
||||
response.owner === source.owner && response.location === source.territory &&
|
||||
response.advisorResume === undefined &&
|
||||
JSON.stringify(g.pendingAutomaticAdvisorFlips?.[0]) === JSON.stringify(source),
|
||||
'The mandatory advisor conversion has lost its original movement source.');
|
||||
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
|
||||
return source;
|
||||
}
|
||||
function openAutomaticAdvisorFlip(g: Game) {
|
||||
if (g.response || g.decision || g.truthtrance || g.phaseOpening) return;
|
||||
const source = g.pendingAutomaticAdvisorFlips?.[0];
|
||||
if (!source) return;
|
||||
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
|
||||
g.response = { kind: 'advisorFlip', owner: source.owner, location: source.territory,
|
||||
advisors: false, passed: [], automaticAdvisorFlip: source,
|
||||
intent: `${getPlayer(g, source.owner).name}: mandatory advisor-to-fighter conversion in ${territory(source.territory).name}.` };
|
||||
}
|
||||
function prepareAutomaticAdvisorFlips(g: Game, mover: Player) {
|
||||
const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g));
|
||||
for (const group of groups) {
|
||||
const source: AutomaticAdvisorFlipSource = { event: crypto.randomUUID(), owner: group.player,
|
||||
territory: group.territory, turn: g.turn, phase: g.phase, producer: 'groundMovement',
|
||||
mover: mover.id, move: mover.moved, forces: group.forces };
|
||||
getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event;
|
||||
(g.pendingAutomaticAdvisorFlips ??= []).push(source);
|
||||
}
|
||||
openAutomaticAdvisorFlip(g);
|
||||
}
|
||||
function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
|
||||
for (const entry of released)
|
||||
delete getPlayer(g, entry.player).advisors?.[entry.territory];
|
||||
@@ -27201,6 +27243,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
currentFactionPayment(g);
|
||||
const response = g.response!;
|
||||
const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
|
||||
const automaticAdvisorFlip = response.automaticAdvisorFlip ? currentAutomaticAdvisorFlip(g, response) : null;
|
||||
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
|
||||
if (g.pendingKull?.stage === 'counter' && response.kind === 'choamWorthless') kullIntegrity(g);
|
||||
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
|
||||
@@ -28096,7 +28139,14 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
`${bg.name} changed all forces in ${territory(to).name} to ${response.advisors ? 'advisors' : 'fighters'}.`,
|
||||
);
|
||||
}
|
||||
finishAdvisorReaction(g, response);
|
||||
if (automaticAdvisorFlip) {
|
||||
if (canceled)
|
||||
log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed movement stays committed; this group remains advisors while continuously alone.`,
|
||||
{ faction: bg.faction, name: 'Mandatory advisor conversion prevented' });
|
||||
g.pendingAutomaticAdvisorFlips!.shift();
|
||||
if (!g.pendingAutomaticAdvisorFlips!.length) delete g.pendingAutomaticAdvisorFlips;
|
||||
openAutomaticAdvisorFlip(g);
|
||||
} else finishAdvisorReaction(g, response);
|
||||
}
|
||||
} else if (response.kind === 'capture') {
|
||||
const capture = g.pendingCapture!;
|
||||
@@ -28413,7 +28463,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
techIncome(g, 'heighliners', owner);
|
||||
if (advisors) {
|
||||
owner.advisors ??= {};
|
||||
owner.advisors[target.territory] = { lockedTurn: g.turn };
|
||||
owner.advisors[target.territory] = { ...owner.advisors[target.territory], lockedTurn: g.turn };
|
||||
}
|
||||
log(
|
||||
g,
|
||||
@@ -28560,7 +28610,7 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp
|
||||
place(p, arrival.territory, arrival.sector, quote.amount, quote.elite);
|
||||
if (advisors) {
|
||||
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[arrival.territory] = lockedTurn ? { lockedTurn } : {};
|
||||
(p.advisors ??= {})[arrival.territory] = { ...p.advisors?.[arrival.territory], ...(lockedTurn ? { lockedTurn } : {}) };
|
||||
} else if (p.advisors) delete p.advisors[arrival.territory];
|
||||
}
|
||||
p.shipped = true;
|
||||
@@ -29486,7 +29536,7 @@ function commitGuildTransport(
|
||||
place(p, to, sector, amount, elite);
|
||||
if (advisors) {
|
||||
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[to] = lockedTurn ? { lockedTurn } : {};
|
||||
(p.advisors ??= {})[to] = { ...p.advisors?.[to], ...(lockedTurn ? { lockedTurn } : {}) };
|
||||
} else if (p.advisors) delete p.advisors[to];
|
||||
}
|
||||
observeOccupation(g);
|
||||
@@ -32005,6 +32055,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
deferred.amount, deferred.elite, deferred.cause, deferred.resume);
|
||||
continue;
|
||||
}
|
||||
if (!g.response && !g.decision && g.pendingAutomaticAdvisorFlips?.length) openAutomaticAdvisorFlip(g);
|
||||
const response = g.response;
|
||||
const before = JSON.stringify(g);
|
||||
const bonus = g.pendingHomeworldOccupiedBonus;
|
||||
|
||||
@@ -179,7 +179,10 @@ const cancellationSources = {
|
||||
checks: g.battle?.powerChecks,
|
||||
}),
|
||||
advisorFlip: (g, response) =>
|
||||
response.advisorPreparationSource
|
||||
response.automaticAdvisorFlip
|
||||
? { source: response.automaticAdvisorFlip, pending: g.pendingAutomaticAdvisorFlips?.[0] ?? null,
|
||||
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
|
||||
: response.advisorPreparationSource
|
||||
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
|
||||
: response.advisorResume === 'ambassador'
|
||||
? {
|
||||
|
||||
+17
-7
@@ -4310,20 +4310,24 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Bene Gesserit forces share one token type throughout a territory. The map marks advisors with a dashed outer ring and labels their stance in the territory details.',
|
||||
'Advisors do not fight, contest stronghold victory, block another faction’s entry, collect spice, earn stronghold income, grant ornithopters or enable Family Atomics. Storms, worms, explosions and Atomics still destroy them.',
|
||||
'In Advanced, advisors may coexist with their ally without triggering the end-turn alliance constraint in either direction. Neither the advisors nor the allied fighters are lost for that coexistence. Bene Gesserit fighters remain subject to ordinary separation; Basic receives no new advisor exception.',
|
||||
'After Fremen setup, choose the starting advisor’s territory and sector. Advisors become fighters automatically whenever no other faction remains in their territory.',
|
||||
'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. Moving or shipping into your existing group must match its type. Advisors moved into an occupied territory without another Bene Gesserit group may remain peaceful or request a flip to fighters.',
|
||||
'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Non-ground automatic conversions and matching-group flip counters remain unfinished.',
|
||||
'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.',
|
||||
'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.',
|
||||
'After Spice Blow and Nexus, use Prepare advisors for battle during Charity, Bidding, Revival or before the first actual shipment. Quiet boundaries retain current mandatory priority. Each whole-territory flip has its original Karama response; its direct continuation preserves the current phase, auction, readiness and active movement. Declining the initial phase-five chooser leaves this window open. Ordinary allied presence, storm and fresh-advisor locks still restrict eligibility; the recorded supported Ecaz co-side composition remains separate.',
|
||||
'A current-turn window closes on a committed shipment, including a concealed zero No-Field, independent Ambassador shipment or positive Sneak Attack. Proposals, stopped shipments, ground movement and unshipped turns do not close it. Under the explicit provisional retry policy, Karama prevents that territory’s preparation for the rest of this window; Intrusion, Nexus and automatic flips remain independent. Focused evidence does not certify complete modes, broader priority/optional or forced-flip cancellation behavior.',
|
||||
'A current-turn window closes on a committed shipment, including a concealed zero No-Field, independent Ambassador shipment or positive Sneak Attack. Proposals, stopped shipments, ground movement and unshipped turns do not close it. Under the explicit provisional retry policy, Karama prevents that territory’s preparation for the rest of this window; Intrusion, Nexus and automatic flips remain independent. Focused evidence does not certify complete modes, broader priority/optional or remaining automatic-flip cancellation behavior.',
|
||||
],
|
||||
related: ['faction-beneGesserit', 'movement', 'battle', 'victory'],
|
||||
checklist: [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"detail": "Shared native public-board eligibility wraps a current-turn receipt and exact owner/territory/turn source. Direct preparation resolves before-cost and outcome validation without entering movement from earlier phases. Native future declaration queues remain intact; successful shipment commits close the receipt, including markers and positive Sneak Attack.",
|
||||
"detail": "Ground mandatory conversion is now prototyped: physical advisor arrival/last-outsider departure commits before an exact sourced Karama response. Shared normalization/board quotes retain a prevented continuously-alone stance. Non-ground producers and matching-group conversion remain unfinished. Earlier preparation: Shared native public-board eligibility wraps a current-turn receipt and exact owner/territory/turn source. Direct preparation resolves before-cost and outcome validation without entering movement from earlier phases. Native future declaration queues remain intact; successful shipment commits close the receipt, including markers and positive Sneak Attack.",
|
||||
"evidence": [
|
||||
"game/advisor-automatic-flip.ts",
|
||||
"game/advisors.ts",
|
||||
"game/board-resolution-quote.ts",
|
||||
"game/advisor-preparation-window.ts",
|
||||
"game/engine.ts",
|
||||
"game/movement-phase-quote.ts",
|
||||
@@ -4334,8 +4338,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"detail": "Persistent owner-only territory actions and blocked/provisional guidance work in phases2–5 at quiet boundaries, including after initial decline. Busy and mandatory interactions stay blocked; private counts/cards/balances are not read. Original phase5 chooser remains.",
|
||||
"detail": "Standard Karama/Allow responses show the completed ground move and explicit provisional continuous-alone duration, with no new confirmation or timer. Earlier preparation controls: Persistent owner-only territory actions and blocked/provisional guidance work in phases2–5 at quiet boundaries, including after initial decline. Busy and mandatory interactions stay blocked; private counts/cards/balances are not read. Original phase5 chooser remains.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"components/advisor-preparation.tsx",
|
||||
"components/game-table.tsx",
|
||||
"tests/advisor-preparation-controls.test.tsx"
|
||||
@@ -4344,8 +4349,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"detail": "The existing native preparation preference consumes the projected window; sixteen own-view existing-profile response paths accept16 legal proposals. No strategy tuning or difficulty calibration is claimed.",
|
||||
"detail": "Eight existing-profile ground-conversion paths accept legal standard responses and continue without a new strategy. Earlier preparation: The existing native preparation preference consumes the projected window; sixteen own-view existing-profile response paths accept16 legal proposals. No strategy tuning or difficulty calibration is claimed.",
|
||||
"evidence": [
|
||||
"tests/advisor-automatic-flip-engine.test.ts",
|
||||
"game/bots.ts",
|
||||
"docs/ADVISOR_PREPARATION.md"
|
||||
]
|
||||
@@ -4353,8 +4359,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Documentation",
|
||||
"status": "Partial",
|
||||
"detail": "Supplied physical28 G and publisher timing distinguish the open window from shipment allowance flags. Denied-territory scope is provisional, existing Ecaz co-side composition is retained, and old pending/unknown-clock behavior and remaining forced-flip/combination limits are explicit.",
|
||||
"detail": "The ground-conversion contract distinguishes the printed counter, automatic setup, provisional continuous-alone duration and remaining producers. Earlier preparation: Supplied physical28 G and publisher timing distinguish the open window from shipment allowance flags. Denied-territory scope is provisional, existing Ecaz co-side composition is retained, and old pending/unknown-clock behavior and remaining forced-flip/combination limits are explicit.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"docs/ADVISOR_PREPARATION.md",
|
||||
"docs/RULE_DECISIONS.md",
|
||||
"docs/ECAZ_OCCUPY_RULES.md"
|
||||
@@ -4363,8 +4370,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"detail": "16 files229/229, types/lint/build, twelve genuine new-control/gameplay/GameTable SSR journeys and sixteen minimal response paths pass. Public23/29/32/38/39-action sources preserve original setup advisor/Karama; a separate controlled ground/Terror unit proves positive Sneak Attack closure and zero nonclosure. Full combinations, visual/browser/deployed and comprehensive assurance remain open.",
|
||||
"detail": "Ground conversion passes frozen16-file199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Genuine34/36-action sources retain originals; standard controls render without new callback/browser/deployed certification. Earlier preparation evidence: 16 files229/229, types/lint/build, twelve genuine new-control/gameplay/GameTable SSR journeys and sixteen minimal response paths pass. Public23/29/32/38/39-action sources preserve original setup advisor/Karama; a separate controlled ground/Terror unit proves positive Sneak Attack closure and zero nonclosure. Full combinations, visual/browser/deployed and comprehensive assurance remain open.",
|
||||
"evidence": [
|
||||
"tests/advisor-automatic-flip-engine.test.ts",
|
||||
"tests/advisor-automatic-flip-quote.test.ts",
|
||||
"tests/fixture-advisor-automatic-flip.ts",
|
||||
"tests/fixture-advisor-preparation.ts",
|
||||
"tests/advisor-preparation-engine.test.ts",
|
||||
"tests/advisor-preparation-window.test.ts",
|
||||
|
||||
Reference in new issue
Block a user