Implement Homeworld mechanics through Caladan reinforcement checkpoint
Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
This commit is contained in:
1 parent
3be49512f7
commit
28ba1618ba
266 files changed
+47680
-16851
No files matched your search
+419
-158
@@ -4,6 +4,23 @@ import {
|
||||
liveShipmentPromises,
|
||||
} from '@/game/shipment-promises';
|
||||
import { ShipmentPromises } from './shipment-promises';
|
||||
import { HomeworldShipment } from './homeworld-shipment';
|
||||
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
|
||||
import { CaladanReinforcement } from './caladan-reinforcement';
|
||||
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
|
||||
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
|
||||
import { JunctionTransport } from './junction-transport';
|
||||
import { BiddingEnd } from './bidding-end';
|
||||
import { homeworldShipmentPaymentBlock } from '@/game/homeworld-payment-options';
|
||||
import {
|
||||
HomeworldTable,
|
||||
NativeShipmentChoice,
|
||||
EmperorHomeworldMovement,
|
||||
} from './homeworld-table';
|
||||
import { nativeShipmentSources } from '@/game/homeworld-options';
|
||||
import { botBattleChoices } from '@/game/bot-battle-choices';
|
||||
import { specialForceName } from '@/game/combat';
|
||||
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
|
||||
import {
|
||||
StrongholdCardGallery,
|
||||
StrongholdCopyChoice,
|
||||
@@ -142,6 +159,10 @@ export function GameTable({
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const combatName = (id: string) =>
|
||||
g.combatLocations?.find((place) => place.id === id)?.name ??
|
||||
gameTerritories(g).find((place) => place.id === id)?.name ??
|
||||
id;
|
||||
const turnOwner = tableActionOwner(g);
|
||||
const turnOwnerName = g.players.find((p) => p.id === turnOwner)?.name;
|
||||
const setupStage = g.setupStage;
|
||||
@@ -230,7 +251,14 @@ export function GameTable({
|
||||
const nextBotFaction = availableBotFactions.some((f) => f.id === botFaction)
|
||||
? botFaction
|
||||
: (availableBotFactions[0]?.id ?? '');
|
||||
const [selected, setSelected] = useState(g.battle?.territory ?? 'arrakeen');
|
||||
const [selectedId, setSelected] = useState(() =>
|
||||
gameTerritories(g).some((location) => location.id === g.battle?.territory)
|
||||
? g.battle!.territory
|
||||
: 'arrakeen',
|
||||
);
|
||||
const selected = gameTerritories(g).some((location) => location.id === selectedId)
|
||||
? selectedId
|
||||
: 'arrakeen';
|
||||
const [sector, setSector] = useState(10);
|
||||
const [amount, setAmount] = useState(1);
|
||||
const [bidDraft, setBidDraft] = useState({ auction: '', value: 1 });
|
||||
@@ -246,6 +274,23 @@ export function GameTable({
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: (me.spice ?? 0) + g.aid.available;
|
||||
const [eliteAmount, setEliteAmount] = useState(0);
|
||||
const [homeworldDraft, setHomeworldDraft] = useState<{
|
||||
key: string;
|
||||
sources: NativeReserveSelections;
|
||||
} | null>(null);
|
||||
const homeworldSourceKey = JSON.stringify([
|
||||
g.code,
|
||||
g.turn,
|
||||
g.phase,
|
||||
g.active,
|
||||
g.homeworlds,
|
||||
amount,
|
||||
eliteAmount,
|
||||
]);
|
||||
const homeworldSources =
|
||||
homeworldDraft?.key === homeworldSourceKey
|
||||
? homeworldDraft.sources
|
||||
: nativeShipmentSources(g, amount, eliteAmount);
|
||||
const [freeCyborgFirst, setFreeCyborgFirst] = useState(true);
|
||||
const [allyEliteAmount, setAllyEliteAmount] = useState(0);
|
||||
const [eliteForces, setEliteForces] = useState<Record<string, number>>({});
|
||||
@@ -297,7 +342,7 @@ export function GameTable({
|
||||
? ownBattleForces.freeSupport
|
||||
? 0
|
||||
: (ownBattleForces.normalFixedHalf ? 0 : ownBattleForces.normal) +
|
||||
ownBattleForces.elite
|
||||
(ownBattleForces.eliteFreeSupport ? 0 : ownBattleForces.elite)
|
||||
: Number.POSITIVE_INFINITY,
|
||||
);
|
||||
const battleSupport = Number.isFinite(support)
|
||||
@@ -359,6 +404,22 @@ export function GameTable({
|
||||
const payment =
|
||||
!me.ally || allyPayment === '' ? {} : { allyPayment: Number(allyPayment) };
|
||||
const shipmentProblems: string[] = [];
|
||||
if (homeworldSources) {
|
||||
const homes = g.homeworlds!.worlds!.filter((w) => w.native === me.id);
|
||||
if (
|
||||
homes.some((home) =>
|
||||
(['normal', 'elite'] as const).some((kind) => {
|
||||
const n = homeworldSources[home.id]?.[kind];
|
||||
return (
|
||||
!Number.isSafeInteger(n) || n < 0 || n > home.forces[me.id][kind]
|
||||
);
|
||||
}),
|
||||
)
|
||||
)
|
||||
shipmentProblems.push(
|
||||
'Choose available whole numbers of normal forces and Sardaukar from each Homeworld.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
liveShipmentPromises(g.shipmentPromises, me.id, g.turn).some(
|
||||
(p) => matchesShipment(p, { territory: selected, amount }) !== p.answer,
|
||||
@@ -416,6 +477,10 @@ export function GameTable({
|
||||
selectedAllyPayment >= 0 &&
|
||||
selectedAllyPayment <= shipmentCost;
|
||||
if (shipmentCost !== null) {
|
||||
if (validShipmentShare) {
|
||||
const blocked = homeworldShipmentPaymentBlock(g, shipmentCost, selectedAllyPayment);
|
||||
if (blocked) shipmentProblems.push(blocked);
|
||||
}
|
||||
if (!validShipmentShare)
|
||||
shipmentProblems.push(
|
||||
`Ally payment must be a whole number from 0 to ${shipmentCost}.`,
|
||||
@@ -462,7 +527,7 @@ export function GameTable({
|
||||
!!ownNoField &&
|
||||
!!source &&
|
||||
ownNoField.location.territory === splitLocation(source).territory;
|
||||
const includesNoField = moveNoField && markerAtSource;
|
||||
const includesNoField = moveNoField && markerAtSource && !g.homeworldMobility?.noFieldMovementBlocked;
|
||||
const physicalDraftEntries = combineSectors
|
||||
? Object.entries(moveForces).filter(
|
||||
([key]) =>
|
||||
@@ -538,8 +603,10 @@ export function GameTable({
|
||||
);
|
||||
const act = (a: Action) => void send(a);
|
||||
const choose = (id: string) => {
|
||||
const location = gameTerritories(g).find((candidate) => candidate.id === id);
|
||||
if (!location) return;
|
||||
setSelected(id);
|
||||
setSector(territory(id).sectors[0]);
|
||||
setSector(location.sectors[0]);
|
||||
};
|
||||
const leaderName = (id: string) =>
|
||||
id === CHEAP_HERO_TRAITOR
|
||||
@@ -997,7 +1064,7 @@ export function GameTable({
|
||||
? 'awakened'
|
||||
: `${me.kwisatz.losses} of 7 battle losses`}
|
||||
{me.kwisatz.usedAt
|
||||
? ` · used in ${territory(me.kwisatz.usedAt).name} this turn`
|
||||
? ` · used in ${combatName(me.kwisatz.usedAt)} this turn`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
@@ -1276,6 +1343,7 @@ export function GameTable({
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
<HomeworldTable game={g} />
|
||||
<div className="territory-detail">
|
||||
<div>
|
||||
<span className="eyebrow">
|
||||
@@ -1373,6 +1441,10 @@ export function GameTable({
|
||||
{g.status === 'finished' ? 'FINAL OUTCOME' : 'YOUR NEXT DECISION'}
|
||||
</div>
|
||||
<PrivateBattlePlan game={g} />
|
||||
{g.biddingEnd && <BiddingEnd game={g} act={act} busy={busy} />}
|
||||
{g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && (
|
||||
<JunctionTransport key={me.id === g.junctionTransport.owner ? g.junctionTransport.offerEvent : g.junctionTransport.event} game={g} act={act} busy={busy} />
|
||||
)}
|
||||
<RicheseNoFieldControls
|
||||
game={g}
|
||||
act={act}
|
||||
@@ -1765,6 +1837,21 @@ export function GameTable({
|
||||
}[g.response.kind]
|
||||
}
|
||||
</h2>
|
||||
{g.paymentIncome?.low && (
|
||||
<p className="notice">
|
||||
Of the {g.paymentIncome.gross} spice payment,{' '}
|
||||
{g.players.find((player) => player.id === g.paymentIncome?.owner)?.name}
|
||||
{' '}receives {g.paymentIncome.income}. Low-population{' '}
|
||||
{g.paymentIncome.kind === 'shipment' ? 'Junction' : 'Kaitain'}
|
||||
{' '}leaves {g.paymentIncome.bank} spice in the bank. The payer’s cost stays the same.
|
||||
</p>
|
||||
)}
|
||||
{g.response.kind === 'advisor' && (
|
||||
<p className="notice">
|
||||
{g.players.find((player) => player.id === g.response?.owner)?.name} declared {g.response.amount ?? 1} free {(g.response.amount ?? 1) === 1 ? 'force' : 'forces'} to {territory(splitLocation(g.response.location ?? 'polar_sink:0').territory).name}.
|
||||
{' '}Allow the arrival or cancel Spiritual Advisors. Canceling prevents the whole declared group.
|
||||
</p>
|
||||
)}
|
||||
{g.response.kind === 'moritaniRetention' &&
|
||||
g.moritaniRetentionCard && (
|
||||
<div className="notice">
|
||||
@@ -1941,163 +2028,171 @@ export function GameTable({
|
||||
<>
|
||||
<span className="eyebrow">Player decision</span>
|
||||
<h2>
|
||||
{g.decision.kind === 'stoneBurner'
|
||||
? 'Stone Burner'
|
||||
: g.decision.kind === 'nullentropy'
|
||||
? 'Private Nullentropy Box search'
|
||||
: g.decision.kind === 'richeseAllyOpportunity'
|
||||
? 'Offer allied No-Field shipment'
|
||||
: g.decision.kind === 'richeseAllyShipment'
|
||||
? 'Allied No-Field shipment'
|
||||
: [
|
||||
'richeseBlackMarket',
|
||||
'richeseDeclaration',
|
||||
'richeseCache',
|
||||
'richeseUnbid',
|
||||
].includes(g.decision.kind)
|
||||
? 'Richese auction decision'
|
||||
: g.decision.kind === 'ecazAmbassador'
|
||||
? 'Ecaz · Ambassador entry effect'
|
||||
: g.decision.kind === 'ecazPlacement'
|
||||
? 'Ecaz · Ambassador placement'
|
||||
: g.decision.kind === 'moritaniRetention'
|
||||
? 'Moritani · allied card retention'
|
||||
: g.decision.kind === 'moritaniTerror'
|
||||
? 'Moritani · Terror entry reaction'
|
||||
: g.decision.kind === 'moritaniSetup'
|
||||
? 'Moritani · final deployment'
|
||||
: g.decision.kind === 'moritaniPlacement'
|
||||
? 'Moritani · hidden Terror placement'
|
||||
: g.decision.kind === 'choamStorm'
|
||||
? 'CHOAM storm protection'
|
||||
: g.decision.kind === 'choamMovement'
|
||||
? 'CHOAM movement response'
|
||||
: g.decision.kind === 'choamMentat'
|
||||
? 'CHOAM’s final Mentat opportunity'
|
||||
: g.decision.kind ===
|
||||
'choamFreeRevival'
|
||||
? 'CHOAM free revival response'
|
||||
{g.decision.kind === 'caladanReinforcement'
|
||||
? 'Caladan victory reinforcement'
|
||||
: g.decision.kind === 'homeworldRevivalDeployment'
|
||||
? 'Revival deployment'
|
||||
: g.decision.kind === 'ecazSpice'
|
||||
? 'Shared spice collection'
|
||||
: g.decision.kind === 'stoneBurner'
|
||||
? 'Stone Burner'
|
||||
: g.decision.kind === 'nullentropy'
|
||||
? 'Private Nullentropy Box search'
|
||||
: g.decision.kind === 'richeseAllyOpportunity'
|
||||
? 'Offer allied No-Field shipment'
|
||||
: g.decision.kind === 'richeseAllyShipment'
|
||||
? 'Allied No-Field shipment'
|
||||
: [
|
||||
'richeseBlackMarket',
|
||||
'richeseDeclaration',
|
||||
'richeseCache',
|
||||
'richeseUnbid',
|
||||
].includes(g.decision.kind)
|
||||
? 'Richese auction decision'
|
||||
: g.decision.kind === 'ecazAmbassador'
|
||||
? 'Ecaz · Ambassador entry effect'
|
||||
: g.decision.kind === 'ecazPlacement'
|
||||
? 'Ecaz · Ambassador placement'
|
||||
: g.decision.kind === 'moritaniRetention'
|
||||
? 'Moritani · allied card retention'
|
||||
: g.decision.kind === 'moritaniTerror'
|
||||
? 'Moritani · Terror entry reaction'
|
||||
: g.decision.kind === 'moritaniSetup'
|
||||
? 'Moritani · final deployment'
|
||||
: g.decision.kind === 'moritaniPlacement'
|
||||
? 'Moritani · hidden Terror placement'
|
||||
: g.decision.kind === 'choamStorm'
|
||||
? 'CHOAM storm protection'
|
||||
: g.decision.kind === 'choamMovement'
|
||||
? 'CHOAM movement response'
|
||||
: g.decision.kind === 'choamMentat'
|
||||
? 'CHOAM’s final Mentat opportunity'
|
||||
: g.decision.kind ===
|
||||
'choamBattleFunding'
|
||||
? 'CHOAM battle funding'
|
||||
'choamFreeRevival'
|
||||
? 'CHOAM free revival response'
|
||||
: g.decision.kind ===
|
||||
'choamMarket'
|
||||
? 'CHOAM · end of phase'
|
||||
'choamBattleFunding'
|
||||
? 'CHOAM battle funding'
|
||||
: g.decision.kind ===
|
||||
'choamTradeReply' ||
|
||||
g.decision.kind ===
|
||||
'choamTradeConfirm'
|
||||
? 'Allied card exchange'
|
||||
'choamMarket'
|
||||
? 'CHOAM · end of phase'
|
||||
: g.decision.kind ===
|
||||
'ixSetup'
|
||||
? 'Choose your starting technology'
|
||||
'choamTradeReply' ||
|
||||
g.decision.kind ===
|
||||
'choamTradeConfirm'
|
||||
? 'Allied card exchange'
|
||||
: g.decision.kind ===
|
||||
'ixAuction'
|
||||
? 'Prepare the auction'
|
||||
'ixSetup'
|
||||
? 'Choose your starting technology'
|
||||
: g.decision.kind ===
|
||||
'ixTechnology'
|
||||
? 'Substitute an auction card'
|
||||
'ixAuction'
|
||||
? 'Prepare the auction'
|
||||
: g.decision.kind ===
|
||||
'ixAllyCard'
|
||||
? 'Keep or replace your purchase'
|
||||
'ixTechnology'
|
||||
? 'Substitute an auction card'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'mobileStronghold'
|
||||
? g.decision
|
||||
.placement
|
||||
? 'Place your stronghold'
|
||||
: 'Relocate before the storm'
|
||||
'ixAllyCard'
|
||||
? 'Keep or replace your purchase'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'ixSubstitution'
|
||||
? 'Retain lost cyborgs'
|
||||
'mobileStronghold'
|
||||
? g.decision
|
||||
.placement
|
||||
? 'Place your stronghold'
|
||||
: 'Relocate before the storm'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'revivalStop'
|
||||
? 'A revival awaits clearance'
|
||||
'ixSubstitution'
|
||||
? 'Retain lost cyborgs'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'faceDance'
|
||||
? 'A hidden allegiance'
|
||||
'revivalStop'
|
||||
? 'A revival awaits clearance'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'poisonTooth'
|
||||
? 'Activate Poison Tooth?'
|
||||
'faceDance'
|
||||
? 'A hidden allegiance'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'techToken'
|
||||
? 'Claim a tech token'
|
||||
'poisonTooth'
|
||||
? 'Activate Poison Tooth?'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'fullPlanOffer'
|
||||
? 'See the entire battle plan'
|
||||
'techToken'
|
||||
? 'Claim a tech token'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'guildShipment'
|
||||
? 'A shipment awaits clearance'
|
||||
'fullPlanOffer'
|
||||
? 'See the entire battle plan'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'handExchange'
|
||||
? 'Choose cards to return'
|
||||
'guildShipment' || g.decision.kind === 'homeworldShipmentGuild'
|
||||
? 'A shipment awaits clearance'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'captureOffer' ||
|
||||
g
|
||||
.decision
|
||||
.kind ===
|
||||
'capturedLeader'
|
||||
? 'A captive leader'
|
||||
'handExchange'
|
||||
? 'Choose cards to return'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'guildTiming'
|
||||
? 'Choose when to act'
|
||||
'captureOffer' ||
|
||||
g
|
||||
.decision
|
||||
.kind ===
|
||||
'capturedLeader'
|
||||
? 'A captive leader'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'stormLosses'
|
||||
? 'Survive the storm'
|
||||
'guildTiming'
|
||||
? 'Choose when to act'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'wormPlacement'
|
||||
? 'Place an additional worm'
|
||||
'stormLosses'
|
||||
? 'Survive the storm'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'battleLosses'
|
||||
? 'Choose your casualties'
|
||||
'wormPlacement'
|
||||
? 'Place an additional worm'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'auctionPayment'
|
||||
? 'Settle your bid'
|
||||
: [
|
||||
'advisor',
|
||||
'intrusion',
|
||||
'advisorBattle',
|
||||
].includes(
|
||||
g
|
||||
.decision
|
||||
.kind,
|
||||
)
|
||||
? 'Advisors & fighters'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'wormProtection'
|
||||
? 'Protection of Shai-Hulud'
|
||||
'battleLosses'
|
||||
? 'Choose your casualties'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'auctionPayment'
|
||||
? 'Settle your bid'
|
||||
: [
|
||||
'advisor',
|
||||
'intrusion',
|
||||
'advisorBattle',
|
||||
].includes(
|
||||
g
|
||||
.decision
|
||||
.kind,
|
||||
)
|
||||
? 'Advisors & fighters'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'wormRide'
|
||||
? 'Ride the sandworm'
|
||||
: 'Spoils of battle'}
|
||||
'wormProtection'
|
||||
? 'Protection of Shai-Hulud'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'wormRide'
|
||||
? 'Ride the sandworm'
|
||||
: 'Spoils of battle'}
|
||||
</h2>
|
||||
{g.decision.kind === 'capturedLeader' &&
|
||||
(g.decision.controller ?? g.decision.owner) === me.id && (
|
||||
@@ -2118,7 +2213,7 @@ export function GameTable({
|
||||
)?.name
|
||||
}{' '}
|
||||
has declared a move of {g.decision.amount} forces into{' '}
|
||||
{territory(g.decision.territory).name}, sector{' '}
|
||||
{combatName(g.decision.territory)}, sector{' '}
|
||||
{g.decision.sector}. CHOAM must allow the move or respond
|
||||
with Baliset; no forces have moved yet.
|
||||
</p>
|
||||
@@ -2209,6 +2304,10 @@ export function GameTable({
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
) : g.decision.kind === 'homeworldRevivalDeployment' ? (
|
||||
<HomeworldRevivalDeployment game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'caladanReinforcement' ? (
|
||||
<CaladanReinforcement game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'ecazSpice' ? (
|
||||
g.ecazSpice?.allocation ? (
|
||||
<EcazSpice
|
||||
@@ -2277,6 +2376,9 @@ export function GameTable({
|
||||
turn.
|
||||
</p>
|
||||
<HelpTip topic="tleilaxuSpecial" />
|
||||
{g.revival.specialKaramaBlock && (
|
||||
<p role="status">{g.revival.specialKaramaBlock}</p>
|
||||
)}
|
||||
{me.hand
|
||||
?.filter((c) => c.effect === 'karama')
|
||||
.map((c) => (
|
||||
@@ -2284,6 +2386,7 @@ export function GameTable({
|
||||
{actionButton(
|
||||
'Spend special Karama · prevent revival',
|
||||
{ type: 'card', card: c.id, mode: 'special' },
|
||||
!!g.revival.specialKaramaBlock,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -2402,6 +2505,17 @@ export function GameTable({
|
||||
Restoring the saved battle continuation. Your inspection
|
||||
remains available above; no confirmation is needed.
|
||||
</p>
|
||||
) : g.decision.kind === 'homeworldShipmentGuild' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
{g.players.find((p) => p.id === (g.decision?.kind === 'homeworldShipmentGuild' ? g.decision.shipper : ''))?.name} declares {g.decision.amount} forces to {combatName(g.decision.destination)}. Allow this shipment or spend your once-per-game special Karama to stop it.
|
||||
</p>
|
||||
<HelpTip topic="guildShipment" />
|
||||
{actionButton('Allow Homeworld shipment', { type: 'decision', event: g.decision.event, allow: true })}
|
||||
{me.hand?.filter((c) => c.effect === 'karama').map((c) => (
|
||||
<div key={c.id}>{actionButton('Spend Karama · stop shipment', { type: 'card', mode: 'special', card: c.id })}</div>
|
||||
))}
|
||||
</>
|
||||
) : g.decision.kind === 'guildShipment' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
@@ -2415,7 +2529,7 @@ export function GameTable({
|
||||
)?.name
|
||||
}{' '}
|
||||
declares {g.decision.amount} forces to{' '}
|
||||
{territory(g.decision.territory).name}, sector{' '}
|
||||
{combatName(g.decision.territory)}, sector{' '}
|
||||
{g.decision.sector}. Allow this shipment or spend your
|
||||
once-per-game special Karama to stop it.
|
||||
</p>
|
||||
@@ -2484,10 +2598,9 @@ export function GameTable({
|
||||
) : g.decision.kind === 'intrusion' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
Another faction entered{' '}
|
||||
{territory(g.decision.territory).name}. You may turn every
|
||||
fighter there into an advisor. A Karama response resolves
|
||||
before the flip.
|
||||
Another faction entered {combatName(g.decision.territory)}.
|
||||
You may turn every fighter there into an advisor. A Karama
|
||||
response resolves before the flip.
|
||||
</p>
|
||||
{actionButton('Become advisors', {
|
||||
type: 'decision',
|
||||
@@ -2641,11 +2754,74 @@ export function GameTable({
|
||||
accept: false,
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'homeworldDefense' ? (
|
||||
<>
|
||||
<RevealedBattle game={g} />
|
||||
<h3>Late poison defense</h3>
|
||||
<p className="muted">
|
||||
The Traitor window is complete. You may add Portable Snooper
|
||||
before this Homeworld battle resolves. It protects against
|
||||
ordinary poison, not Poison Tooth.
|
||||
</p>
|
||||
{me.hand
|
||||
?.filter(
|
||||
(card) =>
|
||||
richeseCardDefinition(card)?.card.effect ===
|
||||
'portableSnooper',
|
||||
)
|
||||
.map((card) => (
|
||||
<div key={card.id}>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
</div>
|
||||
))}
|
||||
{actionButton('Use Portable Snooper', {
|
||||
type: 'decision',
|
||||
event: g.decision.event,
|
||||
use: true,
|
||||
})}
|
||||
{actionButton('Keep current defense', {
|
||||
type: 'decision',
|
||||
event: g.decision.event,
|
||||
use: false,
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'homeworldExplosion' ? (
|
||||
<>
|
||||
<h3>Native explosion casualties</h3>
|
||||
<p className="muted">
|
||||
The Lasgun–shield explosion limits your native losses at{' '}
|
||||
{combatName(g.decision.territory)} to the Homeworld’s
|
||||
printed battle strength. Choose the physical counters sent
|
||||
to the Tanks; your other native forces remain here.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Available: {g.decision.pool.normal}{' '}
|
||||
{me.faction === 'ixians' ? 'Suboid' : 'ordinary'} and{' '}
|
||||
{g.decision.pool.elite} {specialForceName(me.faction)}{' '}
|
||||
counters.
|
||||
</p>
|
||||
{g.decision.options.map((option, choice) => (
|
||||
<div key={choice}>
|
||||
{actionButton(
|
||||
`Lose ${option.normal} ${me.faction === 'ixians' ? 'Suboid' : 'ordinary'} + ${option.elite} ${specialForceName(me.faction)} counters`,
|
||||
{
|
||||
type: 'decision',
|
||||
event:
|
||||
g.decision?.kind === 'homeworldExplosion'
|
||||
? g.decision.event
|
||||
: undefined,
|
||||
choice,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : g.decision.kind === 'battleLosses' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
Choose forces to lose that match your revealed strength and
|
||||
spice support.
|
||||
At {combatName(g.decision.territory)}, choose forces to lose
|
||||
that match your revealed strength and spice support.
|
||||
</p>
|
||||
{g.decision.options.map((option, choice) => (
|
||||
<div key={choice}>
|
||||
@@ -2682,23 +2858,24 @@ export function GameTable({
|
||||
<>
|
||||
<p className="muted">
|
||||
Another faction has shipped from off-planet. You may send
|
||||
one reserve force to the Polar Sink for free.
|
||||
up to {g.homeworldMobility?.advisorSinkMaximum ?? 1} reserve forces to the Polar Sink for free.
|
||||
</p>
|
||||
{g.guildAmbassadorAdvisorChoices ? (
|
||||
g.guildAmbassadorAdvisorChoices.choices.map((choice) => {
|
||||
const reasonId = `guild-advisor-${choice.accompany}-${choice.territory}-${choice.sector}`;
|
||||
const reasonId = `guild-advisor-${choice.accompany}-${choice.territory}-${choice.sector}-${choice.amount}`;
|
||||
return (
|
||||
<div key={reasonId}>
|
||||
{actionButton(
|
||||
choice.accompany
|
||||
? `Accompany · ${territory(choice.territory).name} · sector ${choice.sector}`
|
||||
: 'Send to Polar Sink',
|
||||
: `Send ${choice.amount} to Polar Sink`,
|
||||
{
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
accompany: choice.accompany,
|
||||
territory: choice.territory,
|
||||
sector: choice.sector,
|
||||
amount: choice.amount,
|
||||
},
|
||||
!!choice.blocked,
|
||||
choice.blocked ? reasonId : undefined,
|
||||
@@ -2731,10 +2908,11 @@ export function GameTable({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{actionButton('Send to Polar Sink', {
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
})}
|
||||
{Array.from({length: g.homeworldMobility?.advisorSinkMaximum ?? 1}, (_, index) => (
|
||||
<div key={index}>{actionButton(`Send ${index + 1} to Polar Sink`, {
|
||||
type: 'decision', accept: true, amount: index + 1,
|
||||
})}</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{actionButton('Decline shipment', {
|
||||
@@ -2746,7 +2924,7 @@ export function GameTable({
|
||||
<>
|
||||
<p className="muted">
|
||||
Shai-Hulud has appeared in{' '}
|
||||
{territory(g.decision.territory).name}. You may protect your
|
||||
{combatName(g.decision.territory)}. You may protect your
|
||||
ally’s forces from being devoured.
|
||||
</p>
|
||||
{actionButton('Protect my ally', {
|
||||
@@ -2762,7 +2940,7 @@ export function GameTable({
|
||||
<>
|
||||
<p className="muted">
|
||||
The Nexus has concluded. Move some or all of your forces
|
||||
from {territory(g.decision.territory).name} to a legal
|
||||
from {combatName(g.decision.territory)} to a legal
|
||||
territory. This does not use your normal movement.
|
||||
</p>
|
||||
{Object.entries(me.forces)
|
||||
@@ -3012,8 +3190,9 @@ export function GameTable({
|
||||
{g.players
|
||||
.filter((p) => p.id !== me.id)
|
||||
.map((p) => (
|
||||
<option value={p.id} key={p.id}>
|
||||
<option value={p.id} key={p.id} disabled={!!g.homeworldAllianceBlocks?.[p.id]}>
|
||||
{p.name}
|
||||
{g.homeworldAllianceBlocks?.[p.id] ? ` · ${g.homeworldAllianceBlocks[p.id]}` : ''}
|
||||
{g.allianceOffers[p.id] === me.id
|
||||
? ' · Invited you'
|
||||
: ''}
|
||||
@@ -3021,10 +3200,11 @@ export function GameTable({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{g.homeworldAllianceBlocks?.[target] && <p className="notice">{g.homeworldAllianceBlocks[target]}</p>}
|
||||
{actionButton('Propose / accept alliance', {
|
||||
type: 'alliance',
|
||||
target,
|
||||
})}
|
||||
}, !target || !!g.homeworldAllianceBlocks?.[target])}
|
||||
{me.ally &&
|
||||
actionButton('Break alliance', { type: 'alliance' })}
|
||||
</>
|
||||
@@ -3047,8 +3227,10 @@ export function GameTable({
|
||||
</p>
|
||||
<p className="fine">
|
||||
{g.charity.payer
|
||||
? `Charity is paid from ${g.players.find((p) => p.id === g.charity.payer)?.name}’s spice.`
|
||||
? `The ordinary ${g.charity.ordinary} spice is paid from ${g.players.find((p) => p.id === g.charity.payer)?.name}’s spice.`
|
||||
: 'Charity is paid by the Spice Bank.'}
|
||||
{g.charity.homeworld > 0 &&
|
||||
` Low Homeworld population adds ${g.charity.homeworld} spice directly from the bank${g.charity.multiplier === 2 ? ' after Inflation doubles the bonus' : ''}.`}
|
||||
</p>
|
||||
{actionButton(
|
||||
g.charity.amount > 0
|
||||
@@ -3177,6 +3359,23 @@ export function GameTable({
|
||||
</p>
|
||||
)}
|
||||
<RevivalCommerce game={g} act={act} busy={busy} />
|
||||
{g.revival.homeworldBonus > 0 && (
|
||||
<p className="notice">
|
||||
Low Homeworld population adds one to your free revival
|
||||
rate. The next request uses your population after this
|
||||
group returns.
|
||||
{g.revival.freeBlocked
|
||||
? ' La La La currently prevents taking any free revivals.'
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{g.revival.tleilaxuHomeworldIncomeBlocked && (
|
||||
<p className="notice">
|
||||
Tleilax started Revival at low population. Tleilaxu
|
||||
receives no bank reward for other factions’ free revivals
|
||||
this phase; paid revival and Ghola income remain separate.
|
||||
</p>
|
||||
)}
|
||||
{g.revival.freeBlocked && (
|
||||
<p className="notice">
|
||||
La La La prevents your free force revivals for this phase.
|
||||
@@ -3255,7 +3454,7 @@ export function GameTable({
|
||||
onChange={setAllyEliteAmount}
|
||||
max={Math.min(
|
||||
amount,
|
||||
eliteRevivalRemaining(ally),
|
||||
eliteRevivalRemaining(ally, g.advanced),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -3307,7 +3506,13 @@ export function GameTable({
|
||||
},
|
||||
g.revival.prevented ||
|
||||
!me.tanks ||
|
||||
amount > g.revival.forcesRemaining,
|
||||
amount > g.revival.forcesRemaining ||
|
||||
!!homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount }),
|
||||
)}
|
||||
{homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount }) && (
|
||||
<p className="fine" aria-live="polite">
|
||||
{homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount })}
|
||||
</p>
|
||||
)}
|
||||
{me.faction === 'fremen' &&
|
||||
amount > g.revival.forcesRemaining ? (
|
||||
@@ -3392,6 +3597,20 @@ export function GameTable({
|
||||
{g.phase === 5 && (
|
||||
<ShipmentPromises game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.phase === 5 && g.active === me.id && g.homeworldShipment && !me.shipped && (
|
||||
<HomeworldShipment key={g.homeworldShipment.event} game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.phase === 5 && g.active === me.id && g.guildHomeworldShipment && !me.shipped && (
|
||||
<GuildHomeworldShipment key={g.guildHomeworldShipment.event} game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.phase === 5 && g.homeworldMove && (
|
||||
<EmperorHomeworldMovement
|
||||
key={g.homeworldMove.event}
|
||||
game={g}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
{g.phase === 5 &&
|
||||
me.faction === 'choam' &&
|
||||
g.choamMovementBonus > 0 && (
|
||||
@@ -3467,6 +3686,19 @@ export function GameTable({
|
||||
)}
|
||||
{!me.shipped && (
|
||||
<>
|
||||
<NativeShipmentChoice
|
||||
game={g}
|
||||
amount={amount}
|
||||
elite={eliteAmount}
|
||||
sources={homeworldSources}
|
||||
busy={busy}
|
||||
onChange={(sources) =>
|
||||
setHomeworldDraft({
|
||||
key: homeworldSourceKey,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ShipmentQuote
|
||||
id="reserve-shipment-quote"
|
||||
quote={shipmentQuote}
|
||||
@@ -3492,6 +3724,7 @@ export function GameTable({
|
||||
territory: selected,
|
||||
sector,
|
||||
amount,
|
||||
...(homeworldSources ? { homeworldSources } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -3505,7 +3738,7 @@ export function GameTable({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includesNoField}
|
||||
disabled={busy}
|
||||
disabled={busy || !!g.homeworldMobility?.noFieldMovementBlocked}
|
||||
onChange={(event) =>
|
||||
setMoveNoField(event.target.checked)
|
||||
}
|
||||
@@ -3521,6 +3754,9 @@ export function GameTable({
|
||||
only the marker. Its hidden value stays unchanged.
|
||||
</p>
|
||||
)}
|
||||
{markerAtSource && g.homeworldMobility?.noFieldMovementBlocked && (
|
||||
<p className="fine">{g.homeworldMobility.noFieldMovementBlocked} Physical forces may still move without the token.</p>
|
||||
)}
|
||||
<label className="decision-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -3734,30 +3970,18 @@ export function GameTable({
|
||||
{g.phase === 6 && !g.battle && g.active === me.id && (
|
||||
<>
|
||||
<p className="muted">
|
||||
Select a disputed territory on the map, then choose your
|
||||
opponent.
|
||||
Choose one of your unresolved battles.
|
||||
</p>
|
||||
<label>
|
||||
Opponent
|
||||
<select
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
{botBattleChoices(g).map((battle) => (
|
||||
<div
|
||||
key={`${String(battle.territory)}-${String(battle.target)}`}
|
||||
>
|
||||
<option value="">Choose opponent</option>
|
||||
{g.players
|
||||
.filter((p) => p.id !== me.id)
|
||||
.map((p) => (
|
||||
<option value={p.id} key={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{actionButton('Begin battle', {
|
||||
type: 'chooseBattle',
|
||||
territory: selected,
|
||||
target,
|
||||
})}
|
||||
{actionButton(
|
||||
`Fight ${g.players.find((p) => p.id === battle.target)?.name} · ${combatName(String(battle.territory))}`,
|
||||
battle,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{g.phase === 8 && me.faction === 'choam' && (
|
||||
@@ -3793,8 +4017,28 @@ export function GameTable({
|
||||
<>
|
||||
<p className="battle-location">
|
||||
<Swords size={17} />
|
||||
{territory(g.battle.territory).name}
|
||||
{g.battle.locationName ?? combatName(g.battle.territory)}
|
||||
</p>
|
||||
{!!g.battle.native && (
|
||||
<p className="notice">
|
||||
Only the native faction may reveal Traitors or Face
|
||||
Dancers here.
|
||||
{[g.battle.attacker, g.battle.defender].includes(
|
||||
g.battle.native,
|
||||
) && (
|
||||
<>
|
||||
{' '}
|
||||
{
|
||||
g.players.find((p) => p.id === g.battle?.native)
|
||||
?.name
|
||||
}{' '}
|
||||
adds +{g.battle.nativeBattleStrength} native strength
|
||||
in ordinary strength comparisons, separately from the
|
||||
dial.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{g.battle.eliteBlocked.length > 0 && (
|
||||
<p className="notice">
|
||||
Karama:{' '}
|
||||
@@ -3919,6 +4163,14 @@ export function GameTable({
|
||||
? `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.`
|
||||
: `You have ${battleForces} fighting forces here.`}
|
||||
</p>
|
||||
{ownBattleForces?.eliteFreeSupport && (
|
||||
<p className="notice">
|
||||
Salusa Secundus is at high population. Your
|
||||
Sardaukar fight at their full current strength
|
||||
without spice support; normal forces still
|
||||
require support.
|
||||
</p>
|
||||
)}
|
||||
{g.advanced &&
|
||||
(me.faction !== 'fremen' ||
|
||||
g.battle.fremenSupportBlocked) && (
|
||||
@@ -4412,6 +4664,14 @@ export function GameTable({
|
||||
)}
|
||||
{c.effect === 'ghola' && (
|
||||
<>
|
||||
{g.ghola.eliteBlock && (
|
||||
<p className="fine">{g.ghola.eliteBlock}</p>
|
||||
)}
|
||||
{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) && (
|
||||
<p className="fine" aria-live="polite">
|
||||
{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined })}
|
||||
</p>
|
||||
)}
|
||||
<label htmlFor={`ghola-${c.id}`}>
|
||||
Forces to revive (or choose a leader)
|
||||
<Input
|
||||
@@ -4683,6 +4943,7 @@ export function GameTable({
|
||||
busy ||
|
||||
!!g.truthtrance ||
|
||||
(!!availability && !availability.available) ||
|
||||
!!homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) ||
|
||||
(g.status !== 'playing' &&
|
||||
!(
|
||||
g.status === 'setup' &&
|
||||
|
||||
Reference in new issue
Block a user