Seal exact native Homeworld Reinforcements costs and plan sources

This commit is contained in:
Quintine committed 2026-10-16 13:07:49 +11:00
1 parent 05c4c8c7fb
commit c64c08af89
32 files changed
+1484 -119

No files matched your search

+5 -1
View File
@@ -3,8 +3,9 @@ import { useId } from 'react';
import { HelpTip } from './help-tip';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
import { planClaimText, type PlanClaim } from '@/game/battle-promises';
import { reinforcementsSourceLabel } from './reinforcements-sources';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import type { Action, GameView, Plan } from '@/game/engine';
@@ -229,6 +230,9 @@ export function BattlePromises({
<br />
Kwisatz: {plan.kwisatz ? 'Included' : 'Not included'}
</p>
{plan.reinforcementsSources && (
<p className="fine">Native reserve fee: {reinforcementsSourceLabel(g, plan.reinforcementsSources)}</p>
)}
<p className="fine">
These choices satisfy your current commitments. Review and change
the other elements before sealing your plan.
+40 -8
View File
@@ -61,6 +61,8 @@ import { nativeShipmentSources } from '@/game/homeworld-options';
import { botBattleChoices } from '@/game/bot-battle-choices';
import { specialForceName, maxCombatDial, maxCombatSupport } from '@/game/combat';
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
import { reinforcementsBattleForces } from '@/game/reinforcements-homeworld';
import { ReinforcementsSources, reinforcementsSourceLabel } from './reinforcements-sources';
import {
StrongholdCardGallery,
StrongholdCopyChoice,
@@ -436,7 +438,26 @@ export function GameTable({
Number.isFinite(stormDial) ? Math.trunc(stormDial) : 0,
),
);
const ownBattleForces = g.battle?.ownForces;
const selectedWeaponId = battlePlanCardValue(g, 'weapon', weapon);
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' ||
selectedDefenseId === 'ecaz-reinforcements';
const [reinforcementsSourceDraft, setReinforcementsSourceDraft] = useState<{
key: string; index: number;
} | null>(null);
const reinforcementsSourceChoices = g.battle?.reinforcements?.homeworldSources;
const reinforcementsSourceKey = reinforcementsSourceChoices
? `${g.code}:${me.id}:${g.battle?.event ?? ''}:${JSON.stringify(reinforcementsSourceChoices)}` : '';
const reinforcementsSourceIndex = reinforcementsSourceDraft?.key === reinforcementsSourceKey
? Math.min(reinforcementsSourceDraft.index, Math.max(0, (reinforcementsSourceChoices?.length ?? 1) - 1)) : 0;
const selectedReserveSource = reinforcementsSourceChoices?.[reinforcementsSourceIndex];
const rawOwnBattleForces = g.battle?.ownForces;
const ownBattleForces = rawOwnBattleForces && reinforcementsSelected &&
selectedReserveSource && g.battle?.native === me.id
? reinforcementsBattleForces(rawOwnBattleForces, selectedReserveSource[g.battle!.territory])
: rawOwnBattleForces;
const occupyProfile = ecazOccupyOwnProfile(g);
const battleDialMinimum = occupyProfile?.fixedEcazDial ?? 0;
const battleFreeSupport = ownBattleForces?.freeSupport ??
@@ -542,15 +563,10 @@ export function GameTable({
selectedLeader: committed.leader ? String(committed.leader.value ?? '') : leader,
card,
});
const selectedWeaponId = battlePlanCardValue(g, 'weapon', weapon);
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' ||
selectedDefenseId === 'ecaz-reinforcements';
const reinforcementsPlanReason = !reinforcementsSelected ? null :
!g.battle?.reinforcements ? 'Reinforcements is unavailable in this battle.' :
g.battle.reinforcements.blocked;
g.battle.reinforcements.blocked ??
(g.homeworlds && !selectedReserveSource ? 'Choose the exact native Reinforcements reserve sources.' : null);
const selectedBattleKwisatz = kwisatz && !!me.kwisatz?.active &&
!me.kwisatz.dead && !g.battle?.kwisatzBlocked &&
(!me.kwisatz.usedAt || me.kwisatz.usedAt === g.battle?.territory);
@@ -5105,6 +5121,12 @@ export function GameTable({
setWeapon(plan.weapon ?? '');
setDefense(plan.defense ?? '');
setKwisatz(!!plan.kwisatz);
if (plan.reinforcementsSources) {
const chosen = JSON.stringify(plan.reinforcementsSources);
const index = reinforcementsSourceChoices?.findIndex(choice => JSON.stringify(choice) === chosen);
if (index !== undefined && index >= 0)
setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index });
}
}}
/>
)}
@@ -5394,6 +5416,11 @@ export function GameTable({
{g.battle.reinforcements.blocked && ` ${g.battle.reinforcements.blocked}`}
</p>
)}
{reinforcementsSelected && reinforcementsSourceChoices && (
<ReinforcementsSources game={g} choices={reinforcementsSourceChoices}
selected={reinforcementsSourceIndex} busy={busy}
onChange={index => setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index })} />
)}
{g.battle.reinforcements?.noField && <p className="fine">
Provisional native order: your No-Field first materializes{' '}
{g.battle.reinforcements.noField.materializes} forces, leaving{' '}
@@ -5505,6 +5532,8 @@ export function GameTable({
leader,
weapon,
defense,
...(reinforcementsSelected && selectedReserveSource
? { reinforcementsSources: selectedReserveSource } : {}),
}),
!!occupyControl?.blocked || !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid,
)}
@@ -5520,6 +5549,9 @@ export function GameTable({
spice committed.
</p>
)}
{g.battle.plans[me.id]?.reinforcementsSources && (
<p className="fine">Sealed reserve fee: {reinforcementsSourceLabel(g, g.battle.plans[me.id].reinforcementsSources!)}</p>
)}
</>
)}
</>
+2 -1
View File
@@ -3,7 +3,8 @@
import { useId, type ReactElement } from 'react';
import type { GameView } from '@/game/engine';
import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
import { TRUTH_CARD_NAMES, type TruthFact } from '@/game/truthtrance';
import type { TruthFact } from '@/game/truthtrance';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
import { SHIPMENT_EXPRESSION_MAX_DEPTH, SHIPMENT_EXPRESSION_MAX_LEAVES } from '@/game/shipment-promises';
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
+7
View File
@@ -4,6 +4,7 @@ import { HelpTip } from './help-tip';
import { faction } from '@/game/catalog';
import { leaderStrengthLabel } from '@/game/cards';
import { BattleComponentInspection } from './battle-component-inspection';
import { reinforcementsSourceLabel } from './reinforcements-sources';
export function PrivateBattlePlan({ game }: { game: GameView }) {
const insight = game.battle?.fullPlanInsight;
@@ -74,6 +75,12 @@ export function PrivateBattlePlan({ game }: { game: GameView }) {
<dt>Kwisatz Haderach</dt>
<dd>{insight.plan.kwisatz ? 'Included' : 'Not included'}</dd>
</div>
{insight.plan.reinforcementsSources && (
<div>
<dt>Native Reinforcements fee</dt>
<dd>{reinforcementsSourceLabel(game, insight.plan.reinforcementsSources)}</dd>
</div>
)}
</dl>
<BattleComponentInspection
playerName={
+36
View File
@@ -0,0 +1,36 @@
'use client';
import type { GameView } from '@/game/engine';
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
import { homeworldName } from '@/game/homeworld-shipment-options';
export function reinforcementsSourceLabel(game: GameView, sources: NativeReserveSelections): string {
return Object.entries(sources).map(([world, forces]) =>
`${homeworldName(game, world)}: ${forces.normal} normal + ${forces.elite} special`).join('; ');
}
export function ReinforcementsSources({ game, choices, selected, onChange, busy }: {
game: GameView;
choices: readonly NativeReserveSelections[];
selected: number;
onChange: (index: number) => void;
busy: boolean;
}) {
if (!choices.length) return null;
return <fieldset disabled={busy} className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3">
<legend>Reinforcements reserve sources</legend>
<p className="fine">Three original native reserves go to the Tanks, normal first. These are not the invading army or future Harass returns.</p>
{choices.length > 1 ? <label className="block space-y-1">
<span>Native reserve fee allocation</span>
<select value={selected} onChange={event => onChange(Number(event.target.value))}>
{choices.map((sources, index) => <option key={index} value={index}>
{reinforcementsSourceLabel(game, sources)}
</option>)}
</select>
</label> : <p className="fine">{reinforcementsSourceLabel(game, choices[0])}</p>}
{choices.length > 1 && <p className="fine" aria-live="polite">
Selected fee: {reinforcementsSourceLabel(game, choices[selected])}.
</p>}
<p className="notice">Provisional own-World composition: counters paying the fee cannot also fund your physical dial. The original printed face and support traits stay fixed; the bonus adds no physical counters.</p>
</fieldset>;
}
+1
View File
@@ -72,6 +72,7 @@ export function ShipmentPromises({
id => id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
: g.allLeaders.find(leader => leader.id === id)?.name ?? id,
id => g.combatLocations.find(place => place.id === id)!.name,
)
: `Will you ${shipmentClaimText(p)} this turn?`}
</li>
+1 -1
View File
@@ -2,7 +2,7 @@
import { useId } from 'react';
import { Input } from '@/components/ui/input';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
import type { CardCountFact } from '@/game/truthtrance-card-count';
import {
HAND_INVENTORY_LABELS,
+7 -5
View File
@@ -95,6 +95,7 @@ export function Truthtrance({
? 'Cheap Hero / Heroine'
: (g.allLeaders.find((l) => l.id === id)?.name ?? id);
const name = (id: string) => g.players.find((p) => p.id === id)!.name;
const locationName = (id: string) => g.combatLocations.find(place => place.id === id)!.name;
const question: TruthQuestion =
kind === 'fact'
? {
@@ -183,7 +184,7 @@ export function Truthtrance({
</p>
{w.question && (
<blockquote>
<p>{truthQuestionText(w.question, leaderName)}</p>
<p>{truthQuestionText(w.question, leaderName, locationName)}</p>
<p className="fine">Asked of {name(w.question.target)}</p>
</blockquote>
)}
@@ -366,10 +367,10 @@ export function Truthtrance({
)}
<p className="notice" aria-label="Public grouped question preview">
{kind === 'mixedShipment'
? invalidMixed ?? truthQuestionText(question, leaderName)
? invalidMixed ?? truthQuestionText(question, leaderName, locationName)
: invalidShipment
? 'Enter a whole minimum of one to twenty forces.'
: truthQuestionText(question, leaderName)}
: truthQuestionText(question, leaderName, locationName)}
</p>
</>
) : kind === 'battlePlan' ? (
@@ -404,7 +405,7 @@ export function Truthtrance({
</fieldset>
))}
<p className="notice">
{truthQuestionText(question, leaderName)}
{truthQuestionText(question, leaderName, locationName)}
</p>
{g.battle &&
![g.battle.attacker, g.battle.defender].includes(
@@ -436,7 +437,7 @@ export function Truthtrance({
<p className="notice">
{invalidFact
? 'Resolve the marked fields before asking this question.'
: truthQuestionText(question, leaderName)}
: truthQuestionText(question, leaderName, locationName)}
</p>
</>
) : (
@@ -514,6 +515,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
: (g.allLeaders.find((l) => l.id === id)?.name ?? id),
id => g.combatLocations.find(place => place.id === id)!.name,
)}
</p>
<strong>