Compose classic Leader Skills with original Tech Tokens
This commit is contained in:
1 parent
21d71e8282
commit
b71f953635
16 files changed
+1059
-22
No files matched your search
@@ -16,6 +16,32 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**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/
|
||||
first-Storm ownership. Native rosters and other overlays remain separate;
|
||||
no public start, played-game conversion or pending-ruling change is added.
|
||||
New25 and affected shared/CLI146/146, types/lint/build and seven native programs
|
||||
pass. Eight genuine Basic/Advanced3..6 games complete2,712 accepted actions,
|
||||
no rejection and69 JSON continuations on a stable source tree. Original
|
||||
**WHTKGXEJ/5YMNGCCT v7** retain native skill/prediction setup and unowned tokens.
|
||||
Human390px **63LV57W2 v13/Mentat** ships physical5 at actual Smuggler cost4:
|
||||
native wallet10→6, separate Emperor Heighliners1 and Atreides Banker-front1.
|
||||
At phase end Emperor10→11; ordinary city income takes Banker10→12 while its
|
||||
front1 stays unavailable. Original readiness collects it once to13; refresh
|
||||
keeps the empty pile. **HC35JG8P v18/Collection** retains a genuinely earned
|
||||
two-token opponent. Skilled Staban5/dial4/support4/Crysknife/Shield wins9–0;
|
||||
Suk keeps one and returns two, leaving original Guild Hagga5/reserves12/Tanks3
|
||||
(including two earlier casualties). Actual card cleanup keeps both; human takes
|
||||
only Axlotl, Emperor keeps production. Wallet3 before separate board Collection6
|
||||
becomes9 after refresh. Original optional Diplomat decline, rescue and mandatory
|
||||
token choice use existing controls. The guide is351px within390px.
|
||||
Controlled positions are labeled, not claimed natural histories. Owned drivers/
|
||||
tabs are removed, rooms/store remain; full assurance/AI/public/deployed gates stay.
|
||||
See [native scope and evidence](LEADER_SKILLS_RUNTIME.md#classic-tech-tokens-composition--4-october-2026).
|
||||
Final guide10/10, types/lint/build and the final rebuilt390px reference pass;
|
||||
the final isolated QA reload retains human v13/Mentat and v18/Collection.
|
||||
|
||||
**Standalone E3 Stronghold composition — bounded Development Verified:**
|
||||
fresh Advanced `stronghold-factions` admits native Ecaz OR Moritani with classic
|
||||
opponents, the exact ecaz33 deck and six Stronghold Cards alone; Moritani excludes
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 4 October 2026 — classic Leader Skills with original Tech Tokens
|
||||
|
||||
Two bounded owners supplied normal payment/free-companion and battle rescue/
|
||||
reward programs; one owner integrated the existing skill profile, original
|
||||
initializer, separate Banker/Mentat opt-ins, source guidance and sample CLI.
|
||||
Fresh classic3..6 Basic/Advanced keeps base33/all14/three original Tech Tokens.
|
||||
All ownership begins through original setup/first Storm; native factions,
|
||||
Stronghold Cards and other overlays remain separate. No public start or
|
||||
played-game conversion is introduced.
|
||||
|
||||
`noOtherLeaderSkillModules` remains strict. A public-config-only classic Tech
|
||||
predicate admits the exact combination without hidden-card/deal eligibility;
|
||||
ordinary skills, Bureaucrat and existing opted-in Mentat share it. The original
|
||||
audit requires canonical unused tokens before dealing. Separate normal Banker
|
||||
retains earned-faction/separate-payer policy; a phase-end Tech credit is not a
|
||||
new bank-payment producer. Original Smuggler changes priced counters, original
|
||||
Suk/Sandmaster finish before cleanup and mandatory token reward, and no decline
|
||||
is invented for the printed mandatory award.
|
||||
|
||||
New25/25 and affected shared/CLI146/146, types/lint/build pass. The CLI case list
|
||||
initially contained a nonexistent Mentat runtime filename; the runner rejected
|
||||
it before executing checks. It was corrected to the exact existing native cases.
|
||||
Two actual fixture defects were repaired: empty Battle automatically advances
|
||||
phase5→7, and that same native transition can collect the ordinary board.
|
||||
The helper now stops at the first clean same-turn native boundary at/after its
|
||||
target; wallet expectations use the independent original Collection quote,
|
||||
not incidental new wallet values or a fabricated battle/phase.
|
||||
|
||||
Seven actual native programs cover both-band Smuggler/Banker/Tech through
|
||||
Mentat, both-band skilled Suk then two-token selection, Advanced Sandmaster,
|
||||
private opted-in Mentat fallback and normal single-payer Bureaucrat bribe.
|
||||
The Bureaucrat program reads the original QA v18 without writing it: paid5,
|
||||
recipient unavailable bribe3, Bank2, one phase use and unchanged Tech custody.
|
||||
The Mentat program names Crysknife, observes the target's actual selected
|
||||
Worthless fallback privately, preserves its held card and resumes posture.
|
||||
These are explicitly controlled native programs, not natural-history claims.
|
||||
|
||||
Eight genuine Basic/Advanced3..6 games complete2,712 accepted actions, zero
|
||||
rejections and69 JSON continuations on identical before/after source snapshot
|
||||
`0d242f253ae391bccfd8fa3bc0dadbc0421b10b90c9c5475ac1c61582a4922a8`.
|
||||
Original CLI **WHTKGXEJ v7** retains Banker skill setup; **5YMNGCCT v7**
|
||||
retains original Bene Gesserit prediction before Skills. Both retain three
|
||||
unowned canonical tokens until the real first Storm.
|
||||
|
||||
Human390px **63LV57W2** ships Harkonnen physical5 to empty Habbanya17 with
|
||||
the original free-companion checkbox: native wallet10→6, reserves10→5.
|
||||
Heighliners accrues1 for Emperor and Banker fronts1 for Atreides while both
|
||||
owned wallets remain10. Real movement enters v10/Collection: Tech owner11,
|
||||
Banker12 from ordinary city2 with front1 still unavailable. Original human
|
||||
readiness reaches **v13/Mentat**, Banker13/front0; refresh preserves those facts.
|
||||
|
||||
Human390px **HC35JG8P** follows a real preliminary token capture and chooses
|
||||
the remaining Hagga battle. Hidden skilled Staban5/dial4/support4/Crysknife/
|
||||
Shield wins9–0 after original optional opposing Diplomat decline. Wallet5−4+2
|
||||
printed Bashar bounty=3. Actual Suk choice saves3: one kept, two reserve returns,
|
||||
one current Tank casualty; earlier two casualties make total Tanks3/reserves12/
|
||||
Hagga5. Winner retains both physical cards, then selects only Axlotl from the
|
||||
two real opposing tokens; production stays Emperor. Refresh preserves
|
||||
**v18/Collection**, wallet9 after separately collecting6 board spice, one token
|
||||
transfer and20 forces. Phone rescue explanation/token controls and the
|
||||
hydrated351px guide within390px are readable without page overflow.
|
||||
|
||||
Owned temporary drivers and managed tabs are removed; original game rows,
|
||||
private metadata, captures and QA store remain. No broad assurance/calibration,
|
||||
public activation, completed-modules or deployed claim; live ab5c782 remains
|
||||
last observed.
|
||||
Final guide10/10, types/lint/build and the final rebuilt390px reference pass
|
||||
(351px topic, no page overflow). The final isolated QA reload retains both
|
||||
original human versions and phases. No extra broad assurance suite is rerun.
|
||||
|
||||
## 4 October 2026 — standalone Ecaz and Moritani Stronghold composition
|
||||
|
||||
Two bounded owners supplied native Ecaz holder-plan and Moritani loss/assassination
|
||||
|
||||
@@ -28,6 +28,68 @@ activation still awaits the uniform-response UX decision; empty hands and
|
||||
Advanced native/combined-module skills remain guarded. See
|
||||
[native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026).
|
||||
|
||||
|
||||
## 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
|
||||
private Mentat opt-in retains its UX/empty-hand boundary; normal Banker retains
|
||||
its original earned-custody and separate-payer policies.
|
||||
|
||||
Tech piles collect at their original phase end; Banker front-shield grants wait
|
||||
for actual Mentat. A Tech credit is not another player's bank-payment producer.
|
||||
Smuggler's free companion changes the original priced count and therefore the
|
||||
actual Banker threshold; Guild player income and Guild's own Heighliners
|
||||
exclusion remain distinct. Winning Suk rescue and Sandmaster board spice finish
|
||||
before original played-card cleanup and mandatory Tech reward. A sole token
|
||||
auto-transfers, several require the original winner selection, none auto-skips.
|
||||
No optional reward decline is added.
|
||||
|
||||
New25 rule cases and affected shared/CLI146/146, types/lint/build pass.
|
||||
Seven actual native programs cover both-band paid Smuggler/Banker/Tech through
|
||||
Mentat, both-band Suk before two-token selection, Advanced Sandmaster board
|
||||
spice, the private opted-in Mentat fallback, and a normal single-payer
|
||||
Bureaucrat bribe fork. The last reads the original QA state without writing it:
|
||||
actual paid5 leaves recipient bribe3 unavailable, redirects2 to Bank, stamps
|
||||
one use and leaves all Tech Tokens unchanged.
|
||||
The payment helper was corrected for the native phase5→7 empty-Battle skip:
|
||||
the same transition can collect the ordinary board. Its expected currency
|
||||
uses the separate original Collection quote, not new incidental wallet pins.
|
||||
Eight genuine Basic/Advanced3..6-seat games complete2,712 accepted actions,
|
||||
no rejection and69 JSON continuations on an unchanged source tree. Natural
|
||||
samples and controlled programs are distinct, not full-module acceptance.
|
||||
|
||||
Original CLI **WHTKGXEJ v7** retains real Banker skill setup; **5YMNGCCT v7**
|
||||
retains original Bene Gesserit prediction before Skills. Three tokens remain
|
||||
unowned until the original first Storm in both rooms.
|
||||
Human390px **63LV57W2** uses native Harkonnen Smuggler: physical5 cost4,
|
||||
wallet10→6, reserves10→5 and actual5 at empty Habbanya17. Original Heighliners
|
||||
accrues1 for Emperor and Banker fronts1 for Atreides; both owned wallets stay10.
|
||||
Native movement auto-skips empty Battle into Collection (v10): Emperor collects
|
||||
Tech1 to11, Atreides collects ordinary city2 to12 while Banker1 stays unavailable.
|
||||
Original human readiness reaches **v13/Mentat**, Banker13/front pile0. Refresh
|
||||
retains those original amounts.
|
||||
Human390px **HC35JG8P** follows a real preliminary token capture, then chooses
|
||||
the remaining Hagga battle and hides skilled Staban5. Actual dial4/support4/
|
||||
Crysknife/Shield wins9–0 after the opponent declines its original optional
|
||||
Diplomat copy. Wallet5−support4+printed Bashar2 bounty=3 before Collection.
|
||||
Original Suk choice saves3: one stays, two return, one current casualty goes
|
||||
to Tanks. The preserved earlier two casualties make total Tanks3/reserves12/
|
||||
Hagga5. Winner keeps both cards, then chooses Axlotl from two genuinely held
|
||||
opposing tokens; production stays Emperor. Refresh retains **v18/Collection**,
|
||||
Guild wallet9 after separately collecting6 board spice, one token transfer,
|
||||
physical20 and both cards. The phone rescue explanation and token choice are
|
||||
readable. The hydrated guide is351px within390px with no page overflow.
|
||||
Owned temporary drivers and managed tabs are removed; original rooms/store,
|
||||
private metadata and game captures remain. Full combinations, source questions,
|
||||
assurance, strategic AI, public activation and deployed acceptance stay open.
|
||||
Final guide10/10, types/lint/build and the final rebuilt390px reference pass
|
||||
(351px topic, no page overflow). The final isolated QA reload retains the
|
||||
original human v13/Mentat and v18/Collection.
|
||||
|
||||
## Native Ecaz ordinary-disc composition — 4 October 2026
|
||||
|
||||
Fresh local `leader-skills` now admits Basic/Advanced native Ecaz with classic
|
||||
|
||||
@@ -197,6 +197,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
|
||||
| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) |
|
||||
| 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 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 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
|
||||
is another player's bank payment. Human **63LV57W2 v13** paid actual Smuggler4
|
||||
for physical5, accrued one unavailable grant and one original Heighliners spice,
|
||||
then collected Tech at phase end and Banker at actual Mentat. Ordinary city
|
||||
Collection stays separately quoted. Native/other overlays and pending earned
|
||||
custody/split questions are unchanged; see the [combined contract and evidence](LEADER_SKILLS_RUNTIME.md#classic-tech-tokens-composition--4-october-2026).
|
||||
|
||||
**4 October composition:** the separate `banker-income` entry additionally
|
||||
admits bounded Advanced native Ixians and/or CHOAM plus classic seats and their
|
||||
distinct required family decks. Actual native support payment grants one
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nativeExpansionLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
|
||||
export type BureaucratPaymentSource = {
|
||||
event: string;
|
||||
@@ -35,10 +35,10 @@ export function bureaucratPaymentSignature(value: object): string {
|
||||
return JSON.stringify({ ...value, signature: undefined });
|
||||
}
|
||||
export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolean {
|
||||
return noOtherLeaderSkillModules(game) &&
|
||||
return (noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length ||
|
||||
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
|
||||
nativeExpansionLeaderSkillsProfile(game));
|
||||
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game);
|
||||
}
|
||||
export function bureaucratUsed(
|
||||
used: readonly BureaucratPaymentUse[],
|
||||
|
||||
+7
-3
@@ -1,4 +1,4 @@
|
||||
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
|
||||
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, 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 &&
|
||||
!g.nexusCards && !g.homeworlds && (!g.techTokens || classicTechLeaderSkillsProfile(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,6 +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);
|
||||
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.');
|
||||
requireRule(
|
||||
(homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) &&
|
||||
(choam || factions || !g.expansions.includes('choam')) &&
|
||||
@@ -9428,7 +9432,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
(discovery || !g.discoveryEnabled) &&
|
||||
(nexus || !g.nexusCards) &&
|
||||
(ecazTreachery || !g.ecazTreachery) &&
|
||||
!g.techTokens &&
|
||||
(!g.techTokens || skillTech) &&
|
||||
(strongholdFactions || !g.strongholdCards) &&
|
||||
(homeworlds || !g.homeworlds) &&
|
||||
g.players.every((p) =>
|
||||
|
||||
@@ -29,6 +29,27 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean {
|
||||
!game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery;
|
||||
}
|
||||
|
||||
/** Classic three-to-six-seat Skills/Tech composition; native and other modules stay separate. */
|
||||
export function classicTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
const players = game.players;
|
||||
if (!game.techTokens || typeof game.advanced !== 'boolean' || game.expansions.length ||
|
||||
!players || players.length < 3 || players.length > 6 ||
|
||||
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 (let i = 0; i < players.length; i++) {
|
||||
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
|
||||
let classic = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === players[i].faction && faction.expansion === 'base') { classic = true; break; }
|
||||
if (!classic) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bounded integration of the ordinary Moritani roster, not Advanced assassination. */
|
||||
export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return game.advanced === false && game.expansions.length === 1 &&
|
||||
@@ -186,6 +207,7 @@ export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): bo
|
||||
|
||||
/** Shared by rule quotes, controls and minimal legal bot participation. */
|
||||
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
|
||||
return noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game));
|
||||
return (noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) ||
|
||||
classicTechLeaderSkillsProfile(game);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { treacheryDeck, type Card } from './cards';
|
||||
import { richeseCards } from './richese-cards';
|
||||
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
|
||||
import {nativeExpansionLeaderSkillsProfile,noOtherLeaderSkillModules,type LeaderSkillProfile} from './leader-skill-profile';
|
||||
import {classicTechLeaderSkillsProfile,nativeExpansionLeaderSkillsProfile,noOtherLeaderSkillModules,type LeaderSkillProfile} from './leader-skill-profile';
|
||||
|
||||
export class MentatQuestionError extends Error {}
|
||||
export const MENTAT_EMPTY_HAND =
|
||||
@@ -40,10 +40,10 @@ export type MentatView = {
|
||||
history: Omit<MentatObservation, 'signature'>[];
|
||||
};
|
||||
export function mentatQuestionModeSupported(game: LeaderSkillProfile): boolean {
|
||||
return noOtherLeaderSkillModules(game) &&
|
||||
return (noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length ||
|
||||
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
|
||||
nativeExpansionLeaderSkillsProfile(game));
|
||||
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game);
|
||||
}
|
||||
function inventory(expansions: readonly string[]): Card[] {
|
||||
return [
|
||||
|
||||
+4
-2
@@ -3281,7 +3281,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Fremen reserve arrivals and Guild cross-planet or return shipments do not trigger Heighliners. A stopped shipment never arrives and cannot trigger it. Bene Gesserit accompanying forces do arrive from off planet and can trigger the token even after a Guild shipment.',
|
||||
'A battle winner takes one tech token from the defeated faction after casualties and played-card decisions. With several available tokens, the winner chooses. A winner still takes the token if no winning forces survive. Mutual destruction has no winner and transfers no token.',
|
||||
'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.',
|
||||
'Token ownership and pending income are public and persist with the room. Two-player token setup and interactions with the unfinished expansion factions and other modules remain unavailable.',
|
||||
'Token ownership and pending income are public and persist with the room. The fresh classic Leader Skills composition retains original three-through-six-seat Basic/Advanced setup and effects. Skill rescue or board-spice awards finish before the original mandatory token reward; normal Banker remains a separate source-to-Mentat opt-in. Two-player token setup, native factions, other optional modules and full combined acceptance remain unfinished.',
|
||||
],
|
||||
example:
|
||||
'You own Heighliners and Axlotl Tanks. When Emperor ships from reserves, place two spice on Heighliners. A later shipment adds nothing. Collect the two spice when Shipping and Movement ends.',
|
||||
@@ -4589,6 +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.',
|
||||
'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.',
|
||||
@@ -4606,6 +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.',
|
||||
'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(' ')}`),
|
||||
],
|
||||
@@ -4615,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 role, custody, privacy, saved-continuation and physical-effect regressions cover the bounded standalone paths, not full module acceptance. Advanced Ixian/CHOAM composition adds affected122/122, types/lint/build, ten controlled actual native programs and three genuine CLI games (511 accepted actions, no rejection, twelve JSON continuations). Native games contained no battles; controlled programs and390px ARRHUS7Z v20 prove typed support, mixed Suk rescue and original substitution into Collection. Original KFW75TCB v6 retains genuine CLI setup. Comprehensive assurance follows all rules; no full-module or 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','docs/LEADER_SKILLS_RUNTIME.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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { createTechTokens, ownedTech } from '../game/tech-tokens';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
|
||||
export type SkillsTechBattleOptions = {
|
||||
/** A fresh authenticated, undealt classic lobby; its seat IDs are retained. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
skill?: 'suk-graduate' | 'sandmaster';
|
||||
band?: 'normal' | 'skilled';
|
||||
/** Selection/absence are earned by a real preceding battle, never staged ownership. */
|
||||
reward?: 'automatic' | 'selection' | 'absent';
|
||||
};
|
||||
export type SkillsTechBattleFixture = {
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
trainer: string;
|
||||
territory: string;
|
||||
key: string;
|
||||
weapon: string;
|
||||
defense: string;
|
||||
losingCard: string;
|
||||
losingLeader: string;
|
||||
ownerPlan: Action;
|
||||
opponentPlan: Action;
|
||||
band: 'normal' | 'skilled';
|
||||
skill: 'suk-graduate' | 'sandmaster';
|
||||
reward: 'automatic' | 'selection' | 'absent';
|
||||
};
|
||||
|
||||
export function skillsTechBattlePlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
function withSkillOffer<T>(skill: SkillsTechBattleFixture['skill'], slot: number, initialize: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
const target = LEADER_SKILL_CARDS.findIndex(card => card.id === skill);
|
||||
let index = LEADER_SKILL_CARDS.length - 1;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (index <= 0) return Reflect.apply(original, crypto, [array]) as V;
|
||||
assert.ok(array instanceof Uint32Array && array.length === 1);
|
||||
const swap = index === Math.max(target, slot) ? Math.min(target, slot) : index;
|
||||
array[0] = swap === index ? 0xffffffff : Math.ceil(swap * 0x100000000 / (index + 1));
|
||||
index--;
|
||||
return array;
|
||||
};
|
||||
try { return initialize(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Native policy, with deliberate no-call/no-spend decisions at original windows. */
|
||||
export function nextSkillsTechBattleStep(game: Game): { actor: string; action: Action } {
|
||||
if (game.decision?.kind === 'battleCards')
|
||||
return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
|
||||
if (game.decision?.kind === 'techToken')
|
||||
return { actor: game.decision.player, action: { type: 'decision', token: game.decision.choices[0] } };
|
||||
if (!game.decision && !game.response && game.battle?.preLeader?.closed === false) {
|
||||
const battle = game.battle;
|
||||
const actor = battle.preLeader!.ready.includes(battle.attacker) ? battle.defender : battle.attacker;
|
||||
return { actor, action: { type: 'battlePreparationReady', event: battle.preLeader!.event } };
|
||||
}
|
||||
const next = nextSpiceBankerIncomeNativeStep(game);
|
||||
assert.ok(next);
|
||||
return next;
|
||||
}
|
||||
function step(game: Game): Game {
|
||||
const next = nextSkillsTechBattleStep(game);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
function reachPhase(state: Game, target: number): Game {
|
||||
let game = state;
|
||||
for (let i = 0; (game.phase !== target || game.phaseOpening || game.response || game.decision) && i < 1000; i++) {
|
||||
assert.equal(game.turn, 1);
|
||||
game = step(game);
|
||||
}
|
||||
assert.equal(game.phase, target);
|
||||
assert.ok(!game.phaseOpening && !game.response && !game.decision);
|
||||
return game;
|
||||
}
|
||||
function untrained(game: Game, player: Player, strongest: boolean) {
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const leader = player.leaders.filter(l => !trained.has(l.id) && !l.dead && !l.usedAt)
|
||||
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0];
|
||||
assert.ok(leader);
|
||||
return leader;
|
||||
}
|
||||
function held(game: Game, player: Player, predicate: (card: Card) => boolean): string {
|
||||
const index = game.deck.findIndex(predicate);
|
||||
assert.ok(index >= 0, 'Conserved physical card must remain in the actual deck.');
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
player.hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
|
||||
/** Fresh original setup/first Storm/Tech assignment. Only the initial skill offer,
|
||||
* unplayed Spice Card order and conserved battle board/cards are controlled.
|
||||
* Wallets, phases, turn, plans, receipts and Tech owners are never fabricated.
|
||||
* The optional preliminary battle earns a second token or leaves no token.
|
||||
* Its second, already-present conflict keeps the genuine Battle phase open. */
|
||||
export function createSkillsTechBattleFixture(options: SkillsTechBattleOptions = {}): SkillsTechBattleFixture {
|
||||
const skill = options.skill ?? 'suk-graduate';
|
||||
const band = options.band ?? 'skilled';
|
||||
const reward = options.reward ?? 'automatic';
|
||||
assert.ok(skill !== 'sandmaster' || band === 'skilled', 'Sandmaster victory uses its lower band.');
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
game = createGame('SKILLSTECHBATTLE', newPlayer('guild', 'Guild', 'guild'), options.advanced ?? false);
|
||||
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
|
||||
}
|
||||
assert.equal(game.status, 'lobby');
|
||||
assert.ok(game.players.length >= 3 && game.players.length <= 6);
|
||||
assert.equal(game.expansions.length, 0);
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
const owner = game.players.find(p => p.faction === 'guild');
|
||||
const opponent = game.players.find(p => p.faction === 'emperor');
|
||||
assert.ok(owner && opponent, 'Authenticated lobby needs Guild and Emperor seats.');
|
||||
if (!game.techTokens) game.techTokens = createTechTokens();
|
||||
assert.deepEqual(game.techTokens, createTechTokens());
|
||||
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = withSkillOffer(skill, game.players.findIndex(p => p.id === owner.id) * 2,
|
||||
() => initializeLeaderSkillsGameForAudit(game));
|
||||
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 selected = actor === owner.id ? skill : offer.cards.find(c => !view.unavailableSkills?.[c]);
|
||||
assert.ok(selected && offer.cards.includes(selected) && !view.unavailableSkills?.[selected]);
|
||||
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
|
||||
const leader = skillsTechBattlePlayer(game, actor).leaders.filter(l => eligible.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(leader);
|
||||
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill: selected, leader: leader.id });
|
||||
} else game = step(game);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
// Arrange two real, unplayed non-worm blows; the first supplies the battle pile.
|
||||
for (const [position, territory] of [[0, 'hagga_basin'], [1, 'funeral_plain']] as const) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && card.territory === territory);
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = reachPhase(game, 5);
|
||||
const winner = skillsTechBattlePlayer(game, owner.id);
|
||||
const enemy = skillsTechBattlePlayer(game, opponent.id);
|
||||
const territory = 'hagga_basin';
|
||||
const key = `${territory}:12`;
|
||||
assert.ok(game.spice[key] > 0 && !TERRITORIES.find(t => t.id === territory)!.sectors.includes(game.storm));
|
||||
// Return every board/card counter to its original physical reserve/deck.
|
||||
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 p of [winner, enemy]) {
|
||||
assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= 10);
|
||||
p.reserves -= 8; p.forces[key] = 8;
|
||||
}
|
||||
let supportTerritory: string | undefined;
|
||||
if (reward !== 'automatic') {
|
||||
const desert = TERRITORIES.find(t => t.type === 'sand' && t.id !== territory && !t.sectors.includes(game.storm));
|
||||
assert.ok(desert);
|
||||
supportTerritory = desert.id;
|
||||
for (const p of [winner, enemy]) { p.reserves -= 2; p.forces[`${desert.id}:${desert.sectors[0]}`] = 2; }
|
||||
}
|
||||
game = reachPhase(game, 6);
|
||||
if (supportTerritory) {
|
||||
game = openSkillsTechBattle(game, winner.id, enemy.id, supportTerritory, true);
|
||||
const firstWinner = skillsTechBattlePlayer(game, reward === 'selection' ? enemy.id : winner.id);
|
||||
const firstLoser = skillsTechBattlePlayer(game, reward === 'selection' ? winner.id : enemy.id);
|
||||
const winLeader = untrained(game, firstWinner, true);
|
||||
const loseLeader = untrained(game, firstLoser, false);
|
||||
assert.ok(winLeader.strength > loseLeader.strength);
|
||||
game = applyAction(game, firstWinner.id, { type: 'battlePlan', leader: winLeader.id, dial: 0, support: 0 });
|
||||
game = applyAction(game, firstLoser.id, { type: 'battlePlan', leader: loseLeader.id, dial: 0, support: 0 });
|
||||
game = finishSkillsTechBattle(game, 'complete');
|
||||
assert.equal(game.phase, 6);
|
||||
assert.equal(game.lastBattleContext!.winner, firstWinner.id);
|
||||
assert.equal(ownedTech(game.techTokens, enemy.id).length, reward === 'selection' ? 2 : 0);
|
||||
}
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === winner.id && a.skill === skill)!.leader;
|
||||
const selectedLeader = band === 'skilled' ? trainer : untrained(game, skillsTechBattlePlayer(game, winner.id), true).id;
|
||||
const losingLeader = untrained(game, skillsTechBattlePlayer(game, enemy.id), false).id;
|
||||
const weapon = held(game, skillsTechBattlePlayer(game, winner.id), card => card.kind === 'projectile');
|
||||
const defense = held(game, skillsTechBattlePlayer(game, winner.id), card => card.kind === 'shield');
|
||||
const losingCard = held(game, skillsTechBattlePlayer(game, enemy.id), card => card.kind === 'worthless');
|
||||
return {
|
||||
game, owner: winner.id, opponent: enemy.id, trainer, territory, key, weapon, defense, losingCard, losingLeader,
|
||||
ownerPlan: { type: 'battlePlan', leader: selectedLeader, dial: 4, support: game.advanced ? 4 : 0, weapon, defense },
|
||||
opponentPlan: { type: 'battlePlan', leader: losingLeader, dial: 0, support: 0, weapon: losingCard },
|
||||
skill, band, reward,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stop at actual private plan controls after original visibility/preparation. */
|
||||
export function openSkillsTechBattle(state: Game, owner: string, opponent: string, territory: string, hide = true): Game {
|
||||
assert.ok(state.active === owner || state.active === opponent);
|
||||
let game = applyAction(state, state.active!, { type: 'chooseBattle', territory, target: state.active === owner ? opponent : owner });
|
||||
for (let i = 0; (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
|
||||
if (game.decision?.kind === 'leaderSkillVisibility')
|
||||
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: game.decision.player === owner ? hide : true });
|
||||
else game = step(game);
|
||||
}
|
||||
assert.ok(game.battle && !game.response && !game.decision);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Resolve calls/mandatory cleanup, stopping BEFORE the requested native choice.
|
||||
* complete means this battle's reward is complete, not that the game is over. */
|
||||
export function finishSkillsTechBattle(state: Game, stop: 'skill' | 'cleanup' | 'tech' | 'complete' = 'complete'): Game {
|
||||
let game = state;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (stop === 'skill' && game.decision?.kind === 'sukRescue') return game;
|
||||
if ((stop === 'skill' || stop === 'cleanup') && game.decision?.kind === 'battleCards') return game;
|
||||
if (stop === 'tech' && game.decision?.kind === 'techToken') return game;
|
||||
if (!game.battle && !game.response && !game.decision && !game.pendingTreacheryDiscard) return game;
|
||||
if (game.decision?.kind === 'sukRescue') {
|
||||
const decision = game.decision;
|
||||
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
|
||||
const choice = decision.options.findIndex(o => o.normal + o.elite === maximum);
|
||||
game = applyAction(game, decision.player, { type: 'decision', event: decision.event, choice });
|
||||
} else game = step(game);
|
||||
}
|
||||
throw new Error('Native skills/Tech battle aftermath did not settle.');
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit,
|
||||
joinGame, newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import type { TechId } from '../game/tech-tokens';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
|
||||
export type SkillsTechPaymentsOptions = {
|
||||
/** Fresh authenticated undealt classic lobby; original seat IDs/order survive. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
kind?: 'force-revival' | 'shipment' | 'guild-shipment';
|
||||
withGuild?: boolean;
|
||||
/** Real counters revived or shipped, including the free Smuggler counter. */
|
||||
amount?: number;
|
||||
/** Ordinary full price when false; the default shipment explicitly opts in. */
|
||||
smuggler?: boolean;
|
||||
};
|
||||
export type SkillsTechPaymentsStep = { actor: string; action: Action };
|
||||
export type SkillsTechPaymentsFixture = {
|
||||
game: Game;
|
||||
beforePayment: Game;
|
||||
setup: Game;
|
||||
actions: SkillsTechPaymentsStep[];
|
||||
actor: string;
|
||||
payer: string;
|
||||
owner: string;
|
||||
smugglerOwner: string;
|
||||
paymentAction: Action;
|
||||
amount: number;
|
||||
cost: number;
|
||||
token: TechId;
|
||||
tokenOwner: string;
|
||||
destination?: string;
|
||||
};
|
||||
|
||||
/** Control only the initial all14 shuffle so native two-card offers contain
|
||||
* Banker/Smuggler. All later skill, card, Traitor and token shuffles stay native. */
|
||||
function withPaymentSkillOffers<T>(game: Game, initialize: () => T): T {
|
||||
const cards: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
|
||||
const selected: LeaderSkillId[] = game.players.map(player => player.faction === 'atreides' ? 'spice-banker'
|
||||
: player.faction === 'harkonnen' ? 'smuggler' : player.faction === 'emperor' ? 'warmaster'
|
||||
: player.faction === 'guild' ? 'swordmaster-of-ginaz' : player.faction === 'fremen' ? 'killer-medic'
|
||||
: 'prana-bindu-adept');
|
||||
// Fill each original two-card offer without introducing another payment or
|
||||
// optional question ability into the continuation policy.
|
||||
const desired: LeaderSkillId[] = [];
|
||||
const remaining = cards.filter(card => !selected.includes(card));
|
||||
for (const skill of selected) desired.push(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);
|
||||
type RandomInput = Parameters<typeof original>[0];
|
||||
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 original(array as RandomInput);
|
||||
return array;
|
||||
};
|
||||
try { return initialize(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
export function nextSkillsTechPaymentsNativeStep(game: Game): SkillsTechPaymentsStep | null {
|
||||
return nextSpiceBankerIncomeNativeStep(game);
|
||||
}
|
||||
function step(game: Game, actions?: SkillsTechPaymentsStep[]): Game {
|
||||
const next = nextSkillsTechPaymentsNativeStep(game);
|
||||
assert.ok(next);
|
||||
actions?.push(next);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
|
||||
export function initializeSkillsTechPaymentsSetup(options: SkillsTechPaymentsOptions = {}): Game {
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('SKILLTECH', newPlayer('banker', 'Banker', 'atreides'), options.advanced ?? false);
|
||||
assert.equal(game.status, 'lobby', 'Continue only a fresh undealt authenticated lobby.');
|
||||
assert.equal(game.expansions.length, 0);
|
||||
if (!options.initial) {
|
||||
joinGame(game, newPlayer('smuggler', 'Smuggler', 'harkonnen'));
|
||||
joinGame(game, newPlayer('payer', 'Payer', 'emperor'));
|
||||
if (options.withGuild || options.kind === 'guild-shipment') joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
}
|
||||
assert.ok(game.players.some(player => player.faction === 'atreides'));
|
||||
assert.ok(game.players.some(player => player.faction === 'harkonnen'));
|
||||
assert.ok(game.players.some(player => player.faction === 'emperor'));
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
if (options.withGuild || options.kind === 'guild-shipment') assert.ok(game.players.some(player => player.faction === 'guild'));
|
||||
// The normal host lobby control creates canonical UNUSED tokens. Original
|
||||
// setup/first Storm alone assign ownership; never retrofit a played game.
|
||||
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
return withPaymentSkillOffers(game, () => initializeSpiceBankerIncomeGameForAudit(game));
|
||||
}
|
||||
|
||||
export function completeSkillsTechPaymentsSetup(state: Game, actions?: SkillsTechPaymentsStep[]): 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 player = game.players.find(seat => seat.id === actor)!;
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const skill = player.faction === 'atreides' ? 'spice-banker'
|
||||
: player.faction === 'harkonnen' ? 'smuggler' : offer.cards[0];
|
||||
assert.ok(offer.cards.includes(skill));
|
||||
assert.ok(view.eligibleLeaders[0]);
|
||||
const action: Action = { type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id };
|
||||
actions?.push({ actor, action });
|
||||
game = applyAction(game, actor, action);
|
||||
} else game = step(game, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** First clean native boundary at/after target; empty Battle may auto-skip. Never assign turn or phase. */
|
||||
export function advanceSkillsTechPaymentsToPhase(state: Game, target: number, actions?: SkillsTechPaymentsStep[]): Game {
|
||||
let game = state;
|
||||
const turn = game.turn;
|
||||
for (let i = 0; (game.phase < target || game.phaseOpening || game.response || game.decision)
|
||||
&& game.status === 'playing' && game.turn === turn && i < 1000; i++) game = step(game, actions);
|
||||
assert.equal(game.turn, turn);
|
||||
assert.ok(game.phase >= target);
|
||||
assert.ok(!game.phaseOpening && !game.response && !game.decision);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Empty native stronghold avoids incidental sand spice collection and combat;
|
||||
* no board occupancy or token ownership is staged for shipment. */
|
||||
export function skillsTechPaymentsEmptyDestination(game: Game): { territory: string; sector: number } {
|
||||
const destination = TERRITORIES.find(territory => territory.type === 'stronghold'
|
||||
&& !territory.sectors.includes(game.storm)
|
||||
&& game.players.every(player => Object.entries(player.forces)
|
||||
.every(([key, amount]) => !amount || !key.startsWith(`${territory.id}:`))));
|
||||
assert.ok(destination);
|
||||
return { territory: destination.id, sector: destination.sectors[0] };
|
||||
}
|
||||
|
||||
/** Stops BEFORE the native payment/companion action, preserving the original
|
||||
* available wallets and IDs for an authenticated human payment smoke. The only
|
||||
* controlled position is labelled conserved Emperor reserve->Tanks casualties. */
|
||||
export function createSkillsTechPaymentsFixture(options: SkillsTechPaymentsOptions = {}): SkillsTechPaymentsFixture {
|
||||
const actions: SkillsTechPaymentsStep[] = [];
|
||||
const setup = initializeSkillsTechPaymentsSetup(options);
|
||||
let game = completeSkillsTechPaymentsSetup(setup, actions);
|
||||
const kind = options.kind ?? 'force-revival';
|
||||
const owner = game.players.find(player => player.faction === 'atreides')!.id;
|
||||
const smugglerOwner = game.players.find(player => player.faction === 'harkonnen')!.id;
|
||||
const actor = kind === 'force-revival' ? game.players.find(player => player.faction === 'emperor')!.id
|
||||
: kind === 'guild-shipment' ? game.players.find(player => player.faction === 'guild')!.id : smugglerOwner;
|
||||
game = advanceSkillsTechPaymentsToPhase(game, kind === 'force-revival' ? 4 : 5, actions);
|
||||
let amount: number;
|
||||
let cost: number;
|
||||
let paymentAction: Action;
|
||||
let destination: string | undefined;
|
||||
if (kind === 'force-revival') {
|
||||
amount = options.amount ?? 3;
|
||||
const payer = game.players.find(player => player.id === actor)!;
|
||||
assert.ok(payer.reserves >= amount);
|
||||
// Explicit conserved casualty unit position, not fabricated native money.
|
||||
payer.reserves -= amount;
|
||||
payer.tanks += amount;
|
||||
cost = forceRevivalQuote(game, payer, amount).cost;
|
||||
paymentAction = { type: 'revive', amount };
|
||||
} else {
|
||||
for (let i = 0; game.active !== actor && i < 20; i++) game = step(game, actions);
|
||||
assert.equal(game.active, actor);
|
||||
const target = skillsTechPaymentsEmptyDestination(game);
|
||||
destination = `${target.territory}:${target.sector}`;
|
||||
amount = options.amount ?? (kind === 'guild-shipment' ? 8 : 5);
|
||||
const smuggler = kind === 'shipment' && options.smuggler !== false;
|
||||
cost = kind === 'guild-shipment' ? Math.ceil(amount / 2) : amount - (smuggler ? 1 : 0);
|
||||
paymentAction = { type: 'ship', ...target, amount, ...(smuggler ? { smuggler: true } : {}) };
|
||||
}
|
||||
const token = kind === 'force-revival' ? 'axlotl' : 'heighliners';
|
||||
const tokenOwner = game.techTokens![token].owner;
|
||||
assert.ok(tokenOwner, 'Ownership must have been assigned by the original first Storm.');
|
||||
return { game, beforePayment: structuredClone(game), setup, actions, actor, payer: actor,
|
||||
owner, smugglerOwner, paymentAction, amount, cost, token, tokenOwner, destination };
|
||||
}
|
||||
|
||||
export function paySkillsTechPaymentsFixture(fixture: SkillsTechPaymentsFixture): Game {
|
||||
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
|
||||
for (let i = 0; (game.response || game.decision || game.pendingRevival || game.pendingShipment) && i < 200; i++) game = step(game);
|
||||
assert.ok(!game.response && !game.decision && !game.pendingRevival && !game.pendingShipment);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
|
||||
import {
|
||||
createSkillsTechBattleFixture, finishSkillsTechBattle, openSkillsTechBattle,
|
||||
skillsTechBattlePlayer, type SkillsTechBattleFixture,
|
||||
} from './fixture-skills-tech-battle';
|
||||
|
||||
function reveal(fixture: SkillsTechBattleFixture): Game {
|
||||
let game = openSkillsTechBattle(fixture.game, fixture.owner, fixture.opponent, fixture.territory, fixture.band === 'skilled');
|
||||
game = applyAction(game, fixture.owner, fixture.ownerPlan);
|
||||
game = applyAction(game, fixture.opponent, fixture.opponentPlan);
|
||||
return finishSkillsTechBattle(game, 'skill');
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function conserveForces(game: Game): void {
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (player.elites) {
|
||||
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), 5);
|
||||
for (const [key, amount] of Object.entries(player.elites.forces)) assert.ok(amount <= (player.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
function tokenChanges(before: Game, after: Game): string[] {
|
||||
return TECH_TOKENS.filter(token => before.techTokens![token.id].owner !== after.techTokens![token.id].owner).map(token => token.id);
|
||||
}
|
||||
function assertLoserCleanup(game: Game, fixture: SkillsTechBattleFixture): void {
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.losingCard), false);
|
||||
assert.equal(game.discard.filter(card => card.id === fixture.losingCard).length, 1);
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).leaders.find(l => l.id === fixture.losingLeader)!.dead, true);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const band of ['normal', 'skilled'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${band} Suk physically rescues before cleanup and one original automatic Tech reward`, () => {
|
||||
const fixture = createSkillsTechBattleFixture({ advanced, band });
|
||||
const before = fixture.game;
|
||||
const ownerBefore = skillsTechBattlePlayer(before, fixture.owner);
|
||||
const enemyBefore = skillsTechBattlePlayer(before, fixture.opponent);
|
||||
const bounty = enemyBefore.leaders.find(l => l.id === fixture.losingLeader)!.strength;
|
||||
let game = reveal(fixture);
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
||||
assertLoserCleanup(game, fixture);
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).tanks, enemyBefore.tanks + 8);
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).forces[fixture.key] ?? 0, 0);
|
||||
assert.deepEqual(tokenChanges(before, game), [], 'Rescue/played-card cleanup precede token reward.');
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.owner).spice, ownerBefore.spice - (advanced ? 4 : 0) + bounty);
|
||||
if (band === 'skilled') {
|
||||
const decision = game.decision;
|
||||
assert.ok(decision?.kind === 'sukRescue');
|
||||
assert.equal(decision.mode, 'skilled');
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.owner).forces[fixture.key], 8);
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.owner).tanks, ownerBefore.tanks);
|
||||
const choice = decision.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.key === fixture.key);
|
||||
assert.ok(choice >= 0);
|
||||
reject(game, fixture.opponent, { type: 'decision', event: decision.event, choice });
|
||||
reject(game, fixture.owner, { type: 'decision', event: `${decision.event}-stale`, choice });
|
||||
const action: Action = { type: 'decision', event: decision.event, choice };
|
||||
game = applyAction(game, fixture.owner, action);
|
||||
reject(game, fixture.owner, action);
|
||||
}
|
||||
const rescued = skillsTechBattlePlayer(game, fixture.owner);
|
||||
assert.equal(rescued.forces[fixture.key], band === 'skilled' ? 5 : 4);
|
||||
assert.equal(rescued.reserves, ownerBefore.reserves + (band === 'skilled' ? 2 : 1));
|
||||
assert.equal(rescued.tanks, ownerBefore.tanks + (band === 'skilled' ? 1 : 3));
|
||||
assert.equal(rescued.battleLosses, ownerBefore.battleLosses + (band === 'skilled' ? 1 : 3));
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.equal(game.pendingSukRescue, null);
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
const walletAfterReward = rescued.spice;
|
||||
const collected = quoteSpiceCollection(game).receipts.find(receipt => receipt.player === fixture.owner)!.collected;
|
||||
const token = ownedTech(before.techTokens, fixture.opponent)[0];
|
||||
assert.ok(token);
|
||||
game = finishSkillsTechBattle(game);
|
||||
assert.deepEqual(tokenChanges(before, game), [token]);
|
||||
assert.equal(game.techTokens![token].owner, fixture.owner);
|
||||
assert.equal(ownedTech(game.techTokens, fixture.opponent).length, 0);
|
||||
assert.equal(game.pendingTech, null);
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.owner).spice, walletAfterReward + collected,
|
||||
'Ordinary leader bounty remains; subsequent Collection is independently quoted.');
|
||||
assert.deepEqual(skillsTechBattlePlayer(game, fixture.owner).hand.map(card => card.id).sort(), [fixture.weapon, fixture.defense].sort());
|
||||
assertLoserCleanup(game, fixture);
|
||||
conserveForces(game);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Sandmaster adds exactly three board spice before choosing one of two genuinely won Tech Tokens`, () => {
|
||||
const fixture = createSkillsTechBattleFixture({ advanced, skill: 'sandmaster', reward: 'selection' });
|
||||
const before = fixture.game;
|
||||
const originalPile = before.spice[fixture.key];
|
||||
const ownerBefore = skillsTechBattlePlayer(before, fixture.owner);
|
||||
const enemyBefore = skillsTechBattlePlayer(before, fixture.opponent);
|
||||
const bounty = enemyBefore.leaders.find(l => l.id === fixture.losingLeader)!.strength;
|
||||
let game = reveal(fixture);
|
||||
assertLoserCleanup(game, fixture);
|
||||
assert.equal(game.spice[fixture.key], originalPile + 3);
|
||||
assert.deepEqual(game.lastBattleContext!.sandmaster, { leader: fixture.trainer, key: fixture.key, before: originalPile, after: originalPile + 3 });
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.owner).spice, ownerBefore.spice - (advanced ? 4 : 0) + bounty,
|
||||
'Sandmaster deposits board spice, not immediate faction income.');
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.owner).forces[fixture.key], 4);
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.owner).tanks, ownerBefore.tanks + 4);
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).tanks, enemyBefore.tanks + 8);
|
||||
assert.deepEqual(tokenChanges(before, game), []);
|
||||
game = finishSkillsTechBattle(game, 'tech');
|
||||
const decision = game.decision;
|
||||
assert.ok(decision?.kind === 'techToken');
|
||||
assert.equal(decision.choices.length, 2);
|
||||
assert.deepEqual(new Set(decision.choices), new Set(ownedTech(before.techTokens, fixture.opponent)));
|
||||
const other = TECH_TOKENS.find(token => !decision.choices.includes(token.id))!;
|
||||
reject(game, fixture.owner, { type: 'decision', token: other.id });
|
||||
reject(game, fixture.opponent, { type: 'decision', token: decision.choices[0] });
|
||||
// Each real legal policy chooses an owned token and transfers precisely one.
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(game, fixture.owner);
|
||||
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'decision');
|
||||
assert.ok(action && typeof action.token === 'string' && decision.choices.includes(action.token as typeof decision.choices[number]));
|
||||
const wallet = skillsTechBattlePlayer(game, fixture.owner).spice;
|
||||
const collected = quoteSpiceCollection(game).receipts.find(receipt => receipt.player === fixture.owner)!.collected;
|
||||
const paid = finishSkillsTechBattle(applyAction(game, fixture.owner, action));
|
||||
assert.deepEqual(tokenChanges(before, paid), [action.token]);
|
||||
assert.equal(ownedTech(paid.techTokens, fixture.opponent).length, 1);
|
||||
assert.equal(paid.pendingTech, null);
|
||||
assert.equal(skillsTechBattlePlayer(paid, fixture.owner).spice, wallet + collected);
|
||||
assert.equal(paid.lastBattleContext!.sandmaster!.after, originalPile + 3);
|
||||
assertLoserCleanup(paid, fixture);
|
||||
conserveForces(paid);
|
||||
reject(paid, fixture.owner, action);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('a real preliminary loss leaves the later loser with no token, so native aftermath skips Tech and keeps the winner cards', () => {
|
||||
const fixture = createSkillsTechBattleFixture({ advanced: true, reward: 'absent' });
|
||||
assert.equal(ownedTech(fixture.game.techTokens, fixture.opponent).length, 0);
|
||||
const game = finishSkillsTechBattle(reveal(fixture));
|
||||
assert.deepEqual(tokenChanges(fixture.game, game), []);
|
||||
assert.equal(game.pendingTech, null);
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(skillsTechBattlePlayer(game, fixture.owner).forces[fixture.key], 5);
|
||||
assertLoserCleanup(game, fixture);
|
||||
assert.deepEqual(skillsTechBattlePlayer(game, fixture.owner).hand.map(card => card.id).sort(), [fixture.weapon, fixture.defense].sort());
|
||||
conserveForces(game);
|
||||
});
|
||||
|
||||
void test('Advanced own-seat legal policies cannot finance battle support with a future bounty, rescue or Tech reward', () => {
|
||||
const fixture = createSkillsTechBattleFixture({ advanced: true });
|
||||
const game = openSkillsTechBattle(fixture.game, fixture.owner, fixture.opponent, fixture.territory);
|
||||
const wallet = skillsTechBattlePlayer(game, fixture.owner).spice;
|
||||
reject(game, fixture.owner, { ...fixture.ownerPlan, support: wallet + 1 });
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(game, fixture.owner);
|
||||
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
|
||||
const plan = botActions(view).find(action => action.type === 'battlePlan');
|
||||
assert.ok(plan);
|
||||
assert.ok(Number(plan.support ?? 0) + Number(plan.bankerSpice ?? 0) <= wallet);
|
||||
const committed = applyAction(game, fixture.owner, plan);
|
||||
assert.equal(skillsTechBattlePlayer(committed, fixture.owner).spice, wallet);
|
||||
assert.deepEqual(tokenChanges(game, committed), []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import {
|
||||
advanceSkillsTechPaymentsToPhase, createSkillsTechPaymentsFixture,
|
||||
nextSkillsTechPaymentsNativeStep, paySkillsTechPaymentsFixture,
|
||||
skillsTechPaymentsEmptyDestination, type SkillsTechPaymentsFixture,
|
||||
} from './fixture-skills-tech-payments';
|
||||
|
||||
function wallets(game: Game): Record<string, number> {
|
||||
return Object.fromEntries(game.players.map(player => [player.id, player.spice]));
|
||||
}
|
||||
function assertPhysicalPayment(before: Game, after: Game): void {
|
||||
const cards = (game: Game) => [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(cards(before).length, 33);
|
||||
assert.equal(new Set(cards(after)).size, 33);
|
||||
assert.deepEqual(cards(after), cards(before), 'Payment must not create, lose or redeal a physical card.');
|
||||
assert.deepEqual(after.leaderSkills, before.leaderSkills, 'Normal income does not move a skill card or training disc.');
|
||||
for (const player of after.players) assert.equal(player.reserves + player.tanks
|
||||
+ Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
}
|
||||
function deferred(game: Game, owner: string): { owner: string; amount: number }[] {
|
||||
return viewGame(game, owner).spiceBankerIncome!.deferred;
|
||||
}
|
||||
|
||||
/** Native phase-end payout may also auto-skip empty Battle and collect the board.
|
||||
* Account for that independent original quote; only Banker waits for Mentat. */
|
||||
function assertSeparateCollections(fixture: SkillsTechPaymentsFixture, paid: Game, bankerAmount: number, techAmount: number): Game {
|
||||
const before = wallets(paid);
|
||||
const sources = structuredClone(paid.spiceBankerIncome!.sources);
|
||||
const phaseEnd = advanceSkillsTechPaymentsToPhase(paid, paid.phase + 1);
|
||||
const expected = phaseEnd.phase === 7
|
||||
? Object.fromEntries(quoteSpiceCollection(paid).receipts.map(receipt => [receipt.player, receipt.balance]))
|
||||
: { ...before };
|
||||
expected[fixture.tokenOwner] += techAmount;
|
||||
assert.deepEqual(wallets(phaseEnd), expected, 'Native Collection plus only the original token owner’s phase-end pile.');
|
||||
assert.equal(phaseEnd.techTokens![fixture.token].spice, 0);
|
||||
assert.equal(phaseEnd.techTokens![fixture.token].triggeredTurn, techAmount ? paid.turn : undefined);
|
||||
assert.deepEqual(deferred(phaseEnd, fixture.owner), bankerAmount ? [{ owner: fixture.owner, amount: bankerAmount }] : []);
|
||||
assert.deepEqual(phaseEnd.spiceBankerIncome!.sources, sources, 'A Tech credit is not a qualifying Banker payment.');
|
||||
const collection = advanceSkillsTechPaymentsToPhase(phaseEnd, 7);
|
||||
const collectionWallets = wallets(collection); // Includes original stronghold/desert Collection, not invented funding.
|
||||
assert.deepEqual(deferred(collection, fixture.owner), bankerAmount ? [{ owner: fixture.owner, amount: bankerAmount }] : []);
|
||||
const mentat = advanceSkillsTechPaymentsToPhase(collection, 8);
|
||||
const expectedMentat = { ...collectionWallets };
|
||||
expectedMentat[fixture.owner] += bankerAmount;
|
||||
assert.deepEqual(wallets(mentat), expectedMentat);
|
||||
assert.deepEqual(deferred(mentat, fixture.owner), []);
|
||||
assert.deepEqual(mentat.spiceBankerIncome!.sources, sources, 'Banker collection must not recursively earn Banker income.');
|
||||
assert.equal(mentat.techTokens![fixture.token].spice, 0, 'Mentat income does not retrigger technology.');
|
||||
const next = nextSkillsTechPaymentsNativeStep(mentat);
|
||||
assert.ok(next);
|
||||
const continued = applyAction(mentat, next.actor, next.action);
|
||||
assert.equal(continued.phase, 8);
|
||||
assert.deepEqual(wallets(continued), expectedMentat, 'Further real Mentat readiness cannot credit either pile twice.');
|
||||
assert.deepEqual(continued.spiceBankerIncome!.collections, mentat.spiceBankerIncome!.collections);
|
||||
assertPhysicalPayment(fixture.beforePayment, continued);
|
||||
return continued;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const band = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${band} Banker + Axlotl: actual four-spice revival delivers forces, keeps both earning stores unspendable, and collects each once at its own boundary`, () => {
|
||||
const fixture = createSkillsTechPaymentsFixture({ advanced });
|
||||
const before = fixture.beforePayment;
|
||||
const payer = before.players.find(player => player.id === fixture.payer)!;
|
||||
assert.equal(fixture.cost, 4);
|
||||
assert.equal(before.techTokens!.axlotl.spice, 0);
|
||||
const paid = paySkillsTechPaymentsFixture(fixture);
|
||||
const revived = paid.players.find(player => player.id === payer.id)!;
|
||||
assert.equal(revived.reserves, payer.reserves + 3);
|
||||
assert.equal(revived.tanks, payer.tanks - 3);
|
||||
assert.equal(revived.revived, payer.revived + 3);
|
||||
assert.equal(revived.freeForcesRevived, 1);
|
||||
const expected = wallets(before);
|
||||
expected[payer.id] -= 4;
|
||||
assert.deepEqual(wallets(paid), expected, 'Neither accrued pile is available money during Revival.');
|
||||
assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, 1);
|
||||
assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn);
|
||||
assert.deepEqual(paid.spiceBankerIncome!.sources.filter(row => row.grant).map(row => row.source.bankLegs),
|
||||
[[{ payer: fixture.payer, amount: 4 }]]);
|
||||
|
||||
// A second real free revival is another eligible Axlotl action, not another
|
||||
// physical token award. Stage only one conserved Harkonnen reserve casualty.
|
||||
const smuggler = paid.players.find(player => player.id === fixture.smugglerOwner)!;
|
||||
smuggler.reserves -= 1;
|
||||
smuggler.tanks += 1;
|
||||
const repeat = applyAction(paid, smuggler.id, { type: 'revive', amount: 1 });
|
||||
assert.equal(repeat.players.find(player => player.id === smuggler.id)!.reserves, smuggler.reserves + 1);
|
||||
assert.equal(repeat.players.find(player => player.id === smuggler.id)!.tanks, smuggler.tanks - 1);
|
||||
assert.deepEqual(wallets(repeat), expected);
|
||||
assert.equal(repeat.techTokens!.axlotl.spice, 1);
|
||||
assert.deepEqual(deferred(repeat, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
|
||||
assertSeparateCollections(fixture, repeat, 1, 1);
|
||||
});
|
||||
|
||||
for (const amount of [1, 2]) void test(`${band} Axlotl free-return trigger is independent of an actual ${amount === 1 ? 'zero' : 'two-spice nonqualifying'} Banker debit`, () => {
|
||||
const fixture = createSkillsTechPaymentsFixture({ advanced, amount });
|
||||
const payer = fixture.beforePayment.players.find(player => player.id === fixture.payer)!;
|
||||
assert.equal(fixture.cost, amount === 1 ? 0 : 2);
|
||||
const paid = paySkillsTechPaymentsFixture(fixture);
|
||||
const revived = paid.players.find(player => player.id === payer.id)!;
|
||||
assert.equal(revived.spice, payer.spice - fixture.cost);
|
||||
assert.equal(revived.reserves, payer.reserves + amount);
|
||||
assert.equal(revived.tanks, payer.tanks - amount);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, 1);
|
||||
assert.deepEqual(deferred(paid, fixture.owner), []);
|
||||
assert.equal(paid.spiceBankerIncome!.sources.some(row => row.grant), false);
|
||||
assertSeparateCollections(fixture, paid, 0, 1);
|
||||
});
|
||||
|
||||
void test(`${band} Smuggler + Banker + Heighliners: five physical counters cost four, two real bank invoices earn only one of each deferred award`, () => {
|
||||
const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'shipment' });
|
||||
const before = fixture.beforePayment;
|
||||
const payer = before.players.find(player => player.id === fixture.payer)!;
|
||||
assert.equal(fixture.cost, 4);
|
||||
let paid = paySkillsTechPaymentsFixture(fixture);
|
||||
const shipped = paid.players.find(player => player.id === payer.id)!;
|
||||
assert.equal(shipped.reserves, payer.reserves - 5);
|
||||
assert.equal(shipped.forces[fixture.destination!], 5);
|
||||
assert.equal(shipped.spice, payer.spice - 4);
|
||||
const expected = wallets(before);
|
||||
expected[payer.id] -= 4;
|
||||
assert.deepEqual(wallets(paid), expected);
|
||||
assert.equal(paid.techTokens!.heighliners.spice, 1);
|
||||
assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
|
||||
|
||||
// Continue to Emperor using original endMovement; no phase or turn is staged.
|
||||
const emperor = paid.players.find(player => player.faction === 'emperor')!;
|
||||
for (let i = 0; paid.active !== emperor.id && i < 20; i++) {
|
||||
const next = nextSkillsTechPaymentsNativeStep(paid);
|
||||
assert.ok(next);
|
||||
paid = applyAction(paid, next.actor, next.action);
|
||||
}
|
||||
assert.equal(paid.phase, 5);
|
||||
assert.equal(paid.active, emperor.id);
|
||||
const target = skillsTechPaymentsEmptyDestination(paid);
|
||||
const secondBefore = paid.players.find(player => player.id === emperor.id)!;
|
||||
paid = applyAction(paid, emperor.id, { type: 'ship', ...target, amount: 4 });
|
||||
assert.equal(paid.players.find(player => player.id === emperor.id)!.spice, secondBefore.spice - 4);
|
||||
assert.equal(paid.players.find(player => player.id === emperor.id)!.reserves, secondBefore.reserves - 4);
|
||||
assert.equal(paid.players.find(player => player.id === emperor.id)!.forces[`${target.territory}:${target.sector}`], 4);
|
||||
assert.equal(paid.techTokens!.heighliners.spice, 1);
|
||||
assert.equal(paid.spiceBankerIncome!.sources.filter(row => row.grant).length, 1);
|
||||
assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
|
||||
assertSeparateCollections(fixture, paid, 1, 1);
|
||||
});
|
||||
|
||||
for (const smuggler of [true, false]) void test(`${band} Smuggler ${smuggler ? 'free companion drops' : 'decline retains'} the original four-counter invoice across Banker's four-spice threshold`, () => {
|
||||
const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'shipment', amount: 4, smuggler });
|
||||
const before = fixture.beforePayment.players.find(player => player.id === fixture.payer)!;
|
||||
const paid = paySkillsTechPaymentsFixture(fixture);
|
||||
const after = paid.players.find(player => player.id === fixture.payer)!;
|
||||
assert.equal(after.reserves, before.reserves - 4);
|
||||
assert.equal(after.forces[fixture.destination!], 4);
|
||||
assert.equal(after.spice, before.spice - (smuggler ? 3 : 4));
|
||||
assert.equal(paid.techTokens!.heighliners.spice, 1);
|
||||
assert.deepEqual(deferred(paid, fixture.owner), smuggler ? [] : [{ owner: fixture.owner, amount: 1 }]);
|
||||
assertSeparateCollections(fixture, paid, smuggler ? 0 : 1, 1);
|
||||
});
|
||||
|
||||
void test(`${band} actual Smuggler fee goes to Guild, while Heighliners accrues separately and Banker gains nothing`, () => {
|
||||
const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'shipment', withGuild: true });
|
||||
const before = wallets(fixture.beforePayment);
|
||||
const guild = fixture.beforePayment.players.find(player => player.faction === 'guild')!;
|
||||
const paid = paySkillsTechPaymentsFixture(fixture);
|
||||
const expected = { ...before };
|
||||
expected[fixture.payer] -= 4;
|
||||
expected[guild.id] += 4;
|
||||
assert.deepEqual(wallets(paid), expected, 'The original Guild fee excludes both deferred earnings.');
|
||||
assert.equal(paid.players.find(player => player.id === fixture.payer)!.forces[fixture.destination!], 5);
|
||||
assert.equal(paid.techTokens!.heighliners.spice, 1);
|
||||
assert.deepEqual(deferred(paid, fixture.owner), []);
|
||||
assertSeparateCollections(fixture, paid, 0, 1);
|
||||
});
|
||||
|
||||
void test(`${band} Guild's actual own-bank shipment earns Banker but is excluded from the original Heighliners trigger`, () => {
|
||||
const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'guild-shipment' });
|
||||
const before = fixture.beforePayment.players.find(player => player.id === fixture.payer)!;
|
||||
const paid = paySkillsTechPaymentsFixture(fixture);
|
||||
const after = paid.players.find(player => player.id === fixture.payer)!;
|
||||
assert.equal(after.spice, before.spice - 4);
|
||||
assert.equal(after.reserves, before.reserves - 8);
|
||||
assert.equal(after.forces[fixture.destination!], 8);
|
||||
assert.equal(paid.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(paid.techTokens!.heighliners.triggeredTurn, undefined);
|
||||
assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
|
||||
assertSeparateCollections(fixture, paid, 1, 0);
|
||||
});
|
||||
}
|
||||
|
||||
void test('fresh authenticated payment fixture retains original faction identities through setup, real Smuggler invoice and Mentat', () => {
|
||||
const initial = createGame('AUTHTECH', newPlayer('human-owner', 'Human owner', 'emperor'), true);
|
||||
joinGame(initial, newPlayer('human-companion', 'Human companion', 'harkonnen'));
|
||||
joinGame(initial, newPlayer('human-banker', 'Human banker', 'atreides'));
|
||||
const identities = initial.players.map(player => ({ id: player.id, name: player.name, faction: player.faction }));
|
||||
const fixture = createSkillsTechPaymentsFixture({ initial, kind: 'shipment' });
|
||||
assert.equal(fixture.payer, 'human-companion');
|
||||
assert.equal(fixture.owner, 'human-banker');
|
||||
const paid = paySkillsTechPaymentsFixture(fixture);
|
||||
assert.equal(paid.players.find(player => player.id === fixture.payer)!.spice,
|
||||
fixture.beforePayment.players.find(player => player.id === fixture.payer)!.spice - 4);
|
||||
const mentat = assertSeparateCollections(fixture, paid, 1, 1);
|
||||
assert.deepEqual(mentat.players.map(player => ({ id: player.id, name: player.name, faction: player.faction })), identities);
|
||||
});
|
||||
+21
-8
@@ -30,13 +30,14 @@ import {
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { sampleInventory, verifySampleCustody } from './sample-custody';
|
||||
import { nativeExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { privateOutputDirectory, sourceSnapshot } from './verification';
|
||||
|
||||
const DEFAULT_SEED = 20_260_926;
|
||||
const DEFAULT_MAX_ACTIONS = 3_500;
|
||||
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
|
||||
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills';
|
||||
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech';
|
||||
type Rules = 'basic' | 'advanced';
|
||||
|
||||
type Scenario = {
|
||||
@@ -351,7 +352,12 @@ const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [
|
||||
roster: (['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'] as FactionId[]).slice(0, players),
|
||||
})),
|
||||
];
|
||||
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills';
|
||||
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),
|
||||
})));
|
||||
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 = {
|
||||
attempt: number;
|
||||
@@ -389,7 +395,7 @@ type Result = {
|
||||
function usage() {
|
||||
return (
|
||||
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
|
||||
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills] ' +
|
||||
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech] ' +
|
||||
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
|
||||
'[--resume FAILED_GAME.json]\n' +
|
||||
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
|
||||
@@ -397,6 +403,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.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -454,7 +461,7 @@ function scenarioName(scenario: Scenario) {
|
||||
|
||||
function parseProfile(value: string | undefined) {
|
||||
const profile = value ?? 'all';
|
||||
if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills'].includes(profile))
|
||||
if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills', 'skills-tech'].includes(profile))
|
||||
throw new Error('--profile must name a documented base, module, expansion or native skill sample profile.');
|
||||
return profile as Profile | 'all';
|
||||
}
|
||||
@@ -500,6 +507,7 @@ function freshGame(scenario: Scenario) {
|
||||
player.ready = true;
|
||||
}
|
||||
if (scenario.ecazTreachery) game.ecazTreachery = true;
|
||||
if (scenario.profile === 'skills-tech') game.techTokens = createTechTokens();
|
||||
if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
game.homeworlds = { custody: null };
|
||||
if (scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
@@ -558,21 +566,23 @@ 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;
|
||||
if (
|
||||
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game)) ||
|
||||
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !techSkills) ||
|
||||
game.discoveryEnabled ||
|
||||
game.discoveries ||
|
||||
game.discoveryStash ||
|
||||
game.greatMaker ||
|
||||
game.techTokens ||
|
||||
(game.techTokens && !techSkills) ||
|
||||
game.mentatQuestionPreview
|
||||
)
|
||||
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS].find(
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS].find(
|
||||
(candidate) =>
|
||||
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||
(candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
|
||||
skillProfile(candidate.profile) === !!game.leaderSkills &&
|
||||
(candidate.profile === 'skills-tech') === !!game.techTokens &&
|
||||
(candidate.profile === 'stronghold-factions') === !!game.strongholdCards &&
|
||||
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
|
||||
(candidate.profile === 'moritani-assassinate' || (candidate.profile === 'moritani-skills' && candidate.rules === 'advanced') ||
|
||||
@@ -793,6 +803,8 @@ async function main() {
|
||||
throw new Error('Moritani assassination samples require Advanced rules.');
|
||||
if (profile === 'stronghold-factions' && rules === 'basic')
|
||||
throw new Error('Stronghold factions samples require Advanced rules.');
|
||||
if (profile === 'skills-tech' && players === 2)
|
||||
throw new Error('Leader Skills with Tech Tokens samples require three through six players.');
|
||||
const resume = values.resume ? resumedGame(values.resume) : null;
|
||||
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
|
||||
? MODULE_SCENARIOS
|
||||
@@ -801,6 +813,7 @@ async function main() {
|
||||
: profile === 'ecaz-occupy' ? ECAZ_OCCUPY_SCENARIOS
|
||||
: profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS
|
||||
: profile === 'stronghold-factions' ? STRONGHOLD_FACTIONS_SCENARIOS
|
||||
: profile === 'skills-tech' ? SKILLS_TECH_SCENARIOS
|
||||
: profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS
|
||||
: profile === 'combined-nexus' ? COMBINED_NEXUS_SCENARIOS
|
||||
: profile === 'combined-homeworld-nexus' ? COMBINED_HOMEWORLD_NEXUS_SCENARIOS
|
||||
|
||||
@@ -25,7 +25,8 @@ async function main() {
|
||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced base-deck roster, or Basic Tleilaxu plus classic opponents with the original Ix47 deck and no optional Sandtrout, into the exact normal-auction purchased-card replacement with Nexus alone. Native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||
'\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.',
|
||||
'\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.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user