Allow individual revived Fedaykin placement
This commit is contained in:
1 parent
6a0fba860d
commit
ff2826b980
25 files changed
+1301
-124
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import type { Game } from './engine';
|
||||
import { location } from './board';
|
||||
import { validAmbassadorResume } from './ambassador-resume';
|
||||
import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
|
||||
import { currentHomeworldRevivalArrival, validateHomeworldRevivalReturn } from './homeworld-revival-return';
|
||||
import { validateHomeworldVictoryReturn } from './homeworld-victory-return';
|
||||
import {
|
||||
validateHomeworldArrival,
|
||||
@@ -29,7 +29,7 @@ export function ambassadorPhaseAllowed(g: AmbassadorPhaseContext): boolean {
|
||||
tag: HomeworldArrivalTag;
|
||||
frame: HomeworldArrivalFrame | undefined;
|
||||
}[] = [
|
||||
{ tag: 'revivalEvent', frame: g.homeworldRevivalReturn },
|
||||
{ tag: 'revivalEvent', frame: currentHomeworldRevivalArrival(g.homeworldRevivalReturn) ?? undefined },
|
||||
{ tag: 'victoryEvent', frame: g.homeworldVictoryReinforcement },
|
||||
];
|
||||
const matching = candidates.filter(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Game } from './engine';
|
||||
import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
|
||||
import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent,
|
||||
revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry';
|
||||
|
||||
@@ -47,7 +48,7 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
|
||||
|
||||
export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: AmbassadorEntry): boolean {
|
||||
const overlap = ordinaryDiscardOverlap({ ...g, pendingAmbassador: entry }, 'ambassador');
|
||||
const arrival = g.homeworldRevivalReturn?.ambassadors?.find(record => record.event === entry.event);
|
||||
const arrival = currentHomeworldRevivalArrival(g.homeworldRevivalReturn)?.ambassadors?.find(record => record.event === entry.event);
|
||||
return !!overlap && entry.stage === 'cards' && entry.resume === 'none' &&
|
||||
entry.event === overlap.ambassadorEvent && entry.owner === overlap.ecaz &&
|
||||
entry.token === overlap.ambassadorToken && entry.entrant === overlap.entrant &&
|
||||
|
||||
+84
-39
@@ -196,6 +196,14 @@ import {
|
||||
} from './homeworld-revival-deployment';
|
||||
import {
|
||||
makeHomeworldRevivalReturn,
|
||||
makeSouthernRevivalAllocation,
|
||||
southernRevivalRemaining,
|
||||
southernRevivalSummary,
|
||||
currentHomeworldRevivalArrival,
|
||||
currentHomeworldRevivalGroup,
|
||||
openSouthernRevivalChoice,
|
||||
commitSouthernRevivalChoice,
|
||||
completeSouthernRevivalChild,
|
||||
makeHomeworldRevivalProgress,
|
||||
type HomeworldRevivalProgress,
|
||||
validateHomeworldRevivalReturn,
|
||||
@@ -21701,7 +21709,7 @@ function openAmbassadorEntry(
|
||||
};
|
||||
g.decision = { kind: 'ecazAmbassador', player: owner.id };
|
||||
if (revivalArrival) {
|
||||
const frame = g.homeworldRevivalReturn;
|
||||
const frame = currentHomeworldRevivalArrival(g.homeworldRevivalReturn);
|
||||
requireRule(frame?.stage === 'arrival' && frame.event === revivalArrival.event,
|
||||
'This entry has lost its original revived group.');
|
||||
g.pendingAmbassador.revivalEvent = frame.event;
|
||||
@@ -21807,7 +21815,7 @@ function openTerritoryEntry(
|
||||
function finishAmbassador(g: Game, deferResume = false) {
|
||||
const pending = g.pendingAmbassador!;
|
||||
if (pending.revivalEvent !== undefined || pending.victoryEvent !== undefined) {
|
||||
const arrival = pending.revivalEvent !== undefined ? g.homeworldRevivalReturn : g.homeworldVictoryReinforcement;
|
||||
const arrival = pending.revivalEvent !== undefined ? currentHomeworldRevivalArrival(g.homeworldRevivalReturn) : g.homeworldVictoryReinforcement;
|
||||
requireRule(arrival && ambassadorPhaseAllowed(g),
|
||||
'This Ambassador has lost its original Homeworld arrival completion.');
|
||||
completeHomeworldArrivalAmbassador(arrival, pending.event);
|
||||
@@ -21884,7 +21892,7 @@ function currentFremenAmbassador(g: Game, event?: string, completed = false) {
|
||||
function ambassadorRelocationIntegrity(g: Game) {
|
||||
const entry = g.pendingAmbassador;
|
||||
if (entry && (entry.revivalEvent !== undefined || entry.victoryEvent !== undefined ||
|
||||
g.homeworldRevivalReturn?.ambassadors?.some((record) => record.event === entry.event) ||
|
||||
currentHomeworldRevivalArrival(g.homeworldRevivalReturn)?.ambassadors?.some((record) => record.event === entry.event) ||
|
||||
g.homeworldVictoryReinforcement?.ambassadors?.some((record) => record.event === entry.event)))
|
||||
requireRule(ambassadorPhaseAllowed(g),
|
||||
'This Ambassador has lost its original Homeworld revival arrival.');
|
||||
@@ -22754,7 +22762,7 @@ function finishGuildAmbassadorAdvisorArrival(g:Game,response:ResponseWindow,
|
||||
const parent = g.pendingAmbassador!;
|
||||
const arrivalTag = parent.revivalEvent !== undefined ? 'revivalEvent' :
|
||||
parent.victoryEvent !== undefined ? 'victoryEvent' : undefined;
|
||||
const arrival = arrivalTag === 'revivalEvent' ? g.homeworldRevivalReturn :
|
||||
const arrival = arrivalTag === 'revivalEvent' ? currentHomeworldRevivalArrival(g.homeworldRevivalReturn) :
|
||||
arrivalTag === 'victoryEvent' ? g.homeworldVictoryReinforcement : undefined;
|
||||
if (arrivalTag !== undefined)
|
||||
requireRule(arrival && ambassadorPhaseAllowed(g),
|
||||
@@ -32441,7 +32449,9 @@ function stageHomeworldRevivalReturn(
|
||||
grant: ReturnType<typeof currentHomeworldRevivalGrant>, card?: string,
|
||||
) {
|
||||
if (!grant) return;
|
||||
g.homeworldRevivalReturn = homeworldRule(() => makeHomeworldRevivalReturn({
|
||||
const makeReturn = !g.advanced && grant.kind === 'fedaykin' && grant.elite > 1
|
||||
? makeSouthernRevivalAllocation : makeHomeworldRevivalReturn;
|
||||
g.homeworldRevivalReturn = homeworldRule(() => makeReturn({
|
||||
event: crypto.randomUUID(), turn: g.turn, phase: g.phase, player: p.id,
|
||||
source, group, quote: grant, ...(card ? { card } : {}),
|
||||
}));
|
||||
@@ -32449,7 +32459,7 @@ function stageHomeworldRevivalReturn(
|
||||
}
|
||||
function stampHomeworldRevivalProgress(g: Game) {
|
||||
const frame = g.homeworldRevivalReturn!;
|
||||
if (frame.progressVersion === 1)
|
||||
if (frame.progressVersion === 1 || frame.progressVersion === 2)
|
||||
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
|
||||
}
|
||||
function homeworldRevivalReturnIntegrity(g: Game) {
|
||||
@@ -32461,20 +32471,49 @@ function homeworldRevivalReturnIntegrity(g: Game) {
|
||||
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const decisions = contexts.flatMap((context) => context?.decision?.kind === 'homeworldRevivalDeployment' ? [context.decision] : []);
|
||||
const arrival = currentHomeworldRevivalArrival(frame);
|
||||
requireRule(decisions.every((decision) => frame?.stage === 'choice' &&
|
||||
decision.player === frame.player && decision.event === frame.event),
|
||||
decision.player === frame.player && decision.event === arrival?.event),
|
||||
'The revival placement decision has lost its original returned group.');
|
||||
if (frame?.stage === 'choice')
|
||||
requireRule(decisions.length > 0, 'The returned group has lost its owned placement choice.');
|
||||
}
|
||||
function completeHomeworldRevivalReturn(g: Game) {
|
||||
const frame = g.homeworldRevivalReturn!;
|
||||
requireRule(!frame.ambassadors?.some((entry) => !entry.completed),
|
||||
const arrival = currentHomeworldRevivalArrival(frame);
|
||||
requireRule(arrival && !arrival.ambassadors?.some((entry) => !entry.completed),
|
||||
'Finish the revived group’s Ambassador opportunity before its original revival income.');
|
||||
frame.stage = 'complete';
|
||||
if (frame.progressVersion === 2) {
|
||||
homeworldRule(() => completeSouthernRevivalChild(frame, arrival.event,
|
||||
southernRevivalRemaining(frame) > 0 ? crypto.randomUUID() : undefined));
|
||||
stampHomeworldRevivalProgress(g);
|
||||
if (frame.stage === 'choice') {
|
||||
openHomeworldRevivalDecision(g);
|
||||
return;
|
||||
}
|
||||
} else frame.stage = 'complete';
|
||||
stampHomeworldRevivalProgress(g);
|
||||
g.response = structuredClone(frame.resumeResponse ?? null);
|
||||
}
|
||||
/** Every portion waits for its genuine arrival children before a fresh choice.
|
||||
* The original revival grant and deferred income are never issued again. */
|
||||
function openHomeworldRevivalDecision(g: Game) {
|
||||
const frame = g.homeworldRevivalReturn!, arrival = currentHomeworldRevivalArrival(frame);
|
||||
requireRule(frame.stage === 'choice' && arrival, 'Retain the current revival placement choice.');
|
||||
const destinations = homeworldRule(() => homeworldRevivalDestinations(g, frame.player, frame.quote));
|
||||
if (!destinations.length || destinations.every((d) => d.blocked && !d.blocked.includes('await'))) {
|
||||
if (frame.progressVersion === 2) homeworldRule(() => commitSouthernRevivalChoice(frame, arrival.event,
|
||||
{ amount: southernRevivalRemaining(frame), destination: 'decline' }));
|
||||
else {
|
||||
frame.destination = 'decline'; frame.ambassadors = [];
|
||||
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
|
||||
}
|
||||
completeHomeworldRevivalReturn(g);
|
||||
log(g, `${getPlayer(g, frame.player).name} left the remaining revived group in reserves because no placement destination is available.`);
|
||||
return;
|
||||
}
|
||||
g.decision = { kind: 'homeworldRevivalDeployment', player: frame.player, event: arrival.event };
|
||||
}
|
||||
function resumeHomeworldRevivalReturn(g: Game) {
|
||||
const frame = g.homeworldRevivalReturn;
|
||||
if (!frame || frame.stage === 'complete' || frame.stage === 'choice' ||
|
||||
@@ -32493,57 +32532,61 @@ function resumeHomeworldRevivalReturn(g: Game) {
|
||||
frame.resumeResponse = structuredClone(g.response);
|
||||
frame.resumeSignature = homeworldRevivalResumeSignature(frame.resumeResponse);
|
||||
g.response = null;
|
||||
frame.stage = 'choice';
|
||||
if (frame.progressVersion === 2) homeworldRule(() => openSouthernRevivalChoice(frame, crypto.randomUUID()));
|
||||
else frame.stage = 'choice';
|
||||
stampHomeworldRevivalProgress(g);
|
||||
const destinations = homeworldRule(() => homeworldRevivalDestinations(g, frame.player, frame.quote));
|
||||
if (!destinations.length || destinations.every((d) => d.blocked && !d.blocked.includes('await'))) {
|
||||
frame.destination = 'decline';
|
||||
frame.ambassadors = [];
|
||||
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
|
||||
completeHomeworldRevivalReturn(g);
|
||||
log(g, `${getPlayer(g, frame.player).name} left the revived group in reserves because no placement destination is available.`);
|
||||
return;
|
||||
}
|
||||
g.decision = { kind: 'homeworldRevivalDeployment', player: frame.player, event: frame.event };
|
||||
openHomeworldRevivalDecision(g);
|
||||
}
|
||||
function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) {
|
||||
const frame = g.homeworldRevivalReturn;
|
||||
requireRule(frame?.stage === 'choice' && frame.player === p.id && action.event === frame.event,
|
||||
const frame = g.homeworldRevivalReturn, arrival = currentHomeworldRevivalArrival(frame);
|
||||
requireRule(frame?.stage === 'choice' && arrival && frame.player === p.id && action.event === arrival.event,
|
||||
'Choose the current returned group’s placement.');
|
||||
const allocation = frame.progressVersion === 2;
|
||||
if (allocation) requireRule(Object.keys(action).every(key => ['type', 'event', 'decline', 'destination', 'amount'].includes(key)),
|
||||
'Choose only the current returned stars, destination or remaining-reserve option.');
|
||||
if (action.decline === true) {
|
||||
requireRule(action.destination === undefined && action.amount === undefined,
|
||||
'Leaving the group in reserves does not select a destination or quantity.');
|
||||
frame.destination = 'decline';
|
||||
frame.ambassadors = [];
|
||||
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
|
||||
if (allocation) homeworldRule(() => commitSouthernRevivalChoice(frame, arrival.event,
|
||||
{ amount: southernRevivalRemaining(frame), destination: 'decline' }));
|
||||
else {
|
||||
frame.destination = 'decline'; frame.ambassadors = [];
|
||||
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
|
||||
}
|
||||
completeHomeworldRevivalReturn(g);
|
||||
log(g, `${p.name} left the newly revived group in reserves.`);
|
||||
return;
|
||||
}
|
||||
const amount = frame.quote.normal + frame.quote.elite;
|
||||
requireRule((action.amount === undefined || action.amount === amount) && typeof action.destination === 'string',
|
||||
'Place the entire eligible revived group in one destination.');
|
||||
const quote = homeworldRule(() => quoteHomeworldRevivalDestination(g, p.id, frame.quote, action.destination as string));
|
||||
const amount = allocation ? action.amount === undefined ? 1 : integer(action.amount, 1, southernRevivalRemaining(frame), 'Revived Fedaykin')
|
||||
: frame.quote.normal + frame.quote.elite;
|
||||
requireRule((allocation || action.amount === undefined || action.amount === amount) && typeof action.destination === 'string',
|
||||
allocation ? 'Choose a legal destination for the selected revived stars.' : 'Place the entire eligible revived group in one destination.');
|
||||
const quote = homeworldRule(() => quoteHomeworldRevivalDestination(g, p.id, frame.quote, action.destination as string,
|
||||
allocation ? amount : undefined));
|
||||
if (allocation) homeworldRule(() => commitSouthernRevivalChoice(frame, arrival.event,
|
||||
{ amount, destination: quote.selected.id }));
|
||||
const seat = quote.transfer.players.find((seat) => seat.id === p.id)!;
|
||||
p.reserves = seat.reserves;
|
||||
if (p.elites) p.elites.reserves = seat.eliteReserves;
|
||||
g.homeworlds!.custody = quote.transfer.state;
|
||||
observeOccupation(g);
|
||||
frame.destination = quote.selected.id;
|
||||
frame.stage = 'arrival';
|
||||
if (!allocation) {
|
||||
frame.destination = quote.selected.id; frame.stage = 'arrival';
|
||||
frame.ambassadors = [];
|
||||
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
|
||||
}
|
||||
stampHomeworldRevivalProgress(g);
|
||||
frame.ambassadors = [];
|
||||
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
|
||||
const group = currentHomeworldRevivalGroup(frame)!;
|
||||
if (quote.selected.territory) {
|
||||
place(p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite);
|
||||
place(p, quote.selected.territory, quote.selected.sector!, amount, group.elite);
|
||||
const bg = byFaction(g, 'beneGesserit');
|
||||
const followup = frame.quote.kind === 'tleilax' && bg && bg.id !== p.id &&
|
||||
spiritualAdvisorMaximum(g, bg.id) > 0
|
||||
? {shipment:p.id,destination:quote.selected.id,revivalEvent:frame.event} : undefined;
|
||||
if (!intrusion(g, p, quote.selected.territory, {revivalEvent:frame.event,...(followup ? {followup} : {})}) && followup)
|
||||
? {shipment:p.id,destination:quote.selected.id,revivalEvent:arrival.event} : undefined;
|
||||
if (!intrusion(g, p, quote.selected.territory, {revivalEvent:arrival.event,...(followup ? {followup} : {})}) && followup)
|
||||
g.decision = {kind:'advisor',player:bg!.id,...followup};
|
||||
openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount,
|
||||
frame.quote.elite, 'homeworldRevival', 'none', {event:frame.event});
|
||||
group.elite, 'homeworldRevival', 'none', {event:arrival.event});
|
||||
}
|
||||
log(g, `${p.name} placed ${amount} newly revived ${frame.quote.kind === 'fedaykin' ? 'Fedaykin' : 'free forces'} in ${quote.selected.name}. No additional revival payment or shipment allowance was used.`,
|
||||
{ faction: p.faction, name: frame.quote.kind === 'fedaykin' ? 'Southern Hemisphere revival' : 'Tleilax free revival' });
|
||||
@@ -32551,8 +32594,10 @@ function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) {
|
||||
function projectedHomeworldRevivalReturn(g: Game, player: string) {
|
||||
const frame = g.homeworldRevivalReturn;
|
||||
if (frame?.stage !== 'choice') return null;
|
||||
return { event: frame.event, player: frame.player, kind: frame.quote.kind,
|
||||
normal: frame.quote.normal, elite: frame.quote.elite, blocked: frame.quote.blocked,
|
||||
const allocation = frame.progressVersion === 2 ? southernRevivalSummary(frame) : undefined;
|
||||
return { event: currentHomeworldRevivalArrival(frame)!.event, player: frame.player, kind: frame.quote.kind,
|
||||
normal: frame.quote.normal, elite: allocation?.remaining ?? frame.quote.elite, blocked: frame.quote.blocked,
|
||||
...(allocation ? { allocation } : {}),
|
||||
destinations: player === frame.player
|
||||
? homeworldRule(() => homeworldRevivalDestinations(g, player, frame.quote)) : [],
|
||||
};
|
||||
|
||||
@@ -124,7 +124,7 @@ export const HOMEWORLD_CARDS: readonly HomeworldCard[] = Object.freeze([
|
||||
2,
|
||||
2,
|
||||
[
|
||||
'When a starred Fedaykin force is revived, you may immediately place it in any territory where you already have forces.',
|
||||
'Newly revived starred Fedaykin forces may immediately be placed in a territory where you already have forces.',
|
||||
],
|
||||
['No additional penalty.'],
|
||||
[
|
||||
|
||||
@@ -37,29 +37,48 @@ export function homeworldRevivalDeploymentCanAct(g: GameView): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function deploymentQuantities(g: GameView): { amounts: number[]; blocked: string | null } {
|
||||
const offer = g.homeworldRevivalDeployment;
|
||||
if (!offer || !Number.isSafeInteger(offer.normal) || !Number.isSafeInteger(offer.elite) ||
|
||||
offer.normal < 0 || offer.elite < 0 || offer.normal + offer.elite <= 0 || offer.normal + offer.elite > 20)
|
||||
return { amounts: [], blocked: 'The revived group is no longer available.' };
|
||||
const allocation = offer.allocation;
|
||||
if (allocation === undefined) return { amounts: [offer.normal + offer.elite], blocked: null };
|
||||
if (!allocation || typeof allocation !== 'object' || Array.isArray(allocation) ||
|
||||
Object.keys(allocation).sort().join(',') !== 'kept,placed,remaining,total' || g.advanced || offer.kind !== 'fedaykin' || offer.normal !== 0 ||
|
||||
![allocation.total, allocation.remaining, allocation.placed, allocation.kept].every(value => Number.isSafeInteger(value) && value >= 0 && value <= 20) ||
|
||||
allocation.remaining <= 0 || allocation.total <= 0 || allocation.total !== allocation.remaining + allocation.placed + allocation.kept ||
|
||||
offer.elite !== allocation.remaining)
|
||||
return { amounts: [], blocked: 'The Southern revival allocation is no longer available.' };
|
||||
return { amounts: Array.from({ length: allocation.remaining }, (_, index) => index + 1), blocked: null };
|
||||
}
|
||||
|
||||
/** Only the stamped native Basic allocation allows a partial placement. */
|
||||
export function homeworldRevivalDeploymentQuantities(g: GameView): number[] {
|
||||
if (!homeworldRevivalDeploymentCanAct(g) || g.homeworldRevivalDeployment?.blocked) return [];
|
||||
return deploymentQuantities(g).amounts;
|
||||
}
|
||||
|
||||
export function homeworldRevivalDeploymentChoice(
|
||||
g: GameView,
|
||||
destination: string,
|
||||
amount?: number,
|
||||
): { blocked: string | null; action: Action | null } {
|
||||
const offer = g.homeworldRevivalDeployment;
|
||||
const target = offer?.destinations.find(
|
||||
(option) => option.id === destination,
|
||||
);
|
||||
const quantities = deploymentQuantities(g);
|
||||
const selected = amount === undefined ? quantities.amounts[0] : amount;
|
||||
let blocked: string | null = null;
|
||||
if (!homeworldRevivalDeploymentCanAct(g))
|
||||
blocked = 'Wait for the current choice to finish.';
|
||||
else if (offer!.blocked) blocked = offer!.blocked;
|
||||
else if (!target) blocked = 'Choose a current revival destination.';
|
||||
else if (target.blocked) blocked = target.blocked;
|
||||
else if (
|
||||
!Number.isSafeInteger(offer!.normal) ||
|
||||
!Number.isSafeInteger(offer!.elite) ||
|
||||
offer!.normal < 0 ||
|
||||
offer!.elite < 0 ||
|
||||
offer!.normal + offer!.elite <= 0 ||
|
||||
offer!.normal + offer!.elite > 20
|
||||
)
|
||||
blocked = 'The revived group is no longer available.';
|
||||
else if (quantities.blocked) blocked = quantities.blocked;
|
||||
else if (selected === undefined || !quantities.amounts.includes(selected))
|
||||
blocked = 'Choose a current revived quantity.';
|
||||
return {
|
||||
blocked,
|
||||
action: blocked
|
||||
@@ -68,7 +87,7 @@ export function homeworldRevivalDeploymentChoice(
|
||||
type: 'decision',
|
||||
event: offer!.event,
|
||||
destination,
|
||||
amount: offer!.normal + offer!.elite,
|
||||
amount: selected,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -89,11 +108,12 @@ export function homeworldRevivalDeploymentActions(g: GameView): Action[] {
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key]) => splitLocation(key).territory),
|
||||
);
|
||||
const quantities = homeworldRevivalDeploymentQuantities(g);
|
||||
const choices = offer.destinations
|
||||
.map((target) => ({
|
||||
.flatMap((target) => quantities.map(amount => ({
|
||||
target,
|
||||
action: homeworldRevivalDeploymentChoice(g, target.id).action,
|
||||
}))
|
||||
action: homeworldRevivalDeploymentChoice(g, target.id, amount).action,
|
||||
})))
|
||||
.filter((choice) => choice.action);
|
||||
const preferred = choices.find(
|
||||
({ target }) =>
|
||||
@@ -102,5 +122,10 @@ export function homeworldRevivalDeploymentActions(g: GameView): Action[] {
|
||||
occupied.has(target.territory),
|
||||
);
|
||||
const action = preferred?.action ?? choices[0]?.action;
|
||||
if (offer.allocation !== undefined) {
|
||||
const ordered = preferred ? [preferred, ...choices.filter(choice => choice !== preferred && choice.target.id === preferred.target.id),
|
||||
...choices.filter(choice => choice.target.id !== preferred.target.id)] : choices;
|
||||
return [...ordered.flatMap(choice => choice.action ? [choice.action] : []), { type: 'decision', event: offer.event, decline: true }];
|
||||
}
|
||||
return [action ?? { type: 'decision', event: offer.event, decline: true }];
|
||||
}
|
||||
@@ -18,7 +18,8 @@ export type HomeworldRevivalDestination = {
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
/** Only the complete earned group to one destination is admitted here.
|
||||
/** Legacy calls place the complete earned group in one destination. New Southern
|
||||
* allocations may quote an exact subset of the original eligible stars.
|
||||
* The engine owns provisional SEND-entry reactions and their continuation. No token face,
|
||||
* opposing hand, spice balance or concealed denomination influences these lists. */
|
||||
export function homeworldRevivalDestinations(
|
||||
@@ -68,11 +69,15 @@ export function quoteHomeworldRevivalDestination(
|
||||
player: string,
|
||||
grant: HomeworldRevivalDeploymentQuote,
|
||||
destination: string,
|
||||
fedaykinAmount?: number,
|
||||
) {
|
||||
if (fedaykinAmount !== undefined && (grant.kind !== 'fedaykin' || grant.normal !== 0 ||
|
||||
!Number.isSafeInteger(fedaykinAmount) || fedaykinAmount < 1 || fedaykinAmount > grant.elite))
|
||||
throw new HomeworldCustodyError('Choose only a positive subset of the original revived Fedaykin grant.');
|
||||
const selected = homeworldRevivalDestinations(g, player, grant).find((d) => d.id === destination);
|
||||
if (!selected || selected.blocked)
|
||||
throw new HomeworldCustodyError(selected?.blocked ?? 'Choose a currently eligible revival destination.');
|
||||
const forces = { normal: grant.normal, elite: grant.elite };
|
||||
const forces = { normal: grant.normal, elite: fedaykinAmount ?? grant.elite };
|
||||
const context = homeworldContext(g);
|
||||
if (selected.homeworld) {
|
||||
const native = homeworldForceGroups(context, g.homeworlds!.custody!)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
completeHomeworldArrivalAmbassador,
|
||||
validateHomeworldArrival,
|
||||
type HomeworldAmbassadorArrival,
|
||||
type HomeworldArrivalFrame,
|
||||
} from './homeworld-arrival';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import type {
|
||||
@@ -14,7 +15,7 @@ import type {
|
||||
} from './homeworld-revival-deployment';
|
||||
|
||||
export type HomeworldRevivalReturn = {
|
||||
progressVersion?: 1;
|
||||
progressVersion?: 1 | 2;
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
@@ -30,8 +31,18 @@ export type HomeworldRevivalReturn = {
|
||||
resumeSignature?: string;
|
||||
ambassadors?: HomeworldAmbassadorArrival[];
|
||||
arrivalSignature?: string;
|
||||
allocation?: SouthernRevivalAllocation;
|
||||
};
|
||||
|
||||
export type SouthernRevivalChild = Omit<HomeworldArrivalFrame, 'stage'> & {
|
||||
rootEvent: string;
|
||||
index: number;
|
||||
amount: number | null;
|
||||
elite: number | null;
|
||||
stage: 'choice' | 'arrival' | 'complete';
|
||||
};
|
||||
export type SouthernRevivalAllocation = { version: 1; children: SouthernRevivalChild[] };
|
||||
|
||||
export type HomeworldRevivalProgress = {
|
||||
event: string;
|
||||
stage: HomeworldRevivalReturn['stage'];
|
||||
@@ -50,6 +61,10 @@ type Original = Pick<
|
||||
| 'group'
|
||||
| 'quote'
|
||||
>;
|
||||
type ArrivalIdentity = Pick<HomeworldArrivalFrame, 'event' | 'turn' | 'phase' | 'stage' | 'destination'> & {
|
||||
progressVersion?: 1 | 2;
|
||||
allocation?: SouthernRevivalAllocation;
|
||||
};
|
||||
type Context = Pick<
|
||||
Game,
|
||||
| 'turn'
|
||||
@@ -58,7 +73,19 @@ type Context = Pick<
|
||||
| 'homeworlds'
|
||||
| 'pendingAmbassador'
|
||||
| 'homeworldRevivalProgress'
|
||||
>;
|
||||
> & { advanced?: boolean };
|
||||
|
||||
/** Selection only: minimal saved parent identities remain valid legacy inputs. */
|
||||
export function currentHomeworldRevivalArrival<T extends ArrivalIdentity>(frame: T | null | undefined): T | SouthernRevivalChild | null {
|
||||
return !frame ? null : frame.progressVersion === 2 ? frame.allocation?.children.at(-1) ?? null : frame;
|
||||
}
|
||||
|
||||
/** Actual entry packet only; an unchosen allocation has not moved any forces. */
|
||||
export function currentHomeworldRevivalGroup(frame: HomeworldRevivalReturn): { amount: number; elite: number } | null {
|
||||
if (frame.progressVersion !== 2) return { amount: frame.quote.normal + frame.quote.elite, elite: frame.quote.elite };
|
||||
const child = frame.allocation?.children.at(-1);
|
||||
return child && child.amount !== null && child.elite !== null ? { amount: child.amount, elite: child.elite } : null;
|
||||
}
|
||||
|
||||
function fail(): never {
|
||||
throw new HomeworldCustodyError(
|
||||
@@ -103,6 +130,7 @@ function originalSignature(frame: Original): string {
|
||||
],
|
||||
// Preserve the exact signature bytes of legacy unversioned saves.
|
||||
...(frame.progressVersion === 1 ? ['progressVersion', 1] : []),
|
||||
...(frame.progressVersion === 2 ? ['progressVersion', 2, 'allocation', 1] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -110,7 +138,9 @@ function originalSignature(frame: Original): string {
|
||||
export function homeworldRevivalArrivalSignature(
|
||||
frame: HomeworldRevivalReturn,
|
||||
): string {
|
||||
return homeworldArrivalSignature(frame);
|
||||
const arrival = currentHomeworldRevivalArrival(frame);
|
||||
if (!arrival) fail();
|
||||
return homeworldArrivalSignature(arrival);
|
||||
}
|
||||
export function appendHomeworldRevivalAmbassador(
|
||||
frame: HomeworldRevivalReturn,
|
||||
@@ -119,7 +149,10 @@ export function appendHomeworldRevivalAmbassador(
|
||||
): void {
|
||||
validateOriginal(frame);
|
||||
if (frame.signature !== originalSignature(frame)) fail();
|
||||
appendHomeworldArrivalAmbassador(frame, entry, parent);
|
||||
validateAllocation(frame);
|
||||
const arrival = currentHomeworldRevivalArrival(frame);
|
||||
if (!arrival) fail();
|
||||
appendHomeworldArrivalAmbassador(arrival, entry, parent);
|
||||
}
|
||||
export function completeHomeworldRevivalAmbassador(
|
||||
frame: HomeworldRevivalReturn,
|
||||
@@ -127,11 +160,14 @@ export function completeHomeworldRevivalAmbassador(
|
||||
): void {
|
||||
validateOriginal(frame);
|
||||
if (frame.signature !== originalSignature(frame)) fail();
|
||||
completeHomeworldArrivalAmbassador(frame, event);
|
||||
validateAllocation(frame);
|
||||
const arrival = currentHomeworldRevivalArrival(frame);
|
||||
if (!arrival) fail();
|
||||
completeHomeworldArrivalAmbassador(arrival, event);
|
||||
}
|
||||
function validateOriginal(frame: Original): void {
|
||||
if (
|
||||
(frame.progressVersion !== undefined && frame.progressVersion !== 1) ||
|
||||
(frame.progressVersion !== undefined && frame.progressVersion !== 1 && frame.progressVersion !== 2) ||
|
||||
!text(frame.event) ||
|
||||
!text(frame.player) ||
|
||||
!count(frame.turn, 1, Number.MAX_SAFE_INTEGER) ||
|
||||
@@ -163,6 +199,7 @@ function validateOriginal(frame: Original): void {
|
||||
)
|
||||
fail();
|
||||
const quote = frame.quote;
|
||||
if (frame.progressVersion === 2 && (quote.kind !== 'fedaykin' || quote.elite < 2)) fail();
|
||||
// Provisional composition policy: the population resulting from this return
|
||||
// decides the ability, so the crossing return's frame is valid even though
|
||||
// the pre-return population was below the high threshold.
|
||||
@@ -210,6 +247,129 @@ export function homeworldRevivalResumeSignature(
|
||||
);
|
||||
}
|
||||
|
||||
function southernChildSignature(root: HomeworldRevivalReturn, child: SouthernRevivalChild): string {
|
||||
return JSON.stringify(['southernRevivalChild', root.signature, root.event, child.index, child.event, child.amount, child.elite]);
|
||||
}
|
||||
|
||||
function validateAllocation(root: HomeworldRevivalReturn): void {
|
||||
if (root.progressVersion !== 2) {
|
||||
if (Object.hasOwn(root, 'allocation')) fail();
|
||||
return;
|
||||
}
|
||||
const allocation = root.allocation;
|
||||
if (!keys(allocation, ['version', 'children']) || allocation?.version !== 1 || !Array.isArray(allocation.children) ||
|
||||
allocation.children.length > root.quote.elite || root.destination !== undefined || root.ambassadors !== undefined ||
|
||||
root.arrivalSignature !== undefined) fail();
|
||||
const seen = new Set([root.event]);
|
||||
let selected = 0;
|
||||
for (const [index, child] of allocation.children.entries()) {
|
||||
if (!keys(child, ['event', 'turn', 'phase', 'player', 'stage', 'signature', 'destination', 'ambassadors',
|
||||
'arrivalSignature', 'rootEvent', 'index', 'amount', 'elite']) || !text(child.event) || seen.has(child.event) ||
|
||||
child.rootEvent !== root.event || child.index !== index || child.turn !== root.turn || child.phase !== root.phase ||
|
||||
child.player !== root.player || !['choice', 'arrival', 'complete'].includes(child.stage) ||
|
||||
(index < allocation.children.length - 1 && child.stage !== 'complete')) fail();
|
||||
seen.add(child.event);
|
||||
if (child.stage === 'choice') {
|
||||
if (child.amount !== null || child.elite !== null || child.destination !== undefined ||
|
||||
child.ambassadors !== undefined || child.arrivalSignature !== undefined || selected >= root.quote.elite) fail();
|
||||
} else {
|
||||
if (!count(child.amount, 1, root.quote.elite) || child.elite !== child.amount || !text(child.destination) ||
|
||||
child.arrivalSignature !== homeworldArrivalSignature(child)) fail();
|
||||
selected += child.amount;
|
||||
}
|
||||
if (child.signature !== southernChildSignature(root, child) || selected > root.quote.elite) fail();
|
||||
}
|
||||
const current = allocation.children.at(-1);
|
||||
if (root.stage === 'waiting' ? allocation.children.length !== 0 : !current || root.stage !== current.stage) fail();
|
||||
if (root.stage === 'complete' && selected !== root.quote.elite) fail();
|
||||
}
|
||||
|
||||
function requireSouthern(root: HomeworldRevivalReturn): void {
|
||||
validateOriginal(root);
|
||||
if (root.progressVersion !== 2 || root.signature !== originalSignature(root)) fail();
|
||||
validateAllocation(root);
|
||||
if (root.stage !== 'waiting' && (root.resumeResponse === undefined ||
|
||||
root.resumeSignature !== homeworldRevivalResumeSignature(root.resumeResponse))) fail();
|
||||
}
|
||||
|
||||
export function southernRevivalSummary(root: HomeworldRevivalReturn): { total: number; remaining: number; placed: number; kept: number } {
|
||||
requireSouthern(root);
|
||||
let placed = 0, kept = 0;
|
||||
for (const child of root.allocation!.children) {
|
||||
if (child.amount === null) continue;
|
||||
if (child.destination === 'decline') kept += child.amount;
|
||||
else placed += child.amount;
|
||||
}
|
||||
return { total: root.quote.elite, remaining: root.quote.elite - placed - kept, placed, kept };
|
||||
}
|
||||
|
||||
export function southernRevivalRemaining(root: HomeworldRevivalReturn): number {
|
||||
return southernRevivalSummary(root).remaining;
|
||||
}
|
||||
|
||||
/** Fresh Basic multi-star roots only. The engine owns the actual one-time return. */
|
||||
export function makeSouthernRevivalAllocation(original: Omit<Original, 'progressVersion'>): HomeworldRevivalReturn {
|
||||
const base = makeHomeworldRevivalReturn(original);
|
||||
const root: HomeworldRevivalReturn = { ...base, progressVersion: 2, allocation: { version: 1, children: [] } };
|
||||
validateOriginal(root);
|
||||
root.signature = originalSignature(root);
|
||||
validateAllocation(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function makeSouthernChoice(root: HomeworldRevivalReturn, event: string): SouthernRevivalChild {
|
||||
if (!text(event) || event !== event.trim() || event === root.event || root.allocation!.children.some(child => child.event === event)) fail();
|
||||
const child: SouthernRevivalChild = { event, turn: root.turn, phase: root.phase, player: root.player,
|
||||
stage: 'choice', signature: '', rootEvent: root.event, index: root.allocation!.children.length, amount: null, elite: null };
|
||||
child.signature = southernChildSignature(root, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Capture the original response once before opening the first owned child. */
|
||||
export function openSouthernRevivalChoice(root: HomeworldRevivalReturn, newEvent: string): void {
|
||||
requireSouthern(root);
|
||||
if (root.stage !== 'waiting' || root.allocation!.children.length || root.resumeResponse === undefined ||
|
||||
root.resumeSignature !== homeworldRevivalResumeSignature(root.resumeResponse)) fail();
|
||||
const child = makeSouthernChoice(root, newEvent);
|
||||
root.allocation!.children.push(child);
|
||||
root.stage = 'choice';
|
||||
}
|
||||
|
||||
/** Called at the same atomic boundary as the native subset debit/placement. */
|
||||
export function commitSouthernRevivalChoice(root: HomeworldRevivalReturn, event: string,
|
||||
selection: { amount: number; destination: string }): void {
|
||||
requireSouthern(root);
|
||||
const child = root.allocation!.children.at(-1);
|
||||
if (root.stage !== 'choice' || !child || child.stage !== 'choice' || child.event !== event ||
|
||||
!keys(selection, ['amount', 'destination']) || !count(selection.amount, 1, southernRevivalRemaining(root)) ||
|
||||
!text(selection.destination) || selection.destination !== selection.destination.trim()) fail();
|
||||
child.amount = selection.amount;
|
||||
child.elite = selection.amount;
|
||||
child.destination = selection.destination;
|
||||
child.stage = 'arrival';
|
||||
child.signature = southernChildSignature(root, child);
|
||||
child.ambassadors = [];
|
||||
child.arrivalSignature = homeworldArrivalSignature(child);
|
||||
root.stage = 'arrival';
|
||||
}
|
||||
|
||||
/** Retire one complete native arrival, then open the next fresh choice or finish.
|
||||
* The original response remains held until the whole root is complete. */
|
||||
export function completeSouthernRevivalChild(root: HomeworldRevivalReturn, event: string, nextEvent?: string): void {
|
||||
requireSouthern(root);
|
||||
const child = root.allocation!.children.at(-1);
|
||||
if (root.stage !== 'arrival' || !child || child.stage !== 'arrival' || child.event !== event ||
|
||||
child.ambassadors!.some(entry => !entry.completed)) fail();
|
||||
const remaining = southernRevivalRemaining(root);
|
||||
if (remaining === 0 && nextEvent !== undefined || remaining > 0 && nextEvent === undefined) fail();
|
||||
const next = remaining > 0 ? makeSouthernChoice(root, nextEvent!) : null;
|
||||
child.stage = 'complete';
|
||||
if (next) {
|
||||
root.allocation!.children.push(next);
|
||||
root.stage = 'choice';
|
||||
} else root.stage = 'complete';
|
||||
}
|
||||
|
||||
/** Construct only after the quoted deposit actually returned its exact group. */
|
||||
export function makeHomeworldRevivalReturn(
|
||||
original: Omit<Original, 'progressVersion'>,
|
||||
@@ -245,11 +405,12 @@ export function makeHomeworldRevivalProgress(
|
||||
): HomeworldRevivalProgress {
|
||||
validateOriginal(frame);
|
||||
if (
|
||||
frame.progressVersion !== 1 ||
|
||||
(frame.progressVersion !== 1 && frame.progressVersion !== 2) ||
|
||||
frame.signature !== originalSignature(frame) ||
|
||||
!['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage)
|
||||
)
|
||||
fail();
|
||||
validateAllocation(frame);
|
||||
return {
|
||||
event: frame.event,
|
||||
stage: frame.stage,
|
||||
@@ -258,6 +419,9 @@ export function makeHomeworldRevivalProgress(
|
||||
frame.signature,
|
||||
frame.event,
|
||||
frame.stage,
|
||||
...(frame.progressVersion === 2 ? ['allocation', frame.allocation!.children.map(child => [
|
||||
child.event, child.index, child.amount, child.elite, child.stage, child.destination ?? null, child.signature,
|
||||
])] : []),
|
||||
]),
|
||||
};
|
||||
}
|
||||
@@ -289,20 +453,23 @@ export function validateHomeworldRevivalReturn(
|
||||
'resumeSignature',
|
||||
'ambassadors',
|
||||
'arrivalSignature',
|
||||
...(frame.progressVersion === 2 ? ['allocation'] : []),
|
||||
])
|
||||
)
|
||||
fail();
|
||||
validateOriginal(frame);
|
||||
validateAllocation(frame);
|
||||
if (
|
||||
frame.signature !== originalSignature(frame) ||
|
||||
!['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage) ||
|
||||
!g.homeworlds?.custody ||
|
||||
(frame.stage !== 'complete' &&
|
||||
(g.turn !== frame.turn || g.phase !== frame.phase)) ||
|
||||
(frame.stage === 'complete' && g.turn < frame.turn)
|
||||
(frame.stage === 'complete' && g.turn < frame.turn) ||
|
||||
(frame.progressVersion === 2 && g.advanced !== false)
|
||||
)
|
||||
fail();
|
||||
if (frame.progressVersion === 1) {
|
||||
if (frame.progressVersion === 1 || frame.progressVersion === 2) {
|
||||
const expected = makeHomeworldRevivalProgress(frame);
|
||||
const progress = g.homeworldRevivalProgress;
|
||||
if (
|
||||
@@ -323,16 +490,23 @@ export function validateHomeworldRevivalReturn(
|
||||
if (frame.stage === 'waiting' || frame.stage === 'choice') {
|
||||
if (frame.ambassadors !== undefined || frame.arrivalSignature !== undefined)
|
||||
fail();
|
||||
}
|
||||
if (frame.stage === 'waiting' || frame.stage === 'choice' || frame.progressVersion === 2 && frame.stage !== 'complete') {
|
||||
const elites = owner.elites?.reserves ?? 0;
|
||||
const elite = frame.progressVersion === 2 ? southernRevivalRemaining(frame) : frame.quote.elite;
|
||||
const normal = frame.progressVersion === 2 ? 0 : frame.quote.normal;
|
||||
if (
|
||||
!count(owner.reserves) ||
|
||||
!count(elites, 0, owner.reserves) ||
|
||||
elites < frame.quote.elite ||
|
||||
owner.reserves - elites < frame.quote.normal
|
||||
elites < elite ||
|
||||
owner.reserves - elites < normal
|
||||
)
|
||||
fail();
|
||||
}
|
||||
if (frame.stage === 'arrival' || frame.stage === 'complete')
|
||||
if (frame.progressVersion === 2) {
|
||||
for (const child of frame.allocation!.children)
|
||||
if (child.stage !== 'choice') validateHomeworldArrival(g, child, 'revivalEvent');
|
||||
} else if (frame.stage === 'arrival' || frame.stage === 'complete')
|
||||
validateHomeworldArrival(g, frame, 'revivalEvent');
|
||||
if (frame.stage === 'waiting') {
|
||||
if (
|
||||
@@ -349,11 +523,11 @@ export function validateHomeworldRevivalReturn(
|
||||
homeworldRevivalResumeSignature(frame.resumeResponse)
|
||||
)
|
||||
fail();
|
||||
if (
|
||||
if (frame.progressVersion !== 2 && (
|
||||
frame.stage === 'choice'
|
||||
? frame.destination !== undefined
|
||||
: !text(frame.destination)
|
||||
)
|
||||
))
|
||||
fail();
|
||||
const response = frame.resumeResponse;
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validAmbassadorResume } from './ambassador-resume';
|
||||
import { validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound } from './discovery-entry';
|
||||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
|
||||
import { currentHomeworldRevivalArrival, validateHomeworldRevivalReturn } from './homeworld-revival-return';
|
||||
import { validateRevivalArrivalSource } from './revival-terror-entry';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import {
|
||||
@@ -516,10 +516,10 @@ export function quoteMovementCancellation(
|
||||
);
|
||||
if (response.advisorResume === 'wormRide') return null;
|
||||
if (response.advisorRevivalEvent !== undefined) {
|
||||
const parent = g.homeworldRevivalReturn;
|
||||
const root = g.homeworldRevivalReturn, parent = currentHomeworldRevivalArrival(root);
|
||||
const terror = response.advisorRevivalTerror;
|
||||
try {
|
||||
validateHomeworldRevivalReturn(g, parent);
|
||||
validateHomeworldRevivalReturn(g, root);
|
||||
if (terror) validateRevivalArrivalSource(g, {event:response.advisorRevivalEvent,terror},
|
||||
{entrant:terror.owner,territory:terror.territory,sector:terror.sector,
|
||||
amount:terror.amount,elite:terror.elite,cause:'shipment'});
|
||||
@@ -536,7 +536,7 @@ export function quoteMovementCancellation(
|
||||
response.advisorResume === undefined && response.advisorRemaining === undefined,
|
||||
'The canceled Intrusion lost its original committed revived-force arrival.');
|
||||
const followup = response.advisorFollowup;
|
||||
requireContext(followup === undefined || ((terror || parent.quote.kind === 'tleilax') &&
|
||||
requireContext(followup === undefined || ((terror || root?.quote.kind === 'tleilax') &&
|
||||
followup.shipment === (terror?.owner ?? parent.player) &&
|
||||
followup.destination === (terror ? location(terror.territory,terror.sector) : parent.destination) &&
|
||||
followup.revivalEvent === parent.event &&
|
||||
|
||||
+8
-6
@@ -5337,9 +5337,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
|
||||
'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.',
|
||||
'Quote a group of normal revivals using population before that group returns. A later group uses the resulting population: a low Fremen group can return four free forces, but returning a smaller group that restores high population can remove the fourth allowance. La La La prevents the full free rate.',
|
||||
'When Southern Hemisphere supports a revival that leaves Fremen at high population — including a return that first reaches the high side: the population resulting from the return decides the ability — Fremen may place the newly revived starred forces together in one territory already containing their forces, or leave them in reserves. This includes supported ordinary, Emperor-funded and Ghola revivals. Evaluating the resulting population is a visibly provisional composition policy, not a located publisher timing ruling. Basic keeps the starred counters; Advanced still limits Fedaykin revival to one per turn.',
|
||||
'When Southern Hemisphere supports a revival that leaves Fremen at high population — including a return that first reaches the high side: the population resulting from the return decides the ability — Fremen may place newly revived starred forces in territories already containing their forces, or leave them in reserves. Fresh Basic multi-star returns provisionally permit separate portions and destinations: finish each arrival before choosing the next portion, or leave all remaining stars in reserves. The original revived batch and its entitlement stay fixed. Existing whole-group returns keep their earlier procedure. This includes supported ordinary, Emperor-funded and Ghola revivals. Evaluating the resulting population is a visibly provisional composition policy, not a located publisher timing ruling. Basic keeps the starred counters; Advanced still limits Fedaykin revival to one per turn.',
|
||||
'Under the same provisional resulting-population policy, an ordinary Free Revival that leaves Tleilax at high population may send its newly revived free group together to one permitted territory or Homeworld, or leave it in reserves. This includes the return that first reaches the high side. Paid counters in the same return and Ghola returns do not join this group. An allied Homeworld is forbidden. Placement costs no additional spice and uses no ordinary shipment or movement allowance.',
|
||||
'The revival placement choice shows unavailable destinations. A return that first restores high population is admitted under the provisional resulting-population policy above. Physical Arrakis entry provisionally earns Intrusion, eligible Terror and Ambassador reactions; off-planet Tleilax placement also earns BG Spiritual Advisors. Southern placement remains on-planet. Original revival income waits for those children, including a committed Sneak Attack reserve group, without another shipment fee or allowance. Preventing a BG power leaves the revived group committed. Terror and Ambassador owners act in storm order when both qualify. Partial groups, split destinations, Southern storm placement and Hidden Mobile Stronghold classification remain guarded; allied Homeworlds remain forbidden. These SEND compositions are explicit provisional interpretations, not a new publisher FAQ.',
|
||||
'The revival placement choice shows unavailable destinations. A return that first restores high population is admitted under the provisional resulting-population policy above. Physical Arrakis entry provisionally earns Intrusion, eligible Terror and Ambassador reactions; off-planet Tleilax placement also earns BG Spiritual Advisors. Southern placement remains on-planet. Original revival income waits for those children, including a committed Sneak Attack reserve group, without another shipment fee or allowance. Preventing a BG power leaves the revived group committed. Terror and Ambassador owners act in storm order when both qualify. Fresh Basic Southern allocation shows exact remaining quantities and permits different current legal destinations under the provisional per-star interpretation. Southern uses sectors outside the storm and keeps the ordinary Hidden Mobile Stronghold entry route; Tleilax keeps its direct-entry restriction. Tleilax still sends the whole free group to one destination, and allied Homeworlds remain forbidden. These SEND compositions are explicit provisional interpretations, not a new publisher FAQ.',
|
||||
'While Tupile has at least eleven native forces, CHOAM cannot discard Worthless cards for spice through either ordinary sale price or a CHOAM Ambassador. Non-Worthless duplicate sales, allied trades and the Advanced CHOAM Karama cash-in retain their own rules. If a Ghola revival raises Tupile before a declared sale settles, that Worthless card stays in hand and earns no spice.',
|
||||
'At the end of Bidding, high-population Kaitain lets Emperor pay two spice per selected Treachery Card to discard it. This does not play the card or begin another auction. Emperor and CHOAM share the closing opportunity, so sales, allied trades and paid discards can occur in either order. Current hands, spice and native population determine each action. Finish when ready; a new action clears readiness.',
|
||||
'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.',
|
||||
@@ -5386,7 +5386,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
|
||||
"Fresh Basic Southern multi-star returns have provisional per-star allocation with exact child arrivals, a fixed original grant and one deferred income. All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
|
||||
evidence: [
|
||||
'game/homeworld-module-profile.ts',
|
||||
'game/faction-module-profile.ts',
|
||||
@@ -5417,6 +5417,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'game/homeworld-revival-deployment.ts',
|
||||
'game/homeworld-revival-destinations.ts',
|
||||
'game/homeworld-revival-return.ts',
|
||||
'tests/fixture-southern-allocation.ts',
|
||||
'tests/southern-allocation-runtime.test.ts',
|
||||
'game/homeworld-victory-reinforcement.ts',
|
||||
'game/homeworld-victory-return.ts',
|
||||
'game/grumman-collection.ts',
|
||||
@@ -5462,7 +5464,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
"Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement. Fresh occupation adds bounded actual percentage allocation, real bonus receiver choice, source-authorized Ix pool/cache controls and separate native Richese terms. Supported starts remain previews. Basic Tupile controls explicitly show provisional current-sole loss and the earlier normal-limit discard. Four current original-action390px journeys show the unused-price warning, full3, poor2 then replacement half2, ordinary purchased2 alternative and full6 normal Guild income; movement remains available.",
|
||||
"Southern allocation controls show original, remaining, placed and kept stars with current quantity/destination choices and an explicit provisional note. Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement. Fresh occupation adds bounded actual percentage allocation, real bonus receiver choice, source-authorized Ix pool/cache controls and separate native Richese terms. Supported starts remain previews. Basic Tupile controls explicitly show provisional current-sole loss and the earlier normal-limit discard. Four current original-action390px journeys show the unused-price warning, full3, poor2 then replacement half2, ordinary purchased2 alternative and full6 normal Guild income; movement remains available.",
|
||||
evidence: [
|
||||
'components/homeworld-cards.tsx',
|
||||
'components/homeworld-table.tsx',
|
||||
@@ -5485,14 +5487,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
"Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Fresh occupied decisions supply legal bank/percentage allocation, one eligible real bonus receiver, displaced Ix/cache selection and native Richese terms. Concealed transport, remaining card-effect strategy and difficulty calibration are unfinished. All four original profiles execute the immune full offered quote with its explicit acknowledgment. This is minimal legal participation, not strategy tuning.",
|
||||
"All four profiles share legal Southern portion/destination choices and the remaining-reserves option. Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Fresh occupied decisions supply legal bank/percentage allocation, one eligible real bonus receiver, displaced Ix/cache selection and native Richese terms. Concealed transport, remaining card-effect strategy and difficulty calibration are unfinished. All four original profiles execute the immune full offered quote with its explicit acknowledgment. This is minimal legal participation, not strategy tuning.",
|
||||
evidence: ['game/homeworld-options.ts', 'game/homeworld-shipment-options.ts', 'game/guild-homeworld-shipment-options.ts', 'game/junction-transport-options.ts'],
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
"All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. The authorized Advanced sole/last-departure cutover, original occupied economic/Bidding ownership and user-ruled whole-invoice rounding are explicit. Remaining occupation variants and interactions stay separate; full guidance remains in progress. Current Junction source choice distinguishes printed immunity from the selected unused-price/no-refund product composition; failed or deferred transport retains the grant.",
|
||||
"The Basic Southern per-star interpretation, exact original-batch scope and legacy/Tleilax/Advanced boundaries are explicit. All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. The authorized Advanced sole/last-departure cutover, original occupied economic/Bidding ownership and user-ruled whole-invoice rounding are explicit. Remaining occupation variants and interactions stay separate; full guidance remains in progress. Current Junction source choice distinguishes printed immunity from the selected unused-price/no-refund product composition; failed or deferred transport retains the grant.",
|
||||
evidence: [
|
||||
'docs/HOMEWORLD_COMPONENT_AUDIT.md',
|
||||
'docs/HOMEWORLD_RULES.md',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Game } from './engine';
|
||||
import { gameTerritories, location, splitLocation, validLocation } from './board';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
|
||||
import { currentHomeworldRevivalArrival, currentHomeworldRevivalGroup, validateHomeworldRevivalReturn } from './homeworld-revival-return';
|
||||
import type { SpiritualAdvisorSource } from './spiritual-advisor';
|
||||
|
||||
export type RevivalTerrorPacket = {
|
||||
@@ -19,7 +19,7 @@ export type RevivalEntryParent = {
|
||||
};
|
||||
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
|
||||
export type RevivalArrivalDetails = Pick<TerrorEntry, 'entrant' | 'territory' | 'sector' | 'amount' | 'elite' | 'cause'>;
|
||||
export type RevivalArrivalContext = Pick<Game, 'status' | 'turn' | 'phase' | 'players' |
|
||||
export type RevivalArrivalContext = Pick<Game, 'status' | 'advanced' | 'turn' | 'phase' | 'players' |
|
||||
'homeworldRevivalReturn' | 'homeworldRevivalProgress' | 'homeworlds' |
|
||||
'pendingAmbassador' | 'pendingTreacheryDiscard' | 'mobileStronghold' | 'discoveries'>;
|
||||
type HistoricalContext = Pick<Game, 'turn' | 'phase'> & {
|
||||
@@ -138,18 +138,21 @@ export function revivalEntryParentTuple(parent: RevivalEntryParent): unknown[] {
|
||||
export function validateRevivalArrivalSource(g: RevivalArrivalContext, parent: RevivalEntryParent, details: RevivalArrivalDetails): void {
|
||||
if (!validRevivalEntryParent(parent)) fail();
|
||||
const frame = g.homeworldRevivalReturn;
|
||||
if (g.status !== 'playing' || !frame || frame.stage !== 'arrival' || frame.event !== parent.event ||
|
||||
frame.turn !== g.turn || frame.phase !== g.phase || !text(frame.destination)) fail();
|
||||
const arrival = frame ? currentHomeworldRevivalArrival(frame) : null;
|
||||
const group = frame ? currentHomeworldRevivalGroup(frame) : null;
|
||||
if (g.status !== 'playing' || !frame || !arrival || !group || frame.stage !== 'arrival' ||
|
||||
arrival.stage !== 'arrival' || arrival.event !== parent.event || frame.turn !== g.turn || frame.phase !== g.phase ||
|
||||
!text(arrival.destination)) fail();
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const context = !g.pendingAmbassador && continuation?.kind === 'ambassador'
|
||||
? { ...g, pendingAmbassador: continuation.entry } : g;
|
||||
validateHomeworldRevivalReturn(context, frame);
|
||||
const original = splitLocation(frame.destination);
|
||||
if (frame.destination !== location(original.territory, original.sector) ||
|
||||
const original = splitLocation(arrival.destination);
|
||||
if (arrival.destination !== location(original.territory, original.sector) ||
|
||||
!gameTerritories(g).some(place => place.id === original.territory && place.sectors.includes(original.sector)) ||
|
||||
!gameTerritories(g).some(place => place.id === details.territory && place.sectors.includes(details.sector)) ||
|
||||
!count(details.amount) || details.amount < 1 || !count(details.elite, details.amount)) fail();
|
||||
if (parent.terror) validateRevivalTerrorPacket({ ...g, revivalPlayer: frame.player }, parent.terror, frame.destination, frame.event);
|
||||
if (parent.terror) validateRevivalTerrorPacket({ ...g, revivalPlayer: frame.player }, parent.terror, arrival.destination, arrival.event);
|
||||
if (parent.spiritual) {
|
||||
const source = parent.spiritual;
|
||||
const origin = parent.terror?.territory ?? original.territory;
|
||||
@@ -165,7 +168,7 @@ export function validateRevivalArrivalSource(g: RevivalArrivalContext, parent: R
|
||||
details.sector !== packet.sector || details.amount !== packet.amount || details.elite !== packet.elite) fail();
|
||||
} else if (details.entrant !== frame.player || details.cause !== 'homeworldRevival' ||
|
||||
details.territory !== original.territory || details.sector !== original.sector ||
|
||||
details.amount !== frame.quote.normal + frame.quote.elite || details.elite !== frame.quote.elite) fail();
|
||||
details.amount !== group.amount || details.elite !== group.elite) fail();
|
||||
}
|
||||
|
||||
export function validateRevivalTerrorEntry(g: RevivalArrivalContext, entry: TerrorEntry): void {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { gameTerritories, splitLocation, type MobileBoard } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
import type { Game } from './engine';
|
||||
import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
|
||||
import { presenceAt, type ForcePresence } from './force-presence';
|
||||
|
||||
export type SpiritualAdvisorSource = {
|
||||
@@ -157,7 +158,7 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
|
||||
'Victory Spiritual Advisors need the original Guild shipment territory or Polar Sink.');
|
||||
}
|
||||
if (source.phase === 4) {
|
||||
const parent = ctx.homeworldRevivalReturn;
|
||||
const parent = currentHomeworldRevivalArrival(ctx.homeworldRevivalReturn);
|
||||
requireSpiritual(identifier(source.revivalEvent) && record(parent) &&
|
||||
parent.event === source.revivalEvent && parent.turn === ctx.turn && parent.phase === 4 &&
|
||||
parent.stage === 'arrival' && identifier(parent.destination),
|
||||
|
||||
Reference in new issue
Block a user