Connect capped extra range, two-origin gathering, Fremen cancellation and saved replacement choices. Add human controls and actual first-choice AI paths for all four profiles. Admit the exact nine base green Specials in the actual skilled leader's weapon slot for +2 on survival, with mandatory physical discard and no native utility effect. Reject stale dead-disc plans. Independent movement/battle review closed after the saved-dead-disc fix. 84 focused tests passed. Two four-profile Basic/Advanced samples completed (194/186 accepted actions, six JSON restorations each, zero rejected actions). Basic used both movement branches; Advanced used added range. Browser genuine setup/gather/refresh and a separate targeted Special/traitor-loss fixture passed. Required verification: types, lint, 4435 offline tests, build and 43 HTTP tests passed. First attempt stopped at missing explicit test promise markers; corrected before the successful broad run. Final check/build/HTTP durations: 144157/6638/25048 ms. Source fingerprint (1260 files): bb7be69cf3e925bf7338ea444f77279ed7b37faadc3c44ed7db01b941f27b861 Private report: checkpoints/20260913-planetologist/checks-final/report.json. Preserved all 325 opening games and all original application/seat/recovery records; 359 rooms after 34 new QA/HTTP rooms. SQLite quick_check ok and zero foreign-key violations. Healthy existing server reused; no reset/restart. Boundaries: eight other Leader Skills, enemy-only mixed BG gather response, expansion roster/green-card/optional-module combinations remain unfinished. Winning traitor caller discard remains a documented card-specific inference. No mode or publication gates opened. Efficiency measure: aggregate goal counter 67321978 -> 68710948 (1388970), including context and coordination; not billing tokens or a savings claim. One skill's three connected effects, selective implementation/research/review, one interrupted lint attempt followed by the required complete verification.
153 lines
4.1 KiB
TypeScript
153 lines
4.1 KiB
TypeScript
import type { GameView } from './engine';
|
|
import { guildShipmentCost } from './shipment-price';
|
|
import { fighterCount, isAdvisor } from './advisors';
|
|
import { presenceAt } from './force-presence';
|
|
import { EcazOccupancyError } from './ecaz-occupy';
|
|
import {
|
|
planetologistLeader,
|
|
planetologistRange,
|
|
type PlanetologistMoveMode,
|
|
} from './planetologist-movement';
|
|
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
|
|
import {
|
|
distance,
|
|
gameDistance,
|
|
location,
|
|
MOBILE_STRONGHOLD,
|
|
splitLocation,
|
|
territory,
|
|
} from './board';
|
|
|
|
type Seat = GameView['players'][number];
|
|
/** These checks consume only the same personalized projection as the bot policy. */
|
|
function advisorArrival(g: GameView, p: Seat, to: string, source?: string) {
|
|
if (
|
|
!g.advanced ||
|
|
p.faction !== 'beneGesserit' ||
|
|
!g.players.some((other) => other.id !== p.id && presenceAt(other, to))
|
|
)
|
|
return false;
|
|
return presenceAt(p, to)
|
|
? isAdvisor(p, to)
|
|
: !!source && isAdvisor(p, source);
|
|
}
|
|
export function botEntryAllowed(
|
|
g: GameView,
|
|
p: Seat,
|
|
to: string,
|
|
sector: number,
|
|
kind: 'move' | 'ship' | 'guildShip',
|
|
source?: string,
|
|
) {
|
|
if (
|
|
to === MOBILE_STRONGHOLD &&
|
|
(!g.mobileStronghold?.location ||
|
|
(kind !== 'move' && p.faction !== 'ixians'))
|
|
)
|
|
return false;
|
|
if (
|
|
sector !== 0 &&
|
|
sector === g.storm &&
|
|
!(kind === 'ship' && g.advanced && p.faction === 'fremen')
|
|
)
|
|
return false;
|
|
const advisors = advisorArrival(
|
|
g,
|
|
p,
|
|
to,
|
|
kind === 'move' ? source : undefined,
|
|
);
|
|
if (
|
|
source &&
|
|
!advisors &&
|
|
p.advisors?.[source]?.lockedTurn === g.turn &&
|
|
g.players.some((other) => other.id !== p.id && presenceAt(other, to))
|
|
)
|
|
return false;
|
|
try {
|
|
return !territoryEntryBlock(g.players, p.id, to, advisors);
|
|
} catch (error) {
|
|
if (error instanceof EcazOccupancyError) return false;
|
|
throw error;
|
|
}
|
|
}
|
|
export function botMovementRange(
|
|
g: GameView,
|
|
p: Seat,
|
|
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;
|
|
}
|
|
export function botGroundMoveAllowed(
|
|
g: GameView,
|
|
p: Seat,
|
|
from: string,
|
|
to: string,
|
|
elite: number,
|
|
planetologist?: PlanetologistMoveMode,
|
|
) {
|
|
const source = splitLocation(from),
|
|
target = splitLocation(to);
|
|
if (
|
|
from === to ||
|
|
(source.sector !== 0 && source.sector === g.storm) ||
|
|
!botEntryAllowed(
|
|
g,
|
|
p,
|
|
target.territory,
|
|
target.sector,
|
|
'move',
|
|
source.territory,
|
|
)
|
|
)
|
|
return false;
|
|
if (
|
|
source.territory !== target.territory &&
|
|
g.balisetRestrictions.some(
|
|
(b) => b.player === p.id && b.territory === target.territory,
|
|
) &&
|
|
g.players.some(
|
|
(other) =>
|
|
other.faction === 'choam' && presenceAt(other, target.territory),
|
|
)
|
|
)
|
|
return false;
|
|
const advisors = isAdvisor(p, source.territory);
|
|
return (
|
|
gameDistance(g, from, to, (key) => {
|
|
const loc = splitLocation(key);
|
|
return (
|
|
(loc.sector !== 0 && loc.sector === g.storm) ||
|
|
strongholdPathBlocked(g.players, p.id, loc.territory, advisors)
|
|
);
|
|
}) <= botMovementRange(g, p, elite, planetologist)
|
|
);
|
|
}
|
|
/** Fremen reinforcement radius is static territory geometry, not a ground route. */
|
|
export function fremenReserveEntry(to: string) {
|
|
return territory('the_great_flat').sectors.some((from) =>
|
|
territory(to).sectors.some(
|
|
(sector) =>
|
|
distance(location('the_great_flat', from), location(to, sector)) <= 2,
|
|
),
|
|
);
|
|
}
|
|
/** Guild cross-shipping is paid transport, including allied Fremen reserves. */
|
|
export function guildTransportCost(to: string, amount: number) {
|
|
return guildShipmentCost(
|
|
to === 'reserves' ? 'reserves' : territory(to).type,
|
|
amount,
|
|
);
|
|
}
|