Connect Basic Moritani to the full Leader Skills prototype

Admit the genuine private setup profile for Basic Moritani with base opponents,
exactly the Ecaz expansion and no other optional modules. Keep all14 physical
skills, private two-card offers, starting-card/Traitor order and Moritani's final
six-force placement. Public starts and complete-mode/publication gates remain.

Share public profile eligibility across Banker, Diplomat, Sandmaster movement
and worm riding, Smuggler shipping/collection, Bureaucrat payments, Planetologist
controls/bots and Suk/Rihani/Sandmaster battle settlement. Admission reads no
hidden cards or random deal. Preserve base/CHOAM boundaries and existing source
questions; no full AI implementation, strategy or calibration changes.

Genuine setup regressions cover all14 assignments, legal profiles and guards,
Terror Assassination death and exact skill return, optional own
revival draw/privacy, Sabotage, movement controls/bots, Smuggler shipment and
Bureaucrat paid bribes. Battle actions cover Rihani exchange, Sandmaster spice,
Banker funds, Suk casualties and Smuggler collection. Actual enemy alliances
prove losing-ally retention excludes mandatory Planetologist Special and
Diplomat-copy disposal. A real worm/Nexus ride collects Sandmaster spice once.
Authenticated in-memory SQLite restarts and concurrent skill choices preserve
one assignment, private offers, saved seats and final Moritani placement.
Conserved staged positions are explicitly distinct from played phase histories.

Add a reproducible moritani-skills profile to the existing faction-game runner,
including fixed2-6-player Basic rosters, seed offsets16-20 and saved resume.
Verify expected module presence, all14 physical skills and ordinary Traitor
custody alongside existing card/force/private-view checks. Default six expansion
samples and base samples retain their rosters/ordinals. No live DB is used by
these complete-game samples. CLI regressions check interrupted continuation
and Advanced refusal; corruption tests catch lost cards and entire module loss.

Independent review caught an old all-base worm guard and a sample checker that
missed whole-module deletion; both fixed with meaningful regressions and reviewed.
Initial edits had a stale helper reference and final lint found an unused import;
these were corrected. Types, lint, all 5034 offline tests and build pass on
stable final source. Five genuine complete-game samples passed with no rejected
actions:2 players189 actions/5 restores,3 players53/1,4 players862/23,
5 players116/3 and6 players1338/36. These are legality samples, not calibration
or exhaustive faction/card acceptance. No endpoint/auth/storage changes; existing
HTTP suite not repeated, while new authenticated storage and live browser actions
exercise the newly admitted profile through production boundaries.

Browser created a fresh Basic human Moritani/Easy Emperor lobby, then the existing
backed-up local initializer started that named ready room without replacing seats.
Human inspected both Killer Medic bands, assigned Lupino Ord, chose a private
Traitor and placed six in Polar Sink after all other setup. Refresh preserved
the two public skills, own hand/Traitor and6 deployed/14 reserves at pending
Storm. Mobile skill panel339px, client/scroll337; viewport restored afterward.
Screenshot capture timed out, so full visual/screen-reader acceptance stays open.

All 1580 opening games, 3348 saved seats and auxiliary rows
remain identical. One isolated QA game adds one human seat and entry receipt;
its row is unchanged after setup through refresh, with all seats unchanged.
SQLite integrity and foreign keys pass. No server restart, migration, reset or
recurring automation. Existing unfinished Leader Skill bands and material
rulings remain explicit in the new integration guide and internal checklist.

Private evidence: 20260921-moritani-skills.
Verified source tree: d5078661ecc2199b2bbfebd18034b16585d06410a697a78dc514bfa5b3b77a1c
This commit is contained in:
Quintine committed 2026-09-21 13:43:27 +10:00
1 parent e7e16024b7
commit 489bd3265d
29 files changed
+1474 -126

No files matched your search

