diff --git a/components/ix-technology.tsx b/components/ix-technology.tsx index 0db90015..05e18f49 100644 --- a/components/ix-technology.tsx +++ b/components/ix-technology.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { HelpTip } from './help-tip'; import { CardInspector } from './card-inspector'; import type { Action, GameView } from '@/game/engine'; +import { ixStartingCardDealPlan } from '@/game/ixian-starting-cards'; export function IxTechnology({ game: g, act, @@ -28,6 +29,20 @@ export function IxTechnology({ : kind === 'ixAuction' ? g.ixTechnology?.pool : me.hand; + let startingExplanation = 'Choose your starting card from the recorded pool. The remaining cards are shuffled and dealt privately.'; + if (kind === 'ixSetup' && cards?.length) { + try { + const plan = ixStartingCardDealPlan(g, cards.length); + startingExplanation = plan.kind === 'advanced-pool' + ? 'Choose your starting card from the full Advanced pool, including Harkonnen’s extra card. The remainder is shuffled: two cards go to Harkonnen and one to every other faction. No extra card is drawn from the deck.' + : plan.deckBonus + ? `${g.advanced ? 'This recorded draft keeps the earlier starting procedure. ' : ''}Choose your starting card. The remaining cards are shuffled and dealt privately, one to each other faction; Harkonnen then draws its extra card from the deck.` + : 'Choose your starting card. The remaining cards are shuffled and dealt privately, one to each other faction.'; + } catch { + // A transient/incomplete view keeps the generic prompt; the server owns + // the saved draft, recipient quantities and final physical deal. + } + } if (kind === 'ixAllyCard') return ( <> @@ -61,7 +76,7 @@ export function IxTechnology({ <>

{kind === 'ixSetup' - ? 'Choose your starting card. The remaining cards are shuffled and dealt privately, one to each other faction; Harkonnen then draws its extra card.' + ? startingExplanation : kind === 'ixAuction' ? 'Privately choose one card from the original Ixian pool to return to the deck. The remaining pool will be shuffled for auction. This choice does not transfer unrelated Ixian Technology.' : 'Exchange one card from your hand for the upcoming card, before Atreides looks. You commit your offered card before learning what you receive. This advantage can be attempted once this round.'}{' '} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e5de027c..99a86071 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,41 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Ixian Advanced starting draft — 27 October. Prototyped / Partial.** +Fresh Advanced setup with Harkonnen now includes its extra starting card in the +original Ixian pool; both Harkonnen cards come from the shuffled remainder. +Basic retains its publisher-FAQ top-deck bonus. Already-opened Advanced +old-count drafts keep their saved completion without a redeal. The persistent +choice panel explains the actual procedure; Skills setup order is unchanged. + +Frozen **15 affected files, 153/153**, types/lint/build and 6 genuine setup +programmes pass: Basic/Advanced with Harkonnen, with/without all14 Skills, +and two no-Hark controls. Runtime records 14 GameTable SSR renders, +6 IxTechnology renders and 6 actual starting-card callbacks. Recorded +arrays supply aggregate source/further action counts 28/16; preview +initialization is outside those counts and is not public-start acceptance. +Seventeen new pure/native cases include exact fourth-card selection, physical +dealing, Skills order, old-count completion, rejected packets and four minimal +legal policies. The conserved legacy supplement is labelled as a unit boundary, +not a native historical-save journey. Related feedback updates the old pool +expectation and advances an older Revival fixture through preceding real turns. + +The existing server returned HTTP200 without restart. This checkpoint has local +native/SSR/callback evidence; final rules, recovery, privacy, browser and deployed +acceptance remain. No HTTP/storage boundary or saved-game/database operation. +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-ix-starting-pool-proof-v2/`; +journeys/HTML: adjacent `20261027-ix-starting-pool-runtime/run/`. +[Source contract](IX_PROTOTYPE.md#advanced-starting-draft-source-cutover--27-october-2026). + +**Next source-identified functions:** Advanced Charity's First Player claim +order (supplied physical8), CHOAM's total-support rounding (physical29; final +eligibility composition still needs its source check), and Family Atomics' +full timing/sector/removal contract (physical39; the revision's stricter +requirements remain Advanced-only after the bounded Basic source comparison). +These are concrete remaining code/source divergences, not a complete rules +audit. The bounded six-classic-faction pass found connected independent +functions; optional modules, interactions and final acceptance remain open. + **Native E3 trained capture — 27 October. Prototyped / Partial.** Fresh bare Advanced E3/all14 setups now connect Harkonnen with actual Ecaz and/or Moritani and classics. The known trainer/card follows native capture; diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 14be336d..c3bfd177 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,32 @@ # Dune implementation status +## 27 October 2026 — Source-aligned Advanced Ixian starting draft + +Prototyped / Partial. Fresh Advanced setup with Harkonnen now includes its extra starting card in the +original Ixian pool; both Harkonnen cards come from the shuffled remainder. +Basic retains its publisher-FAQ top-deck bonus. Already-opened Advanced +old-count drafts keep their saved completion without a redeal. The persistent +choice panel explains the actual procedure; Skills setup order is unchanged. + +Frozen **15 affected files, 153/153**, types/lint/build and 6 genuine setup +programmes pass: Basic/Advanced with Harkonnen, with/without all14 Skills, +and two no-Hark controls. Runtime records 14 GameTable SSR renders, +6 IxTechnology renders and 6 actual starting-card callbacks. Recorded +arrays supply aggregate source/further action counts 28/16; preview +initialization is outside those counts and is not public-start acceptance. +Seventeen new pure/native cases include exact fourth-card selection, physical +dealing, Skills order, old-count completion, rejected packets and four minimal +legal policies. The conserved legacy supplement is labelled as a unit boundary, +not a native historical-save journey. Related feedback updates the old pool +expectation and advances an older Revival fixture through preceding real turns. + +The existing server returned HTTP200 without restart. This checkpoint has local +native/SSR/callback evidence; final rules, recovery, privacy, browser and deployed +acceptance remain. No HTTP/storage boundary or saved-game/database operation. +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-ix-starting-pool-proof-v2/`; +journeys/HTML: adjacent `20261027-ix-starting-pool-runtime/run/`. +[Source contract](IX_PROTOTYPE.md#advanced-starting-draft-source-cutover--27-october-2026). + ## 27 October 2026 — Trained native E3 capture in Advanced Prototyped / Partial. Fresh bare Advanced E3/all14 setups now connect Harkonnen with actual Ecaz diff --git a/docs/IX_LEADER_SKILLS.md b/docs/IX_LEADER_SKILLS.md index b8fc17d5..b5fb52ab 100644 --- a/docs/IX_LEADER_SKILLS.md +++ b/docs/IX_LEADER_SKILLS.md @@ -23,6 +23,12 @@ scope above. The real private Ixian starting-card choice still precedes skill offers/assignments, Traitors and Face Dancers; the first Storm retains actual HMS placement and all seven Cyborgs/thirteen Suboids. +Fresh Advanced Ixian setup with Harkonnen now includes the extra starting card +in the inspected pool; both Harkonnen cards come from its shuffled remainder. +Basic and already-opened old-count Advanced drafts keep the separate extra +draw. Selection and the complete card deal still precede Skill assignment. +[Source cutover](IX_PROTOTYPE.md#advanced-starting-draft-source-cutover--27-october-2026). + Create and ready a fresh local lobby, then follow the backed-up version-checked [prototype entry](IX_PROTOTYPE.md) using profile `leader-skills`. The shared initializer retains the [Tleilaxu integration](TLEILAXU_LEADER_SKILLS.md). diff --git a/docs/IX_PROTOTYPE.md b/docs/IX_PROTOTYPE.md index f6001ac7..9c4e99bb 100644 --- a/docs/IX_PROTOTYPE.md +++ b/docs/IX_PROTOTYPE.md @@ -1,5 +1,50 @@ # Ixians & Tleilaxu functional prototype +## Advanced starting draft source cutover — 27 October 2026 + +**Prototyped / Partial.** The user-authorized Advanced revision, physical34 / +printed32 A, explicitly adds one card to the original Ixian starting pool when +Harkonnen is seated, then deals Harkonnen two from the shuffled remainder. +Fresh Advanced setup now follows that complete pool: Ixians may choose any +of its N+1 physical cards, including the added card. Every other faction still +starts with one, Harkonnen with two, and no later extra card is drawn. +See the [source adoption](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026). + +Basic remains different. The publisher-authored E1 book, printed8, draws one +card per faction; its printed11 Q&A explicitly retains Harkonnen's extra draw +from the top of the deck after Ixian setup. Fresh retrieval of the official +GF9 URL returned403; the [publisher-file mirror](https://cdn.1j1ju.com/medias/20/16/31-dune-ixians-tleilaxu-rulebook.pdf#page=11) +was reread, including the original FAQ. This is an Advanced-source cutover, +not a claim that the supplied revision changed Basic or the classic edition. + +The recorded pool's original size identifies the procedure. Existing Advanced +N-card pools and historical short pools retain the prior one-per-other-player +deal and separate Harkonnen bonus; no card is inserted into a saved choice. +Fresh Advanced without Harkonnen still draws N. The shared pure deal plan +preserves original seat order and exact recipient quantities. Actual native +choice, shuffle, private views and existing legal AI use the offered cards. + +With Leader Skills, Ixian selection and the complete physical starting-card +deal still precede offers/training. Without Skills, the existing setup order +remains. The persistent choice panel explains the current saved procedure; +no new action, confirmation, hand access or module gate is introduced. + +Focused paths: [deal plan](../game/ixian-starting-cards.ts), +[native source](../tests/fixture-ix-starting-pool.ts), +[native cases](../tests/ix-starting-pool-runtime.test.ts), +[controls](../components/ix-technology.tsx). +Frozen **15 files, 153/153**, types/lint/build and six genuine setup programmes +pass. Runtime records 14 GameTable SSR, 6 private choice-panel renders and +6 actual starting-card callbacks, with source/further action arrays +28/16. Initializers are not counted actions or public-start acceptance. +Basic/Advanced, all14 order and two no-Hark controls use actual original deals; +old-count completion is a labelled conserved unit supplement. Seventeen new +focused cases and the corrected old source expectations pass. The existing +server returned HTTP200 without restart. Wider combinations, complete modes +and final rules/recovery/privacy/browser/deployed acceptance remain. +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-ix-starting-pool-proof-v2/`; +journeys/HTML: adjacent `20261027-ix-starting-pool-runtime/run/`. + The [26 October live World follow-up](HOMEWORLD_RULES.md#live-world-sandtrout--26-october-2026) keeps occupation capabilities available while an original Sandtrout waits. It corrects a module-context rejection; the original Spice effects and diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 08a747ad..d9e3b6c3 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,17 @@ # Rule decision index +## Ixian starting draft with Harkonnen — 27 October 2026 + +Supplied Advanced physical34/printed32 A explicitly draws N+1 when Harkonnen +is in play, lets Ixians choose from that entire pool, then deals Harkonnen two +from the shuffled remainder. Fresh Advanced setup follows this source. Basic +retains publisher E1 printed8 and11's N-card pool plus Harkonnen's separate +top-deck bonus. Already-opened Advanced old-count pools keep their saved +procedure without new cards or a redeal. Skills order and unrelated Bidding +powers remain separate. This is the adopted Advanced source difference, +not a newly invented combined-effect ruling or a Basic change. +[Exact source and cutover](IX_PROTOTYPE.md#advanced-starting-draft-source-cutover--27-october-2026). + ## Trained native E3 leaders and Advanced capture — 27 October 2026 Fresh bare all14/E3 setups now admit Advanced Harkonnen with actual Ecaz and/or diff --git a/game/engine.ts b/game/engine.ts index 73d2655e..c924f34a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -571,6 +571,7 @@ import { type MoritaniRetention, } from './moritani-retention'; import { ordinaryCardAvailability } from './card-availability'; +import { IxStartingCardError, ixStartingCardPoolSize, ixStartingCardDealPlan } from './ixian-starting-cards'; import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival'; import { reserveShipmentCost, @@ -10897,11 +10898,18 @@ function initializeStartingForces(g: Game) { if (g.homeworlds) g.homeworlds.custody = createHomeworldCustody(homeworldContext(g)); } +function ixStartingRule(operation: () => T): T { + try { return operation(); } catch (error) { + if (error instanceof IxStartingCardError) throw new RuleError(error.message); + throw error; + } +} function dealStartingTreachery(g: Game) { const ixians = byFaction(g, 'ixians'); if (ixians) { g.ixSetupCards = []; - for (const _player of g.players) { + const poolSize = ixStartingRule(() => ixStartingCardPoolSize(g)); + for (let index = 0; index < poolSize; index++) { const card = draw(g); if (card) g.ixSetupCards.push(card); } @@ -39997,23 +40005,26 @@ function applyActionInner( g.status === 'setup' && p.faction === 'ixians' && g.ixSetupCards, 'Ixian starting-card selection is not available.', ); + const plan = ixStartingRule(() => ixStartingCardDealPlan(g, g.ixSetupCards!.length)); const card = g.ixSetupCards.find((c) => c.id === action.card); requireRule(card, 'Choose one of the starting cards.'); p.hand.push(card); const remainder = shuffle(g.ixSetupCards.filter((c) => c.id !== card.id)); - for (const other of g.players.filter((other) => other.id !== p.id)) { - const dealt = remainder.shift(); - if (dealt) other.hand.push(dealt); + for (const seat of plan.seats) { + const other = getPlayer(g, seat.player); + for (let count = 0; count < seat.amount; count++) { + const dealt = remainder.shift(); + if (dealt) other.hand.push(dealt); + } } - const harkonnen = byFaction(g, 'harkonnen'); - if (harkonnen) { + if (plan.deckBonus) { const bonus = draw(g); - if (bonus) harkonnen.hand.push(bonus); + if (bonus) getPlayer(g, plan.deckBonus).hand.push(bonus); } g.ixSetupCards = null; log( g, - `${p.name} selected a starting card. The other starting cards were shuffled and dealt privately.`, + `${p.name} selected a starting card. The other starting cards were shuffled and dealt privately.${plan.kind === 'advanced-pool' ? ' Harkonnen received both starting cards from that inspected pool.' : ''}`, ); } else if (decision.kind === 'ixAuction') { requireRule( diff --git a/game/ixian-starting-cards.ts b/game/ixian-starting-cards.ts new file mode 100644 index 00000000..2aee66d8 --- /dev/null +++ b/game/ixian-starting-cards.ts @@ -0,0 +1,61 @@ +import { FACTIONS } from './catalog'; + +export type IxStartingCardGame = { + advanced: boolean; + players: readonly { id: string; faction: string }[]; +}; +export type IxStartingCardDealPlan = { + kind: 'advanced-pool' | 'legacy-deck-bonus'; + seats: { player: string; amount: number }[]; + deckBonus: string | null; +}; +export class IxStartingCardError extends Error { + constructor(message: string) { + super(message); + this.name = 'IxStartingCardError'; + } +} + +function validateStartingRoster(game: IxStartingCardGame): void { + if (!game || typeof game !== 'object' || typeof game.advanced !== 'boolean' || + !Array.isArray(game.players) || game.players.length < 2 || game.players.length > 6) + throw new IxStartingCardError('Ixian starting cards require a two-to-six-player native roster and rules mode.'); + const ids = new Set(); + const factions = new Set(); + for (const player of game.players) { + if (!player || typeof player !== 'object' || typeof player.id !== 'string' || + !player.id || player.id.trim() !== player.id || ids.has(player.id) || + !FACTIONS.some(faction => faction.id === player.faction) || factions.has(player.faction)) + throw new IxStartingCardError('Ixian starting cards require distinct registered factions and player identities.'); + ids.add(player.id); + factions.add(player.faction); + } + if (!factions.has('ixians')) + throw new IxStartingCardError('The starting-card pool requires the native Ixian faction.'); +} + +/** Fresh supplied Advanced p34 includes Harkonnen's second card in Ixians' pool. */ +export function ixStartingCardPoolSize(game: IxStartingCardGame): number { + validateStartingRoster(game); + return game.players.length + + (game.advanced && game.players.some(player => player.faction === 'harkonnen') ? 1 : 0); +} + +/** poolCount is the recorded original pool, including the card Ixians will keep. + * Old N-card and historical short pools retain the original deck-bonus suffix. + */ +export function ixStartingCardDealPlan(game: IxStartingCardGame, poolCount: number): IxStartingCardDealPlan { + const maximum = ixStartingCardPoolSize(game); + if (!Number.isSafeInteger(poolCount) || poolCount < 1 || poolCount > maximum) + throw new IxStartingCardError('The recorded Ixian starting-card pool has an invalid size.'); + const harkonnen = game.players.find(player => player.faction === 'harkonnen'); + const advancedPool = game.advanced && !!harkonnen && poolCount === game.players.length + 1; + return { + kind: advancedPool ? 'advanced-pool' : 'legacy-deck-bonus', + seats: game.players.filter(player => player.faction !== 'ixians').map(player => ({ + player: player.id, + amount: advancedPool && player.faction === 'harkonnen' ? 2 : 1, + })), + deckBonus: advancedPool ? null : harkonnen?.id ?? null, + }; +} diff --git a/game/reference.ts b/game/reference.ts index 60de9d86..9af94b96 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2075,7 +2075,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Private starting-card selection, auction preparation, advanced substitution and allied purchase replacement.', steps: [ - 'Before starting treachery cards are dealt, Ixians inspect one card per faction and choose one to keep. Shuffle and privately deal the remainder, one per other faction. Harkonnen receives its additional card from the deck afterward.', + 'Before starting Treachery Cards are dealt, Ixians choose one from the original inspected pool. Fresh Advanced includes one extra card when Harkonnen is seated: shuffle the remainder and deal two to Harkonnen, one to each other faction, without an additional deck draw. Basic retains one card per faction in the pool and a separate Harkonnen top-deck bonus. An already-opened old-count Advanced draft keeps its recorded procedure without a redeal.', 'Before the auction is prepared, everyone receives a Karama response window. Allowing Ixian inspection draws one extra card above the number up for bid. Ixians privately return one to the top or bottom of the deck, then shuffle the rest for auction. Canceling leaves only the ordinary auction cards and reveals none to Ixians.', 'A full Ixian hand does not prevent its inspection advantage. The number of auction cards is determined by factions able to bid. The private pool record is unordered and includes cards already auctioned; it is not a view of another player’s hand or the upcoming card.', 'In advanced play, before bidding on a card and before Atreides gets its peek, Ixians may offer one hand card for the upcoming unseen card. Declining saves the option for a later card. Declaring an exchange spends the once-per-round attempt and opens a separate Karama response; cancellation keeps both cards where they are.', diff --git a/tests/fixture-ix-independent-skills.ts b/tests/fixture-ix-independent-skills.ts index d7575d49..f7802b21 100644 --- a/tests/fixture-ix-independent-skills.ts +++ b/tests/fixture-ix-independent-skills.ts @@ -258,7 +258,8 @@ export function createIxIndependentSkillsRevivalFixture(options: IxIndependentSk const afterBattle = finishIxIndependentSkillsBattle(f, { rescue: 'none' }); const bribe: IxIndependentSkillsStep = { actor: f.opponent, action: { type: 'bribe', target: f.ixians, amount: 3 } }; let game = stepIxIndependentSkills(afterBattle, bribe); - game = advanceIxIndependentSkills(game, g => g.turn === 2 && g.phase === 4 && ixIndependentSkillsClean(g) && !g.pendingRevival); + game = advanceIxIndependentSkills(game, g => g.turn === 2 && g.phase === 4 && ixIndependentSkillsClean(g) && !g.pendingRevival && + (!g.revivalOrder || g.revivalOrder.remaining[0] === f.ixians)); const staging = [...f.staging]; conserveCard(game, f.ixians, RECRUITS_CARD_ID, staging); const beforeRecruits = structuredClone(game); const priorReturn: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'revive', amount: 1, elite: 0 } }; diff --git a/tests/fixture-ix-starting-pool.ts b/tests/fixture-ix-starting-pool.ts new file mode 100644 index 00000000..fb34dbba --- /dev/null +++ b/tests/fixture-ix-starting-pool.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeIxGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, + newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { treacheryDeck } from '../game/cards'; +import { traitorDeck } from '../game/traitors'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type IxStartingPoolStep = { actor: string; action: Action }; +export type IxStartingPoolOptions = { advanced?: boolean; skills?: boolean; harkonnen?: boolean }; +export const ixStartingPoolPlayer = (game: Game, actor: string) => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const ixStartingPoolReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game; + +function permutation(source: readonly T[], desired: readonly T[]): number[] { + const working = [...source], rolls: number[] = []; + for (let index = working.length - 1; index > 0; index--) { + const from = working.indexOf(desired[index]); assert.ok(from >= 0 && from <= index); + rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1))); + [working[index], working[from]] = [working[from], working[index]]; + } + assert.deepEqual(working, desired); return rolls; +} +/** Only original shuffle draws are selected. UUID and byte entropy remain native. */ +function entropy(rolls: readonly number[], run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return run(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function actIxStartingPool(game: Game, actor: string, action: Action, + actions: IxStartingPoolStep[] = []): Game { + const next = entropy([], () => applyAction(game, actor, action)); + actions.push({ actor, action: structuredClone(action) }); return next; +} + +/** Includes the still-private native draft; historical references are not slots. */ +export function ixStartingPoolInventory(game: Game) { + const cards = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), + ...game.players.flatMap(player => player.hand), ...(game.auction?.cards.slice(game.auction.index) ?? [])] + .map(card => card.id).sort(); + assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort()); + const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => + [...player.traitors, ...player.traitorChoices])].sort(); + if (traitors.length) assert.deepEqual(traitors, traitorDeck(game.players, true).sort()); + const skills = game.leaderSkills ? [...game.leaderSkills.deck, + ...game.leaderSkills.assignments.map(row => row.skill), + ...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort() : []; + if (game.leaderSkills) { + validateLeaderSkills(game.leaderSkills, game.players); + assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort()); + } + for (const player of game.players) { + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20); + if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + + Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0), player.faction === 'ixians' ? 7 : 5); + } + return { cards, traitors, skills }; +} + +/** The original Ix packet is deliberately caller-owned; no draft choice is guessed. */ +export function nextIxStartingPoolStep(game: Game): IxStartingPoolStep | null { + if (game.decision?.kind === 'ixSetup') return null; + if (game.status === 'setup' && game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!; + const skill = own.offer!.cards.find(card => !own.unavailableSkills?.[card]); + const leader = own.eligibleLeaders[0]; assert.ok(skill && leader); + return { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } }; + } + return nextStrongholdFactionsNativeStep(game); +} +export function advanceIxStartingPool(game: Game, until: (state: Game) => boolean, + actions: IxStartingPoolStep[] = []): Game { + for (let count = 0; count < 200; count++) { + if (until(game)) return game; + const step = nextIxStartingPoolStep(game); assert.ok(step, 'Choose the actual private Ix starting card before setup continues'); + game = actIxStartingPool(game, step.actor, step.action, actions); + } + throw Error('Original Ix starting-card setup did not reach its finite boundary'); +} +export function ixStartingPoolChoice(game: Game, card: string): Action { + const own = viewGame(game, 'ixians'); + assert.equal(game.decision?.kind, 'ixSetup'); + assert.ok(own.ixTechnology?.setup?.some(candidate => candidate.id === card)); + return { type: 'decision', card }; +} +export function finishIxStartingPool(game: Game, actions: IxStartingPoolStep[] = []): Game { + return advanceIxStartingPool(game, state => state.status === 'playing', actions); +} + +/** Original fresh Ix/Hark/Atreides setup, or Emperor in the no-Hark control. + * The supported Ix or all14 preview initializer is used, not a public start. + * Only initial shuffle entropy is selected. No dealt hand, phase, force, purse, + * pending receipt or module flag is installed after initialization. + */ +export function createIxStartingPoolFixture(options: IxStartingPoolOptions = {}) { + const advanced = options.advanced ?? true, skills = options.skills ?? false, harkonnen = options.harkonnen ?? true; + const second = harkonnen ? 'harkonnen' : 'emperor'; + let game = createGame('IXSTARTINGPOOL', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']); + joinGame(game, newPlayer(second, second, second)); + joinGame(game, newPlayer('atreides', 'Atreides', 'atreides')); + const initial = structuredClone(game), actions: IxStartingPoolStep[] = []; + for (const player of game.players) game = actIxStartingPool(game, player.id, { type: 'ready' }, actions); + const lobby = structuredClone(game), original = treacheryDeck(['ix']); + const prefix = [original.find(card => card.kind === 'projectile')!, original.find(card => card.kind === 'shield')!, + original.find(card => card.kind === 'snooper')!, original.find(card => card.effect === 'karama')!]; + const deckOrder = [...prefix.map(card => card.id), ...original.filter(card => !prefix.some(first => first.id === card.id)).map(card => card.id)]; + const rolls = [...(skills ? Array(LEADER_SKILL_CARDS.length - 1).fill(0xffffffff) : []), + ...permutation(original.map(card => card.id), deckOrder)]; + game = entropy(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeIxGameForAudit(game)); + const initialized = structuredClone(game); + game = advanceIxStartingPool(game, state => state.decision?.kind === 'ixSetup', actions); + assert.equal(game.status, 'setup'); assert.equal(game.decision?.player, 'ixians'); + assert.ok(game.players.every(player => !player.hand.length)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled && !game.techTokens && !game.strongholdCards && !game.ecazTreachery); + assert.equal(!!game.leaderSkills, skills); + assert.deepEqual(game.ixSetupCards!.map(card => card.id), deckOrder.slice(0, game.ixSetupCards!.length)); + const stock = ixStartingPoolInventory(game); + return { initial, lobby, initialized, game, advanced, skills, harkonnen, second, + pool: structuredClone(game.ixSetupCards!), prefix: prefix.map(card => card.id), extra: prefix[3].id, + nextAfterFour: deckOrder[4], deckOrder, stock, actions, + readyActions: actions.filter(step => step.action.type === 'ready'), + setupActions: actions.filter(step => step.action.type !== 'ready'), + sourceCounts: { ready: actions.filter(step => step.action.type === 'ready').length, + beforeDraft: actions.filter(step => step.action.type !== 'ready').length, source: actions.length }, + sourceDisclosure: 'Genuine fresh Ix/Harkonnen/Atreides (or Emperor control) preview initialization, with optional original all14 Skills. Actual Ready/setup actions are recorded; initializer is not an action or public-start acceptance. Only original initial skill/Treachery shuffle entropy selected. Source stops before the actual Ix choice; no hand, army, purse, phase or receipt is installed.' }; +} + +/** Conserved old-count UNIT supplement, not a native historical save claim. + * Move only the untouched extra fourth card back to the undealt top, restoring + * the old Advanced N-card boundary without minting/dropping any physical card. + */ +export function createLegacyIxStartingPoolSupplement(skills = false) { + const base = createIxStartingPoolFixture({ advanced: true, skills }); + const game = ixStartingPoolReload(base.game); + assert.equal(game.ixSetupCards!.length, game.players.length + 1); + const extra = game.ixSetupCards!.pop()!; + game.deck.unshift(extra); + assert.deepEqual(ixStartingPoolInventory(game), base.stock); + return { ...base, game, pool: structuredClone(game.ixSetupCards!), + sourceDisclosure: `${base.sourceDisclosure} Controlled conserved old-count unit supplement: the untouched fourth pool card returns to the undealt top to represent the earlier pending N-card contract. This is not claimed native old setup history.` }; +} diff --git a/tests/ix-prototype.test.ts b/tests/ix-prototype.test.ts index b2b4ca8c..2514e970 100644 --- a/tests/ix-prototype.test.ts +++ b/tests/ix-prototype.test.ts @@ -231,7 +231,7 @@ void test('all AI profiles complete genuine private Ix setup through JSON contin assert.ok(g.players.every((p) => p.hand.length === 0)); g = reachIxChoice(g, difficulty); - assert.equal(g.ixSetupCards?.length, ROSTER.length); + assert.equal(g.ixSetupCards?.length, ROSTER.length + (advanced ? 1 : 0)); const ids = g.ixSetupCards!.map((card) => card.id); assert.deepEqual(viewGame(g, 'i').ixTechnology?.setup, g.ixSetupCards); for (const observer of ['t', 'a', 'h']) { diff --git a/tests/ix-starting-pool-runtime.test.ts b/tests/ix-starting-pool-runtime.test.ts new file mode 100644 index 00000000..8f9f9703 --- /dev/null +++ b/tests/ix-starting-pool-runtime.test.ts @@ -0,0 +1,153 @@ +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 type { Difficulty } from '../game/bot-profiles'; +import { + actIxStartingPool as act, createIxStartingPoolFixture as fixture, + createLegacyIxStartingPoolSupplement as legacy, finishIxStartingPool as finish, + ixStartingPoolChoice as choice, ixStartingPoolInventory as inventory, + ixStartingPoolPlayer as p, ixStartingPoolReload as reload, type IxStartingPoolStep, +} from './fixture-ix-starting-pool'; + +function reject(game: Game, actor: string, action: Action) { + const before = reload(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +for (const advanced of [false, true]) for (const skills of [false, true]) { + void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Ix starting pool ${skills ? 'with all14' : 'without Skills'} preserves original stock and native setup order`, () => { + const f = fixture({ advanced, skills }); + assert.equal(f.sourceCounts.ready, f.readyActions.length); + assert.equal(f.sourceCounts.beforeDraft, f.setupActions.length); + assert.equal(f.sourceCounts.source, f.actions.length); + assert.equal(f.pool.length, f.game.players.length + (advanced ? 1 : 0)); + assert.equal(f.game.players.every(player => player.hand.length === 0), true); + assert.deepEqual(viewGame(f.game, 'ixians').ixTechnology?.setup?.map(card => card.id), f.pool.map(card => card.id)); + assert.deepEqual(inventory(f.game).cards, f.stock.cards); + if (skills) { + assert.equal(Object.keys(f.game.leaderSkills!.offers).length, 0); + assert.equal(f.game.leaderSkills!.assignments.length, 0); + assert.equal(f.actions.some(step => step.action.type === 'leaderSkill' || step.action.type === 'traitor'), false); + } else assert.ok(f.actions.some(step => step.action.type === 'traitor')); + }); +} + +void test('fresh Advanced Ix may choose the actual fourth card and Harkonnen receives two from that same inspected remainder', () => { + for (const skills of [false, true]) { + const f = fixture({ advanced: true, skills }), beforeTop = f.game.deck[0].id; + assert.equal(f.pool[3].id, f.extra); + const game = finish(act(reload(f.game), 'ixians', choice(f.game, f.extra))); + assert.deepEqual(p(game, 'ixians').hand.map(card => card.id), [f.extra]); + const remainder = f.pool.filter(card => card.id !== f.extra).map(card => card.id); + assert.equal(p(game, 'harkonnen').hand.length, 2); + assert.ok(p(game, 'harkonnen').hand.every(card => remainder.includes(card.id))); + assert.deepEqual([...p(game, 'harkonnen').hand, ...p(game, 'atreides').hand].map(card => card.id).sort(), remainder.sort()); + assert.equal(game.deck[0].id, beforeTop); + assert.equal(game.deck[0].id, f.nextAfterFour, 'Fresh Advanced consumes no extra undealt card after the pool is dealt'); + assert.deepEqual(inventory(game).cards, f.stock.cards); + assert.deepEqual(inventory(game).skills, f.stock.skills); + } +}); + +void test('Basic keeps the fourth card outside Ix inspection and actually deals it as the separate Harkonnen top-deck bonus', () => { + for (const skills of [false, true]) { + const f = fixture({ advanced: false, skills }); + assert.equal(f.game.deck[0].id, f.extra); + assert.equal(f.pool.some(card => card.id === f.extra), false); + reject(f.game, 'ixians', { type: 'decision', card: f.extra }); + const selected = f.pool[0].id; + const game = finish(act(reload(f.game), 'ixians', choice(f.game, selected))); + assert.deepEqual(p(game, 'ixians').hand.map(card => card.id), [selected]); + assert.equal(p(game, 'harkonnen').hand.length, 2); + assert.ok(p(game, 'harkonnen').hand.some(card => card.id === f.extra)); + assert.equal(p(game, 'harkonnen').hand.filter(card => f.pool.some(source => source.id === card.id)).length, 1); + assert.equal(game.deck[0].id, f.nextAfterFour); + assert.deepEqual(inventory(game).cards, f.stock.cards); + } +}); + +void test('without a Harkonnen seat both Basic and Advanced retain the native one-card-per-faction draft', () => { + for (const advanced of [false, true]) for (const skills of [false, true]) { + const f = fixture({ advanced, skills, harkonnen: false }); + assert.equal(f.pool.length, f.game.players.length); + const before = f.game.deck[0].id; + const game = finish(act(reload(f.game), 'ixians', choice(f.game, f.pool[0].id))); + assert.ok(game.players.every(player => player.hand.length === 1)); + assert.equal(game.deck[0].id, before); + assert.deepEqual(inventory(game).cards, f.stock.cards); + } +}); + +void test('native Ix choice precedes all real skill offers assignments and Traitor selections in both modes', () => { + for (const advanced of [false, true]) { + const f = fixture({ advanced, skills: true }), actions: IxStartingPoolStep[] = []; + const selected = f.pool.at(-1)!.id; + let game = act(reload(f.game), 'ixians', choice(f.game, selected), actions); + assert.equal(game.setupStage, 'leaderSkills'); + assert.equal(Object.keys(game.leaderSkills!.offers).length, game.players.length); + assert.equal(game.leaderSkills!.assignments.length, 0); + assert.equal(p(game, 'harkonnen').hand.length, 2); + game = finish(game, actions); + assert.equal(actions[0].action.card, selected); + const firstSkill = actions.findIndex(step => step.action.type === 'leaderSkill'); + const firstTraitor = actions.findIndex(step => step.action.type === 'traitor'); + assert.ok(firstSkill > 0 && firstTraitor > firstSkill); + assert.equal(game.leaderSkills!.assignments.length, game.players.length); + assert.deepEqual(inventory(game).cards, f.stock.cards); + assert.deepEqual(inventory(game).skills, f.stock.skills); + } +}); + +void test('native draft rejects another actor foreign card and replay after its actual one choice', () => { + const f = fixture({ advanced: true, skills: true }), packet = choice(f.game, f.extra); + reject(f.game, 'harkonnen', packet); + reject(f.game, 'ixians', { type: 'decision', card: f.game.deck[0].id }); + const game = act(reload(f.game), 'ixians', packet); + reject(game, 'ixians', packet); + assert.equal(game.ixSetupCards, null); + assert.equal(p(game, 'ixians').hand.filter(card => card.id === f.extra).length, 1); + assert.deepEqual(inventory(game).cards, f.stock.cards); +}); + +void test('controlled conserved old Advanced N-card draft completes its old separate top-deck Harkonnen bonus', () => { + for (const skills of [false, true]) { + const f = legacy(skills); + assert.match(f.sourceDisclosure, /Controlled conserved old-count unit supplement/); + assert.equal(f.pool.length, f.game.players.length); + assert.equal(f.game.deck[0].id, f.extra); + const game = finish(act(reload(f.game), 'ixians', choice(f.game, f.pool[0].id))); + assert.equal(p(game, 'harkonnen').hand.length, 2); + assert.ok(p(game, 'harkonnen').hand.some(card => card.id === f.extra)); + assert.equal(game.deck[0].id, f.nextAfterFour); + assert.deepEqual(inventory(game).cards, f.stock.cards); + assert.deepEqual(inventory(game).skills, f.stock.skills); + } +}); + +void test('all four minimal profiles legally choose from the fresh Advanced pool and finish original all14 setup', () => { + const f = fixture({ advanced: true, skills: true }); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) { + let game = reload(f.game), accepted = 0; + const own = viewGame(game, 'ixians'); own.players.find(player => player.id === 'ixians')!.bot = difficulty; + const proposal = botActions(own).find(action => action.type === 'decision' && f.pool.some(card => card.id === action.card)); + assert.ok(proposal, `${difficulty} chooses one actual inspected card`); + game = act(game, 'ixians', proposal); accepted++; + for (let count = 0; game.status === 'setup' && count < 100; count++) { + let progressed = false; + for (const player of game.players) { + const view = viewGame(game, player.id); view.players.find(row => row.id === player.id)!.bot = difficulty; + const action = botActions(view)[0]; + if (!action) continue; + game = act(game, player.id, action); accepted++; progressed = true; break; + } + assert.ok(progressed, `${difficulty} finishes actual original setup`); + } + assert.equal(game.status, 'playing'); assert.ok(accepted > 1); + assert.equal(p(game, 'harkonnen').hand.length, 2); + assert.equal(game.deck[0].id, f.nextAfterFour); + assert.deepEqual(inventory(game).cards, f.stock.cards); + assert.deepEqual(inventory(game).skills, f.stock.skills); + } +}); diff --git a/tests/ixian-starting-cards.test.ts b/tests/ixian-starting-cards.test.ts new file mode 100644 index 00000000..2ab538e9 --- /dev/null +++ b/tests/ixian-starting-cards.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + IxStartingCardError, + ixStartingCardDealPlan, + ixStartingCardPoolSize, + type IxStartingCardGame, +} from '../game/ixian-starting-cards'; + +function game(advanced: boolean, factions: readonly string[]): IxStartingCardGame { + return { advanced, players: factions.map((faction, index) => ({ id: `seat-${index}`, faction })) }; +} +const otherFactions = ['atreides', 'emperor', 'guild', 'fremen'] as const; + +void test('fresh Ix pools add Harkonnen only in Advanced, from two through six seats', () => { + for (let count = 2; count <= 6; count++) { + for (const advanced of [false, true]) { + const harkonnen = game(advanced, ['ixians', 'harkonnen', ...otherFactions.slice(0, count - 2)]); + assert.equal(ixStartingCardPoolSize(harkonnen), count + (advanced ? 1 : 0)); + const ordinary = game(advanced, ['ixians', ...otherFactions, 'beneGesserit'].slice(0, count)); + assert.equal(ixStartingCardPoolSize(ordinary), count); + } + } +}); + +void test('new Advanced pools deal Harkonnen two from the shuffled remainder in original seat order', () => { + for (let count = 2; count <= 6; count++) { + for (const ixFirst of [false, true]) { + const factions = ixFirst ? ['ixians', 'harkonnen'] : ['harkonnen', 'ixians']; + const native = game(true, [...factions, ...otherFactions.slice(0, count - 2)]); + const before = structuredClone(native); + Object.freeze(native.players); + Object.freeze(native); + const plan = ixStartingCardDealPlan(native, count + 1); + assert.deepEqual(plan, { + kind: 'advanced-pool', + seats: native.players.filter(player => player.faction !== 'ixians').map(player => ({ + player: player.id, amount: player.faction === 'harkonnen' ? 2 : 1, + })), + deckBonus: null, + }); + assert.equal(plan.seats.reduce((amount, seat) => amount + seat.amount, 0), count); + assert.deepEqual(native, before); + plan.seats[0].amount = 99; + assert.deepEqual(native, before); + } + } +}); + +void test('Basic and old Advanced N-card or short pools keep one-per-other then the Harkonnen deck bonus', () => { + for (let count = 2; count <= 6; count++) { + for (const advanced of [false, true]) { + const native = game(advanced, ['harkonnen', ...otherFactions.slice(0, count - 2), 'ixians']); + for (const poolCount of new Set([1, count - 1, count])) { + assert.deepEqual(ixStartingCardDealPlan(native, poolCount), { + kind: 'legacy-deck-bonus', + seats: native.players.filter(player => player.faction !== 'ixians').map(player => ({ player: player.id, amount: 1 })), + deckBonus: 'seat-0', + }); + } + } + } +}); + +void test('without Harkonnen both modes deal one per other seat with no extra deck draw', () => { + for (const advanced of [false, true]) { + const native = game(advanced, ['atreides', 'ixians', 'fremen', 'guild']); + for (const count of [1, 3, 4]) { + assert.deepEqual(ixStartingCardDealPlan(native, count), { + kind: 'legacy-deck-bonus', + seats: [{ player: 'seat-0', amount: 1 }, { player: 'seat-2', amount: 1 }, { player: 'seat-3', amount: 1 }], + deckBonus: null, + }); + } + } +}); + +void test('a pool larger than its native source or an invalid count cannot select a new deal mode', () => { + const advanced = game(true, ['ixians', 'harkonnen']); + const basic = game(false, ['ixians', 'harkonnen']); + const noHarkonnen = game(true, ['ixians', 'atreides']); + for (const count of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1, 4]) + assert.throws(() => ixStartingCardDealPlan(advanced, count), IxStartingCardError); + assert.throws(() => ixStartingCardDealPlan(basic, 3), IxStartingCardError); + assert.throws(() => ixStartingCardDealPlan(noHarkonnen, 3), IxStartingCardError); +}); + +void test('the planner rejects missing Ixians, unregistered or duplicate seats and invalid native roster bounds', () => { + const valid = game(true, ['ixians', 'harkonnen']); + const invalid: unknown[] = [ + null, + { ...valid, advanced: 'advanced' }, + { ...valid, players: undefined }, + game(true, ['ixians']), + game(true, ['ixians', 'harkonnen', ...otherFactions, 'beneGesserit']), + game(true, ['atreides', 'harkonnen']), + game(true, ['ixians', 'ixians']), + game(true, ['ixians', 'unknown']), + { ...valid, players: [{ id: 'seat', faction: 'ixians' }, { id: 'seat', faction: 'harkonnen' }] }, + { ...valid, players: [{ id: '', faction: 'ixians' }, valid.players[1]] }, + { ...valid, players: [valid.players[0], { id: ' seat ', faction: 'harkonnen' }] }, + ]; + for (const native of invalid) { + assert.throws(() => ixStartingCardPoolSize(native as IxStartingCardGame), IxStartingCardError); + assert.throws(() => ixStartingCardDealPlan(native as IxStartingCardGame, 2), IxStartingCardError); + } +});