Prototype Planetologist movement and Special-card battles
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.
This commit is contained in:
1 parent
6e6fecc9d4
commit
5082c81353
22 files changed
+2473
-93
No files matched your search
@@ -34,6 +34,9 @@ import {
|
||||
type HomeworldExplosionLosses,
|
||||
} from './homeworld-battle-rules';
|
||||
import {
|
||||
PLANETOLOGIST_BASE_SPECIALS,
|
||||
canUsePlanetologistBattleSpecial,
|
||||
isPlanetologistBattleSpecialCard,
|
||||
leaderSkillBattleBonus,
|
||||
type BattleLeaderSkill,
|
||||
type LeaderSkillBattleBonus,
|
||||
@@ -213,6 +216,35 @@ function validateCombatant(
|
||||
(side.leader.id === p.leader && Number.isFinite(side.leader.strength)),
|
||||
'The controlled leader must match the revealed plan.',
|
||||
);
|
||||
const selectedPlanetologistCards = [
|
||||
p.weapon,
|
||||
p.defense,
|
||||
p.leader,
|
||||
side.lateDefense,
|
||||
].filter(
|
||||
(selectedId): selectedId is string =>
|
||||
!!selectedId &&
|
||||
PLANETOLOGIST_BASE_SPECIALS.some(
|
||||
(card) => card.id === selectedId,
|
||||
),
|
||||
);
|
||||
requireQuote(
|
||||
selectedPlanetologistCards.every((selectedId) => {
|
||||
const selectedCard = side.hand.find((card) => card.id === selectedId);
|
||||
return (
|
||||
selectedId === p.weapon &&
|
||||
side.leader?.id === p.leader &&
|
||||
!side.leader.dead &&
|
||||
isPlanetologistBattleSpecialCard(selectedCard) &&
|
||||
canUsePlanetologistBattleSpecial({
|
||||
assignments: side.leaderSkills ?? [],
|
||||
selectedLeader: p.leader,
|
||||
card: selectedCard,
|
||||
})
|
||||
);
|
||||
}),
|
||||
'A green Special may replace only the assigned Planetologist leader’s weapon.',
|
||||
);
|
||||
return played;
|
||||
}
|
||||
/**
|
||||
@@ -614,16 +646,30 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
auditCount(other.hand, auditor.usedCards, auditor.survived);
|
||||
}
|
||||
let retention: MoritaniRetention | null = null;
|
||||
const mandatoryPlanetologistDiscard = (
|
||||
side: ResolutionCombatant,
|
||||
selected: string,
|
||||
) =>
|
||||
selected === side.plan.weapon &&
|
||||
canUsePlanetologistBattleSpecial({
|
||||
assignments: side.leaderSkills ?? [],
|
||||
selectedLeader: side.plan.leader,
|
||||
card: card(side, selected),
|
||||
});
|
||||
const moritani = input.participants.find((p) => p.faction === 'moritani');
|
||||
if (winner && moritani) {
|
||||
const loser = winner === a ? d : a,
|
||||
loserPlayed = loser === a ? played.attacker : played.defender;
|
||||
const eligible = loserPlayed.filter((selected) =>
|
||||
canRetainBattleCard(
|
||||
card(loser, selected)!,
|
||||
ac || dc,
|
||||
!!loser.poisonTooth,
|
||||
),
|
||||
const retainablePlayed = loserPlayed.filter(
|
||||
(selected) => !mandatoryPlanetologistDiscard(loser, selected),
|
||||
);
|
||||
const eligible = retainablePlayed.filter(
|
||||
(selected) =>
|
||||
canRetainBattleCard(
|
||||
card(loser, selected)!,
|
||||
ac || dc,
|
||||
!!loser.poisonTooth,
|
||||
),
|
||||
);
|
||||
if (
|
||||
loser.id !== moritani.id &&
|
||||
@@ -640,7 +686,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
player: loser.id,
|
||||
territory: input.territory,
|
||||
turn: input.turn,
|
||||
played: [...loserPlayed],
|
||||
played: [...retainablePlayed],
|
||||
eligible,
|
||||
stage: 'choose',
|
||||
};
|
||||
@@ -651,9 +697,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
.filter(
|
||||
(selected) =>
|
||||
!(
|
||||
retention?.player === s.id && retention.played.includes(selected)
|
||||
retention?.player === s.id &&
|
||||
retention.played.includes(selected) &&
|
||||
!mandatoryPlanetologistDiscard(s, selected)
|
||||
) &&
|
||||
(winner !== s ||
|
||||
(mandatoryPlanetologistDiscard(s, selected) ||
|
||||
winner !== s ||
|
||||
!canRetainBattleCard(
|
||||
card(s, selected)!,
|
||||
ac || dc,
|
||||
|
||||
+17
-3
@@ -3,6 +3,11 @@ 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,
|
||||
@@ -66,7 +71,12 @@ export function botEntryAllowed(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export function botMovementRange(g: GameView, p: Seat, elite: number) {
|
||||
export function botMovementRange(
|
||||
g: GameView,
|
||||
p: Seat,
|
||||
elite: number,
|
||||
planetologist?: PlanetologistMoveMode,
|
||||
) {
|
||||
const base =
|
||||
fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')
|
||||
? 3
|
||||
@@ -74,7 +84,10 @@ export function botMovementRange(g: GameView, p: Seat, elite: number) {
|
||||
(p.faction === 'ixians' && elite > 0 && !p.ixMovementBlocked)
|
||||
? 2
|
||||
: 1;
|
||||
return base + (p.faction === 'choam' ? g.choamMovementBonus : 0);
|
||||
const ordinary = base + (p.faction === 'choam' ? g.choamMovementBonus : 0);
|
||||
return planetologist && planetologistLeader(g, p.id)
|
||||
? planetologistRange(ordinary, planetologist)
|
||||
: ordinary;
|
||||
}
|
||||
export function botGroundMoveAllowed(
|
||||
g: GameView,
|
||||
@@ -82,6 +95,7 @@ export function botGroundMoveAllowed(
|
||||
from: string,
|
||||
to: string,
|
||||
elite: number,
|
||||
planetologist?: PlanetologistMoveMode,
|
||||
) {
|
||||
const source = splitLocation(from),
|
||||
target = splitLocation(to);
|
||||
@@ -117,7 +131,7 @@ export function botGroundMoveAllowed(
|
||||
(loc.sector !== 0 && loc.sector === g.storm) ||
|
||||
strongholdPathBlocked(g.players, p.id, loc.territory, advisors)
|
||||
);
|
||||
}) <= botMovementRange(g, p, elite)
|
||||
}) <= botMovementRange(g, p, elite, planetologist)
|
||||
);
|
||||
}
|
||||
/** Fremen reinforcement radius is static territory geometry, not a ground route. */
|
||||
|
||||
+146
-11
@@ -1,4 +1,6 @@
|
||||
import { discoveryBotActions } from './discovery-options';
|
||||
import type { Card } from './cards';
|
||||
import { canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat';
|
||||
import { discoveryFlightBotActions } from './discovery-flight-options';
|
||||
import { discoveryEntryBotActions } from './discovery-entry-options';
|
||||
import { discoveryStormActions } from './discovery-storm-options';
|
||||
@@ -48,7 +50,6 @@ import {
|
||||
isWeaponCard,
|
||||
isStoneBurner,
|
||||
isDefenseCard,
|
||||
validBattleCardPair,
|
||||
defaultVoiceMatch,
|
||||
playedVoiceMatch,
|
||||
weaponKills,
|
||||
@@ -72,6 +73,8 @@ import {
|
||||
fremenReserveEntry,
|
||||
guildTransportCost,
|
||||
} from './bot-mobility';
|
||||
import { planetologistLeader } from './planetologist-movement';
|
||||
import { faction } from './catalog';
|
||||
import { richeseCardDefinition } from './richese-cards';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
|
||||
@@ -670,9 +673,13 @@ function plans(g: GameView): Action[] {
|
||||
commitments.every(
|
||||
(element) => element.field !== field || element.value === value,
|
||||
);
|
||||
const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [];
|
||||
const specialForLeader = (card: Card | undefined, leader: string | null) =>
|
||||
canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card});
|
||||
const weapons = [
|
||||
null,
|
||||
...(me.hand ?? []).filter(isWeaponCard).map((c) => c.id),
|
||||
...(me.hand ?? []).filter(c => isWeaponCard(c) ||
|
||||
me.leaders.some(l => !l.dead && controlsLeader(me,l) && (!l.usedAt || l.usedAt === b.territory) && specialForLeader(c,l.id))).map(c => c.id),
|
||||
].filter((id) => fixed('weapon', id));
|
||||
const defenses = [
|
||||
null,
|
||||
@@ -752,9 +759,10 @@ function plans(g: GameView): Action[] {
|
||||
) + (kwisatz ? 2 : 0);
|
||||
const w = me.hand?.find((c) => c.id === weapon);
|
||||
const d = me.hand?.find((c) => c.id === defense);
|
||||
if (w && !isWeaponCard(w) && !specialForLeader(w,leader)) continue;
|
||||
const stoneBattle =
|
||||
isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon));
|
||||
if ((!leader && (weapon || defense)) || !validBattleCardPair(w, d))
|
||||
if ((!leader && (weapon || defense)) || !validLeaderSkillBattleCardPair(w, d, specialForLeader(w,leader)))
|
||||
continue;
|
||||
if (voice) {
|
||||
const used =
|
||||
@@ -800,7 +808,11 @@ function plans(g: GameView): Action[] {
|
||||
b.strongholdEffects[other.id],
|
||||
);
|
||||
const ownSurvivingStrength =
|
||||
effects.stunned || effects.attackerDead ? 0 : strength;
|
||||
effects.stunned || effects.attackerDead ? 0 : strength + leaderSkillBattleBonus({
|
||||
assignments:battleSkills,
|
||||
selectedLeader:leader ? {id:leader,kind:myLeader?'disc':'hero'} : undefined,
|
||||
weapon:w,defense:d,skilledLeaderSurvives:true,
|
||||
}).bonus;
|
||||
const enemySurvivingStrength =
|
||||
effects.stunned || (inspected && effects.defenderDead)
|
||||
? 0
|
||||
@@ -869,7 +881,7 @@ function plans(g: GameView): Action[] {
|
||||
))
|
||||
)
|
||||
continue;
|
||||
const effectiveWeapon = w && w.kind !== 'worthless' ? 6 : 0;
|
||||
const effectiveWeapon = w && w.kind !== 'worthless' && !specialForLeader(w,leader) ? 6 : 0;
|
||||
const effectiveDefense = d && d.kind !== 'worthless' ? 4 : 0;
|
||||
const selfExplosion = battleWeaponsExplode(w, d);
|
||||
const correctDefense = inspected
|
||||
@@ -3088,6 +3100,13 @@ function policyActions(g: GameView): Action[] {
|
||||
return { from, amount, elite };
|
||||
})
|
||||
.filter((source) => source.amount > 0);
|
||||
const planetologist = planetologistLeader(g, me.id);
|
||||
const groundMoves: {
|
||||
action: Action;
|
||||
destinationScore: number;
|
||||
amount: number;
|
||||
gather: boolean;
|
||||
}[] = [];
|
||||
if (movementAvailable)
|
||||
for (const to of targets
|
||||
.filter(
|
||||
@@ -3100,15 +3119,131 @@ function policyActions(g: GameView): Action[] {
|
||||
.slice(0, 24))
|
||||
for (const { from, amount, elite } of movingSources) {
|
||||
if (botGroundMoveAllowed(g, me, from, to.key, elite))
|
||||
actions.push({
|
||||
type: 'move',
|
||||
from,
|
||||
territory: to.t,
|
||||
sector: to.s,
|
||||
groundMoves.push({
|
||||
action: {
|
||||
type: 'move',
|
||||
from,
|
||||
territory: to.t,
|
||||
sector: to.s,
|
||||
amount,
|
||||
elite,
|
||||
},
|
||||
destinationScore: to.score,
|
||||
amount,
|
||||
elite,
|
||||
gather: false,
|
||||
});
|
||||
}
|
||||
if (
|
||||
movementAvailable &&
|
||||
planetologist &&
|
||||
!g.ornithopter?.active &&
|
||||
g.players.every((player) => faction(player.faction).expansion === 'base')
|
||||
) {
|
||||
const usefulTargets = targets
|
||||
.filter(
|
||||
(to) =>
|
||||
marker?.location.territory !== to.t && to.score > -1000,
|
||||
);
|
||||
for (const to of usefulTargets
|
||||
.filter((target) =>
|
||||
movingSources.some(
|
||||
({ from, elite }) =>
|
||||
botGroundMoveAllowed(
|
||||
g,
|
||||
me,
|
||||
from,
|
||||
target.key,
|
||||
elite,
|
||||
'range',
|
||||
) && !botGroundMoveAllowed(g, me, from, target.key, elite),
|
||||
),
|
||||
)
|
||||
.slice(0, 24))
|
||||
for (const { from, amount, elite } of movingSources) {
|
||||
if (
|
||||
botGroundMoveAllowed(g, me, from, to.key, elite, 'range') &&
|
||||
!botGroundMoveAllowed(g, me, from, to.key, elite)
|
||||
)
|
||||
groundMoves.push({
|
||||
action: {
|
||||
type: 'move',
|
||||
planetologist: 'range',
|
||||
from,
|
||||
territory: to.t,
|
||||
sector: to.s,
|
||||
amount,
|
||||
elite,
|
||||
},
|
||||
destinationScore: to.score,
|
||||
amount,
|
||||
gather: false,
|
||||
});
|
||||
}
|
||||
const gatherSources = movingSources.slice(0, 12);
|
||||
for (let first = 0; first < gatherSources.length; first += 1)
|
||||
for (let second = first + 1; second < gatherSources.length; second += 1) {
|
||||
const a = gatherSources[first],
|
||||
b = gatherSources[second],
|
||||
aTerritory = splitLocation(a.from).territory,
|
||||
bTerritory = splitLocation(b.from).territory;
|
||||
if (
|
||||
aTerritory === bTerritory ||
|
||||
(g.advanced &&
|
||||
me.faction === 'beneGesserit' &&
|
||||
isAdvisor(me, aTerritory) !== isAdvisor(me, bTerritory))
|
||||
)
|
||||
continue;
|
||||
for (const to of usefulTargets
|
||||
.filter(
|
||||
(target) =>
|
||||
botGroundMoveAllowed(
|
||||
g,
|
||||
me,
|
||||
a.from,
|
||||
target.key,
|
||||
a.elite,
|
||||
'gather',
|
||||
) &&
|
||||
botGroundMoveAllowed(
|
||||
g,
|
||||
me,
|
||||
b.from,
|
||||
target.key,
|
||||
b.elite,
|
||||
'gather',
|
||||
),
|
||||
)
|
||||
.slice(0, 24)) {
|
||||
groundMoves.push({
|
||||
action: {
|
||||
type: 'move',
|
||||
planetologist: 'gather',
|
||||
forces: { [a.from]: a.amount, [b.from]: b.amount },
|
||||
...(me.elites
|
||||
? {
|
||||
eliteForces: {
|
||||
[a.from]: a.elite,
|
||||
[b.from]: b.elite,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
territory: to.t,
|
||||
sector: to.s,
|
||||
},
|
||||
destinationScore: to.score,
|
||||
amount: a.amount + b.amount,
|
||||
gather: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
groundMoves.sort(
|
||||
(a, b) =>
|
||||
b.destinationScore - a.destinationScore ||
|
||||
b.amount - a.amount ||
|
||||
Number(b.gather) - Number(a.gather),
|
||||
);
|
||||
actions.push(...groundMoves.map((candidate) => candidate.action));
|
||||
if (reveal && !actions.length) actions.push(reveal);
|
||||
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
|
||||
actions.unshift(...emperorHomeworldMoveActions(g));
|
||||
|
||||
+149
-47
@@ -1,5 +1,12 @@
|
||||
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
|
||||
import { leaderSkillCard } from './leader-skill-cards';
|
||||
import {
|
||||
canUsePlanetologistBattleSpecial,
|
||||
isPlanetologistBattleSpecialCard,
|
||||
validLeaderSkillBattleCardPair,
|
||||
type BattleLeaderSkill,
|
||||
} from './leader-skill-combat';
|
||||
import { planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
|
||||
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
|
||||
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
|
||||
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
|
||||
@@ -459,7 +466,6 @@ import {
|
||||
isDefenseCard,
|
||||
isPortableSnooper,
|
||||
VOICE_KINDS,
|
||||
validBattleCardPair,
|
||||
defaultVoiceMatch,
|
||||
playedVoiceMatch,
|
||||
} from './battle-cards';
|
||||
@@ -4919,6 +4925,32 @@ function leaderSkillController(
|
||||
)!.capturedBy ?? assignment.owner
|
||||
);
|
||||
}
|
||||
function battleLeaderSkills(g: Game, player: Player): BattleLeaderSkill[] {
|
||||
return (
|
||||
g.leaderSkills?.assignments
|
||||
.filter((assignment) => leaderSkillController(g, assignment) === player.id)
|
||||
.map((assignment) => ({
|
||||
skill: assignment.skill,
|
||||
leader: assignment.leader,
|
||||
faceUp:
|
||||
assignment.owner === player.id &&
|
||||
!g.battle?.leaderSkillHidden?.[player.id],
|
||||
captured: assignment.owner !== player.id,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
function canUsePlanetologistWeapon(
|
||||
g: Game,
|
||||
player: Player,
|
||||
leader: string | null,
|
||||
card: Card | undefined,
|
||||
) {
|
||||
return canUsePlanetologistBattleSpecial({
|
||||
assignments: battleLeaderSkills(g, player),
|
||||
selectedLeader: leader,
|
||||
card,
|
||||
});
|
||||
}
|
||||
function nativeLeaderSkill(g: Game, owner: string) {
|
||||
return g.leaderSkills?.assignments.find(
|
||||
(a) => a.owner === owner && leaderSkillController(g, a) === owner,
|
||||
@@ -7997,7 +8029,7 @@ function eliteChoice(
|
||||
'Elite forces',
|
||||
);
|
||||
}
|
||||
function forceGroup(g: Game, p: Player, action: Action) {
|
||||
function forceGroup(g: Game, p: Player, action: Action, maxOrigins = 1) {
|
||||
let entries: [string, unknown][];
|
||||
if (action.forces !== undefined) {
|
||||
requireRule(
|
||||
@@ -8011,6 +8043,7 @@ function forceGroup(g: Game, p: Player, action: Action) {
|
||||
const group: [string, number][] = [];
|
||||
const eliteGroup: Record<string, number> = {};
|
||||
let origin: string | undefined;
|
||||
const origins = new Set<string>();
|
||||
let noField: { tokenId: string; event: string; from: string } | undefined;
|
||||
if (action.noField !== undefined) {
|
||||
const marker = p.noField?.deployed;
|
||||
@@ -8022,6 +8055,7 @@ function forceGroup(g: Game, p: Player, action: Action) {
|
||||
'This concealed No-Field movement selection is stale.',
|
||||
);
|
||||
origin = marker.location.territory;
|
||||
origins.add(origin);
|
||||
noField = {
|
||||
tokenId: marker.tokenId,
|
||||
event: p.noFieldEvent!,
|
||||
@@ -8036,11 +8070,10 @@ function forceGroup(g: Game, p: Player, action: Action) {
|
||||
);
|
||||
const n = integer(value, 0, p.forces[key] ?? 0, 'Forces');
|
||||
if (!n) continue;
|
||||
requireRule(
|
||||
!origin || origin === src.territory,
|
||||
'A force group must come from one territory.',
|
||||
);
|
||||
origin = src.territory;
|
||||
origins.add(src.territory);
|
||||
requireRule(origins.size <= maxOrigins,
|
||||
maxOrigins === 1 ? 'A force group must come from one territory.' : 'Planetologist gathers forces from exactly two territories.');
|
||||
origin ??= src.territory;
|
||||
group.push([key, n]);
|
||||
const selectedElite =
|
||||
action.forces !== undefined
|
||||
@@ -8062,6 +8095,7 @@ function forceGroup(g: Game, p: Player, action: Action) {
|
||||
eliteGroup,
|
||||
elite: Object.values(eliteGroup).reduce((a, b) => a + b, 0),
|
||||
origin: origin!,
|
||||
origins: [...origins],
|
||||
total: group.reduce((n, [, count]) => n + count, 0) + (noField ? 1 : 0),
|
||||
noField,
|
||||
sourceKeys: [
|
||||
@@ -8284,7 +8318,45 @@ function ornithopterView(g: Game, p: Player) {
|
||||
};
|
||||
}
|
||||
function movementOrderRange(g: Game, p: Player, move: MovementOrder) {
|
||||
return move.discoveryFlight || move.ornithopterRange ? 3 : movementRange(g, p, move.elite);
|
||||
return move.discoveryFlight || move.ornithopterRange ? 3 : planetologistRange(movementRange(g, p, move.elite), move.planetologist?.mode);
|
||||
}
|
||||
function validatePlanetologistMove(g: Game, p: Player, move: Pick<MovementOrder, 'planetologist' | 'origins' | 'origin' | 'noField' | 'ornithopterEvent' | 'discoveryFlight'>) {
|
||||
const skill = move.planetologist;
|
||||
if (!skill) {
|
||||
requireRule(move.origins === undefined, 'Two movement origins require Planetologist.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.phase === 5 && skill && typeof skill === 'object' &&
|
||||
(skill.mode === 'range' || skill.mode === 'gather') &&
|
||||
skill.leader === planetologistLeader(g, p.id),
|
||||
'This Planetologist movement no longer has its living, uncaptured skilled leader.');
|
||||
requireRule(g.players.every(player => faction(player.faction).expansion === 'base'),
|
||||
'Planetologist movement combined with expansion factions is still being implemented.');
|
||||
requireRule(!move.noField && !move.ornithopterEvent && !move.discoveryFlight,
|
||||
'Planetologist combined with special movement cards or No-Field is still being implemented.');
|
||||
requireRule(skill.mode === 'gather'
|
||||
? Array.isArray(move.origins) && move.origins.length === 2 && new Set(move.origins).size === 2 && move.origins[0] === move.origin
|
||||
: move.origins === undefined,
|
||||
'Choose the extra range or exactly two origins, not both.');
|
||||
}
|
||||
function movementOrigins(move: Pick<MovementOrder, 'origin' | 'origins'>): string[] {
|
||||
return move.origins ?? [move.origin];
|
||||
}
|
||||
function movementStance(g: Game, p: Player, origins: string[], to: string, fighters: boolean) {
|
||||
requireRule(!g.advanced || p.faction !== 'beneGesserit' ||
|
||||
at(p, to) > 0 || !g.players.some(other => other.id !== p.id && at(other, to)) ||
|
||||
origins.every(origin => isAdvisor(p, origin) === isAdvisor(p, origins[0])),
|
||||
'Gathering advisors and fighters into an enemy-only territory still needs the combined flip response. Choose sources with the same stance or a destination with a determined stance.');
|
||||
const advisors = arrivalAsAdvisor(g, p, to, origins[0]);
|
||||
const lockedTurn = Math.max(0, ...origins.map(origin => p.advisors?.[origin]?.lockedTurn ?? 0)) || undefined;
|
||||
requireRule(advisors || lockedTurn !== g.turn ||
|
||||
!g.players.some(other => other.id !== p.id && at(other, to)),
|
||||
'New advisors cannot become fighters this turn.');
|
||||
const wantsFighters = advisors && fighters;
|
||||
requireRule(!wantsFighters || (lockedTurn !== g.turn && !at(p, to) &&
|
||||
(!(p.ally && at(getPlayer(g, p.ally), to)) || sharesEcazOccupation(g, p, to))),
|
||||
'These advisors cannot flip to fighters on arrival.');
|
||||
return {advisors, lockedTurn, wantsFighters};
|
||||
}
|
||||
function validateFlightSelection(g: Game, p: Player, move: MovementOrder) {
|
||||
if (move.discoveryFlight)
|
||||
@@ -8355,7 +8427,7 @@ function offerChoamMovement(g: Game, move: MovementOrder) {
|
||||
g.phase === 5 &&
|
||||
choam &&
|
||||
move.player !== choam.id &&
|
||||
move.origin !== move.to &&
|
||||
movementOrigins(move).some(origin => origin !== move.to) &&
|
||||
at(choam, move.to) > 0
|
||||
) {
|
||||
g.pendingChoamMove = move;
|
||||
@@ -8410,6 +8482,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
'The movement is no longer available.',
|
||||
);
|
||||
validateFlightSelection(g, p, move);
|
||||
validatePlanetologistMove(g, p, move);
|
||||
const selected = forceGroup(g, p, {
|
||||
type: 'move',
|
||||
forces: Object.fromEntries(move.group),
|
||||
@@ -8417,16 +8490,22 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
...(move.noField
|
||||
? { noField: move.noField.tokenId, event: move.noField.event }
|
||||
: {}),
|
||||
});
|
||||
}, move.planetologist?.mode === 'gather' ? 2 : 1);
|
||||
requireRule(
|
||||
selected.origin === move.origin &&
|
||||
selected.total === move.total &&
|
||||
selected.elite === move.elite &&
|
||||
selected.group.length === move.group.length,
|
||||
selected.group.length === move.group.length &&
|
||||
JSON.stringify(selected.origins) === JSON.stringify(movementOrigins(move)),
|
||||
'The declared force group no longer matches its movement.',
|
||||
);
|
||||
if (move.planetologist) {
|
||||
const stance = movementStance(g, p, selected.origins, move.to, move.wantsFighters);
|
||||
requireRule(stance.advisors === move.advisors && stance.wantsFighters === move.wantsFighters &&
|
||||
stance.lockedTurn === move.lockedTurn, 'The declared Planetologist arrival stance is no longer current.');
|
||||
}
|
||||
requireRule(
|
||||
!balisetPrevents(g, p.id, move.origin, move.to),
|
||||
movementOrigins(move).every(origin => !balisetPrevents(g, p.id, origin, move.to)),
|
||||
'Baliset prevents that movement.',
|
||||
);
|
||||
allowedEntry(
|
||||
@@ -8444,7 +8523,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
].every(
|
||||
(key) =>
|
||||
gameDistance(g, key, location(move.to, move.sector), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, move.origin)),
|
||||
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
|
||||
) <= movementOrderRange(g, p, move),
|
||||
),
|
||||
'The movement route is no longer available.',
|
||||
@@ -8537,6 +8616,7 @@ function completeMove(g: Game, move: MovementOrder) {
|
||||
player: id,
|
||||
} = move;
|
||||
const p = getPlayer(g, id);
|
||||
validatePlanetologistMove(g, p, move);
|
||||
validateFlightSelection(g, p, move);
|
||||
validateMovementArrival(g, move);
|
||||
const flight = g.ornithopter;
|
||||
@@ -8606,6 +8686,7 @@ function completeMove(g: Game, move: MovementOrder) {
|
||||
const completed: CompletedMovement = {
|
||||
player: id,
|
||||
origin,
|
||||
...(move.origins ? {origins: move.origins} : {}),
|
||||
total: n,
|
||||
to,
|
||||
sector: s,
|
||||
@@ -8630,10 +8711,10 @@ function finishMovedGroup(g: Game, move: CompletedMovement) {
|
||||
g,
|
||||
move.noField
|
||||
? `${p.name} moved a concealed No-Field${n > 1 ? ` with ${n - 1} physical forces` : ''} from ${territory(origin).name} to ${territory(to).name}. Its value remains concealed.`
|
||||
: `${p.name} moved ${n} forces from ${territory(origin).name} to ${territory(to).name}.`,
|
||||
: `${p.name} moved ${n} forces from ${movementOrigins(move).map(source => territory(source).name).join(' and ')} to ${territory(to).name}.`,
|
||||
);
|
||||
intrusion(g, p, to);
|
||||
if (origin !== to) openTerritoryEntry(g, p, to, s, n, elite, 'movement');
|
||||
if (movementOrigins(move).some(source => source !== to)) openTerritoryEntry(g, p, to, s, n, elite, 'movement');
|
||||
}
|
||||
/** A played movement card is already retired before queue advancement. */
|
||||
function finishMovementTurn(g: Game, id: string) {
|
||||
@@ -12668,9 +12749,15 @@ function validatePlan(
|
||||
);
|
||||
const w = cardOf(p, plan.weapon),
|
||||
d = cardOf(p, plan.defense);
|
||||
const planetologistWeapon = canUsePlanetologistWeapon(
|
||||
g,
|
||||
p,
|
||||
plan.leader,
|
||||
w,
|
||||
);
|
||||
requireRule(
|
||||
!plan.weapon || (w && isWeaponCard(w)),
|
||||
'Choose a weapon or worthless card.',
|
||||
!plan.weapon || (w && (isWeaponCard(w) || planetologistWeapon)),
|
||||
'Choose a weapon, worthless card, or eligible Planetologist Special.',
|
||||
);
|
||||
requireRule(
|
||||
!plan.defense || (d && isDefenseCard(d)),
|
||||
@@ -12685,7 +12772,7 @@ function validatePlan(
|
||||
'Without a leader, no battle cards can be played.',
|
||||
);
|
||||
requireRule(
|
||||
validBattleCardPair(w, d),
|
||||
validLeaderSkillBattleCardPair(w, d, planetologistWeapon),
|
||||
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
|
||||
);
|
||||
if (isStoneBurner(w)) {
|
||||
@@ -13094,7 +13181,15 @@ function findLegalBattlePlan(
|
||||
.map((l) => l.id),
|
||||
...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id),
|
||||
];
|
||||
const weapons = [null, ...p.hand.filter(isWeaponCard).map((c) => c.id)];
|
||||
const weapons = [
|
||||
null,
|
||||
...p.hand
|
||||
.filter(
|
||||
(card) =>
|
||||
isWeaponCard(card) || isPlanetologistBattleSpecialCard(card),
|
||||
)
|
||||
.map((card) => card.id),
|
||||
];
|
||||
const defenses = [
|
||||
null,
|
||||
...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id),
|
||||
@@ -13106,7 +13201,11 @@ function findLegalBattlePlan(
|
||||
for (const defense of defenses) {
|
||||
if (
|
||||
!accepts({ leader, weapon, defense }) ||
|
||||
!validBattleCardPair(cardOf(p, weapon), cardOf(p, defense))
|
||||
!validLeaderSkillBattleCardPair(
|
||||
cardOf(p, weapon),
|
||||
cardOf(p, defense),
|
||||
canUsePlanetologistWeapon(g, p, leader, cardOf(p, weapon)),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
for (const kwisatz of [false, true]) {
|
||||
@@ -13657,7 +13756,12 @@ function feasiblePrescience(
|
||||
];
|
||||
const weapons: (string | null)[] = [
|
||||
null,
|
||||
...p.hand.filter(isWeaponCard).map((c) => c.id),
|
||||
...p.hand
|
||||
.filter(
|
||||
(card) =>
|
||||
isWeaponCard(card) || isPlanetologistBattleSpecialCard(card),
|
||||
)
|
||||
.map((card) => card.id),
|
||||
];
|
||||
const defenses: (string | null)[] = [
|
||||
null,
|
||||
@@ -13920,7 +14024,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
hand: p.hand,
|
||||
plan,
|
||||
leader: controlledLeaders(g, p).find((l) => l.id === plan.leader),
|
||||
leaderSkills: g.leaderSkills?.assignments.filter((assignment) => leaderSkillController(g,assignment) === p.id).map((assignment) => ({skill:assignment.skill,leader:assignment.leader,faceUp:assignment.owner === p.id && !b.leaderSkillHidden?.[p.id],captured:assignment.owner !== p.id})),
|
||||
leaderSkills: battleLeaderSkills(g, p),
|
||||
forces: combatForces(g, p, b.territory, opponent),
|
||||
stronghold: strongholdEffect(g, p.id),
|
||||
lateDefense: b.lateDefense?.[p.id],
|
||||
@@ -15924,7 +16028,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
player.fremenMovementBlocked = { turn: quote.turn, move: quote.move };
|
||||
log(
|
||||
g,
|
||||
`${player.name}'s two-territory movement advantage was canceled for this move. The forces remain in place and no movement was spent. Choose a one-territory move; available ornithopters still apply.`,
|
||||
`${player.name}'s two-territory movement advantage was canceled for this move. The forces remain in place and no movement was spent. Choose a legal replacement; independent ornithopters and Planetologist still apply.`,
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
@@ -18396,7 +18500,7 @@ function richeseSpecialKaramaView(g: Game, p: Player) {
|
||||
|
||||
type CompletedMovement = Pick<
|
||||
MovementOrder,
|
||||
'player' | 'origin' | 'total' | 'to' | 'sector' | 'elite'
|
||||
'player' | 'origin' | 'origins' | 'total' | 'to' | 'sector' | 'elite'
|
||||
> & { noField: boolean };
|
||||
type MovementOrder = {
|
||||
source?: 'ambassador';
|
||||
@@ -18406,6 +18510,8 @@ type MovementOrder = {
|
||||
eliteGroup: Record<string, number>;
|
||||
elite: number;
|
||||
origin: string;
|
||||
origins?: string[];
|
||||
planetologist?: PlanetologistMovement;
|
||||
total: number;
|
||||
to: string;
|
||||
sector: number;
|
||||
@@ -21515,36 +21621,28 @@ function applyActionInner(
|
||||
g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p),
|
||||
'Your movement is not available.',
|
||||
);
|
||||
const skillLeader = planetologistLeader(g, id);
|
||||
requireRule(action.origins === undefined &&
|
||||
(action.planetologist === undefined || (skillLeader &&
|
||||
(action.planetologist === 'range' || action.planetologist === 'gather'))),
|
||||
'Choose an available Planetologist movement mode.');
|
||||
const planetologist: PlanetologistMovement | undefined = action.planetologist === undefined
|
||||
? undefined : {leader: skillLeader!, mode: action.planetologist as PlanetologistMovement['mode']};
|
||||
const {
|
||||
group,
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin,
|
||||
origins,
|
||||
total: n,
|
||||
noField,
|
||||
sourceKeys,
|
||||
} = forceGroup(g, p, action);
|
||||
} = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1);
|
||||
const to = stringField(action.territory),
|
||||
s = integer(action.sector, 0, 18, 'Sector');
|
||||
const advisors = arrivalAsAdvisor(g, p, to, origin);
|
||||
const lockedTurn = p.advisors?.[origin]?.lockedTurn;
|
||||
const {advisors, lockedTurn, wantsFighters} = movementStance(g, p, origins, to, action.fighters === true);
|
||||
requireRule(
|
||||
advisors ||
|
||||
lockedTurn !== g.turn ||
|
||||
!g.players.some((other) => other.id !== id && at(other, to)),
|
||||
'New advisors cannot become fighters this turn.',
|
||||
);
|
||||
const wantsFighters = advisors && action.fighters === true;
|
||||
requireRule(
|
||||
!wantsFighters ||
|
||||
(lockedTurn !== g.turn &&
|
||||
!at(p, to) &&
|
||||
(!(p.ally && at(getPlayer(g, p.ally), to)) ||
|
||||
sharesEcazOccupation(g, p, to))),
|
||||
'These advisors cannot flip to fighters on arrival.',
|
||||
);
|
||||
requireRule(
|
||||
!balisetPrevents(g, id, origin, to),
|
||||
origins.every(source => !balisetPrevents(g, id, source, to)),
|
||||
'Baliset prevents movement into this CHOAM territory; shipment remains possible.',
|
||||
);
|
||||
allowedEntry(g, p, to, s, false, advisors && !wantsFighters);
|
||||
@@ -21608,6 +21706,8 @@ function applyActionInner(
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin,
|
||||
...(planetologist ? {planetologist} : {}),
|
||||
...(planetologist?.mode === 'gather' ? {origins} : {}),
|
||||
total: n,
|
||||
to,
|
||||
sector: s,
|
||||
@@ -21624,12 +21724,13 @@ function applyActionInner(
|
||||
};
|
||||
if (action.discoveryOrnithopter !== undefined)
|
||||
move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move));
|
||||
validatePlanetologistMove(g, p, move);
|
||||
const speed = movementOrderRange(g, p, move);
|
||||
requireRule(
|
||||
sourceKeys.every(
|
||||
(key) =>
|
||||
gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, origin)),
|
||||
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
|
||||
) <= speed,
|
||||
),
|
||||
`That destination is blocked or more than ${speed} territories away.`,
|
||||
@@ -21657,14 +21758,15 @@ function applyActionInner(
|
||||
passed: [],
|
||||
};
|
||||
} else if (
|
||||
!move.ornithopterRange &&
|
||||
!move.ornithopterRange && !move.discoveryFlight &&
|
||||
p.faction === 'fremen' &&
|
||||
speed === 2 &&
|
||||
!(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
|
||||
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
|
||||
group.some(
|
||||
([key]) =>
|
||||
gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, origin)),
|
||||
) > 1,
|
||||
) > planetologistRange(1, planetologist?.mode),
|
||||
)
|
||||
) {
|
||||
g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move };
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MOBILE_STRONGHOLD,
|
||||
} from './board';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { planetologistLeader } from './planetologist-movement';
|
||||
import { validateAmbassadors } from './ecaz-ambassadors';
|
||||
import {
|
||||
homeworldMobileStrongholdMovementBlock,
|
||||
@@ -115,7 +116,7 @@ function movementDeclaration(
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
sectorKey(g, entry[0]) &&
|
||||
splitLocation(entry[0]).territory === move.origin &&
|
||||
(move.origins ?? [move.origin]).includes(splitLocation(entry[0]).territory) &&
|
||||
integer(entry[1]) &&
|
||||
entry[1] > 0,
|
||||
) &&
|
||||
@@ -131,6 +132,18 @@ function movementDeclaration(
|
||||
move.total,
|
||||
'The canceled movement has invalid declared physical groups.',
|
||||
);
|
||||
if (move.planetologist || move.origins) {
|
||||
requireContext(move.planetologist &&
|
||||
move.planetologist.leader === planetologistLeader(g, owner.id) &&
|
||||
!move.noField && !move.ornithopterEvent && !move.discoveryFlight &&
|
||||
(move.planetologist.mode === 'range'
|
||||
? move.origins === undefined
|
||||
: move.planetologist.mode === 'gather' && Array.isArray(move.origins) &&
|
||||
move.origins.length === 2 && new Set(move.origins).size === 2 &&
|
||||
move.origins[0] === move.origin &&
|
||||
JSON.stringify([...new Set(move.group.map(([key]) => splitLocation(key).territory))]) === JSON.stringify(move.origins)),
|
||||
'The canceled Planetologist movement lost its skilled leader or exact origins.');
|
||||
}
|
||||
if (move.noField)
|
||||
requireContext(
|
||||
record(move.noField) &&
|
||||
|
||||
@@ -27,6 +27,7 @@ export type CompletedMovementArrivalInput = Readonly<{
|
||||
order: Readonly<{
|
||||
player: string;
|
||||
origin: string;
|
||||
origins?: readonly string[];
|
||||
to: string;
|
||||
advisors: boolean;
|
||||
wantsFighters: boolean;
|
||||
@@ -91,7 +92,7 @@ export function quoteCompletedMovementArrival(
|
||||
);
|
||||
const base = { intrusion, retiresOrnithopter };
|
||||
// Intrusion also runs between sectors of one territory; entry does not.
|
||||
if (order.origin === order.to) return { ...base, reaction: null };
|
||||
if ((order.origins ?? [order.origin]).every(origin => origin === order.to)) return { ...base, reaction: null };
|
||||
|
||||
const ecaz = players.find((p) => p.faction === 'ecaz');
|
||||
const ambassador = input.ambassadors.find(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Card } from './cards';
|
||||
import { defenseTypes, weaponTypes } from './battle-cards';
|
||||
import {
|
||||
defenseTypes,
|
||||
isDefenseCard,
|
||||
validBattleCardPair,
|
||||
weaponTypes,
|
||||
} from './battle-cards';
|
||||
import type { LeaderSkillId } from './leader-skill-cards';
|
||||
|
||||
export type DirectLeaderSkillId =
|
||||
@@ -19,8 +24,8 @@ export type BattleLeaderSkill = {
|
||||
};
|
||||
|
||||
export type AppliedLeaderSkill = {
|
||||
skill: DirectLeaderSkillId;
|
||||
amount: 1 | 3;
|
||||
skill: DirectLeaderSkillId | 'planetologist';
|
||||
amount: 1 | 2 | 3;
|
||||
mode: 'normal' | 'skilled';
|
||||
};
|
||||
|
||||
@@ -37,6 +42,74 @@ const directSkills = new Set<LeaderSkillId>([
|
||||
'prana-bindu-adept',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Canonical GF9 base-deck green Special cards. Persisted IDs distinguish these
|
||||
* physical components from expansion cards sharing the engine's broad
|
||||
* `special` bucket. Cheap Hero and Cheap Heroine occupy the leader slot and
|
||||
* are deliberately absent.
|
||||
*/
|
||||
export const PLANETOLOGIST_BASE_SPECIALS = [
|
||||
{ id: 'treachery-24', name: 'Family Atomics', effect: 'atomics' },
|
||||
{ id: 'treachery-25', name: 'Weather Control', effect: 'weather' },
|
||||
{ id: 'treachery-26', name: 'Hajr', effect: 'hajr' },
|
||||
{ id: 'treachery-27', name: 'Tleilaxu Ghola', effect: 'ghola' },
|
||||
{ id: 'treachery-28', name: 'Harvester', effect: 'harvester' },
|
||||
{ id: 'treachery-29', name: 'Karama', effect: 'karama' },
|
||||
{ id: 'treachery-30', name: 'Karama', effect: 'karama' },
|
||||
{ id: 'treachery-31', name: 'Truthtrance', effect: 'truthtrance' },
|
||||
{ id: 'treachery-32', name: 'Truthtrance', effect: 'truthtrance' },
|
||||
] as const;
|
||||
|
||||
const planetologistBaseSpecialById = new Map<
|
||||
string,
|
||||
(typeof PLANETOLOGIST_BASE_SPECIALS)[number]
|
||||
>(
|
||||
PLANETOLOGIST_BASE_SPECIALS.map((card) => [card.id, card]),
|
||||
);
|
||||
|
||||
/** Shared engine/UI/bot eligibility: exact printed base component, no native use. */
|
||||
export function isPlanetologistBattleSpecialCard(
|
||||
card: Pick<Card, 'id' | 'name' | 'kind' | 'effect'> | undefined,
|
||||
): boolean {
|
||||
if (!card || card.kind !== 'special') return false;
|
||||
const canonical = planetologistBaseSpecialById.get(card.id);
|
||||
return (
|
||||
canonical?.name === card.name &&
|
||||
canonical.effect === card.effect
|
||||
);
|
||||
}
|
||||
|
||||
/** The substitute role belongs only to the actual assigned leader in battle. */
|
||||
export function canUsePlanetologistBattleSpecial(input: {
|
||||
assignments: readonly BattleLeaderSkill[];
|
||||
selectedLeader: string | null | undefined;
|
||||
card: Pick<Card, 'id' | 'name' | 'kind' | 'effect'> | undefined;
|
||||
}): boolean {
|
||||
return (
|
||||
isPlanetologistBattleSpecialCard(input.card) &&
|
||||
!!input.selectedLeader &&
|
||||
input.assignments.some(
|
||||
(assignment) =>
|
||||
assignment.skill === 'planetologist' &&
|
||||
assignment.leader === input.selectedLeader &&
|
||||
(!assignment.faceUp || assignment.captured),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Planetologist fills the slot without becoming an actual Weapon card. */
|
||||
export function validLeaderSkillBattleCardPair(
|
||||
weapon: Card | undefined,
|
||||
defense: Card | undefined,
|
||||
planetologistWeapon: boolean,
|
||||
): boolean {
|
||||
if (!planetologistWeapon) return validBattleCardPair(weapon, defense);
|
||||
return (
|
||||
(!defense || isDefenseCard(defense)) &&
|
||||
defense?.kind !== 'weirdingWay'
|
||||
);
|
||||
}
|
||||
|
||||
function isDirectSkill(skill: string): skill is DirectLeaderSkillId {
|
||||
return directSkills.has(skill as LeaderSkillId);
|
||||
}
|
||||
@@ -71,6 +144,20 @@ export function leaderSkillBattleBonus(input: {
|
||||
}): LeaderSkillBattleBonus {
|
||||
if (!input.selectedLeader) return { bonus: 0, applied: [] };
|
||||
const applied: AppliedLeaderSkill[] = [];
|
||||
if (
|
||||
input.selectedLeader.kind === 'disc' &&
|
||||
input.skilledLeaderSurvives &&
|
||||
canUsePlanetologistBattleSpecial({
|
||||
assignments: input.assignments,
|
||||
selectedLeader: input.selectedLeader.id,
|
||||
card: input.weapon,
|
||||
})
|
||||
)
|
||||
applied.push({
|
||||
skill: 'planetologist',
|
||||
amount: 2,
|
||||
mode: 'skilled',
|
||||
});
|
||||
for (const assignment of input.assignments) {
|
||||
if (!isDirectSkill(assignment.skill)) continue;
|
||||
if (!qualifies(assignment.skill, input.weapon, input.defense)) continue;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Game, GameView } from './engine';
|
||||
|
||||
export type PlanetologistMoveMode = 'range' | 'gather';
|
||||
export type PlanetologistMovement = {
|
||||
leader: string;
|
||||
mode: PlanetologistMoveMode;
|
||||
};
|
||||
|
||||
/** Public component ownership suffices; a captive never grants the upper band. */
|
||||
export function planetologistLeader(
|
||||
g: Game | GameView,
|
||||
player: string,
|
||||
): string | null {
|
||||
const assignment = g.leaderSkills?.assignments.find(
|
||||
(a) => a.owner === player && a.skill === 'planetologist',
|
||||
);
|
||||
if (
|
||||
!assignment ||
|
||||
('controller' in assignment && assignment.controller !== player) ||
|
||||
('faceUp' in assignment && assignment.faceUp === false)
|
||||
)
|
||||
return null;
|
||||
const leader = g.players
|
||||
.find((p) => p.id === player)
|
||||
?.leaders.find((l) => l.id === assignment.leader);
|
||||
if (!leader || leader.dead || leader.capturedBy || leader.gholaBy)
|
||||
return null;
|
||||
return leader.id;
|
||||
}
|
||||
|
||||
/** The cap belongs to the extra-range alternative, not the two-origin one. */
|
||||
export function planetologistRange(
|
||||
base: number,
|
||||
mode?: PlanetologistMoveMode,
|
||||
): number {
|
||||
return mode === 'range' ? Math.min(3, base + 1) : base;
|
||||
}
|
||||
+8
-7
@@ -3563,23 +3563,24 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Fourteen physical skills, private assignment choices and five connected battle disciplines in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines and Planetologist movement/battle effects in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
|
||||
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
||||
'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.',
|
||||
'The assigned leader keeps its skill through capture and returns with it if it survives its captive battle. The known skill identifies that captured leader; Harkonnen receives only its lower battle effect. Normal effects remain unavailable to both the captor and original faction while it is captured. A killed skilled leader returns its card to the shuffled skill deck.',
|
||||
'When reviving an own leader without an assigned skill, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Replacement while an earlier skill is captured remains a separate unresolved case.',
|
||||
'Bureaucrat, Spice Banker, Diplomat, Mentat, Suk Graduate, Rihani Decipherer, Sandmaster, Smuggler and Planetologist have readable cards but their effects are still missing. The complete deck remains present; this development prototype does not enable public module starts.',
|
||||
'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.',
|
||||
'Bureaucrat, Spice Banker, Diplomat, Mentat, Suk Graduate, Rihani Decipherer, Sandmaster and Smuggler have readable cards but their effects are still missing. The complete deck remains present; 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: ['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. Other nine effects, foreign gholas and optional-module combinations remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline controls are connected. Other effect controls remain missing.',evidence:['components/leader-skills.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers and legal visibility/revival actions. This is a legal prototype path; 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; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup and revival checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts'] },
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses and Planetologist movement/Special substitution. Other eight skills, foreign gholas and optional-module combinations remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.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 and Special weapon selection are connected. Other effect controls remain missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.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. This is a legal prototype path; 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; replacement entitlement and 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'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival and Planetologist movement/cancellation checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
Reference in new issue
Block a user