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.
278 lines
8.8 KiB
TypeScript
278 lines
8.8 KiB
TypeScript
'use client';
|
|
|
|
import { useId, useState } from 'react';
|
|
import type { Action, GameView } from '@/game/engine';
|
|
import {
|
|
planetologistLeader,
|
|
planetologistMovementModeSupported,
|
|
selectedOriginElites,
|
|
type PlanetologistMoveMode,
|
|
} from '@/game/planetologist-movement';
|
|
import { botGroundMoveAllowed } from '@/game/bot-mobility';
|
|
import {
|
|
gameTerritories,
|
|
location,
|
|
splitLocation,
|
|
territory,
|
|
} from '@/game/board';
|
|
import { isAdvisor } from '@/game/advisors';
|
|
import { presenceAt } from '@/game/force-presence';
|
|
import { Button } from './ui/button';
|
|
import { Input } from './ui/input';
|
|
|
|
type Counts = Record<string, number>;
|
|
|
|
export function planetologistMoveDraft(
|
|
game: GameView,
|
|
mode: PlanetologistMoveMode,
|
|
destination: string,
|
|
sector: number,
|
|
forces: Counts,
|
|
elites: Counts,
|
|
): { action: Action | null; blocked: string | null } {
|
|
const me = game.players.find((player) => player.id === game.me);
|
|
if (!me || !planetologistLeader(game, me.id))
|
|
return {
|
|
action: null,
|
|
blocked: 'A living, uncaptured Planetologist is required.',
|
|
};
|
|
if (
|
|
!planetologistMovementModeSupported(game)
|
|
)
|
|
return {
|
|
action: null,
|
|
blocked:
|
|
'Planetologist movement with expansion factions awaits its movement audit.',
|
|
};
|
|
const selected = Object.entries(forces).filter(([, count]) => count > 0);
|
|
for (const [from, count] of selected) {
|
|
const owned = me.forces[from] ?? 0;
|
|
if (!Number.isSafeInteger(count) || count < 1 || count > owned)
|
|
return { action: null, blocked: 'Choose available whole force counts.' };
|
|
const elite = elites[from] ?? 0;
|
|
const ownedElite = me.elites?.forces[from] ?? 0;
|
|
const minimumElite = Math.max(0, count - (owned - ownedElite));
|
|
if (
|
|
!Number.isSafeInteger(elite) ||
|
|
elite < minimumElite ||
|
|
elite > Math.min(count, ownedElite)
|
|
)
|
|
return {
|
|
action: null,
|
|
blocked: 'Choose an available normal and elite mix for every source.',
|
|
};
|
|
}
|
|
const origins = new Set(
|
|
selected.map(([from]) => splitLocation(from).territory),
|
|
);
|
|
if (
|
|
(mode === 'range' && origins.size !== 1) ||
|
|
(mode === 'gather' && origins.size !== 2)
|
|
)
|
|
return {
|
|
action: null,
|
|
blocked:
|
|
mode === 'range'
|
|
? 'Select forces from one territory.'
|
|
: 'Select forces from exactly two different territories.',
|
|
};
|
|
if (
|
|
game.advanced &&
|
|
me.faction === 'beneGesserit' &&
|
|
new Set([...origins].map((origin) => isAdvisor(me, origin))).size > 1 &&
|
|
!presenceAt(me, destination) &&
|
|
game.players.some(
|
|
(player) => player.id !== me.id && presenceAt(player, destination),
|
|
)
|
|
)
|
|
return {
|
|
action: null,
|
|
blocked:
|
|
'Gathering advisors and fighters into an enemy-only territory is still being implemented.',
|
|
};
|
|
const target = location(destination, sector);
|
|
if (
|
|
selected.some(
|
|
([from]) =>
|
|
!botGroundMoveAllowed(game, me, from, target,
|
|
selectedOriginElites(selected, elites, splitLocation(from).territory), mode),
|
|
)
|
|
)
|
|
return {
|
|
action: null,
|
|
blocked:
|
|
mode === 'range'
|
|
? 'The selected route is beyond the boosted range or is blocked.'
|
|
: 'Each selected source must reach the destination using its normal range.',
|
|
};
|
|
return {
|
|
action: {
|
|
type: 'move',
|
|
planetologist: mode,
|
|
forces: Object.fromEntries(selected),
|
|
...(me.elites
|
|
? {
|
|
eliteForces: Object.fromEntries(
|
|
selected.map(([from]) => [from, elites[from] ?? 0]),
|
|
),
|
|
}
|
|
: {}),
|
|
territory: destination,
|
|
sector,
|
|
},
|
|
blocked: null,
|
|
};
|
|
}
|
|
|
|
export function PlanetologistMovement({
|
|
game,
|
|
act,
|
|
busy,
|
|
destination,
|
|
sector,
|
|
unavailableReason,
|
|
}: {
|
|
game: GameView;
|
|
act: (action: Action) => void;
|
|
busy: boolean;
|
|
destination: string;
|
|
sector: number;
|
|
unavailableReason?: string | null;
|
|
}) {
|
|
const id = useId();
|
|
const [mode, setMode] = useState<PlanetologistMoveMode>('range');
|
|
const [forces, setForces] = useState<Counts>({});
|
|
const [elites, setElites] = useState<Counts>({});
|
|
const me = game.players.find((player) => player.id === game.me);
|
|
const leader = me && planetologistLeader(game, me.id);
|
|
if (!me || !leader) return null;
|
|
const sources = Object.entries(me.forces).filter(([, count]) => count > 0);
|
|
const draft = planetologistMoveDraft(
|
|
game,
|
|
mode,
|
|
destination,
|
|
sector,
|
|
forces,
|
|
elites,
|
|
);
|
|
const blocked =
|
|
unavailableReason ??
|
|
(game.phase !== 5 || game.active !== me.id
|
|
? 'Planetologist movement is available only during your movement turn.'
|
|
: game.ornithopter?.active
|
|
? 'Finish the current Ornithopter movement first.'
|
|
: game.truthtrance || game.nexusTraitors?.pending
|
|
? 'Resolve the current private response first.'
|
|
: me.autopilot || game.automaticContinuationPending
|
|
? 'Wait for the current automatic action to finish.'
|
|
: draft.blocked);
|
|
const leaderName =
|
|
game.allLeaders.find((candidate) => candidate.id === leader)?.name ??
|
|
leader;
|
|
return (
|
|
<section
|
|
className="my-4 flex min-w-0 flex-col gap-3"
|
|
aria-labelledby={`${id}-heading`}
|
|
>
|
|
<h3 id={`${id}-heading`}>Planetologist movement</h3>
|
|
<p>
|
|
{leaderName} may add one territory to one movement, up to three, or
|
|
gather forces from exactly two territories into the selected
|
|
destination.
|
|
</p>
|
|
<label htmlFor={`${id}-mode`}>Movement mode</label>
|
|
<select
|
|
id={`${id}-mode`}
|
|
value={mode}
|
|
disabled={busy}
|
|
onChange={(event) =>
|
|
setMode(event.target.value as PlanetologistMoveMode)
|
|
}
|
|
>
|
|
<option value="range">One territory farther, maximum three</option>
|
|
<option value="gather">Gather from two territories</option>
|
|
</select>
|
|
<p className="fine">
|
|
Destination: {territory(destination).name}, sector {sector}. Select the
|
|
physical forces to move below.
|
|
</p>
|
|
{me.faction === 'ixians' && (
|
|
<p className="fine">
|
|
Each origin uses its own selected cyborgs for movement range. Cyborgs
|
|
from one territory cannot carry suboids from the other territory farther.
|
|
</p>
|
|
)}
|
|
{sources.length ? (
|
|
sources.map(([from, owned]) => {
|
|
const source = splitLocation(from);
|
|
const sourceName =
|
|
gameTerritories(game).find(
|
|
(candidate) => candidate.id === source.territory,
|
|
)?.name ?? source.territory;
|
|
const selected = forces[from] ?? 0;
|
|
const ownedElite = me.elites?.forces[from] ?? 0;
|
|
const minimumElite = Math.max(0, selected - (owned - ownedElite));
|
|
return (
|
|
<div className="grid gap-2" key={from}>
|
|
<label htmlFor={`${id}-${from}-forces`}>
|
|
{sourceName}, sector {source.sector} · {owned} forces
|
|
<Input
|
|
id={`${id}-${from}-forces`}
|
|
type="number"
|
|
min={0}
|
|
max={owned}
|
|
value={selected}
|
|
disabled={busy || source.sector === game.storm}
|
|
onChange={(event) =>
|
|
setForces({ ...forces, [from]: Number(event.target.value) })
|
|
}
|
|
/>
|
|
</label>
|
|
{me.elites && (
|
|
<label htmlFor={`${id}-${from}-elites`}>
|
|
Elite from this group · {ownedElite} available
|
|
<Input
|
|
id={`${id}-${from}-elites`}
|
|
type="number"
|
|
min={minimumElite}
|
|
max={Math.min(selected, ownedElite)}
|
|
value={elites[from] ?? 0}
|
|
disabled={busy || selected === 0}
|
|
onChange={(event) =>
|
|
setElites({
|
|
...elites,
|
|
[from]: Number(event.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<p className="fine">No board forces are available to move.</p>
|
|
)}
|
|
{blocked && (
|
|
<output className="notice" id={`${id}-reason`}>
|
|
{blocked}
|
|
</output>
|
|
)}
|
|
<Button
|
|
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
|
disabled={busy || !!blocked || !draft.action}
|
|
aria-describedby={blocked ? `${id}-reason` : undefined}
|
|
onClick={() => {
|
|
if (!busy && !blocked && draft.action) act(draft.action);
|
|
}}
|
|
>
|
|
Move with Planetologist
|
|
</Button>
|
|
<p className="fine">
|
|
This does not combine with a No-Field or either Ornithopter movement.
|
|
Normal movement and arrival rules still apply.
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|