Implement Homeworld mechanics through Caladan reinforcement checkpoint
Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
This commit is contained in:
1 parent
3be49512f7
commit
28ba1618ba
266 files changed
+47680
-16851
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import type { Game } from './engine';
|
||||
import { location, validLocation } from './board';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
|
||||
export type HomeworldAmbassadorArrival = {
|
||||
event: string;
|
||||
entrant: string;
|
||||
destination: string;
|
||||
parent?: string;
|
||||
completed: boolean;
|
||||
};
|
||||
export type HomeworldArrivalFrame = {
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
player: string;
|
||||
stage: 'waiting' | 'choice' | 'arrival' | 'complete';
|
||||
signature: string;
|
||||
destination?: string;
|
||||
ambassadors?: HomeworldAmbassadorArrival[];
|
||||
arrivalSignature?: string;
|
||||
};
|
||||
export type HomeworldArrivalTag = 'revivalEvent' | 'victoryEvent';
|
||||
export type HomeworldArrivalContext = Pick<
|
||||
Game,
|
||||
'turn' | 'phase' | 'players' | 'pendingAmbassador'
|
||||
>;
|
||||
function fail(): never {
|
||||
throw new HomeworldCustodyError(
|
||||
'The saved Homeworld arrival has lost its original Ambassador chain or completion proof.',
|
||||
);
|
||||
}
|
||||
function text(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
function keys(value: unknown, allowed: string[]): boolean {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).every((key) => allowed.includes(key))
|
||||
);
|
||||
}
|
||||
|
||||
/** Arrival provenance is fixed separately from the original revival grant. */
|
||||
export function homeworldArrivalSignature(
|
||||
frame: HomeworldArrivalFrame,
|
||||
): string {
|
||||
if (!text(frame.destination) || !Array.isArray(frame.ambassadors)) fail();
|
||||
const seen = new Set<string>();
|
||||
for (const [index, entry] of frame.ambassadors.entries()) {
|
||||
if (
|
||||
!keys(entry, [
|
||||
'event',
|
||||
'entrant',
|
||||
'destination',
|
||||
'parent',
|
||||
'completed',
|
||||
]) ||
|
||||
!text(entry.event) ||
|
||||
!text(entry.entrant) ||
|
||||
!text(entry.destination) ||
|
||||
typeof entry.completed !== 'boolean' ||
|
||||
(index < frame.ambassadors.length - 1 && !entry.completed) ||
|
||||
seen.has(entry.event) ||
|
||||
(index === 0
|
||||
? entry.parent !== undefined ||
|
||||
entry.entrant !== frame.player ||
|
||||
entry.destination !== frame.destination
|
||||
: !text(entry.parent) || !seen.has(entry.parent))
|
||||
)
|
||||
fail();
|
||||
seen.add(entry.event);
|
||||
}
|
||||
if (frame.destination === 'decline' && frame.ambassadors.length !== 0) fail();
|
||||
return JSON.stringify([
|
||||
// Preserve the existing persisted signature format; frame.signature binds its source.
|
||||
'homeworldRevivalArrival',
|
||||
frame.signature,
|
||||
frame.destination,
|
||||
frame.ambassadors.map((entry) => [
|
||||
entry.event,
|
||||
entry.entrant,
|
||||
entry.destination,
|
||||
entry.parent ?? null,
|
||||
entry.completed,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Only an actual Ambassador opening may append to this linear arrival chain. */
|
||||
export function appendHomeworldArrivalAmbassador(
|
||||
frame: HomeworldArrivalFrame,
|
||||
entry: {
|
||||
event: string;
|
||||
entrant: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
turn: number;
|
||||
phase: number;
|
||||
},
|
||||
parent?: string,
|
||||
): void {
|
||||
if (
|
||||
frame.stage !== 'arrival' ||
|
||||
frame.turn !== entry.turn ||
|
||||
frame.phase !== entry.phase ||
|
||||
!validLocation(entry.territory, entry.sector) ||
|
||||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
|
||||
(parent !== undefined && parent !== frame.ambassadors!.at(-1)?.event)
|
||||
)
|
||||
fail();
|
||||
const ambassadors = [
|
||||
...frame.ambassadors!,
|
||||
{
|
||||
event: entry.event,
|
||||
entrant: entry.entrant,
|
||||
destination: location(entry.territory, entry.sector),
|
||||
completed: false,
|
||||
...(parent === undefined ? {} : { parent }),
|
||||
},
|
||||
];
|
||||
const arrivalSignature = homeworldArrivalSignature({
|
||||
...frame,
|
||||
ambassadors,
|
||||
});
|
||||
frame.ambassadors = ambassadors;
|
||||
frame.arrivalSignature = arrivalSignature;
|
||||
}
|
||||
|
||||
/** Record actual completion before removing its live Ambassador control. */
|
||||
export function completeHomeworldArrivalAmbassador(
|
||||
frame: HomeworldArrivalFrame,
|
||||
event: string,
|
||||
): void {
|
||||
const current = frame.ambassadors?.at(-1);
|
||||
if (
|
||||
frame.stage !== 'arrival' ||
|
||||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
|
||||
!current ||
|
||||
current.event !== event ||
|
||||
current.completed
|
||||
)
|
||||
fail();
|
||||
const ambassadors = frame.ambassadors!.map((record) =>
|
||||
record === current ? { ...record, completed: true } : record,
|
||||
);
|
||||
const arrivalSignature = homeworldArrivalSignature({
|
||||
...frame,
|
||||
ambassadors,
|
||||
});
|
||||
frame.ambassadors = ambassadors;
|
||||
frame.arrivalSignature = arrivalSignature;
|
||||
}
|
||||
|
||||
/** Validate only the completed transfer's arrival chain. Original entitlement,
|
||||
* winner/revival evidence and their independent obligations stay with callers. */
|
||||
export function validateHomeworldArrival(
|
||||
g: HomeworldArrivalContext,
|
||||
frame: HomeworldArrivalFrame,
|
||||
tag: HomeworldArrivalTag,
|
||||
): void {
|
||||
if (
|
||||
!['revivalEvent', 'victoryEvent'].includes(tag) ||
|
||||
!frame ||
|
||||
!['arrival', 'complete'].includes(frame.stage) ||
|
||||
!text(frame.event) ||
|
||||
!text(frame.signature) ||
|
||||
!Number.isSafeInteger(frame.turn) ||
|
||||
frame.turn < 1 ||
|
||||
frame.turn > g.turn ||
|
||||
!Number.isSafeInteger(frame.phase) ||
|
||||
frame.phase < 0 ||
|
||||
frame.phase > 8 ||
|
||||
(frame.stage === 'arrival' &&
|
||||
(frame.turn !== g.turn || frame.phase !== g.phase)) ||
|
||||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
|
||||
frame.ambassadors!.some(
|
||||
(entry, index) =>
|
||||
!g.players.some(
|
||||
(seat) =>
|
||||
seat.id === entry.entrant &&
|
||||
(index === 0 || seat.faction === 'beneGesserit'),
|
||||
),
|
||||
)
|
||||
)
|
||||
fail();
|
||||
const unfinished = frame.ambassadors!.find((entry) => !entry.completed);
|
||||
const current = g.pendingAmbassador;
|
||||
const otherTag = tag === 'revivalEvent' ? 'victoryEvent' : 'revivalEvent';
|
||||
if (
|
||||
unfinished &&
|
||||
(frame.stage !== 'arrival' ||
|
||||
!current ||
|
||||
current[tag] !== frame.event ||
|
||||
current[otherTag] !== undefined ||
|
||||
current.event !== unfinished.event ||
|
||||
current.entrant !== unfinished.entrant ||
|
||||
location(current.territory, current.sector) !== unfinished.destination ||
|
||||
current.turn !== frame.turn ||
|
||||
current.phase !== frame.phase)
|
||||
)
|
||||
fail();
|
||||
if (
|
||||
current &&
|
||||
frame.ambassadors!.some(
|
||||
(entry) => entry.event === current.event && entry.completed,
|
||||
)
|
||||
)
|
||||
fail();
|
||||
}
|
||||
Reference in new issue
Block a user