Connect Basic Tleilaxu to the full Leader Skills prototype

Admit genuine Basic Tleilaxu/base setup with the full fourteen-skill deck,
Ix card inventories and native three Face Dancers. Share public profile guards
across existing skill effects while retaining public-start and Advanced gates.

Record native own-leader skill offers at successful revival commitment so
automatic Tleilaxu response chains preserve the optional private draw. Connect
Rihani to unrevealed Face Dancer exchange with exact physical/status custody,
private controls and existing legal AI choices. Release completed Rihani's
live snapshot only after validated Face Dance admission; keep signed history
and current census checks. Zoal Smuggler uses opposing printed disc value at
reveal, excluding opposing skill bonuses and his separate revival value.

Add genuine setup, response/cancellation, private choice, custody, Face Dance
reset, Zoal, legal AI and authenticated concurrent recovery regressions. Add a
reproducible two-through-six-player sample profile and saved continuation.
Preserve unresolved skill effects, foreign-ghola boundaries and delayed AI tuning.

Validation: types, lint, 5,058 offline tests and production build passed.
Five full games completed with zero rejected actions and 74 JSON restores.
Browser QA CRY4DD4J used genuine setup and an explicitly conserved staged battle;
real Rihani draw/exchange and same-state refresh passed on mobile and desktop.
Screenshot capture timed out; full visual acceptance remains open. No HTTP
endpoint/auth/storage implementation changed; authenticated SQLite recovery was
verified. All 1,581 prior rooms, 3,349 seats and auxiliary records were preserved.
One new QA room/seat remains. No server restart, database reset or automation.
This commit is contained in:
Quintine committed 2026-09-21 14:08:30 +10:00
1 parent 489bd3265d
commit d3559f65ad
28 files changed
+1324 -97

No files matched your search

