Connect Advanced Ecaz and Moritani special Karama first versions

This commit is contained in:
Quintine committed 2026-10-08 11:29:08 +11:00
1 parent 59a406830c
commit d438d923a3
17 files changed
+872 -3

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
'use client';
import { useId, useState } from 'react';
import type { Action } from '@/game/engine';
import type { EcazSpecialKaramaQuote } from '@/game/ecaz-special-karama';
import { Button } from './ui/button';
import { CardInspector } from './card-inspector';
/** The parent supplies this private quote only to its Ecaz owner. */
export function EcazSpecialKarama({
quote,
act,
busy,
}: {
quote: EcazSpecialKaramaQuote | null;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [selection, setSelection] = useState('');
if (!quote) return null;
const karama = quote.cards.find((card) => card.id === selection) ?? quote.cards[0];
const reason = quote.blocked ??
(!quote.event ? 'No current battle event is available.' :
!karama ? 'You need an available Karama card.' : null);
return (
<details className="my-4" open={quote.declared}>
<summary className="min-h-11 cursor-pointer py-3">
Ecaz special Karama: leader-disc difference
</summary>
<div className="flex flex-col gap-4 py-3">
<p>
Before Battle Plans are revealed, spend one Karama to declare your
once-per-game special power. Your Battle Plan must contain neither a
weapon nor a defense. You may declare before sealing your plan.
</p>
<p className="fine">
Provisional prototype policy, not a publisher ruling: after reveal,
the absolute difference between the printed or copied leader-disc
values adds virtual dial strength. Other leader modifiers are excluded.
This adds no physical forces, spice support payments or casualties.
The bonus is not quoted from your opponent’s hidden plan.
</p>
{quote.declared ? (
<output className="notice">
Declared for this battle. Keep both your weapon and defense slots
empty; the leader-disc bonus is determined after reveal.
</output>
) : (
<>
{quote.cards.length > 0 && (
<>
<label htmlFor={`${id}-card`}>Karama to spend</label>
<select
id={`${id}-card`}
className="min-h-11"
value={karama?.id ?? ''}
disabled={busy || !!reason}
onChange={(event) => setSelection(event.target.value)}
>
{quote.cards.map((card) => (
<option value={card.id} key={card.id}>
{card.name}
</option>
))}
</select>
{karama && <CardInspector card={karama} />}
</>
)}
{reason && (
<p id={`${id}-reason`} className="notice">
{reason}
</p>
)}
<Button
className="min-h-11 whitespace-normal"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !reason && quote.event && karama)
act({
type: 'card',
mode: 'special',
card: karama.id,
event: quote.event,
});
}}
>
Spend Karama to declare the Ecaz leader-disc bonus
</Button>
</>
)}
</div>
</details>
);
}
+6
View File
@@ -69,6 +69,8 @@ import {
} from './stronghold-cards';
import { EcazPlacement, AmbassadorSupply } from './ecaz-ambassadors';
import { EcazEntry, AmbassadorInsights } from './ecaz-entry';
import { EcazSpecialKarama } from './ecaz-special-karama';
import { MoritaniSpecialKarama } from './moritani-special-karama';
import { RicheseAuctionDecision, RicheseAuctionLot } from './richese-auctions';
import { RicheseSpecialKarama } from './richese-special-karama';
import { RicheseGift } from './richese-gift';
@@ -2805,6 +2807,9 @@ export function GameTable({
/>
) : g.decision.kind === 'moritaniAssassinate' ? (
<MoritaniAssassinate key={g.decision.event} game={g} act={act} busy={busy} />
) : g.decision.kind === 'moritaniSpecialKarama' ? (
<MoritaniSpecialKarama quote={g.moritaniSpecialKarama ?? null} act={act} busy={busy}
declineAction={{ type: 'decision', event: g.decision.event, decline: true }} />
) : g.decision.kind === 'moritaniRetention' ? (
<MoritaniRetention game={g} act={act} busy={busy} />
) : g.decision.kind === 'moritaniTerror' ? (
@@ -5377,6 +5382,7 @@ export function GameTable({
<>
<ChoamCashIn game={g} act={act} busy={busy} />
<RicheseSpecialKarama game={g} act={act} busy={busy} />
<EcazSpecialKarama quote={g.ecazSpecialKarama ?? null} act={act} busy={busy} />
<RicheseGift game={g} act={act} busy={busy} />
<Distrans game={g} act={act} busy={busy} />
<JuiceOfSapho game={g} act={act} busy={busy} />
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { useId, useState } from 'react';
import type { Action } from '@/game/engine';
import type { MoritaniSpecialKaramaView } from '@/game/moritani-special-karama';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
type Props = {
quote: MoritaniSpecialKaramaView | null;
act: (action: Action) => void;
busy: boolean;
/** The engine owns the opportunity's decline action; no Karama is spent. */
declineAction: Action;
};
type Disposition = 'unchanged' | 'keep' | 'discard';
export function MoritaniSpecialKarama({ quote, act, busy, declineAction }: Props) {
if (!quote) return null;
return (
<MoritaniSpecialKaramaChoice
key={`${quote.source.event}:${quote.source.owner}:${quote.source.opponent}`}
quote={quote}
act={act}
busy={busy}
declineAction={declineAction}
/>
);
}
function MoritaniSpecialKaramaChoice({ quote, act, busy, declineAction }: Omit<Props, 'quote'> & {
quote: MoritaniSpecialKaramaView;
}) {
const id = useId();
const [activation, setActivation] = useState('');
const [choices, setChoices] = useState<Record<string, Disposition>>({});
const karama = quote.karamas.find(card => card.id === activation) ?? quote.karamas[0];
const keep = quote.playedCards
.filter(card => choices[card.id] === 'keep' && quote.keepableIds.includes(card.id))
.map(card => card.id);
const discard = quote.playedCards.filter(card => choices[card.id] === 'discard').map(card => card.id);
const reason = !karama
? 'You need an available Karama Card.'
: keep.length + discard.length === 0
? 'Force at least one card to be kept or discarded, or decline without spending a Karama.'
: null;
return (
<section className="my-4 flex flex-col gap-4" aria-labelledby={`${id}-heading`}>
<h3 id={`${id}-heading`}>Moritani special Karama: opponent’s played cards</h3>
<p>
After your battle loss, spend one of your Karama Cards to force your opponent
to keep or discard selected publicly revealed cards they played. Unchanged
cards remain their choice. This uses your once-per-game special power.
</p>
<p className="fine">{quote.boundary}</p>
<label htmlFor={`${id}-activation`}>Karama to spend</label>
<select
id={`${id}-activation`}
className="min-h-11"
value={karama?.id ?? ''}
disabled={busy || !quote.karamas.length}
onChange={event => setActivation(event.target.value)}
>
{quote.karamas.map(card => (
<option value={card.id} key={card.id}>{card.name}</option>
))}
</select>
{karama && <CardInspector card={karama} />}
{quote.playedCards.map((card, index) => {
const keepable = quote.keepableIds.includes(card.id);
const selected = choices[card.id] ?? 'unchanged';
const disposition = selected === 'keep' && !keepable ? 'unchanged' : selected;
return (
<div className="flex flex-col gap-3" key={card.id}>
<label htmlFor={`${id}-card-${index}`}>{card.name}: disposition</label>
<select
id={`${id}-card-${index}`}
className="min-h-11"
value={disposition}
disabled={busy}
onChange={event => setChoices(previous => ({
...previous,
[card.id]: event.target.value as Disposition,
}))}
>
<option value="unchanged">Unchanged — opponent chooses normally</option>
<option value="keep" disabled={!keepable}>Force keep</option>
<option value="discard">Force discard</option>
</select>
{!keepable && <p className="fine">Force keep is unavailable under the provisional mandatory-disposal precedence.</p>}
<CardRules card={card} />
<CardInspector card={card} />
</div>
);
})}
{reason && <p className="notice" id={`${id}-reason`}>{reason}</p>}
<div className="flex flex-wrap gap-3">
<Button
className="min-h-11 whitespace-normal"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !reason && karama) act({
type: 'card',
mode: 'special',
card: karama.id,
event: quote.source.event,
keep,
discard,
});
}}
>
Spend Karama and force {keep.length + discard.length} card disposition(s)
</Button>
<Button
className="min-h-11 whitespace-normal"
disabled={busy}
onClick={() => { if (!busy) act(declineAction); }}
>
Decline — keep your Karama
</Button>
</div>
</section>
);
}
+8
View File
@@ -88,6 +88,14 @@ now grants the original disc and resumes the entrant without another loan choice
Basic remains optional. One-battle tenure stays provisional; unavailable/captured
scope remains guarded. One actual grant-and-battle smoke and three changed
contract cases pass; types pass. No broad suite or deployed acceptance was run.
**Advanced Ecaz and Moritani special Karama connected.** Ecaz's supplied p30
unarmed disc-difference declaration now reaches real cost/once-use and virtual
strength without additional physical losses. Moritani's supplied p35 post-loss
power now controls exact public winner-card keep/discard subsets before optional
cleanup. Both have owned live controls and minimum legal AI; numeric/custody
precedence interpretations are explicit. Two direct battle smokes and types pass;
live isolated React declarations/selectors submit exact native payloads in Chromium.
No broad suite, deployed acceptance or strategic AI work was run.
+25
View File
@@ -2,6 +2,31 @@
Historical primary-source audit, 2026-09-07, corrected after independent verification. The publisher/designer odd-force conflict remains unresolved as a publisher correction. Under the 8 October Basic-first prototype instruction, Basic now provisionally uses the main p8 paragraph: ceiling contribution/loss and floor survivors, with a visible control warning. This is not a numerical ruling by the user or designer. Advanced retains its separately authorized source. The earlier false attribution to Jack Reda remains withdrawn.
## Advanced Ecaz special Karama first version — 8 October 2026
The authorized supplied revision, physical **p30, Ecaz G**, permits Karama
before plans reveal to add the leader-disc difference to the number dialed
when Ecaz plays neither weapon nor defense. The native Ecaz combatant now
declares this through the existing normalized special-use, real-card cost
and once-per-game pipeline. Declare before sealing or after an unarmed own
plan, without inspecting the opponent's hidden leader.
**Provisional interpretation:** absolute printed/copied disc-value
difference is virtual dial strength, not new counters, extra support or
additional physical casualties. KH/skill modifiers are excluded. Ordinary
traitor/explosion precedence remains unchanged. Later plans enforce both
empty card slots; existing Voice/inspection/Truthtrance constraints must
leave a legal unarmed plan before the cost can commit.
`game/ecaz-special-karama.ts` supplies the owner-only quote and numeric
bonus; authoritative resolution adds the virtual strength independently
from physical dial/losses. `components/ecaz-special-karama.tsx` and minimum
legal AI are connected. A direct smoke uses Easy's declaration, rejects
an armed plan, changes the winner through the disc difference and observes
two actual losses/two spice support. Live isolated React controls submit
the exact current event/card in Chromium. Types pass; no broad suite or
complete-mode/deployed acceptance follows.
## Original live Discovery coalition — 5 October 2026
The original standalone Ecaz Discovery profile marks its native Occupy path,
+27
View File
@@ -2,6 +2,33 @@
Bounded source and implementation audit, 2026-09-06. No runtime edits or live-room actions were performed.
## Distinct Advanced special Karama first version — 8 October 2026
This is **not** the ally-retention ability below. Authorized supplied PDF
physical **p35, Moritani F** lets Moritani, after losing a battle, force
its winning opponent to keep/discard played Treachery Cards. The native
unspent Moritani receives an owned opportunity before optional winner
disposal, with only publicly revealed played identities still held there.
It can decline or spend one actual Karama for disjoint forced keep/discard
subsets. Unchanged cards retain the winner's ordinary choice.
**Provisional precedence:** existing compulsory named-card disposal
finishes first; force keep targets only ordinarily retainable cards.
This does not adjudicate a special-power override of Tooth/Artillery/
skill-card mandatory disposal. No arbitrary unused opposing hand is exposed.
`game/moritani-special-karama.ts` revalidates the exact resolved source and
subsets. The original normalized cost/once-use pipeline then applies real
forced discards and a typed fresh-discard continuation before resuming the
remaining winner choice once; forced keeps cannot be selected in that
choice. Owned controls and legal AI are connected.
One real-loss smoke observes the opportunity, legal bot discard candidate,
one physical Karama cost, an actual forced weapon discard, retained Snooper
and completed cleanup. Live isolated React controls in Chromium change
both selectors and submit the exact native event/card/identity subsets.
Types pass; no broad suite or complete-mode/deployed acceptance follows.
## Explicit effect and eligible outcomes
When Moritani’s ally loses a battle that has a winner, **the ally** may retain **one of its played Treachery cards**, provided it could have retained that card as the winner. This is optional, does not benefit Moritani itself, and does not retrieve arbitrary unplayed hand cards or the opponent’s cards. The paragraph is the ordinary Alliance ability, not the advanced Assassinate Leaders ability. [GF9 Ecaz & Moritani, printed p. 6](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=6)
+10
View File
@@ -35,6 +35,16 @@ Turn-end set-aside is supplied text; one-battle loan release remains the
explicit provisional implementation policy. Existing exceptional custody and
Advanced Harkonnen guards remain. [Current Duke grant contract](ECAZ_DUKE_ACQUISITION.md).
Supplied **p30 Ecaz G** and **p35 Moritani F** now have connected once-game
special Karama first versions. Ecaz uses an explicitly provisional absolute
disc-value virtual dial addition without extra physical losses; declaration
enforces an unarmed plan without hidden-opponent preview. Moritani forces
public played-card keep/discard subsets, with an explicitly provisional
mandatory-disposal-first boundary. These are implementation interpretations,
not publisher clarifications. [Ecaz combat](ECAZ_OCCUPY_RULES.md) and
[distinct Moritani special disposal](MORITANI_ALLY_RETENTION_RULES.md) retain
their exact scope and observed evidence.
## User rulings — 7 October 2026
Five previously pending questions were answered directly by the user. These are
+1
View File
@@ -108,6 +108,7 @@ const decisions = {
nexusFremenCunningOffer: true,
nexusFremenCunningRide: true,
moritaniRetention: true,
moritaniSpecialKarama: true,
battleCards: true,
} satisfies Record<Decision['kind'], true>;
const responses = {
+9
View File
@@ -53,6 +53,7 @@ import {
type LeaderSkillBattleBonus,
} from './leader-skill-combat';
import { quoteEcazOccupyOutcome, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
import { ecazSpecialKaramaBonus, ecazSpecialKaramaPlanAllowed } from './ecaz-special-karama';
export class BattleResolutionQuoteError extends Error {
constructor(message: string) {
@@ -100,6 +101,8 @@ export type ResolutionCombatant = ResolutionParticipant & {
occupiedStrongholds?: number;
/** The current aidFor result: this escrow has already left the donor's balance. */
aid?: { donor: string; amount: number };
/** Revealed Ecaz declaration; virtual dial strength never changes physical commitments. */
ecazSpecialKarama?: true;
};
export type BattleResolutionInput = {
ecazOccupy?: EcazOccupyBattleProfile;
@@ -598,6 +601,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
attacker: battleLeaderStrength(a.leader, d.leader),
defender: battleLeaderStrength(d.leader, a.leader),
};
for (const side of [a, d])
requireQuote(!side.ecazSpecialKarama ||
(input.advanced && side.faction === 'ecaz' && ecazSpecialKaramaPlanAllowed(side.plan)),
'Ecaz special Karama requires its native unarmed Advanced plan.');
let winner: ResolutionCombatant | null = null;
let effects: BattleResolutionQuote['effects'] = null;
let scores: BattleResolutionQuote['scores'] = null;
@@ -668,6 +675,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
scores = {
attacker:
a.plan.dial - attackerPenalty +
(a.ecazSpecialKarama ? ecazSpecialKaramaBonus(strengths.attacker, strengths.defender) : 0) +
(reinforcements[0]?.player === a.id ? 2 : 0) +
(a.id === homeworld?.native ? homeworld.strength : 0) +
(deaths.attacker || effects.stunned
@@ -677,6 +685,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
(a.plan.kwisatz ? 2 : 0)),
defender:
d.plan.dial - defenderPenalty +
(d.ecazSpecialKarama ? ecazSpecialKaramaBonus(strengths.defender, strengths.attacker) : 0) +
(reinforcements[0]?.player === d.id ? 2 : 0) +
(d.id === homeworld?.native ? homeworld.strength : 0) +
(deaths.defender || effects.stunned
+13
View File
@@ -83,6 +83,7 @@ import {
shrineTruthtranceBotAction,
} from './shrine';
import { reserveShipmentCost, alliedNoFieldShipmentPricing } from './shipment-price';
import { moritaniSpecialKaramaBotAction } from './moritani-special-karama-options';
import { quoteSmugglerShipment } from './smuggler-shipment';
import { quoteSmugglerNoField } from './smuggler-no-field';
import { spiceBankerBattleMaximum, spiceBankerModeSupported } from './spice-banker';
@@ -1791,6 +1792,11 @@ function policyActions(g: GameView): Action[] {
? {type:'decision',event:d.event,card:cards[0].card}
: {type:'decision',event:d.event,decline:true}];
}
if (d.kind === 'moritaniSpecialKarama')
return [g.moritaniSpecialKarama
? moritaniSpecialKaramaBotAction(g.moritaniSpecialKarama) ??
{ type: 'decision', event: d.event, decline: true }
: { type: 'decision', event: d.event, decline: true }];
if (d.kind === 'moritaniRetention') {
const candidates = (me.hand ?? [])
.filter((card) => d.cards.includes(card.id))
@@ -3754,6 +3760,11 @@ function policyActions(g: GameView): Action[] {
value: battleCategoryInspectionValue(field, candidatePlan(p)[field], me.hand?.find(card => card.id === p[field])),
}));
}
const ecazPower = g.ecazSpecialKarama;
if (ecazPower && !ecazPower.blocked && !ecazPower.declared && ecazPower.event &&
ecazPower.cards.length && (me.hand ?? []).every(card =>
['special', 'worthless', 'hero'].includes(card.kind)))
return [{ type: 'card', mode: 'special', card: ecazPower.cards[0].id, event: ecazPower.event }];
if (b.revealed) {
const ownsTraitorDecision =
b.traitorVoters.includes(me.id) && !b.traitorSubmitted.includes(me.id);
@@ -4117,6 +4128,8 @@ export function botActions(g: GameView): Action[] {
if (intelligence.length) return intelligence;
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
if (action.type === 'move' && nexusGuildCunningActive(g) && !nexusGuildMovementAvailable(g)) return [];
if (action.type === 'battlePlan' && g.ecazSpecialKarama?.declared &&
(action.weapon || action.defense)) return [];
const sourced = withNativeShipmentSources(g, action);
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) ? [sourced] : [];
});
+118
View File
@@ -0,0 +1,118 @@
import type { Card } from './cards';
import type { FactionId } from './catalog';
export type EcazSpecialKaramaPlan = {
weapon: string | null;
defense: string | null;
};
/** Store this normalized declaration on the battle, not on a hidden plan. */
export type EcazSpecialKaramaDeclaration = {
event: string;
owner: string;
card: string;
};
export type EcazSpecialKaramaContext = {
status: string;
phase: number;
advanced: boolean;
owner: {
id: string;
faction: FactionId;
specialKaramaUsed?: boolean;
};
/** Authoritative owned, unreserved, actual Karama cards supplied by the engine. */
cards: readonly Card[];
battle: {
event?: string;
attacker: string;
defender: string;
revealed: boolean;
/** Only this owner's sealed plan; omit/null means it has not been sealed. */
ownPlan?: EcazSpecialKaramaPlan | null;
declaration?: EcazSpecialKaramaDeclaration | null;
} | null;
};
/** Private owner-only projection; it contains no opposing leader or plan data. */
export type EcazSpecialKaramaQuote = {
event: string | null;
blocked: string | null;
cards: readonly Card[];
declared: boolean;
};
export class EcazSpecialKaramaError extends Error {
constructor(message: string) {
super(message);
this.name = 'EcazSpecialKaramaError';
}
}
/** Revised Dune Rulebook v2.3, physical p. 30, Ecaz G: neither card slot may be used. */
export function ecazSpecialKaramaPlanAllowed(plan: EcazSpecialKaramaPlan): boolean {
return plan.weapon === null && plan.defense === null;
}
/**
* Revised Dune Rulebook v2.3, physical p. 30, Ecaz G allows play before reveal.
* An unsealed own plan does not block declaration; the engine must enforce the
* empty-slot commitment when that plan is later sealed or changed. No opponent
* plan, leader or hidden inspection is an input to eligibility.
*/
export function quoteEcazSpecialKarama(
context: EcazSpecialKaramaContext,
): EcazSpecialKaramaQuote {
const { battle, owner } = context;
const event = battle?.event || null;
const declared = !!(
event &&
battle?.declaration?.event === event &&
battle.declaration.owner === owner.id
);
let blocked: string | null = null;
if (declared) blocked = 'Your Ecaz special Karama is already declared for this battle.';
else if (!context.advanced) blocked = 'Special Karama powers require the Advanced game.';
else if (owner.faction !== 'ecaz') blocked = 'Only native Ecaz may declare this special Karama.';
else if (context.status !== 'playing' || context.phase !== 6 || !battle || !event)
blocked = 'Declare this special Karama in your current battle before plans are revealed.';
else if (battle.attacker !== owner.id && battle.defender !== owner.id)
blocked = 'You must be a combatant in the current battle.';
else if (battle.revealed) blocked = 'Battle Plans have already been revealed.';
else if (owner.specialKaramaUsed) blocked = 'Your special Karama power has already been used this game.';
else if (battle.ownPlan && !ecazSpecialKaramaPlanAllowed(battle.ownPlan))
blocked = 'Your sealed Battle Plan must contain neither a weapon nor a defense.';
else if (context.cards.length === 0) blocked = 'You need an available Karama card.';
return { event, blocked, cards: context.cards, declared };
}
/** Pure preflight only: resource cost, once-use and delayed execution belong to the engine. */
export function quoteEcazSpecialKaramaDeclaration(
context: EcazSpecialKaramaContext,
selection: { event: string; card: string },
): EcazSpecialKaramaDeclaration {
const quote = quoteEcazSpecialKarama(context);
if (quote.blocked) throw new EcazSpecialKaramaError(quote.blocked);
if (!quote.event || selection.event !== quote.event)
throw new EcazSpecialKaramaError('Choose the exact current battle event for this declaration.');
if (!quote.cards.some((card) => card.id === selection.card))
throw new EcazSpecialKaramaError('Choose one of your available Karama cards.');
return { event: quote.event, owner: context.owner.id, card: selection.card };
}
/**
* Ecaz G (p. 30) says to add the leader-disc difference to the number dialed.
* Provisional prototype policy, not a publisher ruling: use the absolute
* printed/copied disc-value difference as virtual dial strength only. Caller
* supplies authoritative revealed disc values (including Zoal's copied value),
* excluding KH, skills and other strength modifiers. Add this result to battle
* strength, never to physical counters, spice support or dialed casualties.
* Existing traitor/explosion outcome precedence remains the engine's concern.
*/
export function ecazSpecialKaramaBonus(
ownDiscValue: number,
opposingDiscValue: number,
): number {
return Math.abs(ownDiscValue - opposingDiscValue);
}
+162 -1
View File
@@ -549,6 +549,10 @@ import {
playedVoiceMatch,
} from './battle-cards';
import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
import { quoteEcazSpecialKarama, quoteEcazSpecialKaramaDeclaration, ecazSpecialKaramaPlanAllowed,
EcazSpecialKaramaError, type EcazSpecialKaramaDeclaration } from './ecaz-special-karama';
import { quoteMoritaniSpecialKarama, validateMoritaniSpecialKaramaChoice,
MoritaniSpecialKaramaError, type MoritaniSpecialKaramaSource } from './moritani-special-karama';
import {
TECH_TOKENS,
createTechTokens,
@@ -728,6 +732,7 @@ export type Battle = {
noFieldPlayers?: string[];
/** Provisional Basic mixed-pool participation, separate from the hidden value. */
basicMixedNoFieldPlayers?: string[];
ecazSpecialKarama?: EcazSpecialKaramaDeclaration;
truthPromises?: BattlePromise[];
poisonTooth?: Record<string, boolean>;
stoneBurner?: Record<string, 'kill' | 'ignore'>;
@@ -961,6 +966,7 @@ export type Decision =
source?: 'nexus';
event?: string;
}
| { kind: 'moritaniSpecialKarama'; player: string; event: string }
| { kind: 'battleCards'; player: string; territory: string; cards: string[] };
type SpiceBankerIncomePayment = {
event: string;
@@ -1702,6 +1708,12 @@ export type Game = {
} | null;
/** Current ordinary Basic shipment's physical departure obligation. */
basicAlliedShipment?: BasicAlliedShipment | null;
moritaniSpecialKarama?: {
source: MoritaniSpecialKaramaSource;
cards: string[];
optional: string[];
stage: 'offer' | 'discard' | 'done';
} | null;
/** Committed entries whose optional reactions must wait for the arrival's own
* pending interaction (payment, advisor choice, storm protection). */
pendingArrivalReaction?: {
@@ -7047,6 +7059,10 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
} else if (next.kind === 'terrorAtomics') {
g.pendingTerrorEntry = next.entry;
finishTerrorEntry(g);
} else if (next.kind === 'battleCleanup' && next.source === 'winner' &&
g.moritaniSpecialKarama?.stage === 'discard' &&
next.event === g.moritaniSpecialKarama.source.event) {
resumeMoritaniSpecialKarama(g);
} else {
finishBattle(g);
}
@@ -19681,6 +19697,9 @@ function validatePlan(
typeof input.defense === 'string' && input.defense ? input.defense : null,
support,
};
if (b.ecazSpecialKarama?.owner === p.id)
requireRule(ecazSpecialKaramaPlanAllowed(plan),
'The declared Ecaz special Karama requires neither a weapon nor a defense.');
requireRule(
input.kwisatz === undefined || typeof input.kwisatz === 'boolean',
'Choose whether to use the Kwisatz Haderach.',
@@ -20192,6 +20211,7 @@ function findLegalBattlePlan(
options: {
promises?: BattlePromise[];
prescience?: { field: PlanField; value: unknown };
unarmed?: boolean;
} = {},
): Plan | null {
const b = g.battle!;
@@ -20199,6 +20219,7 @@ function findLegalBattlePlan(
const fixed = options.prescience;
const commitments = committedPlanElements(b, p.id);
const accepts = (plan: Partial<Plan>) =>
(!options.unarmed || (!plan.weapon && !plan.defense)) &&
commitments.every(element =>
inspectedPlanMatches(p, element.field, element.value, plan[element.field])) &&
respectsBattlePromises(promises, p.id, plan, p.hand) &&
@@ -21323,6 +21344,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
hand: p.hand,
plan,
leader: controlledLeaders(g, p).find((l) => l.id === plan.leader),
...(b.ecazSpecialKarama?.owner === p.id ? { ecazSpecialKarama: true as const } : {}),
leaderSkills: battleLeaderSkills(g, p),
occupiedStrongholds: leaderSkillStrongholdCount(g, p),
forces: planCombatForces(g, p, opponent),
@@ -22487,6 +22509,23 @@ function finishWinner(g: Game, winner: Player, t: string, cards: string[]) {
territory: t, optional: [...cards], commitment: structuredClone(pending) });
return;
}
const context = g.lastBattleContext;
const moritani = byFaction(g, 'moritani');
if (g.advanced && context && moritani && !moritani.specialKaramaUsed &&
context.winner === winner.id && context.combatants.includes(moritani.id) &&
moritani.id !== winner.id && cards.length &&
g.moritaniSpecialKarama?.source.event !== context.event) {
g.moritaniSpecialKarama = {
source: { event: context.event, turn: g.turn, territory: t,
owner: moritani.id, opponent: winner.id },
cards: [...cards], optional: [...cards], stage: 'offer',
};
if (currentMoritaniSpecialKaramaQuote(g)) {
g.decision = { kind: 'moritaniSpecialKarama', player: moritani.id, event: context.event };
return;
}
g.moritaniSpecialKarama.stage = 'done';
}
if (cards.length)
g.decision = {
kind: 'battleCards',
@@ -26694,6 +26733,64 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
? `${p.name} spent CHOAM Nexus Cunning and declared ${effectName}. Its chosen Treachery card stays private and is discarded only if the effect occurs; Karama may prevent this native advantage.`
: `${p.name} declared ${effectName} for its special effect.`);
}
function expansionSpecialKaramaRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) {
if (error instanceof EcazSpecialKaramaError || error instanceof MoritaniSpecialKaramaError)
throw new RuleError(error.message);
throw error;
}
}
function actualSpecialKaramaCards(g: Game, p: Player) {
return p.hand.filter(card => card.kind === 'special' && card.effect === 'karama' &&
!karamaSpendingBlock(g, p, card));
}
function ecazSpecialKaramaContext(g: Game, p: Player) {
const b = g.battle;
return { status: g.status, phase: g.phase, advanced: g.advanced, owner: p,
cards: actualSpecialKaramaCards(g, p),
battle: b ? { event: b.event, attacker: b.attacker, defender: b.defender,
revealed: b.revealed, ownPlan: b.plans[p.id], declaration: b.ecazSpecialKarama } : null };
}
function ecazSpecialKaramaView(g: Game, p: Player) {
if (!g.advanced || p.faction !== 'ecaz' || !g.battle) return null;
const quote = quoteEcazSpecialKarama(ecazSpecialKaramaContext(g, p));
if (!quote.declared && (g.response || g.decision || g.battle.preparation ||
(g.battle.preLeader && !g.battle.preLeader.closed)))
quote.blocked = 'Finish the current battle preparation or response before declaring.';
if (!quote.blocked && !findLegalBattlePlan(g, p, { unarmed: true }))
quote.blocked = 'Existing battle commitments leave no legal unarmed plan.';
return quote;
}
function currentMoritaniSpecialKaramaQuote(g: Game) {
const pending = g.moritaniSpecialKarama;
const context = g.lastBattleContext;
if (!pending || pending.stage !== 'offer' || !context ||
context.event !== pending.source.event || context.turn !== g.turn) return null;
const owner = getPlayer(g, pending.source.owner);
const winner = getPlayer(g, pending.source.opponent);
const cards = pending.cards.map(id => winner.hand.find(card => card.id === id));
requireRule(cards.every(card => !!card), 'The public played cards changed before Moritani’s choice.');
return expansionSpecialKaramaRule(() => quoteMoritaniSpecialKarama({
advanced: g.advanced, phase: g.phase, battlePresent: !!g.battle, turn: g.turn,
owner, used: !!owner.specialKaramaUsed, battle: context,
karamas: actualSpecialKaramaCards(g, owner), playedCards: cards as Card[],
heldPlayedIds: pending.cards, keepableIds: pending.cards,
}));
}
function resumeMoritaniSpecialKarama(g: Game) {
const pending = g.moritaniSpecialKarama!;
requireRule(g.lastBattleContext?.event === pending.source.event &&
g.lastBattleContext.winner === pending.source.opponent,
'Resume the original Moritani winner-card opportunity.');
pending.stage = 'done';
g.decision = null;
if (pending.optional.length)
g.decision = { kind: 'battleCards', player: pending.source.opponent,
territory: pending.source.territory, cards: [...pending.optional] };
else finishBattle(g);
}
/** Internal normalized command; never accept this object directly from a route. */
export type SpecialKaramaIntent = {
owner: string;
@@ -26716,6 +26813,8 @@ export type SpecialKaramaIntent = {
| { kind: 'emperorForces'; amount: number; elite: number }
| { kind: 'emperorLeader'; leader: string }
| { kind: 'harkonnen'; target: string; amount: number }
| { kind: 'ecaz'; event: string }
| { kind: 'moritani'; source: MoritaniSpecialKaramaSource; keep: string[]; discard: string[] }
);
/** Validates and copies a selection without spending resources, continuing play, or drawing RNG. */
@@ -26758,6 +26857,24 @@ export function prepareSpecialKaramaIntent(
const activationBlock = karamaSpendingBlock(g, p, card);
requireRule(!activationBlock, activationBlock ?? 'This Karama is committed elsewhere.');
const base = { owner: p.id, card: card.id, turn: g.turn, phase: g.phase };
if (p.faction === 'ecaz') {
requireRule(!g.response && !g.decision && !g.battle?.preparation &&
(!g.battle?.preLeader || g.battle.preLeader.closed),
'Finish battle preparation before declaring the Ecaz special Karama.');
const selection = expansionSpecialKaramaRule(() => quoteEcazSpecialKaramaDeclaration(
ecazSpecialKaramaContext(g, p), { event: stringField(action.event), card: card.id }));
requireRule(!!findLegalBattlePlan(g, p, { unarmed: true }),
'Your existing Voice, inspection or Truthtrance commitments leave no legal unarmed plan.');
return { ...base, kind: 'ecaz', event: selection.event };
}
if (p.faction === 'moritani') {
const quote = currentMoritaniSpecialKaramaQuote(g);
requireRule(quote && g.decision?.kind === 'moritaniSpecialKarama' &&
g.decision.player === p.id, 'Use the current Moritani post-loss card opportunity.');
const choice = expansionSpecialKaramaRule(() => validateMoritaniSpecialKaramaChoice(
quote, g.moritaniSpecialKarama!.source, p.id, action));
return { ...base, kind: 'moritani', source: choice.source, keep: choice.keep, discard: choice.discard };
}
if (p.faction === 'richese') {
const blocked = karamaSpendingBlock(g, p, card);
requireRule(!blocked, blocked ?? 'This Karama is committed elsewhere.');
@@ -27007,6 +27124,10 @@ export function prepareSpecialKaramaIntent(
function specialKaramaAction(intent: SpecialKaramaIntent): Action {
const action = { type: 'card', mode: 'special', card: intent.card };
switch (intent.kind) {
case 'ecaz':
return { ...action, event: intent.event };
case 'moritani':
return { ...action, event: intent.source.event, keep: [...intent.keep], discard: [...intent.discard] };
case 'richese':
return { ...action, acquire: intent.acquire };
case 'choam':
@@ -27081,6 +27202,13 @@ export function executeSpecialKaramaIntent(
sameDeclaration(intent.battle, prepared.battle),
'The original battle is no longer awaiting special prescience.',
);
if (intent.kind === 'ecaz' && prepared.kind === 'ecaz')
requireRule(intent.event === prepared.event, 'The Ecaz battle event changed before execution.');
if (intent.kind === 'moritani' && prepared.kind === 'moritani')
requireRule(sameDeclaration(intent.source, prepared.source) &&
JSON.stringify(intent.keep) === JSON.stringify(prepared.keep) &&
JSON.stringify(intent.discard) === JSON.stringify(prepared.discard),
'The original Moritani card selection changed before execution.');
const p = getPlayer(g, intent.owner);
const card = p.hand.find((candidate) => candidate.id === intent.card)!;
const converted = card.effect === 'truthtrance';
@@ -27089,7 +27217,31 @@ export function executeSpecialKaramaIntent(
g,
`${p.name} used the physical Truthtrance as Karama for their special faction power.`,
);
if (intent.kind === 'richese') {
if (intent.kind === 'ecaz') {
discard(g, p, card.id);
p.specialKaramaUsed = true;
g.battle!.ecazSpecialKarama = { owner: p.id, card: card.id, event: intent.event };
log(g, `${p.name} declared the once-game unarmed Ecaz special Karama. The revealed disc-value difference adds provisional virtual dial strength, without additional physical losses.`,
{ faction: 'ecaz', name: 'Special Karama' });
} else if (intent.kind === 'moritani') {
const pending = g.moritaniSpecialKarama!;
discard(g, p, card.id);
p.specialKaramaUsed = true;
const winner = getPlayer(g, intent.source.opponent);
pending.optional = pending.cards.filter(id => !intent.keep.includes(id) && !intent.discard.includes(id));
const entries = intent.discard.map(id => ({ card: discard(g, winner, id,
cleanupDiscardRole(g, winner, id)), discardedBy: winner.id, publicFace: true }));
log(g, `${p.name} used special Karama to force ${intent.keep.length} played card(s) kept and ${intent.discard.length} discarded. Mandatory named-card disposal remains the provisional precedence.`,
{ faction: 'moritani', name: 'Special Karama' });
if (entries.length) {
pending.stage = 'discard';
g.decision = null;
stageTreacheryDiscard(g, 'battle:winner', entries, { kind: 'battleCleanup',
event: pending.source.event, combatants: [...g.lastBattle],
territory: pending.source.territory, player: winner.id, source: 'winner',
played: [...intent.discard], kept: [] });
} else resumeMoritaniSpecialKarama(g);
} else if (intent.kind === 'richese') {
const selected = g.richeseCache!.findIndex((c) => c.id === intent.acquire);
discard(g, p, card.id);
p.spice -= 3;
@@ -29746,6 +29898,11 @@ function applyActionInner(
return g;
} else log(g, `${p.name} declined the sandworm ride.`);
nextWormRide(g);
} else if (decision.kind === 'moritaniSpecialKarama') {
requireRule(g.moritaniSpecialKarama?.stage === 'offer' &&
decision.event === action.event && decision.event === g.moritaniSpecialKarama.source.event &&
action.decline === true, 'Decline only the current Moritani special Karama opportunity.');
resumeMoritaniSpecialKarama(g);
} else if (decision.kind === 'battleCards') {
requireRule(
Array.isArray(action.discard) &&
@@ -32863,6 +33020,10 @@ export function viewGame(state: Game, id: string) {
}
: null,
richeseSpecialKarama: richeseSpecialKaramaView(g, me),
...(me.faction === 'ecaz' && g.advanced && g.battle
? { ecazSpecialKarama: ecazSpecialKaramaView(g, me) } : {}),
...(g.moritaniSpecialKarama?.source.owner === me.id && g.moritaniSpecialKarama.stage === 'offer'
? { moritaniSpecialKarama: currentMoritaniSpecialKaramaQuote(g) } : {}),
ixRicheseTechnology: g.pendingIxRicheseTechnology ? {
event: g.pendingIxRicheseTechnology.event,
player: g.pendingIxRicheseTechnology.player,
+2
View File
@@ -176,6 +176,7 @@ export const FACTION_RULES: Record<
'Loyalty: before initial Traitor dealing, randomly set aside one of your five native Traitor Cards face up for everyone. It stays outside the deck for the whole game, including later Traitor and Face Dancer draws. Karama cannot prevent this automatic power; the corresponding leader disc is unchanged.',
'In the bounded Advanced Occupy preview, choose Ecaz or your ally as lead. The lead uses its own leaders, cards and spice; total dial includes the ceiling of half your fighters at free full strength plus chosen allied army strength. An ordinary win leaves the floor of half your fighters and settles allied dial losses separately. Every participating whole army must be storm-clear and connected.',
'Karama prevention keeps the selected lead but switches to that lead’s own forces; the other ally contributes zero. These numbers use the authorized revised Advanced sheet, not a Basic ruling; only Ecaz may revive Duke, including by Ghola.',
'Once per game before plans reveal, declare special Karama while playing neither weapon nor defense. The supplied p30 disc difference has a connected provisional virtual-strength/no-extra-casualty first version; the owned panel explains the formula.',
'Loyalty, Collection and bounded Advanced Occupy have connected development implementations. Remaining powers and combined interactions are still being completed; this sheet is not a complete faction certification.',
],
},
@@ -191,6 +192,7 @@ export const FACTION_RULES: Record<
advanced: [
'After losing a qualifying battle with no traitor called and the opposing leader surviving, you may reveal a held Traitor Card naming a different leader from that opposing faction. A living target dies and pays its printed strength from the bank; an already-dead target pays nothing.',
'During Mentat Pause, set the revealed card aside face up as that faction’s use marker and draw its replacement. Use this advantage once against each faction; Karama cannot prevent it.',
'Once per game after a battle loss with a winner, special Karama can force kept/discarded subsets of that opponent’s public played cards. Owned selectors, native cost and cleanup are connected; mandatory named-card disposal has explicitly provisional precedence.',
'Advanced assassination is available in an opted-in development preview. The duration of forfeiture after calling a normal traitor and exceptional leader-custody interactions remain unresolved.',
],
},
+35
View File
@@ -0,0 +1,35 @@
import {
validateMoritaniSpecialKaramaChoice,
type MoritaniSpecialKaramaAction,
type MoritaniSpecialKaramaQuote,
} from './moritani-special-karama';
/**
* Public-quote-only first version: deny useful played weapons/defenses. Null
* means decline; the engine supplies its own opportunity-specific action.
* No opposing hand, hidden plans or speculative future battle are consulted.
*/
export function moritaniSpecialKaramaBotAction(
quote: MoritaniSpecialKaramaQuote,
): MoritaniSpecialKaramaAction | null {
const karama = quote.karamas[0];
if (!karama) return null;
const discard = quote.playedCards.filter(card =>
quote.keepableIds.includes(card.id) && [
'projectile', 'poison', 'lasgun', 'shield', 'snooper', 'poisonBlade',
'shieldSnooper', 'weirdingWay', 'chemistry', 'poisonTooth', 'artillery',
].includes(card.kind),
).map(card => card.id);
// Cards already destined for compulsory disposal give no benefit for the cost.
if (!discard.length) return null;
const action: MoritaniSpecialKaramaAction = {
type: 'card',
mode: 'special',
card: karama.id,
event: quote.source.event,
keep: [],
discard,
};
validateMoritaniSpecialKaramaChoice(quote, quote.source, quote.source.owner, action);
return action;
}
+183
View File
@@ -0,0 +1,183 @@
import type { Card } from './cards';
import type { FactionId } from './catalog';
import type { ResolvedBattleReceipt } from './karama-battle-preflight';
export class MoritaniSpecialKaramaError extends Error {
constructor(message: string) {
super(message);
this.name = 'MoritaniSpecialKaramaError';
}
}
/** Prototype precedence, not a publisher clarification of Moritani F. */
export const MORITANI_SPECIAL_KARAMA_BOUNDARY =
'Provisional first-version precedence: mandatory named-card disposal wins. Force keep applies only to ordinarily retainable cards; this is not a publisher clarification.';
export type MoritaniSpecialKaramaSource = {
event: string;
turn: number;
territory: string;
owner: string;
opponent: string;
};
export type MoritaniSpecialKaramaInput = {
advanced: boolean;
phase: number;
battlePresent: boolean;
turn: number;
owner: { id: string; faction: FactionId };
used: boolean;
/** The actual resolved battle, never a prospective or reconstructed result. */
battle: ResolvedBattleReceipt;
/** Owned, unreserved actual Karama Cards, supplied by the integration owner. */
karamas: readonly Card[];
/** Only publicly revealed winner cards played in this battle, never their hand. */
playedCards: readonly Card[];
/** Public played identities still in the winner's physical custody. */
heldPlayedIds: readonly string[];
/** Normal retainable identities after compulsory named-card disposal. */
keepableIds: readonly string[];
};
export type MoritaniSpecialKaramaQuote = {
source: MoritaniSpecialKaramaSource;
karamas: readonly Card[];
/** Public played cards still held; all are legal force-discard targets. */
playedCards: readonly Card[];
keepableIds: readonly string[];
boundary: string;
};
/** Project only to source.owner; independent of GameView and hidden hands. */
export type MoritaniSpecialKaramaView = MoritaniSpecialKaramaQuote;
export type MoritaniSpecialKaramaAction = {
type: 'card';
mode: 'special';
card: string;
event: string;
keep: string[];
discard: string[];
};
/** Detached instruction only: the caller owns cost, once-use and continuation. */
export type MoritaniSpecialKaramaChoice = {
source: MoritaniSpecialKaramaSource;
card: string;
keep: string[];
discard: string[];
};
function requireMoritani(condition: unknown, message: string): asserts condition {
if (!condition) throw new MoritaniSpecialKaramaError(message);
}
function id(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function ids(value: unknown): value is string[] {
return Array.isArray(value) && value.every(id) && new Set(value).size === value.length;
}
function cardList(value: unknown): value is Card[] {
return Array.isArray(value) &&
value.every(card => card && id(card.id) && id(card.name) && id(card.kind)) &&
new Set(value.map(card => card.id)).size === value.length;
}
/**
* Call after compulsory aftermath and before optional winner disposal. The
* supplied played-card list is already public; this helper never finds cards
* in an opposing hand or infers whether an unrevealed card was played.
*/
export function quoteMoritaniSpecialKarama(
input: MoritaniSpecialKaramaInput,
): MoritaniSpecialKaramaQuote | null {
if (!input.advanced || input.owner.faction !== 'moritani' || input.used ||
input.phase !== 6 || input.battlePresent) return null;
const battle = input.battle;
requireMoritani(
battle && id(battle.event) && Number.isSafeInteger(input.turn) && input.turn >= 1 &&
battle.turn === input.turn && id(battle.territory) && id(input.owner.id) &&
ids(battle.combatants) && battle.combatants.length === 2 &&
['normal', 'traitor', 'mutualTraitors', 'explosion', 'legacy'].includes(battle.result) &&
(battle.winner === null || (id(battle.winner) && battle.combatants.includes(battle.winner))) &&
((battle.result === 'mutualTraitors' || battle.result === 'explosion')
? battle.winner === null : battle.winner !== null),
'Moritani special Karama needs the current resolved battle receipt.',
);
if (!battle.combatants.includes(input.owner.id) || battle.winner === null ||
battle.winner === input.owner.id) return null;
requireMoritani(
cardList(input.karamas) && input.karamas.every(card => card.kind === 'special' && card.effect === 'karama'),
'Supply only unique, owned, available actual Karama Cards.',
);
requireMoritani(
cardList(input.playedCards) && ids(input.heldPlayedIds) && ids(input.keepableIds) &&
input.heldPlayedIds.every(card => input.playedCards.some(played => played.id === card)) &&
input.keepableIds.every(card => input.heldPlayedIds.includes(card)) &&
!input.karamas.some(card => input.playedCards.some(played => played.id === card.id)),
'Supply unique public played cards, their current custody, and normal retainable identities.',
);
const playedCards = input.playedCards.filter(card => input.heldPlayedIds.includes(card.id));
if (!input.karamas.length || !playedCards.length) return null;
return {
source: {
event: battle.event,
turn: battle.turn,
territory: battle.territory,
owner: input.owner.id,
opponent: battle.winner,
},
karamas: input.karamas,
playedCards,
keepableIds: input.keepableIds,
boundary: MORITANI_SPECIAL_KARAMA_BOUNDARY,
};
}
/** Validate against a freshly rebuilt quote for the exact pending opportunity. */
export function validateMoritaniSpecialKaramaChoice(
quote: MoritaniSpecialKaramaQuote,
source: MoritaniSpecialKaramaSource,
actor: string,
action: unknown,
): MoritaniSpecialKaramaChoice {
requireMoritani(
source.event === quote.source.event && source.turn === quote.source.turn &&
source.territory === quote.source.territory && source.owner === quote.source.owner &&
source.opponent === quote.source.opponent,
'This Moritani special Karama must resume its exact original battle opportunity.',
);
requireMoritani(action && typeof action === 'object' && !Array.isArray(action),
'Choose a Moritani special Karama disposition.');
const choice = action as Record<string, unknown>;
requireMoritani(
actor === quote.source.owner && choice.type === 'card' && choice.mode === 'special' &&
choice.event === quote.source.event &&
Object.keys(choice).every(key => ['type', 'mode', 'card', 'event', 'keep', 'discard'].includes(key)),
'This Moritani special Karama must match its owner and original battle event.',
);
requireMoritani(id(choice.card) && quote.karamas.some(card => card.id === choice.card),
'Spend one of your available actual Karama Cards.');
requireMoritani(ids(choice.keep) && ids(choice.discard),
'Keep and discard must be unique card-identity lists.');
const keep = choice.keep;
const discard = choice.discard;
requireMoritani(keep.length + discard.length > 0,
'Force at least one disposition, or decline the opportunity without spending a Karama.');
requireMoritani(
keep.every(card => quote.keepableIds.includes(card) && quote.playedCards.some(played => played.id === card)) &&
discard.every(card => quote.playedCards.some(played => played.id === card)) &&
!keep.some(card => discard.includes(card)),
'Choose disjoint played cards still held; force keep only normally retainable cards.',
);
return {
source: { ...quote.source },
card: choice.card,
keep: [...keep],
discard: [...discard],
};
}
+49 -2
View File
@@ -2994,6 +2994,50 @@ export const RULE_TOPICS: RuleTopic[] = [
'privacy',
],
},
{
id: 'ecaz-special-karama',
title: 'Ecaz special Karama: unarmed leader-disc difference',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Declare the once-game unarmed power before reveal; virtual disc difference adds strength without extra physical losses.',
steps: [
'Native Advanced Ecaz may spend one available actual Karama before both plans reveal. Declare before sealing or after an unarmed own plan. Both weapon and defense slots must stay empty.',
'The owned panel does not inspect or quote an opposing hidden leader. Current battle event, physical card, once-use and legal unarmed completion are validated before cost.',
'Provisional formula: absolute printed/copied leader-disc difference adds virtual dial strength after reveal. It adds no counters, extra support or physical casualties; KH and skill modifiers are excluded. Traitor/explosion precedence remains native.',
'This is supplied PDF physical p30 Ecaz G with explicit first-version interpretation. Combined lead/custody and wider timing refinement do not certify complete Ecaz or reopen public gates.',
],
related: ['ecaz-occupy', 'special-karama', 'battle'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Normalized real-card/once-use declaration, unarmed commitment and authoritative virtual-strength resolution are connected.', evidence: ['game/ecaz-special-karama.ts', 'game/engine.ts', 'game/battle-resolution-quote.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Owned event-bound selector, declared status and provisional formula; live isolated React declaration clicked in Chromium.', evidence: ['components/ecaz-special-karama.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Minimum legal unarmed declaration and plan filtering use only its own quote; strategic refinement is deferred.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Supplied source and physical/virtual interpretation are explicit.', evidence: ['docs/ECAZ_OCCUPY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'One direct changed-path smoke observes winner change, unarmed rejection, two physical losses and two support spice; types pass. No broad acceptance claimed.' },
],
},
{
id: 'moritani-special-karama',
title: 'Moritani special Karama: opponent played-card disposal',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'After losing, force kept/discarded subsets of the actual winner’s public played cards with one real Karama.',
steps: [
'Native Advanced Moritani with its once-use unspent gets an owned opportunity before optional winner-card disposal. Decline without cost or choose exact disjoint keep/discard subsets and one actual Karama.',
'Only public played identities still held by the winner are offered. Unchanged cards remain the winner’s choice; forced keeps are removed from that optional discard list.',
'Provisional precedence: compulsory named-card disposal finishes first, and force keep applies only to ordinary retainable cards. The supplied p35 Moritani F does not adjudicate this mandatory-disposal override.',
'Cost, once-use, source/event revalidation and fresh-discard continuation are native. Mixed aftermath and other timing refinement remain separate; no full Moritani certification is claimed.',
],
related: ['moritani-ally-retention', 'special-karama', 'battle'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Owned resolved-loss opportunity, exact subsets, real cost and one winner-cleanup continuation are connected.', evidence: ['game/moritani-special-karama.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Real card selector and per-public-card keep/discard controls; live isolated React choices submitted in Chromium.', evidence: ['components/moritani-special-karama.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal own-quote weapon/defense forced discard or decline; no unused opposing hand data.', evidence: ['game/moritani-special-karama-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Distinct special power, supplied source and provisional precedence are recorded.', evidence: ['docs/MORITANI_ALLY_RETENTION_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'One actual loss/cost/forced keep/discard/cleanup smoke and current types pass; no broad acceptance claimed.' },
],
},
{
id: 'moritani-assassinate-leaders',
title: 'Moritani: advanced Assassinate Leaders',
@@ -3336,7 +3380,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
summary:
'The five base faction special powers and Tleilaxu revival prevention have engine support; timing and expansion audits remain.',
'Native faction special powers have connected first versions, including Ecaz’s unarmed disc-difference and Moritani’s winner-card disposal; timing and combination refinement remain.',
steps: [
'Spend an actual Karama card to activate your faction’s special power once per game. The usage remains spent on later turns, but you may still use other Karama cards normally. These powers resolve directly without a normal faction cancellation window.',
'After Voice and ordinary prescience are resolved or declined, Atreides may spend its special Karama to inspect either combatant’s entire plan, including in a battle it is not fighting. The target commits first; Atreides receives the dial, leader, weapon, defense, spice support and KH selection privately. Its ally does not automatically receive this view.',
@@ -3354,12 +3398,15 @@ export const RULE_TOPICS: RuleTopic[] = [
'Tleilaxu receive a decision before another faction’s normal revival completes, whether or not they hold Karama. Decline to continue the revival, or spend a real Karama once per game to prevent that faction’s normal force and leader revivals for the current turn. A stopped attempt spends no spice, returns no pieces and consumes no elite or extra-revival quota.',
'This includes negotiated leaders, KH and Emperor-funded allied extra forces. The Emperor remains the payer when an extra revival is allowed. Hidden leader identities and prices are not added to the public stop decision. Ghola treachery and the Emperor’s special Karama currently remain separate from the normal-revival restriction.',
'The printed Tleilaxu power is brief. Its full-turn scope, precise timing and card/special-power exceptions remain provisional pending the complete primary-source audit. These choices are tested implementation behavior, not certification of the full expansion rules.',
'Remaining expansion special powers, plus further timing and expansion interaction audits, remain unfinished. Advanced preview is available for the six classic factions.',
'CHOAM cash-in, Richese acquisition and Ixian HMS relocation also have connected special-use paths. Ecaz’s supplied p30 unarmed disc difference and Moritani’s supplied p35 post-loss winner-card disposal now have owned controls and native cost/once-use, with explicit provisional numeric/disposal interpretations. Advanced preview remains six-classic only; broader timing and combinations are not certified.',
'Richese’s special purchase is available in development tables: spend a Karama and three spice to choose privately from the cache, with separate Emperor income and explicit full-hand/final-cache guards. See its acquisition guide for current controls and limits.',
'See Ecaz special Karama and Moritani special Karama for their source-bound first versions. Their declarations do not create a normal faction-cancellation window; existing Kull/BG handling remains in the shared special-intent pipeline.',
],
related: [
'card-karama',
'richese-acquisition',
'ecaz-special-karama',
'moritani-special-karama',
'bidding',
'revival',
'faction-harkonnen',
+1
View File
@@ -89,6 +89,7 @@ const DECISIONS = {
nexusFremenCunningOffer: true,
nexusFremenCunningRide: true,
moritaniRetention: true,
moritaniSpecialKarama: true,
battleCards: true,
} satisfies Record<Decision['kind'], true>;
const RESPONSES = {