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:
1 parent
489bd3265d
commit
d3559f65ad
28 files changed
+1324
-97
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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' : ''}.`,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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) &&
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user