+3 -1
View File
@@ -79,7 +79,9 @@ persists through refresh. See [Ecaz Loyalty](docs/ECAZ_LOYALTY.md).
The local Leader Skills prototype also supports Basic Moritani with base
opponents and the full fourteen-card deck. See [entry, connected interactions
and remaining boundaries](docs/MORITANI_LEADER_SKILLS.md).
and remaining boundaries](docs/MORITANI_LEADER_SKILLS.md). Basic Tleilaxu also
supports the full deck, native Rihani exchanges and saved revival choices; see
[its integration boundary](docs/TLEILAXU_LEADER_SKILLS.md).
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
counts separately from hands, auction cards and discards. See
+15 -11
View File
@@ -13,13 +13,14 @@ function identity(game: GameView, id: string): LeaderDisplayIdentity | null {
const leader = game.allLeaders.find((l) => l.id === id);
return leader ? { id: leader.id, name: leader.name, factionName: faction(leader.faction).name, strength: leaderStrengthLabel(leader) } : null;
}
function Card({ game, id }: { game: GameView; id: string }) {
function Card({ game, id, faceDancer = false }: { game: GameView; id: string; faceDancer?: boolean }) {
const card = identity(game, id);
return card ? <div><span>{card.name}</span> <LeaderInspector identity={card} kind="traitor" /></div> : <p>Card identity unavailable.</p>;
return card ? <div><span>{card.name}</span> <LeaderInspector identity={card} kind={faceDancer ? "faceDancer" : "traitor"} /></div> : <p>Card identity unavailable.</p>;
}
export function RihaniHistory({ game }: { game: GameView }) {
const history = game.rihani?.history;
const faceDancer = game.players.find(p => p.id === game.me)?.faction === 'tleilaxu';
if (!history?.length) return null;
return <details className="notice" aria-label="Your private Rihani history">
<summary>Your private Rihani inspections</summary>
@@ -27,7 +28,7 @@ export function RihaniHistory({ game }: { game: GameView }) {
{[...history].reverse().map((r) => <div key={r.event} className="space-y-2 border-t py-2">
<p>Turn {r.turn}</p>
{r.peeked.length > 0 && <><p>Normal inspection — returned to the shuffled deck</p>{r.peeked.map((id) => <Card key={id} game={game} id={id} />)}</>}
{r.drawn.length > 0 && <><p>Separate skilled draw</p>{r.drawn.map((id) => <Card key={id} game={game} id={id} />)}</>}
{r.drawn.length > 0 && <><p>Separate skilled draw</p>{r.drawn.map((id) => <Card key={id} game={game} id={id} faceDancer={faceDancer} />)}</>}
{r.kept && <p>Kept {identity(game, r.kept)?.name}; revealed and returned {identity(game, r.given!)?.name}.</p>}
</div>)}
</details>;
@@ -38,24 +39,27 @@ export function RihaniChoice({ game, act, busy }: { game: GameView; act: (action
const pending = game.rihani?.pending;
if (!pending || pending.owner !== game.me) return null;
const blocked = busy || !!game.truthtrance;
const faceDancer = game.players.find(p => p.id === game.me)?.faction === 'tleilaxu';
const cardName = faceDancer ? 'Face Dancer' : 'Traitor';
const oldCard = faceDancer ? 'unrevealed' : 'unused';
if (pending.stage === 'offer') return <section className="space-y-3" aria-label="Rihani draw offer">
<p>You may draw two Traitor Cards. Once drawn, you must keep one new card by publicly revealing and returning one unused card you already hold.</p>
<p>You may draw two {cardName} Cards. Once drawn, you must keep one new card by publicly revealing and returning one {oldCard} card you already hold.</p>
<p className="fine">The other drawn card returns secretly. Your normal inspection, when available, has already returned its cards to the shuffled deck.</p>
<Button disabled={blocked} onClick={() => !blocked && act({ type: 'decision', event: pending.event, draw: true })}>Draw two Traitors</Button>
<Button disabled={blocked} onClick={() => !blocked && act({ type: 'decision', event: pending.event, draw: true })}>Draw two {cardName}s</Button>
<Button variant="outline" disabled={blocked} onClick={() => !blocked && act({ type: 'decision', event: pending.event, draw: false })}>Decline exchange</Button>
</section>;
return <section className="space-y-3" aria-label="Rihani Traitor exchange">
<p>Keep one newly drawn card and reveal one unused card from your old hand. The revealed card and the other new card return to the shuffled deck.</p>
<fieldset><legend>Keep one new Traitor — private</legend>
return <section className="space-y-3" aria-label={`Rihani ${cardName} exchange`}>
<p>Keep one newly drawn card and reveal one {oldCard} card from your old hand. The revealed card and the other new card return to the shuffled deck.</p>
<fieldset><legend>Keep one new {cardName} — private</legend>
{pending.drawn?.map((id) => <div key={id} className="space-y-2 py-2">
<label className="flex min-h-11 items-center gap-2"><input type="radio" name="rihani-keep" value={id} checked={kept === id} disabled={blocked} onChange={() => setKept(id)} />Keep {identity(game, id)?.name}</label>
<Card game={game} id={id} />
<Card game={game} id={id} faceDancer={faceDancer} />
</div>)}
</fieldset>
<fieldset><legend>Reveal and return one unused old Traitor — public</legend>
<fieldset><legend>Reveal and return one {oldCard} old {cardName} — public</legend>
{pending.eligible?.map((id) => <div key={id} className="space-y-2 py-2">
<label className="flex min-h-11 items-center gap-2"><input type="radio" name="rihani-give" value={id} checked={given === id} disabled={blocked} onChange={() => setGiven(id)} />Reveal {identity(game, id)?.name}</label>
<Card game={game} id={id} />
<Card game={game} id={id} faceDancer={faceDancer} />
</div>)}
</fieldset>
<Button disabled={blocked || !pending.drawn?.includes(kept) || !pending.eligible?.includes(given)} onClick={() => {
+14 -22
View File
@@ -6,29 +6,21 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Latest delivered checkpoint: `e7e1602`, [revealed battle components](REVEALED_BATTLE_COMPONENTS.md).
Types, lint, **5,017 offline tests** and the production build passed. The shared
public display retains direct inspection across decision ownership; browser
checks covered genuine plan sealing, keyboard navigation, mobile layout and
refresh after an explicitly staged conserved battle. All 1,579 opening rooms,
3,347 human seats and auxiliary rows stayed unchanged. One new QA room/seat
brings the preservation baseline to 1,580 rooms and 3,348 human seats. Screenshot
capture timed out; full visual acceptance remains open. The healthy server was
reused. Earlier checkpoints include `de26aaf` for [Ecaz Loyalty](ECAZ_LOYALTY.md).
Latest delivered checkpoint: `489bd32`, [Basic Moritani with Leader Skills](MORITANI_LEADER_SKILLS.md).
Types, lint, **5,034 offline tests** and the production build passed; five
complete two-through-six-player samples had no rejected actions. Browser setup
and refresh were checked. All 1,580 prior rooms and 3,348 seats remained intact;
one QA room/seat brought the baseline to 1,581 rooms and 3,349 human seats.
Screenshot capture timed out; full visual acceptance remains open.
Current work: [Basic Moritani with Leader Skills](MORITANI_LEADER_SKILLS.md)
connects the full fourteen-card deck with base opponents and no other optional
modules. Coordinated public-profile guards connect movement, shipment, payment
and battle effects; focused tests cover Terror death/revival, real alliance
retention exclusions and authenticated saved choices. A reusable two-through-six
player sample profile exercises legal continuation. Final results and browser,
independent review, preservation and Git evidence accompany this checkpoint.
The remaining Leader Skills effects and source questions retain their explicit
boundaries; admitting Basic Moritani does not finish the module. Next integration
work should assess the remaining faction/module dependencies against these new
shared profile checks, without reducing the physical deck or hiding unsupported
cards after dealing. Advanced Moritani and Ecaz/Skills remain separate gates.
Current work: [Basic Tleilaxu with Leader Skills](TLEILAXU_LEADER_SKILLS.md)
connects genuine full-deck setup, own revival through automatic response chains,
native Rihani Face Dancer exchanges and later Face Dance skill custody. Zoal's
Smuggler collection now uses the opposing printed disc value. Focused tests,
authenticated saved choices, private controls and a reusable two-through-six
player sample profile accompany final source-bound reports. Existing missing
skill bands, Advanced Tleilaxu/foreign gholas and other roster/module combinations
remain separate work; admission does not certify complete Leader Skills.
The Bureaucrat/Emperor gift question is still pending. The `8f7a62c` policy
checkpoint removed usage checks/cutoffs and deferred full AI work until feature
completion, followed by three adjacent 75% targets.
+10
View File
@@ -1,5 +1,15 @@
# Dune implementation status
## 21 September 2026 — Basic Tleilaxu with Leader Skills
Genuine Basic Tleilaxu setup now uses the full skill deck, native Rihani Face
Dancer exchanges and response-driven own-leader revival choices. Completed
Rihani history releases live custody only when later Face Dance begins; Zoal
Smuggler collection uses opposing printed disc strength. Shared guards, private
controls, legal AI continuation, authenticated recovery and two-through-six
player samples accompany the [integration contract](TLEILAXU_LEADER_SKILLS.md).
Existing unfinished effects and public completion/publication gates remain.
## 21 September 2026 — Basic Moritani with Leader Skills
The existing private initializer now connects Basic Moritani with base opponents
+3 -2
View File
@@ -48,8 +48,9 @@ when fewer than two physical deck cards are available.
The [Ixian/Tleilaxu rules](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf)
place skill resolution before faction capture and Face Dance. Tleilaxu Rihani
must instead exchange an unrevealed Face Dancer. That combination, foreign
gholas and the skill lifecycle after non-kill Face Dance remain unimplemented.
must instead exchange an unrevealed Face Dancer. The [Basic Tleilaxu follow-up](TLEILAXU_LEADER_SKILLS.md)
connects that exchange and the skill return when Face Dance kills a leader.
Foreign gholas remain gated.
The connected entry supports base factions in Basic/Advanced with Leader Skills
alone. These boundaries are implementation limits, not prohibitions in the game.
+6 -3
View File
@@ -7,6 +7,10 @@ records the fresh publisher/designer search requested by the user.
## Connected boundary
The [Basic Tleilaxu follow-up](TLEILAXU_LEADER_SKILLS.md) adds the full-deck
Tleilaxu/base profile, response-driven own revival, native Rihani Face Dancer
exchanges and later Face Dance custody. Advanced foreign gholas stay gated.
The [Basic Moritani follow-up](MORITANI_LEADER_SKILLS.md) now connects Moritani
with base opponents and no other optional modules. It preserves all fourteen
cards and existing unfinished-effect boundaries. The older base-only scope
@@ -80,9 +84,8 @@ own-leader revival while its original skill is captured, before beginning a
paid revival or consuming the Ghola/special-Karama action. This is an explicit
unsupported boundary, not a ruling that the physical game forbids that revival.
Foreign gholas, other expansion rosters and combined optional modules remain gated.
Before enabling them, add response-driven automatic revival continuation
coverage; the current before/after revival observer does not certify deferred
Tleilaxu response chains. The other source-contract timing questions also remain
The Basic Tleilaxu follow-up now tests response-driven automatic own revival
continuation; this does not certify Advanced foreign-ghola custody. The other source-contract timing questions also remain
open. Planetologist now has bounded movement and battle controls as described below.
Remaining normal-band payments, ordinary Mentat activation, modified Smuggler collection,
Diplomat retreat and the other guarded skill combinations still need connected
+3 -2
View File
@@ -52,8 +52,9 @@ Banker income, ordinary Mentat-question activation, Diplomat retreat, modified
Smuggler collection, captured replacement entitlement and other source gaps.
The private Mentat question preview has not been enabled for this roster.
Basic Moritani admission does not settle those effects or certify every valid
expansion/module combination. Advanced Moritani, Ecaz, Ixians and Tleilaxu need
their own integration, including the recorded deferred Tleilaxu revival timing.
expansion/module combination. Advanced Moritani, Ecaz and Ixians need their own integration. The later
[Basic Tleilaxu checkpoint](TLEILAXU_LEADER_SKILLS.md) connects its own revival
response chains and Face Dancer interactions.
Full AI strategy and difficulty calibration still wait for feature completion.
## Reproducible entry and verification
+1
View File
@@ -33,6 +33,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) |
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine have bounded prototypes; Orgiz and contested/mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
+86
View File
@@ -0,0 +1,86 @@
# Basic Tleilaxu with Leader Skills
Connected development prototype, 21 September 2026. Basic Tleilaxu with base
opponents now uses all fourteen physical skills through genuine setup. Public
starts, complete-mode acceptance and publication remain gated. Existing missing
skill effects are still explicit; this is not complete expansion certification.
## Configuration and physical setup
The existing `leader-skills` local initializer accepts Basic rules, exactly the
Ixians & Tleilaxu expansion, Tleilaxu plus base factions, two through six seats,
and no other optional modules. It keeps the real Ix Treachery/Spice decks and
the Cheap Hero Traitor identity. Starting Treachery precedes simultaneous
private two-card skill offers; public assignments and ordinary Traitor choices
precede the native three Face Dancers. No hidden card controls profile admission.
Ixians, Advanced Tleilaxu and combined modules require further integration.
## Connected interactions
Native own-leader revival now records its optional skill offer when the revival
actually commits. Automatic early-permission/discount responses cannot skip the
offer; income responses finish before the player's choice. The undrawn offer
reveals neither card, can be declined, and binds a drawn choice to that revived
leader. Canceled or unaffordable revivals create no offer. Existing observation
of Ghola and other native revivals remains idempotent. Captured replacement
entitlement stays guarded. Foreign gholas remain Advanced-only and excluded
from this profile; the FAQ says acquiring one does not draw a new skill.
Rihani uses the [recorded FAQ](LEADER_SKILLS_RULES.md):
Tleilaxu's drawn cards become unrevealed Face Dancers, and only an unrevealed old
Face Dancer can be exchanged. The normal private peek returns cards first; the
optional draw then requires keeping one new card, publicly revealing/returning
one eligible old card, and privately returning the other new one. Retained
reveal statuses and all physical identities survive saved choices. Opponents
receive neither inspection history nor the drawn/kept identities. Human
controls identify Face Dancers and reuse the enlarged component inspector;
existing AI chooses only from its private eligible lists.
Skills and winner card cleanup finish before native Face Dance. A completed
Rihani receipt remains bound to live custody during that cleanup. At validated
Face Dance admission a signed saved boundary retires that historical snapshot
as a live constraint, allowing the later reveal or three-card refresh. The
original receipt and the current physical census remain validated. Face Dance
kills a living winning leader without a bounty and returns its skill once.
Older receipts without the new boundary retain their original signature shape.
Shared profile checks extend the existing ordinary movement, shipment, payment
and battle effects. Zoal's unmodified Smuggler collection uses the opposing
disc's printed value from the two revealed plans, including zero against Cheap
Hero; it does not use Zoal's revival value or copy the opponent's skill bonus.
This follows the existing [Zoal and skill contract](LEADER_SKILLS_RULES.md) and
[Smuggler boundary](SMUGGLER_BATTLE.md), without settling modified-strength cases.
## Entry and checks
Create a fresh Basic lobby with Ixians & Tleilaxu selected, Tleilaxu and base
opponents, and ready every human. Keep its room cookie, then use the backed-up,
version-checked local entry described in [prototype setup](IX_PROTOTYPE.md):
```sh
node --import tsx tools/start-prototype.ts --profile leader-skills \
--db PATH_TO_LOCAL_D1_SQLITE --room EIGHT_CHARACTER_CODE \
--version CURRENT_LOBBY_VERSION --out /private/new-tleilaxu-skills
node --import tsx tools/faction-games.ts --profile tleilaxu-skills \
--out /private/new-tleilaxu-skills-samples
npm test -- tleilaxu-skills rihani-face-dancers
```
The sample profile runs two through six players with Emperor, Guild, Harkonnen,
Fremen and Bene Gesserit added in order; focused tests also include Atreides.
It counts all Treachery, skill, Traitor/Face Dancer and force custody, checks
rejected-action immutability and JSON/private-view restoration. Saved sample
continuation preserves the full module; Advanced selection fails before running.
These are legal-play checks, not difficulty calibration. The tests use genuine
setup followed by explicitly conserved focused positions, not invented full
phase histories. Authenticated in-memory SQLite checks cover concurrent setup
choices, restarted private views, three Face Dancers and unchanged seat records.
Private source-bound reports record final checks, game samples and browser work.
## Remaining work
Normal Banker income, ordinary Mentat questioning, Diplomat retreat, modified
Smuggler collection, captured replacement entitlement and other recorded skill
questions remain incomplete. See the [runtime boundaries](LEADER_SKILLS_RUNTIME.md)
and [rule decisions](RULE_DECISIONS.md). Full AI implementation and tuning wait
until all non-AI game features are complete.
+1
View File
@@ -36,6 +36,7 @@ node --import tsx tools/faction-games.ts --out /tmp/dune-new-faction-games
node --import tsx tools/faction-games.ts --out /tmp/dune-new-base-games --profile base
# Basic Moritani with the full Leader Skills deck, two through six seats.
node --import tsx tools/faction-games.ts --out /tmp/dune-new-moritani-skills --profile moritani-skills
node --import tsx tools/faction-games.ts --out /tmp/dune-new-tleilaxu-skills --profile tleilaxu-skills
node --import tsx tools/faction-games.ts --out /tmp/dune-new-base-three --profile base --players 3 --rules advanced
node --import tsx tools/faction-games.ts --out /tmp/dune-new-combined --profile combined --rules advanced --seed 20260926
node --import tsx tools/faction-games.ts --out /tmp/dune-new-resume --resume /private/failed-combined-advanced.json
+2 -2
View File
@@ -1,4 +1,4 @@
import { basicMoritaniLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
import { basicExpansionLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe';
export type BureaucratPaymentSource = {
event: string;
@@ -38,7 +38,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea
return noOtherLeaderSkillModules(game) &&
(!game.expansions.length ||
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
basicMoritaniLeaderSkillsProfile(game));
basicExpansionLeaderSkillsProfile(game));
}
export function bureaucratUsed(
used: readonly BureaucratPaymentUse[],
+39 -12
View File
@@ -1,4 +1,4 @@
import { basicMoritaniLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
import { basicExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
@@ -518,6 +518,7 @@ import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, ty
import { FACTIONS, faction, type FactionId } from './catalog';
import { quoteLobbyBotConfiguration } from './lobby-bot-configuration';
import {
battleLeaderStrength,
treacheryDeck,
normalizeLegacyCardNames,
leaders,
@@ -1609,7 +1610,7 @@ export type Game = {
cardRolesSignature?: string;
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
sukRescue?: { signature: string; completed: boolean };
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean };
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean; faceDanceStarted?: true };
sandmaster?: { leader: string; key: string; before: number; after: number };
smugglerCollection?: SmugglerBattleReceipt;
caladanReinforcement?: HomeworldVictoryObligation;
@@ -5904,7 +5905,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random);
return initializeSetupGameForAudit(g, false, false, false, false, true,
g.expansions.length === 1 && g.expansions[0] === 'choam', basicMoritaniLeaderSkillsProfile(g));
g.expansions.length === 1 && g.expansions[0] === 'choam', basicExpansionLeaderSkillsProfile(g));
}
/** Gated development setup for the independent three-card Ecaz variant. */
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
@@ -15527,10 +15528,13 @@ function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null {
skills: [b.attacker, b.defender].map(id => battleLeaderSkills(g, getPlayer(g, id))) });
const receipts = [b.attacker, b.defender].flatMap(id => {
const p = getPlayer(g, id), plan = b.plans[id];
const opponent = getPlayer(g, id === b.attacker ? b.defender : b.attacker);
const leader = controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead);
const opposingLeader = controlledLeaders(g, opponent).find(l => l.id === b.plans[opponent.id].leader && !l.dead);
const receipt = nexusRule(() => createSmugglerBattle({ event: b.event!, turn: g.turn,
territory: b.territory, player: id, frame, spice: g.spice, supported: smugglerBattleModeSupported(g),
plan: { assignments: battleLeaderSkills(g, p),
leader: controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead),
leader: leader ? { id: leader.id, strength: battleLeaderStrength(leader, opposingLeader) } : undefined,
weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense), kwisatz: plan.kwisatz } }));
return receipt ? [receipt] : [];
});
@@ -15618,11 +15622,11 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
if (quote.sukGraduate) requireRule(
ordinaryLeaderSkillModeSupported(g) &&
(basicMoritaniLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
(basicExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.');
if (quote.rihani || quote.sandmaster) requireRule(
ordinaryLeaderSkillModeSupported(g) &&
(basicMoritaniLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
(basicExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.');
const smuggler = b.smugglerCollection ? nexusRule(() => settleSmugglerBattle(b.smugglerCollection!,
!(b.smugglerCollection!.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender), g.spice)) : null;
@@ -16253,7 +16257,8 @@ function winnerDiscardsIntegrity(g: Game) {
}
function rihaniObligationSignature(context: NonNullable<Game['lastBattleContext']>) {
return JSON.stringify([context.event, context.turn, context.winner, context.territory,
context.rihani?.skill, context.rihani?.cards, context.rihani?.completed]);
context.rihani?.skill, context.rihani?.cards, context.rihani?.completed,
...(context.rihani?.faceDanceStarted === undefined ? [] : [context.rihani.faceDanceStarted])]);
}
function pendingRihani(g: Game) {
return g.rihaniHistory?.find((r) => r.stage !== 'complete') ?? null;
@@ -16281,14 +16286,19 @@ function rihaniIntegrity(g: Game) {
nexusRule(() => validateRihani(pending, nexusTraitorUniverse(g), nexusTraitorSnapshot(g)));
} else requireRule(!decisions.length, 'The Rihani choice has lost its physical draw receipt.');
if (!obligation) return;
requireRule(obligation.faceDanceStarted === undefined ||
(obligation.faceDanceStarted === true && obligation.completed), 'Invalid saved Rihani aftermath boundary.');
requireRule(obligation.signature === rihaniObligationSignature(context!), 'The saved Rihani victory obligation changed.');
const receipt = history.find((r) => r.event === context!.event);
if (obligation.completed) {
requireRule(receipt?.stage === 'complete' && receipt.owner === context!.winner &&
receipt.turn === context!.turn && JSON.stringify(receipt.skill) === JSON.stringify(obligation.skill),
'The completed Rihani victory lost its receipt.');
if (g.phase === 6 && context!.turn === g.turn && !g.battle)
nexusRule(() => validateRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g)));
if (g.phase === 6 && context!.turn === g.turn && !g.battle) {
if (obligation.faceDanceStarted)
nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), nexusTraitorUniverse(g)));
else nexusRule(() => validateRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g)));
}
} else {
const winner = g.players.find((p) => p.id === context!.winner);
requireRule(winner && g.status === 'playing' && g.phase === 6 && !g.battle && context!.turn === g.turn &&
@@ -16316,7 +16326,7 @@ function startRihaniVictory(g: Game, winner: Player, territory: string, cards: s
if (receipt.peeked.length) log(g, `${winner.name}'s Rihani Decipherer privately inspected two random Traitor Deck cards and shuffled them back. The identities are in that player's private skill history.`, { faction: winner.faction, name: 'Rihani Decipherer' });
if (receipt.stage === 'complete') {
completeRihaniObligation(g);
if (receipt.skill.skilled && !receipt.eligible.length) log(g, `${winner.name} has no unused held Traitor Card to exchange, so Rihani offers no draw.`);
if (receipt.skill.skilled && !receipt.eligible.length) log(g, `${winner.name} has no ${winner.faction === 'tleilaxu' ? 'unrevealed Face Dancer' : 'unused held Traitor Card'} to exchange, so Rihani offers no draw.`);
return false;
}
g.decision = { kind: 'rihani', event: receipt.event, player: winner.id, stage: 'offer' };
@@ -16341,12 +16351,12 @@ function actRihani(g: Game, player: Player, decision: Extract<Decision, { kind:
applyNexusTraitorSnapshot(g, next.state);
if (next.stage === 'return') {
g.decision = { kind: 'rihani', event: next.event, player: player.id, stage: 'return' };
log(g, `${player.name} drew two private Traitor Cards through Rihani and must keep one by revealing an unused old card.`);
log(g, `${player.name} drew two private ${player.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} Cards through Rihani and must keep one by revealing an ${player.faction === 'tleilaxu' ? 'unrevealed' : 'unused'} old card.`);
} else {
completeRihaniObligation(g);
if (next.given) {
const name = next.given === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : g.players.flatMap((p) => p.leaders).find((l) => l.id === next.given)?.name;
log(g, `${player.name} revealed the unused ${name ?? next.given} Traitor Card for Rihani and returned it with the unkept new card to the shuffled Traitor Deck. The kept card remains private.`);
log(g, `${player.name} revealed the unused ${name ?? next.given} ${player.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor Card'} for Rihani and returned it with the unkept new card to the shuffled Traitor Deck. The kept card remains private.`);
} else log(g, `${player.name} declined Rihani's optional Traitor exchange.`);
finishWinner(g, player, context.territory, context.rihani!.cards);
}
@@ -16597,6 +16607,15 @@ function finishBattle(g: Game) {
}
}
const next = quote.next;
const rihani = g.lastBattleContext?.rihani;
if (next.kind === 'faceDance' && rihani && !rihani.faceDanceStarted) {
// Winner cleanup is complete. Face Dance may now reveal or recycle cards;
// the earlier exchange remains immutable history, not current deck custody.
requireRule(rihani.completed, 'Finish Rihani before Face Dance.');
rihaniIntegrity(g);
rihani.faceDanceStarted = true;
rihani.signature = rihaniObligationSignature(g.lastBattleContext!);
}
if ((next.kind === 'faceDance' || next.kind === 'board') &&
openHomeworldVictoryReturn(g, next.kind === 'faceDance')) return;
if (next.kind === 'choamBattleIncome') {
@@ -17151,6 +17170,14 @@ function finishRevival(
else delete leader.gholaBy;
leader.dead = false;
delete leader.concealed;
// Automatic Karama response chains may commit after the action's before/after
// observer. Record the own-leader offer at the successful revival itself.
if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader) &&
!leader.capturedBy && p.leaders.some(own => own.id === leader.id)) {
requireLeaderSkillRevival(g, p.id);
g.leaderSkills = skillRule(() => offerRevivedLeaderSkill(
g.leaderSkills!, p.id, leader.id, crypto.randomUUID()));
}
log(
g,
`${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.`,
+14 -1
View File
@@ -28,8 +28,21 @@ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): bool
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
}
/** Native Basic Tleilaxu, including Face Dancers; foreign gholas are Advanced. */
export function basicTleilaxuLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return game.advanced === false && game.expansions.length === 1 &&
game.expansions[0] === 'ix' && noOtherLeaderSkillModules(game) &&
!!game.players?.some(player => player.faction === 'tleilaxu') &&
game.players.every(player => player.faction === 'tleilaxu' ||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
}
export function basicExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game);
}
/** Shared by rule quotes, controls and minimal legal bot participation. */
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
return noOtherLeaderSkillModules(game) &&
(!game.expansions.length || basicMoritaniLeaderSkillsProfile(game));
(!game.expansions.length || basicExpansionLeaderSkillsProfile(game));
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { FACTIONS } from './catalog';
import { basicMoritaniLeaderSkillsProfile } from './leader-skill-profile';
import { basicExpansionLeaderSkillsProfile } from './leader-skill-profile';
import type { Game, GameView } from './engine';
export type PlanetologistMoveMode = 'range' | 'gather';
@@ -41,5 +41,5 @@ export function planetologistRange(
/** Keep the existing base-roster boundary and add the connected Basic Moritani profile. */
export function planetologistMovementModeSupported(game: Game | GameView): boolean {
return game.players.every(player => FACTIONS.some(f => f.id === player.faction && f.expansion === 'base')) ||
basicMoritaniLeaderSkillsProfile(game);
basicExpansionLeaderSkillsProfile(game);
}
+23 -1
View File
@@ -4000,6 +4000,28 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
{
id: 'tleilaxu-leader-skills',
title: 'Tleilaxu with Leader Skills',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'The Basic development combination preserves the full skill deck and private Face Dancers.',
steps: [
'Basic Tleilaxu with base opponents uses all fourteen skills. Starting cards precede private skill choices and the native three Face Dancers. Other optional modules, Advanced foreign gholas and public starts remain unavailable.',
'Rihani draws become unrevealed Face Dancers. Keep one new card by revealing and returning one unrevealed old card; the other new card returns privately. Only the owner sees the inspection history and new identities.',
'A successful own-leader revival preserves the optional skill draw after revival responses. Winner skill and card cleanup precede Face Dance; a killed skilled leader returns its card once. Zoal copies the opposing disc value for unmodified Smuggler collection.',
'Other unfinished skill effects retain their stated limits. This development combination does not certify the full expansion or every optional-module interaction.',
],
related: ['leader-skills', 'tleilaxu-face-dancers', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Full setup, response-driven own revival, Rihani Face Dancer exchanges, later death custody and copied Zoal collection are connected; remaining skill effects stay explicit.', evidence: ['game/leader-skill-profile.ts', 'game/rihani-decipherer.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'Private offers, Face Dancer keep/reveal inspectors and saved own-revival choices reuse the table controls.', evidence: ['components/rihani-decipherer.tsx', 'components/leader-skills.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing profiles follow their private legal offers. Full strategy and strength tuning wait for feature completion.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Entry, source contracts and remaining boundaries are recorded.', evidence: ['docs/TLEILAXU_LEADER_SKILLS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine setup, physical custody, revival response chains, private exchange, Face Dance aftermath, Zoal and authenticated recovery have focused checks; full acceptance remains open.', evidence: ['tests/tleilaxu-skills-integration.test.ts', 'tests/tleilaxu-skills-revival.test.ts', 'tests/tleilaxu-skills-recovery.test.ts', 'tests/rihani-face-dancers.test.ts'] },
],
},
{
id: 'moritani-leader-skills',
title: 'Moritani with Leader Skills',
@@ -4051,7 +4073,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Other special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
related: ['moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
related: ['tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
checklist: [
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
+32 -10
View File
@@ -1,6 +1,6 @@
import type { BattleLeaderSkill } from './leader-skill-combat';
import { usesSurvivingSkilledLeader } from './leader-skill-combat';
import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
import { validateNexusTraitorSnapshot, type NexusExchangePlayer, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
export type RihaniSkill = { leader: string; normal: boolean; skilled: boolean };
export type RihaniReceipt = {
@@ -18,6 +18,24 @@ function requireRihani(value: unknown, message: string): asserts value {
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const members = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && same([...a].sort(), [...b].sort());
function eligibleOldCards(owner: NexusExchangePlayer, used: readonly string[]): string[] {
return owner.faction === 'tleilaxu'
? owner.faceDancers!.filter((card) => !card.revealed).map((card) => card.leader)
: owner.traitors.filter((id) => !used.includes(id));
}
function unchangedHand(owner: NexusExchangePlayer, old: NexusExchangePlayer): boolean {
return same(owner.traitors, old.traitors) && same(owner.faceDancers, old.faceDancers);
}
function augmentedHand(owner: NexusExchangePlayer, old: NexusExchangePlayer, drawn: readonly string[]): boolean {
return owner.faction === 'tleilaxu'
? same(owner.faceDancers, [...old.faceDancers!, ...drawn.map((leader) => ({ leader, revealed: false }))])
: same(owner.traitors, [...old.traitors, ...drawn]);
}
function exchangedHand(owner: NexusExchangePlayer, old: NexusExchangePlayer, kept: string, given: string): boolean {
return owner.faction === 'tleilaxu'
? same(owner.faceDancers, [...old.faceDancers!.filter((card) => card.leader !== given), { leader: kept, revealed: false }])
: members(owner.traitors, [...old.traitors.filter((id) => id !== given), kept]);
}
function shuffled<T>(values: readonly T[], rng: () => number): T[] {
const result = [...values];
for (let i = result.length - 1; i > 0; i--) {
@@ -49,8 +67,8 @@ export function beginRihani(snapshot: NexusTraitorSnapshot, universe: readonly s
identity: Pick<RihaniReceipt, 'event' | 'turn' | 'owner' | 'skill'>, used: readonly string[], rng: () => number): RihaniReceipt {
validateNexusTraitorSnapshot(snapshot, universe);
const owner = snapshot.players.find((p) => p.id === identity.owner);
requireRihani(owner && owner.faction !== 'tleilaxu', 'Rihani Face Dancer exchanges are still being integrated.');
const eligible = owner.traitors.filter((id) => !used.includes(id));
requireRihani(owner, 'Rihani requires its original card owner.');
const eligible = eligibleOldCards(owner, used);
const optional = identity.skill.skilled && eligible.length > 0;
requireRihani(!(identity.skill.normal || optional) || snapshot.reserve.length >= 2,
'Rihani requires two physical cards in the Traitor Deck.');
@@ -72,13 +90,13 @@ export function validateRihani(receipt: RihaniReceipt, universe: readonly string
validateNexusTraitorSnapshot(receipt.state, universe);
const old = receipt.before.players.find((p) => p.id === receipt.owner);
const owner = receipt.state.players.find((p) => p.id === receipt.owner);
requireRihani(old && owner && old.faction !== 'tleilaxu' && owner.faction === old.faction &&
requireRihani(old && owner && owner.faction === old.faction &&
same(receipt.before.players.filter((p) => p.id !== receipt.owner), receipt.state.players.filter((p) => p.id !== receipt.owner)),
'Rihani changed another player’s physical Traitors.');
requireRihani(Array.isArray(receipt.used) && new Set(receipt.used).size === receipt.used.length &&
members(receipt.deckAfterPeek, receipt.before.reserve) &&
(receipt.skill.normal || same(receipt.deckAfterPeek, receipt.before.reserve)) &&
same(receipt.eligible, old.traitors.filter((id) => !receipt.used.includes(id))) &&
same(receipt.eligible, eligibleOldCards(old, receipt.used)) &&
receipt.peeked.length === (receipt.skill.normal ? 2 : 0) &&
new Set(receipt.peeked).size === receipt.peeked.length &&
receipt.peeked.every((id) => receipt.before.reserve.includes(id)), 'Rihani lost its original inspection or unused cards.');
@@ -86,18 +104,18 @@ export function validateRihani(receipt: RihaniReceipt, universe: readonly string
new Set(receipt.drawn).size === receipt.drawn.length && receipt.drawn.every((id) => receipt.before.reserve.includes(id)),
'Rihani must draw two distinct physical cards.');
if (!receipt.drawn.length) {
requireRihani(receipt.stage !== 'return' && !receipt.kept && !receipt.given && same(owner.traitors, old.traitors) &&
requireRihani(receipt.stage !== 'return' && !receipt.kept && !receipt.given && unchangedHand(owner, old) &&
same(receipt.state.reserve, receipt.deckAfterPeek), 'The undrawn Rihani offer changed hand custody.');
} else {
requireRihani(receipt.skill.skilled && receipt.eligible.length > 0, 'This Rihani cannot exchange an unused card.');
requireRihani(same(receipt.drawn, receipt.deckAfterPeek.slice(0, 2)), 'Rihani did not draw from the saved deck order.');
const remaining = receipt.deckAfterPeek.slice(2);
if (receipt.stage === 'return') requireRihani(!receipt.kept && !receipt.given &&
same(owner.traitors, [...old.traitors, ...receipt.drawn]) && same(receipt.state.reserve, remaining),
augmentedHand(owner, old, receipt.drawn) && same(receipt.state.reserve, remaining),
'The pending Rihani return lost its drawn cards.');
else requireRihani(receipt.stage === 'complete' && receipt.kept && receipt.drawn.includes(receipt.kept) &&
receipt.given && receipt.eligible.includes(receipt.given) &&
members(owner.traitors, [...old.traitors.filter((id) => id !== receipt.given), receipt.kept]) &&
exchangedHand(owner, old, receipt.kept, receipt.given) &&
members(receipt.state.reserve, [...remaining, receipt.given, ...receipt.drawn.filter((id) => id !== receipt.kept)]),
'The completed Rihani exchange lost its exact new or returned card.');
}
@@ -114,7 +132,9 @@ export function chooseRihaniDraw(receipt: RihaniReceipt, universe: readonly stri
else {
requireRihani(next.state.reserve.length >= 2, 'Rihani requires two physical cards in the Traitor Deck.');
next.drawn = next.state.reserve.splice(0, 2);
next.state.players.find((p) => p.id === next.owner)!.traitors.push(...next.drawn);
const owner = next.state.players.find((p) => p.id === next.owner)!;
if (owner.faction === 'tleilaxu') owner.faceDancers!.push(...next.drawn.map((leader) => ({ leader, revealed: false })));
else owner.traitors.push(...next.drawn);
next.stage = 'return';
}
signed(next); validateRihani(next, universe); return next;
@@ -126,7 +146,9 @@ export function finishRihani(receipt: RihaniReceipt, universe: readonly string[]
'Keep one newly drawn Traitor and reveal one unused Traitor held before the draw.');
const next = structuredClone(receipt), other = next.drawn.find((id) => id !== kept)!;
const owner = next.state.players.find((p) => p.id === next.owner)!;
owner.traitors = owner.traitors.filter((id) => id !== given && id !== other);
if (owner.faction === 'tleilaxu')
owner.faceDancers = owner.faceDancers!.filter((card) => card.leader !== given && card.leader !== other);
else owner.traitors = owner.traitors.filter((id) => id !== given && id !== other);
next.state.reserve = shuffled([...next.state.reserve, given, other], rng);
next.kept = kept; next.given = given; next.stage = 'complete';
signed(next); validateRihani(next, universe); return next;
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Game, GameView } from './engine';
import { splitLocation, validGameLocation } from './board';
import { faction } from './catalog';
import { basicMoritaniLeaderSkillsProfile } from './leader-skill-profile';
import { basicExpansionLeaderSkillsProfile } from './leader-skill-profile';
import { sandmasterLeader, sandmasterModeSupported } from './sandmaster-movement';
export type SandmasterWormCollection = {
@@ -26,7 +26,7 @@ export function sandmasterWormCollection(
const blocked = (reason: string): SandmasterWormCollection =>
({ leader, key: null, before: 0, blocked: reason });
if (!sandmasterModeSupported(game) || game.ecazTreachery ||
!(basicMoritaniLeaderSkillsProfile(game) ||
!(basicExpansionLeaderSkillsProfile(game) ||
game.players.every(p => faction(p.faction).expansion === 'base')))
return blocked('Sandmaster worm collection with other optional modules is still being integrated.');
if (destination === decision.territory ||
+2 -2
View File
@@ -1,5 +1,5 @@
import { FACTIONS } from './catalog';
import { basicMoritaniLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
import { basicExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
import type { Card, Leader } from './cards';
import { splitLocation, validGameLocation } from './board';
import { leaderSkillBattleBonus, usesSurvivingSkilledLeader, type BattleLeaderSkill } from './leader-skill-combat';
@@ -11,7 +11,7 @@ export type SmugglerBattleReceipt = {
};
export function smugglerBattleModeSupported(g: LeaderSkillProfile & { players: readonly { faction: string }[] }) {
return ordinaryLeaderSkillModeSupported(g) &&
(basicMoritaniLeaderSkillsProfile(g) || g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')));
(basicExpansionLeaderSkillsProfile(g) || g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')));
}
export type SmugglerBattlePlan = {
assignments: readonly BattleLeaderSkill[];
+21 -1
View File
@@ -115,6 +115,26 @@ void test('Moritani Skills samples preserve their full module on saved continuat
assert.equal(existsSync(unsupported), false);
});
void test('Tleilaxu Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
const area = temporary(t);
const out = join(area, 'skills');
const result = run(out, '--profile', 'tleilaxu-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'tleilaxu-skills-2-basic', 1021, false);
const snapshot = join(out, 'failed-tleilaxu-skills-2-basic.json');
const game = json<{ advanced: boolean; leaderSkills: { deck: string[]; offers: Record<string, { cards: string[] }>; assignments: { skill: string }[] } }>(snapshot);
assert.equal(game.advanced, false);
const physical = [...game.leaderSkills.deck, ...Object.values(game.leaderSkills.offers).flatMap(o => o.cards), ...game.leaderSkills.assignments.map(a => a.skill)];
assert.equal(physical.length, 14); assert.equal(new Set(physical).size, 14);
const resumed = join(area, 'skills-resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(resumed, 'tleilaxu-skills-2-basic', 1021, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'tleilaxu-skills', '--rules', 'advanced');
assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/);
assert.equal(existsSync(unsupported), false);
});
void test('selected genuine three-player Advanced base sample fails honestly, resumes its fixed snapshot and keeps source-bound private evidence', (t) => {
const area = temporary(t);
const first = join(area, 'first');
@@ -256,7 +276,7 @@ void test('selected genuine three-player Advanced base sample fails honestly, re
join(corruptOut, 'results.json'),
).results[0];
assert.equal(corruptResult.actions, 0);
assert.match(corruptResult.error ?? '', /physical base traitor custody/);
assert.match(corruptResult.error ?? '', /physical traitor custody/);
for (const [label, args] of [
['profile', ['--profile', 'base']],
+178
View File
@@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
beginRihani,
chooseRihaniDraw,
finishRihani,
rihaniSignature,
validateRihani,
type RihaniReceipt,
} from '../game/rihani-decipherer';
import {
validateNexusTraitorSnapshot,
type NexusTraitorSnapshot,
} from '../game/nexus-traitor-exchange';
const universe = Array.from({ length: 10 }, (_, index) => `leader-${index}`);
const random = () => 0.37;
function snapshot(): NexusTraitorSnapshot {
return {
reserve: universe.slice(4),
players: [
{
id: 't', faction: 'tleilaxu', traitors: [],
faceDancers: [
{ leader: universe[0], revealed: false },
{ leader: universe[1], revealed: true },
{ leader: universe[2], revealed: false },
],
},
{ id: 'a', faction: 'atreides', traitors: [universe[3]] },
],
};
}
function begin(normal = true, skilled = true, state = snapshot()) {
return beginRihani(state, universe, {
event: 'battle-rihani', turn: 2, owner: 't',
skill: { leader: 'tleilaxu-0', normal, skilled },
}, [], random);
}
function restore(receipt: RihaniReceipt): RihaniReceipt {
return JSON.parse(JSON.stringify(receipt)) as RihaniReceipt;
}
function resigned(receipt: RihaniReceipt, change: (r: RihaniReceipt) => void) {
const changed = restore(receipt);
change(changed);
changed.signature = rihaniSignature(changed);
return changed;
}
function conserved(receipt: RihaniReceipt) {
validateRihani(receipt, universe, receipt.state);
validateNexusTraitorSnapshot(receipt.state, universe);
assert.deepEqual(receipt.state.players[0].traitors, []);
assert.deepEqual(receipt.state.players[1], receipt.before.players[1]);
}
void test('native Tleilaxu Rihani inspects first, commits a separate draw and keeps one new unrevealed Face Dancer', () => {
const original = snapshot(), frozen = structuredClone(original);
let receipt = begin(true, true, original);
assert.deepEqual(original, frozen);
assert.equal(receipt.stage, 'offer');
assert.equal(receipt.peeked.length, 2);
assert.equal(new Set(receipt.peeked).size, 2);
assert.ok(receipt.peeked.every((id) => original.reserve.includes(id)));
assert.deepEqual(receipt.drawn, []);
assert.deepEqual(receipt.eligible, [universe[0], universe[2]]);
assert.deepEqual(receipt.state.players, original.players);
conserved(receipt);
receipt = restore(receipt);
const offered = structuredClone(receipt);
const pending = chooseRihaniDraw(receipt, universe, receipt.state, true);
assert.deepEqual(receipt, offered);
assert.equal(pending.stage, 'return');
assert.deepEqual(pending.drawn, offered.deckAfterPeek.slice(0, 2));
assert.deepEqual(pending.state.players[0].faceDancers, [
...original.players[0].faceDancers!,
...pending.drawn.map((leader) => ({ leader, revealed: false })),
]);
conserved(restore(pending));
for (const kept of pending.drawn) {
const restored = restore(pending), frozenPending = structuredClone(restored);
const done = finishRihani(restored, universe, restored.state, kept, universe[0], random);
assert.deepEqual(restored, frozenPending);
assert.equal(done.stage, 'complete');
assert.equal(done.kept, kept);
assert.equal(done.given, universe[0]);
assert.deepEqual(done.state.players[0].faceDancers, [
...original.players[0].faceDancers!.slice(1),
{ leader: kept, revealed: false },
]);
assert.ok(done.state.reserve.includes(universe[0]));
assert.ok(done.state.reserve.includes(pending.drawn.find((id) => id !== kept)!));
assert.ok(!done.state.reserve.includes(kept));
conserved(restore(done));
}
});
void test('Tleilaxu Rihani normal-only inspection, lower-only exchange and pre-draw decline preserve their distinct bands', () => {
const normal = begin(true, false);
assert.equal(normal.stage, 'complete');
assert.equal(normal.peeked.length, 2);
assert.deepEqual(normal.drawn, []);
assert.deepEqual(normal.state.players, normal.before.players);
conserved(normal);
const lower = begin(false, true);
assert.equal(lower.stage, 'offer');
assert.deepEqual(lower.peeked, []);
assert.deepEqual(lower.state.reserve, lower.before.reserve);
const drawn = chooseRihaniDraw(lower, universe, lower.state, true);
const complete = finishRihani(drawn, universe, drawn.state, drawn.drawn[0], universe[2], random);
conserved(complete);
const both = begin();
const declined = chooseRihaniDraw(restore(both), universe, both.state, false);
assert.equal(declined.stage, 'complete');
assert.deepEqual(declined.state, both.state);
assert.deepEqual(declined.peeked, both.peeked);
assert.deepEqual(declined.drawn, []);
conserved(restore(declined));
assert.throws(() => chooseRihaniDraw(declined, universe, declined.state, true), /already been answered/);
});
void test('Rihani cannot replace a revealed Face Dancer or return both newly drawn cards', () => {
const offer = begin();
const drawn = chooseRihaniDraw(offer, universe, offer.state, true);
for (const given of [universe[1], ...drawn.drawn]) {
const before = structuredClone(drawn);
assert.throws(() => finishRihani(drawn, universe, drawn.state, drawn.drawn[0], given, random), /unused Traitor/);
assert.deepEqual(drawn, before);
}
const allRevealed = snapshot();
allRevealed.players[0].faceDancers!.forEach((card) => { card.revealed = true; });
const noExchange = begin(true, true, allRevealed);
assert.deepEqual(noExchange.eligible, []);
assert.equal(noExchange.stage, 'complete');
assert.equal(noExchange.peeked.length, 2);
assert.deepEqual(noExchange.state.players, allRevealed.players);
conserved(noExchange);
});
void test('Rihani rejects status changes in old, drawn and retained Face Dancers even with a recomputed signature', () => {
const offer = begin();
const drawn = chooseRihaniDraw(offer, universe, offer.state, true);
const complete = finishRihani(drawn, universe, drawn.state, drawn.drawn[0], universe[0], random);
const invalid = [
resigned(offer, (r) => { r.state.players[0].faceDancers![1].revealed = false; }),
resigned(drawn, (r) => { r.state.players[0].faceDancers![0].revealed = true; }),
resigned(drawn, (r) => { r.state.players[0].faceDancers![3].revealed = true; }),
resigned(complete, (r) => { r.state.players[0].faceDancers![0].revealed = false; }),
resigned(complete, (r) => { r.state.players[0].faceDancers![2].revealed = true; }),
resigned(drawn, (r) => { r.eligible.push(universe[1]); }),
];
for (const receipt of invalid) assert.throws(() => validateRihani(receipt, universe), /custody|drawn cards|returned card|unused cards/);
const changedCurrent = structuredClone(drawn.state);
changedCurrent.players[0].faceDancers![1].revealed = false;
assert.throws(() => finishRihani(drawn, universe, changedCurrent, drawn.drawn[0], universe[0], random), /current Traitor custody/);
const duplicate = resigned(drawn, (r) => { r.state.reserve.push(r.drawn[0]); });
assert.throws(() => validateRihani(duplicate, universe), /census/);
});
void test('ordinary Rihani receipts retain the existing used-Traitor exclusion and JSON transition', () => {
const state = snapshot();
state.players[0] = { id: 't', faction: 'harkonnen', traitors: universe.slice(0, 3) };
const offer = beginRihani(state, universe, {
event: 'legacy-rihani', turn: 1, owner: 't',
skill: { leader: 'harkonnen-0', normal: true, skilled: true },
}, [universe[1]], random);
assert.deepEqual(offer.eligible, [universe[0], universe[2]]);
const drawn = chooseRihaniDraw(restore(offer), universe, offer.state, true);
assert.deepEqual(drawn.state.players[0].traitors, [...universe.slice(0, 3), ...drawn.drawn]);
const done = finishRihani(restore(drawn), universe, drawn.state, drawn.drawn[1], universe[0], random);
assert.deepEqual(done.state.players[0].traitors, [universe[1], universe[2], drawn.drawn[1]]);
assert.equal(Object.hasOwn(done.state.players[0], 'faceDancers'), false);
validateRihani(restore(done), universe, done.state);
});
+2 -2
View File
@@ -109,7 +109,7 @@ void test('sample custody catches a duplicated base traitor after setup despite
game.players[0].traitors[0] = other;
assert.throws(
() => verifySampleCustody(game, inventory),
/physical base traitor custody/,
/physical traitor custody/,
);
});
@@ -146,6 +146,6 @@ void test('resumed sample inventory cannot shrink with a deleted leader and matc
player.traitors = player.traitors.filter((card) => card !== leader.id);
assert.throws(
() => verifySampleCustody(game, sampleInventory(game)),
/physical base traitor custody/,
/physical traitor custody/,
);
});
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
type Action,
type Game,
type Player,
} from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { traitorDeck } from '../game/traitors';
export const TLEILAXU_SKILLS_SEATS = {
tleilaxu: 't',
atreides: 'a',
emperor: 'e',
} as const;
export type TleilaxuSkillsFaction = keyof typeof TLEILAXU_SKILLS_SEATS;
export type TleilaxuSkillsFixtureOptions = {
requestedSkill?: LeaderSkillId;
skillOwner?: TleilaxuSkillsFaction;
};
const names: Record<TleilaxuSkillsFaction, string> = {
tleilaxu: 'Tleilaxu',
atreides: 'Atreides',
emperor: 'Emperor',
};
export const reloadTleilaxuSkillsGame = (game: Game): Game =>
JSON.parse(JSON.stringify(game)) as Game;
export function tleilaxuSkillsPlayer(game: Game, id: string): Player {
const player = game.players.find((candidate) => candidate.id === id);
assert.ok(player);
return player;
}
export function rejectTleilaxuSkillsAction(
game: Game,
id: string,
action: Action,
pattern?: RegExp,
): void {
const before = structuredClone(game);
if (pattern) assert.throws(() => applyAction(game, id, action), pattern);
else assert.throws(() => applyAction(game, id, action));
assert.deepEqual(game, before);
}
/** Count physical pieces only; projected/history copies do not create custody. */
export function assertTleilaxuSkillsCustody(game: Game): void {
const skills = game.leaderSkills!;
const physical = [
...skills.deck,
...Object.values(skills.offers).flatMap((offer) => offer.cards),
...skills.assignments.map((assignment) => assignment.skill),
];
const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
assert.equal(physical.length, canonical.length);
assert.equal(new Set(physical).size, canonical.length);
assert.deepEqual([...physical].sort(), [...canonical].sort());
const treachery = [
...game.deck,
...game.discard,
...game.players.flatMap((player) => player.hand),
...(game.auction?.cards.slice(game.auction.index) ?? []),
].map((card) => card.id);
assert.deepEqual(treachery.sort(), treacheryDeck(['ix']).map((card) => card.id).sort());
for (const player of game.players)
assert.equal(
player.reserves + player.tanks + Object.values(player.forces).reduce((a, b) => a + b, 0),
20,
`${player.name} must retain every physical force`,
);
if (game.status === 'playing' || game.status === 'finished') {
const traitors = [
...game.traitorReserve!,
...game.players.flatMap((player) => player.traitors),
...game.players.flatMap((player) => (player.faceDancers ?? []).map((card) => card.leader)),
];
assert.deepEqual(traitors.sort(), traitorDeck(game.players, true).sort());
assert.equal(new Set(traitors).size, traitors.length);
}
}
/** Real ready lobby and setup deal; only the production shuffle is controlled. */
export function initializedTleilaxuSkillsOffers(
options: TleilaxuSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'warmaster';
const skillOwner = options.skillOwner ?? 'tleilaxu';
const factions = (
[skillOwner, 'tleilaxu', 'atreides', 'emperor'] as TleilaxuSkillsFaction[]
).filter((faction, index, all) => all.indexOf(faction) === index);
const first = factions[0];
let game = createGame(
'TLEISKIL',
newPlayer(TLEILAXU_SKILLS_SEATS[first], names[first], first),
false,
['ix'],
);
for (const faction of factions.slice(1))
joinGame(game, newPlayer(TLEILAXU_SKILLS_SEATS[faction], names[faction], faction));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex((card) => card.id === requestedSkill);
assert.ok(targetIndex >= 0);
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
} finally {
mock.restoreAll();
}
assert.equal(game.status, 'setup');
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(game.leaderSkills!.offers[TLEILAXU_SKILLS_SEATS[skillOwner]].cards[0], requestedSkill);
assertTleilaxuSkillsCustody(game);
return game;
}
/** Complete skill assignment, ordinary Traitor selection and three Face Dancers. */
export function completedTleilaxuSkillsGame(
options: TleilaxuSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'warmaster';
const requestedOwner = TLEILAXU_SKILLS_SEATS[options.skillOwner ?? 'tleilaxu'];
let game = initializedTleilaxuSkillsOffers(options);
for (const player of game.players) {
const offer = game.leaderSkills!.offers[player.id];
game = applyAction(game, player.id, {
type: 'leaderSkill',
event: offer.event,
skill: player.id === requestedOwner ? requestedSkill : offer.cards[0],
leader: player.leaders[0].id,
});
}
assert.equal(game.setupStage, 'traitors');
assert.equal(tleilaxuSkillsPlayer(game, 't').faceDancers, undefined);
for (const player of game.players) {
if (player.faction === 'tleilaxu') continue;
const choice = tleilaxuSkillsPlayer(game, player.id).traitorChoices[0];
assert.ok(choice);
game = applyAction(game, player.id, { type: 'traitor', leader: choice });
}
assert.equal(game.status, 'playing');
assert.equal(game.setupStage, undefined);
assert.deepEqual(tleilaxuSkillsPlayer(game, 't').traitors, []);
assert.equal(tleilaxuSkillsPlayer(game, 't').faceDancers!.length, 3);
assertTleilaxuSkillsCustody(game);
return game;
}
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { createElement } from 'react';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { basicTleilaxuLeaderSkillsProfile } from '../game/leader-skill-profile';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { registerHooks } from 'node:module';
const aliases = registerHooks({resolve(specifier,context,next){return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier,context);}});
const { RihaniChoice } = await import('../components/rihani-decipherer');
aliases.deregister();
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
import { completedTleilaxuSkillsGame, initializedTleilaxuSkillsOffers, assertTleilaxuSkillsCustody as custody, tleilaxuSkillsPlayer as player, reloadTleilaxuSkillsGame as reload, rejectTleilaxuSkillsAction as reject } from './tleilaxu-skills-fixture';
const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
function stable(g: Game) {
custody(g); verifySampleCustody(g, sampleInventory(g));
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
}
// Explicit conserved positions after genuine setup; not claimed as played phase histories.
function stage(g: Game, owner = 't', opponent = 'e') {
for (const p of g.players) {
g.deck.push(...p.hand); p.hand = [];
p.forces = [owner, opponent].includes(p.id) ? { 'wind_pass:14': 6, 'carthag:11': 1 } : {};
p.reserves = [owner, opponent].includes(p.id) ? 13 : 20; p.tanks = 0; p.spice = 20;
}
Object.assign(g, { phase: 6, active: owner, storm: 18, order: [owner, opponent, ...g.players.map(p => p.id).filter(id => id !== owner && id !== opponent)],
ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
return g;
}
function open(g: Game, owner = 't', opponent = 'e') {
g = act(g, owner, { type: 'chooseBattle', territory: 'wind_pass', target: opponent });
for (let i = 0; i < 30; i++) {
if (g.response) g = act(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
else if (g.decision?.kind === 'leaderSkillVisibility') g = act(g, g.decision.player, { type: 'leaderSkillVisibility', event: g.decision.event, hide: true });
else if (g.battle?.preparation) g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
else break;
}
assert.equal(g.decision, null); assert.equal(g.battle?.preparation, undefined);
return g;
}
function plans(g: Game, own: Partial<Action> = {}, other: Partial<Action> = {}, owner = 't', opponent = 'e') {
g = act(g, owner, { type: 'battlePlan', dial: 4, leader: player(g, owner).leaders[0].id, ...own });
return act(g, opponent, { type: 'battlePlan', dial: 0, leader: player(g, opponent).leaders[1].id, ...other });
}
function resolve(g: Game, owner = 't', opponent = 'e') {
g = act(g, owner, { type: 'traitorCall', call: false });
return act(g, opponent, { type: 'traitorCall', call: false });
}
function exchange(g: Game, owner: string) {
const event = g.rihaniHistory!.at(-1)!.event;
g = act(g, owner, { type: 'decision', event, draw: true });
const r = g.rihaniHistory!.at(-1)!;
return act(g, owner, { type: 'decision', event, cards: [r.drawn[0], r.eligible[0]] });
}
void test('all fourteen skills admit genuine Basic Tleilaxu setup while public, Advanced, Ixian and module gates remain closed', () => {
for (const card of LEADER_SKILL_CARDS) {
const game = completedTleilaxuSkillsGame({ requestedSkill: card.id });
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 't')!.skill, card.id);
assert.equal(basicTleilaxuLeaderSkillsProfile(game), true);
assert.equal(basicTleilaxuLeaderSkillsProfile(viewGame(game, 'a')), true);
stable(game);
}
for (const mutate of [(g: Game) => {g.advanced = true;}, (g: Game) => {g.players[1] = newPlayer('x','Ixian','ixians');},
(g: Game) => {g.discoveryEnabled = true;}, (g: Game) => {g.expansions.push('choam');}]) {
const g = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
joinGame(g,newPlayer('e','Emperor','emperor'));g.players.forEach(p => {p.ready = true;});mutate(g);
const before = JSON.stringify(g);assert.throws(() => initializeLeaderSkillsGameForAudit(g));assert.equal(JSON.stringify(g),before);
}
const lobby = createGame('TLEIPUBL',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
joinGame(lobby,newPlayer('e','Emperor','emperor'));lobby.players.forEach(p => {p.ready = true;});reject(lobby,'t',{type:'start'});
const offer = initializedTleilaxuSkillsOffers({requestedSkill:'rihani-decipherer'});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(offer,'t');view.players.find(p => p.id === 't')!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'leaderSkill');assert.ok(action);assert.doesNotThrow(() => act(offer,'t',action));
}
});
void test('native Rihani keeps a private Face Dancer, returns only an old unrevealed card and survives JSON across every choice', () => {
let g = stage(completedTleilaxuSkillsGame({requestedSkill:'rihani-decipherer'}));
player(g,'t').faceDancers![0].revealed = true; // Conserved pre-existing public reveal.
const known = player(g,'t').faceDancers![0].leader;
g = resolve(plans(open(g)));
assert.equal(g.decision?.kind,'rihani');stable(g);
const offered = g.rihaniHistory!.at(-1)!;
assert.equal(offered.peeked.length,2);assert.equal(offered.drawn.length,0);assert.ok(!offered.eligible.includes(known));
assert.deepEqual(viewGame(g,'e').rihani!.history,[]);
reject(g,'e',{type:'decision',event:offered.event,draw:true});
const markup = renderToStaticMarkup(createElement(RihaniChoice,{game:viewGame(g,'t'),act:()=>{},busy:false}));
assert.match(markup,/Draw two Face Dancers/);assert.match(markup,/unrevealed/);
g = act(g,'t',{type:'decision',event:offered.event,draw:true});stable(g);
const drawn = g.rihaniHistory!.at(-1)!;
assert.equal(player(g,'t').faceDancers!.length,5);assert.equal(player(g,'t').traitors.length,0);
for (const observer of ['a','e']) {
assert.deepEqual(viewGame(g,observer).rihani!.history,[]);
assert.equal(viewGame(g,observer).rihani!.pending?.drawn,undefined);
}
reject(g,'t',{type:'decision',event:drawn.event,cards:[drawn.drawn[0],known]});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g,'t');view.players.find(p => p.id === 't')!.bot = difficulty;
const action=botActions(view).find(a=>a.type==='decision');assert.ok(action);assert.doesNotThrow(()=>act(g,'t',action));
}
g=act(g,'t',{type:'decision',event:drawn.event,cards:[drawn.drawn[0],drawn.eligible[0]]});
assert.equal(player(g,'t').faceDancers!.length,3);
assert.ok(player(g,'t').faceDancers!.some(c=>c.leader===known&&c.revealed));
assert.ok(player(g,'t').faceDancers!.some(c=>c.leader===drawn.drawn[0]&&!c.revealed));
assert.ok(g.traitorReserve!.includes(drawn.drawn[1]));stable(g);
});
void test('completed winner Rihani cleanup releases custody for later Face Dance and returns the killed leader skill once', () => {
let g = stage(completedTleilaxuSkillsGame({requestedSkill:'rihani-decipherer',skillOwner:'atreides'}),'a','e');
const target='atreides-0', dancer=player(g,'t').faceDancers![0];
// Swap physical identities to target this battle without fabricating a card.
const index=g.traitorReserve!.indexOf(target);
if(index>=0) {g.traitorReserve![index]=dancer.leader;dancer.leader=target;}
else {
const held=g.players.find(p=>p.traitors.includes(target));
if(held) {held.traitors[held.traitors.indexOf(target)]=dancer.leader;dancer.leader=target;}
else assert.ok(player(g,'t').faceDancers!.some(c=>c.leader===target));
}
g=resolve(plans(open(g,'a','e'),{},{},'a','e'),'a','e');
g=exchange(g,'a');
assert.equal(g.decision?.kind,'faceDance');assert.equal(g.lastBattleContext!.rihani!.faceDanceStarted,true);stable(g);
const before=structuredClone(g.rihaniHistory), spice=player(g,'t').spice;
g=act(g,'t',{type:'decision',reveal:true,sources:{reserves:1},sector:14});
assert.equal(g.phase,6);assert.equal(player(g,'a').leaders[0].dead,true);
assert.ok(!g.leaderSkills!.assignments.some(a=>a.leader===target));
assert.equal(g.leaderSkills!.deck.filter(s=>s==='rihani-decipherer').length,1);
assert.deepEqual(g.rihaniHistory,before);assert.equal(player(g,'t').spice,spice);stable(g);
});
void test('Zoal Smuggler collects copied opposing printed strength at reveal, including zero for Cheap Hero', () => {
for (const strength of [5,3,0]) {
let g=stage(completedTleilaxuSkillsGame({requestedSkill:'smuggler'}));g.spice={'wind_pass:14':9};
let other:Partial<Action>;
if(strength===0) {
const i=g.deck.findIndex(c=>c.kind==='hero');assert.ok(i>=0);
const card=g.deck.splice(i,1)[0];player(g,'e').hand.push(card);other={leader:card.id};
} else {
const disc=player(g,'e').leaders.find(l=>l.strength===strength);assert.ok(disc);other={leader:disc.id};
}
g=plans(open(g),{},other);
assert.equal(g.battle!.smugglerCollection!.strength,strength);
assert.equal(player(g,'t').spice,20); // Sealing/reveal records, settlement collects.
g=resolve(g);assert.equal(player(g,'t').spice,20+strength);assert.equal(g.spice['wind_pass:14'],9-strength);stable(g);
}
});
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import { unitStore } from './fixture-nexus-room-store';
import { startPrototypeRoom } from '../tools/prototype-room';
import { assertTleilaxuSkillsCustody } from './tleilaxu-skills-fixture';
import type { Action } from '../game/engine';
void test('authenticated restart and concurrent Tleilaxu setup choices retain one skill and three private Face Dancers', async () => {
const f = unitStore();
const db: DatabaseSync = f.sqlite;
const clock = { now: () => 10000, sleep: async () => {} };
try {
const created = await f.rooms.createRoom('Tleilaxu', 'tleilaxu', false, ['ix']);
const code = created.view.code;
const joined = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const auths = await Promise.all([created.token!, joined.token!].map(token => f.restart().authenticate(code, token)));
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
await act(0, { type: 'ready' }); await act(1, { type: 'ready' });
const ready = await f.restart().readRoom(code);
const seats = db.prepare('SELECT * FROM seats').all();
startPrototypeRoom(db, code, ready.version, 'leader-skills');
const initial = await f.restart().readRoom(code);
assertTleilaxuSkillsCustody(initial);
for (const auth of auths) {
const view = await f.restart().readSeatView(code, auth);
assert.deepEqual(view.leaderSkills!.offer, initial.leaderSkills!.offers[auth.playerId]);
assert.equal('offers' in view.leaderSkills!, false);
assert.equal('deck' in view.leaderSkills!, false);
}
const offer = initial.leaderSkills!.offers[auths[0].playerId];
const choice = { type: 'leaderSkill', event: offer.event, skill: offer.cards[0], leader: 'tleilaxu-0' };
let arrivals = 0; let release!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
const results = await Promise.allSettled([
f.rooms.act(code, auths[0], initial.version, choice, clock),
f.restart().act(code, auths[0], initial.version, choice, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
assert.equal(results.filter(r => r.status === 'rejected').length, 1);
let game = await f.restart().readRoom(code);
assert.equal(game.version, initial.version + 1);
assert.equal(game.leaderSkills!.assignments.filter(a => a.owner === auths[0].playerId).length, 1);
assert.equal(game.leaderSkills!.deck.filter(s => s === offer.cards[1]).length, 1);
const other = game.leaderSkills!.offers[auths[1].playerId];
game = await act(1, { type: 'leaderSkill', event: other.event, skill: other.cards[0], leader: 'atreides-0' });
game = await act(1, { type: 'traitor', leader: game.players.find(p => p.id === auths[1].playerId)!.traitorChoices[0] });
assert.equal(game.status, 'playing');
assert.equal(game.players.find(p => p.id === auths[0].playerId)!.faceDancers!.length, 3);
const own = await f.restart().readSeatView(code, auths[0]);
const otherView = await f.restart().readSeatView(code, auths[1]);
assert.equal(own.players.find(p => p.id === auths[0].playerId)!.faceDancers!.length, 3);
assert.equal(otherView.players.find(p => p.id === auths[0].playerId)!.faceDancers, undefined);
assertTleilaxuSkillsCustody(game);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths) {
const view = await f.restart().readSeatView(code, auth);
assert.equal(view.leaderSkills!.assignments.length, 2);
assert.equal(view.leaderSkills!.offer, null);
}
} finally { db.close(); }
});
+138
View File
@@ -0,0 +1,138 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, type Action, type Game } from '../game/engine';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import {
assertTleilaxuSkillsCustody as custody,
completedTleilaxuSkillsGame,
reloadTleilaxuSkillsGame as reload,
rejectTleilaxuSkillsAction as reject,
tleilaxuSkillsPlayer as player,
} from './tleilaxu-skills-fixture';
const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
/** Conserved battle positions after genuine setup, with a second battle keeping phase 6 open. */
function stage(g: Game, owner = 't') {
for (const p of g.players) {
g.deck.push(...p.hand); p.hand = [];
p.forces = [owner, 'e'].includes(p.id) ? { 'wind_pass:14': 6, 'carthag:11': 1 } : {};
p.reserves = [owner, 'e'].includes(p.id) ? 13 : 20;
p.tanks = 0; p.spice = 20;
}
Object.assign(g, { phase: 6, active: owner, storm: 18,
order: [owner, 'e', ...g.players.map(p => p.id).filter(id => id !== owner && id !== 'e')],
ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
return g;
}
function open(g: Game, owner = 't') {
g = act(g, owner, { type: 'chooseBattle', territory: 'wind_pass', target: 'e' });
for (let i = 0; i < 30; i++) {
if (g.response) g = act(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
else if (g.decision?.kind === 'leaderSkillVisibility')
g = act(g, g.decision.player, { type: 'leaderSkillVisibility', event: g.decision.event, hide: true });
else if (g.battle?.preparation) g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
else break;
}
assert.equal(g.decision, null); assert.equal(g.battle?.preparation, undefined);
return g;
}
function resolve(g: Game, owner = 't') {
g = act(g, owner, { type: 'traitorCall', call: false });
return act(g, 'e', { type: 'traitorCall', call: false });
}
function assignPhysicalSkill(g: Game, owner: string, skill: LeaderSkillId) {
const target = g.leaderSkills!.assignments.find(a => a.owner === owner)!;
const previous = g.leaderSkills!.assignments.find(a => a.skill === skill);
if (previous) [target.skill, previous.skill] = [previous.skill, target.skill];
else {
const index = g.leaderSkills!.deck.indexOf(skill);
assert.ok(index >= 0);
[target.skill, g.leaderSkills!.deck[index]] = [g.leaderSkills!.deck[index], target.skill];
}
custody(g);
}
function matchingFaceDancer(g: Game, target: string) {
const dancers = player(g, 't').faceDancers!;
if (dancers.some(card => card.leader === target)) return;
const former = dancers[0].leader;
const index = g.traitorReserve!.indexOf(target);
if (index >= 0) g.traitorReserve![index] = former;
else {
const owner = g.players.find(p => p.traitors.includes(target));
assert.ok(owner);
owner.traitors[owner.traitors.indexOf(target)] = former;
}
dancers[0].leader = target;
}
void test('Zoal Smuggler copies the opposing disc without the surviving Mentat battle bonus', () => {
let g = stage(completedTleilaxuSkillsGame({ requestedSkill: 'smuggler' }));
// This swaps the two actual cards; it creates neither a duplicate nor a reduced deck.
assignPhysicalSkill(g, 'e', 'mentat');
const printed = player(g, 'e').leaders.find(leader => leader.id === 'emperor-0')!.strength;
g.spice = { 'wind_pass:14': 9 };
g = open(g);
g = act(g, 't', { type: 'battlePlan', dial: 4, leader: 'tleilaxu-0' });
assert.equal(g.battle!.smugglerCollection, undefined, 'A sealed first plan cannot inspect the opposing disc.');
g = act(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
assert.equal(g.battle!.smugglerCollection!.strength, printed);
assert.equal(g.battle!.smugglerCollection!.amount, printed);
g = resolve(g);
assert.ok(g.log.some(entry => /Emperor gained 2 battle strength from Mentat/.test(entry.text)));
assert.equal(g.lastBattleContext!.winner, 't');
assert.equal(g.lastBattleContext!.smugglerCollection!.strength, printed);
assert.equal(player(g, 't').spice, 20 + printed);
assert.equal(g.spice['wind_pass:14'], 9 - printed);
custody(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
});
void test('Rihani keeps live custody through winner cleanup then survives all-three Face Dancer replacement as immutable history', () => {
let g = stage(completedTleilaxuSkillsGame({ requestedSkill: 'rihani-decipherer', skillOwner: 'atreides' }), 'a');
matchingFaceDancer(g, 'atreides-0');
// Two earlier reveals are explicitly staged; the current Face Dance triggers the native reset.
for (const card of player(g, 't').faceDancers!) card.revealed = card.leader !== 'atreides-0';
const index = g.deck.findIndex(card => card.kind === 'worthless');
assert.ok(index >= 0);
const worthless = g.deck.splice(index, 1)[0]; player(g, 'a').hand.push(worthless);
custody(g);
g = open(g, 'a');
g = act(g, 'a', { type: 'battlePlan', dial: 4, leader: 'atreides-0', weapon: worthless.id });
g = act(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-1' });
g = resolve(g, 'a');
const event = g.rihaniHistory!.at(-1)!.event;
g = act(g, 'a', { type: 'decision', event, draw: false });
assert.equal(g.decision?.kind, 'battleCards');
assert.equal(g.lastBattleContext!.rihani!.faceDanceStarted, undefined);
const shuffledEarly = reload(g);
shuffledEarly.traitorReserve!.reverse();
reject(shuffledEarly, 'a', { type: 'decision', discard: [] }, /current Traitor custody/);
const prematureRelease = reload(g);
prematureRelease.lastBattleContext!.rihani!.faceDanceStarted = true;
reject(prematureRelease, 'a', { type: 'decision', discard: [] }, /obligation changed/);
g = act(g, 'a', { type: 'decision', discard: [] });
assert.equal(g.decision?.kind, 'faceDance');
assert.equal(g.lastBattleContext!.rihani!.faceDanceStarted, true);
const history = structuredClone(g.rihaniHistory);
const unmarked = reload(g);
delete unmarked.lastBattleContext!.rihani!.faceDanceStarted;
reject(unmarked, 't', { type: 'decision', reveal: false }, /obligation changed/);
const changedHistory = reload(g);
changedHistory.rihaniHistory![0].peeked.reverse();
reject(changedHistory, 't', { type: 'decision', reveal: false }, /receipt is invalid/);
const duplicated = reload(g);
duplicated.traitorReserve!.push(player(duplicated, 't').faceDancers![0].leader);
reject(duplicated, 't', { type: 'decision', reveal: false }, /census/);
g = act(g, 't', { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 14 });
assert.equal(g.phase, 6);
assert.equal(g.pendingFaceDance, null);
assert.equal(player(g, 't').faceDancers!.length, 3);
assert.ok(player(g, 't').faceDancers!.every(card => !card.revealed));
assert.deepEqual(g.rihaniHistory, history);
assert.equal(g.leaderSkills!.deck.filter(skill => skill === 'rihani-decipherer').length, 1);
custody(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
});
+311
View File
@@ -0,0 +1,311 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import type { Card } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { newRevivalRules } from '../game/revival';
import {
assertTleilaxuSkillsCustody as conserved,
completedTleilaxuSkillsGame,
reloadTleilaxuSkillsGame as reload,
rejectTleilaxuSkillsAction as reject,
tleilaxuSkillsPlayer as player,
} from './tleilaxu-skills-fixture';
const act = (game: Game, owner: string, action: Action): Game =>
applyAction(reload(game), owner, action);
function take(game: Game, owner: string, predicate: (card: Card) => boolean): Card {
const index = game.deck.findIndex(predicate);
assert.ok(index >= 0, 'The selected physical Treachery Card must exist in the real deck.');
const [card] = game.deck.splice(index, 1);
player(game, owner).hand.push(card);
return card;
}
function passWindow(state: Game): Game {
let game = state;
const kind = game.response!.kind;
for (let step = 0; game.response?.kind === kind && step < 8; step++) {
const responder = game.players.find((candidate) => !game.response!.passed.includes(candidate.id));
assert.ok(responder);
game = act(game, responder.id, { type: 'passResponse' });
}
assert.notEqual(game.response?.kind, kind);
return game;
}
/** Stage conserved armies, then kill the original skilled leader in a real battle. */
function killedOriginalSkill(owner: 't' | 'a' = 't'): Game {
let game = completedTleilaxuSkillsGame({ skillOwner: owner === 't' ? 'tleilaxu' : 'atreides' });
for (const p of game.players) {
game.deck.push(...p.hand);
p.hand = [];
p.forces = [owner, 'e'].includes(p.id)
? { 'wind_pass:14': 5, 'carthag:11': 1 }
: { 'polar_sink:0': 1 };
p.reserves = [owner, 'e'].includes(p.id) ? 14 : 19;
p.tanks = 0;
p.spice = 20;
}
Object.assign(game, {
phase: 6, storm: 18, order: [owner, 'e', owner === 't' ? 'a' : 't'],
active: owner, ready: [], decision: null, response: null, phaseOpening: null,
});
const poison = take(game, 'e', (card) => card.kind === 'poison');
game = act(game, owner, { type: 'chooseBattle', territory: 'wind_pass', target: 'e' });
for (let step = 0; step < 20; step++) {
if (game.response) game = passWindow(game);
else if (game.decision?.kind === 'leaderSkillVisibility') {
const decision = game.decision;
game = act(game, decision.player, {
type: 'leaderSkillVisibility', event: decision.event, hide: true,
});
} else if (game.battle?.preparation)
game = act(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
else break;
}
assert.equal(game.decision, null);
assert.equal(game.battle?.preparation, undefined);
const leader = player(game, owner).leaders[0].id;
game = act(game, owner, { type: 'battlePlan', dial: 0, leader });
game = act(game, 'e', {
type: 'battlePlan', dial: 1, leader: 'emperor-1', weapon: poison.id,
});
game = act(game, owner, { type: 'traitorCall', call: false });
game = act(game, 'e', { type: 'traitorCall', call: false });
for (let step = 0; game.decision && step < 10; step++) {
const decision = game.decision;
if (decision.kind === 'battleCards')
game = act(game, decision.player, { type: 'decision', discard: [] });
else if (decision.kind === 'faceDance')
game = act(game, decision.player, { type: 'decision', reveal: false });
else assert.fail(`Unexpected postbattle choice: ${decision.kind}`);
}
assert.equal(player(game, owner).leaders[0].dead, true);
assert.equal(game.leaderSkills!.assignments.some((assignment) => assignment.owner === owner), false);
assert.equal(game.leaderSkills!.deck.filter((skill) => skill === 'warmaster').length, 1);
assert.equal(game.leaderSkills!.offers[owner], undefined);
conserved(game);
return game;
}
/** A clean Revival boundary retains every card, disc and force from the battle. */
function revivalGame(owner: 't' | 'a' = 't', heldKarama = false): Game {
const game = killedOriginalSkill(owner);
Object.assign(game, {
phase: 4, active: null, ready: [], decision: null, response: null,
phaseOpening: null, revivalRules: newRevivalRules(),
});
for (const p of game.players) p.leaderRevived = false;
if (heldKarama) take(game, 'e', (card) => card.effect === 'karama');
conserved(game);
return game;
}
function reviveOwn(game: Game): Game {
return act(game, 't', { type: 'reviveLeader', leader: 'tleilaxu-0' });
}
function negotiatedRevival(game: Game, amount = 7): Game {
game = act(game, 'a', { type: 'requestLeaderRevival', leader: 'atreides-0' });
game = act(game, 't', { type: 'quoteLeaderRevival', target: 'a', amount });
return act(game, 'a', { type: 'acceptLeaderRevival' });
}
function assertOffer(game: Game, owner: 't' | 'a'): string {
const offer = game.leaderSkills!.offers[owner];
assert.ok(offer);
assert.equal(offer.leader, player(game, owner).leaders[0].id);
assert.deepEqual(offer.cards, []);
for (const viewer of game.players) {
const projection = viewGame(reload(game), viewer.id);
assert.deepEqual(projection.leaderSkills!.offer, viewer.id === owner ? offer : null);
assert.equal('deck' in projection.leaderSkills!, false);
assert.equal('offers' in projection.leaderSkills!, false);
}
conserved(game);
return offer.event;
}
void test('genuine Basic Tleilaxu battle death returns the original physical skill exactly once', () => {
const game = killedOriginalSkill();
const restored = normalizeAutomaticGame(reload(game));
assert.deepEqual(restored, game);
assert.equal(game.log.filter((entry) => entry.text.includes('Warmaster returned')).length, 1);
assert.equal(player(game, 't').faceDancers!.length, 3);
assert.deepEqual(player(game, 't').traitors, []);
conserved(restored);
});
void test('automatic Tleilaxu early permission and discount keep a private undrawn skill offer after own revival', () => {
const before = revivalGame();
const previousDeck = [...before.leaderSkills!.deck];
const original = player(before, 't').leaders[0];
const cost = Math.ceil(original.strength / 2);
const game = reviveOwn(before);
assert.equal(game.response, null);
assert.equal(game.pendingRevival, null);
assert.equal(player(game, 't').leaders[0].dead, false);
assert.equal(player(game, 't').spice, player(before, 't').spice - cost);
assert.equal(player(game, 'e').spice, player(before, 'e').spice);
assert.equal(game.decision?.kind, 'leaderSkillRevival');
assertOffer(game, 't');
assert.deepEqual(game.leaderSkills!.deck, previousDeck, 'An optional draw must not preview or remove cards.');
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
assert.equal(before.leaderSkills!.offers.t, undefined, 'Applying revival must leave its input unchanged.');
});
void test('held Karama preserves early permission and discount as separate saved windows before the own-leader offer', () => {
const before = revivalGame('t', true);
let game = reviveOwn(before);
assert.equal(game.response?.kind, 'earlyRevival');
assert.equal(player(game, 't').leaders[0].dead, true);
assert.equal(game.leaderSkills!.offers.t, undefined);
assert.equal(player(game, 't').spice, player(before, 't').spice);
reject(game, 't', { type: 'leaderSkill', event: 'not-yet-created', mode: 'draw' });
game = passWindow(game);
assert.equal(game.response?.kind, 'revivalDiscount');
assert.equal(player(game, 't').leaders[0].dead, true);
assert.equal(game.leaderSkills!.offers.t, undefined);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
game = passWindow(game);
assert.equal(game.response, null);
assert.equal(game.decision?.kind, 'leaderSkillRevival');
const event = assertOffer(game, 't');
const restored = normalizeAutomaticGame(normalizeAutomaticGame(reload(game)));
assert.equal(restored.leaderSkills!.offers.t.event, event);
assert.deepEqual(restored, game);
});
void test('declining the optional own-leader skill draw keeps deck order and never reopens after JSON normalization', () => {
let game = reviveOwn(revivalGame());
const event = assertOffer(game, 't');
const deck = [...game.leaderSkills!.deck];
game = act(game, 't', { type: 'leaderSkill', event, mode: 'decline' });
assert.equal(game.decision, null);
assert.equal(game.leaderSkills!.offers.t, undefined);
assert.deepEqual(game.leaderSkills!.deck, deck);
assert.equal(game.leaderSkills!.assignments.some((assignment) => assignment.owner === 't'), false);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
reject(game, 't', { type: 'leaderSkill', event, mode: 'draw' });
conserved(game);
});
void test('saved optional draw rejects stale, alien and wrong-leader choices and binds one dealt card to the same revived disc', () => {
let game = reviveOwn(revivalGame());
const event = assertOffer(game, 't');
reject(game, 'e', { type: 'leaderSkill', event, mode: 'draw' });
reject(game, 't', { type: 'leaderSkill', event: `${event}-old`, mode: 'draw' });
reject(game, 't', { type: 'leaderSkill', event, skill: game.leaderSkills!.deck[0], leader: 'tleilaxu-0' });
const deck = [...game.leaderSkills!.deck];
game = act(game, 't', { type: 'leaderSkill', event, mode: 'draw' });
assert.deepEqual(game.leaderSkills!.offers.t.cards, deck.slice(0, 2));
assert.deepEqual(game.leaderSkills!.deck, deck.slice(2));
assert.equal(viewGame(reload(game), 'e').leaderSkills!.offer, null);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
const [chosen, returned] = game.leaderSkills!.offers.t.cards;
reject(game, 't', { type: 'leaderSkill', event, mode: 'draw' });
reject(game, 't', { type: 'leaderSkill', event, mode: 'decline' });
reject(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'tleilaxu-1' });
reject(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'emperor-0' });
reject(game, 't', { type: 'leaderSkill', event, skill: game.leaderSkills!.deck[0], leader: 'tleilaxu-0' });
game = act(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'tleilaxu-0' });
assert.equal(game.decision, null);
assert.equal(game.leaderSkills!.offers.t, undefined);
assert.deepEqual(game.leaderSkills!.assignments.find((assignment) => assignment.owner === 't'), {
owner: 't', leader: 'tleilaxu-0', skill: chosen,
});
assert.equal(game.leaderSkills!.deck.filter((skill) => skill === returned).length, 1);
assert.equal(game.leaderSkills!.deck.includes(chosen), false);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
reject(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'tleilaxu-0' });
conserved(game);
});
for (const heldKarama of [false, true])
void test(`negotiated original-leader revival retains one offer through native Tleilaxu income ${heldKarama ? 'with held Karama' : 'automatically'}`, () => {
const before = revivalGame('a', heldKarama);
let game = negotiatedRevival(before);
if (heldKarama) {
assert.equal(game.response?.kind, 'earlyRevival');
assert.equal(game.leaderSkills!.offers.a, undefined);
assert.equal(player(game, 'a').leaders[0].dead, true);
game = passWindow(game);
assert.equal(game.response?.kind, 'revivalIncome');
assert.equal(player(game, 't').spice, player(before, 't').spice);
const event = assertOffer(game, 'a');
reject(game, 'a', { type: 'leaderSkill', event, mode: 'draw' });
game = passWindow(game);
assert.equal(assertOffer(game, 'a'), event);
}
assert.equal(game.response, null);
assert.equal(game.pendingRevival, null);
assert.equal(game.decision?.kind, 'leaderSkillRevival');
assert.equal(player(game, 'a').leaders[0].dead, false);
assert.equal(player(game, 'a').spice, player(before, 'a').spice - 7);
assert.equal(player(game, 't').spice, player(before, 't').spice + 7);
assertOffer(game, 'a');
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
conserved(game);
});
for (const owner of ['t', 'a'] as const)
void test(`Karama cancellation of ${owner === 't' ? 'native Tleilaxu' : 'negotiated opponent'} early permission leaves the leader dead and creates no skill offer`, () => {
const before = revivalGame(owner, true);
let game = owner === 't' ? reviveOwn(before) : negotiatedRevival(before);
assert.equal(game.response?.kind, 'earlyRevival');
const karama = player(game, 'e').hand.find((card) => card.effect === 'karama')!;
game = act(game, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(game.pendingRevival, null);
assert.equal(game.response, null);
assert.equal(game.decision, null);
assert.equal(player(game, owner).leaders[0].dead, true);
assert.equal(game.leaderSkills!.offers[owner], undefined);
assert.equal(player(game, owner).spice, player(before, owner).spice);
assert.equal(player(game, 't').spice, player(before, 't').spice);
assert.equal(game.discard.filter((card) => card.id === karama.id).length, 1);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
conserved(game);
});
void test('canceling an unaffordable Tleilaxu discount aborts before revival and cannot create a skill offer', () => {
const before = revivalGame('t', true);
player(before, 't').spice = Math.ceil(player(before, 't').leaders[0].strength / 2);
let game = passWindow(reviveOwn(before));
assert.equal(game.response?.kind, 'revivalDiscount');
const karama = player(game, 'e').hand.find((card) => card.effect === 'karama')!;
game = act(game, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(game.pendingRevival, null);
assert.equal(player(game, 't').leaders[0].dead, true);
assert.equal(player(game, 't').spice, player(before, 't').spice);
assert.equal(game.leaderSkills!.offers.t, undefined);
assert.equal(game.decision, null);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
conserved(game);
});
void test('each AI level can complete the private original-leader draw with legal saved actions', () => {
const revived = reviveOwn(revivalGame());
for (const difficulty of DIFFICULTIES) {
let game = reload(revived);
player(game, 't').bot = difficulty;
for (let step = 0; game.leaderSkills!.offers.t && step < 3; step++) {
const action = botActions(viewGame(reload(game), 't'))[0];
assert.ok(action);
assert.equal(action.type, 'leaderSkill');
game = act(game, 't', action);
}
assert.equal(game.leaderSkills!.offers.t, undefined, difficulty);
assert.equal(game.decision, null);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
conserved(game);
}
});
+25 -16
View File
@@ -20,13 +20,13 @@ import {
type Game,
} from '../game/engine';
import { sampleInventory, verifySampleCustody } from './sample-custody';
import { basicMoritaniLeaderSkillsProfile } from '../game/leader-skill-profile';
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
import { privateOutputDirectory, sourceSnapshot } from './verification';
const DEFAULT_SEED = 20_260_926;
const DEFAULT_MAX_ACTIONS = 3_500;
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'moritani-skills';
type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'moritani-skills' | 'tleilaxu-skills';
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -112,6 +112,15 @@ const MORITANI_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(playe
roster: MORITANI_SKILLS_ROSTER.slice(0, players),
}));
const TLEILAXU_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({
ordinal: 21 + players - 2,
profile: 'tleilaxu-skills',
rules: 'basic',
expansions: ['ix'],
roster: (['tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
}));
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills';
type TraceEntry = {
attempt: number;
accepted: number;
@@ -148,10 +157,10 @@ type Result = {
function usage() {
return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
'[--seed UINT32] [--profile all|base|choam|ecaz|combined|moritani-skills] ' +
'[--seed UINT32] [--profile all|base|choam|ecaz|combined|moritani-skills|tleilaxu-skills] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' +
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and moritani-skills default to their 2–6-player samples. --players requires --profile base or moritani-skills. Output must be a new private directory outside the checkout.'
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and the skill profiles default to their 2–6-player samples. --players requires --profile base, moritani-skills or tleilaxu-skills. Output must be a new private directory outside the checkout.'
);
}
@@ -182,15 +191,15 @@ function positive(value: string | undefined) {
}
function scenarioName(scenario: Scenario) {
return scenario.profile === 'base' || scenario.profile === 'moritani-skills'
return scenario.profile === 'base' || skillProfile(scenario.profile)
? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}`
: `${scenario.profile}-${scenario.rules}`;
}
function parseProfile(value: string | undefined) {
const profile = value ?? 'all';
if (!['all', 'base', 'choam', 'ecaz', 'combined', 'moritani-skills'].includes(profile))
throw new Error('--profile must be all, base, choam, ecaz, combined or moritani-skills.');
if (!['all', 'base', 'choam', 'ecaz', 'combined', 'moritani-skills', 'tleilaxu-skills'].includes(profile))
throw new Error('--profile must be all, base, choam, ecaz, combined, moritani-skills or tleilaxu-skills.');
return profile as Profile | 'all';
}
@@ -234,7 +243,7 @@ function freshGame(scenario: Scenario) {
player.bot = DIFFICULTIES[index % DIFFICULTIES.length];
player.ready = true;
}
return scenario.profile === 'moritani-skills'
return skillProfile(scenario.profile)
? initializeLeaderSkillsGameForAudit(game)
: scenario.profile === 'base'
? initializeBaseGameForAudit(game)
@@ -275,7 +284,7 @@ function resumedGame(path: string) {
if (
game.homeworlds ||
game.nexusCards ||
(game.leaderSkills && !basicMoritaniLeaderSkillsProfile(game)) ||
(game.leaderSkills && !basicExpansionLeaderSkillsProfile(game)) ||
game.discoveryEnabled ||
game.discoveries ||
game.discoveryStash ||
@@ -290,9 +299,9 @@ function resumedGame(path: string) {
game.moritaniAssassinateCallEvents
)
throw new Error('--resume sample scenarios exclude optional modules.');
const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS].find(
const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS].find(
(candidate) =>
(candidate.profile === 'moritani-skills') === !!game.leaderSkills &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) ===
JSON.stringify(game.expansions) &&
@@ -485,14 +494,14 @@ async function main() {
const profile = parseProfile(values.profile);
const rules = parseRules(values.rules);
const players = parsePlayers(values.players);
if (supplied('players') && profile !== 'base' && profile !== 'moritani-skills')
throw new Error('--players requires --profile base or moritani-skills.');
if (profile === 'moritani-skills' && rules === 'advanced')
throw new Error('Moritani with Leader Skills currently supports only Basic samples.');
if (supplied('players') && profile !== 'base' && !skillProfile(profile))
throw new Error('--players requires --profile base, moritani-skills or tleilaxu-skills.');
if (skillProfile(profile) && rules === 'advanced')
throw new Error('Moritani and Tleilaxu with Leader Skills currently support only Basic samples.');
const resume = values.resume ? resumedGame(values.resume) : null;
const selected = resume
? [resume.scenario]
: (profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : SCENARIOS).filter(
: (profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS : SCENARIOS).filter(
(scenario) =>
(profile === 'all' || scenario.profile === profile) &&
(rules === 'both' || scenario.rules === rules) &&
+8 -5
View File
@@ -3,7 +3,7 @@ import { leaders, treacheryDeck } from '../game/cards';
import type { Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import { basicMoritaniLeaderSkillsProfile } from '../game/leader-skill-profile';
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
import { validateLeaderSkills } from '../game/leader-skills';
/** Fixed inventory captured after genuine setup initialization, before any choices. */
@@ -18,11 +18,12 @@ export function sampleInventory(game: Game) {
]
.map((card) => card.id)
.sort(),
// Expansion Face Dancer/assassination zones require a separate adapter.
traitors: game.expansions.length && !basicMoritaniLeaderSkillsProfile(game)
// Other expansion assassination zones require a separate adapter.
traitors: game.expansions.length && !basicExpansionLeaderSkillsProfile(game)
? null
: traitorDeck(
game.players.map((player) => ({ leaders: leaders(player.faction) })),
game.expansions.includes('ix'),
).sort(),
};
}
@@ -111,9 +112,11 @@ export function verifySampleCustody(
assert.deepEqual(
[
...(game.traitorReserve ?? []),
...game.players.flatMap((player) => player.traitors),
...game.players.flatMap((player) => [
...player.traitors, ...(player.faceDancers ?? []).map(card => card.leader),
]),
].sort(),
inventory.traitors,
'physical base traitor custody',
'physical traitor custody',
);
}