Prototype Moritani Assassinate Leaders with saved Mentat replacement
Connect a qualifying native Advanced Moritani battle loss to private reveal/continue controls, live or already-dead opposing-faction targets, printed-strength bank bounty, and automatic Mentat replacement. Keep the revealed physical card held until Mentat, then set it aside as a public per-faction use marker and draw one private card. All four AI profiles use the owner-only legal quote. New local preview profile supports genuine setup without opening public starts. Keep unresolved normal traitor-call duration and uniform private no-choice UX explicit. Record normal calls and guard later use without claiming a settled permanent loss. Exclude exceptional captured/foreign leader custody, other expansion factions and combined optional modules from this bounded preview. No mode or publication gate opens. Independent source/privacy/persistence review and 32 focused cases pass. Battle obligations independently bind cleanup, sealed normal-call logs wait for public revelation, physical replacement custody rejects missing cards and forged same-turn chains, and invalid actions remain immutable. Five authenticated SQLite cases cover pending/resolved restoration, Truthtrance interruption, competing submissions, stale/duplicate actions and corrupt saves. The local writer preserves seats and unrelated rooms. Validation: types, lint, 4690 offline tests, production build and 43 HTTP tests passed with unchanged source. Two genuine Advanced four-profile games finish in 296 and 101 actions with zero rejected candidates, ten periodic JSON rounds and eight restored final seat views. All physical cards, forces and elites remain conserved. Two observed assassination opportunities are declined; these complete games do not establish natural reveal/replacement use. The separate browser exercise explicitly stages conserved battle forces and one physical traitor swap after genuine setup. Actual battle actions open the choice. Master Bewt dies once and pays three spice; the card remains held through cleanup and is automatically replaced at Mentat with a public set-aside marker. Pending and resolved refresh, readable context-correct inspectors, physical custody and private projection pass. Guild independently collects two spice from Arrakeen. The saved QA game remains at human Terror placement. After the reported outage, all 801 opening games and original seat/recovery records are preserved (835 after HTTP checks). All 802 rooms and original records immediately before verification also remain unchanged. Database integrity and foreign keys pass. The healthy server was reused without restart, reset or recurring automation. Verified source tree: ad64f6775fb4c481002cc5eccf6d31e490085d11e10b7b19fb7df4947bcf0ccd
This commit is contained in:
1 parent
8906b41bda
commit
58ae84f8e2
20 files changed
+2152
-13
No files matched your search
@@ -184,6 +184,7 @@ import {
|
||||
} from './moritani-terror';
|
||||
import { TERROR_DEFINITIONS } from '@/game/moritani-terror';
|
||||
import { MoritaniRetention } from './moritani-retention';
|
||||
import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assassinate';
|
||||
export function GameTable({
|
||||
game: g,
|
||||
send,
|
||||
@@ -742,8 +743,11 @@ export function GameTable({
|
||||
};
|
||||
const inspectIdentity = (id: string, kind: LeaderInspectorProps['kind']) => {
|
||||
const identity = identityFor(id);
|
||||
const assassination = kind === 'traitor'
|
||||
? g.moritaniAssassinate?.history.find(entry => entry.card === id) : undefined;
|
||||
return identity ? (
|
||||
<LeaderInspector kind={kind} identity={identity} />
|
||||
<LeaderInspector kind={kind} identity={identity}
|
||||
assassination={assassination ? assassination.stage === 'replaced' ? 'retired' : 'revealed' : undefined} />
|
||||
) : null;
|
||||
};
|
||||
const actionButton = (
|
||||
@@ -975,6 +979,7 @@ export function GameTable({
|
||||
<SeatAutopilot game={g} act={act} busy={transportBusy} />
|
||||
<DukeVidal game={g} />
|
||||
<MoritaniTerrorSupply game={g} />
|
||||
<MoritaniAssassinateHistory game={g} />
|
||||
<AmbassadorSupply game={g} />
|
||||
<AmbassadorInsights key={`${g.code}-${g.me}`} game={g} />
|
||||
<AuditorInsight game={g} />
|
||||
@@ -2180,7 +2185,9 @@ export function GameTable({
|
||||
? 'Ecaz · Ambassador entry effect'
|
||||
: g.decision.kind === 'ecazPlacement'
|
||||
? 'Ecaz · Ambassador placement'
|
||||
: g.decision.kind === 'moritaniRetention'
|
||||
: g.decision.kind === 'moritaniAssassinate'
|
||||
? 'Moritani · Assassinate Leaders'
|
||||
: g.decision.kind === 'moritaniRetention'
|
||||
? 'Moritani · allied card retention'
|
||||
: g.decision.kind === 'moritaniTerror'
|
||||
? 'Moritani · Terror entry reaction'
|
||||
@@ -2404,6 +2411,8 @@ export function GameTable({
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
) : g.decision.kind === 'moritaniAssassinate' ? (
|
||||
<MoritaniAssassinate key={g.decision.event} game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'moritaniRetention' ? (
|
||||
<MoritaniRetention game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'moritaniTerror' ? (
|
||||
|
||||
@@ -26,6 +26,7 @@ export type LeaderDisplayIdentity = Readonly<
|
||||
export type LeaderInspectorProps = {
|
||||
identity: LeaderDisplayIdentity;
|
||||
kind?: 'leader' | 'traitor' | 'faceDancer';
|
||||
assassination?: 'choice' | 'revealed' | 'retired';
|
||||
};
|
||||
|
||||
/** An original geometric medallion, not a portrait or a printed component reproduction. */
|
||||
@@ -104,12 +105,15 @@ function IdentityMedallion({ traitor }: { traitor: boolean }) {
|
||||
export function LeaderInspector({
|
||||
identity,
|
||||
kind = 'leader',
|
||||
assassination,
|
||||
}: LeaderInspectorProps) {
|
||||
const traitor = kind === 'traitor';
|
||||
const faceDancer = kind === 'faceDancer';
|
||||
const label = faceDancer ? 'Face Dancer' : kind;
|
||||
const cheapHero = identity.cheapHero === true;
|
||||
const description = faceDancer
|
||||
const description = traitor && assassination === 'retired'
|
||||
? 'This face-up card marks the faction against which Moritani has already used Assassinate Leaders. It is set aside, separate from held Traitor Cards.'
|
||||
: faceDancer
|
||||
? cheapHero
|
||||
? 'This Face Dancer identity matches either Cheap Hero or Cheap Heroine used as the winning battle leader, regardless of the hero card’s name or the player’s faction.'
|
||||
: 'This Face Dancer identity matches the named winning leader. Tleilaxu can reveal an unrevealed match after that battle and its rewards are settled.'
|
||||
@@ -255,6 +259,19 @@ export function LeaderInspector({
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : traitor && assassination ? (
|
||||
<>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
{assassination === 'choice' ? 'Inspection does not reveal this card. Use the assassination controls to reveal it publicly or decline.' : 'This identity has already been revealed publicly through Assassinate Leaders.'}
|
||||
</p>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
After a qualifying lost battle, Moritani may reveal a different opposing-faction leader’s Traitor Card. A living target goes to the Tanks and pays its printed strength; a target already in the Tanks pays no spice.
|
||||
</p>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
{assassination === 'retired' ? 'This physical card no longer belongs to a hand and cannot be called as a held traitor. Moritani has drawn its private replacement.' : 'The revealed card is set aside face up during Mentat Pause, when Moritani draws one private replacement.'}
|
||||
{' '}The marker records the faction’s once-per-game assassination use. Karama cannot cancel this advantage.
|
||||
</p>
|
||||
</>
|
||||
) : traitor ? (
|
||||
<>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { territory } from '@/game/board';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LeaderInspector } from './leader-inspector';
|
||||
|
||||
export function MoritaniAssassinate({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState('');
|
||||
const offer = game.moritaniAssassinate;
|
||||
const decision = game.decision;
|
||||
if (
|
||||
!offer?.pending ||
|
||||
decision?.kind !== 'moritaniAssassinate' ||
|
||||
decision.player !== game.me ||
|
||||
decision.event !== offer.pending.event ||
|
||||
offer.owner !== game.me
|
||||
)
|
||||
return null;
|
||||
const pending = offer.pending;
|
||||
const card = pending.cards.find((candidate) => candidate.card === selected) ?? pending.cards[0];
|
||||
const blocked = busy || !!game.response || !!game.truthtrance;
|
||||
const opponent = game.players.find((player) => player.id === pending.opponent);
|
||||
const factionName = opponent ? faction(opponent.faction).name : 'the opposing faction';
|
||||
const selectedLeader = card && game.allLeaders.find((leader) => leader.id === card.card);
|
||||
return (
|
||||
<section className="min-w-0 space-y-3" aria-label="Moritani Assassinate Leaders">
|
||||
<h3 className="font-serif text-xl">Assassinate Leaders</h3>
|
||||
<p className="text-base leading-7">
|
||||
After losing this battle, you may reveal one Traitor Card for {factionName},
|
||||
naming a different opposing leader. The physical card is revealed publicly;
|
||||
it is replaced automatically during Mentat Pause.
|
||||
</p>
|
||||
<p className="fine">
|
||||
This advantage is available once against each faction. It cannot be canceled
|
||||
with Karama. A dead target remains a legal choice, but yields no spice.
|
||||
</p>
|
||||
{pending.blocked && <p className="notice">{pending.blocked}</p>}
|
||||
{pending.cards.length > 0 ? (
|
||||
<>
|
||||
<label className="block space-y-2">
|
||||
<span className="font-semibold">Traitor Card to reveal</span>
|
||||
<select
|
||||
aria-label="Traitor Card to reveal"
|
||||
className="min-h-11 w-full rounded border border-[#65644b] bg-[#171b17] px-3 py-2"
|
||||
value={card?.card ?? ''}
|
||||
disabled={blocked}
|
||||
onChange={(event) => setSelected(event.currentTarget.value)}
|
||||
>
|
||||
{pending.cards.map((candidate) => (
|
||||
<option key={candidate.card} value={candidate.card}>
|
||||
{candidate.name}{candidate.dead ? ' · dead target · 0 spice' : ` · ${candidate.bounty} spice`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{card && (
|
||||
<div className="rounded-lg border border-[#485542] p-4 space-y-2">
|
||||
<p className="m-0"><strong>{card.name}</strong> · {card.dead ? '0 spice; target already dead' : `${card.bounty} spice bounty`}</p>
|
||||
<p className="m-0 text-sm">Printed leader strength: {selectedLeader?.strength ?? 'unknown'}</p>
|
||||
<LeaderInspector
|
||||
kind="traitor"
|
||||
assassination="choice"
|
||||
identity={{ name: card.name, factionName, strength: selectedLeader?.strength ?? 0 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={blocked || !card}
|
||||
onClick={() => card && !blocked && act({ type: 'decision', event: pending.event, card: card.card })}
|
||||
>
|
||||
Reveal {card?.name ?? 'selected Traitor Card'} publicly
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="notice">No eligible opposing-faction Traitor Card is available for this advantage.</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={blocked}
|
||||
onClick={() => !blocked && act({ type: 'decision', event: pending.event, decline: true })}
|
||||
>
|
||||
{pending.cards.length ? 'Decline Assassinate Leaders' : 'Continue'}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoritaniAssassinateHistory({ game }: { game: GameView }) {
|
||||
const history = game.moritaniAssassinate?.history ?? [];
|
||||
if (!history.length) return null;
|
||||
return (
|
||||
<section className="min-w-0 space-y-3" aria-label="Moritani Assassinate Leaders history">
|
||||
<h3 className="font-serif text-xl">Assassinate Leaders revealed cards</h3>
|
||||
<p className="fine">
|
||||
These physical Traitor Cards were revealed publicly. A replaced card is
|
||||
set aside face up; replacement happens during Mentat Pause.
|
||||
</p>
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
{history.map((entry) => {
|
||||
const leader = game.allLeaders.find((candidate) => candidate.id === entry.card);
|
||||
const leaderName = leader?.name ?? entry.card;
|
||||
const factionName = faction(entry.faction).name;
|
||||
return (
|
||||
<article key={entry.event} className="min-w-0 space-y-2 rounded-lg border border-[#485542] p-4">
|
||||
<p className="m-0 text-sm uppercase tracking-wide text-[#ddbc77]">
|
||||
{entry.stage === 'replaced' ? 'Set aside face up · replaced' : 'Revealed · awaiting replacement'}
|
||||
</p>
|
||||
<h4 className="font-serif text-lg break-words">{leaderName}</h4>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
{factionName} · {territory(entry.territory).name} · {entry.bounty} spice bounty · Turn {entry.turn}
|
||||
</p>
|
||||
<LeaderInspector
|
||||
kind="traitor"
|
||||
assassination={entry.stage === 'replaced' ? 'retired' : 'revealed'}
|
||||
identity={{
|
||||
id: entry.card,
|
||||
name: leaderName,
|
||||
factionName,
|
||||
strength: leader?.strength ?? 0,
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user