Compose native E1/E2 Leader Skills with original Tech Tokens
This commit is contained in:
1 parent
7a6972cd1b
commit
219489ae04
20 files changed
+1734
-29
No files matched your search
@@ -16,6 +16,29 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Native E1/E2 Leader Skills / Tech Tokens — bounded Development Verified:**
|
||||
fresh supported Ixian/Tleilaxu/CHOAM/Richese Basic/Advanced3..6-seat entries
|
||||
preserve canonical unused tokens, original family decks/all14 skills, Richese
|
||||
cache and native setup/first-Storm owners. Other overlays/E3, public starts,
|
||||
save conversion and unrelated rulings remain excluded. Existing Richese
|
||||
Banker invoice validation admits native marker/event fields; payment, typed
|
||||
casualties, Suk/card cleanup and original winner Tech before Face Dance remain.
|
||||
Native52/52, shared261 passing plus the corrected exact obsolete-rejection case,
|
||||
types/lint/build and nine actual rule programs pass. Seventeen genuine3-seat
|
||||
games (fifteen native) complete5,733 actions/no rejection and149 JSON
|
||||
continuations on unchanged source. Original CLI **VQ3AVFTC/M7HPHNTN/PQ6U2X4S**
|
||||
retain native setup atv7. Human390px **7RPHJN87 v15** rescues actual mixed
|
||||
Suboid/cyborg losses before retaining Shield and taking sole Axlotl;
|
||||
**F244P8DT v8** replaces actual board/reserve sources after original Guild
|
||||
Tech reward, preserving win/cards/bounty; **YWVH5XQF v8** ships concealed5
|
||||
plus one free physical companion for1 while original Tech and unavailable
|
||||
Banker currencies remain separate. Refresh preserves those versions/outcomes.
|
||||
See [scope, source pages and evidence](LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026).
|
||||
Full combinations/assurance/AI/public/deployed gates remain open.
|
||||
Final guide10/10, types/lint/build and rebuilt390px native reference pass
|
||||
(351px topic, no page overflow). Original human versions remain after the
|
||||
final isolated QA reload; owned temporary drivers/tabs are removed.
|
||||
|
||||
**Classic Leader Skills / Tech Tokens — bounded Development Verified:**
|
||||
fresh classic3..6 Basic/Advanced `leader-skills` and separate `banker-income`
|
||||
preserve unused selected Tech Tokens with base33/all14 and original setup/
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 4 October 2026 — native E1/E2 Leader Skills with original Tech Tokens
|
||||
|
||||
Three bounded owners supplied independent Ixian/CHOAM, Tleilaxu and Richese
|
||||
rule cases; the integration owner extended existing native eligibility,
|
||||
initializer, original Banker/Mentat opt-ins, CLI and canonical guidance.
|
||||
Fresh supported3..6-seat Basic/Advanced retains original deck/roster limits,
|
||||
all14 skills, cache, native wallets and printed Tech assignment. Public starts,
|
||||
E3/other modules, played-game conversion and unrelated ruling guards remain.
|
||||
The supplied Advanced source physical21/38 expressly supports the combination
|
||||
and original winner Tech before Face Dance.
|
||||
|
||||
Actual native Richese marker invoices exposed an existing Banker action-key
|
||||
whitelist omission: legal `noField`/`event` was rejected. Admit those original
|
||||
fields only for native Richese marker shipping; original validators still
|
||||
enforce source, custody, event and funding. Shared fixture continuation handles
|
||||
actual phase-opening readiness before response passes. Controlled cases use
|
||||
real cyborgs/legal typed commitment and physical winner-card cleanup rather
|
||||
than fabricated dial counts or incidental null/undefined receipt assertions.
|
||||
|
||||
Native52/52,261 affected shared cases and the corrected exact obsolete
|
||||
prototype-rejection case, types/lint/build and nine actual programs pass.
|
||||
Seventeen genuine3-seat games (fifteen native) complete5,733 accepted actions,
|
||||
no rejection and149 JSON continuations on unchanged source tree
|
||||
`14be0172b28a45fcabbf0240da12d6718e4fdc871d9a2b639466e6e90d8a1aca`.
|
||||
Original CLI VQ3AVFTC/M7HPHNTN/PQ6U2X4S retains native setup atv7.
|
||||
Human390px7RPHJN87v15 typed Suk/card cleanup/sole Axlotl,
|
||||
F244P8DTv8 original Tech-before-FaceDance with mixed real sources, and
|
||||
YWVH5XQFv8 marker5/free companion/price1 retain original outcomes on refresh.
|
||||
The earlier compiled worker rejected native Richese Banker admission; the
|
||||
rebuilt isolated idle QA reload preserves the rooms and exercises the new path.
|
||||
Detailed current mechanics/evidence remain in
|
||||
[the existing skill runtime](LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026).
|
||||
No full-module, strategic-AI, assurance, public or deployed acceptance claim.
|
||||
|
||||
## 4 October 2026 — classic Leader Skills with original Tech Tokens
|
||||
|
||||
Two bounded owners supplied normal payment/free-companion and battle rescue/
|
||||
|
||||
@@ -29,13 +29,82 @@ Advanced native/combined-module skills remain guarded. See
|
||||
[native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026).
|
||||
|
||||
|
||||
## Native E1/E2 Tech Tokens composition — 4 October 2026
|
||||
|
||||
Fresh three-through-six-seat Basic/Advanced `leader-skills` and separate
|
||||
`banker-income` entries now preserve canonical unused Tech Tokens for the
|
||||
already-supported native Ixian, Tleilaxu, CHOAM and Richese skill rosters.
|
||||
Original family/deck restrictions remain: Basic uses its single supported
|
||||
family; Advanced admits distinct selected E1/E2 decks and their eligible native
|
||||
seats. All fourteen skills, ordinary33/35/47 cards, Richese's original ten-card
|
||||
cache, native setup, wallets and printed Tech owners remain genuine.
|
||||
An extra selected Ix deck never implies a native Ixian seat.
|
||||
E3, other overlays, public starts and played-game conversion remain excluded.
|
||||
Existing Banker and private Mentat opt-ins do not become ordinary activation.
|
||||
|
||||
The authorized supplied Advanced PDF, physical page21 (printed19), permits
|
||||
variants together irrespective of factions and gives native Tech assignment,
|
||||
phase-end income and losing-owner token reward. Physical page38 (printed36),
|
||||
Tleilaxu A.1–3, explicitly retains the original winner's cards, bounty and
|
||||
appropriate Tech Token before replacement. This composition reuses that
|
||||
original order; Face Dance neither revokes nor creates another reward.
|
||||
Tleilaxu-only free revival still excludes Axlotl; CHOAM native initial income
|
||||
is not poverty Charity and triggers neither Production nor Axlotl.
|
||||
Actual eligible Charity triggers Production alone.
|
||||
|
||||
Ixian paid/free Suboids and cyborgs retain typed payment and loss allocation.
|
||||
Suk rescue and optional native substitution finish before card cleanup and
|
||||
mandatory Tech award. Richese's marker-only revelation remains capped by real
|
||||
reserves. Its optional Smuggler companion is one physical counter, not the
|
||||
marker value or an extra priced force. Banker action validation now admits
|
||||
the existing Richese `noField`/`event` invoice fields without bypassing native
|
||||
marker, funding or ownership checks. Existing mixed No-Field, special-lot
|
||||
Technology, empty-cache auction and other interpretation guards are unchanged.
|
||||
|
||||
New native52/52 and affected shared261/262 initially passed; the sole obsolete
|
||||
prototype assertion still rejected classic Tech, now admitted by the preceding
|
||||
checkpoint. Removing that obsolete rejection passes the exact remaining case.
|
||||
Types/lint/build pass. Nine actual programs cover Basic/Advanced Ixian
|
||||
invoice/Banker/Heighliners, Advanced Guild/Ix winner Tech before Face Dance,
|
||||
Basic/Advanced Richese marker companions, CHOAM Charity and both Stone modes.
|
||||
Seventeen genuine three-seat games (fifteen native, two classic) complete5,733
|
||||
accepted actions/no rejection and149 JSON continuations on unchanged tree
|
||||
`14be0172b28a45fcabbf0240da12d6718e4fdc871d9a2b639466e6e90d8a1aca`.
|
||||
The CLI registry retains existing classic names/ordinals and adds sixty native
|
||||
samples, sixty-eight total. Natural samples and controlled cases are distinct.
|
||||
|
||||
Original fresh CLI **VQ3AVFTC**, **M7HPHNTN** and **PQ6U2X4S v7/setup**
|
||||
preserve native Ixian/Tleilaxu/Richese setup through the existing entries.
|
||||
Human390px **7RPHJN87 v15/Collection** seals C’tair5/dial5/support1/Shield,
|
||||
chooses actual4 Suboids+2 cyborg casualties and saves2 ordinary+1 elite,
|
||||
keeping the cyborg and returning2 ordinary. Original substitution is declined,
|
||||
Shield retained, then sole opposing Axlotl auto-transfers. Native wallet10→9,
|
||||
Wind Pass3 (one cyborg), reserves14/Tanks3 (one cyborg); Emperor8 Tanks.
|
||||
Human390px **F244P8DT v8/Collection** replaces the original Guild's five
|
||||
survivors using one actual reserve and two Habbanya Erg counters. Guild keeps
|
||||
Axlotl/Heighliners, wallet2, held weapon and the original win; its trainer dies
|
||||
without another bounty and returns Suk once. Guild reserves19/Tanks1, Tleilaxu
|
||||
reserves8/Tanks8 with original-source1/replacement3.
|
||||
Human390px **YWVH5XQF v8/movement** ships concealed5 plus one free companion
|
||||
at original price1: wallet6→5, reserves20→19, real board1. Atreides retains
|
||||
its actual prior Revival Banker-front1 and accrues independent Heighliners1;
|
||||
neither funds this shipment. The native marker/cost and typed rescue/source
|
||||
controls are readable. Refresh retains the original versions and outcomes.
|
||||
Full combinations, assurance, strategic AI, public and deployed acceptance
|
||||
remain open; this is bounded Development Verified, not full-module completion.
|
||||
Final reference10/10, types/lint/build and the rebuilt390px native guide pass:
|
||||
topic351px, page390px, no horizontal overflow. The final isolated QA reload
|
||||
retains original human v15/Collection, v8/Collection and v8/movement.
|
||||
Owned temporary drivers and managed tabs are removed; rooms/store, private
|
||||
metadata and original game captures remain.
|
||||
|
||||
## Classic Tech Tokens composition — 4 October 2026
|
||||
|
||||
Fresh `leader-skills` and separate `banker-income` preserve canonical unused
|
||||
Tech Tokens selected in a three-through-six-seat classic Basic/Advanced lobby.
|
||||
Original base33/all14 setup, first-Storm token assignment and native wallets
|
||||
remain; no token owner, income, phase or battle receipt is fabricated.
|
||||
Native expansion rosters and other overlays remain separate. The existing
|
||||
Native E1/E2 rosters now compose Tech in the section above; other overlays remain separate. The existing
|
||||
private Mentat opt-in retains its UX/empty-hand boundary; normal Banker retains
|
||||
its original earned-custody and separate-payer policies.
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
|
||||
| Native Ecaz / Leader Skills | Basic/Advanced Ecaz plus classic opponents retains the exact ecaz deck, five native assignment discs, public skills before Loyalty/Traitors and original placement. Ordinary rescue/death/own revival and Ambassador handlers compose. A separately acquired Duke6 may receive the normal Warmaster1, never trained-disc3; surviving release or printed6 death leaves the native trainer intact. Existing-skilled Ecaz paid5 Duke revival preserves the earlier living set-aside result, without authorizing Duke assignment. Advanced Harkonnen and combined Occupy skills remain bounded. | [Native scope and source contracts](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026), [original Duke lifecycle](DUKE_VIDAL_RULES.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) |
|
||||
| Classic Leader Skills / Tech Tokens | Fresh classic3..6 Basic/Advanced, base33/all14, canonical unused three tokens and original setup/first-Storm owners. Existing leader-skills/banker-income entry preserves selected Tech; native/other overlays stay separate. Tech collects at phase end, independent Banker waits for actual Mentat; neither currency store is a recursive payment producer. Smuggler changes actual priced count; original Suk/Sandmaster precede card cleanup and mandatory token transfer. One token auto-transfers, several require choice, none skips. Mentat/Bureaucrat original bounds remain. This composes existing rules, not a new ruling or public activation. | [Scope and evidence](LEADER_SKILLS_RUNTIME.md#classic-tech-tokens-composition--4-october-2026) |
|
||||
| Native E1/E2 Leader Skills / Tech Tokens | Fresh supported Ixian/Tleilaxu/CHOAM/Richese3..6 Basic/Advanced keeps original native deck/roster limits, all14 skills, cache and printed Tech setup. Original typed payments/rescue/card cleanup precede mandatory winner token reward; Tleilaxu A.1–3 expressly preserves that reward before Face Dance. Actual Charity affects Production, not Axlotl; native CHOAM initial income is not poverty Charity. Separate Banker/Mentat bounds and unrelated guards remain. E3/other overlays, public starts and save conversion are excluded. This composes sourced rules, not a new interpretation. | [Scope, supplied physical21/38 and evidence](LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026) |
|
||||
| Native Mentat preview | Explicit fresh `leader-skills --mentat-question` reuses the existing private uniform question for already-supported Basic Ixian/Tleilaxu/CHOAM/Moritani profiles. Exact/default card roles, held fallback and skill-before-faction timing are unchanged. Ordinary/public activation, empty-hand handling, Advanced native skills and other modules stay gated; this is integration, not a new answer to the pending UX question. | [Native integration and existing authority](MENTAT_QUESTION.md#native-skill-integration--2-october-2026) |
|
||||
| Normal Spice Banker local preview | Fresh classic/base33 Basic/Advanced or supported Basic native skill profiles, all14 skills alone. One additional deferred bank spice per physical card/turn/phase for another payer's one actual bank payment ≥4; no payer pooling or advance spending. Tleilaxu self-revival pays Bank; other-player revival pays Tleilaxu unless canceled. The separate free-revival award never contributes to payer debit. Actual initialized Mentat collects once; original earned-faction retention and separate-payer threshold remain provisional local policies, not answers to pending custody/split questions. No public start, retrofit or other overlays. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [native integration](SPICE_BANKER_RUNTIME.md#basic-native-banker-integration--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) |
|
||||
| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected E1/E2 native families and standalone Ecaz OR Moritani with classic2..6-seat Advanced, canonical selected decks and six Stronghold Cards alone. Standalone E3 uses ecaz33; Moritani excludes Harkonnen. Ecaz co-control holds the card at Ecaz, but only the selected actual holder-plan receives its effect; fixed Ecaz strength is free, and Arrakeen funds only actual variable support. Losing Moritani retains printed Tuek income before original assassination, printed bounty and one actual Mentat replacement; winner cleanup finishes once and normal Traitor reveal forfeits assassination for the game. Existing E1/E2 No-Field/Stone/FaceDance and retained winner rewards remain. E3 pairs/mixed families, mixed No-Field/full-plan, Stone/Poison Tooth and other overlays stay guarded. This composes existing contracts, not a new ruling. Save/privacy/custody assurance follows all rules. | [E1/E2 scope](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules), [standalone E3 scope and evidence](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026) |
|
||||
|
||||
@@ -6,6 +6,15 @@ positive bank payments to automatic current Mentat collection.
|
||||
Bounded coverage is **Partial / Development Verified**. No public start,
|
||||
old-game retrofit or other module is enabled; this is not deployed acceptance.
|
||||
|
||||
**4 October native Tech composition:** the same separate opt-in preserves
|
||||
unused original Tech in fresh3..6-seat supported Ixian/Tleilaxu/CHOAM/Richese
|
||||
Basic/Advanced lobbies. Native deck/roster limits and payment recipients remain.
|
||||
Richese marker invoices retain native `noField`/`event` validation. Human
|
||||
**YWVH5XQF v8** pays1 for a concealed5 plus one free companion, wallet6→5;
|
||||
the earlier actual four-spice Emperor revival leaves Atreides Banker-front1
|
||||
unavailable while its Heighliners separately accrues1. A Tech credit is not
|
||||
another bank-payment producer. See the [native contract and evidence](LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026).
|
||||
|
||||
**4 October classic Tech composition:** a fresh classic3..6 Basic/Advanced
|
||||
`banker-income` lobby may retain canonical unused Tech Tokens. Original
|
||||
phase-end Tech income is independent of the front-shield grant; neither credit
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
|
||||
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') ||
|
||||
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game);
|
||||
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game);
|
||||
}
|
||||
export function bureaucratUsed(
|
||||
used: readonly BureaucratPaymentUse[],
|
||||
|
||||
+8
-6
@@ -1,4 +1,4 @@
|
||||
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
|
||||
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, 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';
|
||||
@@ -7845,7 +7845,7 @@ function bankerIncomeProfile(g: Game): boolean {
|
||||
new Set(g.players.map(p => p.faction)).size === g.players.length &&
|
||||
((!g.expansions.length && g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base'))) ||
|
||||
nativeExpansionLeaderSkillsProfile(g)) &&
|
||||
!g.nexusCards && !g.homeworlds && (!g.techTokens || classicTechLeaderSkillsProfile(g)) &&
|
||||
!g.nexusCards && !g.homeworlds && (!g.techTokens || classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g)) &&
|
||||
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
|
||||
!g.greatMaker && !g.ecazTreachery && !g.semutaPreview && !g.mentatQuestionPreview &&
|
||||
((!g.moritaniAssassinatePreview && !g.moritaniAssassinate) || advancedMoritaniLeaderSkillsProfile(g)) && !g.advancedPreview &&
|
||||
@@ -9420,10 +9420,10 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
homeworldShipmentIntegrity(state);
|
||||
const g = structuredClone(state);
|
||||
requireFreshSetup(g, homeworlds || nexus || ix || factions);
|
||||
const skillTech = leaderSkills && classicTechLeaderSkillsProfile(g);
|
||||
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g));
|
||||
requireRule(!g.techTokens || (skillTech &&
|
||||
JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())),
|
||||
'Leader Skills with Tech Tokens requires a fresh classic three-through-six-seat lobby with unused tokens.');
|
||||
'Leader Skills with Tech Tokens requires a fresh supported classic/native three-through-six-seat lobby with unused tokens.');
|
||||
requireRule(
|
||||
(homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) &&
|
||||
(choam || factions || !g.expansions.includes('choam')) &&
|
||||
@@ -26233,8 +26233,10 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
emperorRevival: ['type','amount','elite'], passResponse: ['type'],
|
||||
traitorCall: ['type','call','leader'],
|
||||
};
|
||||
const allowed = action.type === 'decision' && state.decision?.kind === 'auctionPayment'
|
||||
? ['type','karama','card'] : keysBySource[action.type];
|
||||
const allowed = action.type === 'ship' && action.noField !== undefined && getPlayer(state, id).faction === 'richese'
|
||||
? ['type','territory','sector','amount','elite','allyPayment','smuggler','noField','event']
|
||||
: action.type === 'decision' && state.decision?.kind === 'auctionPayment'
|
||||
? ['type','karama','card'] : keysBySource[action.type];
|
||||
requireRule(!allowed || Object.keys(action).every(key => allowed.includes(key)),
|
||||
'Use only the original native payment fields; Spice Banker income is automatic.');
|
||||
requireRule(!Object.keys(action).some(key => ['actor','bankerIncome','paymentSource','spiceBankerIncome','bankLegs','trainer'].includes(key)),
|
||||
|
||||
@@ -29,6 +29,25 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean {
|
||||
!game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery;
|
||||
}
|
||||
|
||||
/** E1/E2 native skills may add Tech alone; each native predicate retains its original roster/decks. */
|
||||
function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
|
||||
if (noOtherLeaderSkillModules(game)) return true;
|
||||
const players = game.players;
|
||||
if (!game.techTokens || !players || players.length < 3 || players.length > 6 ||
|
||||
game.expansions.length < 1 || game.expansions.length > 2 ||
|
||||
game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled ||
|
||||
game.strongholdCards || game.ecazTreachery || game.semutaPreview ||
|
||||
game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview ||
|
||||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
|
||||
game.nexusHarkonnenBetrayalPreview) return false;
|
||||
for (const expansion of game.expansions)
|
||||
if (expansion !== 'ix' && expansion !== 'choam') return false;
|
||||
for (let i = 0; i < players.length; i++)
|
||||
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Classic three-to-six-seat Skills/Tech composition; native and other modules stay separate. */
|
||||
export function classicTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
const players = game.players;
|
||||
@@ -113,7 +132,7 @@ export function nativeEcazLeaderSkillsProfile(game: LeaderSkillProfile): boolean
|
||||
/** 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.expansions[0] === 'ix' && nativeLeaderSkillModulesSupported(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'));
|
||||
@@ -122,7 +141,7 @@ export function basicTleilaxuLeaderSkillsProfile(game: LeaderSkillProfile): bool
|
||||
/** Basic Ixians, optionally with native Tleilaxu, keep the ordinary Ix inventories. */
|
||||
export function basicIxLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return game.advanced === false && game.expansions.length === 1 &&
|
||||
game.expansions[0] === 'ix' && noOtherLeaderSkillModules(game) &&
|
||||
game.expansions[0] === 'ix' && nativeLeaderSkillModulesSupported(game) &&
|
||||
!!game.players?.some(player => player.faction === 'ixians') &&
|
||||
game.players.every(player => ['ixians', 'tleilaxu'].includes(player.faction) ||
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
|
||||
@@ -131,7 +150,7 @@ export function basicIxLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
/** Basic CHOAM's ordinary leaders and economy; Auditor belongs to Advanced. */
|
||||
export function basicChoamLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return game.advanced === false && game.expansions.length === 1 &&
|
||||
game.expansions[0] === 'choam' && noOtherLeaderSkillModules(game) &&
|
||||
game.expansions[0] === 'choam' && nativeLeaderSkillModulesSupported(game) &&
|
||||
!!game.players?.some(player => player.faction === 'choam') &&
|
||||
game.players.every(player => player.faction === 'choam' ||
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
|
||||
@@ -142,7 +161,7 @@ export function basicRicheseLeaderSkillsProfile(game: LeaderSkillProfile): boole
|
||||
const players = game.players;
|
||||
if (game.advanced !== false || game.expansions.length !== 1 ||
|
||||
game.expansions[0] !== 'choam' || !players || players.length < 2 || players.length > 6 ||
|
||||
!noOtherLeaderSkillModules(game) || game.semutaPreview || game.moritaniAssassinatePreview ||
|
||||
!nativeLeaderSkillModulesSupported(game) || game.semutaPreview || game.moritaniAssassinatePreview ||
|
||||
game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
||||
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
|
||||
@@ -173,7 +192,7 @@ export function advancedNativeLeaderSkillsProfile(game: LeaderSkillProfile): boo
|
||||
if (game.advanced !== true || !players || players.length < 2 || players.length > 6 ||
|
||||
game.expansions.length < 1 || game.expansions.length > 2 ||
|
||||
(game.expansions.length === 2 && game.expansions[0] === game.expansions[1]) ||
|
||||
!noOtherLeaderSkillModules(game) ||
|
||||
!nativeLeaderSkillModulesSupported(game) ||
|
||||
game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview ||
|
||||
game.kullPreview || game.nexusKullPreview ||
|
||||
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
@@ -205,9 +224,14 @@ export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): bo
|
||||
advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game);
|
||||
}
|
||||
|
||||
/** Only the original eligible E1/E2 native families gain the Tech combination. */
|
||||
export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return !!game.techTokens && nativeExpansionLeaderSkillsProfile(game);
|
||||
}
|
||||
|
||||
/** Shared by rule quotes, controls and minimal legal bot participation. */
|
||||
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
|
||||
return (noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) ||
|
||||
classicTechLeaderSkillsProfile(game);
|
||||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { treacheryDeck, type Card } from './cards';
|
||||
import { richeseCards } from './richese-cards';
|
||||
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
|
||||
import {classicTechLeaderSkillsProfile,nativeExpansionLeaderSkillsProfile,noOtherLeaderSkillModules,type LeaderSkillProfile} from './leader-skill-profile';
|
||||
import {classicTechLeaderSkillsProfile,nativeExpansionLeaderSkillsProfile,nativeTechLeaderSkillsProfile,noOtherLeaderSkillModules,type LeaderSkillProfile} from './leader-skill-profile';
|
||||
|
||||
export class MentatQuestionError extends Error {}
|
||||
export const MENTAT_EMPTY_HAND =
|
||||
@@ -43,7 +43,7 @@ export function mentatQuestionModeSupported(game: LeaderSkillProfile): boolean {
|
||||
return (noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length ||
|
||||
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
|
||||
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game);
|
||||
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game);
|
||||
}
|
||||
function inventory(expansions: readonly string[]): Card[] {
|
||||
return [
|
||||
|
||||
+3
-3
@@ -4589,7 +4589,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.',
|
||||
'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.',
|
||||
'Fresh classic three-through-six-seat Basic/Advanced development tables may compose all fourteen Leader Skills with the three original Tech Tokens. Starting cards, skill/Traitor choices and first-Storm token assignment stay native. Existing leader-skills and banker-income local entry preserve unused selected tokens; public activation, native factions and other overlays remain separate.',
|
||||
'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Leader Skills with the three original Tech Tokens: classic factions or the already-supported native Ixian, Tleilaxu, CHOAM and Richese skill rosters with their original required decks. Starting cards, cache, native skill/Traitor choices and printed or first-Storm token assignment stay genuine. Existing leader-skills and banker-income local entry preserve unused selected tokens; E3, other overlays, public activation and saved-game conversion remain separate.',
|
||||
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
|
||||
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
||||
'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.',
|
||||
@@ -4607,7 +4607,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Mentat has an explicit private developer preview for naming a specific weapon before posture and faction powers in supported classic and native skill profiles, including bounded Advanced Moritani. The target privately shows the named held card or chooses another held card. Weirding Way is a nameable default weapon; Chemistry is not. The card stays held and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history is an observation, not a later hand guarantee.',
|
||||
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in supported Basic or bounded Advanced native skill profiles have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.',
|
||||
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
|
||||
'In the classic Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline.',
|
||||
'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.',
|
||||
'Other special Sandmaster relocation remains missing. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
@@ -4617,7 +4617,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ 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, and a separate surviving-loser destination/normal/elite retreat or decline. 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/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Existing standalone/native evidence remains dated rather than full-module acceptance. Classic Skills/Tech adds25 meaningful cases, affected shared/CLI146, types/lint/build, seven actual native programs and eight genuine Basic/Advanced3..6 games (2712 accepted actions, no rejection,69 JSON continuations). Human390px Smuggler cost4/physical5 keeps independent Tech phase income and Banker Mentat collection; skilled Suk rescue finishes before one real two-token reward selection, retaining original cards/bounty and separately quoted Collection. Other combinations and comprehensive assurance follow all rules; no public/deployed claim.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Dated classic Skills/Tech evidence:25 cases,146 affected cases, eight genuine Basic/Advanced3..6 games (2712 actions,69 JSON continuations) and human Smuggler/payment/Mentat/Suk/two-token reward. Native E1/E2 adds52 meaningful cases,261 passing shared cases plus the corrected exact obsolete rejection, types/lint/build, nine actual programs and seventeen genuine3-seat games (fifteen native,5733 accepted actions/no rejection,149 JSON continuations). Human390px mixed cyborg Suk rescue, original Tech before real-source Face Dance, and Richese marker5/free companion/price1 use original controls and preserve outcomes on refresh. Full combinations, assurance, public and deployed acceptance stay open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeSpiceBankerIncomeGameForAudit,
|
||||
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { nextSkillsTechBattleStep, openSkillsTechBattle } from './fixture-skills-tech-battle';
|
||||
import { advanceSkillsTechPaymentsToPhase, skillsTechPaymentsEmptyDestination } from './fixture-skills-tech-payments';
|
||||
|
||||
export type IxChoamSkillsTechOptions = {
|
||||
/** Fresh authenticated undealt lobby. Seat IDs, order and native offers survive. */
|
||||
initial?: Game;
|
||||
family?: 'ixians' | 'choam';
|
||||
advanced?: boolean;
|
||||
kind?: 'shipment' | 'suk-battle' | 'charity';
|
||||
band?: 'normal' | 'skilled';
|
||||
amount?: number;
|
||||
elite?: number;
|
||||
smuggler?: boolean;
|
||||
/** Separate normal-income audit opt-in; needs an actual Atreides seat. */
|
||||
bankerIncome?: boolean;
|
||||
};
|
||||
export type IxChoamSkillsTechStep = { actor: string; action: Action };
|
||||
export type IxChoamSkillsTechFixture = {
|
||||
setup: Game;
|
||||
game: Game;
|
||||
actions: IxChoamSkillsTechStep[];
|
||||
family: 'ixians' | 'choam';
|
||||
kind: 'shipment' | 'suk-battle' | 'charity';
|
||||
actor: string;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
banker?: string;
|
||||
trainer: string;
|
||||
action: Action;
|
||||
opponentPlan?: Action;
|
||||
beforeCharityIncome?: Game;
|
||||
afterCharityIncome?: Game;
|
||||
key?: string;
|
||||
cost: number;
|
||||
amount: number;
|
||||
elite: number;
|
||||
band: 'normal' | 'skilled';
|
||||
};
|
||||
|
||||
export function ixChoamSkillsTechPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
/** Control only the first all14 shuffle. Native offers are dealt in retained seat
|
||||
* order; subsequent Traitor, treachery and Tech randomness remains original. */
|
||||
function withOffers<T>(selected: LeaderSkillId[], initialize: () => T): T {
|
||||
const cards: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
|
||||
const remaining = cards.filter(card => !selected.includes(card));
|
||||
const desired = selected.flatMap(skill => [skill, remaining.shift()!]);
|
||||
desired.push(...remaining);
|
||||
const working = [...cards];
|
||||
const rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]);
|
||||
assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor(((j + 0.5) / (i + 1)) * 0x100000000));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
let index = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (index < rolls.length) {
|
||||
assert.ok(array instanceof Uint32Array && array.length === 1);
|
||||
array[0] = rolls[index++];
|
||||
} else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return initialize(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Native continuation only; decline optional substitution so it cannot conceal
|
||||
* Suk's reserve/Tanks result. Human snapshots stop before this policy runs. */
|
||||
export function nextIxChoamSkillsTechStep(game: Game): IxChoamSkillsTechStep {
|
||||
if (game.decision?.kind === 'ixSubstitution')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
return nextSkillsTechBattleStep(game);
|
||||
}
|
||||
function step(game: Game, actions?: IxChoamSkillsTechStep[]): Game {
|
||||
const next = nextIxChoamSkillsTechStep(game);
|
||||
actions?.push(next);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
function act(game: Game, actor: string, action: Action, actions: IxChoamSkillsTechStep[]): Game {
|
||||
actions.push({ actor, action });
|
||||
return applyAction(game, actor, action);
|
||||
}
|
||||
|
||||
export function initializeIxChoamSkillsTechSetup(options: IxChoamSkillsTechOptions = {}): Game {
|
||||
const family = options.family ?? 'ixians';
|
||||
const expansion = family === 'ixians' ? 'ix' : 'choam';
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('IXCHOAMSKILLTECH', newPlayer('native', family, family), options.advanced ?? false, [expansion]);
|
||||
if (!options.initial) {
|
||||
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('fremen', 'Fremen', 'fremen'));
|
||||
if (options.bankerIncome) joinGame(game, newPlayer('banker', 'Banker', 'atreides'));
|
||||
}
|
||||
assert.equal(game.status, 'lobby', 'Only the original fresh undealt lobby is accepted.');
|
||||
assert.ok(game.expansions.includes(expansion));
|
||||
assert.ok(game.players.length >= 3 && game.players.length <= 6);
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
assert.ok(game.players.some(p => p.faction === family));
|
||||
assert.ok(game.players.some(p => p.faction === 'emperor'));
|
||||
if (options.bankerIncome) assert.ok(game.players.some(p => p.faction === 'atreides'));
|
||||
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
assert.deepEqual(game.techTokens, createTechTokens());
|
||||
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
||||
const passive: LeaderSkillId[] = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'planetologist'];
|
||||
const selected = game.players.map(p => p.faction === family
|
||||
? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler'
|
||||
: options.bankerIncome && p.faction === 'atreides' ? 'spice-banker' : passive.shift()!);
|
||||
return withOffers(selected, () => options.bankerIncome
|
||||
? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game));
|
||||
}
|
||||
|
||||
export function completeIxChoamSkillsTechSetup(state: Game, options: IxChoamSkillsTechOptions = {}, actions?: IxChoamSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let i = 0; game.status === 'setup' && i < 200; i++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const faction = ixChoamSkillsTechPlayer(game, actor).faction;
|
||||
const skill = faction === (options.family ?? 'ixians')
|
||||
? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler'
|
||||
: options.bankerIncome && faction === 'atreides' ? 'spice-banker' : offer.cards[0];
|
||||
assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
|
||||
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
|
||||
const leader = ixChoamSkillsTechPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(leader && leader.id !== 'choam-auditor');
|
||||
const action: Action = { type: 'leaderSkill', event: offer.event, skill, leader: leader.id };
|
||||
actions?.push({ actor, action });
|
||||
game = applyAction(game, actor, action);
|
||||
} else game = step(game, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Original setup, first Storm and phase history. Only unplayed Spice Card
|
||||
* order and (for battle) conserved board/hand custody are controlled. No wallet,
|
||||
* token owner, phase, turn, plan or earned receipt is assigned. */
|
||||
export function createIxChoamSkillsTechFixture(options: IxChoamSkillsTechOptions = {}): IxChoamSkillsTechFixture {
|
||||
const family = options.family ?? 'ixians';
|
||||
const kind = options.kind ?? 'shipment';
|
||||
assert.ok(kind !== 'charity' || family === 'choam');
|
||||
const band = options.band ?? 'skilled';
|
||||
const actions: IxChoamSkillsTechStep[] = [];
|
||||
const setup = initializeIxChoamSkillsTechSetup(options);
|
||||
let game = completeIxChoamSkillsTechSetup(setup, options, actions);
|
||||
const owner = game.players.find(p => p.faction === family)!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'emperor')!.id;
|
||||
const banker = options.bankerIncome ? game.players.find(p => p.faction === 'atreides')!.id : undefined;
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
|
||||
// Four conserved non-worm cards avoid optional riders during the two-turn
|
||||
// charity continuation. They are not asserted to be a natural shuffle history.
|
||||
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 4);
|
||||
for (let i = 0; i < blows.length; i++) {
|
||||
const index = game.spiceDeck.indexOf(blows[i]);
|
||||
game.spiceDeck.splice(i, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = advanceSkillsTechPaymentsToPhase(game, 5, actions);
|
||||
if (family === 'ixians') assert.equal(game.techTokens!.heighliners.owner, owner);
|
||||
if (kind === 'suk-battle') {
|
||||
// Explicit physical battle rule position: return board counters/cards to
|
||||
// native reserves/deck, then place eight counters (two Ix cyborgs).
|
||||
for (const p of game.players) {
|
||||
game.deck.push(...p.hand); p.hand = [];
|
||||
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
|
||||
if (p.elites) {
|
||||
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
|
||||
p.elites.forces = {};
|
||||
}
|
||||
if (p.advisors) p.advisors = {};
|
||||
}
|
||||
const territory = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm));
|
||||
assert.ok(territory);
|
||||
const key = `${territory.id}:${territory.sectors[0]}`;
|
||||
for (const id of [owner, opponent]) {
|
||||
const p = ixChoamSkillsTechPlayer(game, id);
|
||||
assert.ok(p.reserves >= 8); p.reserves -= 8; p.forces[key] = 8;
|
||||
}
|
||||
if (family === 'ixians') {
|
||||
const p = ixChoamSkillsTechPlayer(game, owner);
|
||||
p.elites!.reserves -= 2; p.elites!.forces[key] = 2;
|
||||
}
|
||||
const shield = game.deck.findIndex(card => card.kind === 'shield');
|
||||
assert.ok(shield >= 0);
|
||||
const defense = game.deck.splice(shield, 1)[0];
|
||||
ixChoamSkillsTechPlayer(game, owner).hand.push(defense);
|
||||
game = advanceSkillsTechPaymentsToPhase(game, 6, actions);
|
||||
game = openSkillsTechBattle(game, owner, opponent, territory.id, band === 'skilled');
|
||||
const untrained = (id: string, strongest: boolean) => ixChoamSkillsTechPlayer(game, id).leaders
|
||||
.filter(l => !l.dead && l.id !== 'choam-auditor' && !game.leaderSkills!.assignments.some(a => a.leader === l.id))
|
||||
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id;
|
||||
const dial = game.advanced && family === 'ixians' ? 5 : 4;
|
||||
const support = game.advanced ? family === 'ixians' ? 1 : 4 : 0;
|
||||
return { setup, game, actions, family, kind, actor: owner, owner, opponent, banker, trainer, key,
|
||||
action: { type: 'battlePlan', leader: band === 'skilled' ? trainer : untrained(owner, true), dial, support, defense: defense.id },
|
||||
opponentPlan: { type: 'battlePlan', leader: untrained(opponent, false), dial: 0, support: 0 },
|
||||
cost: support, amount: 8, elite: family === 'ixians' ? 2 : 0, band };
|
||||
}
|
||||
const actor = kind === 'charity' ? opponent : owner;
|
||||
for (let i = 0; game.active !== actor && i < 30; i++) game = step(game, actions);
|
||||
assert.equal(game.active, actor);
|
||||
const amount = kind === 'charity' ? 5 : options.amount ?? 5;
|
||||
const elite = kind === 'charity' ? 0 : options.elite ?? (family === 'ixians' ? 2 : 0);
|
||||
let target: { territory: string; sector: number };
|
||||
if (kind === 'charity') {
|
||||
const territory = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm)
|
||||
&& game.players.every(p => Object.entries(p.forces).every(([key, n]) => !n || !key.startsWith(`${t.id}:`)))
|
||||
&& t.sectors.every(s => !game.spice[`${t.id}:${s}`])
|
||||
&& blows.every(card => !('territory' in card) || card.territory !== t.id));
|
||||
assert.ok(territory); target = { territory: territory.id, sector: territory.sectors[0] };
|
||||
game = act(game, actor, { type: 'ship', ...target, amount, elite }, actions);
|
||||
for (let i = 0; (game.response || game.decision || game.pendingShipment) && i < 200; i++) game = step(game, actions);
|
||||
// Actual ten-spice desert invoice exhausts Emperor's original bank wallet.
|
||||
// Any native Heighliners payout can add at most one; no Collection at this pile.
|
||||
const counter = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(counter >= 0);
|
||||
ixChoamSkillsTechPlayer(game, actor).hand.push(game.deck.splice(counter, 1)[0]);
|
||||
let beforeCharityIncome: Game | undefined;
|
||||
let afterCharityIncome: Game | undefined;
|
||||
for (let i = 0; (game.turn !== 2 || game.phase !== 2 || game.phaseOpening || game.response || game.decision)
|
||||
&& game.status === 'playing' && i < 2000; i++) {
|
||||
if (game.turn === 2 && game.response?.kind === 'choamCharity' && !beforeCharityIncome)
|
||||
beforeCharityIncome = structuredClone(game);
|
||||
game = step(game, actions);
|
||||
if (beforeCharityIncome && !afterCharityIncome && game.response?.kind !== 'choamCharity')
|
||||
afterCharityIncome = structuredClone(game);
|
||||
}
|
||||
assert.ok(beforeCharityIncome && afterCharityIncome);
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 2);
|
||||
assert.ok(ixChoamSkillsTechPlayer(game, actor).spice < 2);
|
||||
return { setup, game, actions, family, kind, actor, owner, opponent, banker, trainer,
|
||||
action: { type: 'charity' }, cost: 10, amount: 2 - ixChoamSkillsTechPlayer(game, actor).spice,
|
||||
elite: 0, band, key: `${target.territory}:${target.sector}`, beforeCharityIncome, afterCharityIncome };
|
||||
}
|
||||
target = skillsTechPaymentsEmptyDestination(game);
|
||||
const smuggler = options.smuggler !== false;
|
||||
return { setup, game, actions, family, kind, actor, owner, opponent, banker, trainer,
|
||||
action: { type: 'ship', ...target, amount, elite, ...(smuggler ? { smuggler: true } : {}) },
|
||||
cost: amount - (smuggler ? 1 : 0), amount, elite, band, key: `${target.territory}:${target.sector}` };
|
||||
}
|
||||
|
||||
/** Settle native response/arrival windows, or stop at the real Suk choice. */
|
||||
export function settleIxChoamSkillsTech(state: Game, stopAtSuk = false): Game {
|
||||
let game = state;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
if (stopAtSuk && (game.decision?.kind === 'sukRescue' ||
|
||||
game.decision?.kind === 'battleCards' && game.lastBattleContext?.sukRescue?.completed)) return game;
|
||||
if (!game.response && !game.decision && !game.pendingShipment && !game.battle && !game.pendingTreacheryDiscard) return game;
|
||||
if (game.decision?.kind === 'battleLosses') {
|
||||
const options = game.decision.options;
|
||||
const choice = options.findIndex(o => o.elite === 2);
|
||||
assert.ok(choice >= 0);
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', choice });
|
||||
} else game = step(game);
|
||||
}
|
||||
throw new Error('Original Ix/CHOAM continuation did not settle.');
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { nextSkillsTechBattleStep, openSkillsTechBattle, finishSkillsTechBattle } from './fixture-skills-tech-battle';
|
||||
import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
|
||||
|
||||
export type RicheseSkillsTechOptions = {
|
||||
/** Fresh authenticated undealt E2 lobby; retain its original seat IDs/order. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
kind?: 'marker' | 'stone' | 'shipment';
|
||||
marker?: 0 | 3 | 5;
|
||||
reserves?: number;
|
||||
band?: 'normal' | 'skilled';
|
||||
/** Atreides opponent exposes its original concealed-marker prescience fences. */
|
||||
opponent?: 'emperor' | 'atreides';
|
||||
/** A separate actual four-spice revival, never funding fabricated for shipment. */
|
||||
bankerPayment?: boolean;
|
||||
precedence?: 'double-traitor' | 'explosion';
|
||||
};
|
||||
export type RicheseSkillsTechStep = { actor: string; action: Action };
|
||||
export type RicheseSkillsTechFixture = {
|
||||
game: Game;
|
||||
initial: Game;
|
||||
setup: Game;
|
||||
actions: RicheseSkillsTechStep[];
|
||||
staging: string[];
|
||||
owner: string;
|
||||
opponent: string;
|
||||
banker: string;
|
||||
trainer: string;
|
||||
territory: string;
|
||||
key: string;
|
||||
marker: 0 | 3 | 5;
|
||||
kind: 'marker' | 'stone' | 'shipment';
|
||||
beforeShipment: Game | null;
|
||||
shipmentAction: Action | null;
|
||||
afterShipment: Game | null;
|
||||
acquisition: { before: Game; actions: RicheseSkillsTechStep[]; after: Game };
|
||||
ownerPlan: Action;
|
||||
opponentPlan: Action;
|
||||
};
|
||||
|
||||
export function richeseSkillsTechPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
/** Control only the original all14 offer shuffle. All later shuffles are native. */
|
||||
function withOffers<T>(selected: LeaderSkillId[], initialize: () => T): T {
|
||||
const cards = LEADER_SKILL_CARDS.map(c => c.id);
|
||||
const remaining = cards.filter(c => !selected.includes(c));
|
||||
const desired = selected.flatMap(c => [c, remaining.shift()!]);
|
||||
desired.push(...remaining);
|
||||
const working = [...cards], rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]);
|
||||
assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor(((j + 0.5) / (i + 1)) * 0x100000000));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
let index = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (index < rolls.length) {
|
||||
assert.ok(array instanceof Uint32Array && array.length === 1);
|
||||
array[0] = rolls[index++];
|
||||
} else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return initialize(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing legal no-spend/no-call policy, with the actual Richese unbid producer. */
|
||||
export function nextRicheseSkillsTechNativeStep(game: Game): RicheseSkillsTechStep {
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const d = game.decision, actor = d.player, event = game.richeseBidding?.event;
|
||||
if (d.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
|
||||
if (d.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
|
||||
if (d.kind === 'richeseCache') {
|
||||
const card = game.richeseCache!.find(c => c.id === 'richese-stone-burner') ?? game.richeseCache![0];
|
||||
assert.ok(card);
|
||||
return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
}
|
||||
if (d.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === 'richese-stone-burner' } };
|
||||
if (d.kind === 'stoneBurner') return { actor, action: { type: 'decision', event: d.event, mode: 'ignore' } };
|
||||
}
|
||||
const lot = game.richeseAuction;
|
||||
if (!game.response && !game.phaseOpening && !game.decision && lot && !lot.outcome) {
|
||||
const actor = lot.method === 'silent' ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
|
||||
}
|
||||
return nextSkillsTechBattleStep(game);
|
||||
}
|
||||
function step(game: Game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
const next = nextRicheseSkillsTechNativeStep(game);
|
||||
actions?.push(next);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
export function advanceRicheseSkillsTechToPhase(state: Game, phase: number, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; (game.phase < phase || !clean(game)) && game.turn === state.turn && n < 1200; n++) game = step(game, actions);
|
||||
assert.equal(game.turn, state.turn);
|
||||
assert.ok(game.phase >= phase);
|
||||
assert.ok(clean(game));
|
||||
return game;
|
||||
}
|
||||
export function finishRicheseSkillsTechBattle(state: Game, stop: 'skill' | 'cleanup' | 'tech' | 'complete' = 'complete'): Game {
|
||||
return finishSkillsTechBattle(state, stop);
|
||||
}
|
||||
export function openRicheseSkillsTechBattle(fixture: RicheseSkillsTechFixture, hide = true): Game {
|
||||
return openSkillsTechBattle(fixture.game, fixture.owner, fixture.opponent, fixture.territory, hide);
|
||||
}
|
||||
|
||||
function weakestUntrained(game: Game, id: string): string {
|
||||
const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const leader = richeseSkillsTechPlayer(game, id).leaders.filter(l => !assigned.has(l.id) && !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader);
|
||||
return leader.id;
|
||||
}
|
||||
/** Exchange original physical Traitor custody; never mint another identity. */
|
||||
function exchangeTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const player = richeseSkillsTechPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0];
|
||||
assert.ok(returned);
|
||||
const at = game.traitorReserve?.indexOf(identity) ?? -1;
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(at >= 0 || donor);
|
||||
if (at >= 0) game.traitorReserve!.splice(at, 1, returned);
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved native Traitor ${identity} exchanged for ${returned}.`);
|
||||
}
|
||||
function held(game: Game, actor: string, kind: Player['hand'][number]['kind'], staging: string[]): string {
|
||||
const player = richeseSkillsTechPlayer(game, actor);
|
||||
const at = game.deck.findIndex(c => c.kind === kind);
|
||||
assert.ok(at >= 0);
|
||||
const card = game.deck.splice(at, 1)[0]; player.hand.push(card);
|
||||
staging.push(`Conserved ${card.id}: undealt native deck to ${actor}'s hand.`);
|
||||
return card.id;
|
||||
}
|
||||
|
||||
/** Original fresh setup, first Storm and canonical token ownership, real cache
|
||||
* acquisition and phase transitions. Only skill offers, unplayed Spice order,
|
||||
* conserved forces/cards/Traitors are controlled and explicitly labelled.
|
||||
* Never assign wallets, turn, phases, plans, receipts, marker or Tech ownership.
|
||||
* Shipment stops BEFORE the real marker/companion action for authenticated UI use;
|
||||
* battle stops BEFORE chooseBattle, with live native private plan controls next. */
|
||||
export function createRicheseSkillsTechFixture(options: RicheseSkillsTechOptions = {}): RicheseSkillsTechFixture {
|
||||
const kind = options.kind ?? 'marker', marker = options.marker ?? 5;
|
||||
const skill = kind === 'shipment' ? 'smuggler' : 'suk-graduate';
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('RICHESKILLTECH', newPlayer('richese', 'Richese', 'richese'), options.advanced ?? true, ['choam']);
|
||||
if (!options.initial) {
|
||||
joinGame(game, newPlayer('banker', 'Banker', 'atreides'));
|
||||
joinGame(game, newPlayer('opponent', 'Emperor', 'emperor'));
|
||||
}
|
||||
assert.equal(game.status, 'lobby', 'Only a fresh authenticated undealt lobby can enter.');
|
||||
assert.deepEqual(game.expansions, ['choam']);
|
||||
assert.ok(game.players.length >= 3 && game.players.length <= 6);
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
const owner = game.players.find(p => p.faction === 'richese')!.id;
|
||||
const opponent = game.players.find(p => p.faction === (options.opponent ?? 'emperor'))!.id;
|
||||
const banker = game.players.find(p => p.faction === 'atreides')!.id;
|
||||
assert.ok(owner && opponent && banker);
|
||||
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
assert.deepEqual(game.techTokens, createTechTokens());
|
||||
for (const seat of game.players) if (!seat.ready) game = applyAction(game, seat.id, { type: 'ready' });
|
||||
const initial = structuredClone(game), actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
|
||||
const safe: LeaderSkillId[] = ['prana-bindu-adept', 'swordmaster-of-ginaz', 'warmaster', 'killer-medic'];
|
||||
const selected = game.players.map(p => p.id === owner ? skill : p.id === banker ? 'spice-banker' as const : safe.shift()!);
|
||||
const bySeat: Record<string, LeaderSkillId> = Object.fromEntries(game.players.map((p, i) => [p.id, selected[i]]));
|
||||
game = withOffers(selected, () => initializeSpiceBankerIncomeGameForAudit(game));
|
||||
const setup = structuredClone(game);
|
||||
for (let n = 0; game.status === 'setup' && n < 200; n++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const eligible = view.eligibleLeaders.map(l => l.id);
|
||||
const leaders = richeseSkillsTechPlayer(game, actor).leaders.filter(l => eligible.includes(l.id))
|
||||
.sort((a, b) => actor === owner ? b.strength - a.strength : a.strength - b.strength);
|
||||
const selectedSkill = bySeat[actor];
|
||||
assert.ok(offer.cards.includes(selectedSkill) && !view.unavailableSkills?.[selectedSkill] && leaders[0]);
|
||||
const action: Action = { type: 'leaderSkill', event: offer.event, skill: selectedSkill, leader: leaders[0].id };
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
} else game = step(game, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
// Two controlled, still-unplayed original blows, not minted cards or resources.
|
||||
const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 2);
|
||||
for (const [position, card] of blows.entries()) {
|
||||
const at = game.spiceDeck.indexOf(card);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
staging.push('Original all14 offer shuffle and first two unplayed non-worm Spice Cards are controlled.');
|
||||
for (let n = 0; game.decision?.kind !== 'richeseCache' && n < 1000; n++) game = step(game, actions);
|
||||
assert.equal(game.decision?.kind, 'richeseCache');
|
||||
const acquisitionBefore = structuredClone(game), acquisitionActions: RicheseSkillsTechStep[] = [];
|
||||
for (let n = 0; !richeseSkillsTechPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner') && n < 100; n++) game = step(game, acquisitionActions);
|
||||
assert.ok(richeseSkillsTechPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner'));
|
||||
actions.push(...acquisitionActions);
|
||||
const acquisition = { before: acquisitionBefore, actions: acquisitionActions, after: structuredClone(game) };
|
||||
if (options.bankerPayment) {
|
||||
game = advanceRicheseSkillsTechToPhase(game, 4, actions);
|
||||
const payer = game.players.find(p => p.faction === 'emperor');
|
||||
assert.ok(payer);
|
||||
assert.ok(payer.reserves >= 3);
|
||||
payer.reserves -= 3; payer.tanks += 3;
|
||||
staging.push('Three original Emperor reserve counters conserved into Tanks before actual four-spice revival.');
|
||||
const action: Action = { type: 'revive', amount: 3 };
|
||||
actions.push({ actor: payer.id, action }); game = applyAction(game, payer.id, action);
|
||||
}
|
||||
game = advanceRicheseSkillsTechToPhase(game, 5, actions);
|
||||
for (let n = 0; game.active !== owner && n < 20; n++) game = step(game, actions);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, owner);
|
||||
for (const p of game.players) {
|
||||
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
|
||||
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {}; }
|
||||
if (p.advisors) p.advisors = {};
|
||||
const kept = p.id === owner ? p.hand.filter(c => c.id === 'richese-stone-burner') : [];
|
||||
game.deck.push(...p.hand.filter(c => !kept.includes(c))); p.hand = kept;
|
||||
}
|
||||
staging.push('Return original board counters to their own reserves and ordinary hands to undealt deck; retain the cache-acquired Stone in Richese custody.');
|
||||
const destination = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm));
|
||||
assert.ok(destination);
|
||||
const territory = destination.id, key = `${territory}:${destination.sectors[0]}`;
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === skill)!.leader;
|
||||
let beforeShipment: Game | null = null, shipmentAction: Action | null = null, afterShipment: Game | null = null;
|
||||
if (kind !== 'stone') {
|
||||
const own = richeseSkillsTechPlayer(game, owner), token = own.noField!.tokens.find(t => t.value === marker)!;
|
||||
shipmentAction = { type: 'ship', territory, sector: destination.sectors[0], noField: token.id, event: own.noFieldEvent,
|
||||
...(kind === 'shipment' ? { smuggler: true } : {}) };
|
||||
beforeShipment = structuredClone(game);
|
||||
if (kind !== 'shipment') {
|
||||
actions.push({ actor: owner, action: shipmentAction }); game = applyAction(game, owner, shipmentAction);
|
||||
for (let n = 0; !clean(game) && n < 100; n++) game = step(game, actions);
|
||||
afterShipment = structuredClone(game);
|
||||
}
|
||||
}
|
||||
const dial = kind === 'marker' ? Math.min(marker, options.reserves ?? 20, 3) : 2;
|
||||
let ownerPlan: Action = { type: 'battlePlan', dial, support: game.advanced ? dial : 0,
|
||||
leader: options.band === 'normal' ? weakestUntrained(game, owner) : trainer };
|
||||
let opponentPlan: Action = { type: 'battlePlan', dial: 0, support: 0, leader: weakestUntrained(game, opponent) };
|
||||
if (kind !== 'shipment') {
|
||||
const own = richeseSkillsTechPlayer(game, owner), enemy = richeseSkillsTechPlayer(game, opponent);
|
||||
const reserveLimit = options.reserves ?? 20;
|
||||
assert.ok(reserveLimit >= 0 && reserveLimit <= own.reserves);
|
||||
if (kind === 'marker' && own.reserves > reserveLimit) {
|
||||
own.forces['polar_sink:0'] = own.reserves - reserveLimit; own.reserves = reserveLimit;
|
||||
staging.push(`Conserve Richese counters outside battle in Polar Sink, leaving ${reserveLimit} real reserves.`);
|
||||
}
|
||||
const enemyAmount = kind === 'stone' ? 4 : 6;
|
||||
enemy.reserves -= enemyAmount; enemy.forces[key] = enemyAmount;
|
||||
if (kind === 'stone') {
|
||||
own.reserves -= 8; own.forces[key] = 8;
|
||||
ownerPlan = { ...ownerPlan, weapon: 'richese-stone-burner' };
|
||||
opponentPlan = { ...opponentPlan, dial: 1, support: game.advanced ? 1 : 0 };
|
||||
if (options.precedence === 'explosion') {
|
||||
ownerPlan.defense = held(game, owner, 'shield', staging);
|
||||
opponentPlan.weapon = held(game, opponent, 'lasgun', staging);
|
||||
}
|
||||
if (options.precedence === 'double-traitor') {
|
||||
exchangeTraitor(game, owner, String(opponentPlan.leader), staging);
|
||||
exchangeTraitor(game, opponent, String(ownerPlan.leader), staging);
|
||||
}
|
||||
} else {
|
||||
ownerPlan.defense = held(game, owner, 'shield', staging);
|
||||
opponentPlan.weapon = held(game, opponent, 'worthless', staging);
|
||||
}
|
||||
staging.push(`Conserved ordinary opponent battle counters: ${enemyAmount}; Stone owner counters: ${kind === 'stone' ? 8 : 0}.`);
|
||||
game = advanceRicheseSkillsTechToPhase(game, 6, actions);
|
||||
assert.equal(game.phase, 6);
|
||||
}
|
||||
assertAdvancedNativeCustody(game);
|
||||
return { game, initial, setup, actions, staging, owner, opponent, banker, trainer, territory, key, marker, kind,
|
||||
beforeShipment, shipmentAction, afterShipment, acquisition, ownerPlan, opponentPlan };
|
||||
}
|
||||
|
||||
export function payRicheseSkillsTechShipment(fixture: RicheseSkillsTechFixture): Game {
|
||||
assert.equal(fixture.kind, 'shipment'); assert.ok(fixture.shipmentAction);
|
||||
let game = applyAction(fixture.game, fixture.owner, fixture.shipmentAction);
|
||||
for (let n = 0; (!clean(game) || game.pendingShipment) && n < 200; n++) game = step(game);
|
||||
assert.ok(clean(game) && !game.pendingShipment);
|
||||
return game;
|
||||
}
|
||||
@@ -19,8 +19,8 @@ export type SpiceBankerIncomeFixture = {game:Game;beforePayment:Game;actor:strin
|
||||
export function nextSpiceBankerIncomeNativeStep(game:Game):SpiceBankerIncomeNativeStep|null {
|
||||
if(game.status==='finished') return null;
|
||||
if(game.pendingTreacheryDiscard) return {actor:game.host,action:{type:'advanceBots'}};
|
||||
if(game.response) return {actor:game.players.find(p=>!game.response!.passed.includes(p.id))!.id,action:{type:'passResponse'}};
|
||||
if(game.phaseOpening) return {actor:game.players.find(p=>!game.phaseOpening!.passed.includes(p.id))!.id,action:{type:'ready'}};
|
||||
if(game.response) return {actor:game.players.find(p=>!game.response!.passed.includes(p.id))!.id,action:{type:'passResponse'}};
|
||||
if(game.decision?.kind==='bureaucratPayment') return {actor:game.decision.player,action:{type:'decision',event:game.decision.event,redirect:false}};
|
||||
if(game.decision?.kind==='auctionPayment') return {actor:game.decision.player,action:{type:'decision',karama:false}};
|
||||
if(game.decision?.kind==='guildShipment') return {actor:game.decision.player,action:{type:'decision',allow:true}};
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES, splitLocation } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import {
|
||||
initializeAdvancedNativeSkillsSetup, completeAdvancedNativeSkillsSetup,
|
||||
advancedNativePlayer, type AdvancedNativeSkillsOptions,
|
||||
} from './fixture-advanced-native-skills';
|
||||
import { nextSkillsTechBattleStep, openSkillsTechBattle } from './fixture-skills-tech-battle';
|
||||
|
||||
export type TleilaxuSkillsTechOptions = {
|
||||
/** Fresh authenticated, undealt original lobby; preserve its IDs and order. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
winner?: 'guild' | 'ixians';
|
||||
skill?: 'suk-graduate' | 'rihani-decipherer';
|
||||
winnerDies?: boolean;
|
||||
};
|
||||
export type TleilaxuSkillsTechStep = { actor: string; action: Action };
|
||||
export type TleilaxuSkillsTechFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforePlans: Game;
|
||||
revealed: Game;
|
||||
skillChoice: Game | null;
|
||||
winnerCards: Game;
|
||||
faceDance: Game;
|
||||
actions: TleilaxuSkillsTechStep[];
|
||||
winner: string;
|
||||
tleilaxu: string;
|
||||
winnerLeader: string;
|
||||
loserLeader: string;
|
||||
territory: string;
|
||||
location: string;
|
||||
sector: number;
|
||||
boardSource: string;
|
||||
weapon: string;
|
||||
defense: string;
|
||||
loserCard: string;
|
||||
skill: 'suk-graduate' | 'rihani-decipherer';
|
||||
winnerDies: boolean;
|
||||
ownerPlan: Action;
|
||||
opponentPlan: Action;
|
||||
faceDanceAction: Action;
|
||||
};
|
||||
|
||||
export const tleilaxuSkillsTechPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
};
|
||||
export function tleilaxuSkillsTechForces(game: Game, id: string, territory: string): number {
|
||||
return Object.entries(tleilaxuSkillsTechPlayer(game, id).forces)
|
||||
.filter(([key]) => splitLocation(key).territory === territory).reduce((sum, [, count]) => sum + count, 0);
|
||||
}
|
||||
export function nextTleilaxuSkillsTechStep(game: Game): TleilaxuSkillsTechStep {
|
||||
if (game.decision?.kind === 'rihani') {
|
||||
assert.equal(game.decision.stage, 'offer');
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, draw: false } };
|
||||
}
|
||||
if (game.decision?.kind === 'ixSubstitution')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
if (game.decision?.kind === 'sukRescue') {
|
||||
const decision = game.decision;
|
||||
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event,
|
||||
choice: decision.options.findIndex(o => o.normal + o.elite === maximum) } };
|
||||
}
|
||||
return nextSkillsTechBattleStep(game);
|
||||
}
|
||||
function step(game: Game, actions?: TleilaxuSkillsTechStep[]): Game {
|
||||
const next = nextTleilaxuSkillsTechStep(game);
|
||||
actions?.push(next);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
/** Original transitions only. Empty Battle is allowed to skip into Collection. */
|
||||
export function advanceTleilaxuSkillsTechToPhase(state: Game, target: number): Game {
|
||||
let game = state;
|
||||
for (let i = 0; (game.phase < target || game.phaseOpening || game.response || game.decision)
|
||||
&& game.turn === state.turn && i < 1000; i++) game = step(game);
|
||||
assert.equal(game.turn, state.turn);
|
||||
assert.ok(game.phase >= target && !game.phaseOpening && !game.response && !game.decision);
|
||||
return game;
|
||||
}
|
||||
function setup(options: TleilaxuSkillsTechOptions) {
|
||||
const faction = options.winner ?? 'guild';
|
||||
let initial = options.initial ? structuredClone(options.initial)
|
||||
: createGame('TLEISKILLTECH', newPlayer(faction, faction, faction), options.advanced ?? true, ['ix']);
|
||||
if (!options.initial) {
|
||||
joinGame(initial, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
|
||||
joinGame(initial, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
}
|
||||
assert.equal(initial.status, 'lobby');
|
||||
assert.ok(initial.players.length >= 3 && initial.players.length <= 6);
|
||||
assert.equal(new Set(initial.players.map(p => p.faction)).size, initial.players.length);
|
||||
assert.deepEqual(initial.expansions, ['ix']);
|
||||
if (options.advanced !== undefined) assert.equal(initial.advanced, options.advanced);
|
||||
advancedNativePlayer(initial, faction);
|
||||
advancedNativePlayer(initial, 'tleilaxu');
|
||||
const original = structuredClone(initial);
|
||||
if (!initial.techTokens) initial = applyAction(initial, initial.host, { type: 'techTokens', enabled: true });
|
||||
const native: AdvancedNativeSkillsOptions = { family: 'tleilaxu', skillOwner: faction,
|
||||
requestedSkill: options.skill ?? 'suk-graduate', rules: initial.advanced ? 'advanced' : 'basic', initial };
|
||||
const game = completeAdvancedNativeSkillsSetup(initializeAdvancedNativeSkillsSetup(native), native);
|
||||
return { initial: original, game };
|
||||
}
|
||||
function held(game: Game, id: string, matches: (card: Card) => boolean): string {
|
||||
const index = game.deck.findIndex(matches);
|
||||
assert.ok(index >= 0, 'An original physical card must remain available.');
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
tleilaxuSkillsTechPlayer(game, id).hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
/** Conserved physical Face Dancer/Traitor exchange, not a natural draw claim. */
|
||||
function matchingFaceDancer(game: Game, owner: string, leader: string) {
|
||||
const cards = tleilaxuSkillsTechPlayer(game, owner).faceDancers!;
|
||||
if (cards.some(card => card.leader === leader && !card.revealed)) return;
|
||||
const dancer = cards.find(card => !card.revealed)!;
|
||||
assert.ok(dancer);
|
||||
const reserveIndex = game.traitorReserve!.indexOf(leader);
|
||||
if (reserveIndex >= 0) game.traitorReserve![reserveIndex] = dancer.leader;
|
||||
else {
|
||||
const holder = game.players.find(p => p.traitors.includes(leader));
|
||||
assert.ok(holder, 'Matching original physical Traitor must have a real custodian.');
|
||||
holder.traitors[holder.traitors.indexOf(leader)] = dancer.leader;
|
||||
}
|
||||
dancer.leader = leader;
|
||||
}
|
||||
|
||||
/** Genuine native setup and phase transitions, with explicitly controlled
|
||||
* conserved board/cards/Face Dancer custody at Shipment. No wallet, phase,
|
||||
* plan, receipt, token owner, or played-game retrofit is assigned. */
|
||||
export function createTleilaxuSkillsTechFixture(options: TleilaxuSkillsTechOptions = {}): TleilaxuSkillsTechFixture {
|
||||
const { initial, game: started } = setup(options);
|
||||
const afterSetup = structuredClone(started);
|
||||
let game = advanceTleilaxuSkillsTechToPhase(started, 5);
|
||||
const winner = advancedNativePlayer(game, options.winner ?? 'guild').id;
|
||||
const tleilaxu = advancedNativePlayer(game, 'tleilaxu').id;
|
||||
assert.equal(game.techTokens!.axlotl.owner, tleilaxu, 'Original printed native owner is the reward donor.');
|
||||
assert.deepEqual(ownedTech(game.techTokens, tleilaxu), ['axlotl']);
|
||||
const skill = options.skill ?? 'suk-graduate';
|
||||
const winnerDies = options.winnerDies ?? false;
|
||||
const winnerLeader = game.leaderSkills!.assignments.find(a => a.owner === winner && a.skill === skill)!.leader;
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const loserLeader = tleilaxuSkillsTechPlayer(game, tleilaxu).leaders
|
||||
.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0].id;
|
||||
const sites = TERRITORIES.filter(t => t.type === 'sand' && !t.sectors.includes(game.storm)
|
||||
&& !Object.keys(game.spice).some(key => splitLocation(key).territory === t.id && game.spice[key] > 0));
|
||||
assert.ok(sites.length >= 2);
|
||||
const territory = sites[0].id, sector = sites[0].sectors[0], location = `${territory}:${sector}`;
|
||||
const boardSource = `${sites[1].id}:${sites[1].sectors[0]}`;
|
||||
for (const p of game.players) {
|
||||
game.deck.push(...p.hand); p.hand = [];
|
||||
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
|
||||
if (p.elites) {
|
||||
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
|
||||
p.elites.forces = {};
|
||||
}
|
||||
if (p.advisors) p.advisors = {};
|
||||
}
|
||||
for (const id of [winner, tleilaxu]) {
|
||||
const p = tleilaxuSkillsTechPlayer(game, id);
|
||||
assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= 8);
|
||||
p.reserves -= 8; p.forces[location] = 8;
|
||||
}
|
||||
if (tleilaxuSkillsTechPlayer(game, winner).faction === 'ixians') {
|
||||
const ix = tleilaxuSkillsTechPlayer(game, winner);
|
||||
ix.elites!.reserves -= 2;
|
||||
ix.elites!.forces[location] = 2;
|
||||
}
|
||||
const dancers = tleilaxuSkillsTechPlayer(game, tleilaxu);
|
||||
dancers.reserves -= 3; dancers.forces[boardSource] = 3;
|
||||
matchingFaceDancer(game, tleilaxu, winnerLeader);
|
||||
const weapon = held(game, winner, c => c.kind === 'projectile');
|
||||
const defense = held(game, winner, c => c.kind === 'shield');
|
||||
const loserCard = held(game, tleilaxu, c => c.kind === (winnerDies ? 'poison' : 'worthless'));
|
||||
game = advanceTleilaxuSkillsTechToPhase(game, 6);
|
||||
assert.equal(game.phase, 6);
|
||||
game = openSkillsTechBattle(game, winner, tleilaxu, territory, true);
|
||||
const beforePlans = structuredClone(game);
|
||||
const ixWinner = tleilaxuSkillsTechPlayer(game, winner).faction === 'ixians';
|
||||
const ownerPlan: Action = { type: 'battlePlan', leader: winnerLeader, dial: game.advanced && ixWinner ? 5 : 4,
|
||||
support: game.advanced ? ixWinner ? 1 : 4 : 0, weapon, defense };
|
||||
const opponentPlan: Action = { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: loserCard };
|
||||
game = applyAction(game, winner, ownerPlan);
|
||||
game = applyAction(game, tleilaxu, opponentPlan);
|
||||
const revealed = structuredClone(game), actions: TleilaxuSkillsTechStep[] = [];
|
||||
let skillChoice: Game | null = null, winnerCards: Game | null = null;
|
||||
for (let i = 0; game.decision?.kind !== 'faceDance' && i < 200; i++) {
|
||||
if (game.decision?.kind === 'sukRescue' || game.decision?.kind === 'rihani') skillChoice = structuredClone(game);
|
||||
if (game.decision?.kind === 'battleCards') {
|
||||
assert.equal(game.decision.player, winner);
|
||||
winnerCards = structuredClone(game);
|
||||
const next: TleilaxuSkillsTechStep = { actor: winner, action: { type: 'decision', discard: [defense] } };
|
||||
actions.push(next); game = applyAction(game, next.actor, next.action);
|
||||
} else game = step(game, actions);
|
||||
}
|
||||
assert.ok(winnerCards, 'Original winner card cleanup must precede Tech and Face Dance.');
|
||||
assert.equal(game.decision?.kind, 'faceDance');
|
||||
assert.equal(game.techTokens!.axlotl.owner, winner);
|
||||
const faceDanceAction: Action = { type: 'decision', reveal: true, sources: { reserves: 1, [boardSource]: 2 }, sector };
|
||||
return { initial, afterSetup, beforePlans, revealed, skillChoice, winnerCards, faceDance: game, actions,
|
||||
winner, tleilaxu, winnerLeader, loserLeader, territory, location, sector, boardSource,
|
||||
weapon, defense, loserCard, skill, winnerDies, ownerPlan, opponentPlan, faceDanceAction };
|
||||
}
|
||||
|
||||
export type TleilaxuSkillsTechRevivalFixture = {
|
||||
game: Game; tleilaxu: string; other: string; nativeTrainer: string; foreignLeader: string;
|
||||
ownFreeAction: Action; otherFreeAction: Action; foreignGholaAction: Action;
|
||||
};
|
||||
/** Original Revival boundary. Only conserved reserve->Tanks counters and two
|
||||
* untrained original discs' dead states are controlled, never funding. The
|
||||
* native dead disc makes genuine room in Tleilaxu's five-leader active pool. */
|
||||
export function createTleilaxuSkillsTechRevivalFixture(options: Pick<TleilaxuSkillsTechOptions, 'initial' | 'advanced'> = {}): TleilaxuSkillsTechRevivalFixture {
|
||||
const { game: started } = setup({ ...options, winner: 'guild' });
|
||||
const game = advanceTleilaxuSkillsTechToPhase(started, 4);
|
||||
const tleilaxu = advancedNativePlayer(game, 'tleilaxu').id;
|
||||
const other = advancedNativePlayer(game, 'guild').id;
|
||||
for (const id of [tleilaxu, other]) {
|
||||
const p = tleilaxuSkillsTechPlayer(game, id);
|
||||
assert.ok(p.reserves >= 1); p.reserves--; p.tanks++;
|
||||
}
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const foreign = advancedNativePlayer(game, 'emperor').leaders.filter(l => !trained.has(l.id))
|
||||
.sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(foreign); foreign.dead = true; foreign.deaths++;
|
||||
const ownDead = advancedNativePlayer(game, 'tleilaxu').leaders.find(l => !trained.has(l.id));
|
||||
assert.ok(ownDead); ownDead.dead = true; ownDead.deaths++;
|
||||
const nativeTrainer = game.leaderSkills!.assignments.find(a => a.owner === tleilaxu)!.leader;
|
||||
return { game, tleilaxu, other, nativeTrainer, foreignLeader: foreign.id,
|
||||
ownFreeAction: { type: 'revive', amount: 1 }, otherFreeAction: { type: 'revive', amount: 1 },
|
||||
foreignGholaAction: { type: 'reviveForeignGhola', leader: foreign.id } };
|
||||
}
|
||||
export function settleTleilaxuSkillsTechRevival(state: Game): Game {
|
||||
let game = state;
|
||||
for (let i = 0; (game.response || game.decision || game.pendingRevival) && i < 100; i++) game = step(game);
|
||||
assert.ok(!game.response && !game.decision && !game.pendingRevival);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { advanceSkillsTechPaymentsToPhase } from './fixture-skills-tech-payments';
|
||||
import {
|
||||
createIxChoamSkillsTechFixture, initializeIxChoamSkillsTechSetup,
|
||||
ixChoamSkillsTechPlayer as player, nextIxChoamSkillsTechStep, settleIxChoamSkillsTech,
|
||||
} from './fixture-ix-choam-skills-tech';
|
||||
|
||||
function custody(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
|
||||
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected native action cannot mutate custody or money.');
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const rules = advanced ? 'Advanced' : 'Basic';
|
||||
for (const family of ['ixians', 'choam'] as const) {
|
||||
for (const smuggler of [true, false]) void test(`${rules} ${family}: real typed four-counter invoice ${smuggler ? 'includes the free companion' : 'declines it'} and pays only native Heighliners at phase end`, () => {
|
||||
const fixture = createIxChoamSkillsTechFixture({ advanced, family, amount: 4, smuggler, bankerIncome: true });
|
||||
const before = fixture.game;
|
||||
const payer = player(before, fixture.actor);
|
||||
const tokenOwner = before.techTokens!.heighliners.owner!;
|
||||
const paid = settleIxChoamSkillsTech(applyAction(before, fixture.actor, fixture.action));
|
||||
const delivered = player(paid, fixture.actor);
|
||||
assert.equal(delivered.spice, payer.spice - (smuggler ? 3 : 4));
|
||||
assert.equal(delivered.reserves, payer.reserves - 4);
|
||||
assert.equal(delivered.forces[fixture.key!], 4, 'Free means a delivered physical counter, not a price-only fiction.');
|
||||
if (family === 'ixians') {
|
||||
assert.equal(tokenOwner, fixture.owner, 'Original setup assigns printed Heighliners to Ixians.');
|
||||
assert.equal(delivered.elites!.reserves, payer.elites!.reserves - 2);
|
||||
assert.equal(delivered.elites!.forces[fixture.key!], 2);
|
||||
}
|
||||
for (const p of before.players.filter(p => p.id !== fixture.actor))
|
||||
assert.equal(player(paid, p.id).spice, p.spice, 'Neither deferred store is spendable upon arrival.');
|
||||
const techAmount = ownedTech(before.techTokens, tokenOwner).length;
|
||||
assert.equal(paid.techTokens!.heighliners.spice, techAmount);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, before.techTokens!.axlotl.spice);
|
||||
const bankerAmount = smuggler ? 0 : 1;
|
||||
assert.deepEqual(viewGame(paid, fixture.banker!).spiceBankerIncome!.deferred,
|
||||
bankerAmount ? [{ owner: fixture.banker, amount: bankerAmount }] : []);
|
||||
const sources = structuredClone(paid.spiceBankerIncome!.sources);
|
||||
const end = advanceSkillsTechPaymentsToPhase(paid, 6);
|
||||
// Empty Battle may auto-skip into native Collection. Quote that separately,
|
||||
// rather than treating stronghold income as a discounted shipment fee.
|
||||
const expected = end.phase === 7
|
||||
? Object.fromEntries(quoteSpiceCollection(paid).receipts.map(r => [r.player, r.balance]))
|
||||
: Object.fromEntries(paid.players.map(p => [p.id, p.spice]));
|
||||
expected[tokenOwner] += techAmount;
|
||||
assert.deepEqual(Object.fromEntries(end.players.map(p => [p.id, p.spice])), expected);
|
||||
assert.equal(end.techTokens!.heighliners.spice, 0);
|
||||
assert.deepEqual(end.spiceBankerIncome!.sources, sources, 'Tech and Collection income are not Banker bank payments.');
|
||||
const collection = advanceSkillsTechPaymentsToPhase(end, 7);
|
||||
const mentat = advanceSkillsTechPaymentsToPhase(collection, 8);
|
||||
for (const p of collection.players)
|
||||
assert.equal(player(mentat, p.id).spice, p.spice + (p.id === fixture.banker ? bankerAmount : 0));
|
||||
assert.deepEqual(viewGame(mentat, fixture.banker!).spiceBankerIncome!.deferred, []);
|
||||
assert.deepEqual(mentat.spiceBankerIncome!.sources, sources);
|
||||
reject(paid, fixture.actor, fixture.action);
|
||||
custody(mentat);
|
||||
});
|
||||
|
||||
for (const band of ['normal', 'skilled'] as const) void test(`${rules} ${family} ${band} Suk: native typed casualties rescue to board/reserves, remaining counters enter Tanks, and the original loser token follows victory`, () => {
|
||||
const fixture = createIxChoamSkillsTechFixture({ advanced, family, kind: 'suk-battle', band });
|
||||
const before = fixture.game;
|
||||
const owner = player(before, fixture.owner);
|
||||
const enemyToken = ownedTech(before.techTokens, fixture.opponent);
|
||||
assert.equal(enemyToken.length, 1, 'Three original seats receive one original token each.');
|
||||
let game = applyAction(before, fixture.owner, fixture.action);
|
||||
game = applyAction(game, fixture.opponent, fixture.opponentPlan!);
|
||||
game = settleIxChoamSkillsTech(game, true);
|
||||
const pending = game;
|
||||
const decision = game.decision?.kind === 'sukRescue' ? game.decision : null;
|
||||
const losses = decision ? game.pendingSukRescue!.losses!
|
||||
: family === 'ixians' ? { normal: 0, elite: 2 } : { normal: 4, elite: 0 };
|
||||
assert.equal(player(game, fixture.owner).spice, owner.spice - fixture.cost,
|
||||
'CHOAM own support earns no CHOAM payment income; Tech reward does not mint money.');
|
||||
assert.equal(game.techTokens![enemyToken[0]].owner, fixture.opponent, 'Suk resolves before original Tech custody reward.');
|
||||
if (family === 'ixians' && advanced)
|
||||
assert.deepEqual(losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: 1 });
|
||||
if (!decision) {
|
||||
assert.equal(band, 'normal');
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true, 'A uniquely determined normal rescue is automatic.');
|
||||
}
|
||||
const choice = decision?.options.findIndex(o => band === 'normal'
|
||||
? o.normal === (family === 'ixians' ? 0 : 1) && o.elite === (family === 'ixians' ? 1 : 0) && !o.kept
|
||||
: family === 'ixians' && !advanced
|
||||
? o.normal === 0 && o.elite === 2 && o.kept?.kind === 'elite'
|
||||
: o.normal === (family === 'ixians' ? 2 : 3) && o.elite === (family === 'ixians' ? 1 : 0) && o.kept?.kind === 'normal') ?? -1;
|
||||
if (decision) assert.ok(choice >= 0);
|
||||
const selected = decision ? decision.options[choice]
|
||||
: { normal: family === 'ixians' ? 0 : 1, elite: family === 'ixians' ? 1 : 0, kept: null };
|
||||
if (decision) {
|
||||
reject(pending, fixture.opponent, { type: 'decision', event: decision.event, choice });
|
||||
reject(pending, fixture.owner, { type: 'decision', event: decision.event, choice: decision.options.length });
|
||||
game = applyAction(pending, fixture.owner, { type: 'decision', event: decision.event, choice });
|
||||
}
|
||||
const rescued = player(game, fixture.owner);
|
||||
const kept = selected.kept ? 1 : 0;
|
||||
assert.equal(rescued.reserves, owner.reserves + selected.normal + selected.elite - kept);
|
||||
assert.equal(rescued.tanks, owner.tanks + losses.normal + losses.elite - selected.normal - selected.elite);
|
||||
assert.equal(rescued.forces[fixture.key!], 8 - losses.normal - losses.elite + kept);
|
||||
if (family === 'ixians') {
|
||||
assert.equal(rescued.elites!.reserves, owner.elites!.reserves + selected.elite - (selected.kept?.kind === 'elite' ? 1 : 0));
|
||||
assert.equal(rescued.elites!.tanks, owner.elites!.tanks + losses.elite - selected.elite);
|
||||
assert.equal(rescued.elites!.forces[fixture.key!] ?? 0, 2 - losses.elite + (selected.kept?.kind === 'elite' ? 1 : 0));
|
||||
}
|
||||
custody(game);
|
||||
game = settleIxChoamSkillsTech(game);
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(game.techTokens![enemyToken[0]].owner, fixture.owner);
|
||||
assert.equal(ownedTech(game.techTokens, fixture.owner).length, 2);
|
||||
assert.equal(ownedTech(game.techTokens, fixture.opponent).length, 0);
|
||||
assert.equal(game.techTokens![enemyToken[0]].spice, 0, 'Original reward transfers custody, not a new income event.');
|
||||
custody(game);
|
||||
|
||||
if (decision) for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, fixture.owner);
|
||||
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, difficulty);
|
||||
const continued = applyAction(pending, fixture.owner, action);
|
||||
assert.equal(continued.pendingSukRescue, null);
|
||||
assert.equal(player(continued, fixture.owner).spice, owner.spice - fixture.cost);
|
||||
custody(continued);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${rules} CHOAM: real exhausted Emperor shipment makes next-turn charity eligible; only original Spice Production accrues, never Axlotl`, () => {
|
||||
const fixture = createIxChoamSkillsTechFixture({ advanced, family: 'choam', kind: 'charity', bankerIncome: true });
|
||||
const before = fixture.game;
|
||||
assert.ok(fixture.actions.some(step => step.actor === fixture.actor && step.action.type === 'ship'
|
||||
&& step.action.amount === 5), 'Eligibility comes from an actual original ten-spice desert invoice.');
|
||||
const incomeBefore = fixture.beforeCharityIncome!;
|
||||
const incomeAfter = fixture.afterCharityIncome!;
|
||||
assert.equal(incomeBefore.response!.kind, 'choamCharity');
|
||||
for (const p of incomeBefore.players) assert.equal(player(incomeAfter, p.id).spice,
|
||||
p.spice + (p.id === fixture.owner ? 2 * incomeBefore.players.length : 0));
|
||||
assert.deepEqual(incomeAfter.techTokens, incomeBefore.techTokens,
|
||||
'Original CHOAM opening income is not a Charity claim or Tech trigger.');
|
||||
assert.deepEqual(incomeAfter.spiceBankerIncome, incomeBefore.spiceBankerIncome,
|
||||
'Opening income is not an actual four-spice bank payment.');
|
||||
const productionOwner = before.techTokens!.production.owner!;
|
||||
const after = applyAction(before, fixture.actor, fixture.action);
|
||||
assert.equal(player(after, fixture.actor).spice, 2);
|
||||
assert.equal(player(after, fixture.owner).spice, player(before, fixture.owner).spice - fixture.amount);
|
||||
assert.equal(after.techTokens!.production.spice, ownedTech(before.techTokens, productionOwner).length);
|
||||
assert.deepEqual(after.techTokens!.axlotl, before.techTokens!.axlotl);
|
||||
assert.deepEqual(after.spiceBankerIncome, before.spiceBankerIncome, 'Charity is CHOAM-funded income, not a skill bank payment.');
|
||||
reject(after, fixture.actor, fixture.action);
|
||||
const end = advanceSkillsTechPaymentsToPhase(after, 3);
|
||||
for (const p of after.players) assert.equal(player(end, p.id).spice,
|
||||
p.spice + (p.id === productionOwner ? ownedTech(before.techTokens, productionOwner).length : 0));
|
||||
assert.equal(end.techTokens!.production.spice, 0);
|
||||
assert.deepEqual(end.techTokens!.axlotl, before.techTokens!.axlotl);
|
||||
custody(end);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced CHOAM Auditor remains unskilled in original Tech setup and an eligible Bene Gesserit charity claim is expressly excluded from Spice Production', () => {
|
||||
const initial = createGame('AUTHCHOAMTECH', newPlayer('human-e', 'Human Emperor', 'emperor'), true, ['choam']);
|
||||
joinGame(initial, newPlayer('human-c', 'Human CHOAM', 'choam'));
|
||||
joinGame(initial, newPlayer('human-bg', 'Human BG', 'beneGesserit'));
|
||||
const identities = initial.players.map(p => ({ id: p.id, name: p.name, faction: p.faction }));
|
||||
let setup = initializeIxChoamSkillsTechSetup({ initial, family: 'choam', kind: 'charity' });
|
||||
while (setup.setupStage === 'prediction') {
|
||||
const next = nextIxChoamSkillsTechStep(setup);
|
||||
setup = applyAction(setup, next.actor, next.action);
|
||||
}
|
||||
const offer = setup.leaderSkills!.offers['human-c'];
|
||||
assert.ok(!viewGame(setup, 'human-c').leaderSkills!.eligibleLeaders.some(l => l.id === 'choam-auditor'));
|
||||
reject(setup, 'human-c', { type: 'leaderSkill', event: offer.event, skill: 'smuggler', leader: 'choam-auditor' });
|
||||
const fixture = createIxChoamSkillsTechFixture({ initial, family: 'choam', kind: 'charity' });
|
||||
assert.equal(fixture.actor, 'human-e');
|
||||
assert.equal(fixture.owner, 'human-c');
|
||||
assert.deepEqual(fixture.game.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), identities);
|
||||
assert.ok(!fixture.game.leaderSkills!.assignments.some(a => a.leader === 'choam-auditor'));
|
||||
const before = fixture.game;
|
||||
const claimed = settleIxChoamSkillsTech(applyAction(before, 'human-bg', { type: 'charity' }));
|
||||
assert.equal(player(claimed, 'human-bg').spice, player(before, 'human-bg').spice + 2);
|
||||
assert.equal(player(claimed, 'human-c').spice, player(before, 'human-c').spice - 2);
|
||||
assert.deepEqual(claimed.techTokens!.production, before.techTokens!.production, 'Advanced BG income is not the original production trigger.');
|
||||
assert.deepEqual(claimed.techTokens!.axlotl, before.techTokens!.axlotl);
|
||||
const eligible = applyAction(claimed, fixture.actor, fixture.action);
|
||||
assert.equal(eligible.techTokens!.production.spice, 1);
|
||||
custody(eligible);
|
||||
});
|
||||
@@ -880,7 +880,7 @@ void test('Banker income rejects incompatible profiles and public activation wit
|
||||
game => { game.players.splice(1); },
|
||||
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
|
||||
];
|
||||
const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
|
||||
const modules = ['homeworlds', 'strongholdCards'].map(type => {
|
||||
const game = applyAction(native, native.host, { type, enabled: true });
|
||||
for (const player of game.players) player.ready = true;
|
||||
return game;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
|
||||
import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
|
||||
import {
|
||||
advanceRicheseSkillsTechToPhase, createRicheseSkillsTechFixture, finishRicheseSkillsTechBattle,
|
||||
nextRicheseSkillsTechNativeStep, openRicheseSkillsTechBattle, payRicheseSkillsTechShipment,
|
||||
richeseSkillsTechPlayer as player, type RicheseSkillsTechFixture,
|
||||
} from './fixture-richese-skills-tech';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function cards(game: Game): string[] {
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])].map(c => c.id).sort();
|
||||
}
|
||||
function wallets(game: Game): Record<string, number> {
|
||||
return Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
}
|
||||
function changedTokens(before: Game, after: Game): string[] {
|
||||
return TECH_TOKENS.filter(t => before.techTokens![t.id].owner !== after.techTokens![t.id].owner).map(t => t.id);
|
||||
}
|
||||
function reveal(fixture: RicheseSkillsTechFixture, hide = true): Game {
|
||||
let game = openRicheseSkillsTechBattle(fixture, hide);
|
||||
game = applyAction(game, fixture.owner, fixture.ownerPlan);
|
||||
return applyAction(game, fixture.opponent, fixture.opponentPlan);
|
||||
}
|
||||
function calls(state: Game, callers: string[] = []): Game {
|
||||
let game = state;
|
||||
for (let n = 0; game.battle?.revealed && !game.decision && !game.response && n < 20; n++) {
|
||||
const next = nextRicheseSkillsTechNativeStep(game);
|
||||
if (next.action.type !== 'traitorCall') break;
|
||||
next.action.call = callers.includes(next.actor);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function reward(fixture: RicheseSkillsTechFixture, state: Game): Game {
|
||||
const token = ownedTech(fixture.game.techTokens, fixture.opponent)[0];
|
||||
assert.ok(token, 'Original three-seat first Storm gives the defeated seat one physical token.');
|
||||
assert.deepEqual(changedTokens(fixture.game, state), [], 'Physical rescue/cleanup must precede token reward.');
|
||||
const settled = finishRicheseSkillsTechBattle(state);
|
||||
assert.deepEqual(changedTokens(fixture.game, settled), [token]);
|
||||
assert.equal(settled.techTokens![token].owner, fixture.owner);
|
||||
assert.equal(settled.pendingTech, null);
|
||||
assert.deepEqual(cards(settled), cards(fixture.game));
|
||||
assertAdvancedNativeCustody(settled);
|
||||
return settled;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} native Richese ${marker} marker reveals only after both plans, caps real Suk casualties, then rewards the original loser token`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ advanced, marker, ...(marker === 5 ? { reserves: 3 } : {}) });
|
||||
const beforeShipment = fixture.beforeShipment!, shipped = fixture.afterShipment!;
|
||||
assert.equal(player(shipped, fixture.owner).spice, player(beforeShipment, fixture.owner).spice - 1);
|
||||
assert.equal(player(shipped, fixture.owner).reserves, player(beforeShipment, fixture.owner).reserves);
|
||||
let game = openRicheseSkillsTechBattle(fixture);
|
||||
const pool = marker === 5 ? 3 : marker;
|
||||
reject(game, fixture.owner, { ...fixture.ownerPlan, dial: pool + 1, support: advanced ? pool + 1 : 0 });
|
||||
game = applyAction(game, fixture.owner, fixture.ownerPlan);
|
||||
assert.ok(player(game, fixture.owner).noField!.deployed);
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, 0);
|
||||
assert.equal(viewGame(game, fixture.opponent).richeseNoField!.private, null);
|
||||
game = applyAction(game, fixture.opponent, fixture.opponentPlan);
|
||||
assert.equal(player(game, fixture.owner).noField!.deployed, null);
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, pool);
|
||||
assert.equal(player(game, fixture.owner).reserves, (marker === 5 ? 3 : 20) - pool);
|
||||
const before = player(fixture.game, fixture.owner).spice;
|
||||
game = finishRicheseSkillsTechBattle(calls(game), 'skill');
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(player(game, fixture.owner).spice, before - (advanced ? pool : 0));
|
||||
assert.equal(player(game, fixture.opponent).tanks, 6);
|
||||
const losingCard = String(fixture.opponentPlan.weapon);
|
||||
assert.equal(game.discard.filter(c => c.id === losingCard).length, 1);
|
||||
if (pool) {
|
||||
const d = game.decision;
|
||||
assert.ok(d?.kind === 'sukRescue');
|
||||
assert.equal(d.mode, 'skilled');
|
||||
assert.equal(player(game, fixture.owner).tanks, 0, 'Do not pre-apply declared casualties before real rescue.');
|
||||
const choice = d.options.findIndex(o => o.normal === pool && o.elite === 0 && o.kept?.key === fixture.key);
|
||||
assert.ok(choice >= 0);
|
||||
reject(game, fixture.opponent, { type: 'decision', event: d.event, choice });
|
||||
reject(game, fixture.owner, { type: 'decision', event: `${d.event}-stale`, choice });
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', event: d.event, choice });
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key], 1);
|
||||
assert.equal(player(game, fixture.owner).reserves, (marker === 5 ? 3 : 20) - 1);
|
||||
assert.equal(player(game, fixture.owner).tanks, 0);
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
} else {
|
||||
assert.equal(player(game, fixture.owner).reserves, 20);
|
||||
assert.equal(player(game, fixture.owner).tanks, 0);
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, 0);
|
||||
}
|
||||
reward(fixture, game);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} normal Suk saves one actual three-marker casualty, never all declared losses`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ advanced, marker: 3, band: 'normal' });
|
||||
let game = finishRicheseSkillsTechBattle(calls(reveal(fixture, false)), 'cleanup');
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, 0);
|
||||
assert.equal(player(game, fixture.owner).tanks, 2);
|
||||
assert.equal(player(game, fixture.owner).reserves, 18);
|
||||
game = reward(fixture, game);
|
||||
assert.equal(player(game, fixture.opponent).tanks, 6);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Smuggler adds one real companion to No-Field ${marker} without changing its one-marker fee or earning Banker`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ advanced, kind: 'shipment', marker });
|
||||
const before = fixture.game, paid = payRicheseSkillsTechShipment(fixture);
|
||||
const own = player(paid, fixture.owner);
|
||||
assert.equal(own.spice, player(before, fixture.owner).spice - 1);
|
||||
assert.equal(own.reserves, player(before, fixture.owner).reserves - 1);
|
||||
assert.equal(own.forces[fixture.key], 1);
|
||||
assert.equal(own.noField!.deployed!.tokenId, String(fixture.shipmentAction!.noField));
|
||||
assert.equal(paid.techTokens!.heighliners.spice, 1);
|
||||
assert.deepEqual(viewGame(paid, fixture.banker).spiceBankerIncome!.deferred, []);
|
||||
assert.deepEqual(cards(paid), cards(before));
|
||||
assertAdvancedNativeCustody(paid);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a real qualifying revival earns Banker independently; No-Field companion triggers Heighliners and each collects at its original final leg exactly once', () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ kind: 'shipment', bankerPayment: true });
|
||||
const before = fixture.game;
|
||||
assert.deepEqual(viewGame(before, fixture.banker).spiceBankerIncome!.deferred, [{ owner: fixture.banker, amount: 1 }]);
|
||||
const grants = before.spiceBankerIncome!.sources.filter(row => row.grant);
|
||||
assert.deepEqual(grants.map(row => row.source.bankLegs), [[{ payer: fixture.opponent, amount: 4 }]]);
|
||||
const paid = payRicheseSkillsTechShipment(fixture);
|
||||
assert.equal(player(paid, fixture.owner).spice, player(before, fixture.owner).spice - 1);
|
||||
assert.deepEqual(paid.spiceBankerIncome!.sources.filter(row => row.grant), grants);
|
||||
const sources = structuredClone(paid.spiceBankerIncome!.sources);
|
||||
const expected = Object.fromEntries(quoteSpiceCollection(paid).receipts.map(r => [r.player, r.balance]));
|
||||
const techOwner = paid.techTokens!.heighliners.owner!;
|
||||
expected[techOwner] += 1;
|
||||
const collection = advanceRicheseSkillsTechToPhase(paid, 7);
|
||||
assert.equal(collection.phase, 7, 'Empty Battle may correctly auto-skip from Movement to Collection.');
|
||||
assert.deepEqual(wallets(collection), expected);
|
||||
assert.equal(collection.techTokens!.heighliners.spice, 0);
|
||||
assert.deepEqual(viewGame(collection, fixture.banker).spiceBankerIncome!.deferred, [{ owner: fixture.banker, amount: 1 }]);
|
||||
const mentat = advanceRicheseSkillsTechToPhase(collection, 8);
|
||||
expected[fixture.banker] += 1;
|
||||
assert.deepEqual(wallets(mentat), expected);
|
||||
assert.deepEqual(viewGame(mentat, fixture.banker).spiceBankerIncome!.deferred, []);
|
||||
assert.deepEqual(mentat.spiceBankerIncome!.sources, sources, 'Neither Tech income nor Banker collection creates another bank-payment source.');
|
||||
const next = nextRicheseSkillsTechNativeStep(mentat), continued = applyAction(mentat, next.actor, next.action);
|
||||
assert.deepEqual(wallets(continued), expected);
|
||||
assert.deepEqual(continued.spiceBankerIncome!.collections, mentat.spiceBankerIncome!.collections);
|
||||
assertAdvancedNativeCustody(continued);
|
||||
});
|
||||
|
||||
void test('original mixed own-marker battle guard survives Skills + Tech; only native voluntary reveal before Battle permits physical combat', () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ kind: 'shipment', marker: 5 });
|
||||
let paid = payRicheseSkillsTechShipment(fixture);
|
||||
const enemy = player(paid, fixture.opponent);
|
||||
enemy.reserves -= 6; enemy.forces[fixture.key] = 6; // Explicit conserved opponent position, not a played shipment claim.
|
||||
const battle = advanceRicheseSkillsTechToPhase(paid, 6);
|
||||
const actor = battle.active!, target = actor === fixture.owner ? fixture.opponent : fixture.owner;
|
||||
reject(battle, actor, { type: 'chooseBattle', territory: fixture.territory, target });
|
||||
const own = player(paid, fixture.owner), token = own.noField!.deployed!.tokenId;
|
||||
reject(battle, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
|
||||
paid = applyAction(paid, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
|
||||
assert.equal(player(paid, fixture.owner).forces[fixture.key], 6);
|
||||
assert.equal(player(paid, fixture.owner).reserves, 14);
|
||||
assert.equal(player(paid, fixture.owner).noField!.deployed, null);
|
||||
const ready = advanceRicheseSkillsTechToPhase(paid, 6);
|
||||
const opened = openRicheseSkillsTechBattle({ ...fixture, game: ready });
|
||||
assert.equal(viewGame(opened, fixture.owner).battle!.ownForces!.normal, 6);
|
||||
assertAdvancedNativeCustody(opened);
|
||||
});
|
||||
|
||||
for (const mode of ['kill', 'ignore'] as const) void test(`cache-acquired Stone ${mode} uses real undialed forces and cleanup before awarding the actual winner an original loser-owned Tech Token`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ kind: 'stone' });
|
||||
assert.ok(fixture.acquisition.before.richeseCache!.some(c => c.id === 'richese-stone-burner'));
|
||||
assert.equal(fixture.acquisition.after.richeseCache!.some(c => c.id === 'richese-stone-burner'), false);
|
||||
for (const p of fixture.acquisition.before.players) assert.equal(player(fixture.acquisition.after, p.id).spice, p.spice);
|
||||
let game = reveal(fixture);
|
||||
assert.ok(game.decision?.kind === 'stoneBurner');
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', event: game.decision.event, mode });
|
||||
const paidBefore = player(fixture.game, fixture.owner).spice;
|
||||
game = finishRicheseSkillsTechBattle(calls(game), 'skill');
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner, 'Eight minus two undialed counters beats four minus one, independent of leaders.');
|
||||
for (const [actor, plan] of [[fixture.owner, fixture.ownerPlan], [fixture.opponent, fixture.opponentPlan]] as const) {
|
||||
assert.equal(player(game, actor).leaders.find(l => l.id === plan.leader)!.dead, mode === 'kill');
|
||||
}
|
||||
const bounty = mode === 'kill' ? [fixture.ownerPlan, fixture.opponentPlan].reduce((sum, plan, i) =>
|
||||
sum + player(fixture.game, i === 0 ? fixture.owner : fixture.opponent).leaders.find(l => l.id === plan.leader)!.strength, 0) : 0;
|
||||
assert.equal(player(game, fixture.owner).spice, paidBefore - 2 + bounty);
|
||||
if (mode === 'ignore') {
|
||||
const d = game.decision;
|
||||
assert.ok(d?.kind === 'sukRescue', 'Surviving hidden Suk trainer rescues actual Stone dialed casualties.');
|
||||
const choice = d.options.findIndex(o => o.normal === 2 && o.elite === 0 && o.kept?.key === fixture.key);
|
||||
assert.ok(choice >= 0);
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', event: d.event, choice });
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key], 7);
|
||||
assert.equal(player(game, fixture.owner).tanks, 0);
|
||||
assert.equal(player(game, fixture.owner).reserves, 13);
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
} else {
|
||||
assert.equal(game.lastBattleContext!.sukRescue, undefined, 'Killed skilled trainer cannot rescue.');
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key], 6);
|
||||
assert.equal(player(game, fixture.owner).tanks, 2);
|
||||
}
|
||||
assert.equal(player(game, fixture.opponent).tanks, 4);
|
||||
const collection = quoteSpiceCollection(game).receipts.find(r => r.player === fixture.owner)!;
|
||||
const settled = reward(fixture, game);
|
||||
assert.ok(player(settled, fixture.owner).hand.some(c => c.id === 'richese-stone-burner'));
|
||||
assert.equal(player(settled, fixture.owner).spice, paidBefore - 2 + bounty + collection.collected + collection.strongholds,
|
||||
'Only real Stone support/bounty and independently quoted native Collection affect the wallet.');
|
||||
});
|
||||
|
||||
for (const precedence of ['double-traitor', 'explosion'] as const) void test(`${precedence} takes precedence over actual cached Stone and gives no winner, no rescue and no Tech reward`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ kind: 'stone', precedence });
|
||||
let game = reveal(fixture);
|
||||
assert.ok(game.decision?.kind === 'stoneBurner');
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', event: game.decision.event, mode: 'kill' });
|
||||
game = finishRicheseSkillsTechBattle(calls(game, precedence === 'double-traitor' ? [fixture.owner, fixture.opponent] : []));
|
||||
assert.equal(game.lastBattleContext!.winner, null);
|
||||
assert.deepEqual(changedTokens(fixture.game, game), []);
|
||||
assert.equal(player(game, fixture.owner).tanks, 8);
|
||||
assert.equal(player(game, fixture.opponent).tanks, 4);
|
||||
assert.equal(game.discard.filter(c => c.id === 'richese-stone-burner').length, 1);
|
||||
assert.deepEqual(cards(game), cards(fixture.game));
|
||||
assertAdvancedNativeCustody(game);
|
||||
});
|
||||
|
||||
void test('all four own-seat policies legally commit real capped marker plans, resolve skilled Suk and choose both-mode Stone controls without inventing token custody', () => {
|
||||
const marker = createRicheseSkillsTechFixture({ marker: 5, reserves: 3 });
|
||||
const stone = createRicheseSkillsTechFixture({ kind: 'stone' });
|
||||
const pending = finishRicheseSkillsTechBattle(calls(reveal(marker)), 'skill');
|
||||
assert.ok(pending.decision?.kind === 'sukRescue');
|
||||
const choice = reveal(stone);
|
||||
assert.ok(choice.decision?.kind === 'stoneBurner');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const plans = openRicheseSkillsTechBattle(marker);
|
||||
const view = viewGame(plans, marker.owner); view.players.find(p => p.id === marker.owner)!.bot = difficulty;
|
||||
const plan = botActions(view).find(a => a.type === 'battlePlan'); assert.ok(plan);
|
||||
assert.ok(Number(plan.dial) <= 3 && Number(plan.support ?? 0) <= player(plans, marker.owner).spice);
|
||||
const sealed = applyAction(plans, marker.owner, plan);
|
||||
assert.ok(player(sealed, marker.owner).noField!.deployed);
|
||||
assert.deepEqual(changedTokens(marker.game, sealed), []);
|
||||
const rescueView = viewGame(pending, marker.owner); rescueView.players.find(p => p.id === marker.owner)!.bot = difficulty;
|
||||
const rescue = botActions(rescueView)[0]; assert.ok(rescue?.type === 'decision');
|
||||
const rescued = applyAction(pending, marker.owner, rescue);
|
||||
assert.equal(player(rescued, marker.owner).forces[marker.key], 1);
|
||||
reward(marker, rescued);
|
||||
const stoneView = viewGame(choice, stone.owner); stoneView.players.find(p => p.id === stone.owner)!.bot = difficulty;
|
||||
const mode = botActions(stoneView)[0]; assert.ok(mode.mode === 'kill' || mode.mode === 'ignore');
|
||||
const chosen = applyAction(choice, stone.owner, mode);
|
||||
assert.equal(chosen.battle!.stoneBurner![stone.owner], mode.mode);
|
||||
const settled = finishRicheseSkillsTechBattle(calls(chosen));
|
||||
assert.equal(settled.lastBattleContext!.winner, stone.owner);
|
||||
assert.equal(ownedTech(settled.techTokens, stone.owner).length, 2);
|
||||
assertAdvancedNativeCustody(settled);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh authenticated original ready lobby retains IDs and seat order through actual marker payment, battle and original reward', () => {
|
||||
const initial = createGame('AUTHRICH', newPlayer('human-enemy', 'Human Emperor', 'emperor'), true, ['choam']);
|
||||
joinGame(initial, newPlayer('human-richese', 'Human Richese', 'richese'));
|
||||
joinGame(initial, newPlayer('human-banker', 'Human Banker', 'atreides'));
|
||||
const original = structuredClone(initial), identities = initial.players.map(p => ({ id: p.id, name: p.name, faction: p.faction }));
|
||||
const fixture = createRicheseSkillsTechFixture({ initial, marker: 3 });
|
||||
assert.deepEqual(initial, original);
|
||||
assert.equal(fixture.owner, 'human-richese');
|
||||
const completed = finishRicheseSkillsTechBattle(calls(reveal(fixture)));
|
||||
assert.equal(completed.lastBattleContext!.winner, fixture.owner);
|
||||
assert.deepEqual(completed.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), identities);
|
||||
assert.equal(ownedTech(completed.techTokens, fixture.owner).length, 2);
|
||||
assertAdvancedNativeCustody(completed);
|
||||
});
|
||||
|
||||
void test('native Atreides dial and whole-plan prescience cannot disclose an own concealed Richese marker merely because Skills and Tech coexist', () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ marker: 5, opponent: 'atreides' });
|
||||
let game = structuredClone(fixture.game);
|
||||
// Explicit conserved physical Karama custody; no invented offer or preparation.
|
||||
const at = game.deck.findIndex(c => c.effect === 'karama'); assert.ok(at >= 0);
|
||||
const karama = game.deck.splice(at, 1)[0]; player(game, fixture.opponent).hand.push(karama);
|
||||
const actor = game.active!;
|
||||
game = applyAction(game, actor, { type: 'chooseBattle', territory: fixture.territory,
|
||||
target: actor === fixture.owner ? fixture.opponent : fixture.owner });
|
||||
for (let n = 0; game.battle?.preparation?.kind !== 'prescience' && n < 100; n++) {
|
||||
const next = nextRicheseSkillsTechNativeStep(game); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
||||
reject(game, fixture.opponent, { type: 'prescience', field: 'dial' });
|
||||
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
||||
for (let n = 0; game.decision?.kind !== 'fullPlanOffer' && n < 100; n++) {
|
||||
const next = nextRicheseSkillsTechNativeStep(game); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'fullPlanOffer');
|
||||
reject(game, fixture.opponent, { type: 'card', mode: 'special', card: karama.id, target: fixture.owner });
|
||||
assert.ok(player(game, fixture.owner).noField!.deployed);
|
||||
assert.ok(player(game, fixture.opponent).hand.some(c => c.id === karama.id));
|
||||
assert.deepEqual(changedTokens(fixture.game, game), []);
|
||||
assertAdvancedNativeCustody(game);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { quoteSukRescue } from '../game/suk-graduate';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quoteSpiceBankerIncomeBattleEconomics } from './fixture-spice-banker-income';
|
||||
import {
|
||||
createTleilaxuSkillsTechFixture, createTleilaxuSkillsTechRevivalFixture,
|
||||
advanceTleilaxuSkillsTechToPhase, settleTleilaxuSkillsTechRevival,
|
||||
tleilaxuSkillsTechPlayer as player, tleilaxuSkillsTechForces as at,
|
||||
type TleilaxuSkillsTechFixture,
|
||||
} from './fixture-tleilaxu-skills-tech';
|
||||
|
||||
function custody(game: Game) {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
p.faction === 'ixians' ? 7 : 5);
|
||||
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
function retainedReward(f: TleilaxuSkillsTechFixture, after: Game) {
|
||||
const before = player(f.faceDance, f.winner), winner = player(after, f.winner);
|
||||
assert.equal(after.lastBattleContext!.winner, f.winner);
|
||||
assert.equal(after.lastBattleContext!.event, f.faceDance.lastBattleContext!.event);
|
||||
assert.equal(after.lastBattleContext!.result, f.faceDance.lastBattleContext!.result);
|
||||
assert.deepEqual(after.techTokens, f.faceDance.techTokens, 'Replacement is not another win, token claim or Axlotl restoration.');
|
||||
assert.equal(after.techTokens!.axlotl.owner, f.winner);
|
||||
assert.deepEqual(winner.hand, before.hand, 'The original winner keeps its completed physical card outcome.');
|
||||
assert.equal(winner.spice, before.spice, 'Face Dance adds no bounty and claws back no earned spice.');
|
||||
assert.equal(winner.tanks, before.tanks, 'Replacement returns survivors rather than killing a second army.');
|
||||
assert.equal(winner.reserves, before.reserves + at(f.faceDance, f.winner, f.territory));
|
||||
assert.equal(at(after, f.winner, f.territory), 0);
|
||||
const leader = winner.leaders.find(l => l.id === f.winnerLeader)!;
|
||||
const original = before.leaders.find(l => l.id === f.winnerLeader)!;
|
||||
assert.equal(leader.dead, true);
|
||||
assert.equal(leader.deaths, original.deaths + (original.dead ? 0 : 1));
|
||||
assert.ok(!after.leaderSkills!.assignments.some(a => a.leader === f.winnerLeader));
|
||||
assert.equal(after.leaderSkills!.deck.filter(skill => skill === f.skill).length, 1, 'Native trainer death returns the physical skill once.');
|
||||
assert.equal(player(after, f.tleilaxu).faceDancers!.find(c => c.leader === f.winnerLeader)!.revealed, true);
|
||||
custody(after);
|
||||
}
|
||||
|
||||
void test('native Guild/Ix winner earns actual losing Tleilaxu Axlotl after skill and card cleanup, before optional Face Dance', () => {
|
||||
for (const advanced of [false, true]) for (const winner of ['guild', 'ixians'] as const) {
|
||||
const f = createTleilaxuSkillsTechFixture({ advanced, winner });
|
||||
const quote = quoteSpiceBankerIncomeBattleEconomics(f.revealed, f.faceDance).resolution;
|
||||
assert.equal(quote.winner, f.winner);
|
||||
assert.deepEqual(quote.bounty, { player: f.winner, amount: player(f.revealed, f.tleilaxu).leaders.find(l => l.id === f.loserLeader)!.strength });
|
||||
assert.equal(f.beforePlans.techTokens!.axlotl.owner, f.tleilaxu);
|
||||
assert.equal(f.winnerCards.techTokens!.axlotl.owner, f.tleilaxu, 'Reward is still owed while the actual winner chooses cards.');
|
||||
assert.equal(f.skillChoice!.decision!.kind, 'sukRescue');
|
||||
assert.equal(f.skillChoice!.techTokens!.axlotl.owner, f.tleilaxu);
|
||||
assert.equal(f.winnerCards.lastBattleContext!.sukRescue!.completed, true);
|
||||
const ownPayment = quote.payments.find(p => p.player === f.winner)?.ownPayment ?? 0;
|
||||
assert.equal(player(f.faceDance, f.winner).spice, player(f.revealed, f.winner).spice - ownPayment + quote.bounty!.amount);
|
||||
assert.ok(player(f.faceDance, f.winner).hand.some(c => c.id === f.weapon));
|
||||
assert.ok(!player(f.faceDance, f.winner).hand.some(c => c.id === f.defense));
|
||||
assert.ok(f.faceDance.discard.some(c => c.id === f.defense));
|
||||
assert.ok(f.faceDance.discard.some(c => c.id === f.loserCard));
|
||||
assert.equal(player(f.faceDance, f.tleilaxu).leaders.find(l => l.id === f.loserLeader)!.dead, true);
|
||||
assert.equal(player(f.faceDance, f.tleilaxu).tanks, player(f.revealed, f.tleilaxu).tanks + 8);
|
||||
assert.equal(f.faceDance.decision!.kind, 'faceDance');
|
||||
assert.equal(f.faceDance.decision!.player, f.tleilaxu);
|
||||
assert.equal(f.faceDance.techTokens!.axlotl.owner, f.winner);
|
||||
const pending = f.skillChoice!.pendingSukRescue!, decision = f.skillChoice!.decision!;
|
||||
assert.equal(decision.kind, 'sukRescue');
|
||||
if (decision.kind !== 'sukRescue') throw new Error('Missing original Suk choice.');
|
||||
const selected = decision.options.reduce((best, option) => option.normal + option.elite > best.normal + best.elite ? option : best);
|
||||
const rescued = quoteSukRescue(pending.skill, pending.pool, pending.losses!, selected, !!pending.eliteOrigins);
|
||||
assert.equal(player(f.faceDance, f.winner).tanks, player(f.revealed, f.winner).tanks + rescued.tanks.normal + rescued.tanks.elite);
|
||||
assert.equal(at(f.faceDance, f.winner, f.territory), 8 - rescued.removed.reduce((sum, group) => sum + group.normal + group.elite, 0));
|
||||
retainedReward(f, applyAction(f.faceDance, f.tleilaxu, f.faceDanceAction));
|
||||
}
|
||||
});
|
||||
|
||||
void test('original replacement controls permit zero, partial, board-only and mixed real-source forces without another reward', () => {
|
||||
const f = createTleilaxuSkillsTechFixture();
|
||||
const maximum = at(f.faceDance, f.winner, f.territory);
|
||||
const choices: Record<string, number>[] = [{}, { reserves: 1 }, { [f.boardSource]: 2 }, { reserves: maximum - 2, [f.boardSource]: 2 }];
|
||||
for (const sources of choices) {
|
||||
const after = applyAction(f.faceDance, f.tleilaxu, { type: 'decision', reveal: true, sources, sector: f.sector });
|
||||
retainedReward(f, after);
|
||||
const total = Object.values(sources).reduce((sum, count) => sum + count, 0);
|
||||
assert.equal(at(after, f.tleilaxu, f.territory), total);
|
||||
assert.equal(player(after, f.tleilaxu).reserves, player(f.faceDance, f.tleilaxu).reserves - (sources.reserves ?? 0));
|
||||
assert.equal(player(after, f.tleilaxu).forces[f.boardSource] ?? 0, 3 - (sources[f.boardSource] ?? 0));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Face Dance cap and physical source boundaries reject without consuming the matching card or genuine award', () => {
|
||||
const f = createTleilaxuSkillsTechFixture(), before = structuredClone(f.faceDance);
|
||||
const maximum = at(f.faceDance, f.winner, f.territory);
|
||||
for (const sources of [{ reserves: maximum + 1 }, { [f.boardSource]: 4 }, { reserves: -1 }, { reserves: 0.5 }]) {
|
||||
assert.throws(() => applyAction(f.faceDance, f.tleilaxu, { type: 'decision', reveal: true, sources, sector: f.sector }));
|
||||
assert.deepEqual(f.faceDance, before);
|
||||
}
|
||||
assert.throws(() => applyAction(f.faceDance, f.winner, f.faceDanceAction));
|
||||
assert.deepEqual(f.faceDance, before);
|
||||
});
|
||||
|
||||
void test('declining replacement retains real Tech, native trainer and original completed card outcome', () => {
|
||||
const f = createTleilaxuSkillsTechFixture();
|
||||
const after = applyAction(f.faceDance, f.tleilaxu, { type: 'decision', reveal: false });
|
||||
assert.deepEqual(after.techTokens, f.faceDance.techTokens);
|
||||
assert.equal(after.lastBattleContext!.winner, f.winner);
|
||||
assert.equal(at(after, f.winner, f.territory), at(f.faceDance, f.winner, f.territory));
|
||||
assert.equal(player(after, f.winner).leaders.find(l => l.id === f.winnerLeader)!.dead, false);
|
||||
assert.ok(after.leaderSkills!.assignments.some(a => a.leader === f.winnerLeader && a.skill === f.skill));
|
||||
assert.equal(player(after, f.tleilaxu).faceDancers!.find(c => c.leader === f.winnerLeader)!.revealed, false);
|
||||
assert.deepEqual(player(after, f.winner).hand, player(f.faceDance, f.winner).hand);
|
||||
custody(after);
|
||||
});
|
||||
|
||||
void test('already-killed native trainer returns its skill in the original battle; matching Face Dance gives no second death or bounty', () => {
|
||||
const f = createTleilaxuSkillsTechFixture({ winnerDies: true });
|
||||
const quote = quoteSpiceBankerIncomeBattleEconomics(f.revealed, f.faceDance).resolution;
|
||||
assert.equal(quote.winner, f.winner);
|
||||
assert.equal(f.skillChoice, null, 'A dead selected Suk trainer has no rescue entitlement.');
|
||||
const before = player(f.faceDance, f.winner).leaders.find(l => l.id === f.winnerLeader)!;
|
||||
assert.equal(before.dead, true); assert.equal(before.deaths, 1);
|
||||
assert.ok(!f.faceDance.leaderSkills!.assignments.some(a => a.leader === f.winnerLeader));
|
||||
assert.equal(f.faceDance.leaderSkills!.deck.filter(skill => skill === f.skill).length, 1);
|
||||
retainedReward(f, applyAction(f.faceDance, f.tleilaxu, f.faceDanceAction));
|
||||
});
|
||||
|
||||
void test('native winner Rihani completes before card cleanup and Tech; replacement kills its trainer without rewriting exchange history', () => {
|
||||
const f = createTleilaxuSkillsTechFixture({ skill: 'rihani-decipherer' });
|
||||
assert.equal(f.skillChoice!.decision!.kind, 'rihani');
|
||||
assert.equal(f.skillChoice!.techTokens!.axlotl.owner, f.tleilaxu);
|
||||
assert.equal(f.winnerCards.lastBattleContext!.rihani!.completed, true);
|
||||
assert.equal(f.faceDance.lastBattleContext!.rihani!.faceDanceStarted, true);
|
||||
const after = applyAction(f.faceDance, f.tleilaxu, f.faceDanceAction);
|
||||
retainedReward(f, after);
|
||||
assert.deepEqual(after.rihaniHistory, f.faceDance.rihaniHistory);
|
||||
});
|
||||
|
||||
void test('four original native policies replace the actual winner using legal sources after Axlotl was awarded', () => {
|
||||
const f = createTleilaxuSkillsTechFixture();
|
||||
for (const level of DIFFICULTIES) {
|
||||
const view = viewGame(f.faceDance, f.tleilaxu);
|
||||
view.players.find(p => p.id === f.tleilaxu)!.bot = level;
|
||||
const action = botActions(view).find(a => a.type === 'decision');
|
||||
assert.ok(action); assert.equal(action.reveal, true);
|
||||
const count = Object.values(action.sources as Record<string, number>).reduce((sum, n) => sum + n, 0);
|
||||
assert.equal(count, at(f.faceDance, f.winner, f.territory));
|
||||
const after = applyAction(f.faceDance, f.tleilaxu, action);
|
||||
retainedReward(f, after);
|
||||
assert.equal(at(after, f.tleilaxu, f.territory), count);
|
||||
}
|
||||
});
|
||||
|
||||
void test('only-Tleilaxu real free revival excludes Axlotl; another faction real free revival accrues and pays exactly its owned-token count', () => {
|
||||
for (const anotherFaction of [false, true]) {
|
||||
const f = createTleilaxuSkillsTechRevivalFixture();
|
||||
let game = settleTleilaxuSkillsTechRevival(applyAction(f.game, f.tleilaxu, f.ownFreeAction));
|
||||
assert.equal(player(game, f.tleilaxu).tanks, 0);
|
||||
assert.equal(player(game, f.tleilaxu).freeForcesRevived, 1);
|
||||
assert.deepEqual(game.techTokens, f.game.techTokens, 'The native Tleilaxu-only free revival is not an Axlotl trigger.');
|
||||
if (anotherFaction) {
|
||||
game = settleTleilaxuSkillsTechRevival(applyAction(game, f.other, f.otherFreeAction));
|
||||
assert.equal(player(game, f.other).tanks, 0);
|
||||
assert.equal(player(game, f.other).freeForcesRevived, 1);
|
||||
assert.equal(game.techTokens!.axlotl.triggeredTurn, game.turn);
|
||||
assert.equal(game.techTokens!.axlotl.spice, ownedTech(game.techTokens, f.tleilaxu).length);
|
||||
}
|
||||
const before = player(game, f.tleilaxu).spice;
|
||||
const paid = advanceTleilaxuSkillsTechToPhase(game, 5);
|
||||
assert.equal(player(paid, f.tleilaxu).spice - before, anotherFaction ? ownedTech(game.techTokens, f.tleilaxu).length : 0);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, 0);
|
||||
custody(paid);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real native foreign Ghola revival grants no new Tleilaxu skill and preserves its living native trainer', () => {
|
||||
const f = createTleilaxuSkillsTechRevivalFixture();
|
||||
const assignments = structuredClone(f.game.leaderSkills!.assignments);
|
||||
const after = settleTleilaxuSkillsTechRevival(applyAction(f.game, f.tleilaxu, f.foreignGholaAction));
|
||||
const foreign = after.players.flatMap(p => p.leaders).find(l => l.id === f.foreignLeader)!;
|
||||
assert.equal(foreign.dead, false); assert.equal(foreign.gholaBy, f.tleilaxu);
|
||||
assert.deepEqual(after.leaderSkills!.assignments, assignments);
|
||||
assert.equal(after.leaderSkills!.offers[f.tleilaxu], undefined);
|
||||
assert.equal(player(after, f.tleilaxu).leaders.find(l => l.id === f.nativeTrainer)!.dead, false);
|
||||
assert.ok(!after.leaderSkills!.assignments.some(a => a.leader === f.foreignLeader));
|
||||
assert.deepEqual(after.techTokens, f.game.techTokens, 'Foreign leader revival is not force free-revival Tech income.');
|
||||
custody(after);
|
||||
});
|
||||
|
||||
void test('authenticated undealt lobby continuation retains exact original IDs/order without mutating or replacing seats', () => {
|
||||
const original = createTleilaxuSkillsTechFixture().initial;
|
||||
const before = structuredClone(original);
|
||||
const f = createTleilaxuSkillsTechFixture({ initial: original });
|
||||
assert.deepEqual(original, before);
|
||||
assert.deepEqual(f.initial, before);
|
||||
assert.deepEqual(f.afterSetup.players.map(p => ({ id: p.id, faction: p.faction })),
|
||||
original.players.map(p => ({ id: p.id, faction: p.faction })));
|
||||
assert.equal(f.winner, original.players.find(p => p.faction === 'guild')!.id);
|
||||
assert.equal(f.tleilaxu, original.players.find(p => p.faction === 'tleilaxu')!.id);
|
||||
assert.equal(f.faceDance.decision!.player, f.tleilaxu);
|
||||
retainedReward(f, applyAction(f.faceDance, f.tleilaxu, f.faceDanceAction));
|
||||
});
|
||||
+15
-6
@@ -30,7 +30,7 @@ import {
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { sampleInventory, verifySampleCustody } from './sample-custody';
|
||||
import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { privateOutputDirectory, sourceSnapshot } from './verification';
|
||||
|
||||
@@ -352,11 +352,15 @@ const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [
|
||||
roster: (['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'] as FactionId[]).slice(0, players),
|
||||
})),
|
||||
];
|
||||
const SKILLS_TECH_SCENARIOS: readonly Scenario[] =
|
||||
[3, 4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules, band) => ({
|
||||
const SKILLS_TECH_SCENARIOS: readonly Scenario[] = [
|
||||
...[3, 4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules, band) => ({
|
||||
ordinal: 308 + (players - 3) * 2 + band, profile: 'skills-tech' as const,
|
||||
rules, expansions: [], roster: BASE_ROSTER.slice(0, players),
|
||||
})));
|
||||
}))),
|
||||
...[...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS]
|
||||
.filter(scenario => scenario.roster.length >= 3)
|
||||
.map((scenario, index): Scenario => ({ ...scenario, ordinal: 316 + index, profile: 'skills-tech' })),
|
||||
];
|
||||
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech';
|
||||
|
||||
type TraceEntry = {
|
||||
@@ -403,7 +407,7 @@ function usage() {
|
||||
'\nThe four earlier native skill profiles retain their Basic samples. Advanced Ix-skills/choam-skills/tleilaxu-skills use documented native plus classic families; Advanced moritani-skills composes its original non-Harkonnen classic assassination roster. Richese-skills adds Basic/Advanced Richese or paired CHOAM tables, Advanced Richese/Tleilaxu/CHOAM and optional Ix-deck tables without native Ixians. All retain fourteen skills and original cache/native inventories. Native Ixian Technology on Richese lots, own mixed No-Field, other roster/modules and public starts retain their separate guards.'
|
||||
+ '\nEcaz-skills uses native five-disc Ecaz with classic opponents, the ecaz deck and all fourteen skills in Basic/Advanced; Advanced Harkonnen is excluded by the existing shared-Duke capture boundary. Ambassador and ordinary skill paths retain native custody. Shared-Duke assignment and combined Occupy skills remain separate boundaries; no public starts or save retrofit.'
|
||||
+ '\nStandalone E3 Stronghold samples additionally use native Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards only. Ecaz holder-only coalition effects and original Moritani assassination remain; Moritani excludes Harkonnen. E3 pairs, E1/E2 mixtures and other overlays remain guarded.'
|
||||
+ '\nSkills-tech uses three-through-six unique classic seats in Basic/Advanced, base33, all fourteen Leader Skills and three original Tech Tokens. Original setup, phase-end Tech income, skill aftermath and mandatory winner token transfer remain. Other modules, native factions, public starts and old-game conversion stay separate.'
|
||||
+ '\nSkills-tech uses three-through-six unique seats in Basic/Advanced: classic/base33 or the already-supported native Ixian/Tleilaxu/CHOAM/Richese skill rosters with original selected ix/choam decks and separate Richese cache. All fourteen Skills and three original Tech Tokens retain original setup/native owners, phase-end income, aftermath and mandatory original-winner token transfer before Face Dance. Other modules/E3, public starts and played-game conversion stay separate.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -434,6 +438,11 @@ function positive(value: string | undefined) {
|
||||
}
|
||||
|
||||
function scenarioName(scenario: Scenario) {
|
||||
if (scenario.profile === 'skills-tech' && scenario.expansions.length) {
|
||||
const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam', 'richese'].includes(faction));
|
||||
const extraIx = scenario.expansions.includes('ix') && !native.some(faction => faction === 'ixians' || faction === 'tleilaxu');
|
||||
return `${scenario.profile}-${native.join('-')}${extraIx ? '-ix-deck' : ''}-${scenario.roster.length}-${scenario.rules}`;
|
||||
}
|
||||
if (scenario.profile === 'stronghold-factions') {
|
||||
const native = scenario.roster.filter(faction =>
|
||||
['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(faction));
|
||||
@@ -566,7 +575,7 @@ function resumedGame(path: string) {
|
||||
throw new Error(
|
||||
'--resume is not an incomplete faction-games snapshot with saved AI profiles.',
|
||||
);
|
||||
const techSkills = classicTechLeaderSkillsProfile(game) && !game.spiceBankerIncomePreview;
|
||||
const techSkills = (classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
|
||||
if (
|
||||
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !techSkills) ||
|
||||
game.discoveryEnabled ||
|
||||
|
||||
@@ -26,7 +26,7 @@ async function main() {
|
||||
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. Ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' +
|
||||
'\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Explicit --mentat-question stays private; banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
|
||||
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards only. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +
|
||||
'\nLeader-skills and banker-income preserve canonical unused Tech Tokens selected in a fresh three-through-six-seat classic Basic/Advanced lobby. Both retain base33, all fourteen skills and original token setup, phase-end income and mandatory battle rewards. Banker income and --mentat-question remain separate existing opt-ins. Other modules/native rosters and played-game conversion remain excluded.',
|
||||
'\nLeader-skills and banker-income preserve canonical unused Tech Tokens selected in fresh three-through-six-seat classic or existing supported E1/E2 native Basic/Advanced lobbies. Original selected decks, Richese cache, all fourteen skills, printed native Tech owners, phase-end income and mandatory original-winner reward before Face Dance remain. Banker income and --mentat-question stay separate existing opt-ins. E3/other modules and played-game conversion remain excluded.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user