Connect stable occupied Homeworld income sharing and native protections

This commit is contained in:
Quintine committed 2026-10-05 09:12:41 +11:00
1 parent 84d2ba1d3d
commit 5e902e384d
24 files changed
+1536 -41

No files matched your search

+2 -1
View File
@@ -150,6 +150,7 @@ export function BattlePreparation({
before Atreides chooses prescience and the combatants seal their
plans.
</p>
{preparation.blocked && <p className="notice">{preparation.blocked}</p>}
<label htmlFor="voice-kind">
Card type
<select
@@ -174,7 +175,7 @@ export function BattlePreparation({
</label>
<Button
className="game-action"
disabled={busy}
disabled={busy || !!preparation.blocked}
onClick={() => act({ type: 'voice', kind, must })}
>
Use the Voice
+15
View File
@@ -24,6 +24,7 @@ import { HomeworldShipment } from './homeworld-shipment';
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
import { CaladanReinforcement } from './caladan-reinforcement';
import { GrummanCollection } from './grumman-collection';
import { HomeworldOccupiedIncome } from './homeworld-occupied-income';
import { TupileIntelligence } from './tupile-intelligence';
import { NexusCards } from './nexus-cards';
import { NexusTraitors } from './nexus-traitors';
@@ -1095,6 +1096,16 @@ export function GameTable({
</span>
</div>
{g.advanced && g.status !== 'lobby' && <AdvancedPreviewNotice compact />}
{g.homeworldOccupationPreview && (
<output className="notice block">
Homeworld occupation development preview · continuous, uncontested
qualification only. Printed bank Collection, immediate ally sharing
and occupied native defenses are connected. Departure, replacement,
contested/repopulated histories, percentage income and other unfinished
occupied powers remain guarded. Not complete or certified Homeworld rules.{' '}
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
</output>
)}
{g.nexusKullPreview ? (
<p className="notice" role="status">
CHOAM Nexus Cunning · Kull Wahad development preview. Any eligible
@@ -2456,6 +2467,8 @@ export function GameTable({
? 'Caladan victory reinforcement'
: g.decision.kind === 'grummanCollection'
? 'Grumman Collection'
: g.decision.kind === 'homeworldOccupiedIncome'
? 'Occupied Homeworld bank income'
: g.decision.kind === 'leaderSkillVisibility'
? 'Position your skilled leader'
: g.decision.kind === 'mentatQuestion'
@@ -2781,6 +2794,8 @@ export function GameTable({
<CaladanReinforcement game={g} act={act} busy={busy} />
) : g.decision.kind === 'grummanCollection' ? (
<GrummanCollection game={g} act={act} busy={busy} />
) : g.decision.kind === 'homeworldOccupiedIncome' ? (
<HomeworldOccupiedIncome offer={g.homeworldOccupiedIncome} act={act} busy={busy} />
) : g.decision.kind === 'ecazSpice' ? (
g.ecazSpice?.allocation ? (
<EcazSpice
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useState } from 'react';
import type { Action } from '@/game/engine';
import { homeworldCard } from '@/game/homeworld-cards';
import type { HomeworldOccupiedIncomeOffer } from '@/game/homeworld-occupied-income';
import { HomeworldFace } from './homeworld-cards';
import { Button } from './ui/button';
import { Input } from './ui/input';
export type HomeworldOccupiedIncomeProps = {
offer: HomeworldOccupiedIncomeOffer | null;
act: (action: Action) => void;
busy: boolean;
};
export function HomeworldOccupiedIncome({ offer, act, busy }: HomeworldOccupiedIncomeProps) {
if (!offer) return null;
return <IncomeChoice key={`${offer.event}:${offer.world}`} offer={offer} act={act} busy={busy} />;
}
function IncomeChoice({ offer, act, busy }: HomeworldOccupiedIncomeProps & { offer: HomeworldOccupiedIncomeOffer }) {
const [ownAmount, setOwnAmount] = useState(offer.amount);
const valid = Number.isSafeInteger(ownAmount) && ownAmount >= offer.minOwnAmount && ownAmount <= offer.maxOwnAmount;
const card = homeworldCard(offer.card)!;
return (
<section aria-label="Occupied Homeworld bank income" className="space-y-3">
<h3>{offer.name} · occupied bank income</h3>
<p>
Collect {offer.amount} spice from the Bank. Keep all of it
{offer.allyName ? `, or immediately allocate any portion to ${offer.allyName}.` : '; you have no reciprocal ally to share with.'}
</p>
<p className="fine">
This is the printed Homeworld bank award, not a percentage of payments or a bribe from your personal spice.
Homeworld effects cannot be canceled by Karama.
</p>
<details className="rounded-lg border border-[#a88b60]/50 p-3">
<summary className="cursor-pointer py-2">Inspect {offer.name} source card</summary>
<HomeworldFace card={card} />
</details>
{offer.blocked && <output className="notice block">{offer.blocked}</output>}
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
if (!busy && !offer.blocked && valid)
act({ type: 'decision', event: offer.event, world: offer.world, ownAmount });
}}
>
{offer.ally && (
<>
<label className="block space-y-1">
<span>Spice to keep · {offer.minOwnAmount}–{offer.maxOwnAmount}</span>
<Input
aria-label="Occupied bank spice to keep"
type="number"
min={offer.minOwnAmount}
max={offer.maxOwnAmount}
step={1}
value={Number.isNaN(ownAmount) ? '' : ownAmount}
disabled={busy || !!offer.blocked}
onChange={(event) => setOwnAmount(event.target.value === '' ? Number.NaN : Number(event.target.value))}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={busy || !!offer.blocked} onClick={() => setOwnAmount(offer.amount)}>
Keep all
</Button>
<Button type="button" variant="outline" disabled={busy || !!offer.blocked} onClick={() => setOwnAmount(0)}>
Give all to {offer.allyName}
</Button>
</div>
</>
)}
<output className="notice block" aria-live="polite">
{valid
? `Bank credits: ${offer.ownerName} ${ownAmount}${offer.allyName ? ` · ${offer.allyName} ${offer.amount - ownAmount}` : ''}. Total ${offer.amount}.`
: `Keep a whole number from ${offer.minOwnAmount} through ${offer.maxOwnAmount}.`}
</output>
<Button type="submit" className="game-action" disabled={busy || !!offer.blocked || !valid}>
Settle {offer.name} bank award
</Button>
</form>
</section>
);
}