+20
View File
@@ -95,6 +95,26 @@ function assertFailedEvidence(
return report;
}
void test('Moritani Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
const area = temporary(t);
const out = join(area, 'skills');
const result = run(out, '--profile', 'moritani-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'moritani-skills-2-basic', 1016, false);
const snapshot = join(out, 'failed-moritani-skills-2-basic.json');
const game = json<{ advanced: boolean; leaderSkills: { deck: string[]; offers: Record<string, { cards: string[] }>; assignments: { skill: string }[] } }>(snapshot);
assert.equal(game.advanced, false);
const physical = [...game.leaderSkills.deck, ...Object.values(game.leaderSkills.offers).flatMap(o => o.cards), ...game.leaderSkills.assignments.map(a => a.skill)];
assert.equal(physical.length, 14); assert.equal(new Set(physical).size, 14);
const resumed = join(area, 'skills-resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(resumed, 'moritani-skills-2-basic', 1016, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'moritani-skills', '--rules', 'advanced');
assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/);
assert.equal(existsSync(unsupported), false);
});
void test('selected genuine three-player Advanced base sample fails honestly, resumes its fixed snapshot and keeps source-bound private evidence', (t) => {
const area = temporary(t);
const first = join(area, 'first');
+229
View File
@@ -0,0 +1,229 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
import { validateLeaderSkills } from '../game/leader-skills';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import {
completedMoritaniSkillsGame,
assertMoritaniSkillsCustody,
moritaniSkillsPlayer as player,
reloadMoritaniSkillsGame as reload,
rejectMoritaniSkillsAction as reject,
placeMoritaniSkillsTerror,
enterMoritaniSkillsTerror,
} from './moritani-skills-fixture';
const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
function conserved(g: Game) {
assertMoritaniSkillsCustody(g);
validateLeaderSkills(g.leaderSkills!, g.players);
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)].map(c => c.id);
assert.deepEqual(cards.sort(), baseDeck().map(c => c.id).sort());
for (const p of g.players)
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
const traitors = [...g.traitorReserve!, ...g.players.flatMap(p => p.traitors)];
assert.equal(traitors.length, 15);
assert.equal(new Set(traitors).size, 15);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
}
function take(g: Game, owner: string, predicate: (card: Card) => boolean) {
const index = g.deck.findIndex(predicate);
assert.ok(index >= 0, 'The staged card must come from the existing physical deck.');
const [card] = g.deck.splice(index, 1);
player(g, owner).hand.push(card);
return card;
}
function allow(g: Game): Game {
for (let i = 0; g.response && i < 20; i++) {
const responder = g.players.find(p => !g.response!.passed.includes(p.id));
assert.ok(responder);
g = act(g, responder.id, { type: 'passResponse' });
}
assert.equal(g.response, null);
return g;
}
/** The alliance is produced by real Terror placement, shipment, offer and reply. */
function alliedSetup(skill: LeaderSkillId): Game {
let g = completedMoritaniSkillsGame({ requestedSkill: skill, skillOwner: 'emperor' });
g = placeMoritaniSkillsTerror(g, 'robbery');
g = enterMoritaniSkillsTerror(g, 'e');
g = allow(act(g, 'm', { type: 'decision', alliance: true }));
g = act(g, 'e', { type: 'decision', accept: true });
assert.equal(player(g, 'm').ally, 'e');
assert.equal(player(g, 'e').ally, 'm');
return g;
}
/** After genuine setup, stage conserved armies/hands, then choose the battle normally. */
function stageBattle(g: Game, owner = 'm', opponent = 'e'): Game {
for (const p of g.players) {
g.deck.push(...p.hand);
p.hand = [];
p.forces = [owner, opponent].includes(p.id)
? { 'wind_pass:14': 5, 'carthag:11': 1 }
: { 'polar_sink:0': 1 };
p.reserves = [owner, opponent].includes(p.id) ? 14 : 19;
p.tanks = 0;
p.spice = 20;
}
Object.assign(g, { phase: 6, storm: 18, order: [owner, opponent, ...g.players.map(p => p.id).filter(id => id !== owner && id !== opponent)],
active: owner, ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
// The second unresolved battle prevents automatic Spice Collection from
// obscuring this battle's own board-spice and income results.
return g;
}
function openBattle(state: Game, owner = 'm', opponent = 'e', hide = true): Game {
let g = act(state, owner, { type: 'chooseBattle', territory: 'wind_pass', target: opponent });
for (let i = 0; i < 20; i++) {
g = allow(g);
if (g.decision?.kind === 'leaderSkillVisibility') {
const d = g.decision;
g = act(g, d.player, { type: 'leaderSkillVisibility', event: d.event, hide: d.player === owner ? hide : true });
} else if (g.decision?.kind === 'mentatQuestion' || g.decision?.kind === 'fullPlanOffer') {
g = act(g, g.decision.player, { type: 'decision', decline: true });
} else if (g.battle?.preparation) {
g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
} else break;
}
assert.ok(g.battle);
assert.equal(g.decision, null);
assert.ok(!g.battle.preparation);
return g;
}
function reveal(g: Game, own: Partial<Action> = {}, other: Partial<Action> = {}, owner = 'm', opponent = 'e'): Game {
g = act(g, owner, { type: 'battlePlan', dial: 4, leader: player(g, owner).leaders[0].id, ...own });
return act(g, opponent, { type: 'battlePlan', dial: 0, leader: player(g, opponent).leaders[1].id, ...other });
}
function resolve(g: Game, owner = 'm', opponent = 'e'): Game {
g = act(g, owner, { type: 'traitorCall', call: false });
return act(g, opponent, { type: 'traitorCall', call: false });
}
void test('Basic Moritani Rihani completes its private peek and actual exchange across saved decisions', () => {
const setup = completedMoritaniSkillsGame({ requestedSkill: 'rihani-decipherer' });
let g = resolve(reveal(openBattle(stageBattle(setup))));
const receipt = g.rihaniHistory!.at(-1)!;
assert.equal(g.decision?.kind, 'rihani');
assert.equal(receipt.owner, 'm');
assert.equal(receipt.peeked.length, 2);
assert.equal(receipt.drawn.length, 0);
assert.equal(viewGame(g, 'm').rihani!.history.at(-1)!.peeked.length, 2);
assert.deepEqual(viewGame(g, 'e').rihani!.history, []);
assert.deepEqual(viewGame(g, 'a').rihani!.history, []);
conserved(g);
reject(g, 'e', { type: 'decision', event: receipt.event, draw: true });
g = act(g, 'm', { type: 'decision', event: receipt.event, draw: true });
const drawn = g.rihaniHistory!.at(-1)!;
assert.equal(drawn.drawn.length, 2);
g = act(g, 'm', { type: 'decision', event: drawn.event, cards: [drawn.drawn[0], drawn.eligible[0]] });
assert.equal(player(g, 'm').traitors.length, 1);
assert.ok(player(g, 'm').traitors.includes(drawn.drawn[0]));
assert.ok(g.traitorReserve!.includes(drawn.eligible[0]));
assert.ok(g.traitorReserve!.includes(drawn.drawn[1]));
assert.equal(g.lastBattleContext?.rihani?.completed, true);
conserved(g);
reject(g, 'm', { type: 'decision', event: drawn.event, cards: [drawn.drawn[0], drawn.eligible[0]] });
});
void test('Basic Moritani Sandmaster adds board spice once and Banker spends its separately committed spice', () => {
for (const skill of ['sandmaster', 'spice-banker'] as const) {
const staged = stageBattle(completedMoritaniSkillsGame({ requestedSkill: skill }));
staged.spice = { 'wind_pass:14': 2 };
let g = openBattle(staged);
const printed = player(g, 'm').leaders[0].strength;
g = resolve(reveal(g, skill === 'spice-banker' ? { bankerSpice: 3 } : {}));
assert.equal(g.lastBattleContext?.winner, 'm');
assert.equal(g.spice['wind_pass:14'], skill === 'sandmaster' ? 5 : 2);
assert.equal(player(g, 'm').spice, skill === 'spice-banker' ? 17 : 20);
assert.equal(player(g, 'm').leaders[0].strength, printed);
if (skill === 'sandmaster') assert.ok(g.lastBattleContext?.sandmaster);
else assert.ok(g.log.some(l => l.text.includes('gained 3 battle strength from Spice Banker')));
conserved(g);
reject(g, 'e', { type: 'traitorCall', call: false });
}
});
void test('Basic Moritani Suk saves three actual casualties and completes its saved choice once', () => {
let g = resolve(reveal(openBattle(stageBattle(completedMoritaniSkillsGame({ requestedSkill: 'suk-graduate' })))));
const decision = g.decision;
assert.equal(decision?.kind, 'sukRescue');
if (decision?.kind !== 'sukRescue') throw new Error('Expected the real Suk rescue choice.');
assert.equal(player(g, 'm').tanks, 0);
const choice = decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.kind === 'normal');
assert.ok(choice >= 0);
conserved(g);
reject(g, 'e', { type: 'decision', event: decision.event, choice });
g = act(g, 'm', { type: 'decision', event: decision.event, choice });
assert.equal(player(g, 'm').tanks, 1);
assert.equal(player(g, 'm').reserves, 16);
assert.equal(player(g, 'm').forces['wind_pass:14'], 2);
assert.equal(g.lastBattleContext?.sukRescue?.completed, true);
conserved(g);
reject(g, 'm', { type: 'decision', event: decision.event, choice });
});
void test('Basic Moritani Smuggler freezes public collection and pays its surviving native disc once', () => {
const staged = stageBattle(completedMoritaniSkillsGame({ requestedSkill: 'smuggler' }));
staged.spice = { 'wind_pass:14': 8 };
let g = reveal(openBattle(staged));
const strength = player(g, 'm').leaders[0].strength;
assert.equal(g.battle!.smugglerCollection!.amount, strength);
assert.equal(player(g, 'm').spice, 20);
assert.equal(g.spice['wind_pass:14'], 8);
conserved(g);
g = resolve(g);
assert.equal(player(g, 'm').spice, 20 + strength);
assert.equal(g.spice['wind_pass:14'], 8 - strength);
assert.equal(g.lastBattleContext?.smugglerCollection?.stage, 'collected');
conserved(g);
reject(g, 'e', { type: 'traitorCall', call: false });
});
void test('Moritani ally retention excludes the used Planetologist Special and the actual Diplomat copy', () => {
for (const skill of ['planetologist', 'diplomat'] as const) {
const staged = stageBattle(alliedSetup(skill), 'e', 'a');
const mandatory = take(staged, 'e', c => skill === 'planetologist' ? c.effect === 'atomics' : c.kind === 'worthless');
const retained = take(staged, 'e', c => skill === 'planetologist' ? c.kind === 'snooper' : c.kind === 'worthless');
const enemyDefense = take(staged, 'a', c => c.kind === 'snooper');
const enemyWeapon = skill === 'diplomat' ? take(staged, 'a', c => c.kind === 'poison') : undefined;
let g = openBattle(staged, 'e', 'a', skill === 'planetologist');
const ownLeader = player(g, 'e').leaders[skill === 'planetologist' ? 0 : 1].id;
g = reveal(g, { dial: 0, leader: ownLeader, weapon: mandatory.id, defense: retained.id },
{ dial: 5, weapon: enemyWeapon?.id, defense: enemyDefense.id }, 'e', 'a');
if (skill === 'diplomat') {
const d = g.decision;
assert.equal(d?.kind, 'diplomatDefense');
if (d?.kind !== 'diplomatDefense') throw new Error('Expected the actual copied-defense choice.');
g = act(g, 'e', { type: 'decision', event: d.event, card: mandatory.id });
assert.equal(g.battle!.diplomatDefense!.card, mandatory.id);
assert.equal(player(g, 'e').hand.find(c => c.id === mandatory.id)!.kind, 'worthless');
}
g = resolve(g, 'e', 'a');
assert.equal(g.lastBattleContext?.winner, 'a');
// Winner card cleanup precedes the losing ally's separate retention choice.
if (g.decision?.kind === 'battleCards')
g = act(g, g.decision.player, { type: 'decision', discard: [] });
assert.equal(g.decision?.kind, 'moritaniRetention');
assert.deepEqual(g.moritaniRetention!.eligible, [retained.id]);
assert.ok(!g.moritaniRetention!.played.includes(mandatory.id));
assert.equal(g.discard.filter(c => c.id === mandatory.id).length, 1);
assert.equal(player(g, 'e').leaders.find(l => l.id === ownLeader)!.dead, false);
conserved(g);
reject(g, 'e', { type: 'decision', keep: mandatory.id });
reject(g, 'm', { type: 'decision', keep: retained.id });
g = allow(act(g, 'e', { type: 'decision', keep: retained.id }));
assert.ok(player(g, 'e').hand.some(c => c.id === retained.id));
assert.equal(g.discard.filter(c => c.id === mandatory.id).length, 1);
assert.equal(g.moritaniRetention, null);
conserved(g);
reject(g, 'e', { type: 'decision', keep: retained.id });
}
});
+267
View File
@@ -0,0 +1,267 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { newRevivalRules } from '../game/revival';
import {
assertMoritaniSkillsCustody,
completedMoritaniSkillsGame,
enterMoritaniSkillsTerror,
initializedMoritaniSkillsOffers,
moritaniSkillsPlayer,
placeMoritaniSkillsTerror,
rejectMoritaniSkillsAction,
reloadMoritaniSkillsGame,
} from './moritani-skills-fixture';
const forceTotal = (game: Game, id: string) => {
const player = moritaniSkillsPlayer(game, id);
return (
player.reserves +
player.tanks +
Object.values(player.forces).reduce((sum, count) => sum + count, 0)
);
};
void test('genuine Basic Moritani setup keeps all fourteen skill cards and each two-card offer private', () => {
const game = initializedMoritaniSkillsOffers({
requestedSkill: 'warmaster',
skillOwner: 'moritani',
});
assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, ['ecaz']);
assert.deepEqual(
new Set(game.players.map((player) => player.faction)),
new Set(['moritani', 'atreides', 'emperor']),
);
assert.equal(game.leaderSkills!.deck.length, 8);
assert.equal(Object.keys(game.leaderSkills!.offers).length, 3);
assert.equal(game.leaderSkills!.assignments.length, 0);
assertMoritaniSkillsCustody(game);
for (const viewer of game.players) {
const view = viewGame(reloadMoritaniSkillsGame(game), viewer.id);
assert.deepEqual(
view.leaderSkills!.offer,
game.leaderSkills!.offers[viewer.id],
);
assert.equal(view.leaderSkills!.offer!.cards.length, 2);
assert.equal('deck' in view.leaderSkills!, false);
assert.equal('offers' in view.leaderSkills!, false);
}
});
void test('Terror Assassination returns exactly the killed leader skill once and real revival offers one private optional replacement after JSON restore', () => {
let game = completedMoritaniSkillsGame({
requestedSkill: 'warmaster',
skillOwner: 'emperor',
});
const assignment = game.leaderSkills!.assignments.find(
(candidate) => candidate.owner === 'e',
)!;
assert.deepEqual(assignment, {
owner: 'e',
leader: 'emperor-0',
skill: 'warmaster',
});
const emperor = moritaniSkillsPlayer(game, 'e');
for (const leader of emperor.leaders)
if (leader.id !== assignment.leader) {
leader.dead = true;
leader.deaths = 1;
}
const forceTotals = Object.fromEntries(
game.players.map((player) => [player.id, forceTotal(game, player.id)]),
);
game = placeMoritaniSkillsTerror(game, 'assassination');
assert.deepEqual(
Object.fromEntries(
game.players.map((player) => [player.id, forceTotal(game, player.id)]),
),
forceTotals,
);
const beforeEntry = reloadMoritaniSkillsGame(game);
game = enterMoritaniSkillsTerror(game, 'e');
assert.equal(moritaniSkillsPlayer(game, 'e').reserves,
moritaniSkillsPlayer(beforeEntry, 'e').reserves - 1);
assert.equal(moritaniSkillsPlayer(game, 'e').forces['carthag:11'], 1);
assert.equal(forceTotal(game, 'e'), forceTotals.e);
assert.equal(viewGame(game, 'm').terrorEntry!.kind, 'assassination');
assert.equal('kind' in viewGame(game, 'e').terrorEntry!, false);
assert.equal(game.leaderSkills!.deck.includes('warmaster'), false);
const killed = applyAction(game, 'm', {
type: 'decision',
reveal: true,
});
const killedLeader = moritaniSkillsPlayer(killed, 'e').leaders.find(
(leader) => leader.id === assignment.leader,
)!;
assert.equal(killedLeader.dead, true);
assert.equal(killedLeader.deaths, 1);
assert.equal(
killed.leaderSkills!.assignments.some(
(candidate) => candidate.owner === 'e',
),
false,
);
assert.equal(
killed.leaderSkills!.deck.filter((skill) => skill === 'warmaster').length,
1,
);
assertMoritaniSkillsCustody(killed);
rejectMoritaniSkillsAction(killed, 'm', {
type: 'decision',
reveal: true,
});
const restored = reloadMoritaniSkillsGame(killed);
Object.assign(restored, {
phase: 4,
active: null,
ready: [],
decision: null,
response: null,
phaseOpening: null,
revivalRules: newRevivalRules(),
});
const restoredEmperor = moritaniSkillsPlayer(restored, 'e');
restoredEmperor.spice = 20;
restoredEmperor.leaderRevived = false;
const offered = applyAction(restored, 'e', {
type: 'reviveLeader',
leader: assignment.leader,
});
assert.equal(offered.decision?.kind, 'leaderSkillRevival');
assert.equal(
moritaniSkillsPlayer(offered, 'e').leaders.find(
(leader) => leader.id === assignment.leader,
)!.dead,
false,
);
assert.deepEqual(viewGame(offered, 'e').leaderSkills!.offer!.cards, []);
assert.equal(viewGame(offered, 'm').leaderSkills!.offer, null);
assert.equal(viewGame(offered, 'a').leaderSkills!.offer, null);
assertMoritaniSkillsCustody(offered);
const event = offered.leaderSkills!.offers.e.event;
rejectMoritaniSkillsAction(
offered,
'm',
{ type: 'leaderSkill', event, mode: 'draw' },
/not waiting for you/,
);
rejectMoritaniSkillsAction(
offered,
'e',
{ type: 'leaderSkill', event: `${event}:stale`, mode: 'draw' },
/not waiting for you/,
);
const declined = applyAction(reloadMoritaniSkillsGame(offered), 'e', {
type: 'leaderSkill',
event,
mode: 'decline',
});
assert.equal(declined.decision, null);
assert.equal(declined.leaderSkills!.offers.e, undefined);
assert.equal(
declined.leaderSkills!.assignments.some(
(candidate) => candidate.owner === 'e',
),
false,
);
assertMoritaniSkillsCustody(declined);
const drawn = applyAction(reloadMoritaniSkillsGame(offered), 'e', {
type: 'leaderSkill',
event,
mode: 'draw',
});
const privateOffer = viewGame(reloadMoritaniSkillsGame(drawn), 'e')
.leaderSkills!.offer!;
assert.equal(privateOffer.cards.length, 2);
assert.equal(viewGame(drawn, 'm').leaderSkills!.offer, null);
assert.equal(viewGame(drawn, 'a').leaderSkills!.offer, null);
assertMoritaniSkillsCustody(drawn);
const choice = {
type: 'leaderSkill',
event,
skill: privateOffer.cards[0],
leader: assignment.leader,
} as const;
const selected = applyAction(drawn, 'e', choice);
assert.deepEqual(
selected.leaderSkills!.assignments.find(
(candidate) => candidate.owner === 'e',
),
{
owner: 'e',
leader: assignment.leader,
skill: privateOffer.cards[0],
},
);
assert.equal(selected.leaderSkills!.offers.e, undefined);
assertMoritaniSkillsCustody(selected);
rejectMoritaniSkillsAction(
selected,
'e',
choice,
/not waiting for you/,
);
});
void test('Sabotage discards only Treachery cards while skill custody and shared projections stay unchanged', () => {
let game = completedMoritaniSkillsGame({
requestedSkill: 'warmaster',
skillOwner: 'emperor',
});
game = placeMoritaniSkillsTerror(game, 'sabotage');
const beforeEntry = reloadMoritaniSkillsGame(game);
const entrantCard = moritaniSkillsPlayer(beforeEntry, 'e').hand[0];
assert.ok(entrantCard);
game = enterMoritaniSkillsTerror(game, 'e');
const revealed = applyAction(game, 'm', {
type: 'decision',
reveal: true,
});
assert.equal(revealed.pendingTerrorEntry?.stage, 'gift');
assert.equal(
moritaniSkillsPlayer(revealed, 'e').hand.some(
(card) => card.id === entrantCard.id,
),
false,
);
assert.equal(
revealed.discard.filter((card) => card.id === entrantCard.id).length,
1,
);
assert.deepEqual(revealed.leaderSkills, beforeEntry.leaderSkills);
assertMoritaniSkillsCustody(revealed);
const projections = revealed.players.map(
(player) => viewGame(reloadMoritaniSkillsGame(revealed), player.id).leaderSkills!,
);
for (const projection of projections) {
assert.deepEqual(projection.assignments, projections[0].assignments);
assert.equal(projection.offer, null);
assert.equal('deck' in projection, false);
}
const opponentView = viewGame(revealed, 'a');
assert.equal('discard' in opponentView, false);
assert.equal(JSON.stringify(opponentView).includes(entrantCard.id), false);
const done = applyAction(reloadMoritaniSkillsGame(revealed), 'm', {
type: 'decision',
decline: true,
});
assert.equal(done.pendingTerrorEntry, null);
assert.equal(done.decision, null);
assert.deepEqual(done.leaderSkills, beforeEntry.leaderSkills);
assertMoritaniSkillsCustody(done);
rejectMoritaniSkillsAction(done, 'm', {
type: 'decision',
decline: true,
});
});
+237
View File
@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
type Action,
type Game,
type Player,
} from '../game/engine';
import {
LEADER_SKILL_CARDS,
type LeaderSkillId,
} from '../game/leader-skill-cards';
import type { TerrorKind } from '../game/moritani-terror';
export const MORITANI_SKILLS_SEATS = {
moritani: 'm',
atreides: 'a',
emperor: 'e',
} as const;
export type MoritaniSkillsFaction = keyof typeof MORITANI_SKILLS_SEATS;
export type MoritaniSkillsFixtureOptions = {
requestedSkill?: LeaderSkillId;
skillOwner?: MoritaniSkillsFaction;
};
const names: Record<MoritaniSkillsFaction, string> = {
moritani: 'Moritani',
atreides: 'Atreides',
emperor: 'Emperor',
};
export const reloadMoritaniSkillsGame = (game: Game): Game =>
JSON.parse(JSON.stringify(game)) as Game;
export function moritaniSkillsPlayer(game: Game, id: string): Player {
return game.players.find((player) => player.id === id)!;
}
export function rejectMoritaniSkillsAction(
game: Game,
id: string,
action: Action,
pattern?: RegExp,
): void {
const before = structuredClone(game);
if (pattern) assert.throws(() => applyAction(game, id, action), pattern);
else assert.throws(() => applyAction(game, id, action));
assert.deepEqual(game, before);
}
export function assertMoritaniSkillsCustody(game: Game): void {
const skills = game.leaderSkills!;
const physical = [
...skills.deck,
...Object.values(skills.offers).flatMap((offer) => offer.cards),
...skills.assignments.map((assignment) => assignment.skill),
];
const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
assert.equal(physical.length, canonical.length);
assert.equal(new Set(physical).size, canonical.length);
assert.deepEqual([...physical].sort(), [...canonical].sort());
}
/**
* Reach the real private Leader Skill setup offers from a ready Basic lobby.
* Seating the requested owner first lets the production shuffle seam put that
* requested physical card at the front of its genuine two-card deal.
*/
export function initializedMoritaniSkillsOffers(
options: MoritaniSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'warmaster';
const skillOwner = options.skillOwner ?? 'moritani';
const factions = (
[skillOwner, 'moritani', 'atreides', 'emperor'] as MoritaniSkillsFaction[]
).filter((faction, index, all) => all.indexOf(faction) === index);
const first = factions[0];
let game = createGame(
'MORISKIL',
newPlayer(MORITANI_SKILLS_SEATS[first], names[first], first),
false,
['ecaz'],
);
for (const faction of factions.slice(1))
joinGame(
game,
newPlayer(MORITANI_SKILLS_SEATS[faction], names[faction], faction),
);
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex(
(card) => card.id === requestedSkill,
);
assert.ok(targetIndex >= 0);
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
} finally {
mock.restoreAll();
}
const owner = MORITANI_SKILLS_SEATS[skillOwner];
assert.equal(game.status, 'setup');
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(game.leaderSkills!.offers[owner].cards[0], requestedSkill);
assertMoritaniSkillsCustody(game);
return game;
}
/** Complete skills, traitors, and Moritani's six-force placement by actions. */
export function completedMoritaniSkillsGame(
options: MoritaniSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'warmaster';
const skillOwner = options.skillOwner ?? 'moritani';
const requestedOwner = MORITANI_SKILLS_SEATS[skillOwner];
let game = initializedMoritaniSkillsOffers(options);
for (const player of game.players) {
const offer = game.leaderSkills!.offers[player.id];
game = applyAction(game, player.id, {
type: 'leaderSkill',
event: offer.event,
skill: player.id === requestedOwner ? requestedSkill : offer.cards[0],
leader: player.leaders[0].id,
});
}
assert.equal(game.setupStage, 'traitors');
for (const player of game.players) {
const choice = moritaniSkillsPlayer(game, player.id).traitorChoices[0];
assert.ok(choice);
game = applyAction(game, player.id, { type: 'traitor', leader: choice });
}
assert.deepEqual(game.decision, {
kind: 'moritaniSetup',
player: MORITANI_SKILLS_SEATS.moritani,
});
game = applyAction(game, MORITANI_SKILLS_SEATS.moritani, {
type: 'decision',
territory: 'polar_sink',
sector: 0,
});
assert.equal(game.status, 'playing');
assert.equal(game.setupStage, undefined);
assert.equal(moritaniSkillsPlayer(game, 'm').forces['polar_sink:0'], 6);
assert.equal(moritaniSkillsPlayer(game, 'm').reserves, 14);
assertMoritaniSkillsCustody(game);
return game;
}
/**
* Stage only the phase controls, then place a real hidden Terror token through
* the Mentat decision and its complete response window. Force custody is not
* changed by this helper.
*/
export function placeMoritaniSkillsTerror(
state: Game,
kind: TerrorKind,
territory = 'carthag',
): Game {
let game = structuredClone(state);
Object.assign(game, {
phase: 7,
active: null,
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
assert.equal(game.phase, 8);
assert.equal(game.decision?.kind, 'moritaniPlacement');
const token = game.moritaniTerror!.tokens.find(
(candidate) => candidate.kind === kind,
);
assert.ok(token);
game = applyAction(game, MORITANI_SKILLS_SEATS.moritani, {
type: 'decision',
token: token.id,
territory,
});
while (game.response) {
const responder = game.players.find(
(player) => !game.response!.passed.includes(player.id),
);
assert.ok(responder);
game = applyAction(game, responder.id, { type: 'passResponse' });
}
assert.equal(
game.moritaniTerror!.tokens.find((candidate) => candidate.id === token.id)
?.location,
territory,
);
return game;
}
/** Stage Shipment and perform the entrant's real reserve shipment. */
export function enterMoritaniSkillsTerror(
state: Game,
entrant: string,
territory = 'carthag',
sector = 11,
amount = 1,
): Game {
const game = structuredClone(state);
Object.assign(game, {
phase: 5,
storm: 18,
active: entrant,
ready: [],
decision: null,
response: null,
phaseOpening: null,
movementRemaining: [...game.order],
});
const player = moritaniSkillsPlayer(game, entrant);
player.shipped = false;
player.moved = 0;
return applyAction(game, entrant, {
type: 'ship',
territory,
sector,
amount,
});
}
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { basicMoritaniLeaderSkillsProfile } from '../game/leader-skill-profile';
import { spiceBankerModeSupported } from '../game/spice-banker';
import { diplomatDefenseModeSupported } from '../game/diplomat-defense';
import { sandmasterModeSupported } from '../game/sandmaster-movement';
import { smugglerBattleModeSupported } from '../game/smuggler-battle';
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
import { planetologistMovementModeSupported } from '../game/planetologist-movement';
import { planetologistMoveDraft } from '../components/planetologist-movement';
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { completedMoritaniSkillsGame, initializedMoritaniSkillsOffers, assertMoritaniSkillsCustody, moritaniSkillsPlayer as player, reloadMoritaniSkillsGame as reload, rejectMoritaniSkillsAction as reject } from './moritani-skills-fixture';
function movementPosition(game: Game): Game {
// Conserved focused position following genuine setup, not a played phase history.
for (const p of game.players) {
p.forces = {}; p.reserves = 20; p.tanks = 0;
p.spice = 20; p.moved = 0; p.shipped = false;
}
Object.assign(game, { phase: 5, active: 'm', order: ['m', 'a', 'e'], storm: 18,
ready: [], decision: null, response: null, phaseOpening: null });
return game;
}
void test('every canonical skill can be dealt and publicly assigned to Basic Moritani without reducing the deck', () => {
for (const card of LEADER_SKILL_CARDS) {
const game = completedMoritaniSkillsGame({ requestedSkill: card.id });
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 'm')!.skill, card.id);
assertMoritaniSkillsCustody(game);
for (const p of game.players) {
const view = viewGame(game, p.id);
assert.equal(view.leaderSkills!.offer, null);
assert.equal(view.leaderSkills!.assignments.length, 3);
assert.deepEqual(viewGame(reload(game), p.id), view);
}
}
});
void test('Moritani skill integration shares public guards and retains Advanced, Ecaz and optional-module boundaries', () => {
const game = completedMoritaniSkillsGame();
const gates = [basicMoritaniLeaderSkillsProfile, spiceBankerModeSupported, diplomatDefenseModeSupported,
sandmasterModeSupported, smugglerBattleModeSupported, bureaucratPaymentModeSupported, planetologistMovementModeSupported];
for (const gate of gates) {
assert.equal(gate(game), true, gate.name);
assert.equal(gate(viewGame(game, 'a')), true, `${gate.name} projected`);
}
for (const change of [
(g: Game) => { g.advanced = true; },
(g: Game) => { g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
(g: Game) => { g.expansions.push('choam'); },
(g: Game) => { g.discoveryEnabled = true; },
(g: Game) => { g.ecazTreachery = true; },
]) {
const lobby = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
joinGame(lobby, newPlayer('a', 'Atreides', 'atreides'));
lobby.players.forEach(p => { p.ready = true; });
change(lobby);
const before = JSON.stringify(lobby);
assert.throws(() => initializeLeaderSkillsGameForAudit(lobby));
assert.equal(JSON.stringify(lobby), before);
assert.equal(basicMoritaniLeaderSkillsProfile(lobby), false);
}
const publicLobby = createGame('MORIPUBL', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
joinGame(publicLobby, newPlayer('a', 'Atreides', 'atreides'));
publicLobby.players.forEach(p => { p.ready = true; });
reject(publicLobby, 'm', { type: 'start' });
});
void test('all four profiles legally choose from the same private Moritani setup offer', () => {
const game = initializedMoritaniSkillsOffers({ requestedSkill: 'planetologist' });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(game), 'm');
view.players.find(p => p.id === 'm')!.bot = difficulty;
const action = botActions(view)[0];
assert.equal(action.type, 'leaderSkill');
const next = applyAction(game, 'm', action);
assert.equal(next.leaderSkills!.assignments.find(a => a.owner === 'm')?.leader, action.leader);
assertMoritaniSkillsCustody(next);
}
});
void test('Moritani Planetologist controls and bots use the same extended movement through saved views', () => {
const game = movementPosition(completedMoritaniSkillsGame({ requestedSkill: 'planetologist' }));
const from = 'red_chasm:7';
player(game, 'm').forces = { [from]: 3 }; player(game, 'm').reserves = 17;
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
.find(key => splitLocation(key).sector !== 18 && distance(from, key, k => splitLocation(k).sector === 18) === 2)!;
assert.ok(to);
const target = splitLocation(to);
const draft = planetologistMoveDraft(viewGame(game, 'm'), 'range', target.territory, target.sector, { [from]: 3 }, {});
assert.equal(draft.blocked, null); assert.ok(draft.action);
const done = applyAction(reload(game), 'm', draft.action);
assert.equal(player(done, 'm').forces[to], 3);
assert.equal(player(done, 'm').forces[from] ?? 0, 0);
assert.equal(player(done, 'm').moved, 1);
assertMoritaniSkillsCustody(done);
// Make the same two-step target useful; only legal availability is assessed.
game.spice = { [to]: 8 };
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'm'); view.players.find(p => p.id === 'm')!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'move' && a.planetologist === 'range');
assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(game, 'm', action));
}
});
void test('Moritani Smuggler ships the free companion once and Bureaucrat redirects a real paid bribe after restore', () => {
let game = movementPosition(completedMoritaniSkillsGame({ requestedSkill: 'smuggler' }));
const ship = { type: 'ship', territory: 'carthag', sector: 11, amount: 3, smuggler: true };
game = applyAction(game, 'm', ship);
assert.equal(player(game, 'm').forces['carthag:11'], 3);
assert.equal(player(game, 'm').reserves, 17);
assert.equal(player(game, 'm').spice, 18);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
reject(game, 'm', ship); assertMoritaniSkillsCustody(game);
game = movementPosition(completedMoritaniSkillsGame({ requestedSkill: 'bureaucrat' }));
game = applyAction(game, 'a', { type: 'bribe', target: 'e', amount: 5 });
assert.equal(game.decision?.kind, 'bureaucratPayment');
assert.equal(game.decision?.player, 'm');
const event = game.bureaucratPaymentEvent;
const done = applyAction(reload(game), 'm', { type: 'decision', event, redirect: true });
assert.equal(player(done, 'a').spice, 15);
assert.equal(player(done, 'e').bribes, 3);
assert.equal(done.bureaucratPayments!.used.length, 1);
reject(done, 'm', { type: 'decision', event, redirect: true });
assertMoritaniSkillsCustody(done);
});
+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import { unitStore } from './fixture-nexus-room-store';
import { startPrototypeRoom } from '../tools/prototype-room';
import { assertMoritaniSkillsCustody } from './moritani-skills-fixture';
import type { Action } from '../game/engine';
void test('authenticated restart and concurrent Moritani setup choices retain one skill and the final force placement', async () => {
const f = unitStore();
const db: DatabaseSync = f.sqlite;
const clock = { now: () => 10000, sleep: async () => {} };
try {
const created = await f.rooms.createRoom('Moritani', 'moritani', false, ['ecaz']);
const code = created.view.code;
const joined = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const auths = await Promise.all([created.token!, joined.token!].map(token => f.restart().authenticate(code, token)));
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
await act(0, { type: 'ready' }); await act(1, { type: 'ready' });
const ready = await f.restart().readRoom(code);
const seats = db.prepare('SELECT * FROM seats').all();
startPrototypeRoom(db, code, ready.version, 'leader-skills');
const initial = await f.restart().readRoom(code);
assertMoritaniSkillsCustody(initial);
for (const auth of auths) {
const view = await f.restart().readSeatView(code, auth);
assert.deepEqual(view.leaderSkills!.offer, initial.leaderSkills!.offers[auth.playerId]);
assert.equal('offers' in view.leaderSkills!, false);
assert.equal('deck' in view.leaderSkills!, false);
}
const offer = initial.leaderSkills!.offers[auths[0].playerId];
const choice = { type: 'leaderSkill', event: offer.event, skill: offer.cards[0], leader: 'moritani-0' };
let arrivals = 0; let release!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
const results = await Promise.allSettled([
f.rooms.act(code, auths[0], initial.version, choice, clock),
f.restart().act(code, auths[0], initial.version, choice, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
assert.equal(results.filter(r => r.status === 'rejected').length, 1);
let game = await f.restart().readRoom(code);
assert.equal(game.version, initial.version + 1);
assert.equal(game.leaderSkills!.assignments.filter(a => a.owner === auths[0].playerId).length, 1);
assert.equal(game.leaderSkills!.deck.filter(s => s === offer.cards[1]).length, 1);
const other = game.leaderSkills!.offers[auths[1].playerId];
game = await act(1, { type: 'leaderSkill', event: other.event, skill: other.cards[0], leader: 'atreides-0' });
for (let index = 0; index < auths.length; index++)
game = await act(index, { type: 'traitor', leader: game.players.find(p => p.id === auths[index].playerId)!.traitorChoices[0] });
assert.equal(game.decision?.kind, 'moritaniSetup');
game = await act(0, { type: 'decision', territory: 'polar_sink', sector: 0 });
assert.equal(game.status, 'playing');
assert.equal(game.players.find(p => p.id === auths[0].playerId)!.forces['polar_sink:0'], 6);
assertMoritaniSkillsCustody(game);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths) {
const view = await f.restart().readSeatView(code, auth);
assert.equal(view.leaderSkills!.assignments.length, 2);
assert.equal(view.leaderSkills!.offer, null);
}
} finally { db.close(); }
});
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { sandmasterWormCollection } from '../game/sandmaster-worm';
function totalForces(game: Game, player: string) {
const owner = game.players.find((candidate) => candidate.id === player)!;
return (
owner.reserves +
owner.tanks +
Object.values(owner.forces).reduce((sum, count) => sum + count, 0)
);
}
const totalSpiceCards = (game: Game) =>
game.spiceDeck.length +
game.spiceDiscard.reduce((sum, pile) => sum + pile.length, 0);
/** Genuine Basic setup through ready, skill, traitor and both force decisions. */
function completedMoritaniFremenSkillsGame(): Game {
let game = createGame(
'MORIWORM',
newPlayer('f', 'Fremen', 'fremen'),
false,
['ecaz'],
);
joinGame(game, newPlayer('m', 'Moritani', 'moritani'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex(
(card) => card.id === 'sandmaster',
);
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
} finally {
mock.restoreAll();
}
assert.equal(game.leaderSkills!.offers.f.cards[0], 'sandmaster');
for (const player of game.players) {
const offer = game.leaderSkills!.offers[player.id];
game = applyAction(game, player.id, {
type: 'leaderSkill',
event: offer.event,
skill: player.id === 'f' ? 'sandmaster' : offer.cards[0],
leader: player.leaders[0].id,
});
}
for (const player of game.players) {
const current = game.players.find((candidate) => candidate.id === player.id)!;
game = applyAction(game, player.id, {
type: 'traitor',
leader: current.traitorChoices[0],
});
}
game = applyAction(game, 'f', {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.deepEqual(game.decision, { kind: 'moritaniSetup', player: 'm' });
game = applyAction(game, 'm', {
type: 'decision',
territory: 'polar_sink',
sector: 0,
});
assert.equal(game.status, 'playing');
assert.deepEqual(
game.leaderSkills!.assignments.find((assignment) => assignment.owner === 'f'),
{ owner: 'f', leader: 'fremen-0', skill: 'sandmaster' },
);
return game;
}
function summonedWormRide(): Game {
let game = completedMoritaniFremenSkillsGame();
const fremen = game.players.find((player) => player.id === 'f')!;
// Stage a conserved turn-two Spice Blow position. Only locations, deck order
// and phase controls change; all twenty Fremen forces and Spice cards remain.
fremen.forces = { 'wind_pass_north:17': 4 };
fremen.reserves = 16;
fremen.moved = 1;
game.spice = { 'habbanya_ridge_flat:17': 2 };
const spiceCardCount = totalSpiceCards(game);
const priorIndex = game.spiceDeck.findIndex(
(card) => 'territory' in card && card.territory === 'wind_pass_north',
);
assert.ok(priorIndex >= 0);
const [prior] = game.spiceDeck.splice(priorIndex, 1);
const wormIndex = game.spiceDeck.findIndex((card) => 'worm' in card);
assert.ok(wormIndex >= 0);
const [worm] = game.spiceDeck.splice(wormIndex, 1);
const nextIndex = game.spiceDeck.findIndex(
(card) =>
'territory' in card &&
card.territory !== 'wind_pass_north' &&
card.territory !== 'habbanya_ridge_flat',
);
assert.ok(nextIndex >= 0);
const [next] = game.spiceDeck.splice(nextIndex, 1);
game.spiceDeck = [worm, next, ...game.spiceDeck];
game.spiceDiscard = [[prior], []];
assert.equal(totalSpiceCards(game), spiceCardCount);
Object.assign(game, {
turn: 2,
phase: 1,
storm: 18,
active: null,
ready: [],
decision: null,
response: null,
phaseOpening: null,
nexus: false,
spiceSequence: null,
spiceResolution: null,
spiceWindow: null,
wormRides: [],
movementRemaining: null,
});
assert.equal(totalForces(game, 'f'), 20);
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
while (game.response) {
const responder = game.players.find(
(player) => !game.response!.passed.includes(player.id),
);
assert.ok(responder);
game = applyAction(game, responder.id, { type: 'passResponse' });
}
assert.equal(game.nexus, true);
for (let round = 0; round < 3 && game.decision?.kind !== 'wormRide'; round += 1)
for (const player of game.players)
if (!game.ready.includes(player.id))
game = applyAction(game, player.id, { type: 'ready' });
assert.deepEqual(game.decision, {
kind: 'wormRide',
player: 'f',
territory: 'wind_pass_north',
});
assert.equal(totalForces(game, 'f'), 20);
return game;
}
void test('Basic Moritani permits a projected Sandmaster worm collection and one legal bot ride after JSON restoration', () => {
const pending = summonedWormRide();
const spiceCardCount = totalSpiceCards(pending);
const restored = JSON.parse(JSON.stringify(pending)) as Game;
assert.deepEqual(viewGame(restored, 'f'), viewGame(pending, 'f'));
const authoritative = sandmasterWormCollection(
restored,
'f',
'habbanya_ridge_flat',
17,
);
const projected = sandmasterWormCollection(
viewGame(restored, 'f'),
'f',
'habbanya_ridge_flat',
17,
);
assert.deepEqual(projected, authoritative);
assert.deepEqual(authoritative, {
leader: 'fremen-0',
key: 'habbanya_ridge_flat:17',
before: 2,
blocked: null,
});
const botView = viewGame(restored, 'f');
botView.players.find((player) => player.id === 'f')!.bot = 'Easy';
const actions = botActions(botView);
const collecting = actions.find(
(action) =>
action.accept === true &&
action.territory === 'habbanya_ridge_flat' &&
action.sector === 17 &&
action.sandmasterCollect === true,
);
assert.ok(
collecting,
`the minimal bot path should include the legal collection: ${JSON.stringify(actions)}`,
);
const beforeSpice = restored.players.find((player) => player.id === 'f')!.spice;
const done = applyAction(restored, 'f', collecting);
const rider = done.players.find((player) => player.id === 'f')!;
assert.equal(rider.spice, beforeSpice + 1);
assert.equal(done.spice['habbanya_ridge_flat:17'], 1);
assert.equal(rider.forces['wind_pass_north:17'] ?? 0, 0);
assert.equal(rider.forces['habbanya_ridge_flat:17'], 4);
assert.equal(rider.moved, 1);
assert.equal(totalForces(done, 'f'), 20);
assert.equal(totalSpiceCards(done), spiceCardCount);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Sandmaster collection',
).length,
1,
);
});
+17
View File
@@ -11,6 +11,23 @@ import {
viewGame,
} from '../game/engine';
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
import { completedMoritaniSkillsGame } from './moritani-skills-fixture';
void test('Moritani Skills sample custody checks the physical skill and Traitor inventories after genuine setup', () => {
const game = completedMoritaniSkillsGame();
const inventory = sampleInventory(game);
assert.ok(inventory.traitors);
verifySampleCustody(game, inventory);
const skills = structuredClone(game);
skills.leaderSkills!.deck.pop();
assert.throws(() => verifySampleCustody(skills, inventory));
const missing = structuredClone(game);
delete missing.leaderSkills;
assert.throws(() => verifySampleCustody(missing, inventory), /Leader Skills module custody/);
const traitors = structuredClone(game);
traitors.players[0].traitors[0] = traitors.players[1].traitors[0];
assert.throws(() => verifySampleCustody(traitors, inventory), /traitor custody/);
});
function fixture(advanced = false) {
let game = createGame(