Connect bounded Moritani Atomics Aftermath

This commit is contained in:
Quintine committed 2026-09-29 19:13:16 +10:00
1 parent b2603f6715
commit 66a427ab55
17 files changed
+1036 -68

No files matched your search

+20 -11
View File
@@ -42,6 +42,7 @@ import { NexusAdvisors } from './nexus-advisors';
import { NexusSardaukar } from './nexus-sardaukar';
import { TerrorBoardMarkers } from './terror-board-markers';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { atomicsAllianceChangeBlocked } from '@/game/moritani-atomics';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
import { JunctionTransport } from './junction-transport';
import { BiddingEnd } from './bidding-end';
@@ -830,6 +831,8 @@ export function GameTable({
: null,
) !== p.answer,
);
const atomicsShipmentBlocked =
a.type === 'ship' && a.territory === g.moritaniAtomics?.territory;
return (
<Button
className="game-action"
@@ -837,11 +840,12 @@ export function GameTable({
[
describedBy,
shipmentBlocked ? 'shipment-promise-guidance' : undefined,
atomicsShipmentBlocked ? 'atomics-shipment-guidance' : undefined,
]
.filter(Boolean)
.join(' ') || undefined
}
disabled={busy || !!g.truthtrance || !!g.nexusTraitors?.pending || disabled || shipmentBlocked}
disabled={busy || !!g.truthtrance || !!g.nexusTraitors?.pending || disabled || shipmentBlocked || atomicsShipmentBlocked}
onClick={() => act(a)}
>
{text}
@@ -1483,7 +1487,7 @@ export function GameTable({
));
})}
<NoFieldBoardMarkers players={g.players} />
<TerrorBoardMarkers tokens={g.moritaniTerror?.tokens ?? []} />
<TerrorBoardMarkers tokens={g.moritaniTerror?.tokens ?? []} atomics={g.moritaniAtomics} />
{Object.entries(g.spice)
.filter(([, n]) => n > 0)
.map(([k, n]) => {
@@ -3600,24 +3604,29 @@ export function GameTable({
<option value="">Choose a player</option>
{g.players
.filter((p) => p.id !== me.id)
.map((p) => (
<option value={p.id} key={p.id} disabled={!!g.homeworldAllianceBlocks?.[p.id]}>
.map((p) => {
const atomicsBlocked = atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, p.id);
return <option value={p.id} key={p.id}
disabled={!!g.homeworldAllianceBlocks?.[p.id] || atomicsBlocked}>
{p.name}
{g.homeworldAllianceBlocks?.[p.id] ? ` · ${g.homeworldAllianceBlocks[p.id]}` : ''}
{g.allianceOffers[p.id] === me.id
? ' · Invited you'
: ''}
</option>
))}
{atomicsBlocked ? ' · Atomics alliance ruling pending' : ''}
{g.allianceOffers[p.id] === me.id ? ' · Invited you' : ''}
</option>;
})}
</select>
</label>
{g.homeworldAllianceBlocks?.[target] && <p className="notice">{g.homeworldAllianceBlocks[target]}</p>}
{!!target && atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, target) &&
<p className="notice">Changing Moritani’s ally after Atomics awaits a hand-limit ruling.</p>}
{actionButton('Propose / accept alliance', {
type: 'alliance',
target,
}, !target || !!g.homeworldAllianceBlocks?.[target])}
}, !target || !!g.homeworldAllianceBlocks?.[target] ||
atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, target))}
{me.ally &&
actionButton('Break alliance', { type: 'alliance' })}
actionButton('Break alliance', { type: 'alliance' },
atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, null))}
</>
)}
{g.phase === 1 && !g.nexus && !g.spiceWindow && (
+48 -10
View File
@@ -204,12 +204,12 @@ function TokenInspector({
),
)}
<p className="border-t border-[#414536] pt-4 text-sm leading-6 text-[#c9c2ae]">
Robbery, Sabotage, Sneak Attack and ordinary native-leader
Assassination have controls for supported entry reactions.
Enemy of My Enemy has partial alliance controls. Atomics and
Extortion remain under implementation, and complete Moritani
games remain unavailable. Inspection explains the rules; it
does not reveal a token or activate an effect.
Supported entry reactions include Robbery, Sabotage, Sneak
Attack, ordinary native-leader Assassination and Extortion.
Enemy of My Enemy has partial alliance controls. Atomics can
be revealed only on supported tables; complete Moritani games
remain unavailable. Inspection does not reveal a token or
activate an effect.
</p>
</section>
</div>
@@ -223,6 +223,15 @@ function TokenInspector({
export function MoritaniTerrorSupply({ game }: { game: GameView }) {
const titleId = useId();
const view = game.moritaniTerror;
const aftermath = game.moritaniAtomics;
const affectedNames = aftermath
? [
game.players.find((player) => player.id === aftermath.moritaniId)?.name ?? 'Moritani',
...(aftermath.allyAtActivation
? [game.players.find((player) => player.id === aftermath.allyAtActivation)?.name ?? 'their ally at activation']
: []),
].join(' and ')
: '';
if (!view) return null;
const owner =
game.players.find((p) => p.id === game.me)?.faction === 'moritani';
@@ -242,6 +251,22 @@ export function MoritaniTerrorSupply({ game }: { game: GameView }) {
? 'Your supply and placed tokens. Inspect a face without revealing it to the table.'
: 'Placed tokens stay hidden. Revealed tokens can be inspected by everyone.'}
</p>
{aftermath && (
<div id="atomics-shipment-guidance" className="mb-3 rounded-lg border border-[#b99a68] bg-[#302923] p-3 text-sm leading-6 text-[#f1dfb4]" aria-label="Atomics Aftermath">
<p className="m-0 font-semibold">
Atomics Aftermath · {territory(aftermath.territory).name}
</p>
<p className="mt-1 mb-0">
From turn {aftermath.turn}, no faction may ship forces into this
territory, including Fremen reinforcements. Otherwise legal
ordinary movement and Sneak Attack remain possible.
</p>
<p className="mt-1 mb-0">
A lasting hand-limit reduction of one applies to {affectedNames}.
Any excess cards were discarded at random when Atomics resolved.
</p>
</div>
)}
{tokens.length ? (
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{tokens.map((token) => (
@@ -563,6 +588,18 @@ export function MoritaniEntry({
native leader. You do not choose the victim.
</p>
)}
{entry.kind === 'atomics' && entry.canReveal && (
<p className="m-0 text-sm leading-6">
Revealing Atomics sends every faction’s forces in{' '}
{territory(entry.territory).name} to the Tanks, including your
own. The public Aftermath permanently blocks shipments into
that territory, including Fremen reinforcements, but not
otherwise legal movement or Sneak Attack. Your hand limit
falls by one starting this turn; your ally’s at activation
does too, if you have one. Each hand over its new limit
discards one random card per excess card.
</p>
)}
{entry.kind === 'sneakAttack' && sneak && (
<div className="space-y-2 text-sm leading-6">
<p className="m-0">
@@ -625,10 +662,11 @@ export function MoritaniEntry({
Leave token hidden
</Button>
<p className="m-0 text-sm leading-6">
Robbery, Sabotage, Sneak Attack and ordinary native-leader
Assassination support eligible arrivals. Enemy of My Enemy has
partial alliance controls. Atomics and Extortion are not yet
available; complete Moritani games remain unavailable.
Robbery, Sabotage, Sneak Attack, ordinary native-leader
Assassination and Extortion support eligible arrivals. Atomics
is available only on supported tables. Enemy of My Enemy has
partial alliance controls; complete Moritani games remain
unavailable.
</p>
</>
)}
+35 -17
View File
@@ -1,26 +1,44 @@
import { territory } from '@/game/board';
import type { GameView } from '@/game/engine';
/** The map shows public stack counts without reading any hidden token face. */
export function TerrorBoardMarkers({ tokens }: {
/** Public map markers never inspect the face of a placed Terror token. */
export function TerrorBoardMarkers({ tokens, atomics }: {
tokens: readonly { status: string; location: string | null }[];
atomics?: GameView['moritaniAtomics'];
}) {
const groups = new Map<string, number>();
for (const token of tokens)
if (token.status === 'placed' && token.location)
groups.set(token.location, (groups.get(token.location) ?? 0) + 1);
return [...groups].map(([id, count]) => {
const place = territory(id);
const label = `${count} hidden Terror token${count === 1 ? '' : 's'} in ${place.name}`;
return (
<g key={id} transform={`translate(${place.center[0] + 42},${place.center[1] - 22})`}
pointerEvents="none" aria-label={label}>
<title>{label}</title>
{count > 1 && <circle cx="3" cy="-3" r="15" fill="#392b41" stroke="#d6b9dc" strokeWidth="2" />}
<circle r="15" fill="#392b41" stroke="#d6b9dc" strokeWidth="2" />
<text y="5" textAnchor="middle" fill="#f4e1f1" fontSize="15" fontWeight="700">
{count === 1 ? 'T' : `T${count}`}
</text>
</g>
);
});
const aftermath = atomics && territory(atomics.territory);
const handLimitLabel = atomics?.allyAtActivation
? 'Moritani and the ally at activation have hand limits reduced by one'
: 'Moritani has a hand limit reduced by one';
return (
<>
{[...groups].map(([id, count]) => {
const place = territory(id);
const label = `${count} hidden Terror token${count === 1 ? '' : 's'} in ${place.name}`;
return (
<g key={id} transform={`translate(${place.center[0] + 42},${place.center[1] - 22})`}
pointerEvents="none" aria-label={label}>
<title>{label}</title>
{count > 1 && <circle cx="3" cy="-3" r="15" fill="#392b41" stroke="#d6b9dc" strokeWidth="2" />}
<circle r="15" fill="#392b41" stroke="#d6b9dc" strokeWidth="2" />
<text y="5" textAnchor="middle" fill="#f4e1f1" fontSize="15" fontWeight="700">
{count === 1 ? 'T' : `T${count}`}
</text>
</g>
);
})}
{aftermath && (
<g transform={`translate(${aftermath.center[0] - 42},${aftermath.center[1] - 22})`}
pointerEvents="none" aria-label={`Atomics Aftermath in ${aftermath.name}: permanent shipment ban; ${handLimitLabel}`}>
<title>{`Atomics Aftermath in ${aftermath.name}: no shipments, including Fremen reinforcements; ordinary movement and Sneak Attack remain legal. ${handLimitLabel}.`}</title>
<circle r="15" fill="#543929" stroke="#f5bc79" strokeWidth="2" />
<text y="5" textAnchor="middle" fill="#fff1d4" fontSize="15" fontWeight="700">A</text>
</g>
)}
</>
);
}