Restore ordinary Skills in bounded Guild Betrayal composition
This commit is contained in:
1 parent
9fb680da09
commit
ea12a68e56
12 files changed
+637
-11
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user