Files
Dune/components/planetologist-movement.tsx
T
Quintine 489bd3265d Connect Basic Moritani to the full Leader Skills prototype
Admit the genuine private setup profile for Basic Moritani with base opponents,
exactly the Ecaz expansion and no other optional modules. Keep all14 physical
skills, private two-card offers, starting-card/Traitor order and Moritani's final
six-force placement. Public starts and complete-mode/publication gates remain.

Share public profile eligibility across Banker, Diplomat, Sandmaster movement
and worm riding, Smuggler shipping/collection, Bureaucrat payments, Planetologist
controls/bots and Suk/Rihani/Sandmaster battle settlement. Admission reads no
hidden cards or random deal. Preserve base/CHOAM boundaries and existing source
questions; no full AI implementation, strategy or calibration changes.

Genuine setup regressions cover all14 assignments, legal profiles and guards,
Terror Assassination death and exact skill return, optional own
revival draw/privacy, Sabotage, movement controls/bots, Smuggler shipment and
Bureaucrat paid bribes. Battle actions cover Rihani exchange, Sandmaster spice,
Banker funds, Suk casualties and Smuggler collection. Actual enemy alliances
prove losing-ally retention excludes mandatory Planetologist Special and
Diplomat-copy disposal. A real worm/Nexus ride collects Sandmaster spice once.
Authenticated in-memory SQLite restarts and concurrent skill choices preserve
one assignment, private offers, saved seats and final Moritani placement.
Conserved staged positions are explicitly distinct from played phase histories.

Add a reproducible moritani-skills profile to the existing faction-game runner,
including fixed2-6-player Basic rosters, seed offsets16-20 and saved resume.
Verify expected module presence, all14 physical skills and ordinary Traitor
custody alongside existing card/force/private-view checks. Default six expansion
samples and base samples retain their rosters/ordinals. No live DB is used by
these complete-game samples. CLI regressions check interrupted continuation
and Advanced refusal; corruption tests catch lost cards and entire module loss.

Independent review caught an old all-base worm guard and a sample checker that
missed whole-module deletion; both fixed with meaningful regressions and reviewed.
Initial edits had a stale helper reference and final lint found an unused import;
these were corrected. Types, lint, all 5034 offline tests and build pass on
stable final source. Five genuine complete-game samples passed with no rejected
actions:2 players189 actions/5 restores,3 players53/1,4 players862/23,
5 players116/3 and6 players1338/36. These are legality samples, not calibration
or exhaustive faction/card acceptance. No endpoint/auth/storage changes; existing
HTTP suite not repeated, while new authenticated storage and live browser actions
exercise the newly admitted profile through production boundaries.

Browser created a fresh Basic human Moritani/Easy Emperor lobby, then the existing
backed-up local initializer started that named ready room without replacing seats.
Human inspected both Killer Medic bands, assigned Lupino Ord, chose a private
Traitor and placed six in Polar Sink after all other setup. Refresh preserved
the two public skills, own hand/Traitor and6 deployed/14 reserves at pending
Storm. Mobile skill panel339px, client/scroll337; viewport restored afterward.
Screenshot capture timed out, so full visual/screen-reader acceptance stays open.

All 1580 opening games, 3348 saved seats and auxiliary rows
remain identical. One isolated QA game adds one human seat and entry receipt;
its row is unchanged after setup through refresh, with all seats unchanged.
SQLite integrity and foreign keys pass. No server restart, migration, reset or
recurring automation. Existing unfinished Leader Skill bands and material
rulings remain explicit in the new integration guide and internal checklist.

Private evidence: 20260921-moritani-skills.
Verified source tree: d5078661ecc2199b2bbfebd18034b16585d06410a697a78dc514bfa5b3b77a1c
2026-09-21 13:43:27 +10:00

270 lines
8.5 KiB
TypeScript

'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import {
planetologistLeader,
planetologistMovementModeSupported,
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, elites[from] ?? 0, 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>
{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>
);
}