Open Tleilaxu special Karama throughout Revival
Permit native Advanced quiet any-player declarations alongside the original target-bound pending request. Preserve completed returns and charges. Adopt explicit provisional Ghola inclusion during the remaining Revival and phase expiry; keep printed Emperor special immunity and the existing low-Homeworld guard. Connect persistent controls and minimal legal bots through the shared quote. Validation: 14 related files148/148, types/lint/build, six actual native control/engine/GameTable SSR journeys and four existing-profile continuations. Private-driver-only feedback reuses passed source-identical checks. Native111/113-action setup, paid shipment and battle sources earn originals. No saved-room/D1/restart/deployment operation; full modes and browser/deployed acceptance remain open.
This commit is contained in:
1 parent
6ea691255a
commit
1a505525a8
18 files changed
+977
-59
No files matched your search
@@ -55,6 +55,7 @@ import { CharityClaim } from './charity-claim';
|
||||
import { FremenRevivalGrant } from './fremen-revival-grant';
|
||||
import { AdvisorPreparation } from './advisor-preparation';
|
||||
import { AtreidesFullPlan } from './atreides-full-plan';
|
||||
import { TleilaxuSpecialKarama } from './tleilaxu-special-karama';
|
||||
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
|
||||
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
|
||||
import { JunctionTransport } from './junction-transport';
|
||||
@@ -1977,6 +1978,7 @@ export function GameTable({
|
||||
<Recruits game={g} act={act} busy={busy} />
|
||||
<AdvisorPreparation game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||||
{g.decision?.kind !== 'fullPlanOffer' && <AtreidesFullPlan game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />}
|
||||
{g.decision?.kind !== 'revivalStop' && <TleilaxuSpecialKarama game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />}
|
||||
<NexusTraitors game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
<DiscoveryPanel game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
@@ -3161,8 +3163,8 @@ export function GameTable({
|
||||
declared a{' '}
|
||||
{g.decision.revival === 'forces' ? 'force' : 'leader'}{' '}
|
||||
revival. Allow it or use your once-per-game special Karama
|
||||
to prevent their normal force and leader revivals for this
|
||||
turn.
|
||||
to prevent their further force and leader revivals during
|
||||
this Revival Phase, including Ghola.
|
||||
</p>
|
||||
{g.decision.source === 'nexusEmperor' && (
|
||||
<p className="notice">
|
||||
@@ -3171,21 +3173,7 @@ export function GameTable({
|
||||
income; both the Nexus and your special Karama remain spent.
|
||||
</p>
|
||||
)}
|
||||
<HelpTip topic="tleilaxuSpecial" />
|
||||
{g.revival.specialKaramaBlock && (
|
||||
<p role="status">{g.revival.specialKaramaBlock}</p>
|
||||
)}
|
||||
{me.hand
|
||||
?.filter(canActivateKarama)
|
||||
.map((c) => (
|
||||
<div key={c.id}>
|
||||
{actionButton(
|
||||
'Spend special Karama · prevent revival',
|
||||
{ type: 'card', card: c.id, mode: 'special' },
|
||||
!!g.revival.specialKaramaBlock,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<TleilaxuSpecialKarama game={g} act={act} busy={busy} />
|
||||
{actionButton('Allow this revival', {
|
||||
type: 'decision',
|
||||
decline: true,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
export function TleilaxuSpecialKarama({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const offer = game.tleilaxuSpecialKarama;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
if (!offer || game.status !== 'playing' || !game.advanced ||
|
||||
game.phase !== 4 || me?.faction !== 'tleilaxu') return null;
|
||||
const ownStop = offer.pending && game.decision?.kind === 'revivalStop' &&
|
||||
game.decision.player === game.me;
|
||||
const priorityPending = !!game.response || !!game.phaseOpening || !!game.truthtrance ||
|
||||
!!game.nexusTraitors?.pending || (!!game.decision && !ownStop) || (offer.pending && !ownStop);
|
||||
const blocked = offer.blocked || (priorityPending
|
||||
? 'Finish the current response, decision or phase opening before using this special Karama.'
|
||||
: null);
|
||||
return (
|
||||
<section className="notice space-y-3" aria-label="Tleilaxu special Karama revival prevention">
|
||||
<h3 className="font-serif text-xl">Prevent revival</h3>
|
||||
<p className="muted">
|
||||
Spend your once-per-game special Karama to prevent one faction’s force
|
||||
and leader revivals for the rest of this Revival, including returns from
|
||||
Ghola Treachery. You may choose
|
||||
yourself or an ally, even if the chosen faction is ready or has already
|
||||
completed revivals.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Completed returns and payments stay unchanged. This power does not
|
||||
remove returned pieces or refund spice.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Provisional rule: Ghola Treachery is included during the
|
||||
affected Revival, and this prevention expires when the phase ends. It
|
||||
does not stop Ghola outside Revival.
|
||||
</p>
|
||||
<p className="fine">
|
||||
The Emperor’s native special Karama remains protected by the printed
|
||||
immunity of special Karama powers.
|
||||
</p>
|
||||
{offer.pending && (
|
||||
<p className="fine">Only the original recipient can be selected during this pending revival.</p>
|
||||
)}
|
||||
{blocked && <p className="notice" id="tleilaxu-special-karama-unavailable">{blocked}</p>}
|
||||
{!offer.cards.length && (
|
||||
<p className="muted">No spendable Karama is currently available for this special power.</p>
|
||||
)}
|
||||
{!offer.targets.length && (
|
||||
<p className="muted">No faction is currently available for this revival-prevention power.</p>
|
||||
)}
|
||||
{offer.targets.map((target) => {
|
||||
const name = game.players.find((player) => player.id === target.player)?.name ?? 'the selected faction';
|
||||
const targetReasonId = `tleilaxu-special-karama-${target.player}-unavailable`;
|
||||
const describedBy = [
|
||||
blocked ? 'tleilaxu-special-karama-unavailable' : undefined,
|
||||
target.blocked ? targetReasonId : undefined,
|
||||
].filter(Boolean).join(' ') || undefined;
|
||||
const disabled = busy || !!blocked || !!target.blocked;
|
||||
return (
|
||||
<div key={target.player} className="space-y-2">
|
||||
{target.blocked && <p className="notice" id={targetReasonId}>{name}: {target.blocked}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{offer.cards.map((card) => (
|
||||
<Button
|
||||
key={card}
|
||||
data-testid={`tleilaxu-special-karama-${card}-${target.player}`}
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
onClick={() => {
|
||||
if (!disabled) act({ type: 'card', mode: 'special', card, target: target.player });
|
||||
}}
|
||||
>
|
||||
Spend special Karama · prevent revival for {name}
|
||||
<ArrowRight size={15} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+21
-7
@@ -16,6 +16,27 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Tleilaxu proactive Revival prevention — 24 October. Prototyped / Partial.**
|
||||
The native Advanced special Karama now selects any one player at a quiet Revival
|
||||
boundary, including self, ally, ready and prior-return targets. The original
|
||||
pending normal-revival interruption remains target-bound and can be declined
|
||||
without losing later quiet use. Completed returns, payments and quotas stay
|
||||
intact. The selected provisional composition includes Ghola during the remaining
|
||||
Revival, then expires at phase end; Emperor special revival retains printed
|
||||
immunity and the unresolved low-Homeworld guard remains.
|
||||
[Source, implementation and limits](TLEILAXU_SPECIAL_KARAMA.md).
|
||||
|
||||
Disjoint control/native-fixture contributions, bounded source review and root
|
||||
integration pass **14 files148/148**, types/lint/build, six actual control/engine/
|
||||
GameTable SSR journeys and four existing-profile paths. Genuine111-action public
|
||||
Advanced setup, paid shipments and battle earn original Karama/Ghola and twelve
|
||||
Atreides Tanks; a prior-free source takes113 actions. Feedback updates the old
|
||||
provisional Ghola bypass and corrects quota-exhaustion assertions and one const
|
||||
lint issue. After the private smoke-driver correction, unchanged-source test,
|
||||
type/lint and build results are reused explicitly. No AI refinement, full-mode,
|
||||
comprehensive/browser/deployed or save/recovery/privacy/custody certificate;
|
||||
no saved-room/D1/reset/restart/deployment operation.
|
||||
|
||||
**Truthtrance unrevealed Face Dancer facts — 24 October. Prototyped / Partial.**
|
||||
The shared current-fact author can now ask whether a respondent holds a named
|
||||
identity as an unrevealed Face Dancer. Actual current cards determine the answer;
|
||||
@@ -35,13 +56,6 @@ fit the fixture. No full-mode, broader mixed/future-question, comprehensive/
|
||||
browser/deployed or AI calibration certificate; no saved-room/D1/reset/restart/
|
||||
deployment operation.
|
||||
|
||||
Next concrete native content candidate: supplied physical38 H permits the
|
||||
Tleilaxu special Karama during Revival, while native preflight currently waits
|
||||
for a pending normal request. Its proactive window needs implementation;
|
||||
Ghola/Emperor-special exceptions remain separately recorded provisional scope.
|
||||
Inspect the existing [native special cases](../tests/tleilaxu-special.test.ts)
|
||||
before changing that contract.
|
||||
|
||||
**Moritani captured and foreign-ghola targets — 24 October. Prototyped / Partial.**
|
||||
Assassinate now accepts the opposing printed faction's valid captured or ordinary
|
||||
foreign-ghola target. Pending choices show uniform printed potential bounty;
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — Tleilaxu proactive Revival prevention
|
||||
|
||||
Implement supplied physical38 H’s any-player Revival window through a focused
|
||||
quote, native owner-only projection, persistent card/target controls and the
|
||||
existing special-intent pipeline. Quiet intents have no pending request;
|
||||
interrupted intents bind the original complete declaration. Revalidation rejects
|
||||
a replaced source before spending. One original Karama and the game-long use
|
||||
receipt are consumed; no completed return, payment, income or allowance changes.
|
||||
Existing pending decline and low-Homeworld overlap safeguards remain.
|
||||
|
||||
The 14 October selected textual composition includes Ghola Treachery during the
|
||||
affected remaining Revival and expires afterward. This explicitly supersedes
|
||||
the old provisional bypass, without claiming a combined publisher FAQ. Printed
|
||||
Special Karama immunity keeps Emperor’s native special revival legal. Both
|
||||
ordinary-card availability and the shared Ghola effect enforce the same clock.
|
||||
Minimal bots reuse their public-Tanks thresholds through the owner quote.
|
||||
|
||||
Frozen **14 files148/148**, types/lint/build, six actual control/engine/GameTable
|
||||
SSR journeys and four existing-profile paths pass. Genuine111-action public
|
||||
Advanced native setup, original paid shipments and battle provide original
|
||||
Karama/Ghola and twelve Atreides Tanks; prior-free chronology takes113. Quiet,
|
||||
pending, prior-free, prior-paid, ready and self branches continue through actual
|
||||
phase5 Ghola without using normal quota. No played hand, phase, casualty,
|
||||
wallet or special-use receipt is installed in that source.
|
||||
|
||||
Initial feedback is145/148: two old Ghola-bypass expectations and an exhausted
|
||||
normal-quota error expectation. Three focused repairs pass; the frozen union
|
||||
then passes148/148. Types pass and lint catches one newly constant test variable;
|
||||
that correction is included in the final frozen source. The private smoke driver
|
||||
also needed the same quota-error correction and accurate pending-branch action
|
||||
recording. Application bytes stay unchanged, so its passed union/types/lint/build
|
||||
are explicitly reused with the repaired runtime proof rather than repeated.
|
||||
|
||||
[Contract and remaining boundaries](TLEILAXU_SPECIAL_KARAMA.md). Private evidence:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-tleilaxu-special-karama/`.
|
||||
Frozen source fingerprint:
|
||||
`ea3f7b9de32d42208ea12cdafa5f581a2de0fee3638e307adade21591e6bbf7a`
|
||||
(2,728 nonignored files), based on `6ea691255af68003b900a490256b3d2381b32322`.
|
||||
Final prose/reference overlay is separately validated and hashed. Broader modes,
|
||||
combinations, comprehensive assurance, browser/mobile/deployed acceptance and AI
|
||||
refinement remain open. No saved-room/D1/reset/restart/deployment operation.
|
||||
|
||||
## 24 October 2026 — Verified current Face Dancer questions
|
||||
|
||||
Add a distinct Truthtrance current unrevealed Face Dancer identity fact. Exact
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Rule decision index
|
||||
|
||||
## Tleilaxu proactive Revival prevention — 24 October 2026
|
||||
|
||||
Supplied Advanced physical38 H permits selecting any one player during Revival,
|
||||
without requiring a pending request, unused allowance or current Tanks. Keep the
|
||||
original pending normal-revival interruption target-bound; decline preserves a
|
||||
later quiet declaration. Neither path reverses completed returns or payments.
|
||||
The printed Special Karama immunity (physical16/39) protects Emperor’s native
|
||||
special revival (physical31 D).
|
||||
|
||||
Under the 14 October recommended-policy authority, prevention also covers Ghola
|
||||
Treachery returns during the remaining affected Revival and expires outside that
|
||||
phase. Physical39’s Ghola erratum supplies free immediate returns and a separate
|
||||
normal allowance, without naming prevention immunity. This is an explicit textual
|
||||
composition, not a newly retrieved combined publisher FAQ. It supersedes the old
|
||||
provisional Ghola bypass. The unresolved low-Homeworld overlap guard remains.
|
||||
[Source, connected controls and limits](TLEILAXU_SPECIAL_KARAMA.md).
|
||||
|
||||
## Truthtrance unrevealed Face Dancer fact — 24 October 2026
|
||||
|
||||
Compose the publisher FAQ's game-related Yes/No/AND/OR permission with native
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Tleilaxu Special Karama during Revival
|
||||
|
||||
## Source and selected scope — 24 October 2026
|
||||
|
||||
Status: **Prototyped / Partial**. The supplied authorized Advanced rulebook’s
|
||||
physical38 H permits a Karama during Revival to prevent any one player from
|
||||
reviving forces or leaders. It imposes no pending-request, unused-allowance,
|
||||
Tanks-count or opponent-only prerequisite. The native Advanced owner can now
|
||||
declare at a quiet phase4 boundary, including after a prior return or after the
|
||||
target marks ready. Self and ally selection remain legal. The original normal
|
||||
revivalStop decision is retained and binds only its pending recipient.
|
||||
|
||||
Physical16/39 says Karama cannot stop a Special Karama Power. Emperor’s native
|
||||
special revival (physical31 D) therefore remains immune. Physical39’s Ghola
|
||||
erratum allows free immediate own-leader or up-to-five-force returns regardless
|
||||
of normal leader eligibility and without using normal revival allowance; it
|
||||
does not expressly confer prevention immunity.
|
||||
|
||||
The 14 October choose-recommended authority selects this **provisional textual
|
||||
composition**: prevention includes Ghola Treachery during the remainder of the
|
||||
affected Revival Phase and expires outside that phase. Ghola during Shipment
|
||||
and Movement or Battle stays legal. This replaces the earlier provisional Ghola
|
||||
bypass; it is not a direct combined GF9 FAQ ruling. No already completed return,
|
||||
payment, income or allowance is undone. Low-population Homeworld overlap remains
|
||||
guarded under its existing unresolved contract. Wider combinations and complete
|
||||
rules/mode acceptance remain unfinished.
|
||||
|
||||
## Connected implementation
|
||||
|
||||
- [Shared quote](../game/tleilaxu-special-karama.ts) takes current spendable
|
||||
physical cards, seated identities, enclosing priorities and target blocks.
|
||||
Readiness, public Tanks and hidden leader eligibility do not gate selection.
|
||||
- [Engine](../game/engine.ts) supplies the owner-only native Advanced phase4
|
||||
projection and revalidates the same quote before spending. Quiet intents carry
|
||||
no pending revival; interrupted intents bind the complete original request.
|
||||
A newly pending request cannot replace a quiet intent before execution.
|
||||
- [Controls](../components/tleilaxu-special-karama.tsx) expose actual card/target
|
||||
actions in the quiet window and the existing pending decision. Pending decline
|
||||
remains separate. The guide persists with the normal table; it adds no
|
||||
confirmation, automatic delay or turn advancement.
|
||||
- [Ordinary card availability](../game/card-availability.ts) and the engine’s
|
||||
shared Ghola executor both apply the current-phase prevention receipt. The
|
||||
existing [revival clock](../game/revival.ts) retains phase/turn/recipient scope.
|
||||
- [Minimal bots](../game/bots.ts) reuse their original public-Tanks thresholds
|
||||
and pending-decision preference through the same projection. This supplies
|
||||
legal participation, without strategy refinement or difficulty calibration.
|
||||
|
||||
The original card is discarded once and the game-long special-use receipt is
|
||||
spent once. A stopped pending request returns no pieces and consumes no payment,
|
||||
normal allowance, elite quota or Emperor extra quota. Prior completed income and
|
||||
returns remain recorded. No room, save, schema, start-mode or release gate changes.
|
||||
|
||||
## Bounded verification
|
||||
|
||||
[Native fixture](../tests/fixture-tleilaxu-special-karama.ts) starts an actual
|
||||
public Advanced Tleilaxu/Atreides/Emperor preview with the Ix deck. Only unopened
|
||||
original Treachery/Spice ordering and scalar shuffle order are selected. Native
|
||||
setup deals the original Karama and Ghola. Paid one-force Tleilaxu and two-force
|
||||
Atreides shipments put twelve Atreides counters into Arrakeen. An actual unarmed
|
||||
Hidar4/Yueh1 battle sends those twelve counters to Tanks, then original phases
|
||||
reach turn2 Revival. No phase, hand, wallet, casualty or played army is installed.
|
||||
An optional branch actually completes a free return before declaration.
|
||||
|
||||
[Native window regressions](../tests/tleilaxu-special-karama-window.test.ts) cover
|
||||
quiet, self/ready, completed paid/free return, original interruption, stale quiet
|
||||
intent, actual phase expiry and the four existing profiles.
|
||||
[Quote cases](../tests/tleilaxu-special-karama-quote.test.ts) cover ownership,
|
||||
window, priority and independent target blocks.
|
||||
[Control cases](../tests/tleilaxu-special-karama-controls.test.tsx) invoke the
|
||||
actual callbacks and verify readable restrictions.
|
||||
[Existing special cases](../tests/tleilaxu-special.test.ts) retain negotiated,
|
||||
KH, elite, Emperor and pending source coverage, with the Ghola expectation cut
|
||||
over explicitly.
|
||||
|
||||
Frozen related union: **14 files148/148**, types/lint and production build pass.
|
||||
Six actual component-callback/engine/GameTable SSR journeys cover quiet, pending,
|
||||
prior-free, prior-paid, ready and self choices, each continuing through actual
|
||||
phase5 Ghola. Four existing profiles submit accepted proactive prevention and
|
||||
continue without strategic tuning. Native source takes111 actions; prior-free
|
||||
source takes113. Initial feedback repairs two stale Ghola-bypass expectations,
|
||||
a quota-exhaustion assertion and one const lint issue. A private smoke-driver
|
||||
correction leaves application bytes unchanged; the final runtime-only proof
|
||||
explicitly reuses the passed source-identical union/types/lint/build.
|
||||
|
||||
Private reports, rendered HTML and hashes:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-tleilaxu-special-karama/`.
|
||||
`frozen-integrated-4/report.json` records reused checks from frozen-integrated-3
|
||||
and the passing repaired runtime; source fingerprint is
|
||||
`ea3f7b9de32d42208ea12cdafa5f581a2de0fee3638e307adade21591e6bbf7a`
|
||||
on base `6ea691255af68003b900a490256b3d2381b32322`. Prose/reference evidence is
|
||||
reviewed and hashed separately after verification.
|
||||
|
||||
These are focused prototype proofs. Comprehensive rules, combinations,
|
||||
save/recovery/privacy/custody assurance, browser/mobile and deployed acceptance
|
||||
remain due after all rules. No existing saved game is reset or deleted.
|
||||
+13
-2
@@ -2302,12 +2302,15 @@ function policyActions(g: GameView): Action[] {
|
||||
target.id !== me.ally &&
|
||||
(target.tanks >= [12, 8, 5, 3][level] ||
|
||||
(level > 0 && d.revival === 'leader' && noLeader));
|
||||
if (specialCard && shouldStop && !g.revival.specialKaramaBlock)
|
||||
const offer = g.tleilaxuSpecialKarama;
|
||||
const card = offer?.cards[0];
|
||||
const allowed = offer?.targets.some(choice => choice.player === target.id && !choice.blocked);
|
||||
if (card && offer && !offer.blocked && allowed && shouldStop)
|
||||
return [
|
||||
{
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: specialCard.id,
|
||||
card,
|
||||
target: target.id,
|
||||
},
|
||||
];
|
||||
@@ -3129,6 +3132,14 @@ function policyActions(g: GameView): Action[] {
|
||||
: [{ type: 'passBid' }];
|
||||
}
|
||||
if (g.phase === 4) {
|
||||
// Reuse the existing public-Tanks preference, with shared current permission.
|
||||
const prevention = g.tleilaxuSpecialKarama;
|
||||
if (prevention && !prevention.blocked && !prevention.pending && prevention.cards.length) {
|
||||
const target = g.players.find(player => player.id !== me.id && player.id !== me.ally &&
|
||||
player.tanks >= [12, 8, 5, 3][level] &&
|
||||
prevention.targets.some(choice => choice.player === player.id && !choice.blocked));
|
||||
if (target) return [{ type: 'card', mode: 'special', card: prevention.cards[0], target: target.id }];
|
||||
}
|
||||
if (g.revival.prevented)
|
||||
return g.ready.includes(me.id) ? [] : [{ type: 'ready' }];
|
||||
const actions: Action[] = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TERRITORIES } from './board';
|
||||
import { harvesterAvailable, type Card } from './cards';
|
||||
import type { FactionId } from './catalog';
|
||||
import { choamSaleGholaTiming } from './choam-market-ghola';
|
||||
import { revivalPrevented } from './revival';
|
||||
|
||||
export type OrdinaryCardEffect =
|
||||
| 'weather'
|
||||
@@ -26,6 +27,8 @@ export type OrdinaryCardPlayer = {
|
||||
export type OrdinaryCardState = {
|
||||
status: 'lobby' | 'setup' | 'playing' | 'finished';
|
||||
phase: number;
|
||||
turn?: number;
|
||||
revivalPrevention?: { player: string; turn: number } | null;
|
||||
active: string | null;
|
||||
me: string;
|
||||
nexusGuildCunning?: { active: { hajrAvailable: boolean } | null } | null;
|
||||
@@ -102,6 +105,11 @@ export function ordinaryCardAvailability(
|
||||
return blocked('Select a special card in your hand.');
|
||||
|
||||
switch (card.effect as OrdinaryCardEffect) {
|
||||
case 'ghola':
|
||||
if (revivalPrevented({ phase: state.phase, turn: state.turn ?? 0,
|
||||
revivalPrevention: state.revivalPrevention ?? undefined }, own.id))
|
||||
return blocked('Tleilaxu prevented this player’s revival, including Ghola, for the remaining Revival Phase.');
|
||||
break;
|
||||
case 'hajr':
|
||||
if (
|
||||
state.phase !== 5 ||
|
||||
|
||||
+40
-20
@@ -11,6 +11,7 @@ import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, home
|
||||
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
|
||||
import { quoteAtreidesFullPlan } from './atreides-full-plan';
|
||||
import { quoteTleilaxuSpecialKarama } from './tleilaxu-special-karama';
|
||||
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
|
||||
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
|
||||
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
|
||||
@@ -23289,6 +23290,8 @@ function playMarketGhola(g: Game, p: Player, action: Action) {
|
||||
stageOrdinaryCardDiscard(g, p, used);
|
||||
}
|
||||
function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
|
||||
requireRule(!revivalPrevented(g, p.id),
|
||||
'Tleilaxu prevented this player’s revival, including Ghola, for the remaining Revival Phase.');
|
||||
if (action.leader === 'kwisatz') {
|
||||
requireRule(
|
||||
g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
|
||||
@@ -30811,6 +30814,27 @@ function atreidesFullPlanView(g: Game, p: Player) {
|
||||
return { ...quote, cards,
|
||||
blocked: quote.blocked ?? (cards.length ? null : 'A spendable Karama card is required.') };
|
||||
}
|
||||
function tleilaxuSpecialKaramaQuote(g: Game, p: Player) {
|
||||
const ownStop = g.decision?.kind === 'revivalStop' && g.decision.player === p.id &&
|
||||
g.pendingRevival?.player === g.decision.recipient;
|
||||
return quoteTleilaxuSpecialKarama({
|
||||
status: g.status, phase: g.phase, advanced: g.advanced, owner: p,
|
||||
players: g.players,
|
||||
cards: p.hand.filter(card => canUseAsKaramaRole(g, p, card) &&
|
||||
!karamaSpendingBlock(g, p, card, kullSpecialPurpose(p.faction))).map(card => card.id),
|
||||
pendingPlayer: g.pendingRevival?.player ?? null,
|
||||
blocked: g.truthtrance || g.response || (g.decision && !ownStop) ||
|
||||
(g.pendingRevival && !ownStop) || g.phaseOpening || g.pendingKarama ||
|
||||
g.choamMarket || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingKull
|
||||
? 'Resolve the current response, decision or card effect before declaring revival prevention.' : null,
|
||||
targetBlocks: Object.fromEntries(g.players.map(player =>
|
||||
[player.id, homeworldRevivalKaramaBlock(g, player.id)])),
|
||||
});
|
||||
}
|
||||
function tleilaxuSpecialKaramaView(g: Game, p: Player) {
|
||||
return g.status === 'playing' && g.advanced && p.faction === 'tleilaxu' && g.phase === 4
|
||||
? tleilaxuSpecialKaramaQuote(g, p) : null;
|
||||
}
|
||||
function ecazSpecialKaramaContext(g: Game, p: Player) {
|
||||
const b = g.battle;
|
||||
return { status: g.status, phase: g.phase, advanced: g.advanced, owner: p,
|
||||
@@ -30867,7 +30891,7 @@ export type SpecialKaramaIntent = {
|
||||
| { kind: 'choam'; cards: string[] }
|
||||
| { kind: 'richese'; acquire: string; nexusEvent?: string }
|
||||
| { kind: 'ixians'; route: string[]; collect: boolean; sandmasterPiles?: string[] }
|
||||
| { kind: 'tleilaxu'; target: string; revival: PendingRevival }
|
||||
| { kind: 'tleilaxu'; target: string; revival: PendingRevival | null }
|
||||
| { kind: 'fremen'; territory: string }
|
||||
| {
|
||||
kind: 'atreides';
|
||||
@@ -31037,29 +31061,21 @@ export function prepareSpecialKaramaIntent(
|
||||
...(action.sandmasterPiles !== undefined ? { sandmasterPiles } : {}),
|
||||
};
|
||||
} else if (p.faction === 'tleilaxu') {
|
||||
const quote = tleilaxuSpecialKaramaQuote(g, p);
|
||||
requireRule(!quote.blocked, quote.blocked ?? 'Revival prevention is unavailable.');
|
||||
const target = action.target === undefined && g.pendingRevival
|
||||
? g.pendingRevival.player : stringField(action.target);
|
||||
const selection = quote.targets.find(candidate => candidate.player === target);
|
||||
requireRule(
|
||||
g.phase === 4 &&
|
||||
!g.response &&
|
||||
g.decision?.kind === 'revivalStop' &&
|
||||
g.decision.player === p.id &&
|
||||
g.pendingRevival,
|
||||
'Wait for a normal revival declaration before preventing it.',
|
||||
);
|
||||
const target = g.pendingRevival.player;
|
||||
const homeworldBlock = homeworldRevivalKaramaBlock(g, target);
|
||||
requireRule(
|
||||
!homeworldBlock,
|
||||
homeworldBlock ?? '',
|
||||
);
|
||||
requireRule(
|
||||
action.target === undefined || action.target === target,
|
||||
'This decision applies to the faction currently reviving.',
|
||||
selection,
|
||||
g.pendingRevival ? 'This decision applies to the faction currently reviving.' : 'Choose a player to prevent during Revival.',
|
||||
);
|
||||
requireRule(!selection.blocked, selection.blocked ?? 'This target is unavailable.');
|
||||
return {
|
||||
...base,
|
||||
kind: 'tleilaxu',
|
||||
target,
|
||||
revival: structuredClone(g.pendingRevival),
|
||||
revival: g.pendingRevival ? structuredClone(g.pendingRevival) : null,
|
||||
};
|
||||
} else if (p.faction === 'fremen') {
|
||||
requireRule(
|
||||
@@ -31265,7 +31281,8 @@ export function executeSpecialKaramaIntent(
|
||||
if (intent.kind === 'tleilaxu' && prepared.kind === 'tleilaxu')
|
||||
requireRule(
|
||||
intent.target === prepared.target &&
|
||||
sameDeclaration(intent.revival, prepared.revival),
|
||||
(intent.revival === null ? prepared.revival === null :
|
||||
prepared.revival !== null && sameDeclaration(intent.revival, prepared.revival)),
|
||||
'The original revival declaration is no longer pending.',
|
||||
);
|
||||
if (intent.kind === 'guild' && prepared.kind === 'guild')
|
||||
@@ -31409,7 +31426,7 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
|
||||
if (g.revivalRequests) delete g.revivalRequests[target];
|
||||
log(
|
||||
g,
|
||||
`${p.name} used special Karama to prevent ${getPlayer(g, target).name}’s normal force and leader revivals for this turn.`,
|
||||
`${p.name} used special Karama to prevent ${getPlayer(g, target).name}’s force and leader revivals, including Ghola, for the remaining Revival Phase. Completed returns stay unchanged; the Emperor’s special Karama remains independent.`,
|
||||
);
|
||||
} else if (intent.kind === 'fremen') {
|
||||
const t = intent.territory;
|
||||
@@ -36003,6 +36020,8 @@ function applyActionInner(
|
||||
{
|
||||
status: g.status,
|
||||
phase: g.phase,
|
||||
turn: g.turn,
|
||||
revivalPrevention: g.revivalPrevention,
|
||||
active: g.active,
|
||||
me: id,
|
||||
nexusGuildCunning: projectedNexusGuildCunning(g,id),
|
||||
@@ -37176,6 +37195,7 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
richeseSpecialKarama: richeseSpecialKaramaView(g, me),
|
||||
atreidesFullPlan: atreidesFullPlanView(g, me),
|
||||
tleilaxuSpecialKarama: tleilaxuSpecialKaramaView(g, me),
|
||||
...(me.faction === 'ecaz' && g.advanced && g.battle
|
||||
? { ecazSpecialKarama: ecazSpecialKaramaView(g, me) } : {}),
|
||||
...(g.moritaniSpecialKarama?.source.owner === me.id && g.moritaniSpecialKarama.stage === 'offer'
|
||||
|
||||
+13
-5
@@ -66,7 +66,7 @@ export const HELP = {
|
||||
ixForces:
|
||||
'Suboids always dial one-half strength and cannot receive spice support. Cyborgs count two in basic play; in advanced play they need one spice each for strength two and otherwise count one. After losses, surviving suboids may replace cyborgs lost in that battle, one for one.',
|
||||
tleilaxuSpecial:
|
||||
'Once per game, Tleilaxu can spend a real Karama at a normal revival declaration to prevent that faction’s normal force and leader revivals for this turn. The attempt costs no spice and returns no pieces. Ghola treachery and Emperor special Karama remain separate; full timing and exception audits are pending.',
|
||||
'Once per game during Revival, Tleilaxu may spend a real Karama to prevent any one player’s remaining force and leader revivals, before a request or at its normal revival decision. Completed returns and payments stay unchanged. Provisional scope includes Ghola during this Revival and expires with the phase; Emperor special Karama remains protected. Low-Homeworld overlap and wider interactions remain unfinished.',
|
||||
techTokens:
|
||||
'Tech tokens are public industries. Their income is paid once at the end of the matching phase, in an amount equal to the owner’s token count. A battle winner takes one from the loser. All three must belong to one player to count as a stronghold.',
|
||||
summonedWorm:
|
||||
@@ -2665,6 +2665,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
coverage: 'Partial',
|
||||
summary: 'Return forces or a leader from the tanks using the card.',
|
||||
steps: [
|
||||
'During Revival, Tleilaxu special Karama prevention also blocks this card for the selected player. This inclusion is provisional and ends when Revival ends; completed returns stay unchanged. Ghola remains available afterward, subject to its other timing and target rules.',
|
||||
'Choose a dead leader for leader revival, or leave the leader choice empty to return up to five forces. A card pays for one of these choices; it cannot revive both a leader and the Kwisatz Haderach.',
|
||||
'A binding battle answer or prescience commitment may require this preparation. The private guide identifies a Ghola play that can fulfill the answer, without spending or revealing the card until you choose to play it. Resolve any revival-income response before using that income to support a battle.',
|
||||
'A Basic ground-movement promise can also require Ghola force preparation. The guide returns physical Tanks to reserves, then preserves an unused affordable shipment and one later matching ground move. Ghola itself does not fulfill movement or consume normal revival allowances. A known discarded Ghola may be recovered with the paid Box and played again.',
|
||||
@@ -2714,7 +2715,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
},
|
||||
],
|
||||
related: ['revival'],
|
||||
related: ['revival', 'special-karama'],
|
||||
},
|
||||
{
|
||||
id: 'atreides-full-plan',
|
||||
@@ -3793,13 +3794,20 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The stopped shipment uses the shipper’s shipment opportunity while retaining forces, spice, allied credit and any shipment-rate card; ordinary movement remains available. These settlement details remain provisional pending primary-source clarification. The Guild decision appears regardless of its hidden hand.',
|
||||
'Fremen may call a worm in a sand territory during Spice Blow and Nexus. It destroys spice and unprotected forces immediately. Ally protection and Fremen survival have separate responses; a second Karama may cause Fremen to be devoured. The special summon itself consumes no spice-deck card.',
|
||||
'Resume any interrupted spice draw, fresh-blow window or worm response after the summoned worm resolves. Its Nexus occurs at the end of the blow, followed by eligible Fremen rides. During an open Nexus, the new ride joins the existing queue. Destroyed fresh spice cannot later be doubled by Harvester.',
|
||||
'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.',
|
||||
'During a quiet Revival boundary, Tleilaxu may spend its once-per-game special Karama to select any one player, including itself, an ally or a ready faction. It also retains the original decision before another faction’s normal revival completes, independently of card holdings. Declining that decision continues the original return and preserves later quiet use. A pending decision can select only its original recipient.',
|
||||
'Prevention stops further force and leader returns in this Revival, including negotiated leaders, KH and Emperor-funded extra forces. It does not undo completed returns, payments or used allowances. A stopped pending request spends no spice or revival quota. Hidden leader identities and negotiated prices remain outside the public decision. Emperor’s special Karama is immune.',
|
||||
'Provisional scope includes Ghola Treachery returns during the affected Revival and ends when that phase ends; Ghola is available afterward. This composes the revised prevention wording with Ghola’s free-return rule, rather than quoting a combined publisher ruling. Low-population Homeworld overlap retains its existing guard. Full expansion and combination acceptance remains open.',
|
||||
'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.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Tleilaxu proactive Revival is prototyped: quiet any-player selection and original target-bound interruption share current physical-cost validation; completed returns stay intact. Ghola inclusion and phase expiry are provisional; Emperor special immunity and the low-Homeworld overlap guard remain.', evidence: ['game/tleilaxu-special-karama.ts', 'game/engine.ts', 'game/card-availability.ts', 'game/revival.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Native Advanced owner has persistent card/target controls before a request and in the pending decision, including self, ally, ready and prior-return choices. Enclosing priorities and target restrictions retain readable reasons.', evidence: ['components/tleilaxu-special-karama.tsx', 'components/game-table.tsx', 'tests/tleilaxu-special-karama-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four existing profiles reuse public-Tanks thresholds through the shared current quote; no strategic refinement or difficulty calibration.', evidence: ['game/bots.ts', 'tests/tleilaxu-special-karama-window.test.ts', 'tests/ghola-bots.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Proactive source cutover, provisional Ghola/phase scope, printed Special Karama immunity and remaining Homeworld/combination limits are explicit.', evidence: ['docs/TLEILAXU_SPECIAL_KARAMA.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Native setup, paid shipment and battle earn the actual cards and twelve Tanks; focused regressions cover declaration, original interruption, prior returns, stale intent and real phase expiry. Frozen14-file148/148, types/lint/build, six actual callback/engine/GameTable SSR journeys and four legal existing-profile paths pass. Genuine111/113-action sources earn original cards and casualties. Private-driver-only repairs reuse unchanged-source checks. Full modes, browser/deployed and final assurance stay open.', evidence: ['tests/fixture-tleilaxu-special-karama.ts', 'tests/tleilaxu-special-karama-window.test.ts', 'tests/tleilaxu-special-karama-quote.test.ts', 'tests/tleilaxu-special.test.ts', 'tests/karama-intents.test.ts', 'tests/ghola-lifecycle.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'card-karama',
|
||||
'richese-acquisition',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FactionId } from './catalog';
|
||||
|
||||
export type TleilaxuSpecialKaramaContext = {
|
||||
status: string;
|
||||
phase: number;
|
||||
advanced: boolean;
|
||||
owner: { id: string; faction: FactionId; specialKaramaUsed?: boolean };
|
||||
players: readonly { id: string }[];
|
||||
/** The authoritative engine supplies current spendable physical card IDs. */
|
||||
cards: readonly string[];
|
||||
/** The original normal-revival interruption, otherwise a quiet declaration. */
|
||||
pendingPlayer: string | null;
|
||||
blocked: string | null;
|
||||
targetBlocks?: Readonly<Record<string, string | null>>;
|
||||
};
|
||||
|
||||
/** Supplied physical p38 H permits choosing any one player during Revival.
|
||||
* Tank count, hidden leader eligibility, prior returns and readiness do not
|
||||
* determine permission. Existing enclosing priorities and target exceptions do.
|
||||
*/
|
||||
export function quoteTleilaxuSpecialKarama(context: TleilaxuSpecialKaramaContext) {
|
||||
let blocked: string | null = null;
|
||||
if (!context.advanced) blocked = 'Special Karama powers require the Advanced game.';
|
||||
else if (context.owner.faction !== 'tleilaxu') blocked = 'Only native Tleilaxu may use this special Karama.';
|
||||
else if (context.status !== 'playing' || context.phase !== 4)
|
||||
blocked = 'Declare this special Karama during the Revival Phase.';
|
||||
else if (context.owner.specialKaramaUsed)
|
||||
blocked = 'Your special Karama power has already been used this game.';
|
||||
else if (context.blocked) blocked = context.blocked;
|
||||
else if (!context.cards.length) blocked = 'A spendable Karama card is required.';
|
||||
const targets = context.players
|
||||
.filter(player => context.pendingPlayer === null || player.id === context.pendingPlayer)
|
||||
.map(player => ({ player: player.id, blocked: context.targetBlocks?.[player.id] ?? null }));
|
||||
if (!blocked && !targets.length) blocked = 'Choose a player in this Revival Phase.';
|
||||
return { blocked, cards: [...context.cards], targets, pending: context.pendingPlayer !== null };
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type TleilaxuSpecialKaramaStep = { actor: string; action: Action };
|
||||
export type TleilaxuSpecialKaramaTransition = {
|
||||
before: Game; steps: TleilaxuSpecialKaramaStep[]; after: Game;
|
||||
};
|
||||
export const tleilaxuSpecialKaramaPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const tleilaxuSpecialKaramaClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
export const tleilaxuSpecialKaramaInventory = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
|
||||
return ids;
|
||||
};
|
||||
|
||||
/** Select original scalar shuffle order. UUIDs and every other native byte
|
||||
* source stay unchanged; no played resource, card hand or casualty is installed. */
|
||||
function originalScalarOrder<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actTleilaxuSpecialKarama(game: Game, actor: string, action: Action,
|
||||
actions: TleilaxuSpecialKaramaStep[] = []): Game {
|
||||
const next = originalScalarOrder(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
/** Native controls only. Preserve Karama/Ghola, decline the optional special
|
||||
* inspection and Face Dance, and allow a pending normal revival by default. */
|
||||
export function nextTleilaxuSpecialKarama(game: Game): TleilaxuSpecialKaramaStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'revivalStop' || decision?.kind === 'fullPlanOffer')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'fullPlanRead')
|
||||
return { actor: decision.player, action: { type: 'decision', continue: true } };
|
||||
if (decision?.kind === 'faceDance')
|
||||
return { actor: decision.player, action: { type: 'decision', reveal: false } };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceTleilaxuSpecialKarama(game: Game, until: (state: Game) => boolean,
|
||||
actions: TleilaxuSpecialKaramaStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine Tleilaxu chronology ended before its requested boundary');
|
||||
const next = nextTleilaxuSpecialKarama(game);
|
||||
assert.ok(next, `Supply the original private plan at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actTleilaxuSpecialKarama(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Tleilaxu special chronology did not reach its finite boundary');
|
||||
}
|
||||
export function passTleilaxuSpecialKarama(game: Game,
|
||||
actions: TleilaxuSpecialKaramaStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTleilaxuSpecialKarama(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.ok(!game.response); return game;
|
||||
}
|
||||
|
||||
/** Genuine public Advanced Tleilaxu/Atreides/Emperor setup. Two paid shipments
|
||||
* make twelve native Atreides counters in Arrakeen; Hidar's real unarmed win
|
||||
* puts all twelve in Tanks. Stop at actual turn-two Revival with the original
|
||||
* Karama and Ghola still held. No card/army/phase/wallet/casualty is assigned. */
|
||||
export function createTleilaxuSpecialKaramaFixture(options: { priorRevival?: boolean } = {}) {
|
||||
const owner = 'tleilaxu', target = 'atreides', observer = 'emperor';
|
||||
let game = createGame('TLEILAXUSPECIALKARAMA', newPlayer(owner, 'Tleilaxu', 'tleilaxu'), true, ['ix']);
|
||||
joinGame(game, newPlayer(target, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: TleilaxuSpecialKaramaStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTleilaxuSpecialKarama(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTleilaxuSpecialKarama(game, until, actions); };
|
||||
const transition = (run: () => void): TleilaxuSpecialKaramaTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
|
||||
assert.deepEqual(game.expansions, ['ix']);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalInventory = tleilaxuSpecialKaramaInventory(game);
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
const ghola = game.deck.find(card => card.effect === 'ghola');
|
||||
const ordinary = game.deck.find(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind));
|
||||
assert.ok(karama && ghola && ordinary);
|
||||
const selected = [karama, ghola, ordinary];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, owner).faceDancers?.length, 3);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).forces['arrakeen:10'], 10);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && tleilaxuSpecialKaramaClean(state));
|
||||
const ownerShipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(tleilaxuSpecialKaramaClean);
|
||||
});
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(ownerShipment.before, owner).spice - tleilaxuSpecialKaramaPlayer(game, owner).spice, 1);
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === target && tleilaxuSpecialKaramaClean(state));
|
||||
const targetShipment = transition(() => {
|
||||
act(target, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(tleilaxuSpecialKaramaClean);
|
||||
});
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(targetShipment.before, target).spice - tleilaxuSpecialKaramaPlayer(game, target).spice, 2);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).forces['arrakeen:10'], 12);
|
||||
reach(state => state.phase === 6 && tleilaxuSpecialKaramaClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
|
||||
.find(choice => choice.territory === 'arrakeen'); assert.ok(choice);
|
||||
const battleStep: TleilaxuSpecialKaramaStep = { actor: choice.chooser,
|
||||
action: { type: 'chooseBattle', territory: 'arrakeen',
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
act(battleStep.actor, battleStep.action);
|
||||
const beforePreparation = structuredClone(game);
|
||||
reach(state => !!state.battle && tleilaxuSpecialKaramaClean(state) &&
|
||||
!state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||
const plans: TleilaxuSpecialKaramaStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: 'tleilaxu-1', dial: 0, support: 0, weapon: null, defense: null } },
|
||||
{ actor: target, action: { type: 'battlePlan', leader: 'atreides-4', dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
for (const step of plans) act(step.actor, step.action);
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => !state.battle && tleilaxuSpecialKaramaClean(state));
|
||||
const afterBattle = structuredClone(game);
|
||||
assert.equal(game.lastBattleContext?.winner, owner);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).tanks, 12);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, owner).tanks, 0);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).leaders.find(leader => leader.id === 'atreides-4')?.dead, false);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && tleilaxuSpecialKaramaClean(state));
|
||||
const beforePriorRevival = structuredClone(game);
|
||||
let priorRevival: TleilaxuSpecialKaramaTransition | null = null;
|
||||
if (options.priorRevival) priorRevival = transition(() => {
|
||||
act(target, { type: 'revive', amount: 1, elite: 0 });
|
||||
reach(tleilaxuSpecialKaramaClean);
|
||||
});
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 4); assert.ok(tleilaxuSpecialKaramaClean(game));
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).tanks, options.priorRevival ? 11 : 12);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).revived, options.priorRevival ? 1 : 0);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).freeForcesRevived, options.priorRevival ? 1 : 0);
|
||||
assert.ok(tleilaxuSpecialKaramaPlayer(game, owner).hand.some(card => card.id === karama.id));
|
||||
assert.ok(tleilaxuSpecialKaramaPlayer(game, target).hand.some(card => card.id === ghola.id));
|
||||
assert.ok(!tleilaxuSpecialKaramaPlayer(game, owner).specialKaramaUsed);
|
||||
assert.deepEqual(tleilaxuSpecialKaramaInventory(game), originalInventory);
|
||||
const special: Action = { type: 'card', card: karama.id, mode: 'special', target };
|
||||
const ordinaryRevival: Action = { type: 'revive', amount: 1, elite: 0 };
|
||||
const gholaAction: Action = { type: 'card', card: ghola.id, amount: 5, elite: 0 };
|
||||
return { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
|
||||
ownerShipment, targetShipment, beforeBattle, battleStep, beforePreparation, plans, revealed, afterBattle,
|
||||
beforePriorRevival, priorRevival, owner, tleilaxu: owner, target, atreides: target, observer, emperor: observer,
|
||||
karama: karama.id, card: karama.id, ghola: ghola.id, special, action: special,
|
||||
ordinaryRevival, gholaAction, prior: !!options.priorRevival,
|
||||
actions, sourceActionCount: actions.length, inventory: originalInventory, originalInventory,
|
||||
originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public native Advanced Tleilaxu/Atreides/Emperor ready/startPreview, private Traitor choices and original three-card Face Dancer deal. No optional module or changed rules mode is introduced.',
|
||||
'Select only original scalar shuffle order, three original unopened Treachery positions and four original unopened ordinary Spice positions. Tleilaxu really receives its starting Karama and Atreides its starting Ghola; neither card is played during the source.',
|
||||
'Tleilaxu actually pays one to ship one reserve into Arrakeen. Atreides actually pays two to ship two reserves into its original ten home counters. Native first Storm, dual Spice, Charity, auction and Revival controls all execute.',
|
||||
'The real Arrakeen battle seals unarmed Hidar4 and Yueh1 at dial0/support0. The native Tleilaxu victory puts all twelve original Atreides counters into Tanks without killing either leader or costing Tleilaxu a force. Actual native Battle loss/Kwisatz history remains.',
|
||||
'Continue real Collection, Mentat, second Storm/dual Spice, Charity and Bidding into clean second Revival, retaining original Karama/Ghola. No phase, army, wallet, card hand, Tanks, prevention or receipt is assigned.',
|
||||
options.priorRevival
|
||||
? 'Before the special choice, Atreides actually returns one own counter free. Tleilaxu declines the native revivalStop and original response passes settle its normal income; the completed return remains earned state.'
|
||||
: 'Stop before any normal revival request or special Karama. Native revivalStop and later ordinary/Ghola continuations remain caller actions.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TleilaxuSpecialKaramaFixture = ReturnType<typeof createTleilaxuSpecialKaramaFixture>;
|
||||
|
||||
/** Optional native partial-return branch. The initial requested snapshot keeps
|
||||
* the real revivalStop available for explicit prevention or ordinary decline. */
|
||||
export function reviveTleilaxuSpecialKarama(source: Game, fixture: TleilaxuSpecialKaramaFixture,
|
||||
amount = 1, actions: TleilaxuSpecialKaramaStep[] = []) {
|
||||
const before = structuredClone(source), start = actions.length;
|
||||
const step: TleilaxuSpecialKaramaStep = { actor: fixture.target, action: { type: 'revive', amount, elite: 0 } };
|
||||
const requested = actTleilaxuSpecialKarama(source, step.actor, step.action, actions);
|
||||
const game = advanceTleilaxuSpecialKarama(requested, tleilaxuSpecialKaramaClean, actions);
|
||||
return { game, before, step, requested, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
|
||||
}
|
||||
export function finishTleilaxuSpecialKarama(source: Game, actions: TleilaxuSpecialKaramaStep[] = []): Game {
|
||||
return advanceTleilaxuSpecialKarama(source, game => game.phase === 5 && tleilaxuSpecialKaramaClean(game), actions);
|
||||
}
|
||||
@@ -223,7 +223,7 @@ void test('foreign-ghola and negotiated early-revival bot candidates explicitly
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('ordinary revival prevention does not itself prevent independent Ghola recovery', () => {
|
||||
void test('bots respect Ghola prevention during Revival and can use it after phase expiry', () => {
|
||||
const { g, card } = fixture();
|
||||
g.phase = 4;
|
||||
g.active = null;
|
||||
@@ -231,7 +231,14 @@ void test('ordinary revival prevention does not itself prevent independent Ghola
|
||||
g.revivalPrevention = { player: 'p', turn: g.turn };
|
||||
for (const level of DIFFICULTIES) {
|
||||
assert.equal(view(g, level).revival.prevented, true);
|
||||
const { a, done } = apply(g, level);
|
||||
assert.equal(view(g, level).ghola.available, false);
|
||||
const blocked = apply(g, level);
|
||||
assert.notEqual(blocked.a.card, card.id);
|
||||
assert.ok(blocked.done.players[0].hand.some(c => c.id === card.id));
|
||||
const expired = structuredClone(g);
|
||||
expired.phase = 5;
|
||||
expired.active = 'p';
|
||||
const { a, done } = apply(expired, level);
|
||||
assert.equal(a.card, card.id);
|
||||
assert.equal(done.players[0].reserves, 5);
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ void test('Ghola options are own-hand only and ignore opponents private spice ca
|
||||
assert.deepEqual(viewGame(g, 'p').ghola, a);
|
||||
assert.deepEqual(before.players[0], g.players[0]);
|
||||
});
|
||||
void test('Ghola respects pending table windows while normal revival prevention does not block it', () => {
|
||||
void test('Ghola respects table windows and Revival prevention, which expires outside Revival', () => {
|
||||
const { g, card } = fixture();
|
||||
Object.assign(g.players[0], {
|
||||
tanks: 5,
|
||||
@@ -243,6 +243,13 @@ void test('Ghola respects pending table windows while normal revival prevention
|
||||
leaderRevived: true,
|
||||
});
|
||||
g.revivalPrevention = { player: 'p', turn: 2 };
|
||||
assert.equal(viewGame(g, 'p').ghola.available, false);
|
||||
const blocked = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'p', { type: 'card', card, amount: 5 }), /prevented.*Ghola/);
|
||||
assert.deepEqual(g, blocked);
|
||||
// Unit timing branch; the native fixture separately proves actual phase exit.
|
||||
g.phase = 5;
|
||||
g.active = 'p';
|
||||
assert.equal(viewGame(g, 'p').ghola.available, true);
|
||||
const done = applyAction(g, 'p', { type: 'card', card, amount: 5 });
|
||||
assert.equal(done.players[0].reserves, 20);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TleilaxuSpecialKarama } from '../components/tleilaxu-special-karama';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import {
|
||||
createGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type GameView,
|
||||
} from '../game/engine';
|
||||
|
||||
// Client projections isolate eligibility controls. The runtime suite covers
|
||||
// physical Karama spending, original pending returns and prevention duration.
|
||||
function projected(): GameView {
|
||||
const game = createGame('TLEILAXUSPECIALCONTROLS', newPlayer('t', 'Tleilaxu', 'tleilaxu'), true, ['ix']);
|
||||
game.players.push(newPlayer('a', 'Ally', 'atreides'), newPlayer('e', 'Ready Emperor', 'emperor'), newPlayer('g', 'Earlier Guild', 'guild'));
|
||||
Object.assign(game, { status: 'playing', phase: 4, turn: 3, order: ['t', 'a', 'e', 'g'] });
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 't';
|
||||
game.players[2].ready = true;
|
||||
game.players[3].revived = 1;
|
||||
game.players[3].freeForcesRevived = 1;
|
||||
const cards = baseDeck().filter((card) => card.effect === 'karama').slice(0, 2);
|
||||
game.players[0].hand = cards;
|
||||
game.deck = baseDeck().filter((card) => !cards.some((held) => held.id === card.id));
|
||||
const view = viewGame(game, 't');
|
||||
view.tleilaxuSpecialKarama = {
|
||||
blocked: null, cards: cards.map((card) => card.id), pending: false,
|
||||
targets: game.players.map((player) => ({ player: player.id, blocked: null })),
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(TleilaxuSpecialKarama, { game, busy, act() {} }));
|
||||
}
|
||||
|
||||
function click(node: ReactNode, selector: string): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
'data-testid'?: string;
|
||||
}>(child)) continue;
|
||||
if (child.props['data-testid'] === selector && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children, selector)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function prevent(game: GameView, card: string, target = 'e', busy = false): Action[] {
|
||||
const actions: Action[] = [];
|
||||
click(TleilaxuSpecialKarama({ game, busy, act(action) { actions.push(action); } }),
|
||||
`tleilaxu-special-karama-${card}-${target}`);
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('actual quiet-window buttons include self, ally, ready and prior-return targets using every projected card', () => {
|
||||
const game = projected();
|
||||
const html = markup(game);
|
||||
assert.match(html, /aria-label="Tleilaxu special Karama revival prevention"/);
|
||||
assert.match(html, /prevent revival for Tleilaxu/);
|
||||
assert.match(html, /prevent revival for Ally/);
|
||||
assert.match(html, /prevent revival for Ready Emperor/);
|
||||
assert.match(html, /prevent revival for Earlier Guild/);
|
||||
assert.equal((html.match(/data-testid="tleilaxu-special-karama-/g) ?? []).length, 8);
|
||||
for (const card of game.tleilaxuSpecialKarama!.cards) {
|
||||
for (const target of game.tleilaxuSpecialKarama!.targets) {
|
||||
assert.deepEqual(prevent(game, card, target.player), [{ type: 'card', mode: 'special', card, target: target.player }]);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(prevent(game, 'stale-card'), []);
|
||||
assert.deepEqual(prevent(game, game.tleilaxuSpecialKarama!.cards[0], 'not-a-target'), []);
|
||||
});
|
||||
|
||||
void test('persistent guidance includes current-Revival Ghola, phase expiry and protected Emperor special without reversing completed returns', () => {
|
||||
const game = projected();
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /once-per-game special Karama/);
|
||||
assert.match(html, /force and leader revivals for the rest of this Revival, including returns from Ghola Treachery/);
|
||||
assert.match(html, /yourself or an ally/);
|
||||
assert.match(html, /even if the chosen faction is ready or has already completed revivals/);
|
||||
assert.match(html, /Completed returns and payments stay unchanged/);
|
||||
assert.match(html, /does not remove returned pieces or refund spice/);
|
||||
assert.match(html, /Provisional rule: Ghola Treachery is included during the affected Revival/);
|
||||
assert.match(html, /prevention expires when the phase ends/);
|
||||
assert.match(html, /does not stop Ghola outside Revival/);
|
||||
assert.match(html, /Emperor’s native special Karama remains protected by the printed immunity of special Karama powers/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('global and per-target projected block reasons remain readable and prevent only the affected actions', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
game.tleilaxuSpecialKarama!.targets.find((target) => target.player === 'e')!.blocked = 'This Homeworld revival combination is not yet available.';
|
||||
const html = markup(game);
|
||||
assert.match(html, /Ready Emperor: This Homeworld revival combination is not yet available/);
|
||||
assert.match(html, /aria-describedby="tleilaxu-special-karama-e-unavailable"/);
|
||||
assert.deepEqual(prevent(game, card, 'e'), []);
|
||||
assert.deepEqual(prevent(game, card, 'a'), [{ type: 'card', mode: 'special', card, target: 'a' }]);
|
||||
game.tleilaxuSpecialKarama!.blocked = 'This once-per-game special Karama has already been used.';
|
||||
assert.match(markup(game), /once-per-game special Karama has already been used/);
|
||||
assert.deepEqual(prevent(game, card, 'a'), []);
|
||||
});
|
||||
|
||||
void test('cardless and targetless projections show useful guidance without inventing actions', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
game.tleilaxuSpecialKarama!.cards = [];
|
||||
assert.match(markup(game), /No spendable Karama is currently available for this special power/);
|
||||
assert.doesNotMatch(markup(game), /data-testid="tleilaxu-special-karama-/);
|
||||
assert.deepEqual(prevent(game, card), []);
|
||||
game.tleilaxuSpecialKarama!.targets = [];
|
||||
assert.match(markup(game), /No faction is currently available for this revival-prevention power/);
|
||||
});
|
||||
|
||||
void test('the owned pending revival-stop uses its original projected recipient without adding a decline or acknowledgement', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
game.tleilaxuSpecialKarama!.pending = true;
|
||||
game.tleilaxuSpecialKarama!.targets = [{ player: 'e', blocked: null }];
|
||||
game.decision = { kind: 'revivalStop', player: 't', recipient: 'e', revival: 'forces' };
|
||||
const html = markup(game);
|
||||
assert.match(html, /Only the original recipient can be selected during this pending revival/);
|
||||
assert.deepEqual(prevent(game, card), [{ type: 'card', mode: 'special', card, target: 'e' }]);
|
||||
assert.deepEqual(prevent(game, card, 'a'), []);
|
||||
assert.doesNotMatch(html, /Allow this revival|Decline|Acknowledge|Confirm prevention/);
|
||||
game.decision = null;
|
||||
assert.deepEqual(prevent(game, card), []);
|
||||
});
|
||||
|
||||
void test('busy and other current priorities block actual callbacks while retaining the guide', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
assert.deepEqual(prevent(game, card, 'e', true), []);
|
||||
assert.equal((markup(game, true).match(/<button(?=[^>]*data-testid="tleilaxu-special-karama-)(?=[^>]*disabled="")[^>]*>/g) ?? []).length, 8);
|
||||
for (const field of ['response', 'decision', 'phaseOpening', 'truthtrance', 'nexus'] as const) {
|
||||
const changed = structuredClone(game);
|
||||
if (field === 'response') changed.response = {} as NonNullable<GameView['response']>;
|
||||
if (field === 'decision') changed.decision = { kind: 'revivalStop', player: 'e', recipient: 'e', revival: 'forces' };
|
||||
if (field === 'phaseOpening') changed.phaseOpening = {} as NonNullable<GameView['phaseOpening']>;
|
||||
if (field === 'truthtrance') changed.truthtrance = {} as NonNullable<GameView['truthtrance']>;
|
||||
if (field === 'nexus') changed.nexusTraitors = {
|
||||
offer: null, pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
|
||||
};
|
||||
assert.deepEqual(prevent(changed, card), [], field);
|
||||
assert.match(markup(changed), /Finish the current response, decision or phase opening/, field);
|
||||
}
|
||||
});
|
||||
|
||||
void test('only the native Advanced Tleilaxu owner receives these phase-four controls', () => {
|
||||
for (const field of ['projection', 'basic', 'viewer', 'faction', 'phase', 'status'] as const) {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
if (field === 'projection') game.tleilaxuSpecialKarama = null;
|
||||
if (field === 'basic') game.advanced = false;
|
||||
if (field === 'viewer') game.me = 'a';
|
||||
if (field === 'faction') game.players[0].faction = 'atreides';
|
||||
if (field === 'phase') game.phase = 5;
|
||||
if (field === 'status') game.status = 'lobby';
|
||||
assert.equal(markup(game), '', field);
|
||||
assert.deepEqual(prevent(game, card), [], field);
|
||||
}
|
||||
});
|
||||
|
||||
void test('eligibility controls never inspect private hands, Tanks, leader liveness, readiness or revival counters', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
for (const player of game.players) {
|
||||
for (const field of ['hand', 'spice', 'tanks', 'leaders', 'ready', 'revived', 'freeForcesRevived', 'leaderRevived']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Revival-prevention UI read ${player.id} private ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.match(markup(game), /prevent revival for Ready Emperor/);
|
||||
assert.deepEqual(prevent(game, card, 't'), [{ type: 'card', mode: 'special', card, target: 't' }]);
|
||||
assert.deepEqual(prevent(game, card, 'g'), [{ type: 'card', mode: 'special', card, target: 'g' }]);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { quoteTleilaxuSpecialKarama as quote, type TleilaxuSpecialKaramaContext } from '../game/tleilaxu-special-karama';
|
||||
|
||||
function context(): TleilaxuSpecialKaramaContext {
|
||||
return { status: 'playing', phase: 4, advanced: true, owner: { id: 't', faction: 'tleilaxu' },
|
||||
players: [{ id: 't' }, { id: 'a' }, { id: 'e' }], cards: ['original-karama'],
|
||||
pendingPlayer: null, blocked: null };
|
||||
}
|
||||
void test('quiet targets use only seated identities and preserve independent target restrictions', () => {
|
||||
const source = context(), before = structuredClone(source);
|
||||
source.targetBlocks = { e: 'Unresolved low-population Homeworld overlap.' };
|
||||
const offered = quote(source);
|
||||
assert.equal(offered.blocked, null);
|
||||
assert.deepEqual(offered.targets, [{ player: 't', blocked: null }, { player: 'a', blocked: null },
|
||||
{ player: 'e', blocked: source.targetBlocks.e }]);
|
||||
offered.cards.pop();
|
||||
assert.deepEqual(source.cards, before.cards);
|
||||
});
|
||||
void test('pending original recipient is the only target, and an absent recipient cannot silently retarget', () => {
|
||||
assert.deepEqual(quote({ ...context(), pendingPlayer: 'a' }).targets, [{ player: 'a', blocked: null }]);
|
||||
assert.match(quote({ ...context(), pendingPlayer: 'missing' }).blocked!, /Choose a player/);
|
||||
});
|
||||
void test('mode, native owner, phase, use, priority and spendable card each gate permission', () => {
|
||||
const source = context();
|
||||
for (const change of [
|
||||
{ advanced: false }, { owner: { id: 't', faction: 'guild' as const } },
|
||||
{ status: 'finished' }, { phase: 5 }, { owner: { ...source.owner, specialKaramaUsed: true } },
|
||||
{ blocked: 'Finish the response first.' }, { cards: [] },
|
||||
]) assert.ok(quote({ ...source, ...change }).blocked);
|
||||
assert.equal(quote(source).blocked, null);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, executeSpecialKaramaIntent, prepareSpecialKaramaIntent, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createTleilaxuSpecialKaramaFixture as fixture,
|
||||
actTleilaxuSpecialKarama as act,
|
||||
tleilaxuSpecialKaramaPlayer as player,
|
||||
tleilaxuSpecialKaramaInventory as inventory,
|
||||
reviveTleilaxuSpecialKarama as revive,
|
||||
finishTleilaxuSpecialKarama as finish,
|
||||
passTleilaxuSpecialKarama as pass,
|
||||
} from './fixture-tleilaxu-special-karama';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action, reason?: RegExp) {
|
||||
const before = structuredClone(game);
|
||||
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
|
||||
else assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('native quiet Revival permits any player before a request and spends the original card exactly once', () => {
|
||||
const f = fixture(), before = structuredClone(f.game);
|
||||
const quote = viewGame(f.game, f.owner).tleilaxuSpecialKarama;
|
||||
assert.ok(!f.game.pendingRevival);
|
||||
assert.equal(quote?.blocked, null);
|
||||
assert.equal(quote?.pending, false);
|
||||
assert.deepEqual(quote?.cards, [f.karama]);
|
||||
assert.deepEqual(quote?.targets, f.game.players.map(p => ({ player: p.id, blocked: null })));
|
||||
for (const actor of [f.target, f.observer]) assert.equal(viewGame(f.game, actor).tleilaxuSpecialKarama, null);
|
||||
const stopped = act(f.game, f.owner, f.special);
|
||||
assert.deepEqual(f.game, before);
|
||||
assert.equal(player(stopped, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(player(stopped, f.owner).hand.some(c => c.id === f.karama), false);
|
||||
assert.equal(stopped.discard.filter(c => c.id === f.karama).length, 1);
|
||||
assert.deepEqual(stopped.revivalPrevention, { player: f.target, turn: stopped.turn });
|
||||
assert.deepEqual(player(stopped, f.target), player(before, f.target));
|
||||
assert.deepEqual(stopped.ready, before.ready);
|
||||
assert.equal(stopped.decision, null);
|
||||
assert.equal(stopped.pendingRevival, null);
|
||||
assert.deepEqual(inventory(stopped), f.inventory);
|
||||
rejected(stopped, f.target, f.ordinaryRevival, /prevented/);
|
||||
rejected(stopped, f.target, f.gholaAction, /prevented.*Ghola/);
|
||||
rejected(stopped, f.owner, f.special, /already been used/);
|
||||
assert.equal(viewGame(stopped, f.target).ghola?.available, false);
|
||||
});
|
||||
|
||||
void test('ready, self and empty-Tanks targets do not narrow quiet declaration permission', () => {
|
||||
const f = fixture();
|
||||
const ready = act(f.game, f.observer, { type: 'ready' });
|
||||
for (const target of [f.owner, f.observer]) {
|
||||
assert.equal(player(ready, target).tanks, 0);
|
||||
const chosen = act(ready, f.owner, { ...f.special, target });
|
||||
assert.equal(chosen.revivalPrevention?.player, target);
|
||||
assert.deepEqual(chosen.ready, ready.ready);
|
||||
assert.equal(player(chosen, target).tanks, 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('completed paid and free returns retain their quota, pieces and native Tleilaxu income', () => {
|
||||
const f = fixture();
|
||||
const completed = revive(f.game, f, 3).game;
|
||||
assert.equal(player(completed, f.target).tanks, 9);
|
||||
assert.equal(player(completed, f.target).revived, 3);
|
||||
assert.ok(player(completed, f.target).spice < player(f.game, f.target).spice);
|
||||
assert.ok(player(completed, f.owner).spice > player(f.game, f.owner).spice);
|
||||
const target = structuredClone(player(completed, f.target));
|
||||
const income = player(completed, f.owner).spice;
|
||||
const stopped = act(completed, f.owner, f.special);
|
||||
assert.deepEqual(player(stopped, f.target), target);
|
||||
assert.equal(player(stopped, f.owner).spice, income);
|
||||
assert.equal(viewGame(stopped, f.target).revival.prevented, true);
|
||||
rejected(stopped, f.target, f.ordinaryRevival);
|
||||
rejected(stopped, f.target, f.gholaAction, /prevented.*Ghola/);
|
||||
assert.deepEqual(inventory(stopped), f.inventory);
|
||||
});
|
||||
|
||||
void test('the pending normal request remains target-bound and can be declined before a later quiet use', () => {
|
||||
const f = fixture();
|
||||
const pending = revive(f.game, f).requested;
|
||||
const quote = viewGame(pending, f.owner).tleilaxuSpecialKarama;
|
||||
assert.equal(quote?.pending, true);
|
||||
assert.deepEqual(quote?.targets.map(t => t.player), [f.target]);
|
||||
rejected(pending, f.owner, { ...f.special, target: f.observer }, /currently reviving/);
|
||||
const stopped = act(pending, f.owner, { type: 'card', card: f.karama, mode: 'special' });
|
||||
assert.deepEqual(player(stopped, f.target), player(f.game, f.target));
|
||||
assert.equal(stopped.pendingRevival, null);
|
||||
const declined = pass(act(pending, f.owner, { type: 'decision', decline: true }));
|
||||
assert.equal(player(declined, f.target).tanks, 11);
|
||||
assert.equal(viewGame(declined, f.owner).tleilaxuSpecialKarama?.blocked, null);
|
||||
assert.equal(act(declined, f.owner, f.special).revivalPrevention?.player, f.target);
|
||||
});
|
||||
|
||||
void test('quiet intent cannot spend after a new pending declaration replaces its source', () => {
|
||||
const f = fixture();
|
||||
const quiet = prepareSpecialKaramaIntent(f.game, f.owner, f.special);
|
||||
assert.equal(quiet.kind, 'tleilaxu');
|
||||
assert.equal(quiet.kind === 'tleilaxu' && quiet.revival, null);
|
||||
const pending = revive(f.game, f).requested;
|
||||
const before = structuredClone(pending);
|
||||
assert.throws(() => executeSpecialKaramaIntent(pending, quiet), /original revival declaration/);
|
||||
assert.deepEqual(pending, before);
|
||||
const intent = prepareSpecialKaramaIntent(pending, f.owner, f.special);
|
||||
assert.ok(intent.kind === 'tleilaxu' && intent.revival);
|
||||
const disposable = structuredClone(pending);
|
||||
executeSpecialKaramaIntent(disposable, intent);
|
||||
assert.equal(disposable.revivalPrevention?.player, f.target);
|
||||
});
|
||||
|
||||
void test('the real end of Revival expires prevention and permits five original Ghola counters without normal quota', () => {
|
||||
const f = fixture({ priorRevival: true });
|
||||
const stopped = act(f.game, f.owner, f.special);
|
||||
rejected(stopped, f.target, f.gholaAction, /prevented.*Ghola/);
|
||||
const shipment = finish(stopped);
|
||||
assert.equal(shipment.phase, 5);
|
||||
assert.equal(viewGame(shipment, f.owner).tleilaxuSpecialKarama, null);
|
||||
const prior = structuredClone(player(shipment, f.target));
|
||||
const returned = pass(act(shipment, f.target, f.gholaAction));
|
||||
assert.equal(player(returned, f.target).tanks, prior.tanks - 5);
|
||||
assert.equal(player(returned, f.target).reserves, prior.reserves + 5);
|
||||
assert.equal(player(returned, f.target).spice, prior.spice);
|
||||
assert.equal(player(returned, f.target).revived, prior.revived);
|
||||
assert.equal(returned.discard.filter(c => c.id === f.ghola).length, 1);
|
||||
assert.deepEqual(inventory(returned), f.inventory);
|
||||
});
|
||||
|
||||
void test('four existing profiles propose legal quiet prevention without a new strategic policy', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const source = structuredClone(f.game);
|
||||
player(source, f.owner).bot = difficulty;
|
||||
const action = botActions(viewGame(source, f.owner))[0];
|
||||
assert.deepEqual(action, f.special);
|
||||
const stopped = act(source, f.owner, action);
|
||||
rejected(stopped, f.target, f.ordinaryRevival, /prevented/);
|
||||
assert.equal(finish(stopped).phase, 5);
|
||||
assert.deepEqual(inventory(stopped), f.inventory);
|
||||
}
|
||||
});
|
||||
@@ -136,7 +136,7 @@ void test('special Karama consumes one card once, aborts payment and prevents re
|
||||
});
|
||||
void test('ownership, actual Karama, window, target and decline validation reject immutably', () => {
|
||||
const idle = beforeRevival();
|
||||
assert.throws(() => prevent(idle), /declaration/);
|
||||
assert.throws(() => prevent(idle), /Expected a string/);
|
||||
const g = declare(idle);
|
||||
const snapshot = structuredClone(g);
|
||||
assert.throws(
|
||||
@@ -245,11 +245,13 @@ void test('allowing Emperor-funded revival preserves separate quota, correct pay
|
||||
assert.equal(g.players[0].revived, 0);
|
||||
assert.equal(g.players[0].tanks, 5);
|
||||
});
|
||||
void test('Ghola treachery and Emperor special Karama remain separate from blocked normal revivals', () => {
|
||||
let g = prevent(declare(beforeRevival()));
|
||||
void test('Ghola is prevented during Revival while Emperor special Karama retains printed immunity', () => {
|
||||
const g = prevent(declare(beforeRevival()));
|
||||
placeFixtureHand(g, 0, [ghola]);
|
||||
g = allow(applyAction(g, 'a', { type: 'card', card: ghola.id, amount: 5 }));
|
||||
assert.equal(g.players[0].tanks, 3);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'a', { type: 'card', card: ghola.id, amount: 5 }), /prevented.*Ghola/);
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(g.players[0].tanks, 8);
|
||||
assert.equal(g.players[0].revived, 0);
|
||||
let emperor = beforeRevival();
|
||||
emperor.players[1].tanks = 5;
|
||||
|
||||
Reference in new issue
Block a user