Integrate Imperial Homeworld movement with Ornithopter
This commit is contained in:
1 parent
7f999f4573
commit
97edc3eac8
16 files changed
+1043
-114
No files matched your search
+3
-1
@@ -3944,8 +3944,10 @@ function policyActions(g: GameView): Action[] {
|
||||
actions.push(...skillCardMoves);
|
||||
actions.push(...cardMoves.filter(action => action.sandmaster !== undefined));
|
||||
if (reveal && !actions.length) actions.push(reveal);
|
||||
const imperialMoves = emperorHomeworldMoveActions(g);
|
||||
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
|
||||
actions.unshift(...emperorHomeworldMoveActions(g));
|
||||
actions.unshift(...imperialMoves);
|
||||
else actions.push(...imperialMoves.filter(action => action.movementCard !== undefined || action.ornithopterEvent !== undefined));
|
||||
const activeCunning = nexusGuildCunningActive(g);
|
||||
if (activeCunning?.stage === 'secondShipment' && activeCunning.hajrAvailable &&
|
||||
me.hand?.some(card => card.effect === 'hajr') &&
|
||||
|
||||
+99
-58
@@ -496,6 +496,7 @@ import {
|
||||
type MovementCohort,
|
||||
type OrnithopterMode,
|
||||
} from './ornithopter';
|
||||
import { imperialFlightPools, imperialFlightCohort } from './ornithopter-homeworld';
|
||||
import {
|
||||
eligibleNullentropyCards,
|
||||
resolveNullentropyBox,
|
||||
@@ -1805,7 +1806,7 @@ export type Game = {
|
||||
}
|
||||
| {
|
||||
kind: 'ornithopterDiscard';
|
||||
source: 'move' | 'end' | 'cunning';
|
||||
source: 'move' | 'end' | 'cunning' | 'homeworld';
|
||||
flight: NonNullable<Game['ornithopter']>;
|
||||
movement: CompletedMovement | null;
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
@@ -1873,6 +1874,8 @@ export type Game = {
|
||||
startingMove: number;
|
||||
completed: number;
|
||||
cohort?: MovementCohort;
|
||||
/** Fresh Imperial flights snapshot both native Homeworld pools. */
|
||||
homeworldCohort?: true;
|
||||
} | null;
|
||||
pendingNullentropy?: {
|
||||
event: string;
|
||||
@@ -7155,6 +7158,8 @@ function ornithopterDiscardSignature(
|
||||
source: next.source,
|
||||
flight: next.flight,
|
||||
movement: next.movement,
|
||||
...(next.source === 'homeworld' || next.flight.homeworldCohort
|
||||
? {homeworlds:g.homeworlds?.custody} : {}),
|
||||
controls: nullentropyParentSignature(g, next.resume, next.flight.event),
|
||||
active: g.active,
|
||||
order: g.order,
|
||||
@@ -8136,8 +8141,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
Number.isSafeInteger(flight.completed) &&
|
||||
flight.completed >= 0 &&
|
||||
owner.moved === flight.startingMove + flight.completed &&
|
||||
['move', 'end', 'cunning'].includes(c.source) &&
|
||||
(c.source === 'move'
|
||||
['move', 'end', 'cunning', 'homeworld'].includes(c.source) &&
|
||||
(c.source === 'move' || c.source === 'homeworld'
|
||||
? flight.completed === total
|
||||
: flight.completed < total) &&
|
||||
batch.cause === `ornithopter:${c.source}` &&
|
||||
@@ -8184,6 +8189,9 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'The ended Ornithopter must resume its original Guild Cunning response.');
|
||||
validateGuildCunningResponse(g, c.resume.response);
|
||||
}
|
||||
if (c.source === 'homeworld') requireRule(owner.faction === 'emperor' && g.advanced &&
|
||||
!!g.homeworlds?.custody && owner.shipped && !g.pendingArrivalOverlap,
|
||||
'The completed Imperial Ornithopter needs its native Homeworld movement.');
|
||||
requireRule(
|
||||
c.stateSignature === ornithopterDiscardSignature(g, c),
|
||||
'The retired Ornithopter no longer matches its saved movement obligations.',
|
||||
@@ -8743,7 +8751,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'winnerMandatoryDiscard' &&
|
||||
continuation.kind !== 'battleResolved' &&
|
||||
!(continuation.kind === 'ornithopterDiscard' &&
|
||||
(continuation.source === 'end' || continuation.source === 'cunning' ||
|
||||
(continuation.source === 'end' || continuation.source === 'cunning' || continuation.source === 'homeworld' ||
|
||||
(continuation.source === 'move' &&
|
||||
(!g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') ||
|
||||
bareBasicSemutaProfile(g)) &&
|
||||
@@ -18441,8 +18449,6 @@ function emperorHomeworldMoveBlock(g: Game, p: Player): string | null {
|
||||
g.battle
|
||||
)
|
||||
return 'Finish the current interaction before moving between Homeworlds.';
|
||||
if (g.ornithopter)
|
||||
return 'Homeworld transfers during an active Ornithopter card await their movement-group integration.';
|
||||
const richese = byFaction(g, 'richese');
|
||||
if (
|
||||
richese &&
|
||||
@@ -18516,6 +18522,8 @@ function ornithopterIntegrity(g: Game) {
|
||||
flight.completed < (flight.mode === 'twoGroups' ? 2 : 1) &&
|
||||
owner.moved === flight.startingMove + flight.completed &&
|
||||
richeseCardDefinition(flight.card)?.card.effect === 'ornithopter' &&
|
||||
(flight.homeworldCohort === undefined || flight.homeworldCohort === true &&
|
||||
owner.faction === 'emperor' && g.advanced && !!g.homeworlds?.custody) &&
|
||||
![
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...g.deck,
|
||||
@@ -18542,7 +18550,7 @@ function ornithopterIntegrity(g: Game) {
|
||||
}
|
||||
function finishOrnithopter(
|
||||
g: Game,
|
||||
source: 'move' | 'end' | 'cunning',
|
||||
source: 'move' | 'end' | 'cunning' | 'homeworld',
|
||||
movement: CompletedMovement | null = null,
|
||||
) {
|
||||
const flight = g.ornithopter;
|
||||
@@ -18589,6 +18597,63 @@ function finishOrnithopter(
|
||||
continuation,
|
||||
);
|
||||
}
|
||||
function declareOrnithopterMovement(g: Game, p: Player, action: Action) {
|
||||
const id = p.id;
|
||||
let flight = g.ornithopter?.player === id ? g.ornithopter : null;
|
||||
if (action.movementCard !== undefined) {
|
||||
const mode = action.ornithopter;
|
||||
requireRule(
|
||||
mode === 'range3' || mode === 'twoGroups',
|
||||
'Choose an Ornithopter movement mode.',
|
||||
);
|
||||
const blocked = ornithopterBlock(g, p);
|
||||
requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.');
|
||||
const card = p.hand.find((c) => c.id === action.movementCard);
|
||||
requireRule(
|
||||
card && richeseCardDefinition(card)?.card.effect === 'ornithopter',
|
||||
'Choose the canonical Ornithopter in your hand.',
|
||||
);
|
||||
const reserved = transferCardBlock(g, p, card);
|
||||
requireRule(!reserved, reserved ?? 'The card is committed.');
|
||||
requireRule(
|
||||
action.ornithopterEvent === undefined,
|
||||
'A new card movement cannot name an old event.',
|
||||
);
|
||||
flight = {
|
||||
event: crypto.randomUUID(),
|
||||
player: id,
|
||||
card,
|
||||
turn: g.turn,
|
||||
mode,
|
||||
startingMove: p.moved,
|
||||
completed: 0,
|
||||
...(p.faction === 'emperor' && g.advanced && g.homeworlds?.custody
|
||||
? {homeworldCohort:true as const} : {}),
|
||||
};
|
||||
g.ornithopter = flight;
|
||||
p.hand = p.hand.filter((c) => c.id !== card.id);
|
||||
log(
|
||||
g,
|
||||
`${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`,
|
||||
);
|
||||
} else if (flight)
|
||||
requireRule(
|
||||
action.ornithopterEvent === flight.event,
|
||||
'Use the current Ornithopter movement event.',
|
||||
);
|
||||
else
|
||||
requireRule(
|
||||
action.ornithopterEvent === undefined &&
|
||||
action.ornithopter === undefined,
|
||||
'No matching Ornithopter movement is active.',
|
||||
);
|
||||
return flight;
|
||||
}
|
||||
function ornithopterMovementPools(g: Game, p: Player) {
|
||||
return p.faction === 'emperor' && g.advanced && g.homeworlds?.custody
|
||||
? noFieldRule(() => imperialFlightPools(p.forces,p.elites?.forces ?? {},homeworldTable(g)!,p.id))
|
||||
: {forces:p.forces,elites:p.elites?.forces ?? {}};
|
||||
}
|
||||
function ornithopterView(g: Game, p: Player) {
|
||||
const flight = g.ornithopter?.player === p.id ? g.ornithopter : null;
|
||||
const card =
|
||||
@@ -18614,6 +18679,7 @@ function ornithopterView(g: Game, p: Player) {
|
||||
completed: flight.completed,
|
||||
remaining: (flight.mode === 'twoGroups' ? 2 : 1) - flight.completed,
|
||||
cohort: flight.cohort,
|
||||
...(flight.homeworldCohort ? {homeworldCohort:true as const} : {}),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
@@ -21227,52 +21293,7 @@ function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = f
|
||||
player: id, group, eliteGroup, elite, origin, origins, total: n,
|
||||
to, sector: s, advisors, wantsFighters, lockedTurn,
|
||||
});
|
||||
let flight = g.ornithopter?.player === id ? g.ornithopter : null;
|
||||
if (action.movementCard !== undefined) {
|
||||
const mode = action.ornithopter;
|
||||
requireRule(
|
||||
mode === 'range3' || mode === 'twoGroups',
|
||||
'Choose an Ornithopter movement mode.',
|
||||
);
|
||||
const blocked = ornithopterBlock(g, p);
|
||||
requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.');
|
||||
const card = p.hand.find((c) => c.id === action.movementCard);
|
||||
requireRule(
|
||||
card && richeseCardDefinition(card)?.card.effect === 'ornithopter',
|
||||
'Choose the canonical Ornithopter in your hand.',
|
||||
);
|
||||
const reserved = transferCardBlock(g, p, card);
|
||||
requireRule(!reserved, reserved ?? 'The card is committed.');
|
||||
requireRule(
|
||||
action.ornithopterEvent === undefined,
|
||||
'A new card movement cannot name an old event.',
|
||||
);
|
||||
flight = {
|
||||
event: crypto.randomUUID(),
|
||||
player: id,
|
||||
card,
|
||||
turn: g.turn,
|
||||
mode,
|
||||
startingMove: p.moved,
|
||||
completed: 0,
|
||||
};
|
||||
g.ornithopter = flight;
|
||||
p.hand = p.hand.filter((c) => c.id !== card.id);
|
||||
log(
|
||||
g,
|
||||
`${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`,
|
||||
);
|
||||
} else if (flight)
|
||||
requireRule(
|
||||
action.ornithopterEvent === flight.event,
|
||||
'Use the current Ornithopter movement event.',
|
||||
);
|
||||
else
|
||||
requireRule(
|
||||
action.ornithopterEvent === undefined &&
|
||||
action.ornithopter === undefined,
|
||||
'No matching Ornithopter movement is active.',
|
||||
);
|
||||
const flight = declareOrnithopterMovement(g,p,action);
|
||||
const move: MovementOrder = {
|
||||
player: id,
|
||||
group,
|
||||
@@ -21387,11 +21408,11 @@ function commitGroundMovementPieces(g: Game, move: MovementOrder) {
|
||||
flight.mode === 'twoGroups' &&
|
||||
flight.completed === 0
|
||||
) {
|
||||
const marker = p.noField?.deployed;
|
||||
const marker = p.noField?.deployed, pools = ornithopterMovementPools(g,p);
|
||||
flight.cohort = noFieldRule(() =>
|
||||
createRemainingCohort(
|
||||
p.forces,
|
||||
p.elites?.forces ?? {},
|
||||
pools.forces,
|
||||
pools.elites,
|
||||
Object.fromEntries(group),
|
||||
eliteGroup,
|
||||
marker && p.noFieldEvent
|
||||
@@ -42482,7 +42503,7 @@ function applyActionInner(
|
||||
requireRule(!blocked, blocked ?? 'Homeworld movement is unavailable.');
|
||||
requireRule(
|
||||
Object.keys(action).every((key) =>
|
||||
['type', 'event', 'origin', 'normal', 'elite'].includes(key),
|
||||
['type', 'event', 'origin', 'normal', 'elite','movementCard','ornithopter','ornithopterEvent'].includes(key),
|
||||
),
|
||||
'Choose only the source Homeworld and physical force types.',
|
||||
);
|
||||
@@ -42510,6 +42531,19 @@ function applyActionInner(
|
||||
},
|
||||
),
|
||||
);
|
||||
const flight = declareOrnithopterMovement(g,p,action);
|
||||
if (flight?.mode === 'twoGroups') {
|
||||
const pools = ornithopterMovementPools(g,p);
|
||||
const selected = {[quote.origin]:quote.forces.normal+quote.forces.elite};
|
||||
const elites = {[quote.origin]:quote.forces.elite};
|
||||
if (flight.completed === 0) {
|
||||
flight.cohort = noFieldRule(() => createRemainingCohort(pools.forces,pools.elites,selected,elites));
|
||||
} else {
|
||||
requireRule(flight.cohort, 'The original unmoved Ornithopter cohort is missing.');
|
||||
const cohort = noFieldRule(() => imperialFlightCohort(flight.cohort!,flight.homeworldCohort === true,homeworldTable(g)!,id));
|
||||
noFieldRule(() => validateCohortSelection(cohort,pools.forces,pools.elites,selected,elites));
|
||||
}
|
||||
}
|
||||
g.homeworlds!.custody = quote.state;
|
||||
observeOccupation(g);
|
||||
finishShipmentPromises(g, p, null);
|
||||
@@ -42527,8 +42561,15 @@ function applyActionInner(
|
||||
.join('; ');
|
||||
log(
|
||||
g,
|
||||
`${p.name} moved ${quote.forces.normal} normal forces and ${quote.forces.elite} Sardaukar from ${name(quote.origin)} to ${name(quote.destination)}. This spends one movement and no spice; total reserves are unchanged. ${shifts}.`,
|
||||
`${p.name} moved ${quote.forces.normal} normal forces and ${quote.forces.elite} Sardaukar from ${name(quote.origin)} to ${name(quote.destination)}. This spends one movement and no spice; total reserves are unchanged. ${shifts}.${flight ? ' Provisional Ornithopter composition uses the independently permitted Imperial route; the card grants no Homeworld route or range.' : ''}`,
|
||||
);
|
||||
if (flight) {
|
||||
flight.completed++;
|
||||
if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
|
||||
if (flight.mode === 'twoGroups') p.ornithopterExtraMoves = (p.ornithopterExtraMoves ?? 0) + 1;
|
||||
finishOrnithopter(g,'homeworld');
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
if (t === 'move') {
|
||||
|
||||
+89
-36
@@ -17,6 +17,9 @@ import {
|
||||
quoteNativeReserveWithdrawal,
|
||||
type NativeReserveSelections,
|
||||
} from './homeworld-native-reserves';
|
||||
import { imperialFlightPools, imperialFlightCohort } from './ornithopter-homeworld';
|
||||
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
|
||||
import { richeseCardDefinition } from './richese-cards';
|
||||
|
||||
/** Reconstitute only the public physical custody accepted by the shared quotes.
|
||||
* Private hands, plans, reserve decks and random state are never consulted. */
|
||||
@@ -150,21 +153,74 @@ export function withNativeShipmentSources(
|
||||
}
|
||||
}
|
||||
|
||||
/** Spend at most one movement restoring a printed high threshold. Salusa has
|
||||
* priority; normal-only returns to Kaitain cannot undo its Sardaukar repair. */
|
||||
export function emperorHomeworldMoveActions(view: GameView): Action[] {
|
||||
/** One exact native World route, optionally declaring or continuing the actual
|
||||
* physical flight. Ornithopter supplies no new route or World range benefit. */
|
||||
export function emperorHomeworldMoveAction(
|
||||
view: GameView,
|
||||
order: EmperorHomeworldMove,
|
||||
mode?: OrnithopterMode,
|
||||
): Action | null {
|
||||
const movement = view.homeworldMove;
|
||||
const me = view.players.find(p => p.id === view.me);
|
||||
const flight = view.ornithopter?.active;
|
||||
if (
|
||||
!movement ||
|
||||
movement.blocked ||
|
||||
movement.remaining <= 0 ||
|
||||
(flight ? flight.remaining <= 0 : movement.remaining <= 0) ||
|
||||
!view.advanced ||
|
||||
view.status !== 'playing' ||
|
||||
view.phase !== 5 ||
|
||||
view.active !== view.me ||
|
||||
view.players.find((p) => p.id === view.me)?.faction !== 'emperor'
|
||||
me?.faction !== 'emperor' || view.response || view.decision || view.truthtrance ||
|
||||
view.phaseOpening || view.automaticContinuationPending || view.nexusTraitors?.pending ||
|
||||
view.roomControl?.paused || view.roomControl?.closed
|
||||
)
|
||||
return [];
|
||||
return null;
|
||||
const physical = publicCustody(view);
|
||||
if (!physical) return null;
|
||||
let envelope: Partial<Action> = {};
|
||||
if (flight) {
|
||||
const needsCohort = flight.mode === 'twoGroups' && flight.completed === 1;
|
||||
if ((needsCohort && !flight.cohort) || !flight.event || (mode !== undefined && mode !== flight.mode) ||
|
||||
richeseCardDefinition(view.ornithopter!.card)?.card.effect !== 'ornithopter') return null;
|
||||
if (needsCohort) {
|
||||
try {
|
||||
const pools = imperialFlightPools(me.forces, me.elites?.forces ?? {}, physical.worlds, me.id);
|
||||
const cohort = imperialFlightCohort(flight.cohort!, flight.homeworldCohort === true, physical.worlds, me.id);
|
||||
validateCohortSelection(cohort, pools.forces, pools.elites,
|
||||
{ [order.origin]: order.forces.normal + order.forces.elite }, { [order.origin]: order.forces.elite });
|
||||
} catch (error) {
|
||||
if (error instanceof RangeError || error instanceof TypeError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
envelope = { ornithopterEvent: flight.event };
|
||||
} else if (mode !== undefined) {
|
||||
const card = view.ornithopter;
|
||||
if (!card || card.blocked || !card.modes.some(option => option.mode === mode && !option.blocked) ||
|
||||
!me.hand?.some(held => held.id === card.card.id && richeseCardDefinition(held)?.card.effect === 'ornithopter'))
|
||||
return null;
|
||||
envelope = { movementCard: card.card.id, ornithopter: mode };
|
||||
}
|
||||
try {
|
||||
quoteEmperorHomeworldMove({ ...physical.context, status: view.status, phase: view.phase,
|
||||
currentPlayer: view.active, movesLeft: Math.max(movement.remaining, flight?.remaining ?? 0) },
|
||||
physical.custody, view.me, order);
|
||||
return { type: 'emperorHomeworldMove', event: movement.event, origin: order.origin,
|
||||
normal: order.forces.normal, elite: order.forces.elite, ...envelope };
|
||||
} catch (error) {
|
||||
if (error instanceof EmperorHomeworldMoveError || error instanceof HomeworldCustodyError)
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the ordinary threshold repair first. Actual card candidates merely
|
||||
* supply legal participation; original typed quotas cap a continuing group. */
|
||||
export function emperorHomeworldMoveActions(view: GameView): Action[] {
|
||||
if (!view.homeworldMove || view.homeworldMove.blocked || !view.advanced ||
|
||||
view.status !== 'playing' || view.phase !== 5 || view.active !== view.me ||
|
||||
view.players.find(p => p.id === view.me)?.faction !== 'emperor') return [];
|
||||
const physical = publicCustody(view);
|
||||
if (!physical) return [];
|
||||
const kaitain = physical.worlds.find(
|
||||
@@ -197,35 +253,32 @@ export function emperorHomeworldMoveActions(view: GameView): Action[] {
|
||||
origin: 'homeworld:emperor:salusa',
|
||||
forces: { normal: kaitainDeficit, elite: 0 },
|
||||
};
|
||||
if (!order) return [];
|
||||
try {
|
||||
quoteEmperorHomeworldMove(
|
||||
{
|
||||
...physical.context,
|
||||
status: view.status,
|
||||
phase: view.phase,
|
||||
currentPlayer: view.active,
|
||||
movesLeft: movement.remaining,
|
||||
},
|
||||
physical.custody,
|
||||
view.me,
|
||||
order,
|
||||
);
|
||||
return [
|
||||
{
|
||||
type: 'emperorHomeworldMove',
|
||||
event: movement.event,
|
||||
origin: order.origin,
|
||||
normal: order.forces.normal,
|
||||
elite: order.forces.elite,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof EmperorHomeworldMoveError ||
|
||||
error instanceof HomeworldCustodyError
|
||||
)
|
||||
return [];
|
||||
throw error;
|
||||
const active = view.ornithopter?.active;
|
||||
const actions: Action[] = [];
|
||||
if (order && !active) {
|
||||
const ordinary = emperorHomeworldMoveAction(view, order);
|
||||
if (ordinary) actions.push(ordinary);
|
||||
}
|
||||
if (!view.ornithopter) return actions;
|
||||
const needsCohort = active?.mode === 'twoGroups' && active.completed === 1;
|
||||
let cohort = needsCohort ? active.cohort : undefined;
|
||||
if (needsCohort && !cohort) return actions;
|
||||
if (cohort) cohort = imperialFlightCohort(cohort, active?.homeworldCohort === true, physical.worlds, view.me);
|
||||
const orders: EmperorHomeworldMove[] = [];
|
||||
for (const home of [salusa, kaitain]) {
|
||||
const group = home.forces[view.me];
|
||||
const normal = cohort ? Math.min(group.normal, (cohort.forces[home.id] ?? 0) - (cohort.elites[home.id] ?? 0)) : group.normal;
|
||||
const elite = cohort ? Math.min(group.elite, cohort.elites[home.id] ?? 0) : group.elite;
|
||||
if (order?.origin === home.id) {
|
||||
const capped = { normal: Math.min(order.forces.normal, normal), elite: Math.min(order.forces.elite, elite) };
|
||||
if (capped.normal + capped.elite > 0) orders.push({ origin: order.origin, forces: capped });
|
||||
}
|
||||
if (normal > 0) orders.push({ origin: home.id as EmperorHomeworldMove['origin'], forces: { normal: 1, elite: 0 } });
|
||||
if (elite > 0) orders.push({ origin: home.id as EmperorHomeworldMove['origin'], forces: { normal: 0, elite: 1 } });
|
||||
}
|
||||
for (const candidate of orders) for (const mode of active ? [undefined] : ['range3', 'twoGroups'] as const) {
|
||||
const action = emperorHomeworldMoveAction(view, candidate, mode);
|
||||
if (action && !actions.some(earlier => JSON.stringify(earlier) === JSON.stringify(action))) actions.push(action);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MovementCohort } from './ornithopter';
|
||||
|
||||
export type ImperialFlightWorld = {
|
||||
id: string;
|
||||
native: string;
|
||||
forces: Record<string, { normal: number; elite: number }>;
|
||||
};
|
||||
const imperialWorlds = ['homeworld:emperor', 'homeworld:emperor:salusa'];
|
||||
|
||||
/** Native World counters come only from their actual typed custody, never the
|
||||
* aggregate reserve total. World IDs are cohort keys without a sector suffix. */
|
||||
export function imperialFlightPools(
|
||||
boardForces: Record<string, number>,
|
||||
boardElites: Record<string, number>,
|
||||
worlds: readonly ImperialFlightWorld[],
|
||||
owner: string,
|
||||
): { forces: Record<string, number>; elites: Record<string, number> } {
|
||||
const forces = { ...boardForces }, elites = { ...boardElites };
|
||||
for (const id of imperialWorlds) {
|
||||
delete forces[id]; delete elites[id];
|
||||
const world = worlds.find(candidate => candidate.id === id && candidate.native === owner);
|
||||
if (!world) continue;
|
||||
const group = world.forces[owner];
|
||||
if (!group || !Number.isSafeInteger(group.normal) || group.normal < 0 ||
|
||||
!Number.isSafeInteger(group.elite) || group.elite < 0 ||
|
||||
!Number.isSafeInteger(group.normal + group.elite))
|
||||
throw new RangeError('Imperial flight needs actual nonnegative typed Homeworld custody.');
|
||||
forces[id] = group.normal + group.elite;
|
||||
elites[id] = group.elite;
|
||||
}
|
||||
return { forces, elites };
|
||||
}
|
||||
|
||||
/** Older flights blocked all World moves, so their then-current World pools
|
||||
* are still unmoved. A fresh aware cohort must retain its depleted quotas. */
|
||||
export function imperialFlightCohort(
|
||||
cohort: MovementCohort,
|
||||
aware: boolean,
|
||||
worlds: readonly ImperialFlightWorld[],
|
||||
owner: string,
|
||||
): MovementCohort {
|
||||
const pools = aware ? { forces: { ...cohort.forces }, elites: { ...cohort.elites } }
|
||||
: imperialFlightPools(cohort.forces, cohort.elites, worlds, owner);
|
||||
return { ...pools, ...(cohort.noField ? { noField: { ...cohort.noField } } : {}) };
|
||||
}
|
||||
+7
-4
@@ -963,6 +963,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Under the provisional composition, Ornithopter replaces one unused ordinary or Hajr movement. A prior move stays spent and cannot become the card’s first group retrospectively. One Hajr play plus a two-group flight permits three group movements; Hajr may reuse a card-moved group. Ending early abandons the unused card group and ends your turn. The single physical Hajr may grant another move after legitimate recovery and a new legal play.',
|
||||
'Advanced Hajr must be played immediately after a completed Force Movement and its required arrival decisions, before your next voluntary game action. A completed card group provisionally supplies this opportunity. Basic retains broader own-turn timing. Kulon leaves the fixed card maximum at three and extends both normal-range groups. Advanced advisors may use both physical card modes. They do not grant city access themselves; two-group mode uses current fighter city access at each move, while range3 is independent. Empty arrivals flip normally, and fresh-advisor locks and original-group limits still apply. After Guild Cunning, the physical card may provisionally replace one earned unused Hajr allowance. Cunning itself adds no ordinary move; an active two-group flight keeps its original remaining group. Declaring Cunning abandons an unfinished earlier flight without extra credit. Wider optional combinations remain unfinished.',
|
||||
'All four AI profiles use their own projected movement choices and original-group limits. They can use a longer route, split distinct groups, finish interrupted movement and end optional movement when no legal second group remains. Complete Richese-game calibration is not yet certified.',
|
||||
'With Advanced Emperor Homeworlds, provisional composition permits a native Kaitain/Salusa transfer as either group, alongside a ground move or another different typed World group. The physical card grants no World route or range; using its one-move mode on the independently allowed transfer is a separate provisional replacement. Current and original normal/Sardaukar quotas prevent reusing arriving counters. Complete two groups for the actual extra-move credit, or finish early without it. One card is discarded; later Hajr remains independent. Use the Imperial movement panel for World groups.',
|
||||
'In supported Basic Ecaz/Moritani tables with Richese, Semuta may recover the completed movement card before the original arrival reaction. The forces and counters stay moved; the original player retains their actual turn. Later Ambassador or Terror discards form separate fresh groups and never repeat the movement.',
|
||||
],
|
||||
related: [
|
||||
@@ -976,25 +977,26 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Each genuinely recovered Hajr replay has its own grant. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Both modes now accept Advanced advisors through native stance/entry and original-group rules. Advisors do not grant city access; current fighter-city range is recomputed for each normal group. Guild Cunning now retains actual earlier credits and permits a physical card to replace an unused Hajr grant, with active-flight and early-end suffixes. New v3 receipts preserve old formats. Wider combinations and broader acceptance remain unfinished.',
|
||||
'Native Imperial World/ground sequences now use original typed pools and one physical disposal; fresh World-aware cohorts distinguish arrived counters, with a recorded legacy inference for older ground-only flights. Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Each genuinely recovered Hajr replay has its own grant. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Both modes now accept Advanced advisors through native stance/entry and original-group rules. Advisors do not grant city access; current fighter-city range is recomputed for each normal group. Guild Cunning now retains actual earlier credits and permits a physical card to replace an unused Hajr grant, with active-flight and early-end suffixes. New v3 receipts preserve old formats. Wider combinations and broader acceptance remain unfinished.',
|
||||
evidence: ['game/ornithopter-homeworld.ts', 'game/homeworld-options.ts', 'game/engine.ts', 'components/homeworld-table.tsx', 'tests/fixture-ornithopter-emperor-world.ts', 'tests/ornithopter-emperor-world-engine.test.ts', 'tests/ornithopter-homeworld-quote.test.ts', 'tests/ornithopter-homeworld-controls.test.tsx', 'docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md'],
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition, Guild Cunning prerequisites/early ending, Advanced timing and advisor/city access; automatic disposal adds no confirmation.',
|
||||
'Imperial controls add actual held-card modes/current continuation, typed unmoved maxima and persistent provisional route/credit guidance. Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition, Guild Cunning prerequisites/early ending, Advanced timing and advisor/city access; automatic disposal adds no confirmation.',
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Four profiles use projected ranges, advisor-aware routes/native entry, distinct group quotas and earned Hajr movement, including post-Cunning card groups. Actual advisor and owned second-group candidates are legal; strategic tuning remains deferred.',
|
||||
'Minimal Imperial candidates use the shared World action adapter and exact typed quotas without strategic refinement. Four profiles use projected ranges, advisor-aware routes/native entry, distinct group quotas and earned Hajr movement, including post-Cunning card groups. Actual advisor and owned second-group candidates are legal; strategic tuning remains deferred.',
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Explains unused entitlement replacement, per-flight distinctness, later Hajr reuse, fixed/normal range, Advanced source timing and explicit unfinished multiple-card/module combinations.',
|
||||
'Imperial composition, source/range boundaries, World-aware and legacy cohorts and distinct no-arrival disposal are explicit. Explains unused entitlement replacement, per-flight distinctness, later Hajr reuse, fixed/normal range, Advanced source timing and explicit unfinished multiple-card/module combinations.',
|
||||
},
|
||||
{
|
||||
area: 'Verification',
|
||||
@@ -5387,6 +5389,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Fresh clean registered World/all14 starts also include original occupation benefit state beside normal Banker. Admission and native role limits remain. Real other-payer Bank payments can earn a deferred skill grant; occupied Collection and phase tokens do not become immediate Banker cash. Actual Mentat settles the original skill receipt once. Nexus/Discovery/Semuta contexts retain their own scope; played games are not retrofitted.',
|
||||
'Existing admitted World starts with Nexus and/or Discoveries also initialize original occupation benefits through fresh setup, optionally with Skills and original phase modules. Occupied Collection, ordinary deposits, nested entry, earned Nexus/Stronghold cards and deferred Banker retain their separate events. Original start/role limits remain; existing occupation history is never replaced and played games are not converted.',
|
||||
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
|
||||
'Provisional physical Ornithopter composition permits independently legal Imperial transfers as first or second groups, mixed with ground movement or another distinct typed World group. Original normal/starred pools prevent counter reuse after merging; fresh World-aware snapshots and the recorded older-flight inference retain the card event. Range3 grants no new World route. Only completed two-group use earns the extra movement credit; one card is discarded, and actual later Hajr is independent.',
|
||||
'Registered selected-family World all14 composes independent36/38/50-card setup and original Tech3+/Advanced Strongholds2+, including mixed E3. Clean bare World cards now use the registered selected-family profile with optional phase modules and original occupation state. Recruits uses current native rates/typed limits and recorded paid/free use; Banker and phase-end Axlotl remain separate. Reinforcements seals exact normal-first sources and excludes an own-World fee from dial/casualties while preserving original face/support. Suk rescues committed losses, not fees. Harass returns actual visitors through native custody and is forbidden on every own World. Original Nexus/Discovery/Semuta and role/effect guards remain.',
|
||||
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
|
||||
'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.',
|
||||
|
||||
Reference in new issue
Block a user