Align Advanced Ix starting cards with Harkonnen pool

This commit is contained in:
Quintine committed 2026-10-27 10:26:44 +11:00
1 parent e530d267a3
commit 84fdd404c9
14 files changed
+637 -12

No files matched your search

+16 -1
View File
@@ -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({
<>
<p>
{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.'}{' '}
+35
View File
@@ -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;
+27
View File
@@ -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
+6
View File
@@ -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).
+45
View File
@@ -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
+12
View File
@@ -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
+19 -8
View File
@@ -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<T>(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(
+61
View File
@@ -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<string>();
const factions = new Set<string>();
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,
};
}
+1 -1
View File
@@ -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.',
+2 -1
View File
@@ -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 } };
+152
View File
@@ -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<T>(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<T>(rolls: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(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<number>(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.` };
}
+1 -1
View File
@@ -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']) {
+153
View File
@@ -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);
}
});
+107
View File
@@ -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);
}
});