Compose Planetologist movement with Ornithopter flights
This commit is contained in:
1 parent
90c50736f2
commit
4af90e547c
24 files changed
+1607
-50
No files matched your search
@@ -103,6 +103,7 @@ export function botGroundMoveAllowed(
|
||||
to: string,
|
||||
elite: number,
|
||||
planetologist?: PlanetologistMoveMode,
|
||||
fixedRange?: 3,
|
||||
) {
|
||||
const source = splitLocation(from),
|
||||
target = splitLocation(to);
|
||||
@@ -130,7 +131,8 @@ export function botGroundMoveAllowed(
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return groundMoveRouteDistance(g, p, from, to) <= botMovementRange(g, p, elite, planetologist);
|
||||
return groundMoveRouteDistance(g, p, from, to) <=
|
||||
(fixedRange ?? botMovementRange(g, p, elite, planetologist));
|
||||
}
|
||||
/** Fremen reinforcement radius is static territory geometry, not a ground route. */
|
||||
export function fremenReserveEntry(to: string) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import { location, splitLocation } from './board';
|
||||
import { botGroundMoveAllowed } from './bot-mobility';
|
||||
import { planetologistFlightSelection, type PlanetologistFlight } from './planetologist-flight';
|
||||
import { planetologistLeader, planetologistMovementModeSupported,
|
||||
planetologistGatherAdvisorEntry, type PlanetologistMoveMode } from './planetologist-movement';
|
||||
import { validateCohortSelection } from './ornithopter';
|
||||
import { nexusGuildMovementAvailable } from './nexus-guild-cunning-options';
|
||||
|
||||
type Source = { from: string; elite: number; marker: boolean };
|
||||
|
||||
/** Minimal legal participation using only current projected transport and stock.
|
||||
* One counter per origin leaves distinct residual groups where available. */
|
||||
export function botPlanetologistFlightActions(game: GameView,
|
||||
targets: readonly { territory: string; sector: number }[],
|
||||
transports: readonly PlanetologistFlight[]): Action[] {
|
||||
const me = game.players.find(player => player.id === game.me);
|
||||
if (!me || game.status !== 'playing' || game.phase !== 5 || game.active !== me.id ||
|
||||
game.response || game.decision || game.phaseOpening || game.truthtrance ||
|
||||
game.automaticContinuationPending || game.roomControl?.paused || game.roomControl?.closed ||
|
||||
!planetologistLeader(game, me.id) || !planetologistMovementModeSupported(game) ||
|
||||
(!game.ornithopter?.active && !nexusGuildMovementAvailable(game))) return [];
|
||||
const info = game.richeseNoField?.owner === me.id ? game.richeseNoField : null;
|
||||
const marker = info?.private?.deployed;
|
||||
const result: Action[] = [];
|
||||
for (const transport of transports) {
|
||||
const flight = planetologistFlightSelection(game, transport);
|
||||
if (flight.blocked) continue;
|
||||
const sources: Source[] = Object.entries(me.forces).flatMap(([from, total]) => {
|
||||
const elite = me.elites?.forces[from] ?? 0;
|
||||
const availableElite = Math.min(elite, flight.cohort?.elites[from] ?? elite);
|
||||
const availableNormal = Math.min(total - elite, flight.cohort
|
||||
? (flight.cohort.forces[from] ?? 0) - (flight.cohort.elites[from] ?? 0) : total - elite);
|
||||
return availableNormal > 0 ? [{ from, elite: 0, marker: false }]
|
||||
: availableElite > 0 ? [{ from, elite: 1, marker: false }] : [];
|
||||
}).slice(0, 12);
|
||||
if (marker?.controller === me.id && info?.event && !game.homeworldMobility?.noFieldMovementBlocked) {
|
||||
const from = location(marker.location.territory, marker.location.sector);
|
||||
if (!flight.cohort || (flight.cohort.noField?.tokenId === marker.tokenId &&
|
||||
flight.cohort.noField.event === info.event && flight.cohort.noField.from === from))
|
||||
sources.push({ from, elite: 0, marker: true });
|
||||
}
|
||||
for (const mode of ['range', 'gather'] as PlanetologistMoveMode[]) {
|
||||
const groups: Source[][] = mode === 'range' ? sources.map(source => [source])
|
||||
: sources.flatMap((first, index) => sources.slice(index + 1)
|
||||
.filter(second => splitLocation(first.from).territory !== splitLocation(second.from).territory)
|
||||
.map(second => [first, second]));
|
||||
let accepted = 0;
|
||||
choices: for (const target of targets) for (const group of groups) {
|
||||
const to = location(target.territory, target.sector);
|
||||
if (!group.every(source => botGroundMoveAllowed(game, me, source.from, to,
|
||||
source.elite, mode, flight.rangeOverride))) continue;
|
||||
const entry = planetologistGatherAdvisorEntry(game, me,
|
||||
group.map(source => splitLocation(source.from).territory), target.territory);
|
||||
if (entry.blocked) continue;
|
||||
const forces = Object.fromEntries(group.filter(source => !source.marker).map(source => [source.from, 1]));
|
||||
const elites = Object.fromEntries(group.filter(source => !source.marker).map(source => [source.from, source.elite]));
|
||||
const selectedMarker = group.some(source => source.marker) && marker && info?.event
|
||||
? { tokenId: marker.tokenId, event: info.event,
|
||||
from: location(marker.location.territory, marker.location.sector) } : undefined;
|
||||
if (flight.cohort) {
|
||||
try { validateCohortSelection(flight.cohort, me.forces, me.elites?.forces ?? {}, forces, elites, selectedMarker); }
|
||||
catch { continue; }
|
||||
}
|
||||
result.push({ type: 'move', planetologist: mode, forces,
|
||||
...(me.elites ? { eliteForces: elites } : {}),
|
||||
...(selectedMarker ? { noField: selectedMarker.tokenId, event: selectedMarker.event } : {}),
|
||||
...(entry.requiresFlip ? { fighters: true } : {}),
|
||||
...flight.fields, territory: target.territory, sector: target.sector });
|
||||
if (++accepted === 24) break choices;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+11
-5
@@ -104,6 +104,7 @@ import {
|
||||
guildTransportCost,
|
||||
} from './bot-mobility';
|
||||
import { planetologistMovementModeSupported, planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement';
|
||||
import { botPlanetologistFlightActions } from './bot-planetologist-flight';
|
||||
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
|
||||
import { sandmasterWormCollection, sandmasterCunningCollection } from './sandmaster-worm';
|
||||
import { fremenCunningPublicDestinationBlock } from './nexus-fremen-cunning';
|
||||
@@ -3371,11 +3372,13 @@ function policyActions(g: GameView): Action[] {
|
||||
const movementAvailable = nexusGuildMovementAvailable(g);
|
||||
const hajr = nexusGuildHajrAction(g);
|
||||
if (hajr) return [hajr];
|
||||
const cardMoves = ornithopterMoves(g);
|
||||
const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked
|
||||
? discoveryFlightBotActions(g,
|
||||
destinations(g).filter(target => target.score > -1000)
|
||||
.map(target => ({ territory: target.t, sector: target.s })))
|
||||
const flightTargets = destinations(g).filter(target => target.score > -1000)
|
||||
.map(target => ({ territory: target.t, sector: target.s }));
|
||||
const skillCardMoves = botPlanetologistFlightActions(g, flightTargets, ['range3', 'twoGroups']);
|
||||
const cardMoves = [...skillCardMoves, ...ornithopterMoves(g)];
|
||||
const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked && !g.ornithopter?.active
|
||||
? [...botPlanetologistFlightActions(g, flightTargets, ['discovery']),
|
||||
...discoveryFlightBotActions(g, flightTargets)]
|
||||
: [];
|
||||
if (discoveryMoves.length && (me.shipped || me.reserves === 0))
|
||||
return discoveryMoves;
|
||||
@@ -3826,6 +3829,9 @@ function policyActions(g: GameView): Action[] {
|
||||
Number(b.gather) - Number(a.gather),
|
||||
);
|
||||
actions.push(...groundMoves.map((candidate) => candidate.action));
|
||||
// Moving before an unused shipment is legal; retain these candidates after
|
||||
// the existing shipment choices without changing their ordinary priority.
|
||||
actions.push(...skillCardMoves);
|
||||
if (reveal && !actions.length) actions.push(reveal);
|
||||
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
|
||||
actions.unshift(...emperorHomeworldMoveActions(g));
|
||||
|
||||
@@ -3,7 +3,10 @@ import { gameDistance, gameTerritories, location, splitLocation, validGameLocati
|
||||
import { isAdvisor } from './advisors';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { strongholdPathBlocked, territoryEntryBlock } from './occupancy';
|
||||
import { botEntryAllowed, botMovementRange } from './bot-mobility';
|
||||
import { botEntryAllowed, botMovementRange, botGroundMoveAllowed } from './bot-mobility';
|
||||
import { planetologistLeader, planetologistMovementModeSupported, planetologistGatherAdvisorEntry,
|
||||
selectedOriginElites } from './planetologist-movement';
|
||||
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching';
|
||||
import { nexusGuildMovementAvailable } from './nexus-guild-cunning-options';
|
||||
import { validateCohortSelection } from './ornithopter';
|
||||
|
||||
@@ -26,6 +29,12 @@ export function discoveryFlightMove(game: GameView, move: Action | null): Quote
|
||||
held.acquiredTurn !== offer.acquiredTurn || held.acquiredTurn >= game.turn)
|
||||
return fail('This carried token is no longer available.');
|
||||
if (!move || move.type !== 'move') return fail('Select a movement group and destination above.');
|
||||
const skill = move.planetologist;
|
||||
if (skill !== undefined && ((skill !== 'range' && skill !== 'gather') ||
|
||||
!planetologistLeader(game, me.id) || !planetologistMovementModeSupported(game)))
|
||||
return fail('Choose a current living Planetologist and movement alternative.');
|
||||
if (skill && (game.ornithopter?.active || move.movementCard !== undefined || move.ornithopterEvent !== undefined))
|
||||
return fail('Choose one Ornithopter flight source for this Planetologist movement.');
|
||||
if (typeof move.territory !== 'string' || !whole(move.sector) ||
|
||||
!validGameLocation(game, move.territory, move.sector))
|
||||
return fail('Select an available destination and sector.');
|
||||
@@ -36,14 +45,16 @@ export function discoveryFlightMove(game: GameView, move: Action | null): Quote
|
||||
if (!entries) return fail('Select forces from one territory.');
|
||||
const forces: Record<string, number> = {}, elites: Record<string, number> = {};
|
||||
let origin: string | undefined;
|
||||
const origins = new Set<string>();
|
||||
for (const [key, count] of entries) {
|
||||
const source = splitLocation(key);
|
||||
if (!validGameLocation(game, source.territory, source.sector) ||
|
||||
!whole(count) || count > (me.forces[key] ?? 0))
|
||||
return fail('The selected forces are no longer available.');
|
||||
if (!count) continue;
|
||||
if (origin && origin !== source.territory) return fail('Select forces from one territory.');
|
||||
origin = source.territory;
|
||||
origins.add(source.territory);
|
||||
if (origins.size > (skill === 'gather' ? 2 : 1)) return fail('Select forces from one territory, or exactly two with Planetologist gathering.');
|
||||
origin ??= source.territory;
|
||||
const availableElite = me.elites?.forces[key] ?? 0;
|
||||
const selectedElite = move.forces !== undefined
|
||||
? object(move.eliteForces) ? move.eliteForces[key] : undefined : move.elite;
|
||||
@@ -63,12 +74,30 @@ export function discoveryFlightMove(game: GameView, move: Action | null): Quote
|
||||
if (marker === null) return fail('Choose the current deployed No-Field.');
|
||||
if (marker) {
|
||||
const markerOrigin = splitLocation(marker.from).territory;
|
||||
if (origin && origin !== markerOrigin) return fail('The selected No-Field and forces must share a territory.');
|
||||
origin = markerOrigin;
|
||||
origins.add(markerOrigin);
|
||||
if (origins.size > (skill === 'gather' ? 2 : 1)) return fail('The selected No-Field and forces must share a territory.');
|
||||
origin ??= markerOrigin;
|
||||
}
|
||||
if (!origin) return fail('Select at least one force or the deployed No-Field.');
|
||||
const sources = [...Object.keys(forces), ...(marker ? [marker.from] : [])];
|
||||
if (sources.some(key => key === destination)) return fail('Choose a different destination.');
|
||||
if (skill) {
|
||||
if (origins.size !== (skill === 'gather' ? 2 : 1)) return fail('Choose the correct number of Planetologist origins.');
|
||||
if (marker && game.homeworldMobility?.noFieldMovementBlocked)
|
||||
return fail(game.homeworldMobility.noFieldMovementBlocked);
|
||||
const entry = planetologistGatherAdvisorEntry(game, me, [...origins], move.territory);
|
||||
if (entry.blocked) return fail(entry.blocked);
|
||||
if (sources.some(from => !botGroundMoveAllowed(game, me, from, destination,
|
||||
selectedOriginElites(Object.entries(forces), elites, splitLocation(from).territory), skill, 3)))
|
||||
return fail('This route is blocked or more than three territories away.');
|
||||
if (skill === 'gather') {
|
||||
try { quoteAdvisorGatherMatching(game, { owner: me.id, move: me.moved ?? 0,
|
||||
destination: move.territory, sector: move.sector, advisors: isAdvisor(me, move.territory), group: forces }); }
|
||||
catch (error) { if (!(error instanceof AdvisorGatherMatchingError)) throw error; return fail(error.message); }
|
||||
}
|
||||
return { action: { ...move, ...(entry.requiresFlip ? { fighters: true } : {}),
|
||||
discoveryOrnithopter: offer.token }, blocked: null };
|
||||
}
|
||||
if (!botEntryAllowed(game, me, move.territory, move.sector, 'move', origin))
|
||||
return fail('The selected destination is unavailable to this group.');
|
||||
if (origin !== move.territory && game.balisetRestrictions.some(b =>
|
||||
|
||||
@@ -23,6 +23,8 @@ export type DiscoveryFlightMovement = {
|
||||
eliteGroup: Readonly<Record<string, number>>;
|
||||
elite: number;
|
||||
origin: string;
|
||||
origins?: readonly string[];
|
||||
planetologist?: { leader: string; mode: 'range' | 'gather' };
|
||||
total: number;
|
||||
to: string;
|
||||
sector: number;
|
||||
@@ -86,9 +88,19 @@ function selection(move: DiscoveryFlightMovement): string {
|
||||
(move.lockedTurn === undefined || integer(move.lockedTurn)) &&
|
||||
Array.isArray(move.group) && record(move.eliteGroup),
|
||||
'Bind the Discovery Ornithopter to an ordinary declared movement group.');
|
||||
const skill = move.planetologist;
|
||||
requireFlight(skill === undefined || (record(skill) && name(skill.leader) &&
|
||||
(skill.mode === 'range' || skill.mode === 'gather')),
|
||||
'The Discovery flight has an invalid Planetologist selection.');
|
||||
const origins = move.origins ?? [move.origin];
|
||||
requireFlight(skill?.mode === 'gather'
|
||||
? Array.isArray(move.origins) && origins.length === 2 &&
|
||||
origins.every(name) && new Set(origins).size === 2 && origins[0] === move.origin
|
||||
: move.origins === undefined,
|
||||
'A Discovery gather must retain exactly two normalized Planetologist origins.');
|
||||
const group = move.group;
|
||||
requireFlight(group.every(part => Array.isArray(part) && part.length === 2 &&
|
||||
validSource(part[0], move.origin) && integer(part[1]) && part[1] > 0) &&
|
||||
origins.some(origin => validSource(part[0], origin)) && integer(part[1]) && part[1] > 0) &&
|
||||
new Set(group.map(([key]) => key)).size === group.length,
|
||||
'The Discovery flight has invalid or repeated source forces.');
|
||||
requireFlight(Object.entries(move.eliteGroup).every(([key, count]) => integer(count) &&
|
||||
@@ -102,6 +114,9 @@ function selection(move: DiscoveryFlightMovement): string {
|
||||
if (move.noField) requireFlight(record(move.noField) &&
|
||||
name(move.noField.tokenId) && name(move.noField.event) && validSource(move.noField.from, move.origin),
|
||||
'The Discovery flight has lost its selected No-Field marker.');
|
||||
requireFlight(origins.every(origin => group.some(([key]) => validSource(key, origin)) ||
|
||||
(move.noField && validSource(move.noField.from, origin))),
|
||||
'Every Discovery gather origin must supply actual selected forces or its marker.');
|
||||
const destination = `${move.to}:${move.sector}`;
|
||||
requireFlight(group.every(([key]) => key !== destination) && move.noField?.from !== destination,
|
||||
'The Discovery flight must move its selected group to a different location.');
|
||||
@@ -112,6 +127,8 @@ function selection(move: DiscoveryFlightMovement): string {
|
||||
move.elite, move.total, move.to, move.sector, move.advisors, move.wantsFighters,
|
||||
move.lockedTurn ?? null,
|
||||
move.noField ? [move.noField.tokenId, move.noField.event, move.noField.from] : null,
|
||||
// Preserve existing no-skill single-origin receipt signatures byte for byte.
|
||||
...(skill ? [[skill.leader, skill.mode, [...origins]]] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -17364,8 +17364,8 @@ function validatePlanetologistMove(g: Game, p: Player, move: Pick<MovementOrder,
|
||||
'This Planetologist movement no longer has its living, uncaptured skilled leader.');
|
||||
requireRule(planetologistMovementModeSupported(g),
|
||||
'Planetologist movement combined with expansion factions is still being implemented.');
|
||||
requireRule(!move.ornithopterEvent && !move.discoveryFlight,
|
||||
'Planetologist combined with special movement cards is still being implemented.');
|
||||
requireRule(!(move.ornithopterEvent && move.discoveryFlight),
|
||||
'Choose one Ornithopter flight source for this Planetologist movement.');
|
||||
// Selected composition: the concealed marker moves as one force for either
|
||||
// alternative. Gathering retains each origin's ordinary range; no +1 stacks.
|
||||
requireRule(skill.mode === 'gather'
|
||||
@@ -39389,6 +39389,7 @@ export function viewGame(state: Game, id: string) {
|
||||
},
|
||||
expansions: g.expansions,
|
||||
ecazTreachery: !!g.ecazTreachery,
|
||||
discoveryEnabled: g.discoveryEnabled === true,
|
||||
turn: g.turn,
|
||||
phase: g.phase,
|
||||
phaseOpening: g.phaseOpening ? { passed: g.phaseOpening.passed } : null,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import { location, splitLocation, validGameLocation } from './board';
|
||||
import { isDiscoveryTokenId } from './discoveries';
|
||||
import { validateCohortSelection, type MovementCohort } from './ornithopter';
|
||||
|
||||
export type PlanetologistFlight = 'ordinary' | 'range3' | 'twoGroups' | 'discovery';
|
||||
export type PlanetologistFlightSelection = {
|
||||
fields: Partial<Action>;
|
||||
rangeOverride?: 3;
|
||||
cohort?: MovementCohort;
|
||||
blocked: string | null;
|
||||
};
|
||||
const fail = (blocked: string): PlanetologistFlightSelection => ({ fields: {}, blocked });
|
||||
const named = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
/** Select only the current seat's projected physical card or carried token.
|
||||
* Range/path, selected forces and action priority are checked by the consumer. */
|
||||
export function planetologistFlightSelection(game: GameView,
|
||||
flight: PlanetologistFlight = 'ordinary'): PlanetologistFlightSelection {
|
||||
if (!game.players.some(player => player.id === game.me)) return fail('Choose the current movement owner.');
|
||||
if (!['ordinary', 'range3', 'twoGroups', 'discovery'].includes(flight))
|
||||
return fail('Choose an available movement transport.');
|
||||
const card = game.ornithopter;
|
||||
const active = card?.active;
|
||||
if (active && card) {
|
||||
if (card.blocked) return fail(card.blocked);
|
||||
if (flight === 'discovery') return fail('Finish the current physical Ornithopter before using Discovery flight.');
|
||||
if (flight !== 'ordinary' && flight !== active.mode)
|
||||
return fail('Continue the current Ornithopter mode before selecting another transport.');
|
||||
const total = active.mode === 'twoGroups' ? 2 : active.mode === 'range3' ? 1 : 0;
|
||||
const played = game.playedOrnithopter;
|
||||
if (!named(card?.card?.id) || !named(active.event) || !total ||
|
||||
!Number.isSafeInteger(active.completed) || active.completed < 0 ||
|
||||
!Number.isSafeInteger(active.remaining) || active.remaining <= 0 ||
|
||||
active.completed + active.remaining !== total ||
|
||||
(played && (played.player !== game.me || played.card.id !== card.card.id ||
|
||||
played.mode !== active.mode || played.completed !== active.completed)))
|
||||
return fail('The current Ornithopter frame no longer belongs to this movement.');
|
||||
if (!Array.isArray(card.modes) || !card.modes.some(option => option.mode === active.mode))
|
||||
return fail('The current Ornithopter mode is no longer offered.');
|
||||
if (active.mode === 'twoGroups' && active.completed > 0 && !active.cohort)
|
||||
return fail('The original unmoved Ornithopter cohort is unavailable.');
|
||||
if (active.cohort) {
|
||||
try {
|
||||
validateCohortSelection(active.cohort, active.cohort.forces, active.cohort.elites, {}, {});
|
||||
const sources = [...Object.keys(active.cohort.forces), ...Object.keys(active.cohort.elites),
|
||||
...(active.cohort.noField ? [active.cohort.noField.from] : [])];
|
||||
if (sources.some(from => {
|
||||
const point = splitLocation(from);
|
||||
return from !== location(point.territory, point.sector) ||
|
||||
!validGameLocation(game, point.territory, point.sector);
|
||||
})) return fail('The original unmoved Ornithopter cohort has invalid sources.');
|
||||
} catch {
|
||||
return fail('The original unmoved Ornithopter cohort is no longer valid.');
|
||||
}
|
||||
}
|
||||
return { fields: { ornithopterEvent: active.event },
|
||||
...(active.mode === 'range3' ? { rangeOverride: 3 as const } : {}),
|
||||
...(active.cohort ? { cohort: active.cohort } : {}), blocked: null };
|
||||
}
|
||||
if (flight === 'ordinary') return { fields: {}, blocked: null };
|
||||
if (flight === 'discovery') {
|
||||
const offer = game.discoveryOrnithopter;
|
||||
if (!offer) return fail('You do not carry an available Discovery Ornithopter.');
|
||||
if (offer.blocked) return fail(offer.blocked);
|
||||
const held = game.discoveries?.tokens.find(token => token.id === offer.token);
|
||||
if (!isDiscoveryTokenId(offer.token) || offer.range !== 3 || !held ||
|
||||
game.discoveries?.tokens.filter(token => token.face === 'ornithopter' &&
|
||||
token.status === 'carried' && token.owner === game.me).length !== 1 ||
|
||||
held.face !== 'ornithopter' || held.status !== 'carried' || held.owner !== game.me ||
|
||||
held.acquiredTurn !== offer.acquiredTurn || held.revealedTurn !== offer.acquiredTurn ||
|
||||
!Number.isSafeInteger(offer.acquiredTurn) || offer.acquiredTurn < 1 || offer.acquiredTurn >= game.turn)
|
||||
return fail('The selected Discovery Ornithopter is no longer carried by you or ready for use.');
|
||||
return { fields: { discoveryOrnithopter: offer.token }, rangeOverride: 3, blocked: null };
|
||||
}
|
||||
if (!card || !named(card.card.id)) return fail('You do not hold an available Ornithopter card.');
|
||||
const mode = card.modes?.find(option => option.mode === flight);
|
||||
if (!mode) return fail('Choose one of the offered Ornithopter modes.');
|
||||
if (card.blocked || mode.blocked) return fail(card.blocked || mode.blocked!);
|
||||
return { fields: { movementCard: card.card.id, ornithopter: flight },
|
||||
...(flight === 'range3' ? { rangeOverride: 3 as const } : {}), blocked: null };
|
||||
}
|
||||
+7
-6
@@ -1095,7 +1095,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
|
||||
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
|
||||
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Special flight combinations remain unfinished.',
|
||||
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
|
||||
'The revealed token stays face up until another token is placed. Revealing it does not clear the previous-shipment restriction. An allied No-Field shipment reveals immediately and uses the ally’s reserves; an existing token must be revealed before another shipment.',
|
||||
'Before an eligible ally uses its shipment, the game holds an offer opportunity for Richese. Richese may pass or privately propose a token, destination and payment. The ally accepts the exact offer and chooses its permitted regular/elite mix, or declines without payment. Success consumes the ally’s shipment, not Richese’s shipment or either player’s movement.',
|
||||
'Ordinary allied shipment costs one spice into a stronghold or two elsewhere. Guild recipients and active purchased prices use the existing provisional bank-only discount. Canceling a native Guild recipient’s rate charges one full-price marker, never zero/five materialized forces. A supplier’s original whole-fee1 approval cannot silently become2; release unchanged and obtain a new full-price offer/consent. Independent purchased price remains. Fremen on-planet reserves stay ineligible.',
|
||||
@@ -5735,6 +5735,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'Planetologist may provisionally compose either movement alternative with physical Ornithopter or carried Discovery. Fixed flight stays three, including both gather origins. In two-group card mode, each action chooses boosted normal range or ordinary-range gathering; a gather counts as one card movement. Different original counters must supply the second movement, even after merging. Current trainer, source events, earlier-turn token ownership and one disposal remain.',
|
||||
'Fresh all14 uses one selected-deck capability for every registered actual seat, Basic/Advanced, including mixed native Ecaz/Moritani with selected E1/E2 families. Neither an expansion faction, independent cards, Nexus nor one exact E3 family is required just to use selected ordinary decks. An unused deck creates no seated faction. Original World/phase envelopes retain components and native power bindings; original Nexus/Discovery/Semuta and Advanced role limits stay separate. Ix drafts before training; Auditor/shared Duke never train. No Advanced power, income, owner or played-save history is invented.',
|
||||
'Classic Nexus core with base33 or selected36 permits optional all14, original Tech3+ and Advanced Strongholds2+. Offers, starting-card order, mandatory normal Banker and native reactions remain. Actual third-party Bureaucrat sees the real Guild-card recipient. Tech pays only at its matching phase end; Banker waits for Mentat. Earned local support does not become personal money or reserve fees. Selection/printed roles are public capabilities; other native/module envelopes stay separate.',
|
||||
'Explicit guild-betrayal --leader-skills remains a bounded classic preset of the original all14 setup. It does not require independent cards or another overlay. Native Mentat private questions, skilled-disc posture, Sandmaster collection and normal income retain their shared quotes; exact payer/recipient and printed Bureaucrat third-party conditions remain. Native cards, paid forces, Use/Pass and movement/plan continuation stay physical. Wider combinations and complete-mode acceptance remain separate.',
|
||||
@@ -5774,11 +5775,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-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 shipping, No-Field accompaniment and provisional modifier-inclusive battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income is mandatory in fresh supported ordinary all14 setups; existing games are not retrofitted and other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',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/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.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 surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',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/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{area:'Verification',status:'Partial',detail:"Classic Nexus optional14 now retains original Tech3+ and Advanced Strongholds2+ with selected33/36. Mandatory Banker remains separate from native phase income; actual third-party receipts, trained-disc posture, local bank support and original cleanup/replacement stay distinct. Real normal lobby, earned Carthag/Arrakeen and fixed/free Axlotl controls complete with four legal policies. Earlier native/Richese evidence remains dated; full modes, strategy and deferred assurance stay open.",evidence:['tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
|
||||
{ area:'Implementation',status:'Partial',detail:'Planetologist flight composes all six alternatives with exact original cohorts and signed Discovery origins; ordinary no-skill receipt bytes remain. 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 shipping, No-Field accompaniment and provisional modifier-inclusive battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income is mandatory in fresh supported ordinary all14 setups; existing games are not retrofitted and other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',evidence:['game/planetologist-flight.ts','game/bot-planetologist-flight.ts','game/discovery-flight.ts','components/planetologist-movement-action.tsx','tests/planetologist-flight-controls.test.tsx','tests/planetologist-flight-profile.test.ts','tests/fixture-planetologist-ornithopter.ts','tests/planetologist-ornithopter-engine.test.ts','tests/fixture-planetologist-discovery-flight.ts','tests/planetologist-discovery-flight-engine.test.ts','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/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Shared owned-flight selection supplies current fields, fixed ranges and whole-map cohorts to the existing form and guarded actual submit. 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 surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',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/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'Minimal policies offer legal skill flights from the same projected selection and original quotas, without strategic tuning. All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'Gather-as-one-card-movement and fixed-three overrides are explicitly provisional; no joint FAQ or complete modes are claimed. All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{area:'Verification',status:'Partial',detail:"Native paid-card and earlier-turn Discovery paths, controlled callbacks and receipt regressions cover the selected flight matrix. Browser/deployed and final assurance remain due. Classic Nexus optional14 now retains original Tech3+ and Advanced Strongholds2+ with selected33/36. Mandatory Banker remains separate from native phase income; actual third-party receipts, trained-disc posture, local bank support and original cleanup/replacement stay distinct. Real normal lobby, earned Carthag/Arrakeen and fixed/free Axlotl controls complete with four legal policies. Earlier native/Richese evidence remains dated; full modes, strategy and deferred assurance stay open.",evidence:['tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user