Compose independent utility cards with classic Leader Skills
This commit is contained in:
1 parent
40a5e51229
commit
5a32c1ac85
23 files changed
+1845
-87
No files matched your search
@@ -27,15 +27,16 @@ import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
|
||||
export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, leader: string, weapon: string, defense: string) {
|
||||
const me = game.players.find(player => player.id === game.me)!;
|
||||
const fixed = battlePlanCommitments(game)[slot];
|
||||
const assignments = game.leaderSkills?.assignments.filter(a => a.controller === game.me) ?? [];
|
||||
return (me.hand ?? []).filter(card => {
|
||||
const w = slot === 'weapon' ? card : me.hand?.find(c => c.id === weapon);
|
||||
const d = slot === 'defense' ? card : me.hand?.find(c => c.id === defense);
|
||||
const planetologistWeapon = canUsePlanetologistBattleSpecial({
|
||||
assignments: game.leaderSkills?.assignments.filter(a => a.controller === game.me) ?? [],
|
||||
assignments,
|
||||
selectedLeader: leader, card: w,
|
||||
});
|
||||
return (card.id !== 'ecaz-reinforcements' || (!!game.battle?.reinforcements && !game.battle.reinforcements.blocked)) &&
|
||||
(card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked)) &&
|
||||
return (card.id !== 'ecaz-reinforcements' || (!!game.battle?.reinforcements && !game.battle.reinforcements.blocked) || (slot === 'weapon' && planetologistWeapon)) &&
|
||||
(card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked) || (slot === 'weapon' && planetologistWeapon)) &&
|
||||
(!isMirrorWeapon(card) || !mirrorWeaponModeBlock(game)) &&
|
||||
battleCardSlotEligible(slot, card, { planetologistWeapon }) &&
|
||||
(!fixed || fixedBattleInspectionMatches(slot, fixed.value, card.id, card)) &&
|
||||
|
||||
@@ -233,6 +233,9 @@ export function BattlePromises({
|
||||
{plan.reinforcementsSources && (
|
||||
<p className="fine">Native reserve fee: {reinforcementsSourceLabel(g, plan.reinforcementsSources)}</p>
|
||||
)}
|
||||
{plan.planetologistSpecial && (
|
||||
<p className="fine">Planetologist alternate Special: +2 if the leader survives; no normal card effect.</p>
|
||||
)}
|
||||
<p className="fine">
|
||||
These choices satisfy your current commitments. Review and change
|
||||
the other elements before sealing your plan.
|
||||
|
||||
+47
-23
@@ -164,7 +164,7 @@ import {
|
||||
isStoneBurner,
|
||||
isMirrorWeapon,
|
||||
} from '@/game/battle-cards';
|
||||
import { validBattleSlotPair } from '@/game/battle-card-slots';
|
||||
import { validBattleSlotPair, usesNativeBattleSpecial } from '@/game/battle-card-slots';
|
||||
import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext } from '@/game/stone-burner';
|
||||
import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
|
||||
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
|
||||
@@ -203,7 +203,7 @@ import { HarassWithdrawChoice } from './harass-withdraw-choice';
|
||||
import { PrivateBattlePlan } from './private-battle-plan';
|
||||
import { SpiceBankerControl, spiceBankerControlState } from './spice-banker';
|
||||
import { SpiceBankerIncome } from './spice-banker-income';
|
||||
import { canUsePlanetologistBattleSpecial } from '../game/leader-skill-combat';
|
||||
import { canUsePlanetologistBattleSpecial, planetologistSpecialNeedsChoice } from '../game/leader-skill-combat';
|
||||
import type { Card } from '../game/cards';
|
||||
import { RevealedBattle } from './revealed-battle';
|
||||
import { DiplomatDefense } from './diplomat-defense';
|
||||
@@ -428,6 +428,7 @@ export function GameTable({
|
||||
const [target, setTarget] = useState('');
|
||||
const [leader, setLeader] = useState('');
|
||||
const [weapon, setWeapon] = useState('');
|
||||
const [planetologistDraft, setPlanetologistDraft] = useState<{ key: string; enabled: boolean } | null>(null);
|
||||
const [defense, setDefense] = useState('');
|
||||
const [dial, setDial] = useState(0);
|
||||
const [stormDial, setStormDial] = useState(0);
|
||||
@@ -442,8 +443,22 @@ export function GameTable({
|
||||
const selectedDefenseId = battlePlanCardValue(g, 'defense', defense);
|
||||
const selectedBattleWeapon = me.hand?.find(c => c.id === selectedWeaponId);
|
||||
const selectedBattleDefense = me.hand?.find(c => c.id === selectedDefenseId);
|
||||
const reinforcementsSelected = selectedWeaponId === 'ecaz-reinforcements' ||
|
||||
const committed = battlePlanCommitments(g);
|
||||
const selectedBattleLeader = committed.leader ? String(committed.leader.value ?? '') : leader;
|
||||
const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [];
|
||||
const planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({
|
||||
assignments: battleSkills, selectedLeader: selectedBattleLeader, card,
|
||||
});
|
||||
const planetologistRoleKey = `${g.battle?.event ?? ''}:${selectedBattleLeader}:${selectedWeaponId}`;
|
||||
const planetologistEligible = !!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon);
|
||||
const planetologistRoleChoice = planetologistEligible && planetologistSpecialNeedsChoice(selectedBattleWeapon);
|
||||
const planetologistAlternate = planetologistRoleChoice &&
|
||||
planetologistDraft?.key === planetologistRoleKey && planetologistDraft.enabled;
|
||||
const planetologistWeapon = planetologistEligible && (!planetologistRoleChoice || planetologistAlternate);
|
||||
const reinforcementsSelected = (selectedWeaponId === 'ecaz-reinforcements' && !planetologistWeapon) ||
|
||||
selectedDefenseId === 'ecaz-reinforcements';
|
||||
const harassSelected = (selectedWeaponId === 'ecaz-harass-withdraw' && !planetologistWeapon) ||
|
||||
selectedDefenseId === 'ecaz-harass-withdraw';
|
||||
const [reinforcementsSourceDraft, setReinforcementsSourceDraft] = useState<{
|
||||
key: string; index: number;
|
||||
} | null>(null);
|
||||
@@ -529,10 +544,6 @@ export function GameTable({
|
||||
);
|
||||
const [discardCards, setDiscardCards] = useState<Record<string, boolean>>({});
|
||||
const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null;
|
||||
const committed = battlePlanCommitments(g);
|
||||
const selectedBattleLeader = committed.leader
|
||||
? String(committed.leader.value ?? '')
|
||||
: leader;
|
||||
const paidBattleSupport = Math.max(0, battleSupport - strongholdBankSupport);
|
||||
const selectedBattleAllyPayment =
|
||||
allyPayment === ''
|
||||
@@ -558,11 +569,6 @@ export function GameTable({
|
||||
0,
|
||||
Math.min(Math.trunc(bankerSpice), bankerControl.maximum),
|
||||
);
|
||||
const planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({
|
||||
assignments: g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [],
|
||||
selectedLeader: committed.leader ? String(committed.leader.value ?? '') : leader,
|
||||
card,
|
||||
});
|
||||
const reinforcementsPlanReason = !reinforcementsSelected ? null :
|
||||
!g.battle?.reinforcements ? 'Reinforcements is unavailable in this battle.' :
|
||||
g.battle.reinforcements.blocked ??
|
||||
@@ -579,9 +585,9 @@ export function GameTable({
|
||||
committed.dial ? Number(committed.dial.value) : battleDial,
|
||||
battleFreeSupport ? 0 : battleSupport);
|
||||
const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense,
|
||||
!!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon));
|
||||
planetologistWeapon);
|
||||
const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw,
|
||||
selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw',
|
||||
harassSelected,
|
||||
committed.dial ? Number(committed.dial.value) : battleDial,
|
||||
battleFreeSupport ? 0 : battleSupport);
|
||||
const selectedSpecialWeapon = me.hand?.find(
|
||||
@@ -590,7 +596,7 @@ export function GameTable({
|
||||
const mirrorSelected = isMirrorWeapon(selectedSpecialWeapon);
|
||||
const stoneContext = g.battle?.stoneBurnerContext;
|
||||
const stoneCandidateContext = selectedSpecialWeapon &&
|
||||
(selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw')
|
||||
harassSelected
|
||||
? stoneBurnerWithdrawalContext(stoneContext?.occupy,
|
||||
g.battle!.attacker === me.id ? 'attacker' : 'defender', g.battle?.harassWithdraw?.occupy)
|
||||
: stoneContext?.occupy;
|
||||
@@ -3105,7 +3111,7 @@ export function GameTable({
|
||||
forces remaining after a successful withdrawal. An opposing
|
||||
successful Traitor cancels that withdrawal.
|
||||
</p>
|
||||
{g.battle?.cards.some(card => card.id === 'ecaz-reinforcements') && (
|
||||
{Object.values(g.battle?.plans ?? {}).some(plan => plan && usesNativeBattleSpecial(plan, 'ecaz-reinforcements')) && (
|
||||
<p className="fine">
|
||||
Reinforcements still pays its original three-reserve cost.
|
||||
Its +2 normal-score bonus does not add undialed physical
|
||||
@@ -5120,6 +5126,10 @@ export function GameTable({
|
||||
setLeader(plan.leader ?? '');
|
||||
setWeapon(plan.weapon ?? '');
|
||||
setDefense(plan.defense ?? '');
|
||||
setPlanetologistDraft({
|
||||
key: `${g.battle?.event ?? ''}:${plan.leader ?? ''}:${plan.weapon ?? ''}`,
|
||||
enabled: plan.planetologistSpecial === true,
|
||||
});
|
||||
setKwisatz(!!plan.kwisatz);
|
||||
if (plan.reinforcementsSources) {
|
||||
const chosen = JSON.stringify(plan.reinforcementsSources);
|
||||
@@ -5387,14 +5397,24 @@ export function GameTable({
|
||||
selectedBattleLeader, selectedWeaponId, selectedDefenseId)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}{name === 'Weapon' && planetologistSpecial(c) ? ' · Planetologist +2, discard after battle' : ''}
|
||||
{c.name}{name === 'Weapon' && planetologistSpecial(c)
|
||||
? planetologistSpecialNeedsChoice(c) ? ' · normal or Planetologist role' : ' · Planetologist +2, discard after battle'
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
<HarassWithdrawGuide preview={g.battle.harassWithdraw} state={harassControl} />
|
||||
{g.homeworlds && g.battle.harassWithdraw && (
|
||||
{planetologistRoleChoice && (
|
||||
<label className="my-3 flex items-start gap-3">
|
||||
<input type="checkbox" checked={!!planetologistAlternate}
|
||||
onChange={event => setPlanetologistDraft({ key: planetologistRoleKey, enabled: event.target.checked })} />
|
||||
<span>Use {selectedBattleWeapon!.name} as Planetologist Special (+2 leader; no normal card effect)</span>
|
||||
</label>
|
||||
)}
|
||||
{!(planetologistWeapon && selectedWeaponId === 'ecaz-harass-withdraw') &&
|
||||
<HarassWithdrawGuide preview={g.battle.harassWithdraw} state={harassControl} />}
|
||||
{g.homeworlds && g.battle.harassWithdraw && !(planetologistWeapon && selectedWeaponId === 'ecaz-harass-withdraw') && (
|
||||
<p className="notice">
|
||||
Homeworld timing: actual undialed counters
|
||||
return to your native reserve Homeworlds, never to
|
||||
@@ -5405,7 +5425,7 @@ export function GameTable({
|
||||
thresholds. Harass cannot be played on any own World.
|
||||
</p>
|
||||
)}
|
||||
{g.battle.reinforcements && (
|
||||
{g.battle.reinforcements && !(planetologistWeapon && selectedWeaponId === 'ecaz-reinforcements') && (
|
||||
<p className="notice">
|
||||
Reinforcements may occupy either card slot but is neither a weapon nor a defense.
|
||||
It adds 2 to battle strength without changing the physical dial or spice support.
|
||||
@@ -5421,7 +5441,7 @@ export function GameTable({
|
||||
selected={reinforcementsSourceIndex} busy={busy}
|
||||
onChange={index => setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index })} />
|
||||
)}
|
||||
{g.battle.reinforcements?.noField && <p className="fine">
|
||||
{g.battle.reinforcements?.noField && !(planetologistWeapon && selectedWeaponId === 'ecaz-reinforcements') && <p className="fine">
|
||||
Provisional native order: your No-Field first materializes{' '}
|
||||
{g.battle.reinforcements.noField.materializes} forces, leaving{' '}
|
||||
{g.battle.reinforcements.noField.reservesAfterReveal} own reserves.
|
||||
@@ -5429,7 +5449,7 @@ export function GameTable({
|
||||
a later Harass return cannot fund or change its original cost.
|
||||
</p>}
|
||||
{reinforcementsSelected &&
|
||||
(selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw') && (
|
||||
harassSelected && (
|
||||
<p className="fine">
|
||||
Provisional combined timing: pay the original three-reserve
|
||||
Reinforcements cost before Harass & Withdraw returns any
|
||||
@@ -5439,7 +5459,7 @@ export function GameTable({
|
||||
)}
|
||||
{reinforcementsPlanReason && <p role="status" className="notice">{reinforcementsPlanReason}</p>}
|
||||
{!battlePairValid && <p role="status" className="notice">Choose two compatible battle cards. A single physical card cannot fill both slots.</p>}
|
||||
{selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon) && (
|
||||
{selectedBattleWeapon && planetologistWeapon && (
|
||||
<p className="notice">
|
||||
{selectedBattleWeapon.name} adds 2 to the surviving Planetologist leader.
|
||||
It makes no weapon attack and does not use its normal effect.
|
||||
@@ -5517,6 +5537,7 @@ export function GameTable({
|
||||
: 'Seal battle plan',
|
||||
bindBattlePlanCommitments(g, {
|
||||
type: 'battlePlan',
|
||||
...(planetologistAlternate ? { planetologistSpecial: true } : {}),
|
||||
...(battleBankerSpice
|
||||
? { bankerSpice: battleBankerSpice }
|
||||
: {}),
|
||||
@@ -5549,6 +5570,9 @@ export function GameTable({
|
||||
spice committed.
|
||||
</p>
|
||||
)}
|
||||
{g.battle.plans[me.id]?.planetologistSpecial && (
|
||||
<p className="fine">Planetologist alternate Special: +2 if the leader survives; no normal card effect.</p>
|
||||
)}
|
||||
{g.battle.plans[me.id]?.reinforcementsSources && (
|
||||
<p className="fine">Sealed reserve fee: {reinforcementsSourceLabel(g, g.battle.plans[me.id].reinforcementsSources!)}</p>
|
||||
)}
|
||||
|
||||
@@ -75,6 +75,12 @@ export function PrivateBattlePlan({ game }: { game: GameView }) {
|
||||
<dt>Kwisatz Haderach</dt>
|
||||
<dd>{insight.plan.kwisatz ? 'Included' : 'Not included'}</dd>
|
||||
</div>
|
||||
{insight.plan.planetologistSpecial && (
|
||||
<div>
|
||||
<dt>Planetologist alternate Special</dt>
|
||||
<dd>+2 if the leader survives; the normal card effect is not used.</dd>
|
||||
</div>
|
||||
)}
|
||||
{insight.plan.reinforcementsSources && (
|
||||
<div>
|
||||
<dt>Native Reinforcements fee</dt>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GameView } from '@/game/engine';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { leaderStrengthLabel } from '@/game/cards';
|
||||
import { isStoneBurner } from '@/game/battle-cards';
|
||||
import { usesNativeBattleSpecial } from '@/game/battle-card-slots';
|
||||
import { stoneBurnerComparison, stoneBurnerWithdrawalContext } from '@/game/stone-burner';
|
||||
import { RevealedPlanPieces } from './revealed-plan-pieces';
|
||||
import { KwisatzInspector } from './kwisatz-inspector';
|
||||
@@ -13,7 +14,7 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
let stoneContext = battle.stoneBurnerContext?.occupy;
|
||||
const withdrawing = [battle.attacker, battle.defender].filter(id => {
|
||||
const plan = battle.plans[id];
|
||||
return plan && [plan.weapon, plan.defense].includes('ecaz-harass-withdraw');
|
||||
return plan && usesNativeBattleSpecial(plan, 'ecaz-harass-withdraw');
|
||||
});
|
||||
for (const id of withdrawing) {
|
||||
const occupy = battle.ecazOccupy?.lead === id ? {
|
||||
@@ -84,6 +85,9 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
{!!plan.bankerSpice && (
|
||||
<span className="revealed-plan-detail">Spice Banker: {plan.bankerSpice} spice committed</span>
|
||||
)}
|
||||
{plan.planetologistSpecial && (
|
||||
<span className="revealed-plan-detail">Planetologist alternate Special · +2 if leader survives · no normal card effect</span>
|
||||
)}
|
||||
{battle.native === id && !battle.cards.some(isStoneBurner) && (
|
||||
<span className="revealed-plan-detail">
|
||||
Native Homeworld bonus: +{battle.nativeBattleStrength} to the
|
||||
|
||||
Reference in new issue
Block a user