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>
|
||||
);
|
||||
}
|
||||
+19
-5
@@ -6,10 +6,10 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest verified checkpoint: `8e1cd7b`, Emperor Nexus extra revival.
|
||||
Types, lint, **4,649 offline tests**, build and **43 HTTP tests** passed.
|
||||
All 733 opening rooms and original seat/recovery records were preserved
|
||||
(767 after HTTP checks); all 734 present immediately before verification also
|
||||
Latest verified checkpoint: `8906b41`, Truthtrance recorded knowledge.
|
||||
Types, lint, **4,662 offline tests**, build and **43 HTTP tests** passed.
|
||||
All 767 opening rooms and original seat/recovery records were preserved
|
||||
(801 after HTTP checks); all 768 present immediately before verification also
|
||||
remained unchanged. The healthy server was reused.
|
||||
|
||||
[Mentat](MENTAT_QUESTION.md) connects private naming/disclosure before posture
|
||||
@@ -43,7 +43,7 @@ forced spice payments. No answer or purchase policy is assumed. All 733 opening
|
||||
rooms and seat/recovery records survived the latest outage unchanged; a new snapshot
|
||||
protects all 734 rooms before final verification. The healthy server is reused.
|
||||
|
||||
Current checkpoint: [Truthtrance recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), connecting
|
||||
Completed prototype: [Truthtrance recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), connecting
|
||||
stored BG prediction faction/turn, submitted current Storm dials and actually known
|
||||
Fremen forecasts. The same private verified answer drives controls, all four bots
|
||||
and saved continuation. Independent source/privacy review and 44 focused cases pass,
|
||||
@@ -56,6 +56,20 @@ the healthy server is reused. A new snapshot protects all 768 rooms before broad
|
||||
verification. Final checks, preservation totals and Git delivery are recorded in
|
||||
the checkpoint commit and private report; full mode/publication gates remain.
|
||||
|
||||
Current checkpoint: [Moritani Assassinate Leaders](MORITANI_ASSASSINATE_LEADERS.md)
|
||||
connects a qualifying battle loss to a private reveal/continue choice, living/dead
|
||||
targets, bank bounty, public markers and automatic Mentat replacement. All four
|
||||
AI profiles and saved continuation are connected in the bounded Advanced preview.
|
||||
Independent source/privacy/persistence review and 32 focused cases pass. Two genuine
|
||||
four-profile games finish in 296/101 actions with no rejections; both observed
|
||||
opportunities are declined. A separate conserved, staged browser battle verifies
|
||||
the reveal, three-spice payment, automatic physical replacement, readable inspectors
|
||||
and pending/resolved refresh. Normal-traitor-call duration and the uniform private
|
||||
UX question remain pending; exceptional custody and combined modules stay gated.
|
||||
All 801 opening games remain unchanged after the reported outage; the healthy
|
||||
server is reused. Required broad checks, source fingerprint, final preservation
|
||||
and Git delivery are recorded in the checkpoint commit and private report.
|
||||
|
||||
## Authoritative navigation
|
||||
|
||||
- [Rules checklist](../game/reference.ts), topic `implementation-checklist` and
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 14 September 2026 — Moritani Assassinate Leaders prototype and outage preservation
|
||||
|
||||
[Assassinate Leaders](MORITANI_ASSASSINATE_LEADERS.md) connects native Advanced
|
||||
Moritani's qualifying battle loss to private reveal/continue controls, a different
|
||||
opposing-faction Traitor Card, live-target death and bank bounty, and automatic
|
||||
Mentat replacement with a public set-aside marker. Dead targets remain legal with
|
||||
zero bounty. All four AI profiles use the owner's legal choices; one use per
|
||||
faction and physical held/reserve/retired custody persist across saves.
|
||||
|
||||
The explicit local preview excludes exceptional captured/foreign leaders and
|
||||
combined modules. A uniform private step prevents hidden eligibility leaks but
|
||||
awaits the user's UX decision. Normal traitor-call duration also remains unresolved;
|
||||
a recorded call guards later use without declaring a publisher ruling. No ordinary
|
||||
start or publication gate opens. The checklist records the bounded prototype.
|
||||
|
||||
Independent source/privacy/persistence review and 32 focused cases pass. Separate
|
||||
battle obligations bind cleanup, normal-call logs wait for public revelation, and
|
||||
replacement custody rejects missing cards and forged same-turn consumption chains.
|
||||
Five SQLite cases cover authenticated pending/resolved restoration, Truthtrance
|
||||
interruption, competing writes, stale/duplicate actions and corrupt saved receipts.
|
||||
|
||||
Two genuine Advanced four-profile games finish in 296/101 accepted actions, with
|
||||
zero rejected candidates, ten periodic JSON rounds and all eight final seat views
|
||||
restored. Exact physical custody holds. Two opportunities are declined; these
|
||||
samples do not exercise an assassination reveal. A separate conserved, staged
|
||||
browser battle verifies Master Bewt's death, three-spice bounty, retained card,
|
||||
automatic Mentat replacement, public marker, context-correct readable inspection,
|
||||
and pending/resolved refresh. The QA game remains at human Terror placement.
|
||||
|
||||
After the reported power failure, all 801 opening games remain unchanged and the
|
||||
healthy server is reused. Final types/lint/offline/build/HTTP results, source
|
||||
fingerprint, preservation of room and seat/recovery records, and Git delivery are
|
||||
recorded in the verified commit and private report. The full goal remains unfinished.
|
||||
|
||||
## 14 September 2026 — Truthtrance recorded knowledge and outage preservation
|
||||
|
||||
[Truthtrance knowledge](TRUTHTRANCE_KNOWLEDGE.md) connects stored Bene Gesserit
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Moritani: Advanced Assassinate Leaders preview
|
||||
|
||||
14 September 2026. **Prototyped / Partial**, in an explicitly opted-in local
|
||||
preview. Normal game-start and publication gates remain closed. This is the
|
||||
battle-loss ability, separate from the random Assassination Terror token.
|
||||
|
||||
## Authority and unresolved interpretation
|
||||
|
||||
The [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf),
|
||||
pp. 6, 14 and 16, supplies the ability, captured-own-leader answer and Karama table.
|
||||
Independent source review found no official clarification of the disputed duration
|
||||
below. The runtime uses the following undisputed sequence:
|
||||
|
||||
1. Native Advanced Moritani loses a normal battle against a surviving opposing
|
||||
leader disc, with no traitor called.
|
||||
2. Moritani may reveal a held Traitor Card of the opposing player's printed
|
||||
faction, naming a different leader from the disc just opposed.
|
||||
3. A living named leader goes to the Tanks and pays its printed strength from
|
||||
the Bank. An already-dead named leader remains a legal reveal: no repeated
|
||||
death or spice, but the faction use and replacement still occur.
|
||||
4. Keep the revealed physical card through battle cleanup. During Mentat Pause,
|
||||
set it aside face up as that faction's permanent use marker and draw one
|
||||
private physical replacement. Use the advantage once per opposing faction.
|
||||
Karama has no effect on this ability.
|
||||
|
||||
The printed statement that a normal traitor reveal loses this advantage does not
|
||||
specify a battle, faction or game duration. The prototype records the normal call
|
||||
and explicitly guards further use; it does not label that guard as a settled
|
||||
permanent forfeiture. The user has been asked whether to keep that interaction
|
||||
gated or choose a duration. Previously normally revealed cards and exceptional
|
||||
leader custody also remain outside this first runtime contract.
|
||||
|
||||
The captured-own-leader answer preserves original faction identity: a Moritani
|
||||
leader used by Harkonnen does not become a Harkonnen-faction assassination card.
|
||||
It does not define every other captured or foreign-ghola target interaction.
|
||||
|
||||
## Connected scope and private information
|
||||
|
||||
The preview supports native Moritani with Atreides, Bene Gesserit, Emperor,
|
||||
Fremen and Guild opponents, Advanced rules and the Ecaz faction expansion, without
|
||||
optional modules. Harkonnen presence is excluded uniformly from public configuration
|
||||
so candidate availability cannot reveal secret captures. Other expansion factions,
|
||||
Nexus, Leader Skills, Stronghold Cards, Homeworlds and Discoveries require further
|
||||
custody, ordering and integration work. A first-version scope is not a printed
|
||||
restriction on those combinations.
|
||||
|
||||
Every publicly qualifying loss gets the same private reveal-or-continue opportunity,
|
||||
regardless of the hidden eligible card. Only Moritani sees its candidate identities
|
||||
and blocked reason; rivals see the same decision and no private options. This
|
||||
requires Continue even for an ineligible hand, conflicting with the user's usual
|
||||
automatic-action preference. A uniform-private-step UX question is pending, shared
|
||||
with Mentat. Until resolved, activation requires an explicit development preview;
|
||||
no ordinary action or room API enables it. A publicly spent faction or prior
|
||||
normal traitor call can be skipped without consulting hidden eligibility.
|
||||
|
||||
All four AI profiles use the same owner-only quote. They choose a legal card or
|
||||
decline and do not inspect rival hidden state. The public history shows only
|
||||
revealed cards and face-up set-aside markers; replacement identities remain private.
|
||||
Dead-target inspection retains the printed leader strength despite its zero bounty.
|
||||
|
||||
## Save and physical custody
|
||||
|
||||
The resolved battle binds the opportunity's event, opposing faction and surviving
|
||||
leader. Its saved continuation is also bound to a separate battle obligation before
|
||||
the decision opens. Reveal/decline resumes that exact cleanup once. A normal
|
||||
traitor call has a separate event ledger; its explanatory log waits until the
|
||||
normal public reveal, preserving sealed submissions.
|
||||
|
||||
A physical Traitor Card belongs to one of three zones: reserve deck, player hands,
|
||||
or this ability's public set-aside markers. Revealed cards stay held until Mentat;
|
||||
retired cards never return to the deck in this profile. A replacement remains with
|
||||
Moritani or becomes the card consumed by a later-turn assassination against another
|
||||
faction. Cross-zone duplication, missing replacements, duplicate draws, forged
|
||||
same-turn chains and altered continuation receipts fail validation.
|
||||
|
||||
Replacement runs automatically when Mentat begins. In this bounded profile it does
|
||||
not compete with other traitor draws, and Terror placement does not alter traitor
|
||||
custody. Ordering with Face Dancers, Nexus/Rihani draws, Extortion and other modules
|
||||
remains an explicit integration boundary, not an inferred universal ordering.
|
||||
|
||||
## Reproduction and evidence
|
||||
|
||||
Use the existing private backup/CAS workflow with a fresh ready lobby:
|
||||
|
||||
```sh
|
||||
node --import tsx tools/start-prototype.ts --profile moritani-assassinate --db /absolute/games.sqlite --room ABCDEFGH --version 1 --out /private/new-checkpoint
|
||||
npm test -- moritani-assassinate prototype-room
|
||||
```
|
||||
|
||||
Rules and engine tests use genuine Advanced setup followed by explicitly staged,
|
||||
conserved forces and physical traitor swaps. They cover living and dead targets,
|
||||
no-use paths, all four profiles, private indistinguishability, sealed normal calls,
|
||||
per-faction limits, a real later-turn replacement chain, rejected-action immutability
|
||||
and corrupt saves. Authenticated SQLite tests exercise pending restoration,
|
||||
Truthtrance interruption, competing submissions and the actual Mentat replacement.
|
||||
The local writer test preserves seats and unrelated rooms and rejects repeat starts.
|
||||
|
||||
Independent rules/privacy/persistence review and the 32-case focused union pass:
|
||||
eight pure quote cases, eleven engine cases, three controls cases, five authenticated
|
||||
SQLite cases and five prototype-writer cases (one added for this profile).
|
||||
|
||||
Two genuine Advanced four-player samples, with all four AI profiles rotated, finish
|
||||
in 296 and 101 accepted actions with no rejected candidates. Neither sample stages
|
||||
hands, decks, forces or phases. Ten periodic JSON rounds and all eight final seat
|
||||
views restore; physical Treachery, Traitor, force and elite custody hold throughout.
|
||||
The first game offers two assassination opportunities, both declined; the second
|
||||
offers none. These samples prove continuation, not natural reveal/replacement use.
|
||||
|
||||
A separate browser exercise starts from genuine setup with explicitly staged,
|
||||
conserved battle forces and one physical traitor swap. Real battle actions produce
|
||||
the pending choice. Revealing Master Bewt kills that live leader and pays three
|
||||
spice once; the card stays held until automatic Mentat replacement, then becomes
|
||||
a public set-aside marker. Pending and resolved refresh preserve the human seat.
|
||||
Choice and retired-card inspectors show readable, context-correct guidance. Exact
|
||||
card/force custody and private projections pass; Guild separately collects two
|
||||
spice from Arrakeen. The game remains saved at human Terror placement.
|
||||
|
||||
All 801 opening games remain unchanged after the reported power failure. The healthy
|
||||
server is reused. Broad checks, source fingerprint, final saved-game preservation
|
||||
and Git delivery are recorded with the checkpoint. Focused and staged evidence
|
||||
does not certify a complete faction, all combinations or public
|
||||
Advanced readiness.
|
||||
@@ -20,6 +20,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
|
||||
| Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) |
|
||||
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic and base Advanced active-opportunity shipment promises bind within their documented scope (Advanced excludes Guild and optional modules); prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
|
||||
| Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) |
|
||||
| Moritani Advanced assassination preview | After an eligible battle loss, a different opposing-faction Traitor Card may be revealed even if its leader is already dead (zero bounty). Face-up set-aside and one private replacement occur during Mentat; one use per faction. Normal-call duration, uniform hidden-eligibility UX and exceptional combinations remain explicit. | [Runtime/source contract](MORITANI_ASSASSINATE_LEADERS.md) |
|
||||
| Truthtrance recorded knowledge | Questions compare an already recorded BG prediction, active submitted storm dial or actually known native Fremen forecast. Missing knowledge is unknown; only the aggregate answer is published. No future promise or unknown deck peek is created. | [Connected predicates and evidence](TRUTHTRANCE_KNOWLEDGE.md) |
|
||||
| Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) |
|
||||
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
|
||||
|
||||
@@ -1586,6 +1586,14 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
return [{ type: 'decision', decline: true }];
|
||||
}
|
||||
if (d.kind === 'moritaniAssassinate') {
|
||||
const pending = g.moritaniAssassinate?.pending;
|
||||
if (!pending || pending.event !== d.event) return [];
|
||||
const cards = [...pending.cards].sort((a,b) => b.bounty-a.bounty || a.card.localeCompare(b.card));
|
||||
return [cards.length && !pending.blocked
|
||||
? {type:'decision',event:d.event,card:cards[0].card}
|
||||
: {type:'decision',event:d.event,decline:true}];
|
||||
}
|
||||
if (d.kind === 'moritaniRetention') {
|
||||
const candidates = (me.hand ?? [])
|
||||
.filter((card) => d.cards.includes(card.id))
|
||||
|
||||
+199
@@ -28,6 +28,7 @@ import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type Great
|
||||
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
|
||||
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { truthKnowledgeOf } from './truthtrance-knowledge';
|
||||
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
|
||||
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
|
||||
@@ -750,6 +751,7 @@ export type Decision =
|
||||
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
|
||||
| { kind: 'fullPlanOffer'; player: string }
|
||||
| {kind:'bureaucratPayment';player:string;event:string}
|
||||
| { kind: 'moritaniAssassinate'; player: string; event: string }
|
||||
| { kind: 'mentatQuestion'; player: string; event: string; owner: string; target: string; stage: 'name' | 'reveal'; weapon: string | null }
|
||||
| { kind: 'fullPlanRead'; player: string; target: string }
|
||||
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
|
||||
@@ -1487,6 +1489,15 @@ export type Game = {
|
||||
bureaucratUseEvents?: string[];
|
||||
/** Private development opt-in pending the uniform Mentat response UX decision. */
|
||||
mentatQuestionPreview?: true;
|
||||
/** Private development opt-in while uniform hidden-eligibility UX is pending. */
|
||||
moritaniAssassinatePreview?: true;
|
||||
moritaniAssassinate?: MoritaniAssassinateState;
|
||||
moritaniAssassinateCallEvents?: string[];
|
||||
moritaniAssassinateResume?: {
|
||||
event: string;
|
||||
continuation: Extract<NonNullable<Game['pendingTreacheryDiscard']>['continuation'], {kind:'battleResolved'}>;
|
||||
signature: string;
|
||||
};
|
||||
mentatHistory?: MentatObservation[];
|
||||
mentatHistoryEvents?: string[];
|
||||
advanced: boolean;
|
||||
@@ -1556,6 +1567,7 @@ export type Game = {
|
||||
sandmaster?: { leader: string; key: string; before: number; after: number };
|
||||
caladanReinforcement?: HomeworldVictoryObligation;
|
||||
nexusSardaukarCasualties?: string;
|
||||
moritaniAssassinate?: {event:string;signature:string;continuation?:string};
|
||||
} | null;
|
||||
auction: Auction | null;
|
||||
battle: Battle | null;
|
||||
@@ -5048,7 +5060,162 @@ function leaderSkillAssignmentUnavailable(
|
||||
? ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK
|
||||
: null;
|
||||
}
|
||||
function moritaniAssassinateModeSupported(g: Game) {
|
||||
return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
|
||||
g.players.some(p => p.faction === 'moritani') &&
|
||||
g.players.every(p => ['moritani','atreides','beneGesserit','guild','emperor','fremen'].includes(p.faction)) &&
|
||||
!g.nexusCards && !g.leaderSkills && !g.strongholdCards && !g.homeworlds &&
|
||||
!g.discoveryEnabled && !g.discoveries && !g.techTokens;
|
||||
}
|
||||
function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) {
|
||||
const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent);
|
||||
return {state:g.moritaniAssassinate!,receipt,held:p.traitors,
|
||||
normallyRevealed:p.revealedTraitors ?? [],leaders:opponent.leaders};
|
||||
}
|
||||
function moritaniAssassinateIntegrity(g: Game) {
|
||||
const state = g.moritaniAssassinate, resume = g.moritaniAssassinateResume;
|
||||
requireRule(g.moritaniAssassinatePreview === undefined || g.moritaniAssassinatePreview === true,
|
||||
'Invalid assassination preview setting.');
|
||||
if (!state) {
|
||||
requireRule(!g.moritaniAssassinatePreview && !resume && !g.moritaniAssassinateCallEvents && !g.lastBattleContext?.moritaniAssassinate &&
|
||||
g.decision?.kind !== 'moritaniAssassinate', 'The assassination preview lost its original state.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.moritaniAssassinatePreview === true && moritaniAssassinateModeSupported(g) &&
|
||||
state.owner === byFaction(g,'moritani')?.id, 'This assassination preview no longer matches its original faction profile.');
|
||||
nexusRule(() => validateMoritaniAssassinate(state,{turn:g.turn,held:getPlayer(g,state.owner).traitors,
|
||||
removed:Array.isArray(state.opportunities) ? state.opportunities.filter(r => r?.stage === 'replaced').map(r => r.card!) : []}));
|
||||
const calls = g.moritaniAssassinateCallEvents;
|
||||
requireRule(Array.isArray(calls) && calls.every(event => typeof event === 'string' && event.length > 0) &&
|
||||
new Set(calls).size === calls.length && state.normalTraitorCall === (calls.length > 0),
|
||||
'The assassination normal-traitor-call guard lost its original event record.');
|
||||
for (const receipt of state.opportunities) {
|
||||
const opponent = getPlayer(g,receipt.opponent);
|
||||
requireRule(receipt.turn <= g.turn && opponent.faction === receipt.faction &&
|
||||
receipt.owner === state.owner && TERRITORIES.some(t => t.id === receipt.territory),
|
||||
'The assassination history no longer matches its original battle participants.');
|
||||
}
|
||||
const pending = state.opportunities.filter(r => r.stage === 'choice');
|
||||
requireRule(pending.length <= 1, 'Finish the current assassination opportunity first.');
|
||||
const obligation = g.lastBattleContext?.moritaniAssassinate;
|
||||
if (obligation) {
|
||||
const receipt = state.opportunities.find(r => r.event === obligation.event);
|
||||
requireRule(receipt && receipt.signature === obligation.signature && receipt.event === g.lastBattleContext!.event,
|
||||
'The completed battle lost or changed its assassination obligation.');
|
||||
}
|
||||
if (pending.length) {
|
||||
const r = pending[0];
|
||||
requireRule(g.status === 'playing' && g.phase === 6 && g.turn === r.turn && !g.battle &&
|
||||
g.lastBattleContext?.event === r.event && g.lastBattleContext.winner === r.opponent &&
|
||||
g.lastBattleContext.result === 'normal' && g.lastBattleContext.territory === r.territory &&
|
||||
obligation?.event === r.event, 'The assassination choice lost its resolved battle.');
|
||||
requireRule(resume && resume.event === r.event && resume.continuation.event === r.event &&
|
||||
resume.continuation.winner === r.opponent && resume.continuation.territory === r.territory &&
|
||||
resume.continuation.result === 'normal' && JSON.stringify(resume.continuation.combatants) === JSON.stringify(g.lastBattleContext.combatants) &&
|
||||
resume.signature === JSON.stringify(resume.continuation) && resume.signature === obligation.continuation,
|
||||
'The assassination choice lost or changed its saved cleanup.');
|
||||
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'moritaniAssassinate');
|
||||
requireRule(decisions.length === 1 && decisions[0].player === r.owner &&
|
||||
(decisions[0] as Extract<Decision,{kind:'moritaniAssassinate'}>).event === r.event,
|
||||
'The assassination choice lost its owning decision.');
|
||||
} else requireRule(!resume && g.decision?.kind !== 'moritaniAssassinate', 'A settled assassination cannot retain its decision.');
|
||||
if (g.status === 'playing' || g.status === 'finished') {
|
||||
const retired = state.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!);
|
||||
const physical = [...(g.traitorReserve ?? []),...g.players.flatMap(p => p.traitors),...retired].sort();
|
||||
requireRule(JSON.stringify(physical) === JSON.stringify([...nexusTraitorUniverse(g)].sort()),
|
||||
'Assassination must preserve every physical Traitor Card in hand, deck or face-up set-aside custody.');
|
||||
for (const r of state.opportunities.filter(r => r.stage === 'revealed'))
|
||||
requireRule(getPlayer(g,r.owner).traitors.includes(r.card!), 'The revealed assassination card must remain held until Mentat replacement.');
|
||||
const replacements = new Set<string>();
|
||||
for (const [index,r] of state.opportunities.entries()) if (r.stage === 'replaced') {
|
||||
const laterUses = state.opportunities.slice(index+1).filter(next => next.card === r.replacement &&
|
||||
(next.stage === 'revealed' || next.stage === 'replaced'));
|
||||
const ownedReplacement = laterUses.length ? laterUses.every(next => next.turn > r.turn)
|
||||
: getPlayer(g,r.owner).traitors.includes(r.replacement!);
|
||||
requireRule(!replacements.has(r.replacement!) && ownedReplacement,
|
||||
'Each assassination replacement must stay with Moritani or be consumed by its later assassination.');
|
||||
replacements.add(r.replacement!);
|
||||
}
|
||||
}
|
||||
}
|
||||
function syncMoritaniAssassinateReceipt(g: Game, receipt: MoritaniAssassinateReceipt) {
|
||||
receipt.signature = moritaniAssassinateSignature(receipt);
|
||||
if (g.lastBattleContext?.event === receipt.event)
|
||||
g.lastBattleContext.moritaniAssassinate = {...g.lastBattleContext.moritaniAssassinate,event:receipt.event,signature:receipt.signature};
|
||||
}
|
||||
function recordMoritaniAssassinateOpportunity(g: Game, b: Battle, winner: Player | undefined, loser: Player | null) {
|
||||
const state = g.moritaniAssassinate;
|
||||
if (!state || state.normalTraitorCall || !winner || loser?.id !== state.owner ||
|
||||
state.opportunities.some(r => r.faction === winner.faction && ['revealed','replaced'].includes(r.stage))) return;
|
||||
const opposingLeader = winner.leaders.find(l => l.id === b.plans[winner.id].leader);
|
||||
const trigger = {advanced:g.advanced,owner:state.owner,ownerFaction:loser.faction,opponent:winner.id,
|
||||
faction:winner.faction,event:g.lastBattleContext!.event,turn:g.turn,territory:b.territory,
|
||||
opposingLeader:opposingLeader?.id ?? null,opposingLeaderSurvived:!!opposingLeader && !opposingLeader.dead,
|
||||
normalBattle:g.lastBattleContext!.result === 'normal',lostBattle:true,
|
||||
traitorCalled:Object.values(b.traitorCalls).some(Boolean)};
|
||||
if (!moritaniAssassinateTrigger(trigger)) return;
|
||||
const receipt = createMoritaniAssassinateOpportunity(trigger);
|
||||
state.opportunities.push(receipt);
|
||||
syncMoritaniAssassinateReceipt(g,receipt);
|
||||
}
|
||||
function projectedMoritaniAssassinate(g: Game, viewer: string) {
|
||||
const state = g.moritaniAssassinate;
|
||||
if (!state) return null;
|
||||
const pending = state.opportunities.find(r => r.stage === 'choice');
|
||||
const own = viewer === state.owner;
|
||||
const choices = pending && own ? nexusRule(() => moritaniAssassinateChoices(moritaniAssassinateContext(g,pending))) : null;
|
||||
return {owner:state.owner,blocked:own && state.normalTraitorCall
|
||||
? 'Assassinate Leaders after a normal traitor call awaits clarification of the printed loss-of-advantage rule.' : null,
|
||||
pending:pending ? {event:pending.event,opponent:pending.opponent,territory:pending.territory,
|
||||
cards:choices?.cards ?? [],blocked:choices?.blocked ?? null} : null,
|
||||
history:state.opportunities.filter(r => r.stage === 'revealed' || r.stage === 'replaced').map(r => ({
|
||||
event:r.event,turn:r.turn,opponent:r.opponent,faction:r.faction,territory:r.territory,
|
||||
card:r.card!,bounty:r.bounty,stage:r.stage as 'revealed'|'replaced',
|
||||
}))};
|
||||
}
|
||||
function actMoritaniAssassinate(g: Game, decision: Extract<Decision,{kind:'moritaniAssassinate'}>, action: Action) {
|
||||
const state = g.moritaniAssassinate!, r = state.opportunities.find(r => r.stage === 'choice')!;
|
||||
requireRule(r && action.event === r.event && decision.event === r.event && decision.player === r.owner,
|
||||
'This assassination opportunity has already changed.');
|
||||
const resume = g.moritaniAssassinateResume!;
|
||||
requireRule(resume?.event === r.event, 'The original assassination cleanup is missing.');
|
||||
if (action.decline === true) {
|
||||
requireRule(Object.keys(action).every(k => ['type','event','decline'].includes(k)), 'Continue without selecting an assassination card.');
|
||||
r.stage = 'declined';
|
||||
log(g, `${getPlayer(g,r.owner).name} finished the Assassinate Leaders opportunity without revealing a card.`, {faction:'moritani',name:'Assassinate Leaders'});
|
||||
} else {
|
||||
requireRule(Object.keys(action).every(k => ['type','event','card'].includes(k)), 'Choose one assassination Traitor Card or continue.');
|
||||
const quote = nexusRule(() => quoteMoritaniAssassinate(moritaniAssassinateContext(g,r),stringField(action.card)));
|
||||
const target = getPlayer(g,r.opponent).leaders.find(l => l.id === quote.leader)!;
|
||||
if (quote.kill) {target.dead = true;target.deaths++;delete target.usedAt;}
|
||||
const owner = getPlayer(g,r.owner);
|
||||
requireRule(Number.isSafeInteger(owner.spice + quote.bounty), 'Assassination income exceeds the supported spice balance.');
|
||||
owner.spice += quote.bounty;
|
||||
owner.revealedTraitors = [...new Set([...(owner.revealedTraitors ?? []),quote.leader])];
|
||||
Object.assign(r,quote.receipt);
|
||||
log(g, `${owner.name} revealed ${target.name} as a traitor of ${faction(r.faction).name}. ${quote.kill ? `${target.name} was sent to the Tanks and ${owner.name} collected ${quote.bounty} spice from the bank.` : 'That leader was already in the Tanks; no new death or spice was awarded.'} This uses Assassinate Leaders against that faction for the game. The revealed card will be set aside and replaced during Mentat Pause.`, {faction:'moritani',name:'Assassinate Leaders'});
|
||||
}
|
||||
syncMoritaniAssassinateReceipt(g,r);
|
||||
delete g.moritaniAssassinateResume;
|
||||
continueResolvedBattle(g,resume.continuation);
|
||||
}
|
||||
function replaceMoritaniAssassinationTraitors(g: Game) {
|
||||
const state = g.moritaniAssassinate;
|
||||
if (!state) return;
|
||||
for (const r of state.opportunities.filter(r => r.stage === 'revealed')) {
|
||||
const owner = getPlayer(g,r.owner);
|
||||
requireRule(r.turn === g.turn && owner.traitors.includes(r.card!) && (g.traitorReserve?.length ?? 0) > 0,
|
||||
'Mentat replacement needs the original revealed card and one remaining physical Traitor Card.');
|
||||
owner.traitors = owner.traitors.filter(id => id !== r.card);
|
||||
r.replacement = g.traitorReserve!.shift()!;
|
||||
owner.traitors.push(r.replacement);
|
||||
r.stage = 'replaced';
|
||||
syncMoritaniAssassinateReceipt(g,r);
|
||||
log(g, `${owner.name} set the revealed ${faction(r.faction).name} Traitor Card aside face up and drew one private replacement during Mentat Pause. The set-aside card records this faction's used assassination.`, {faction:'moritani',name:'Assassination replacement'});
|
||||
}
|
||||
}
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
moritaniAssassinateIntegrity(g);
|
||||
bureaucratPaymentIntegrity(g);
|
||||
mentatQuestionIntegrity(g);
|
||||
diplomatDefenseIntegrity(g);
|
||||
@@ -5638,6 +5805,17 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
|
||||
}), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.');
|
||||
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
|
||||
}
|
||||
/** Genuine, explicitly opted-in preview; no player action enables this profile. */
|
||||
export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
|
||||
requireRule(!state.moritaniAssassinatePreview && !state.moritaniAssassinate && !state.moritaniAssassinateResume && !state.moritaniAssassinateCallEvents,
|
||||
'The assassination preview starts only from a fresh lobby.');
|
||||
requireRule(moritaniAssassinateModeSupported(state), 'The assassination preview needs Advanced Moritani with ordinary base opponents except Harkonnen, the Ecaz expansion and no optional modules.');
|
||||
const g = initializeFactionExpansionsGameForAudit(state);
|
||||
g.moritaniAssassinatePreview = true;
|
||||
g.moritaniAssassinate = {version:1,owner:byFaction(g,'moritani')!.id,normalTraitorCall:false,opportunities:[]};
|
||||
g.moritaniAssassinateCallEvents = [];
|
||||
return g;
|
||||
}
|
||||
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
|
||||
export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker,
|
||||
@@ -12347,6 +12525,7 @@ function beginPhase(g: Game) {
|
||||
collect(g);
|
||||
}
|
||||
if (g.phase === 8) {
|
||||
replaceMoritaniAssassinationTraitors(g);
|
||||
if (g.inflation && g.inflation.updatedTurn < g.turn) {
|
||||
if (g.inflation.flipped) {
|
||||
g.inflation = null;
|
||||
@@ -14898,6 +15077,8 @@ function resolveBattle(g: Game) {
|
||||
: (g.players.flatMap((p) => p.leaders).find((l) => l.id === identity)
|
||||
?.name ?? identity);
|
||||
log(g, `${holder.name} revealed ${name} as a traitor.`);
|
||||
if (holder.id === g.moritaniAssassinate?.owner)
|
||||
log(g, `${holder.name} called a traitor normally. Further Assassinate Leaders use awaits clarification of the printed loss-of-advantage rule.`, {faction:'moritani',name:'Assassinate Leaders boundary'});
|
||||
}
|
||||
const dead = (l: Leader | undefined) => {
|
||||
if (l) {
|
||||
@@ -15113,6 +15294,7 @@ function resolveBattle(g: Game) {
|
||||
...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
|
||||
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
|
||||
};
|
||||
recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer);
|
||||
if (winner && quote.sandmaster) {
|
||||
const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice));
|
||||
if (placement) {
|
||||
@@ -15185,6 +15367,8 @@ function resolveBattle(g: Game) {
|
||||
cards,
|
||||
...(casualtyCommitment ? { casualties: casualtyCommitment } : {}),
|
||||
};
|
||||
if (g.lastBattleContext.moritaniAssassinate)
|
||||
g.lastBattleContext.moritaniAssassinate.continuation = JSON.stringify(continuation);
|
||||
if (discarded.length)
|
||||
stageTreacheryDiscard(g, 'battle:mandatory', discarded, continuation);
|
||||
else continueResolvedBattle(g, continuation);
|
||||
@@ -15196,6 +15380,14 @@ function continueResolvedBattle(
|
||||
{ kind: 'battleResolved' }
|
||||
>,
|
||||
) {
|
||||
const assassination = g.moritaniAssassinate?.opportunities.find(r => r.stage === 'choice');
|
||||
if (assassination) {
|
||||
requireRule(assassination.event === continuation.event && !g.moritaniAssassinateResume,
|
||||
'The assassination opportunity must preserve its original battle cleanup.');
|
||||
g.moritaniAssassinateResume = {event:assassination.event,continuation:structuredClone(continuation),signature:JSON.stringify(continuation)};
|
||||
g.decision = {kind:'moritaniAssassinate',player:assassination.owner,event:assassination.event};
|
||||
return;
|
||||
}
|
||||
const { cards, territory: to } = continuation;
|
||||
const winner = continuation.winner ? getPlayer(g, continuation.winner) : null;
|
||||
const losses = continuation.casualties?.options;
|
||||
@@ -20334,6 +20526,7 @@ function applyActionInner(
|
||||
);
|
||||
g.decision = null;
|
||||
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
|
||||
if (decision.kind === 'moritaniAssassinate') {actMoritaniAssassinate(g,decision,action);return g;}
|
||||
if (decision.kind === 'mentatQuestion') {
|
||||
actMentatQuestion(g, decision, action);
|
||||
return g;
|
||||
@@ -23238,6 +23431,11 @@ function applyActionInner(
|
||||
if (action.call && b.traitorDeclarationVersion)
|
||||
b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id));
|
||||
b.traitorCalls[id] = action.call;
|
||||
if (action.call && g.moritaniAssassinate?.owner === id) {
|
||||
requireRule(b.event && !g.moritaniAssassinateCallEvents!.includes(b.event), 'This normal traitor call was already recorded.');
|
||||
g.moritaniAssassinate.normalTraitorCall = true;
|
||||
g.moritaniAssassinateCallEvents!.push(b.event);
|
||||
}
|
||||
if (
|
||||
action.call &&
|
||||
p.faction === 'harkonnen' &&
|
||||
@@ -23929,6 +24127,7 @@ export function viewGame(state: Game, id: string) {
|
||||
incomeCanceled:
|
||||
g.choamCharity?.turn === g.turn && g.choamCharity.canceled,
|
||||
},
|
||||
moritaniAssassinate: projectedMoritaniAssassinate(g,id),
|
||||
moritaniRetention: g.moritaniRetention ?? null,
|
||||
moritaniRetentionCard: g.moritaniRetention?.keep
|
||||
? (cardOf(
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { leaders as printedLeaders, type Leader } from './cards';
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
|
||||
export type MoritaniAssassinateReceipt = {
|
||||
event: string;
|
||||
turn: number;
|
||||
territory: string;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
faction: FactionId;
|
||||
opposingLeader: string;
|
||||
stage: 'choice' | 'declined' | 'revealed' | 'replaced';
|
||||
card: string | null;
|
||||
bounty: number;
|
||||
replacement: string | null;
|
||||
signature: string;
|
||||
};
|
||||
export type MoritaniAssassinateState = {
|
||||
version: 1;
|
||||
owner: string;
|
||||
normalTraitorCall: boolean;
|
||||
opportunities: MoritaniAssassinateReceipt[];
|
||||
};
|
||||
export type MoritaniAssassinateTrigger = {
|
||||
advanced: boolean;
|
||||
owner: string;
|
||||
ownerFaction: FactionId;
|
||||
opponent: string;
|
||||
faction: FactionId;
|
||||
event: string;
|
||||
turn: number;
|
||||
territory: string;
|
||||
opposingLeader: string | null;
|
||||
opposingLeaderSurvived: boolean;
|
||||
normalBattle: boolean;
|
||||
lostBattle: boolean;
|
||||
traitorCalled: boolean;
|
||||
};
|
||||
export type MoritaniAssassinateContext = {
|
||||
state: MoritaniAssassinateState;
|
||||
receipt: MoritaniAssassinateReceipt;
|
||||
held: readonly string[];
|
||||
normallyRevealed: readonly string[];
|
||||
leaders: readonly Leader[];
|
||||
};
|
||||
/** The engine owns the full traitor universe and the actual Mentat draw.
|
||||
* These lists verify the still-reserved and permanently removed card zones. */
|
||||
export type MoritaniAssassinateCustody = {
|
||||
turn: number;
|
||||
held: readonly string[];
|
||||
removed: readonly string[];
|
||||
};
|
||||
export type MoritaniAssassinateChoice = {
|
||||
card: string;
|
||||
name: string;
|
||||
bounty: number;
|
||||
dead: boolean;
|
||||
};
|
||||
|
||||
const opponents: readonly FactionId[] = [
|
||||
'atreides',
|
||||
'beneGesserit',
|
||||
'guild',
|
||||
'emperor',
|
||||
'fremen',
|
||||
];
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' &&
|
||||
value.trim().length > 0 &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(value);
|
||||
const exact = (value: unknown, keys: readonly string[]): boolean =>
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
|
||||
Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
||||
const identities = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) &&
|
||||
value.every(identifier) &&
|
||||
new Set(value).size === value.length;
|
||||
function requireAssassinate(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function printed(faction: FactionId, id: string) {
|
||||
return FACTIONS.some((f) => f.id === faction)
|
||||
? printedLeaders(faction).find((l) => l.id === id)
|
||||
: undefined;
|
||||
}
|
||||
function ordinaryIdentity(id: string) {
|
||||
return FACTIONS.some((f) => printed(f.id, id));
|
||||
}
|
||||
/** Consistency proof only; the engine separately binds the battle event and
|
||||
* phase continuation. No secret card identity enters this public trigger. */
|
||||
export function moritaniAssassinateTrigger(
|
||||
input: MoritaniAssassinateTrigger,
|
||||
): boolean {
|
||||
return (
|
||||
!!input &&
|
||||
input.advanced === true &&
|
||||
input.ownerFaction === 'moritani' &&
|
||||
identifier(input.owner) &&
|
||||
identifier(input.opponent) &&
|
||||
input.owner !== input.opponent &&
|
||||
identifier(input.event) &&
|
||||
identifier(input.territory) &&
|
||||
Number.isSafeInteger(input.turn) &&
|
||||
input.turn > 0 &&
|
||||
opponents.includes(input.faction) &&
|
||||
identifier(input.opposingLeader) &&
|
||||
!!printed(input.faction, input.opposingLeader) &&
|
||||
input.opposingLeaderSurvived === true &&
|
||||
input.normalBattle === true &&
|
||||
input.lostBattle === true &&
|
||||
input.traitorCalled === false
|
||||
);
|
||||
}
|
||||
export function moritaniAssassinateSignature(
|
||||
receipt: MoritaniAssassinateReceipt,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
'moritaniAssassinate',
|
||||
receipt.event,
|
||||
receipt.turn,
|
||||
receipt.territory,
|
||||
receipt.owner,
|
||||
receipt.opponent,
|
||||
receipt.faction,
|
||||
receipt.opposingLeader,
|
||||
receipt.stage,
|
||||
receipt.card,
|
||||
receipt.bounty,
|
||||
receipt.replacement,
|
||||
]);
|
||||
}
|
||||
export function createMoritaniAssassinateOpportunity(
|
||||
input: MoritaniAssassinateTrigger,
|
||||
): MoritaniAssassinateReceipt {
|
||||
requireAssassinate(
|
||||
moritaniAssassinateTrigger(input),
|
||||
'Assassinate needs an Advanced native Moritani normal battle loss against a surviving opposing leader, without a traitor call.',
|
||||
);
|
||||
const receipt: MoritaniAssassinateReceipt = {
|
||||
event: input.event,
|
||||
turn: input.turn,
|
||||
territory: input.territory,
|
||||
owner: input.owner,
|
||||
opponent: input.opponent,
|
||||
faction: input.faction,
|
||||
opposingLeader: input.opposingLeader!,
|
||||
stage: 'choice',
|
||||
card: null,
|
||||
bounty: 0,
|
||||
replacement: null,
|
||||
signature: '',
|
||||
};
|
||||
receipt.signature = moritaniAssassinateSignature(receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export function validateMoritaniAssassinate(
|
||||
state: MoritaniAssassinateState,
|
||||
custody?: MoritaniAssassinateCustody,
|
||||
): void {
|
||||
requireAssassinate(
|
||||
exact(state, ['version', 'owner', 'normalTraitorCall', 'opportunities']) &&
|
||||
state.version === 1 &&
|
||||
identifier(state.owner) &&
|
||||
typeof state.normalTraitorCall === 'boolean' &&
|
||||
Array.isArray(state.opportunities),
|
||||
'Invalid saved Moritani Assassinate state.',
|
||||
);
|
||||
if (custody)
|
||||
requireAssassinate(
|
||||
Number.isSafeInteger(custody.turn) &&
|
||||
custody.turn > 0 &&
|
||||
identities(custody.held) &&
|
||||
identities(custody.removed) &&
|
||||
!custody.held.some((id) => custody.removed.includes(id)),
|
||||
'Assassinate has invalid or overlapping physical traitor custody.',
|
||||
);
|
||||
const events = new Set<string>(),
|
||||
used = new Set<FactionId>(),
|
||||
cards = new Set<string>();
|
||||
for (const [index, r] of state.opportunities.entries()) {
|
||||
requireAssassinate(
|
||||
exact(r, [
|
||||
'event',
|
||||
'turn',
|
||||
'territory',
|
||||
'owner',
|
||||
'opponent',
|
||||
'faction',
|
||||
'opposingLeader',
|
||||
'stage',
|
||||
'card',
|
||||
'bounty',
|
||||
'replacement',
|
||||
'signature',
|
||||
]) &&
|
||||
identifier(r.event) &&
|
||||
!events.has(r.event) &&
|
||||
identifier(r.territory) &&
|
||||
r.owner === state.owner &&
|
||||
identifier(r.opponent) &&
|
||||
r.opponent !== r.owner &&
|
||||
opponents.includes(r.faction) &&
|
||||
Number.isSafeInteger(r.turn) &&
|
||||
r.turn > 0 &&
|
||||
(!custody || r.turn <= custody.turn) &&
|
||||
(index === 0 || r.turn >= state.opportunities[index - 1].turn) &&
|
||||
identifier(r.opposingLeader) &&
|
||||
!!printed(r.faction, r.opposingLeader) &&
|
||||
['choice', 'declined', 'revealed', 'replaced'].includes(r.stage) &&
|
||||
r.signature === moritaniAssassinateSignature(r),
|
||||
'Assassinate has a changed, duplicated or malformed battle opportunity.',
|
||||
);
|
||||
events.add(r.event);
|
||||
if (r.stage === 'choice' || r.stage === 'declined') {
|
||||
requireAssassinate(
|
||||
r.card === null &&
|
||||
r.bounty === 0 &&
|
||||
r.replacement === null &&
|
||||
(r.stage !== 'choice' ||
|
||||
(index === state.opportunities.length - 1 &&
|
||||
(!custody || r.turn === custody.turn))),
|
||||
'An unspent Assassinate choice cannot contain a reveal, bounty or replacement.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const target =
|
||||
typeof r.card === 'string' ? printed(r.faction, r.card) : undefined;
|
||||
requireAssassinate(
|
||||
target &&
|
||||
r.card !== r.opposingLeader &&
|
||||
!used.has(r.faction) &&
|
||||
!cards.has(r.card!) &&
|
||||
Number.isSafeInteger(r.bounty) &&
|
||||
(r.bounty === 0 || r.bounty === target.strength),
|
||||
'Assassinate must use a different native traitor once per opposing faction for zero or printed bounty.',
|
||||
);
|
||||
used.add(r.faction);
|
||||
cards.add(r.card!);
|
||||
if (r.stage === 'revealed')
|
||||
requireAssassinate(
|
||||
r.replacement === null &&
|
||||
(!custody ||
|
||||
(r.turn === custody.turn &&
|
||||
custody.held.includes(r.card!) &&
|
||||
!custody.removed.includes(r.card!))),
|
||||
'The revealed Assassinate traitor must stay held until its Mentat replacement.',
|
||||
);
|
||||
else
|
||||
requireAssassinate(
|
||||
identifier(r.replacement) &&
|
||||
ordinaryIdentity(r.replacement) &&
|
||||
r.replacement !== r.card &&
|
||||
(!custody ||
|
||||
(custody.removed.includes(r.card!) &&
|
||||
!custody.held.includes(r.card!))),
|
||||
'The replaced Assassinate traitor needs one distinct replacement and its face-up removed card.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Private diagnostics; callers must never gate a public pause on this result. */
|
||||
export function moritaniAssassinateChoices(
|
||||
context: MoritaniAssassinateContext,
|
||||
): {
|
||||
blocked: string | null;
|
||||
cards: MoritaniAssassinateChoice[];
|
||||
unavailable: { card: string; reason: string }[];
|
||||
} {
|
||||
const { state, receipt, held, normallyRevealed, leaders } = context;
|
||||
validateMoritaniAssassinate(state);
|
||||
requireAssassinate(
|
||||
identities(held) && identities(normallyRevealed) && Array.isArray(leaders),
|
||||
'Assassinate needs distinct held/revealed traitors and the native leader pool.',
|
||||
);
|
||||
const current = state.opportunities.find((r) => r.event === receipt?.event);
|
||||
requireAssassinate(
|
||||
current &&
|
||||
exact(receipt, Object.keys(current)) &&
|
||||
moritaniAssassinateSignature(current) ===
|
||||
moritaniAssassinateSignature(receipt) &&
|
||||
receipt.signature === current.signature,
|
||||
'Choose the original saved Assassinate opportunity.',
|
||||
);
|
||||
const unavailable: { card: string; reason: string }[] = [];
|
||||
const stopped = (blocked: string) => ({ blocked, cards: [], unavailable });
|
||||
if (receipt.stage !== 'choice')
|
||||
return stopped('This Assassinate opportunity has already ended.');
|
||||
if (state.normalTraitorCall)
|
||||
return stopped(
|
||||
'Assassinate after an ordinary Moritani traitor call awaits its duration ruling.',
|
||||
);
|
||||
if (
|
||||
state.opportunities.some(
|
||||
(r) =>
|
||||
r.faction === receipt.faction &&
|
||||
(r.stage === 'revealed' || r.stage === 'replaced'),
|
||||
)
|
||||
)
|
||||
return stopped('Assassinate has already been used against this faction.');
|
||||
const reserved = new Set(
|
||||
state.opportunities
|
||||
.filter((r) => r.stage === 'revealed' || r.stage === 'replaced')
|
||||
.map((r) => r.card!),
|
||||
);
|
||||
const cards: MoritaniAssassinateChoice[] = [];
|
||||
for (const card of held) {
|
||||
const native = printed(receipt.faction, card);
|
||||
if (!native || card === receipt.opposingLeader) continue;
|
||||
let reason: string | null = null;
|
||||
if (normallyRevealed.includes(card))
|
||||
reason =
|
||||
'Using an already normally revealed Traitor Card for Assassinate remains pending.';
|
||||
else if (reserved.has(card))
|
||||
reason =
|
||||
'That Traitor Card is already reserved or removed by Assassinate.';
|
||||
const matches = leaders.filter((l) => l.id === card),
|
||||
leader = matches[0];
|
||||
if (
|
||||
!reason &&
|
||||
(matches.length !== 1 ||
|
||||
!leader ||
|
||||
leader.name !== native.name ||
|
||||
leader.strength !== native.strength ||
|
||||
leader.faction !== receipt.faction ||
|
||||
typeof leader.dead !== 'boolean' ||
|
||||
leader.capturedBy ||
|
||||
leader.gholaBy ||
|
||||
leader.concealed ||
|
||||
(leader.controller !== undefined &&
|
||||
leader.controller !== receipt.opponent))
|
||||
)
|
||||
reason =
|
||||
'Assassinate with missing, altered, captured or foreign-ghola target custody remains pending.';
|
||||
if (reason) {
|
||||
unavailable.push({ card, reason });
|
||||
continue;
|
||||
}
|
||||
cards.push({
|
||||
card,
|
||||
name: native.name,
|
||||
bounty: leader.dead ? 0 : native.strength,
|
||||
dead: leader.dead,
|
||||
});
|
||||
}
|
||||
return {
|
||||
blocked: cards.length
|
||||
? null
|
||||
: (unavailable[0]?.reason ??
|
||||
'No different unrevealed native Traitor Card of this opposing faction is held.'),
|
||||
cards,
|
||||
unavailable,
|
||||
};
|
||||
}
|
||||
export function quoteMoritaniAssassinate(
|
||||
context: MoritaniAssassinateContext,
|
||||
card: string,
|
||||
): {
|
||||
receipt: MoritaniAssassinateReceipt;
|
||||
leader: string;
|
||||
bounty: number;
|
||||
kill: boolean;
|
||||
} {
|
||||
const choices = moritaniAssassinateChoices(context),
|
||||
choice = choices.cards.find((c) => c.card === card);
|
||||
requireAssassinate(
|
||||
!choices.blocked && choice,
|
||||
choices.blocked ?? 'Choose one eligible held Traitor Card for Assassinate.',
|
||||
);
|
||||
const receipt: MoritaniAssassinateReceipt = {
|
||||
...context.receipt,
|
||||
stage: 'revealed',
|
||||
card: choice.card,
|
||||
bounty: choice.bounty,
|
||||
replacement: null,
|
||||
signature: '',
|
||||
};
|
||||
receipt.signature = moritaniAssassinateSignature(receipt);
|
||||
return {
|
||||
receipt,
|
||||
leader: choice.card,
|
||||
bounty: choice.bounty,
|
||||
kill: !choice.dead,
|
||||
};
|
||||
}
|
||||
+12
-4
@@ -2558,17 +2558,25 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
id: 'moritani-assassinate-leaders',
|
||||
title: 'Moritani: advanced Assassinate Leaders',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Planned',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary:
|
||||
'A battle-loss advantage using a different Traitor Card, separate from Terror Assassination.',
|
||||
'An opted-in Advanced Moritani preview reveals a different opposing-faction Traitor Card after a loss and replaces it during Mentat Pause.',
|
||||
steps: [
|
||||
'After you lose a battle, this advanced advantage is available only if the opposing leader disc survived and no Traitor was called.',
|
||||
'You may reveal a Traitor Card for that opposing faction, but it must name a different leader from the one you just fought. If that named leader is not in the Tanks, kill it and collect spice for its value.',
|
||||
'During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
|
||||
'You may instead reveal a traitor normally in battle, but doing so loses this advanced advantage. The cancellation table gives Karama no effect against Assassinate Leaders.',
|
||||
'An already-dead named leader remains a legal reveal, awarding no death or spice. During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
|
||||
'The printed rule says a normal traitor reveal loses this advantage, but its duration remains unresolved. The preview guards further assassination after a normal call. Karama has no effect against Assassinate Leaders.',
|
||||
'If Harkonnen has captured your own leader and you hold its Traitor Card, you may call that traitor normally; you may not use Assassinate Leaders on that basis.',
|
||||
'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Advanced starts and Moritani starts remain unavailable.',
|
||||
],
|
||||
checklist: [
|
||||
{area:'Implementation',status:'Partial',detail:'Explicit Advanced Moritani/base-opponent preview before any normal traitor call; native disc deaths, printed bounty, dead-target zero bounty and exact Mentat replacement.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
|
||||
{area:'Player controls',status:'Partial',detail:'Uniform private choice/decline, named target and bounty, public face-up markers and readable inspection; normal activation awaits private-step UX decision.',evidence:['components/moritani-assassinate.tsx','components/game-table.tsx']},
|
||||
{area:'AI',status:'Partial',detail:'All four profiles choose from the owner-only quote or decline; no hidden rival data or strength-calibration claim.',evidence:['game/bots.ts','tests/moritani-assassinate-engine.test.ts']},
|
||||
{area:'Documentation',status:'Partial',detail:'Official authority, dead-target conditional, normal-call ambiguity, exceptional custody and optional-module boundaries remain explicit.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md']},
|
||||
{area:'Verification',status:'Partial',detail:'Rules, controls, genuine-setup staged battles, private views, authenticated recovery, concurrent writes and physical replacement chains; full faction acceptance remains open.',evidence:['tests/moritani-assassinate-quote.test.ts','tests/moritani-assassinate-engine.test.ts','tests/moritani-assassinate-controls.test.tsx','tests/moritani-assassinate-recovery.test.ts']},
|
||||
],
|
||||
related: [
|
||||
'faction-moritani',
|
||||
'moritani-terror-effects',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
||||
const DECISIONS = {
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
moritaniAssassinate: true,
|
||||
mentatQuestion: true,
|
||||
bureaucratPayment: true,
|
||||
sukRescue: true,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
||||
},
|
||||
});
|
||||
const { MoritaniAssassinate, MoritaniAssassinateHistory } = await import('../components/moritani-assassinate');
|
||||
|
||||
function view(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
me: 'm',
|
||||
players: [
|
||||
{ id: 'm', name: 'Moritani', faction: 'moritani' },
|
||||
{ id: 'o', name: 'House Opponent', faction: 'atreides' },
|
||||
],
|
||||
response: null,
|
||||
truthtrance: null,
|
||||
allLeaders: [
|
||||
{ id: 'atreides-leader-1', name: 'Duke Leto', faction: 'atreides', strength: 5 },
|
||||
{ id: 'atreides-leader-2', name: 'Thufir Hawat', faction: 'atreides', strength: 3 },
|
||||
{ id: 'traitor-1', name: 'Duke Leto', faction: 'atreides', strength: 5 },
|
||||
{ id: 'traitor-2', name: 'Thufir Hawat', faction: 'atreides', strength: 4 },
|
||||
],
|
||||
decision: { kind: 'moritaniAssassinate', player: 'm', event: 'assassinate-1' },
|
||||
moritaniAssassinate: {
|
||||
owner: 'm',
|
||||
blocked: null,
|
||||
pending: {
|
||||
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
|
||||
cards: [
|
||||
{ card: 'traitor-1', name: 'Duke Leto', bounty: 5, dead: false },
|
||||
{ card: 'traitor-2', name: 'Thufir Hawat', bounty: 0, dead: true },
|
||||
], blocked: null,
|
||||
},
|
||||
history: [],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(MoritaniAssassinate, { game, act() {}, busy }));
|
||||
}
|
||||
|
||||
void test('owner sees public traitor choices, bounty, dead-target legality and rules', () => {
|
||||
const html = markup(view());
|
||||
assert.match(html, /Traitor Card to reveal/);
|
||||
assert.match(html, /Duke Leto/);
|
||||
assert.match(html, /5 spice bounty/);
|
||||
assert.match(html, /Thufir Hawat · dead target · 0 spice/);
|
||||
assert.match(html, /replaced automatically during Mentat Pause/);
|
||||
assert.match(html, /cannot be canceled with Karama/);
|
||||
const dead = markup(view({ moritaniAssassinate: {
|
||||
owner: 'm', blocked: null, pending: {
|
||||
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
|
||||
cards: [{ card: 'traitor-2', name: 'Thufir Hawat', bounty: 0, dead: true }], blocked: null,
|
||||
}, history: [],
|
||||
} }));
|
||||
assert.match(dead, /0 spice; target already dead/);
|
||||
assert.match(dead, /Printed leader strength: 4/);
|
||||
});
|
||||
|
||||
void test('nonowner, stale, blocked, response and Truthtrance contexts stay guarded', () => {
|
||||
assert.equal(markup(view({ me: 'o' })), '');
|
||||
assert.equal(markup(view({ decision: { kind: 'moritaniAssassinate', player: 'm', event: 'old' } })), '');
|
||||
const blocked = markup(view({ moritaniAssassinate: {
|
||||
owner: 'm', blocked: 'No eligible leader remains.', pending: {
|
||||
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen', cards: [], blocked: 'No eligible leader remains.',
|
||||
}, history: [],
|
||||
} }));
|
||||
assert.match(blocked, /No eligible leader remains/);
|
||||
assert.match(blocked, />Continue</);
|
||||
assert.match(markup(view({ response: { kind: 'test' } })), /disabled=""/);
|
||||
assert.match(markup(view({ truthtrance: { stage: 'answer' } })), /disabled=""/);
|
||||
});
|
||||
|
||||
void test('public history shows face-up traitor markers and read-only inspection for every seat', () => {
|
||||
const html = renderToStaticMarkup(createElement(MoritaniAssassinateHistory, {
|
||||
game: view({
|
||||
moritaniAssassinate: {
|
||||
owner: 'm', blocked: null, pending: null,
|
||||
history: [
|
||||
{ event: 'old-1', turn: 2, opponent: 'o', faction: 'atreides', territory: 'arrakeen', card: 'atreides-leader-1', bounty: 5, stage: 'revealed' },
|
||||
{ event: 'old-2', turn: 3, opponent: 'o', faction: 'atreides', territory: 'carthag', card: 'atreides-leader-2', bounty: 3, stage: 'replaced' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
assert.match(html, /Assassinate Leaders revealed cards/);
|
||||
assert.match(html, /Revealed · awaiting replacement/);
|
||||
assert.match(html, /Set aside face up · replaced/);
|
||||
assert.match(html, /Arrakeen/);
|
||||
assert.match(html, /Inspect traitor/);
|
||||
assert.doesNotMatch(html, /Decline Assassinate/);
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -0,0 +1,169 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction,viewGame,initializeMoritaniAssassinateGameForAudit,createGame,newPlayer,joinGame,type Game} from '../game/engine';
|
||||
import {DIFFICULTIES} from '../game/bot-profiles';
|
||||
import {moritaniAssassinateSignature} from '../game/moritani-assassinate';
|
||||
import {assassinationGame,stageAssassinationBattle,prepareAssassinationBattle,resolveAssassinationBattle,assassinationChoice,assassinationToMentat,assassinationPhysical,assassinationRejects,assassinationActions} from './moritani-assassinate-fixture';
|
||||
|
||||
void test('a real lost battle reveals one different opposing traitor, kills its native disc once and replaces the physical card at Mentat',()=>{
|
||||
const staged=stageAssassinationBattle(assassinationGame()),inventory=assassinationPhysical(staged);
|
||||
const pending=resolveAssassinationBattle(staged),r=pending.moritaniAssassinate!.opportunities[0];
|
||||
assert.equal(r.stage,'choice');assert.equal(r.card,null);assert.equal(pending.phase,6);
|
||||
assert.deepEqual(assassinationPhysical(pending),inventory);
|
||||
assert.equal(viewGame(pending,'g').moritaniAssassinate!.pending!.cards.length,0);
|
||||
assert.equal(viewGame(pending,'m').moritaniAssassinate!.pending!.cards[0].card,'guild-1');
|
||||
const before=pending.players[0].spice,dead=pending.players[1].leaders.find(l=>l.id==='guild-1')!;
|
||||
const done=assassinationChoice(pending,'guild-1');
|
||||
assert.equal(done.players[0].spice,before+dead.strength);
|
||||
assert.equal(done.players[1].leaders.find(l=>l.id===dead.id)!.deaths,dead.deaths+1);
|
||||
assert.equal(done.players[1].leaders.find(l=>l.id==='guild-0')!.dead,false);
|
||||
assert.equal(done.moritaniAssassinate!.opportunities[0].stage,'revealed');
|
||||
assert.ok(done.players[0].traitors.includes('guild-1'));assert.equal(done.response,null);
|
||||
for(const p of done.players)assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)),p.id),viewGame(done,p.id));
|
||||
const next=assassinationToMentat(done),settled=next.moritaniAssassinate!.opportunities[0];
|
||||
assert.equal(settled.stage,'replaced');assert.ok(settled.replacement);
|
||||
assert.equal(next.players[0].traitors.includes('guild-1'),false);
|
||||
assert.ok(next.players[0].traitors.includes(settled.replacement));
|
||||
assert.deepEqual(assassinationPhysical(next),inventory);
|
||||
const rival=viewGame(next,'g');assert.equal('replacement' in rival.moritaniAssassinate!.history[0],false);
|
||||
assert.equal('moritaniAssassinatePreview' in rival,false);assert.equal('moritaniAssassinateCallEvents' in rival,false);
|
||||
});
|
||||
|
||||
void test('an already dead target remains a legal assassination reveal with no bounty or repeated death',()=>{
|
||||
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(),'guild-1',true));
|
||||
const before=pending.players[0].spice,dead=structuredClone(pending.players[1].leaders[1]);
|
||||
assert.equal(viewGame(pending,'m').moritaniAssassinate!.pending!.cards[0].bounty,0);
|
||||
const done=assassinationChoice(pending,'guild-1');
|
||||
assert.equal(done.players[0].spice,before);assert.deepEqual(done.players[1].leaders[1],dead);
|
||||
const next=assassinationToMentat(done);assert.equal(next.moritaniAssassinate!.opportunities[0].stage,'replaced');
|
||||
assert.equal(next.moritaniAssassinate!.opportunities[0].bounty,0);
|
||||
});
|
||||
|
||||
void test('eligible and empty private traitor hands create indistinguishable public loss decisions and explicit decline preserves the card',()=>{
|
||||
const a=stageAssassinationBattle(assassinationGame());
|
||||
const b=stageAssassinationBattle(a,a.traitorReserve!.find(id=>id.startsWith('moritani-'))!);
|
||||
const l=resolveAssassinationBattle(a),r=resolveAssassinationBattle(b);
|
||||
// Runtime UUIDs are independent; erase only public random event identifiers.
|
||||
const normalize=(g:Game)=>{
|
||||
const view=viewGame(g,'g');
|
||||
const event=g.moritaniAssassinate!.opportunities[0].event;
|
||||
return JSON.parse(JSON.stringify(view).replaceAll(event,'battle-event'));
|
||||
};
|
||||
assert.deepEqual(normalize(l),normalize(r));
|
||||
assert.equal(viewGame(r,'m').moritaniAssassinate!.pending!.cards.length,0);
|
||||
const before=assassinationPhysical(l),held=[...l.players[0].traitors];
|
||||
const done=assassinationChoice(l,null);assert.deepEqual(done.players[0].traitors,held);
|
||||
assert.equal(done.moritaniAssassinate!.opportunities[0].stage,'declined');
|
||||
assert.deepEqual(assassinationPhysical(done),before);
|
||||
assert.equal(viewGame(done,'g').moritaniAssassinate!.history.length,0);
|
||||
});
|
||||
|
||||
void test('all four profiles choose from the owner-only quote and decline when no eligible card exists',()=>{
|
||||
for(const identity of ['guild-1','moritani-0']) {
|
||||
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(),identity));
|
||||
for(const profile of DIFFICULTIES) {
|
||||
const actions=assassinationActions(pending,'m',profile);assert.equal(actions.length,1);
|
||||
const next=applyAction(pending,'m',actions[0]);
|
||||
assert.equal(next.moritaniAssassinate!.opportunities[0].stage,identity==='guild-1'?'revealed':'declined');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale, other-seat, same-leader, dishonest mixed and duplicate assassination actions reject without changing their input',()=>{
|
||||
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame()));
|
||||
const event=pending.decision!.kind==='moritaniAssassinate'?pending.decision!.event:'';
|
||||
for(const [id,action] of [
|
||||
['g',{type:'decision',event,card:'guild-1'}],
|
||||
['m',{type:'decision',event:'stale',card:'guild-1'}],
|
||||
['m',{type:'decision',event,card:'guild-0'}],
|
||||
['m',{type:'decision',event,card:'guild-1',decline:true}],
|
||||
['m',{type:'decision',event,card:'moritani-0'}],
|
||||
] as const)assassinationRejects(pending,id,action);
|
||||
const done=assassinationChoice(pending,'guild-1');
|
||||
assassinationRejects(done,'m',{type:'decision',event,card:'guild-1'});
|
||||
const mentat=assassinationToMentat(done);
|
||||
assassinationRejects(mentat,'m',{type:'decision',event,card:'guild-1'});
|
||||
});
|
||||
|
||||
void test('a normal Moritani traitor call records an explicit unresolved guard and cannot create an assassination opportunity',()=>{
|
||||
const staged=stageAssassinationBattle(assassinationGame(),'guild-0');
|
||||
const done=resolveAssassinationBattle(staged,{normalCall:true});
|
||||
assert.equal(done.moritaniAssassinate!.normalTraitorCall,true);
|
||||
assert.equal(done.moritaniAssassinateCallEvents!.length,1);
|
||||
assert.equal(done.moritaniAssassinate!.opportunities.length,0);
|
||||
assert.match(viewGame(done,'m').moritaniAssassinate!.blocked!,/clarification/);
|
||||
const corrupt=structuredClone(done);corrupt.moritaniAssassinate!.normalTraitorCall=false;
|
||||
assert.throws(()=>viewGame(corrupt,'m'),/original event/);
|
||||
});
|
||||
|
||||
void test('saved assassination receipts and cleanup obligations fail closed after corruption',()=>{
|
||||
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame()));
|
||||
for(const breakSave of [
|
||||
(g:Game)=>{g.moritaniAssassinate!.opportunities=[];},
|
||||
(g:Game)=>{g.moritaniAssassinate!.opportunities[0].opponent='m';},
|
||||
(g:Game)=>{delete g.moritaniAssassinateResume;},
|
||||
(g:Game)=>{g.moritaniAssassinateResume!.continuation.cards=['treachery-1'];},
|
||||
(g:Game)=>{g.moritaniAssassinateResume!.continuation.territory='carthag';g.moritaniAssassinateResume!.signature=JSON.stringify(g.moritaniAssassinateResume!.continuation);},
|
||||
(g:Game)=>{g.decision=null;},
|
||||
]) {const bad=structuredClone(pending);breakSave(bad);assert.throws(()=>viewGame(bad,'m'));}
|
||||
const settled=assassinationToMentat(assassinationChoice(pending,'guild-1'));
|
||||
const missing=structuredClone(settled);missing.moritaniAssassinate!.opportunities=[];
|
||||
assert.throws(()=>viewGame(missing,'m'));
|
||||
const wrongDraw=structuredClone(settled),receipt=wrongDraw.moritaniAssassinate!.opportunities[0];
|
||||
receipt.replacement=wrongDraw.traitorReserve![0];
|
||||
receipt.signature=moritaniAssassinateSignature(receipt);
|
||||
wrongDraw.lastBattleContext!.moritaniAssassinate!.signature=receipt.signature;
|
||||
assert.throws(()=>viewGame(wrongDraw,'m'),/replacement must stay/);
|
||||
});
|
||||
|
||||
void test('the explicit preview refuses Basic and exceptional-faction lobbies without mutation',()=>{
|
||||
for(const [advanced,opponent] of [[false,'guild'],[true,'harkonnen'],[true,'ecaz']] as const){
|
||||
const g=createGame('ASSAINIT',newPlayer('m','Moritani','moritani'),advanced,['ecaz']);
|
||||
joinGame(g,newPlayer('x','Opponent',opponent));for(const p of g.players)p.ready=true;
|
||||
const before=structuredClone(g);assert.throws(()=>initializeMoritaniAssassinateGameForAudit(g));assert.deepEqual(g,before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a sealed normal traitor call does not publish the assassination guard before the other voter replies',()=>{
|
||||
const prepared=prepareAssassinationBattle(stageAssassinationBattle(assassinationGame(),'guild-0'));
|
||||
const called=applyAction(prepared,'m',{type:'traitorCall',call:true});
|
||||
const declined=applyAction(prepared,'m',{type:'traitorCall',call:false});
|
||||
assert.deepEqual(viewGame(called,'g'),viewGame(declined,'g'));
|
||||
assert.equal(called.moritaniAssassinate!.normalTraitorCall,true);
|
||||
});
|
||||
|
||||
function nextAssassinationDraw(g:Game,identity:string) {
|
||||
const i=g.traitorReserve!.indexOf(identity);
|
||||
if(i>=0){const [card]=g.traitorReserve!.splice(i,1);g.traitorReserve!.unshift(card);return;}
|
||||
const holder=g.players.find(p=>p.traitors.includes(identity))!;assert.ok(holder&&holder.id!==g.players[0].id);
|
||||
const old=g.traitorReserve!.shift()!;holder.traitors[holder.traitors.indexOf(identity)]=old;g.traitorReserve!.unshift(identity);
|
||||
}
|
||||
void test('the per-faction marker prevents a later use even when the replacement is another matching faction card',()=>{
|
||||
const staged=stageAssassinationBattle(assassinationGame());nextAssassinationDraw(staged,'guild-2');
|
||||
const first=assassinationToMentat(assassinationChoice(resolveAssassinationBattle(staged),'guild-1'));
|
||||
assert.deepEqual(first.players[0].traitors,['guild-2']);first.turn++;
|
||||
const second=resolveAssassinationBattle(stageAssassinationBattle(first,'guild-2'));
|
||||
assert.notEqual(second.decision?.kind,'moritaniAssassinate');
|
||||
assert.equal(second.moritaniAssassinate!.opportunities.length,1);
|
||||
});
|
||||
void test('a private replacement may fund a later assassination against another faction without breaking historical physical custody',()=>{
|
||||
const staged=stageAssassinationBattle(assassinationGame(['m','g','e']));nextAssassinationDraw(staged,'emperor-1');
|
||||
const inventory=assassinationPhysical(staged);
|
||||
const first=assassinationToMentat(assassinationChoice(resolveAssassinationBattle(staged),'guild-1'));
|
||||
assert.deepEqual(first.players[0].traitors,['emperor-1']);first.turn++;
|
||||
first.players=[first.players[0],first.players[2],first.players[1]];
|
||||
const second=resolveAssassinationBattle(stageAssassinationBattle(first,'emperor-1'));
|
||||
const revealed=assassinationChoice(second,'emperor-1');
|
||||
const sameTurn=structuredClone(revealed),earlier=sameTurn.moritaniAssassinate!.opportunities[0];
|
||||
earlier.turn=sameTurn.turn;earlier.signature=moritaniAssassinateSignature(earlier);
|
||||
assert.throws(()=>viewGame(sameTurn,'m'),/replacement must stay/);
|
||||
const final=assassinationToMentat(revealed);
|
||||
assert.deepEqual(final.moritaniAssassinate!.opportunities.map(r=>r.stage),['replaced','replaced']);
|
||||
const physical=assassinationPhysical(final);physical.forces.sort((a,b)=>a.id.localeCompare(b.id));inventory.forces.sort((a,b)=>a.id.localeCompare(b.id));
|
||||
assert.deepEqual(physical,inventory);
|
||||
for(const p of final.players)assert.deepEqual(viewGame(JSON.parse(JSON.stringify(final)),p.id),viewGame(final,p.id));
|
||||
const bad=structuredClone(final),later=bad.moritaniAssassinate!.opportunities[1];
|
||||
later.turn=bad.moritaniAssassinate!.opportunities[0].turn;later.signature=moritaniAssassinateSignature(later);
|
||||
bad.lastBattleContext!.moritaniAssassinate!.signature=later.signature;
|
||||
assert.throws(()=>viewGame(bad,'m'),/replacement must stay/);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeMoritaniAssassinateGameForAudit, joinGame, newPlayer, viewGame, type Game, type Action } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
|
||||
export function assassinationActions(g:Game,id:string,difficulty:Difficulty='Medium') {
|
||||
const view=viewGame(g,id);view.players.find(p=>p.id===id)!.bot=difficulty;
|
||||
return botActions(view);
|
||||
}
|
||||
/** Genuine Advanced Moritani/Guild setup. Subsequent helpers explicitly stage
|
||||
* a conserved battle and physical traitor swap; they do not certify natural use. */
|
||||
export function assassinationGame(ids=['m','g']) {
|
||||
let g=createGame('ASSASSIN',newPlayer(ids[0],'Moritani','moritani'),true,['ecaz']);
|
||||
joinGame(g,newPlayer(ids[1],'Guild','guild'));
|
||||
if(ids[2])joinGame(g,newPlayer(ids[2],'Emperor','emperor'));
|
||||
for(const p of g.players)p.ready=true;
|
||||
g=initializeMoritaniAssassinateGameForAudit(g);
|
||||
for(let n=0;g.status==='setup'&&n<50;n++) {
|
||||
const next=g.players.flatMap(p=>assassinationActions(g,p.id).map(action=>({id:p.id,action})))[0];
|
||||
assert.ok(next,'The real Moritani setup must offer a legal choice');
|
||||
g=applyAction(g,next.id,next.action);
|
||||
}
|
||||
assert.equal(g.status,'playing');assert.equal(g.phase,0);
|
||||
return g;
|
||||
}
|
||||
export function giveAssassinationTraitor(g:Game,identity:string) {
|
||||
const owner=g.players[0],old=owner.traitors[0];
|
||||
assert.ok(old);
|
||||
if(old===identity)return;
|
||||
const other=g.players.find(p=>p.id!==owner.id&&p.traitors.includes(identity));
|
||||
if(other)other.traitors[other.traitors.indexOf(identity)]=old;
|
||||
else {
|
||||
const i=g.traitorReserve!.indexOf(identity);assert.ok(i>=0,'Fixture swap must find the physical traitor');
|
||||
g.traitorReserve![i]=old;
|
||||
}
|
||||
owner.traitors[0]=identity;
|
||||
}
|
||||
export function stageAssassinationBattle(state:Game,identity='guild-1',dead=false) {
|
||||
const g=structuredClone(state);
|
||||
giveAssassinationTraitor(g,identity);
|
||||
g.phase=6;g.ready=[];g.order=[g.players[1].id,g.players[0].id,...g.players.slice(2).map(p=>p.id)];g.active=g.players[1].id;g.storm=18;g.battle=null;g.decision=null;g.response=null;
|
||||
for(const [index,p] of g.players.entries()){
|
||||
p.reserves+=Object.values(p.forces).reduce((a,b)=>a+b,0);p.forces={};
|
||||
if(p.elites){p.elites.reserves+=Object.values(p.elites.forces).reduce((a,b)=>a+b,0);p.elites.forces={};}
|
||||
if(index<2){p.reserves-=3;p.forces['arrakeen:10']=3;}
|
||||
}
|
||||
const target=g.players[1].leaders.find(l=>l.id===identity);
|
||||
if(target&&dead){target.dead=true;target.deaths=1;delete target.usedAt;}
|
||||
return g;
|
||||
}
|
||||
export function prepareAssassinationBattle(state:Game,options:{winnerWeapon?:string}={}) {
|
||||
let g=applyAction(state,state.players[1].id,{type:'chooseBattle',territory:'arrakeen',target:state.players[0].id});
|
||||
for(let n=0;g.response&&n<15;n++) {
|
||||
const candidate=g.players.flatMap(p=>assassinationActions(g,p.id).map(action=>({id:p.id,action}))).find(c=>c.action.type==='passResponse');
|
||||
assert.ok(candidate);g=applyAction(g,candidate.id,candidate.action);
|
||||
}
|
||||
assert.ok(g.battle);assert.equal(g.battle.preparation,undefined);
|
||||
const [m,w]=g.players;
|
||||
g=applyAction(g,w.id,{type:'battlePlan',leader:w.leaders[0].id,dial:0,support:0,...(options.winnerWeapon?{weapon:options.winnerWeapon}:{})});
|
||||
g=applyAction(g,m.id,{type:'battlePlan',leader:m.leaders[4].id,dial:0,support:0});
|
||||
return g;
|
||||
}
|
||||
export function resolveAssassinationBattle(state:Game,options:{normalCall?:boolean;winnerWeapon?:string}={}) {
|
||||
let g=prepareAssassinationBattle(state,options);
|
||||
const [m,w]=g.players;
|
||||
for(const id of [w.id,m.id])g=applyAction(g,id,{type:'traitorCall',call:id===m.id&&!!options.normalCall});
|
||||
return g;
|
||||
}
|
||||
export function assassinationChoice(g:Game,card:string|null) {
|
||||
assert.equal(g.decision?.kind,'moritaniAssassinate');
|
||||
const d=g.decision as Extract<NonNullable<Game['decision']>,{kind:'moritaniAssassinate'}>;
|
||||
return applyAction(g,d.player,{type:'decision',event:d.event,...(card===null?{decline:true}:{card})});
|
||||
}
|
||||
export function assassinationToMentat(state:Game) {
|
||||
let g=state;
|
||||
for(let n=0;g.phase!==8&&n<80;n++) {
|
||||
const choices=g.players.flatMap(p=>assassinationActions(g,p.id).map(action=>({id:p.id,action})));
|
||||
const next=choices.find(c=>c.action.type==='ready')??choices[0];
|
||||
assert.ok(next,`Continuation stalled in ${g.phase}/${g.decision?.kind}`);
|
||||
g=applyAction(g,next.id,next.action);
|
||||
}
|
||||
assert.equal(g.phase,8);return g;
|
||||
}
|
||||
export function assassinationPhysical(g:Game) {
|
||||
return {
|
||||
cards:[...g.deck,...g.discard,...g.players.flatMap(p=>p.hand)].map(c=>c.id).sort(),
|
||||
traitors:[...(g.traitorReserve??[]),...g.players.flatMap(p=>p.traitors),...(g.moritaniAssassinate?.opportunities.filter(r=>r.stage==='replaced').map(r=>r.card!)??[])].sort(),
|
||||
forces:g.players.map(p=>({id:p.id,total:p.reserves+p.tanks+Object.values(p.forces).reduce((a,b)=>a+b,0),elite:(p.elites?.reserves??0)+(p.elites?.tanks??0)+Object.values(p.elites?.forces??{}).reduce((a,b)=>a+b,0)})),
|
||||
};
|
||||
}
|
||||
export function assassinationRejects(g:Game,id:string,action:Action) {
|
||||
const before=structuredClone(g);assert.throws(()=>applyAction(g,id,action));assert.deepEqual(g,before);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { leaders } from '../game/cards';
|
||||
import {
|
||||
createMoritaniAssassinateOpportunity,
|
||||
moritaniAssassinateChoices,
|
||||
moritaniAssassinateSignature,
|
||||
moritaniAssassinateTrigger,
|
||||
quoteMoritaniAssassinate,
|
||||
validateMoritaniAssassinate,
|
||||
type MoritaniAssassinateContext,
|
||||
type MoritaniAssassinateReceipt,
|
||||
type MoritaniAssassinateTrigger,
|
||||
} from '../game/moritani-assassinate';
|
||||
|
||||
function fixture(
|
||||
faction: MoritaniAssassinateTrigger['faction'] = 'atreides',
|
||||
event = 'battle:2:arrakeen',
|
||||
): MoritaniAssassinateContext {
|
||||
const input: MoritaniAssassinateTrigger = {
|
||||
advanced: true,
|
||||
owner: 'm',
|
||||
ownerFaction: 'moritani',
|
||||
opponent: 'a',
|
||||
faction,
|
||||
event,
|
||||
turn: 2,
|
||||
territory: 'arrakeen',
|
||||
opposingLeader: `${faction}-0`,
|
||||
opposingLeaderSurvived: true,
|
||||
normalBattle: true,
|
||||
lostBattle: true,
|
||||
traitorCalled: false,
|
||||
};
|
||||
const receipt = createMoritaniAssassinateOpportunity(input);
|
||||
return {
|
||||
state: {
|
||||
version: 1,
|
||||
owner: 'm',
|
||||
normalTraitorCall: false,
|
||||
opportunities: [receipt],
|
||||
},
|
||||
receipt,
|
||||
held: [
|
||||
`${faction}-0`,
|
||||
`${faction}-1`,
|
||||
`${faction}-2`,
|
||||
'moritani-0',
|
||||
'cheap-hero-traitor',
|
||||
],
|
||||
normallyRevealed: [],
|
||||
leaders: leaders(faction),
|
||||
};
|
||||
}
|
||||
function revised(
|
||||
receipt: MoritaniAssassinateReceipt,
|
||||
patch: Partial<MoritaniAssassinateReceipt>,
|
||||
) {
|
||||
const r = { ...receipt, ...patch };
|
||||
r.signature = moritaniAssassinateSignature(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
void test('public trigger requires a native Advanced Moritani normal loss, a surviving native opposing disc and no actual traitor call', () => {
|
||||
const input: MoritaniAssassinateTrigger = {
|
||||
advanced: true,
|
||||
owner: 'm',
|
||||
ownerFaction: 'moritani',
|
||||
opponent: 'a',
|
||||
faction: 'atreides',
|
||||
event: 'battle',
|
||||
turn: 2,
|
||||
territory: 'arrakeen',
|
||||
opposingLeader: 'atreides-0',
|
||||
opposingLeaderSurvived: true,
|
||||
normalBattle: true,
|
||||
lostBattle: true,
|
||||
traitorCalled: false,
|
||||
};
|
||||
assert.equal(moritaniAssassinateTrigger(input), true);
|
||||
for (const patch of [
|
||||
{ advanced: false },
|
||||
{ ownerFaction: 'atreides' },
|
||||
{ opponent: 'm' },
|
||||
{ lostBattle: false },
|
||||
{ normalBattle: false },
|
||||
{ opposingLeaderSurvived: false },
|
||||
{ traitorCalled: true },
|
||||
{ opposingLeader: null },
|
||||
{ opposingLeader: 'cheap-hero-traitor' },
|
||||
{ opposingLeader: 'emperor-0' },
|
||||
{ faction: 'harkonnen', opposingLeader: 'harkonnen-0' },
|
||||
{ faction: 'ecaz', opposingLeader: 'ecaz-0' },
|
||||
{ turn: 0 },
|
||||
{ turn: 1.5 },
|
||||
{ event: '' },
|
||||
]) {
|
||||
const invalid = { ...input, ...patch } as MoritaniAssassinateTrigger,
|
||||
before = structuredClone(invalid);
|
||||
assert.equal(moritaniAssassinateTrigger(invalid), false);
|
||||
assert.throws(
|
||||
() => createMoritaniAssassinateOpportunity(invalid),
|
||||
/Assassinate/,
|
||||
);
|
||||
assert.deepEqual(invalid, before);
|
||||
}
|
||||
for (const faction of [
|
||||
'atreides',
|
||||
'beneGesserit',
|
||||
'guild',
|
||||
'emperor',
|
||||
'fremen',
|
||||
] as const)
|
||||
assert.equal(
|
||||
moritaniAssassinateTrigger({
|
||||
...input,
|
||||
faction,
|
||||
opposingLeader: `${faction}-0`,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
void test('only a different held native Traitor Card is selectable, and live targets pay exactly their printed strength without mutating inputs', () => {
|
||||
for (const faction of [
|
||||
'atreides',
|
||||
'beneGesserit',
|
||||
'guild',
|
||||
'emperor',
|
||||
'fremen',
|
||||
] as const) {
|
||||
const context = fixture(faction),
|
||||
before = structuredClone(context),
|
||||
native = context.leaders[1];
|
||||
const choices = moritaniAssassinateChoices(context);
|
||||
assert.equal(choices.blocked, null);
|
||||
assert.deepEqual(choices.unavailable, []);
|
||||
assert.deepEqual(
|
||||
choices.cards.map((c) => c.card),
|
||||
[`${faction}-1`, `${faction}-2`],
|
||||
);
|
||||
const quote = quoteMoritaniAssassinate(context, native.id);
|
||||
assert.equal(quote.leader, native.id);
|
||||
assert.equal(quote.bounty, native.strength);
|
||||
assert.equal(quote.kill, true);
|
||||
assert.equal(quote.receipt.stage, 'revealed');
|
||||
assert.equal(quote.receipt.card, native.id);
|
||||
assert.equal(quote.receipt.replacement, null);
|
||||
validateMoritaniAssassinate(
|
||||
{ ...context.state, opportunities: [quote.receipt] },
|
||||
{ turn: 2, held: context.held, removed: [] },
|
||||
);
|
||||
assert.deepEqual(context, before);
|
||||
for (const card of [
|
||||
`${faction}-0`,
|
||||
'moritani-0',
|
||||
'cheap-hero-traitor',
|
||||
`${faction}-4`,
|
||||
]) {
|
||||
assert.throws(() => quoteMoritaniAssassinate(context, card), /eligible/);
|
||||
assert.deepEqual(context, before);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('already-dead targets remain revealable for no death or spice, while prior use locations do not remove a live target', () => {
|
||||
const context = fixture();
|
||||
context.leaders[1].dead = true;
|
||||
context.leaders[1].deaths = 2;
|
||||
context.leaders[2].usedAt = 'carthag';
|
||||
const before = structuredClone(context);
|
||||
assert.deepEqual(moritaniAssassinateChoices(context).cards[0], {
|
||||
card: 'atreides-1',
|
||||
name: context.leaders[1].name,
|
||||
bounty: 0,
|
||||
dead: true,
|
||||
});
|
||||
const dead = quoteMoritaniAssassinate(context, 'atreides-1');
|
||||
assert.equal(dead.kill, false);
|
||||
assert.equal(dead.bounty, 0);
|
||||
assert.equal(dead.receipt.stage, 'revealed');
|
||||
assert.equal(quoteMoritaniAssassinate(context, 'atreides-2').kill, true);
|
||||
validateMoritaniAssassinate(
|
||||
{ ...context.state, opportunities: [dead.receipt] },
|
||||
{ turn: 2, held: context.held, removed: [] },
|
||||
);
|
||||
assert.deepEqual(context, before);
|
||||
});
|
||||
|
||||
void test('ordinary traitor-call ambiguity and already normally revealed cards stay explicit private guards', () => {
|
||||
const context = fixture();
|
||||
context.state.normalTraitorCall = true;
|
||||
assert.match(
|
||||
moritaniAssassinateChoices(context).blocked!,
|
||||
/ordinary.*duration ruling/,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteMoritaniAssassinate(context, 'atreides-1'),
|
||||
/duration ruling/,
|
||||
);
|
||||
context.state.normalTraitorCall = false;
|
||||
context.normallyRevealed = ['atreides-1'];
|
||||
const choices = moritaniAssassinateChoices(context);
|
||||
assert.deepEqual(
|
||||
choices.cards.map((c) => c.card),
|
||||
['atreides-2'],
|
||||
);
|
||||
assert.match(choices.unavailable[0].reason, /normally revealed/);
|
||||
assert.throws(
|
||||
() => quoteMoritaniAssassinate(context, 'atreides-1'),
|
||||
/eligible/,
|
||||
);
|
||||
context.normallyRevealed = ['atreides-1', 'atreides-2'];
|
||||
assert.match(
|
||||
moritaniAssassinateChoices(context).blocked!,
|
||||
/normally revealed/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('missing, duplicated, altered or exceptional target custody cannot select another physical leader or invent bounty', () => {
|
||||
for (const damage of [
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders = c.leaders.filter((l) => l.id !== 'atreides-1');
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders = [...c.leaders, c.leaders[1]];
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].strength = 99;
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].name = 'Wrong disc';
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].faction = 'guild';
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].capturedBy = 'other';
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].gholaBy = 'other';
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].controller = null;
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].concealed = { captor: 'other', dead: false, deaths: 0 };
|
||||
},
|
||||
]) {
|
||||
const context = fixture();
|
||||
damage(context);
|
||||
const before = structuredClone(context);
|
||||
const choices = moritaniAssassinateChoices(context);
|
||||
assert.ok(!choices.cards.some((c) => c.card === 'atreides-1'));
|
||||
assert.match(
|
||||
choices.unavailable.find((c) => c.card === 'atreides-1')!.reason,
|
||||
/custody/,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteMoritaniAssassinate(context, 'atreides-1'),
|
||||
/eligible/,
|
||||
);
|
||||
assert.deepEqual(context, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a revealed card remains held until one Mentat replacement, and the used opposing faction cannot be assassinated again', () => {
|
||||
const context = fixture(),
|
||||
revealed = quoteMoritaniAssassinate(context, 'atreides-1').receipt;
|
||||
const state = { ...context.state, opportunities: [revealed] };
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 2,
|
||||
held: context.held,
|
||||
removed: [],
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 2,
|
||||
held: context.held.filter((c) => c !== 'atreides-1'),
|
||||
removed: ['atreides-1'],
|
||||
}),
|
||||
/stay held/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 3,
|
||||
held: context.held,
|
||||
removed: [],
|
||||
}),
|
||||
/stay held/,
|
||||
);
|
||||
const replaced = revised(revealed, {
|
||||
stage: 'replaced',
|
||||
replacement: 'moritani-4',
|
||||
});
|
||||
state.opportunities = [replaced];
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 3,
|
||||
held: [...context.held.filter((c) => c !== 'atreides-1'), 'moritani-4'],
|
||||
removed: ['atreides-1'],
|
||||
});
|
||||
const next = fixture('atreides', 'battle:3:carthag');
|
||||
next.receipt.turn = 3;
|
||||
next.receipt.signature = moritaniAssassinateSignature(next.receipt);
|
||||
next.state.opportunities = [replaced, next.receipt];
|
||||
assert.match(moritaniAssassinateChoices(next).blocked!, /already been used/);
|
||||
assert.throws(
|
||||
() => quoteMoritaniAssassinate(next, 'atreides-2'),
|
||||
/already been used/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('declining keeps all cards and allows a later loss; distinct opposing factions may each use one card', () => {
|
||||
const context = fixture(),
|
||||
declined = revised(context.receipt, { stage: 'declined' });
|
||||
const retry = fixture('atreides', 'retry');
|
||||
retry.state.opportunities = [declined, retry.receipt];
|
||||
assert.equal(moritaniAssassinateChoices(retry).blocked, null);
|
||||
const first = quoteMoritaniAssassinate(retry, 'atreides-1').receipt;
|
||||
const other = fixture('guild', 'guild-battle');
|
||||
other.state.opportunities = [declined, first, other.receipt];
|
||||
const second = quoteMoritaniAssassinate(other, 'guild-1').receipt;
|
||||
validateMoritaniAssassinate(
|
||||
{ ...other.state, opportunities: [declined, first, second] },
|
||||
{ turn: 2, held: ['atreides-1', 'guild-1'], removed: [] },
|
||||
);
|
||||
assert.deepEqual(context.state.opportunities, [context.receipt]);
|
||||
});
|
||||
|
||||
void test('signed JSON state rejects changed events, invalid stage fields, duplicate faction use, invented bounty and reopened physical custody', () => {
|
||||
const context = fixture(),
|
||||
revealed = quoteMoritaniAssassinate(context, 'atreides-1').receipt;
|
||||
const state = { ...context.state, opportunities: [revealed] },
|
||||
before = structuredClone(state);
|
||||
validateMoritaniAssassinate(JSON.parse(JSON.stringify(state)), {
|
||||
turn: 2,
|
||||
held: context.held,
|
||||
removed: [],
|
||||
});
|
||||
for (const patch of [
|
||||
{ bounty: 999 },
|
||||
{ card: 'atreides-0' },
|
||||
{ card: 'guild-1' },
|
||||
{ replacement: 'guild-0' },
|
||||
{ stage: 'choice' },
|
||||
{ stage: 'replaced', replacement: 'unknown' },
|
||||
{ stage: 'replaced', replacement: 'atreides-1' },
|
||||
{ owner: 'x' },
|
||||
{ faction: 'harkonnen' },
|
||||
{ opposingLeader: 'cheap-hero-traitor' },
|
||||
] as Partial<MoritaniAssassinateReceipt>[]) {
|
||||
const damaged = { ...state, opportunities: [revised(revealed, patch)] };
|
||||
const unchanged = structuredClone(damaged);
|
||||
assert.throws(() => validateMoritaniAssassinate(damaged), /Assassinate/);
|
||||
assert.deepEqual(damaged, unchanged);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate({
|
||||
...state,
|
||||
opportunities: [revealed, structuredClone(revealed)],
|
||||
}),
|
||||
/duplicated/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate({
|
||||
...state,
|
||||
opportunities: [
|
||||
revealed,
|
||||
revised(revealed, { event: 'other', card: 'atreides-2', bounty: 4 }),
|
||||
],
|
||||
}),
|
||||
/once per opposing faction/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate({
|
||||
...state,
|
||||
opportunities: [{ ...revealed, event: 'changed' }],
|
||||
}),
|
||||
/changed/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 2,
|
||||
held: context.held,
|
||||
removed: ['atreides-1'],
|
||||
}),
|
||||
/overlapping/,
|
||||
);
|
||||
assert.deepEqual(state, before);
|
||||
const stale = fixture();
|
||||
stale.receipt = { ...stale.receipt, event: 'stale' };
|
||||
assert.throws(() => moritaniAssassinateChoices(stale), /original saved/);
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { moritaniAssassinateSignature } from '../game/moritani-assassinate';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
assassinationGame,
|
||||
stageAssassinationBattle,
|
||||
resolveAssassinationBattle,
|
||||
assassinationActions,
|
||||
assassinationPhysical,
|
||||
} from './moritani-assassinate-fixture';
|
||||
|
||||
const clock: RoomsClock = { now: () => 42000, sleep: async () => {} };
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const rows = (db: DatabaseSync) =>
|
||||
db.prepare('SELECT state,version FROM rooms ORDER BY code').all();
|
||||
|
||||
async function fixture(t: test.TestContext, dead = false, truth = false) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom(
|
||||
'Assassinate recovery',
|
||||
'moritani',
|
||||
true,
|
||||
['ecaz'],
|
||||
);
|
||||
const code = made.view.code,
|
||||
tokens = [
|
||||
made.token,
|
||||
(await store.rooms.joinRoom(code, 'Guild', 'guild')).token!,
|
||||
];
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
),
|
||||
ids = auths.map((a) => a.playerId);
|
||||
let g = stageAssassinationBattle(assassinationGame(ids), 'guild-1', dead);
|
||||
let truthCard: string | undefined;
|
||||
if (truth) {
|
||||
const card = [...g.deck, ...g.players.flatMap((p) => p.hand)].find(
|
||||
(c) => c.effect === 'truthtrance',
|
||||
)!;
|
||||
g.deck = g.deck.filter((c) => c.id !== card.id);
|
||||
for (const p of g.players) p.hand = p.hand.filter((c) => c.id !== card.id);
|
||||
g.players[0].hand.push(card);
|
||||
truthCard = card.id;
|
||||
}
|
||||
g = resolveAssassinationBattle(g);
|
||||
assert.equal(g.phase, 6);
|
||||
assert.equal(g.decision?.kind, 'moritaniAssassinate');
|
||||
g.code = code;
|
||||
g.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
store.writes.length = 0;
|
||||
const receipt = g.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
return {
|
||||
...store,
|
||||
code,
|
||||
tokens,
|
||||
auths,
|
||||
ids,
|
||||
g,
|
||||
truthCard,
|
||||
action: { type: 'decision', event: receipt.event, card: 'guild-1' },
|
||||
};
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
|
||||
async function restored(f: Fixture, expected: Game) {
|
||||
const before = rows(f.sqlite);
|
||||
for (const [index, token] of f.tokens.entries()) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, token),
|
||||
view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(expected, f.ids[index]));
|
||||
assert.equal('moritaniAssassinatePreview' in view, false);
|
||||
assert.equal('moritaniAssassinateResume' in view, false);
|
||||
for (const rival of view.players.filter((p) => p.id !== f.ids[index])) {
|
||||
assert.equal('traitors' in rival, false);
|
||||
assert.equal('hand' in rival, false);
|
||||
assert.equal('spice' in rival, false);
|
||||
}
|
||||
if (index === 1)
|
||||
assert.deepEqual(view.moritaniAssassinate?.pending?.cards ?? [], []);
|
||||
for (const record of view.moritaniAssassinate?.history ?? []) {
|
||||
assert.equal('signature' in record, false);
|
||||
assert.equal('replacement' in record, false);
|
||||
}
|
||||
}
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
}
|
||||
async function act(
|
||||
f: Fixture,
|
||||
index: number,
|
||||
action: Action,
|
||||
version?: number,
|
||||
) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, f.tokens[index]),
|
||||
g = await rooms.readRoom(f.code);
|
||||
await rooms.act(f.code, auth, version ?? g.version, action, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
async function actualMentat(f: Fixture, state: Game) {
|
||||
let g = state;
|
||||
for (let n = 0; g.phase !== 8 && n < 30; n++) {
|
||||
const candidates = g.players.flatMap((p, index) =>
|
||||
assassinationActions(g, p.id).map((action) => ({ index, action })),
|
||||
);
|
||||
const next =
|
||||
candidates.find((c) => c.action.type === 'ready') ?? candidates[0];
|
||||
assert.ok(
|
||||
next,
|
||||
`Actual saved continuation must progress from phase ${g.phase}`,
|
||||
);
|
||||
g = await act(f, next.index, next.action);
|
||||
}
|
||||
assert.equal(g.phase, 8);
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('saved live and already-dead Assassinate reveals settle once, keep the physical traitor until actual Mentat and draw one private replacement', async (t) => {
|
||||
for (const dead of [false, true]) {
|
||||
const f = await fixture(t, dead),
|
||||
initial = clone(f.g),
|
||||
physical = assassinationPhysical(initial);
|
||||
const target = initial.players[1].leaders.find((l) => l.id === 'guild-1')!,
|
||||
bounty = dead ? 0 : target.strength;
|
||||
const seats = f.sqlite
|
||||
.prepare('SELECT * FROM seats ORDER BY player_id')
|
||||
.all();
|
||||
await restored(f, initial);
|
||||
const before = rows(f.sqlite);
|
||||
await assert.rejects(act(f, 1, f.action));
|
||||
await assert.rejects(act(f, 0, { ...f.action, event: 'old' }));
|
||||
await assert.rejects(act(f, 0, { ...f.action, card: 'guild-0' }));
|
||||
await assert.rejects(
|
||||
act(f, 0, f.action, initial.version - 1),
|
||||
/table changed/,
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
const revealed = await act(f, 0, f.action, initial.version),
|
||||
receipt = revealed.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(revealed.version, initial.version + 1);
|
||||
assert.equal(revealed.phase, 7);
|
||||
assert.equal(revealed.decision, null);
|
||||
assert.equal(revealed.moritaniAssassinateResume, undefined);
|
||||
assert.equal(receipt.stage, 'revealed');
|
||||
assert.equal(receipt.bounty, bounty);
|
||||
assert.equal(receipt.replacement, null);
|
||||
assert.equal(revealed.players[0].spice, initial.players[0].spice + bounty);
|
||||
const afterTarget = revealed.players[1].leaders.find(
|
||||
(l) => l.id === target.id,
|
||||
)!;
|
||||
assert.equal(afterTarget.dead, true);
|
||||
assert.equal(afterTarget.deaths, target.deaths + Number(!dead));
|
||||
assert.deepEqual(
|
||||
revealed.players[1].leaders.filter((l) => l.id !== target.id),
|
||||
initial.players[1].leaders.filter((l) => l.id !== target.id),
|
||||
);
|
||||
assert.deepEqual(revealed.players[0].traitors, initial.players[0].traitors);
|
||||
assert.deepEqual(revealed.traitorReserve, initial.traitorReserve);
|
||||
assert.deepEqual(assassinationPhysical(revealed), physical);
|
||||
await restored(f, revealed);
|
||||
const settled = rows(f.sqlite),
|
||||
writes = f.writes.length;
|
||||
await assert.rejects(act(f, 0, f.action, initial.version), /table changed/);
|
||||
await assert.rejects(act(f, 0, f.action, revealed.version));
|
||||
assert.deepEqual(rows(f.sqlite), settled);
|
||||
assert.equal(f.writes.length, writes);
|
||||
const replacement = revealed.traitorReserve![0],
|
||||
mentat = await actualMentat(f, revealed),
|
||||
done = mentat.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(done.stage, 'replaced');
|
||||
assert.equal(done.replacement, replacement);
|
||||
assert.deepEqual(mentat.traitorReserve, revealed.traitorReserve!.slice(1));
|
||||
assert.deepEqual(mentat.players[0].traitors, [
|
||||
...revealed.players[0].traitors.filter((c) => c !== 'guild-1'),
|
||||
replacement,
|
||||
]);
|
||||
assert.equal(
|
||||
mentat.players.flatMap((p) => p.traitors).includes('guild-1'),
|
||||
false,
|
||||
);
|
||||
assert.equal(mentat.traitorReserve!.includes('guild-1'), false);
|
||||
assert.equal(
|
||||
mentat.moritaniAssassinate!.opportunities.filter(
|
||||
(r) => r.stage === 'replaced' && r.card === 'guild-1',
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(assassinationPhysical(mentat), physical);
|
||||
await restored(f, mentat);
|
||||
const saved = rows(f.sqlite);
|
||||
await assert.rejects(act(f, 0, f.action));
|
||||
assert.deepEqual(rows(f.sqlite), saved);
|
||||
assert.deepEqual(
|
||||
f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
|
||||
seats,
|
||||
);
|
||||
assert.ok(f.writes.every((w) => w.changes === 1));
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent identical Assassinate reveals commit only one death, bounty and delayed replacement', async (t) => {
|
||||
const f = await fixture(t),
|
||||
target = f.g.players[1].leaders.find((l) => l.id === 'guild-1')!;
|
||||
let arrivals = 0,
|
||||
release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const timer = setTimeout(release, 2000);
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled(
|
||||
[0, 1].map(() => act(f, 0, f.action, f.g.version)),
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
delete f.hooks.beforeWrite;
|
||||
}
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
const g = await f.restart().readRoom(f.code);
|
||||
assert.equal(g.version, f.g.version + 1);
|
||||
assert.equal(g.players[0].spice, f.g.players[0].spice + target.strength);
|
||||
assert.equal(
|
||||
g.players[1].leaders.find((l) => l.id === target.id)!.deaths,
|
||||
target.deaths + 1,
|
||||
);
|
||||
assert.equal(g.moritaniAssassinate!.opportunities.length, 1);
|
||||
const mentat = await actualMentat(f, g);
|
||||
assert.equal(mentat.traitorReserve!.length, f.g.traitorReserve!.length - 1);
|
||||
assert.equal(mentat.moritaniAssassinate!.opportunities[0].stage, 'replaced');
|
||||
assert.deepEqual(assassinationPhysical(mentat), assassinationPhysical(f.g));
|
||||
await restored(f, mentat);
|
||||
});
|
||||
|
||||
void test('a real Truthtrance interruption restores the same private Assassinate choice and original battle cleanup', async (t) => {
|
||||
const f = await fixture(t, false, true),
|
||||
resume = clone(f.g.moritaniAssassinateResume),
|
||||
physical = assassinationPhysical(f.g);
|
||||
let g = await act(f, 0, { type: 'card', card: f.truthCard });
|
||||
assert.equal(g.truthtrance?.stage, 'priority');
|
||||
await restored(f, g);
|
||||
while (g.truthtrance?.stage === 'priority') {
|
||||
const p = g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!;
|
||||
g = await act(f, f.ids.indexOf(p.id), { type: 'truthPass' });
|
||||
}
|
||||
g = await act(f, 0, {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: f.ids[1],
|
||||
fact: { kind: 'spice', compare: 'gte', value: 0 },
|
||||
},
|
||||
});
|
||||
await restored(f, g);
|
||||
assert.deepEqual(g.moritaniAssassinateResume, resume);
|
||||
const pending = rows(f.sqlite);
|
||||
await assert.rejects(act(f, 0, f.action));
|
||||
assert.deepEqual(rows(f.sqlite), pending);
|
||||
g = await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(g.truthtrance, null);
|
||||
assert.equal(g.decision?.kind, 'moritaniAssassinate');
|
||||
assert.deepEqual(g.moritaniAssassinateResume, resume);
|
||||
assert.equal(g.discard.filter((c) => c.id === f.truthCard).length, 1);
|
||||
assert.deepEqual(assassinationPhysical(g), physical);
|
||||
await restored(f, g);
|
||||
g = await act(f, 0, {
|
||||
type: 'decision',
|
||||
event: f.action.event,
|
||||
decline: true,
|
||||
});
|
||||
assert.equal(g.phase, 7);
|
||||
assert.equal(g.moritaniAssassinate!.opportunities[0].stage, 'declined');
|
||||
assert.deepEqual(g.players[0].traitors, f.g.players[0].traitors);
|
||||
assert.deepEqual(g.traitorReserve, f.g.traitorReserve);
|
||||
const mentat = await actualMentat(f, g);
|
||||
assert.deepEqual(mentat.traitorReserve, f.g.traitorReserve);
|
||||
assert.deepEqual(assassinationPhysical(mentat), physical);
|
||||
await restored(f, mentat);
|
||||
});
|
||||
|
||||
void test('damaged saved Assassinate cleanup, receipt, obligation or decision rejects reads and actions without SQLite repair', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const damage of [
|
||||
(g: Game) => {
|
||||
delete g.moritaniAssassinateResume;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.moritaniAssassinateResume!.signature = 'changed';
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.winner = f.ids[0];
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.territory = 'carthag';
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.combatants = [f.ids[0]];
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.cards = [g.deck[0].id];
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.casualties!.options[0].normal = 1;
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
g.moritaniAssassinate!.opportunities[0].card = 'guild-1';
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinate!.opportunities[0];
|
||||
r.opposingLeader = 'guild-2';
|
||||
r.signature = moritaniAssassinateSignature(r);
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.lastBattleContext!.moritaniAssassinate;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.lastBattleContext!.moritaniAssassinate!.continuation;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.moritaniAssassinateCallEvents;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.decision = null;
|
||||
},
|
||||
]) {
|
||||
const g = clone(f.g);
|
||||
damage(g);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
||||
.run(JSON.stringify(g), f.code);
|
||||
const before = rows(f.sqlite),
|
||||
writes = f.writes.length;
|
||||
for (const token of f.tokens) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, token);
|
||||
await assert.rejects(rooms.readSeatView(f.code, auth), /assassinat/i);
|
||||
}
|
||||
await assert.rejects(act(f, 0, f.action), /assassinat/i);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, writes);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a replacement receipt cannot point to a card still in reserve or conceal a transfer to the opponent even when the global traitor census is unchanged', async (t) => {
|
||||
const f = await fixture(t),
|
||||
revealed = await act(f, 0, f.action),
|
||||
mentat = await actualMentat(f, revealed);
|
||||
const physical = assassinationPhysical(mentat);
|
||||
for (const damage of [
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinate!.opportunities[0];
|
||||
r.replacement = g.traitorReserve![0];
|
||||
r.signature = moritaniAssassinateSignature(r);
|
||||
g.lastBattleContext!.moritaniAssassinate!.signature = r.signature;
|
||||
},
|
||||
(g: Game) => {
|
||||
const card = g.moritaniAssassinate!.opportunities[0].replacement!,
|
||||
index = g.players[0].traitors.indexOf(card);
|
||||
assert.ok(index >= 0);
|
||||
g.players[0].traitors[index] = g.players[1].traitors[0];
|
||||
g.players[1].traitors[0] = card;
|
||||
},
|
||||
]) {
|
||||
const g = clone(mentat);
|
||||
damage(g);
|
||||
assert.deepEqual(assassinationPhysical(g), physical);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
||||
.run(JSON.stringify(g), f.code);
|
||||
const before = rows(f.sqlite),
|
||||
writes = f.writes.length;
|
||||
for (const token of f.tokens) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, token);
|
||||
await assert.rejects(rooms.readSeatView(f.code, auth), /replacement/i);
|
||||
}
|
||||
await assert.rejects(act(f, 0, { type: 'ready' }), /replacement/i);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, writes);
|
||||
}
|
||||
});
|
||||
@@ -197,3 +197,22 @@ void test('Nexus prototype initializes all twelve hidden cards once and preserve
|
||||
other,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Moritani assassination preview starts only the fresh opted-in Advanced profile and preserves seats', (t) => {
|
||||
const {db}=fixture(t);
|
||||
const game=createGame('PROTOTYP',newPlayer('i','Moritani','moritani'),true,['ecaz']);
|
||||
joinGame(game,newPlayer('g','Guild','guild'));for(const p of game.players)p.ready=true;
|
||||
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game),game.code);
|
||||
const seats=db.prepare('SELECT * FROM seats').all(),other=db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
|
||||
startPrototypeRoom(db,game.code,7,'moritani-assassinate');
|
||||
const saved=JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
|
||||
assert.equal(saved.status,'setup');assert.equal(saved.version,8);assert.equal(saved.advanced,true);
|
||||
assert.equal(saved.moritaniAssassinatePreview,true);assert.equal(saved.moritaniAssassinate!.owner,'i');
|
||||
assert.deepEqual(saved.moritaniAssassinate!.opportunities,[]);assert.deepEqual(saved.moritaniAssassinateCallEvents,[]);
|
||||
for(const p of saved.players)assert.equal('moritaniAssassinatePreview' in viewGame(saved,p.id),false);
|
||||
const rows=db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(()=>startPrototypeRoom(db,game.code,7,'moritani-assassinate'));
|
||||
assert.throws(()=>startPrototypeRoom(db,game.code,8,'moritani-assassinate'));
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(),rows);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(),seats);assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'),other);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
initializeNexusGameForAudit,
|
||||
initializeMoritaniAssassinateGameForAudit,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
@@ -15,6 +16,7 @@ export const PROTOTYPE_PROFILES = [
|
||||
'leader-skills',
|
||||
'factions',
|
||||
'nexus',
|
||||
'moritani-assassinate',
|
||||
] as const;
|
||||
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
|
||||
export function isPrototypeProfile(value: string): value is PrototypeProfile {
|
||||
@@ -57,6 +59,8 @@ export function startPrototypeRoom(
|
||||
const game =
|
||||
profile === 'ix'
|
||||
? initializeIxGameForAudit(initial)
|
||||
: profile === 'moritani-assassinate'
|
||||
? initializeMoritaniAssassinateGameForAudit(initial)
|
||||
: profile === 'factions'
|
||||
? initializeFactionExpansionsGameForAudit(initial)
|
||||
: profile === 'nexus'
|
||||
|
||||
@@ -20,7 +20,7 @@ async function main() {
|
||||
});
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Factions supports selected expansion factions without optional modules; the separate Ecaz Treachery variant stays disabled. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Factions supports selected expansion factions without optional modules; the separate Ecaz Treachery variant stays disabled. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user