Connect Basic Ixians to the full Leader Skills prototype
Admit genuine Basic Ixian setup with the full fourteen-skill deck, real starting-card choice, optional Tleilaxu/base opponents and first-Storm HMS. Keep public starts, Advanced and other module combinations gated. Share per-origin cyborg movement range across engine, controls and legal bots. Preserve independent Planetologist range after native Karama cancellation. Connect Sandmaster force routes through room-local HMS pointer adjacency. Retain canceled movement's no-op contract while validating actual execution. Resolve Suk rescue before substitution with explicit ambiguous cyborg rescue origins and exact remaining Tank losses. Finish Rihani before native substitution, bind saved physical losses and declared exchange, and resume mandatory/optional winner disposal on allow, decline or cancellation. Admit exact green Ix Thumper, Harvester and Amal as Planetologist substitutes. Validation: types, lint, 5,082 offline tests and production build passed. Five genuine 2-6 player games completed with zero rejected actions and 54 JSON restores. Authenticated in-memory concurrent starting-card recovery, private setup, physical card/force custody and independent review passed. Browser FLGHBSA4 completed real setup, first Storm/HMS placement and a conserved staged Suk battle. Mobile rescue, refresh and one-for-one substitution passed. Screenshot capture timed out; full visual acceptance remains open. All 1,582 prior rooms, 3,350 human seats and auxiliary records were preserved; one new QA room/seat remains. No restart, reset or automation. AI work remains limited to legal participation until every non-AI feature is complete, then targets roughly 75% higher-tier wins for each adjacent pair.
This commit is contained in:
1 parent
d3559f65ad
commit
3efda2128e
28 files changed
+1461
-129
No files matched your search
+8
-12
@@ -5,7 +5,7 @@ import { presenceAt } from './force-presence';
|
||||
import { EcazOccupancyError } from './ecaz-occupy';
|
||||
import {
|
||||
planetologistLeader,
|
||||
planetologistRange,
|
||||
groundMovementRange,
|
||||
type PlanetologistMoveMode,
|
||||
} from './planetologist-movement';
|
||||
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
|
||||
@@ -77,17 +77,13 @@ export function botMovementRange(
|
||||
elite: number,
|
||||
planetologist?: PlanetologistMoveMode,
|
||||
) {
|
||||
const base =
|
||||
fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')
|
||||
? 3
|
||||
: (p.faction === 'fremen' && !p.fremenMovementBlocked) ||
|
||||
(p.faction === 'ixians' && elite > 0 && !p.ixMovementBlocked)
|
||||
? 2
|
||||
: 1;
|
||||
const ordinary = base + (p.faction === 'choam' ? g.choamMovementBonus : 0);
|
||||
return planetologist && planetologistLeader(g, p.id)
|
||||
? planetologistRange(ordinary, planetologist)
|
||||
: ordinary;
|
||||
return groundMovementRange({
|
||||
faction: p.faction,
|
||||
cityOrnithopters: !!(fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')),
|
||||
selectedElites: elite,
|
||||
nativeBlocked: p.faction === 'ixians' ? !!p.ixMovementBlocked : !!p.fremenMovementBlocked,
|
||||
choamBonus: p.faction === 'choam' ? g.choamMovementBonus : 0,
|
||||
}, planetologist && planetologistLeader(g, p.id) ? planetologist : undefined);
|
||||
}
|
||||
export function botGroundMoveAllowed(
|
||||
g: GameView,
|
||||
|
||||
+112
-59
@@ -13,11 +13,11 @@ import {
|
||||
import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
|
||||
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
|
||||
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
|
||||
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
|
||||
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement';
|
||||
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
|
||||
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
|
||||
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle';
|
||||
import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
|
||||
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
|
||||
import { sandmasterWormCollection } from './sandmaster-worm';
|
||||
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
|
||||
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
|
||||
@@ -1419,6 +1419,7 @@ export type Game = {
|
||||
order: MovementOrder;
|
||||
} | null;
|
||||
pendingIxSubstitution?: {
|
||||
receipt?: { event: string; turn: number; physical: string; signature: string };
|
||||
homeworld?: { pool: HomeworldForces; cyborgsLost: number; eliteTanks: number; normalTanks: number; battleLosses: number };
|
||||
player: string;
|
||||
territory: string;
|
||||
@@ -1610,6 +1611,7 @@ export type Game = {
|
||||
cardRolesSignature?: string;
|
||||
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
|
||||
sukRescue?: { signature: string; completed: boolean };
|
||||
ixSubstitution?: { signature: string; completed: boolean };
|
||||
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean; faceDanceStarted?: true };
|
||||
sandmaster?: { leader: string; key: string; before: number; after: number };
|
||||
smugglerCollection?: SmugglerBattleReceipt;
|
||||
@@ -5289,6 +5291,7 @@ function leaderSkillsIntegrity(g: Game) {
|
||||
const noFieldShipment = leaderSkillNoFieldIntegrity(g);
|
||||
sandmasterIntegrity(g);
|
||||
sukRescueIntegrity(g);
|
||||
ixSubstitutionIntegrity(g);
|
||||
rihaniIntegrity(g);
|
||||
smugglerBattleIntegrity(g);
|
||||
for (const [owner, plan] of Object.entries(g.battle?.plans ?? {})) {
|
||||
@@ -9388,8 +9391,9 @@ function sandmasterIntegrity(g: Game) {
|
||||
for (const other of [g.pendingIxMove, g.pendingChoamMove])
|
||||
if (other?.sandmaster !== undefined) validateSandmasterOrder(g, getPlayer(g, other.player), other);
|
||||
}
|
||||
function movementOrderRange(g: Game, p: Player, move: MovementOrder) {
|
||||
return move.discoveryFlight || move.ornithopterRange ? 3 : planetologistRange(movementRange(g, p, move.elite), move.planetologist?.mode);
|
||||
function movementOrderRange(g: Game, p: Player, move: MovementOrder, origin = move.origin) {
|
||||
return move.discoveryFlight || move.ornithopterRange ? 3 : planetologistRange(
|
||||
movementRange(g, p, selectedOriginElites(move.group, move.eliteGroup, origin)), move.planetologist?.mode);
|
||||
}
|
||||
function validatePlanetologistMove(g: Game, p: Player, move: Pick<MovementOrder, 'planetologist' | 'origins' | 'origin' | 'noField' | 'ornithopterEvent' | 'discoveryFlight'>) {
|
||||
const skill = move.planetologist;
|
||||
@@ -9457,28 +9461,14 @@ function validateFlightSelection(g: Game, p: Player, move: MovementOrder) {
|
||||
);
|
||||
}
|
||||
function movementRange(g: Game, p: Player, elite: number) {
|
||||
const base =
|
||||
fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')
|
||||
? 3
|
||||
: (p.faction === 'fremen' &&
|
||||
!(
|
||||
p.fremenMovementBlocked?.turn === g.turn &&
|
||||
p.fremenMovementBlocked.move === p.moved
|
||||
)) ||
|
||||
(p.faction === 'ixians' &&
|
||||
elite > 0 &&
|
||||
!(
|
||||
p.ixMovementBlocked?.turn === g.turn &&
|
||||
p.ixMovementBlocked.move === p.moved
|
||||
))
|
||||
? 2
|
||||
: 1;
|
||||
return (
|
||||
base +
|
||||
(p.faction === 'choam' && g.choamMovement?.turn === g.turn
|
||||
? g.choamMovement.bonus
|
||||
: 0)
|
||||
);
|
||||
const blocked = p.faction === 'ixians' ? p.ixMovementBlocked : p.fremenMovementBlocked;
|
||||
return groundMovementRange({
|
||||
faction: p.faction,
|
||||
cityOrnithopters: !!(fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')),
|
||||
selectedElites: elite,
|
||||
nativeBlocked: blocked?.turn === g.turn && blocked.move === p.moved,
|
||||
choamBonus: p.faction === 'choam' && g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0,
|
||||
});
|
||||
}
|
||||
function balisetPrevents(g: Game, player: string, origin: string, to: string) {
|
||||
const choam = byFaction(g, 'choam');
|
||||
@@ -9598,7 +9588,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
(key) =>
|
||||
gameDistance(g, key, location(move.to, move.sector), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
|
||||
) <= movementOrderRange(g, p, move),
|
||||
) <= movementOrderRange(g, p, move, splitLocation(key).territory),
|
||||
),
|
||||
'The movement route is no longer available.',
|
||||
);
|
||||
@@ -15982,6 +15972,7 @@ function resolveBattle(g: Game) {
|
||||
territory: b.territory, skill: quote.sukGraduate,
|
||||
commitment: structuredClone(casualtyCommitment), pool: sukForcePool(winner, b.territory),
|
||||
cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner), losses: null, signature: '',
|
||||
...(winner.faction === 'ixians' ? { eliteOrigins: true as const } : {}),
|
||||
};
|
||||
updateSukReceipt(g);
|
||||
}
|
||||
@@ -16098,7 +16089,7 @@ function settleWinnerCasualties(
|
||||
'Choose the original Suk Graduate casualty allocation.');
|
||||
pending.losses = { ...choice };
|
||||
updateSukReceipt(g);
|
||||
const options = sukRescueOptions(pending.skill, pending.pool, choice);
|
||||
const options = sukRescueOptions(pending.skill, pending.pool, choice, pending.eliteOrigins);
|
||||
if (options.length === 1) settleSukRescue(g, options[0], true);
|
||||
else g.decision = { kind: 'sukRescue', player: p.id, event: pending.event,
|
||||
territory: to, mode: pending.skill.mode, options };
|
||||
@@ -16121,26 +16112,70 @@ function settleWinnerCasualties(
|
||||
`${p.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${combatLocationName(g, to)} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`,
|
||||
automatic ? { faction: p.faction, name: 'Battle casualties' } : undefined,
|
||||
);
|
||||
stageIxSubstitution(g, p, to, cards, losses);
|
||||
finishWinner(g, p, to, cards);
|
||||
}
|
||||
/** Stage faction substitution after actual Tank losses; skills finish before it opens. */
|
||||
function stageIxSubstitution(g: Game, p: Player, to: string, cards: string[], losses: Record<string, number>) {
|
||||
const cyborgsLost = Object.values(losses).reduce((sum, count) => sum + count, 0);
|
||||
const survivingSuboids = to.startsWith('homeworld:') ? combatArmy(g, p.id, to).normal : Object.entries(p.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === to)
|
||||
.reduce(
|
||||
(sum, [key, count]) => sum + count - (p.elites?.forces[key] ?? 0),
|
||||
0,
|
||||
);
|
||||
if (p.faction === 'ixians' && choice.elite > 0 && survivingSuboids > 0) {
|
||||
if (p.faction === 'ixians' && cyborgsLost > 0 && survivingSuboids > 0) {
|
||||
g.pendingIxSubstitution = { player: p.id, territory: to, losses, cards,
|
||||
...(to.startsWith('homeworld:') ? { homeworld: {
|
||||
pool: combatArmy(g, p.id, to), cyborgsLost: choice.elite,
|
||||
pool: combatArmy(g, p.id, to), cyborgsLost,
|
||||
eliteTanks: p.elites!.tanks, normalTanks: p.tanks - p.elites!.tanks, battleLosses: p.battleLosses,
|
||||
} } : {}),
|
||||
};
|
||||
g.decision = {
|
||||
kind: 'ixSubstitution',
|
||||
player: p.id,
|
||||
territory: to,
|
||||
losses,
|
||||
};
|
||||
} else finishWinner(g, p, to, cards);
|
||||
if (g.leaderSkills) {
|
||||
const pending = g.pendingIxSubstitution;
|
||||
pending.receipt = { event: g.lastBattleContext!.event, turn: g.turn, physical: sukPhysicalSignature(p), signature: '' };
|
||||
pending.receipt.signature = ixSubstitutionSignature(pending);
|
||||
g.lastBattleContext!.ixSubstitution = { signature: pending.receipt.signature, completed: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
function ixSubstitutionSignature(pending: NonNullable<Game['pendingIxSubstitution']>) {
|
||||
return JSON.stringify([pending.player, pending.territory, pending.losses, pending.cards,
|
||||
pending.receipt?.event, pending.receipt?.turn, pending.receipt?.physical,
|
||||
...(pending.sources === undefined ? [] : [pending.sources, pending.recover])]);
|
||||
}
|
||||
function ixSubstitutionIntegrity(g: Game) {
|
||||
const pending = g.pendingIxSubstitution, context = g.lastBattleContext, obligation = context?.ixSubstitution;
|
||||
if (!pending?.receipt && !obligation) {
|
||||
requireRule(!pending || !g.leaderSkills, 'The Ixian skill aftermath lost its substitution receipt.');
|
||||
return;
|
||||
}
|
||||
if (obligation?.completed) {
|
||||
requireRule(!pending, 'The completed Ixian substitution cannot be replayed.');
|
||||
return;
|
||||
}
|
||||
const owner = pending && g.players.find(p => p.id === pending.player);
|
||||
requireRule(pending?.receipt && owner?.faction === 'ixians' && obligation && !obligation.completed &&
|
||||
context.event === pending.receipt.event && context.turn === pending.receipt.turn && g.turn === context.turn &&
|
||||
context.winner === pending.player && context.territory === pending.territory &&
|
||||
g.phase === 6 && !g.battle && g.status === 'playing' &&
|
||||
pending.receipt.signature === obligation.signature && pending.receipt.signature === ixSubstitutionSignature(pending) &&
|
||||
pending.receipt.physical === sukPhysicalSignature(owner) &&
|
||||
pending.cards.every(id => owner.hand.some(card => card.id === id)),
|
||||
'The saved Ixian substitution changed its original battle losses, cards or physical counters.');
|
||||
const decisions = homeworldSavedDecisions(g);
|
||||
const selecting = decisions.filter(d => d.kind === 'ixSubstitution');
|
||||
const exchangeResponse = g.response?.kind === 'ixSubstitution' || g.pendingKarama?.use.kind === 'cancel' &&
|
||||
g.pendingKarama.use.response.kind === 'ixSubstitution';
|
||||
requireRule(selecting.every(d => d.player === pending.player && d.territory === pending.territory &&
|
||||
JSON.stringify(d.losses) === JSON.stringify(pending.losses)) &&
|
||||
(exchangeResponse ? !!pending.sources && !!pending.recover : !pending.sources && !pending.recover) &&
|
||||
(exchangeResponse || selecting.length === 1 || decisions.some(d => d.kind === 'rihani' && d.player === pending.player)),
|
||||
'The saved Ixian substitution lost its original choice or preceding skill exchange.');
|
||||
}
|
||||
function completeIxSubstitution(g: Game) {
|
||||
g.pendingIxSubstitution = null;
|
||||
if (g.lastBattleContext?.ixSubstitution) g.lastBattleContext.ixSubstitution.completed = true;
|
||||
}
|
||||
function sukForcePool(p: Player, territory: string): SukForceGroup[] {
|
||||
return Object.entries(p.forces).filter(([key]) => splitLocation(key).territory === territory)
|
||||
@@ -16171,6 +16206,7 @@ function sukRescueIntegrity(g: Game) {
|
||||
context.sukRescue?.signature === pending.signature && !context.sukRescue.completed &&
|
||||
pending.signature === sukReceiptSignature(pending) &&
|
||||
pending.physical === sukPhysicalSignature(player) &&
|
||||
(pending.eliteOrigins === undefined || pending.eliteOrigins === true && player.faction === 'ixians') &&
|
||||
JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(player, pending.territory)) &&
|
||||
pending.commitment.forces.normal === pending.pool.reduce((sum, group) => sum + group.normal, 0) &&
|
||||
pending.commitment.forces.elite === pending.pool.reduce((sum, group) => sum + group.elite, 0) &&
|
||||
@@ -16181,7 +16217,7 @@ function sukRescueIntegrity(g: Game) {
|
||||
requireRule(pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(pending.losses)) &&
|
||||
decisions.length === 1 && decisions.every((d) => d.player === pending.player && d.event === pending.event &&
|
||||
d.territory === pending.territory && d.mode === pending.skill.mode &&
|
||||
JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!))),
|
||||
JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!, pending.eliteOrigins))),
|
||||
'The saved Suk Graduate rescue choices differ from the committed casualties.');
|
||||
} else {
|
||||
const lossDecision = homeworldSavedDecisions(g).find((d) => d.kind === 'battleLosses' && d.player === pending.player);
|
||||
@@ -16199,7 +16235,7 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
|
||||
const player = getPlayer(g, pending.player);
|
||||
requireRule(pending.losses && pending.physical === sukPhysicalSignature(player),
|
||||
'The Suk Graduate rescue no longer has its committed physical counters.');
|
||||
const quote = quoteSukRescue(pending.skill, pending.pool, pending.losses, option);
|
||||
const quote = quoteSukRescue(pending.skill, pending.pool, pending.losses, option, pending.eliteOrigins);
|
||||
for (const group of quote.removed) {
|
||||
player.forces[group.key] -= group.normal + group.elite;
|
||||
if (!player.forces[group.key]) delete player.forces[group.key];
|
||||
@@ -16219,6 +16255,7 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
|
||||
g.lastBattleContext!.sukRescue!.completed = true;
|
||||
log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${option.kept ? `1 remained in sector ${splitLocation(option.kept.key).sector}` : 'none remained in the battle territory'}, ${quote.reserves.normal + quote.reserves.elite} returned to reserves, and ${quote.tanks.normal + quote.tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' });
|
||||
observeOccupation(g);
|
||||
if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, quote.eliteTanks!);
|
||||
finishWinner(g, player, pending.territory, pending.cards);
|
||||
}
|
||||
function winnerDiscardSignature(pending: NonNullable<Game['pendingWinnerDiscards']>) {
|
||||
@@ -16372,6 +16409,14 @@ function projectedRihani(g: Game, owner: string) {
|
||||
}
|
||||
function finishWinner(g: Game, winner: Player, t: string, cards: string[]) {
|
||||
if (startRihaniVictory(g, winner, t, cards)) return;
|
||||
if (g.pendingIxSubstitution) {
|
||||
const pending = g.pendingIxSubstitution;
|
||||
requireRule(pending.player === winner.id && pending.territory === t &&
|
||||
JSON.stringify(pending.cards) === JSON.stringify(cards) && !pending.sources && !pending.recover,
|
||||
'Resume the original Ixian substitution after Leader Skills.');
|
||||
g.decision = { kind: 'ixSubstitution', player: winner.id, territory: t, losses: pending.losses };
|
||||
return;
|
||||
}
|
||||
if (g.pendingWinnerDiscards) {
|
||||
winnerDiscardsIntegrity(g);
|
||||
const pending = g.pendingWinnerDiscards;
|
||||
@@ -17923,6 +17968,9 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
} else if (response.kind === 'ixMovement') {
|
||||
const quote = canceled ? movementCancellationQuote(g, response) : null;
|
||||
const move = g.pendingIxMove!;
|
||||
// Cancellation only releases the declaration; its uncommitted army or route
|
||||
// need not remain executable. An allowed move must still be legal now.
|
||||
if (!canceled) validateMovementOrder(g, getPlayer(g, move.player), move);
|
||||
g.pendingIxMove = null;
|
||||
if (canceled) {
|
||||
const player = getPlayer(g, move.player);
|
||||
@@ -17933,13 +17981,11 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
player.ixMovementBlocked = { turn: quote.turn, move: quote.move };
|
||||
log(
|
||||
g,
|
||||
`${player.name}'s cyborg movement advantage was canceled. A one-territory move remains available.`,
|
||||
`${player.name}'s cyborg movement advantage was canceled for this move. Forces remain in place and no movement was spent. Independent ornithopters and Planetologist still apply to a legal replacement.`,
|
||||
);
|
||||
} else offerChoamMovement(g, move);
|
||||
} else if (response.kind === 'ixSubstitution') {
|
||||
const quote = canceled
|
||||
? ixSubstitutionCancellationQuote(g, response)!
|
||||
: null;
|
||||
ixSubstitutionCancellationQuote(g, response);
|
||||
const pending = g.pendingIxSubstitution!;
|
||||
const player = getPlayer(g, pending.player);
|
||||
if (!canceled && pending.homeworld) {
|
||||
@@ -17968,15 +18014,14 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
);
|
||||
}
|
||||
observeOccupation(g);
|
||||
g.pendingIxSubstitution = null;
|
||||
if (quote) {
|
||||
completeIxSubstitution(g);
|
||||
if (canceled) {
|
||||
log(
|
||||
g,
|
||||
'Karama prevented Ixian substitution. Original cyborg casualties remain in the Tanks; no surviving suboids were exchanged.',
|
||||
);
|
||||
if (quote.decision) g.decision = quote.decision;
|
||||
else finishBattle(g);
|
||||
} else finishWinner(g, player, pending.territory, pending.cards);
|
||||
}
|
||||
finishWinner(g, player, pending.territory, pending.cards);
|
||||
} else if (
|
||||
[
|
||||
'choamRevival',
|
||||
@@ -21757,7 +21802,7 @@ function applyActionInner(
|
||||
} else if (decision.kind === 'ixSubstitution') {
|
||||
const pending = g.pendingIxSubstitution!;
|
||||
if (action.decline === true) {
|
||||
g.pendingIxSubstitution = null;
|
||||
completeIxSubstitution(g);
|
||||
finishWinner(g, p, pending.territory, pending.cards);
|
||||
} else {
|
||||
const parse = (input: unknown, available: Record<string, number>) => {
|
||||
@@ -21795,6 +21840,10 @@ function applyActionInner(
|
||||
sum(pending.sources) === sum(pending.recover),
|
||||
'Exchange one surviving suboid for each cyborg lost in this battle.',
|
||||
);
|
||||
if (pending.receipt) {
|
||||
pending.receipt.signature = ixSubstitutionSignature(pending);
|
||||
g.lastBattleContext!.ixSubstitution!.signature = pending.receipt.signature;
|
||||
}
|
||||
g.response = { kind: 'ixSubstitution', owner: id, passed: [] };
|
||||
}
|
||||
} else if (decision.kind === 'revivalStop') {
|
||||
@@ -23951,22 +24000,26 @@ function applyActionInner(
|
||||
(key) =>
|
||||
gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
|
||||
) <= speed,
|
||||
) <= movementOrderRange(g, p, move, splitLocation(key).territory),
|
||||
),
|
||||
`That destination is blocked or more than ${speed} territories away.`,
|
||||
planetologist?.mode === 'gather'
|
||||
? 'Each selected origin must have an unblocked route within its own movement range.'
|
||||
: `That destination is blocked or more than ${speed} territories away.`,
|
||||
);
|
||||
validateFlightSelection(g, p, move);
|
||||
if (
|
||||
!move.ornithopterRange &&
|
||||
!move.ornithopterRange && !move.discoveryFlight &&
|
||||
p.faction === 'ixians' &&
|
||||
elite > 0 &&
|
||||
speed === 2 &&
|
||||
group.some(
|
||||
([key]) =>
|
||||
!(p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved) &&
|
||||
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
|
||||
(move.sandmaster
|
||||
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
|
||||
: group.some(([key]) =>
|
||||
selectedOriginElites(group, eliteGroup, splitLocation(key).territory) > 0 &&
|
||||
gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, origin)),
|
||||
) > 1,
|
||||
)
|
||||
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
|
||||
) > planetologistRange(1, planetologist?.mode)))
|
||||
) {
|
||||
g.pendingIxMove = move;
|
||||
g.response = {
|
||||
@@ -23983,7 +24036,7 @@ function applyActionInner(
|
||||
!(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
|
||||
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
|
||||
(move.sandmaster
|
||||
? Object.values(move.sandmaster.routes).some(route => mobileRouteDistance(route) > 1)
|
||||
? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
|
||||
: group.some(
|
||||
([key]) => gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, origin))) > planetologistRange(1, planetologist?.mode)))
|
||||
|
||||
@@ -60,14 +60,20 @@ export const PLANETOLOGIST_BASE_SPECIALS = [
|
||||
{ id: 'treachery-32', name: 'Truthtrance', effect: 'truthtrance' },
|
||||
] as const;
|
||||
|
||||
export const PLANETOLOGIST_IX_SPECIALS = [
|
||||
{ id: 'ix-thumper', name: 'Thumper', effect: 'thumper' },
|
||||
{ id: 'ix-harvester', name: 'Harvester', effect: 'harvester' },
|
||||
{ id: 'ix-amal', name: 'Amal', effect: 'amal' },
|
||||
] as const;
|
||||
|
||||
const planetologistBaseSpecialById = new Map<
|
||||
string,
|
||||
(typeof PLANETOLOGIST_BASE_SPECIALS)[number]
|
||||
(typeof PLANETOLOGIST_BASE_SPECIALS)[number] | (typeof PLANETOLOGIST_IX_SPECIALS)[number]
|
||||
>(
|
||||
PLANETOLOGIST_BASE_SPECIALS.map((card) => [card.id, card]),
|
||||
[...PLANETOLOGIST_BASE_SPECIALS, ...PLANETOLOGIST_IX_SPECIALS].map((card) => [card.id, card]),
|
||||
);
|
||||
|
||||
/** Shared engine/UI/bot eligibility: exact printed base component, no native use. */
|
||||
/** Shared engine/UI/bot eligibility: exact printed green component, no native use. */
|
||||
export function isPlanetologistBattleSpecialCard(
|
||||
card: Pick<Card, 'id' | 'name' | 'kind' | 'effect'> | undefined,
|
||||
): boolean {
|
||||
|
||||
@@ -37,8 +37,18 @@ export function basicTleilaxuLeaderSkillsProfile(game: LeaderSkillProfile): bool
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
|
||||
}
|
||||
|
||||
/** Basic Ixians, optionally with native Tleilaxu, keep the ordinary Ix inventories. */
|
||||
export function basicIxLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return game.advanced === false && game.expansions.length === 1 &&
|
||||
game.expansions[0] === 'ix' && noOtherLeaderSkillModules(game) &&
|
||||
!!game.players?.some(player => player.faction === 'ixians') &&
|
||||
game.players.every(player => ['ixians', 'tleilaxu'].includes(player.faction) ||
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
|
||||
}
|
||||
|
||||
export function basicExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game);
|
||||
return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game) ||
|
||||
basicIxLeaderSkillsProfile(game);
|
||||
}
|
||||
|
||||
/** Shared by rule quotes, controls and minimal legal bot participation. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { basicExpansionLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import type { Game, GameView } from './engine';
|
||||
import { splitLocation } from './board';
|
||||
|
||||
export type PlanetologistMoveMode = 'range' | 'gather';
|
||||
export type PlanetologistMovement = {
|
||||
@@ -38,6 +39,33 @@ export function planetologistRange(
|
||||
return mode === 'range' ? Math.min(3, base + 1) : base;
|
||||
}
|
||||
|
||||
/** Cyborgs accompany selected sectors of their own origin, never another origin. */
|
||||
export function selectedOriginElites(
|
||||
group: readonly [string, number][],
|
||||
elites: Readonly<Record<string, number>>,
|
||||
origin: string,
|
||||
): number {
|
||||
return group.reduce((total, [key, count]) => total +
|
||||
(count > 0 && splitLocation(key).territory === origin ? (elites[key] ?? 0) : 0), 0);
|
||||
}
|
||||
|
||||
/** Native movement and the independently retained Planetologist range share one calculation. */
|
||||
export function groundMovementRange(
|
||||
options: {
|
||||
faction: string;
|
||||
cityOrnithopters: boolean;
|
||||
selectedElites: number;
|
||||
nativeBlocked: boolean;
|
||||
choamBonus?: number;
|
||||
},
|
||||
mode?: PlanetologistMoveMode,
|
||||
): number {
|
||||
const base = options.cityOrnithopters ? 3 :
|
||||
!options.nativeBlocked && (options.faction === 'fremen' ||
|
||||
(options.faction === 'ixians' && options.selectedElites > 0)) ? 2 : 1;
|
||||
return planetologistRange(base + (options.choamBonus ?? 0), mode);
|
||||
}
|
||||
|
||||
/** Keep the existing base-roster boundary and add the connected Basic Moritani profile. */
|
||||
export function planetologistMovementModeSupported(game: Game | GameView): boolean {
|
||||
return game.players.every(player => FACTIONS.some(f => f.id === player.faction && f.expansion === 'base')) ||
|
||||
|
||||
+24
-1
@@ -4000,6 +4000,29 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ixian-leader-skills',
|
||||
title: 'Ixians with Leader Skills',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Basic Ixians connect the full skill deck to cyborg movement, the HMS and battle aftermath.',
|
||||
steps: [
|
||||
'The private starting-card choice precedes the full skill deal, Traitors and real first-Storm HMS placement. Basic Ixians can share this development combination with Tleilaxu and base factions; other optional modules and Advanced remain unavailable.',
|
||||
'Planetologist calculates each gathered origin separately using its selected cyborgs. Canceling native cyborg movement leaves the independent skill range available. Sandmaster routes can enter or leave the HMS through its current pointer.',
|
||||
'Suk Graduate rescues finish before substitution. Select the original sectors of rescued cyborgs where needed; only cyborgs actually sent to the Tanks can return through substitution. Rihani also finishes before native substitution.',
|
||||
'Planetologist may substitute the green Thumper, Harvester or Amal in its weapon role. Their normal effects do not execute, and mandatory disposal still follows allowed, declined or canceled substitution.',
|
||||
'Missing skill bands, extra Sandmaster HMS relocation and other combined configurations remain unfinished. This prototype does not certify complete rules or AI strength.',
|
||||
],
|
||||
related: ['leader-skills', 'tleilaxu-leader-skills', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Genuine setup, per-origin range, HMS force routes, actual rescued/casualty custody and ordered battle cleanup are connected.', evidence: ['game/leader-skill-profile.ts', 'game/suk-graduate.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'Private setup, typed movement, room-local routes and explicit rescue-origin choices retain saved decisions.', evidence: ['components/planetologist-movement.tsx', 'components/sandmaster-movement.tsx', 'components/suk-graduate.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing profiles use legal shared ranges and current choice lists. Full strategy and difficulty tuning remain deferred until feature completion.', evidence: ['game/bot-mobility.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Configuration, physical source inventory, source composition and remaining boundaries are recorded.', evidence: ['docs/IX_LEADER_SKILLS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Full-deck setups, controls, corruption/custody, authenticated concurrent recovery and two-through-six-player samples cover this bounded composition.', evidence: ['tests/ix-skills-movement.test.ts', 'tests/ix-skills-battle.test.ts', 'tests/ix-skills-suk.test.ts', 'tests/ix-skills-recovery.test.ts', 'tools/faction-games.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tleilaxu-leader-skills',
|
||||
title: 'Tleilaxu with Leader Skills',
|
||||
@@ -4073,7 +4096,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Other special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
related: ['tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
|
||||
related: ['ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
|
||||
import type { Action, Game, GameView } from './engine';
|
||||
import { GRAPH, location, mobileRouteDistance, splitLocation } from './board';
|
||||
import { GRAPH, location, mobileRouteDistance, splitLocation, MOBILE_LOCATION } from './board';
|
||||
import { isAdvisor } from './advisors';
|
||||
import { strongholdPathBlocked } from './occupancy';
|
||||
import { botMovementRange } from './bot-mobility';
|
||||
@@ -62,7 +62,10 @@ export function sandmasterPathBlocked(
|
||||
key: string,
|
||||
): boolean {
|
||||
const p = g.players.find((p) => p.id === player);
|
||||
if (!p || !Object.hasOwn(GRAPH, key)) return true;
|
||||
const pointer = g.mobileStronghold?.location;
|
||||
if (!p || (!Object.hasOwn(GRAPH, key) && key !== MOBILE_LOCATION)) return true;
|
||||
if (key === MOBILE_LOCATION && (!pointer || !Object.hasOwn(GRAPH, pointer) ||
|
||||
(!!splitLocation(pointer).sector && splitLocation(pointer).sector === g.storm))) return true;
|
||||
const at = splitLocation(key);
|
||||
return (
|
||||
(!!at.sector && at.sector === g.storm) ||
|
||||
@@ -74,6 +77,30 @@ export function sandmasterPathBlocked(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** The HMS interior is a room-local dead end connected only through its pointer. */
|
||||
export function sandmasterAdjacent(g: Game | GameView, key: string): string[] {
|
||||
const pointer = g.mobileStronghold?.location;
|
||||
if (key === MOBILE_LOCATION) return pointer && Object.hasOwn(GRAPH, pointer) ? [pointer] : [];
|
||||
return [...(GRAPH[key] ?? []), ...(pointer === key ? [MOBILE_LOCATION] : [])];
|
||||
}
|
||||
|
||||
/** Ordinary forces may enter or leave HMS; this never relocates its pointer. */
|
||||
export function sandmasterRouteDistance(g: Game | GameView, route: string[]): number {
|
||||
if (!route.includes(MOBILE_LOCATION)) return mobileRouteDistance(route);
|
||||
if (!route.length || route.length > Object.keys(GRAPH).length + 1 ||
|
||||
new Set(route).size !== route.length || route.slice(1, -1).includes(MOBILE_LOCATION)) return Infinity;
|
||||
let steps = 0;
|
||||
for (let index = 0; index < route.length; index++) {
|
||||
const key = route[index];
|
||||
if (key !== MOBILE_LOCATION && !Object.hasOwn(GRAPH, key)) return Infinity;
|
||||
if (key === MOBILE_LOCATION && !sandmasterAdjacent(g, key).length) return Infinity;
|
||||
if (!index) continue;
|
||||
if (!sandmasterAdjacent(g, route[index - 1]).includes(key)) return Infinity;
|
||||
steps += Number(splitLocation(route[index - 1]).territory !== splitLocation(key).territory);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
/** Only crossing a territory boundary enters a new collection opportunity. */
|
||||
export function sandmasterEnteredTerritories(
|
||||
routes: Record<string, string[]>,
|
||||
@@ -140,7 +167,7 @@ export function quoteSandmasterMovement(
|
||||
route.every((k) => typeof k === 'string') &&
|
||||
route[0] === from &&
|
||||
route.at(-1) === location(move.to, move.sector) &&
|
||||
mobileRouteDistance(route) <= move.range &&
|
||||
sandmasterRouteDistance(g, route) <= move.range &&
|
||||
route.every((key) => !sandmasterPathBlocked(g, player, from, key)),
|
||||
'Sandmaster needs a connected route within movement range, outside blocked sectors.',
|
||||
);
|
||||
@@ -198,7 +225,7 @@ export function sandmasterShortestRoute(
|
||||
last = route.at(-1)!;
|
||||
if (cost !== best.get(last)) continue;
|
||||
if (last === to) return route;
|
||||
for (const key of GRAPH[last] ?? []) {
|
||||
for (const key of sandmasterAdjacent(g, last)) {
|
||||
if (sandmasterPathBlocked(g, player, from, key)) continue;
|
||||
const next =
|
||||
cost +
|
||||
|
||||
+35
-4
@@ -8,6 +8,8 @@ export type SukRescueOption = {
|
||||
elite: number;
|
||||
/** The saved counter never leaves its original sector. */
|
||||
kept: { key: string; kind: 'normal' | 'elite' } | null;
|
||||
/** Distinct rescued cyborg origins when later Ixian substitution needs them. */
|
||||
eliteReserves?: Record<string, number>;
|
||||
};
|
||||
export type SukRescueReceipt = {
|
||||
event: string;
|
||||
@@ -20,6 +22,8 @@ export type SukRescueReceipt = {
|
||||
cards: string[];
|
||||
physical: string;
|
||||
losses: Casualties | null;
|
||||
/** New Ixian receipts preserve the actual per-sector cyborg Tank losses. */
|
||||
eliteOrigins?: true;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
@@ -61,7 +65,7 @@ export function sukCasualtyGroups(pool: readonly SukForceGroup[], losses: Casual
|
||||
}
|
||||
|
||||
/** Only the actual dialed counters can be rescued; the two bands never add. */
|
||||
export function sukRescueOptions(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties): SukRescueOption[] {
|
||||
export function sukRescueOptions(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties, eliteOrigins = false): SukRescueOption[] {
|
||||
const groups = sukCasualtyGroups(pool, losses);
|
||||
const total = losses.normal + losses.elite;
|
||||
if (!total) return [];
|
||||
@@ -79,15 +83,42 @@ export function sukRescueOptions(skill: SukGraduateSkill, pool: readonly SukForc
|
||||
options.push({ normal, elite, kept: { key: group.key, kind } });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
if (!eliteOrigins) return options;
|
||||
return options.flatMap(option => {
|
||||
const allocations = rescuedEliteOrigins(groups, option);
|
||||
return allocations.length > 1
|
||||
? allocations.map(eliteReserves => ({ ...option, eliteReserves })) : [option];
|
||||
});
|
||||
}
|
||||
|
||||
/** Enumerate physical reserve rescues, excluding the counter kept on the board. */
|
||||
function rescuedEliteOrigins(groups: readonly SukForceGroup[], option: SukRescueOption): Record<string, number>[] {
|
||||
const remaining = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
|
||||
const result: Record<string, number>[] = [];
|
||||
const visit = (index: number, left: number, selected: Record<string, number>) => {
|
||||
if (index === groups.length) {
|
||||
if (!left) result.push(selected);
|
||||
return;
|
||||
}
|
||||
const group = groups[index];
|
||||
const available = group.elite - (option.kept?.key === group.key && option.kept.kind === 'elite' ? 1 : 0);
|
||||
for (let count = 0; count <= Math.min(left, available); count++)
|
||||
visit(index + 1, left - count, count ? { ...selected, [group.key]: count } : selected);
|
||||
};
|
||||
visit(0, remaining, {});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Quote removals and destinations without touching tanks, reserves or battle-loss counters. */
|
||||
export function quoteSukRescue(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties, option: SukRescueOption) {
|
||||
if (!sukRescueOptions(skill, pool, losses).some((o) => JSON.stringify(o) === JSON.stringify(option)))
|
||||
export function quoteSukRescue(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties, option: SukRescueOption, eliteOrigins = false) {
|
||||
if (!sukRescueOptions(skill, pool, losses, eliteOrigins).some((o) => JSON.stringify(o) === JSON.stringify(option)))
|
||||
throw new Error('Choose an available Suk Graduate rescue.');
|
||||
const groups = sukCasualtyGroups(pool, losses);
|
||||
const rescued = eliteOrigins ? option.eliteReserves ?? rescuedEliteOrigins(groups, option)[0] : null;
|
||||
return {
|
||||
...(rescued ? { eliteTanks: Object.fromEntries(groups.map(group => [group.key,
|
||||
group.elite - (rescued[group.key] ?? 0) - (option.kept?.key === group.key && option.kept.kind === 'elite' ? 1 : 0),
|
||||
]).filter(([, count]) => Number(count) > 0)) as Record<string, number> } : {}),
|
||||
removed: groups.map((g) => ({ ...g,
|
||||
normal: g.normal - (option.kept?.key === g.key && option.kept.kind === 'normal' ? 1 : 0),
|
||||
elite: g.elite - (option.kept?.key === g.key && option.kept.kind === 'elite' ? 1 : 0),
|
||||
|
||||
Reference in new issue
Block a user