Allow individual revived Fedaykin placement

This commit is contained in:
Quintine committed 2026-10-27 09:22:27 +11:00
1 parent 6a0fba860d
commit ff2826b980
25 files changed
+1301 -124

No files matched your search

+188 -14
View File
@@ -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 (