Restore ordinary Skills in bounded Guild Betrayal composition

This commit is contained in:
Quintine committed 2026-10-13 19:19:05 +11:00
1 parent 9fb680da09
commit ea12a68e56
12 files changed
+637 -11

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
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 { DIFFICULTIES } from '../game/bot-profiles';
import {
betrayalMentatResources, betrayalMentatSeat, createBetrayalMentatQuestionFixture,
prepareBetrayalMentatPlans, reloadBetrayalMentat,
} from './fixture-betrayal-mentat-question';
function rejectBetrayalMentat(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('native Betrayal Mentat exact weapon shows privately before original posture and accepts card-free native plans', () => {
const fixture = createBetrayalMentatQuestionFixture();
const { owner, target, observer, leader } = fixture;
let game = reloadBetrayalMentat(fixture.battle);
assert.equal(game.decision?.kind, 'mentatQuestion');
assert.equal(game.decision?.player, owner);
assert.equal(game.battle!.preparation, undefined);
assert.equal(game.battle!.leaderSkillHidden, undefined);
assert.equal(viewGame(game, owner).mentat.pending!.stage, 'name');
const event = game.battle!.mentatQuestionEvent!;
const resources = betrayalMentatResources(game);
assert.deepEqual(resources, betrayalMentatResources(fixture.paid));
assert.ok(fixture.shipmentGateEvents.length);
const ask: Action = { type: 'decision', event, weapon: 'Crysknife' };
rejectBetrayalMentat(game, target, ask);
rejectBetrayalMentat(game, owner, { ...ask, event: 'stale' });
game = applyAction(game, owner, ask);
assert.equal(game.decision?.kind, 'mentatQuestion');
assert.equal(game.decision?.player, target);
assert.equal(game.battle!.preparation, undefined);
const held = betrayalMentatSeat(game, target).hand;
const weapon = held.find(card => card.name === 'Crysknife')!;
const other = held.find(card => card.name === 'Snooper')!;
assert.deepEqual(viewGame(game, target).mentat.pending!.cards,
held.filter(card => card.name === 'Crysknife'));
for (const id of [owner, observer, 'shipper'])
assert.deepEqual(viewGame(game, id).mentat.pending!.cards, []);
const show: Action = { type: 'decision', event, card: weapon.id };
rejectBetrayalMentat(game, owner, show);
rejectBetrayalMentat(game, target, { ...show, event: 'stale' });
rejectBetrayalMentat(game, target, { ...show, card: other.id });
game = applyAction(reloadBetrayalMentat(game), target, show);
assert.equal(game.decision?.kind, 'leaderSkillVisibility');
assert.equal(game.battle!.preparation, undefined);
assert.deepEqual(betrayalMentatResources(game), resources);
for (const id of [owner, target])
assert.equal(viewGame(game, id).mentat.history[0].card.id, weapon.id);
for (const id of [observer, 'shipper']) {
assert.deepEqual(viewGame(game, id).mentat.history, []);
assert.ok(!JSON.stringify(viewGame(game, id)).includes(weapon.id));
}
assert.ok(!JSON.stringify(game.log).includes(weapon.id));
rejectBetrayalMentat(game, target, show);
game = prepareBetrayalMentatPlans(game);
assert.equal(game.decision, null);
assert.equal(game.battle!.preparation, undefined);
assert.equal(game.battle!.leaderSkillHidden![owner], true);
assert.deepEqual(betrayalMentatResources(game), resources);
const targetSkill = game.leaderSkills!.assignments.find(a => a.owner === target)!.leader;
const targetLeader = betrayalMentatSeat(game, target).leaders.find(l =>
!l.dead && !l.capturedBy && l.id !== targetSkill)!;
game = applyAction(game, owner, { type: 'battlePlan', dial: 0, leader });
game = applyAction(game, target, { type: 'battlePlan', dial: 0, leader: targetLeader.id });
assert.equal(game.battle!.plans[owner].leader, leader);
assert.equal(game.battle!.plans[target].leader, targetLeader.id);
assert.equal(game.battle!.plans[target].weapon, null);
assert.equal(game.battle!.plans[target].defense, null);
assert.ok(betrayalMentatSeat(game, target).hand.some(card => card.id === weapon.id));
assert.deepEqual(betrayalMentatResources(game), resources);
});
void test('native Betrayal absent weapon offers only actual held alternatives and retains the freely shown defense', () => {
const fixture = createBetrayalMentatQuestionFixture();
const { owner, target, observer } = fixture;
const before = betrayalMentatResources(fixture.battle);
let game = applyAction(reloadBetrayalMentat(fixture.battle), owner,
{ type: 'decision', event: fixture.battle.battle!.mentatQuestionEvent, weapon: 'Lasgun' });
assert.equal(game.decision?.kind, 'mentatQuestion');
assert.equal(game.decision?.player, target);
const pending = viewGame(game, target).mentat.pending!;
assert.deepEqual(pending.cards, betrayalMentatSeat(game, target).hand);
const shown = pending.cards.find(card => card.name === 'Snooper')!;
rejectBetrayalMentat(game, observer, { type: 'decision', event: pending.event, card: shown.id });
rejectBetrayalMentat(game, target, { type: 'decision', event: pending.event, decline: true });
rejectBetrayalMentat(game, target, { type: 'decision', event: pending.event, card: 'not-held' });
game = applyAction(reloadBetrayalMentat(game), target,
{ type: 'decision', event: pending.event, card: shown.id });
assert.equal(viewGame(game, owner).mentat.history[0].card.id, shown.id);
assert.deepEqual(viewGame(game, observer).mentat.history, []);
assert.deepEqual(betrayalMentatResources(game), before);
game = prepareBetrayalMentatPlans(game);
const targetSkill = game.leaderSkills!.assignments.find(a => a.owner === target)!.leader;
const targetLeader = betrayalMentatSeat(game, target).leaders.find(l =>
!l.dead && !l.capturedBy && l.id !== targetSkill)!;
game = applyAction(game, owner, { type: 'battlePlan', dial: 0, leader: fixture.leader });
game = applyAction(game, target, { type: 'battlePlan', dial: 0, leader: targetLeader.id });
assert.equal(game.battle!.plans[target].defense, null);
assert.ok(betrayalMentatSeat(game, target).hand.some(card => card.id === shown.id));
assert.deepEqual(betrayalMentatResources(game), before);
});
void test('all four legal policies apply the composed private question and response through the original next battle preparation', () => {
const fixture = createBetrayalMentatQuestionFixture();
for (const difficulty of DIFFICULTIES) {
let game = reloadBetrayalMentat(fixture.battle);
const resources = betrayalMentatResources(game);
const own = viewGame(game, fixture.owner);
own.players.find(p => p.id === fixture.owner)!.bot = difficulty;
assert.deepEqual(botActions(viewGame(game, fixture.target)), []);
assert.deepEqual(botActions(viewGame(game, fixture.observer)), []);
const ask = botActions(own)[0];
assert.ok(own.mentat.pending!.weapons.includes(ask.weapon as string));
game = applyAction(game, fixture.owner, ask);
const target = viewGame(game, fixture.target);
target.players.find(p => p.id === fixture.target)!.bot = difficulty;
const show = botActions(target)[0];
assert.ok(target.mentat.pending!.cards.some(card => card.id === show.card));
game = applyAction(game, fixture.target, show);
assert.equal(viewGame(game, fixture.owner).mentat.history.length, 1);
assert.deepEqual(viewGame(game, fixture.observer).mentat.history, []);
assert.equal(game.decision?.kind, 'leaderSkillVisibility');
const actor = game.decision!.player;
const posture = viewGame(game, actor);
posture.players.find(p => p.id === actor)!.bot = difficulty;
game = applyAction(game, actor, botActions(posture)[0]);
game = prepareBetrayalMentatPlans(game);
assert.equal(game.decision, null);
assert.equal(game.battle!.preparation, undefined);
assert.deepEqual(betrayalMentatResources(game), resources);
}
});
+114
View File
@@ -0,0 +1,114 @@
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 { TERRITORIES, location, splitLocation } from '../game/board';
import { fighterCount } from '../game/advisors';
import {
quoteSandmasterMovement, sandmasterActionOrder, sandmasterDefaultChoice,
sandmasterEnteredTerritories, sandmasterShortestRoute, type SandmasterChoice,
} from '../game/sandmaster-movement';
import { createBetrayalSandmasterRoute, sandmasterTrainer } from './fixture-betrayal-sandmaster-route';
function unchanged(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('native Guild Betrayal Sandmaster debits its printed pile once and retains ordinary movement', () => {
const f = createBetrayalSandmasterRoute();
const before = sandmasterTrainer(f.game);
const assignment = f.game.leaderSkills!.assignments.find(skill => skill.owner === before.id)!;
assert.equal(assignment.skill, 'sandmaster');
assert.ok(before.leaders.some(leader => leader.id === assignment.leader && !leader.dead));
assert.ok(f.betrayalPasses > 0);
const quote = quoteSandmasterMovement(f.game, before.id, f.order, f.choice);
assert.equal(quote.leader, assignment.leader);
assert.deepEqual(quote.piles, [{ key: f.printedPile, before: f.printedAmount }]);
assert.deepEqual(sandmasterEnteredTerritories(quote.routes), [splitLocation(f.printedPile).territory]);
const ordinary = applyAction(f.game, before.id, f.ordinary);
const accepted = applyAction(JSON.parse(JSON.stringify(f.game)) as Game, before.id, f.action);
assert.equal(sandmasterTrainer(accepted).spice, before.spice + 1);
assert.deepEqual(accepted.spice, { ...f.game.spice, [f.printedPile]: f.printedAmount - 1 });
assert.deepEqual(sandmasterTrainer(accepted).forces, sandmasterTrainer(ordinary).forces);
assert.equal(sandmasterTrainer(accepted).forces[f.printedPile], 1);
assert.equal(sandmasterTrainer(accepted).reserves, before.reserves);
assert.equal(sandmasterTrainer(accepted).moved, before.moved + 1);
assert.equal(sandmasterTrainer(accepted).moved, sandmasterTrainer(ordinary).moved);
assert.equal(fighterCount(sandmasterTrainer(accepted), splitLocation(f.printedPile).territory), 1);
for (const player of f.game.players.filter(player => player.id !== before.id))
assert.equal(accepted.players.find(seat => seat.id === player.id)!.spice, player.spice);
unchanged(accepted, before.id, f.action);
const declined = applyAction(f.game, before.id, { ...f.ordinary, sandmaster: { ...f.choice, collect: [] } });
assert.deepEqual(declined.spice, ordinary.spice);
assert.equal(sandmasterTrainer(declined).spice, sandmasterTrainer(ordinary).spice);
assert.deepEqual(sandmasterTrainer(declined).forces, sandmasterTrainer(ordinary).forces);
assert.equal(sandmasterTrainer(declined).moved, sandmasterTrainer(ordinary).moved);
const continued = applyAction(accepted, before.id, { type: 'endMovement' });
const originalContinuation = applyAction(ordinary, before.id, { type: 'endMovement' });
assert.equal(continued.phase, originalContinuation.phase);
assert.equal(continued.active, originalContinuation.active);
// endMovement may advance straight through empty Battle to native Collection.
// Compare with the same original continuation, not with its pre-collection pile.
assert.equal(sandmasterTrainer(continued).spice, sandmasterTrainer(originalContinuation).spice + 1);
assert.equal(continued.spice[f.printedPile], originalContinuation.spice[f.printedPile] - 1);
});
void test('native Sandmaster rejects wrong actor, duplicate, invalid and abandoned requests immutably', () => {
const f = createBetrayalSandmasterRoute();
unchanged(f.game, 'holder', f.action);
unchanged(f.game, 'trainer', { ...f.action, sandmaster: { ...f.choice, collect: [f.printedPile, f.printedPile] } });
unchanged(f.game, 'trainer', { ...f.action, sandmaster: { ...f.choice, routes: {} } });
unchanged(f.game, 'trainer', { ...f.action, sandmaster: { ...f.choice, collect: ['polar_sink:0'] } });
const abandoned = applyAction(f.game, 'trainer', { type: 'endMovement' });
assert.equal(abandoned.spice[f.printedPile], f.printedAmount);
assert.equal(sandmasterTrainer(abandoned).spice, sandmasterTrainer(f.game).spice);
assert.deepEqual(sandmasterTrainer(abandoned).forces, sandmasterTrainer(f.game).forces);
unchanged(abandoned, 'trainer', f.action);
});
void test('a native empty destination offers no Sandmaster pile and keeps the free ground move unchanged', () => {
const f = createBetrayalSandmasterRoute();
const empty = TERRITORIES.flatMap(territory => territory.sectors.map(sector => location(territory.id, sector)))
.find(key => splitLocation(key).territory !== 'polar_sink' &&
!Object.entries(f.game.spice).some(([pile, amount]) => amount > 0 &&
splitLocation(pile).territory === splitLocation(key).territory) &&
sandmasterShortestRoute(f.game, 'trainer', 'polar_sink:0', key, 1));
assert.ok(empty);
const destination = splitLocation(empty);
const action: Action = { ...f.ordinary, territory: destination.territory, sector: destination.sector };
const choice = sandmasterDefaultChoice(viewGame(f.game, 'trainer'), 'trainer', action);
assert.ok(choice);
assert.deepEqual(choice.collect, []);
const order = sandmasterActionOrder(viewGame(f.game, 'trainer'), 'trainer', action);
assert.ok(order);
assert.deepEqual(quoteSandmasterMovement(f.game, 'trainer', order, choice).piles, []);
const next = applyAction(f.game, 'trainer', { ...action, sandmaster: choice });
assert.deepEqual(next.spice, f.game.spice);
assert.equal(sandmasterTrainer(next).spice, sandmasterTrainer(f.game).spice);
assert.equal(sandmasterTrainer(next).forces[empty], 1);
assert.equal(sandmasterTrainer(next).moved, sandmasterTrainer(f.game).moved + 1);
unchanged(f.game, 'trainer', { ...action, sandmaster: { ...choice, collect: [empty] } });
});
void test('all four minimal bot policies propose an actual native collecting Sandmaster move', () => {
const f = createBetrayalSandmasterRoute();
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(f.game, 'trainer');
view.players.find(player => player.id === 'trainer')!.autopilot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'move' && candidate.sandmaster);
assert.ok(action, difficulty);
const choice = action.sandmaster as SandmasterChoice;
assert.ok(choice.collect.length > 0, difficulty);
const order = sandmasterActionOrder(view, 'trainer', action);
assert.ok(order, difficulty);
const quote = quoteSandmasterMovement(f.game, 'trainer', order, choice);
const next = applyAction(f.game, 'trainer', action);
assert.equal(sandmasterTrainer(next).spice, sandmasterTrainer(f.game).spice + quote.collect.length, difficulty);
for (const pile of quote.piles) assert.equal(next.spice[pile.key], pile.before - 1, difficulty);
assert.equal(sandmasterTrainer(next).moved, sandmasterTrainer(f.game).moved + 1, difficulty);
}
});
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { splitLocation } from '../game/board';
import { drawNexusCard, validateNexusCards } from '../game/nexus-cards';
import { createGuildBetrayalSkillsSetup } from './fixture-bureaucrat-betrayal-funding';
export const betrayalMentatSeat = (game: Game, id: string) => game.players.find(p => p.id === id)!;
export const reloadBetrayalMentat = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
/** Native obligations only; pass ordinary bidding so original starting wallets
* remain available for the paid trainer deployment. No optional card is played. */
export function stepBetrayalMentat(game: Game): Game {
if (game.pendingGuildBetrayal) {
const frame = game.pendingGuildBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id))!;
return applyAction(game, actor, { type: 'guildBetrayalPass', event: frame.invoice.event });
}
if (game.decision?.kind === 'guildShipment')
return applyAction(game, game.decision.player, { type: 'decision', allow: true });
if (game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
return applyAction(game, game.decision.player, { type: 'decision', accept: false });
if (game.response) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id))!.id;
return applyAction(game, actor, { type: 'passResponse' });
}
if (game.status === 'playing' && game.phase === 0 && !game.phaseOpening &&
!game.decision && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return applyAction(game, actor, { type: 'stormDial', amount: 0 });
}
if (game.auction && !game.decision && !game.phaseOpening)
return applyAction(game, game.auction.active!, { type: 'passBid' });
if (game.status === 'setup' && game.setupStage === 'traitors') {
const player = game.players.find(p => p.traitorChoices.length)!;
return applyAction(game, player.id, { type: 'traitor', leader: player.traitorChoices[0] });
}
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) return applyAction(game, player.id, action);
}
throw Error(`Native Mentat continuation stalled at ${game.status}/${game.phase}/${game.decision?.kind}`);
}
export function betrayalMentatResources(game: Game) {
return structuredClone({ players: game.players, deck: game.deck, discard: game.discard,
nexus: game.nexusCards?.cards, betrayal: game.guildBetrayal, pending: game.pendingGuildBetrayal });
}
export interface BetrayalMentatFixture {
initial: Game;
beforeShipment: Game;
shipment: Action;
paid: Game;
battle: Game;
owner: string;
target: string;
observer: string;
leader: string;
cardCustody: { card: string; from: string; to: string }[];
shipmentGateEvents: string[];
}
/** Actual all14 assignment, original traitors/cards/storm and native readiness.
* Only original unplayed Treachery custody is controlled, with each exchange
* recorded. Armies, wallets, order, phases and battle plans are never assigned. */
export function createBetrayalMentatQuestionFixture(): BetrayalMentatFixture {
const setup = createGuildBetrayalSkillsSetup(false, false, 'mentat');
let game = setup.trained;
for (let n = 0; n < 300 && !(game.status === 'playing' && game.phase === 5 &&
!game.phaseOpening && !game.response && !game.decision); n++) game = stepBetrayalMentat(game);
assert.equal(game.phase, 5);
assert.equal(game.turn, 1);
const owner = setup.trainer, target = 'holder', observer = 'guild';
const holder = betrayalMentatSeat(game, target);
const cardCustody: BetrayalMentatFixture['cardCustody'] = [];
// Arrange the original singleton Guild Nexus in its real non-Guild holder.
// Existing held faces are exchanged, not copied or newly invented.
let nexus = game.nexusCards!.cards!;
const former = nexus.hands[target];
const heldBy = Object.keys(nexus.hands).find(id => nexus.hands[id] === 'guild');
if (heldBy && heldBy !== target) {
nexus.hands[heldBy] = former;
nexus.hands[target] = 'guild';
cardCustody.push({ card: 'nexus:guild', from: heldBy, to: target });
} else if (!heldBy) {
if (former !== null) { nexus.deck.push(former); nexus.hands[target] = null; }
const index = nexus.deck.indexOf('guild');
assert.ok(index >= 0, 'Original unplayed Guild Nexus remains in its deck.');
nexus.deck.unshift(nexus.deck.splice(index, 1)[0]);
nexus = drawNexusCard(nexus, target, game.players, () => 0);
cardCustody.push({ card: 'nexus:guild', from: 'nexusDeck', to: target });
}
game.nexusCards!.cards = nexus;
validateNexusCards(nexus, game.players);
const shipper = betrayalMentatSeat(game, 'shipper');
assert.ok(shipper.hand.length, 'Native Emperor starting card remains unplayed.');
const extra = shipper.hand.shift()!;
holder.hand.push(extra);
cardCustody.push({ card: extra.id, from: 'shipper', to: target });
const inventory = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)]
.map(card => card.id).sort();
// The two target slots contain original physical Crysknife and Snooper.
// Exchange only unplayed cards, including any already dealt desired card.
for (const [slot, name] of ['Crysknife', 'Snooper'].entries()) {
if (holder.hand[slot].name === name) continue;
const pools = [{ id: 'deck', cards: game.deck },
...game.players.map(p => ({ id: p.id, cards: p.hand }))];
const pool = pools.find(p => p.cards.some(card => card.name === name))!;
assert.ok(pool, `Original unplayed ${name} must still exist.`);
const index = pool.cards.findIndex(card => card.name === name);
const original = holder.hand[slot], selected = pool.cards[index];
pool.cards[index] = original;
holder.hand[slot] = selected;
cardCustody.push({ card: original.id, from: target, to: pool.id },
{ card: selected.id, from: pool.id, to: target });
}
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)]
.map(card => card.id).sort(), inventory);
const source = Object.entries(holder.forces).find(([key, amount]) =>
amount > 0 && splitLocation(key).sector !== game.storm)!;
assert.ok(source, 'The original Atreides army has a storm-safe occupied location.');
const { territory, sector } = splitLocation(source[0]);
while (game.active !== owner) game = applyAction(game, game.active!, { type: 'endMovement' });
const beforeShipment = structuredClone(game);
const shipment: Action = { type: 'ship', territory, sector, amount: 2, allyPayment: 0 };
game = applyAction(game, owner, shipment);
const shipmentGateEvents: string[] = [];
for (let n = 0; n < 100 && (game.pendingGuildBetrayal || game.response || game.decision); n++) {
if (game.pendingGuildBetrayal) shipmentGateEvents.push(game.pendingGuildBetrayal.invoice.event);
game = stepBetrayalMentat(game);
}
const paid = structuredClone(game);
assert.equal(betrayalMentatSeat(paid, owner).forces[source[0]], 2);
assert.equal(betrayalMentatSeat(paid, owner).spice,
betrayalMentatSeat(beforeShipment, owner).spice - 2);
assert.ok(shipmentGateEvents.length, 'Ordinary Mentat must not suppress the original Betrayal gate.');
while (game.phase === 5 && !game.phaseOpening && !game.decision && !game.response)
game = applyAction(game, game.active!, { type: 'endMovement' });
for (let n = 0; n < 100 && (game.phaseOpening || game.response || game.decision); n++)
game = stepBetrayalMentat(game);
assert.equal(game.phase, 6);
assert.ok(game.active === owner || game.active === target);
game = applyAction(game, game.active!, { type: 'chooseBattle', territory,
target: game.active === owner ? target : owner });
const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!;
assert.equal(assignment.skill, 'mentat');
assert.equal(betrayalMentatSeat(game, owner).leaders.find(l => l.id === assignment.leader)!.dead, false);
return { initial: setup.initial, beforeShipment, shipment, paid, battle: game,
owner, target, observer, leader: assignment.leader, cardCustody, shipmentGateEvents };
}
/** Finish original posture/power steps, without convenience plan assignments. */
export function prepareBetrayalMentatPlans(state: Game): Game {
let game = state;
for (let n = 0; n < 60; n++) {
if (game.decision?.kind === 'leaderSkillVisibility')
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility',
event: game.decision.event, hide: game.decision.player === 'trainer' });
else if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
else if (game.battle?.preparation)
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
else if (game.response) game = stepBetrayalMentat(game);
else return game;
}
throw Error('Original Mentat preparation did not complete.');
}
+124
View File
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { botEntryAllowed } from '../game/bot-mobility';
import { TERRITORIES, location, splitLocation } from '../game/board';
import { drawNexusCard } from '../game/nexus-cards';
import {
sandmasterActionOrder, sandmasterShortestRoute, type SandmasterChoice,
} from '../game/sandmaster-movement';
import { createGuildBetrayalSkillsSetup } from './fixture-bureaucrat-betrayal-funding';
export const sandmasterTrainer = (game: Game) => game.players.find(player => player.id === 'trainer')!;
/** Only unplayed physical card ordering/custody is controlled. All setup, piles,
* counters, wallets, movement budgets and Guild invoices use native actions. */
export function createBetrayalSandmasterRoute() {
const setup = createGuildBetrayalSkillsSetup(false, false, 'sandmaster');
let game = setup.trained;
const inventory = game.nexusCards!.cards!;
assert.ok(Object.values(inventory.hands).every(card => card === null));
const guildIndex = inventory.deck.indexOf('guild');
assert.ok(guildIndex >= 0);
inventory.deck.unshift(inventory.deck.splice(guildIndex, 1)[0]);
const controlledNexusOrder = [...inventory.deck];
// Preserve the original singleton inventory, arranging its unplayed custody
// before any shipment, exactly as the original Guild Betrayal fixture does.
game.nexusCards!.cards = drawNexusCard(inventory, 'holder', game.players, () => 0);
let orderedSpice = false;
let printedPile = '';
let printedAmount = 0;
let controlledSpiceOrder: typeof game.spiceDeck = [];
let betrayalPasses = 0;
const step = (state: Game): Game => {
if (state.pendingGuildBetrayal) {
const frame = state.pendingGuildBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id))!;
betrayalPasses++;
return applyAction(state, actor, { type: 'guildBetrayalPass', event: frame.invoice.event });
}
if (state.response) {
const actor = state.players.find(player => !state.response!.passed.includes(player.id))!.id;
return applyAction(state, actor, { type: 'passResponse' });
}
if (state.decision?.kind === 'guildShipment')
return applyAction(state, state.decision.player, { type: 'decision', allow: true });
if (state.decision?.kind === 'advisor')
return applyAction(state, state.decision.player, { type: 'decision', accept: false });
for (const player of state.players) {
const view = viewGame(state, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const proposed = botActions(view)[0];
if (!proposed) continue;
const action: Action = proposed.type === 'stormDial' ? { ...proposed, amount: 0 } :
proposed.type === 'bid' ? { type: 'passBid' } : proposed;
return applyAction(state, player.id, action);
}
throw new Error(`Native Sandmaster fixture stalled at ${state.status}/${state.phase}/${state.decision?.kind}`);
};
for (let steps = 0; steps < 700; steps++) {
if (game.status === 'playing' && game.phase === 1 && !orderedSpice) {
assert.equal(game.spiceDiscard.flat().length, 0);
const index = game.spiceDeck.findIndex(card => 'territory' in card &&
sandmasterShortestRoute(game, 'trainer', 'polar_sink:0', location(card.territory, card.sector), 1));
assert.ok(index >= 0, 'A printed first blow is adjacent to the original BG starting counter.');
const card = game.spiceDeck.splice(index, 1)[0];
assert.ok('territory' in card);
printedPile = location(card.territory, card.sector);
printedAmount = card.amount;
const secondIndex = game.spiceDeck.findIndex(other => 'territory' in other);
assert.ok(secondIndex >= 0);
const second = game.spiceDeck.splice(secondIndex, 1)[0];
game.spiceDeck.unshift(card, second);
controlledSpiceOrder = structuredClone(game.spiceDeck);
orderedSpice = true;
}
if (game.status === 'playing' && game.phase === 5 && !game.phaseOpening &&
!game.response && !game.decision && !game.pendingGuildBetrayal) break;
game = step(game);
}
assert.equal(game.phase, 5);
assert.ok(!game.phaseOpening);
assert.ok(orderedSpice);
assert.equal(game.spice[printedPile], printedAmount, 'The original printed blow created the pile.');
assert.equal(game.nexusCards!.cards!.hands.holder, 'guild', 'The conserved physical Guild face remains in recorded custody.');
let shipped = false;
for (let steps = 0; steps < 100 && game.active !== 'trainer'; steps++) {
if (game.pendingGuildBetrayal || game.response || game.decision) {
game = step(game);
continue;
}
const actor = game.active!;
if (!shipped && actor !== 'guild') {
const view = viewGame(game, actor);
const payer = view.players.find(player => player.id === actor)!;
const destination = TERRITORIES.filter(t => t.type === 'stronghold').flatMap(t =>
t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
botEntryAllowed(view, payer, to.territory, to.sector, 'ship'));
assert.ok(destination);
game = applyAction(game, actor, { type: 'ship', ...destination, amount: 1 });
assert.ok(game.pendingGuildBetrayal, 'An unrelated ordinary shipment retains the original Betrayal gate.');
shipped = true;
} else game = applyAction(game, actor, { type: 'endMovement' });
}
while (game.pendingGuildBetrayal || game.response || game.decision) game = step(game);
assert.equal(game.active, 'trainer');
assert.ok(betrayalPasses > 0);
const trainer = sandmasterTrainer(game);
assert.equal(trainer.forces['polar_sink:0'], 1, 'The real original starting counter remains on the board.');
assert.equal(trainer.reserves, 19);
assert.equal(trainer.advisors?.polar_sink, undefined, 'Basic BG starting counter is a fighter.');
const at = splitLocation(printedPile);
const ordinary: Action = { type: 'move', from: 'polar_sink:0', amount: 1, territory: at.territory, sector: at.sector };
const order = sandmasterActionOrder(viewGame(game, 'trainer'), 'trainer', ordinary);
assert.ok(order);
const route = sandmasterShortestRoute(game, 'trainer', 'polar_sink:0', printedPile, order.range);
assert.ok(route);
const choice: SandmasterChoice = { routes: { 'polar_sink:0': route }, collect: [printedPile] };
const action: Action = { ...ordinary, sandmaster: choice };
return { game, ordinary, action, order, choice, printedPile, printedAmount,
betrayalPasses, controlledNexusOrder, controlledSpiceOrder };
}
+5 -4
View File
@@ -39,12 +39,13 @@ function skillLottery<T>(selected: LeaderSkillId[], operation: () => T): T {
}
}
function trainedSetup(advanced: boolean, allied: boolean): { initial: Game; trained: Game; trainer: string } {
export function createGuildBetrayalSkillsSetup(advanced = false, allied = false,
trainerSkill: LeaderSkillId = 'bureaucrat'): { initial: Game; trained: Game; trainer: string } {
const roster: [string, FactionId, LeaderSkillId][] = [
['holder', 'atreides', 'warmaster'], ['guild', 'guild', 'killer-medic'],
['shipper', 'emperor', 'master-of-assassins'],
...(allied ? [['donor', 'harkonnen', 'swordmaster-of-ginaz'] as [string, FactionId, LeaderSkillId]] : []),
['trainer', 'beneGesserit', 'bureaucrat'],
['trainer', 'beneGesserit', trainerSkill],
];
let game = createGame('BUREAUCRATBETRAYAL', newPlayer(roster[0][0], roster[0][0], roster[0][1]), advanced, []);
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction));
@@ -66,7 +67,7 @@ function trainedSetup(advanced: boolean, allied: boolean): { initial: Game; trai
}
assert.equal(game.status, 'setup', 'The shared fixture owns the remaining native setup.');
validateLeaderSkills(game.leaderSkills!, game.players);
assert.equal(game.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, 'trainer');
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 'trainer')?.skill, trainerSkill);
return { initial, trained: game, trainer: 'trainer' };
}
@@ -114,7 +115,7 @@ export interface BureaucratBetrayalFundingFixture extends GuildBetrayalFixture {
export function createBureaucratBetrayalFundingFixture({
advanced = false, primary = 5, donorAmount = 0, own = false, alreadyUsed = false,
}: { advanced?: boolean; primary?: number; donorAmount?: number; own?: boolean; alreadyUsed?: boolean } = {}): BureaucratBetrayalFundingFixture {
const setup = trainedSetup(advanced, donorAmount > 0);
const setup = createGuildBetrayalSkillsSetup(advanced, donorAmount > 0);
const base = createGuildBetrayalFixture({ initial: setup.trained, advanced,
payer: own ? 'holder' : 'other', allyPayment: donorAmount > 0 ? 1 : 0 });
let game = structuredClone(base.before);