Permit native Advanced quiet any-player declarations alongside the original target-bound pending request. Preserve completed returns and charges. Adopt explicit provisional Ghola inclusion during the remaining Revival and phase expiry; keep printed Emperor special immunity and the existing low-Homeworld guard. Connect persistent controls and minimal legal bots through the shared quote. Validation: 14 related files148/148, types/lint/build, six actual native control/engine/GameTable SSR journeys and four existing-profile continuations. Private-driver-only feedback reuses passed source-identical checks. Native111/113-action setup, paid shipment and battle sources earn originals. No saved-room/D1/restart/deployment operation; full modes and browser/deployed acceptance remain open.
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type GameView,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import { baseDeck } from '../game/cards';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { createDukeVidal } from '../game/duke-vidal';
|
|
|
|
function fixture(faction: FactionId = 'atreides', advanced = false) {
|
|
const g = createGame(
|
|
'GHOLABOTS',
|
|
newPlayer('p', 'Player', faction),
|
|
advanced,
|
|
);
|
|
g.players.push(
|
|
newPlayer('q', 'Guild', 'guild'),
|
|
newPlayer('r', 'Harkonnen', 'harkonnen'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'p',
|
|
order: ['p', 'q', 'r'],
|
|
movementRemaining: ['p', 'q', 'r'],
|
|
deck: baseDeck(),
|
|
});
|
|
for (const p of g.players)
|
|
Object.assign(p, {
|
|
hand: [],
|
|
forces: {},
|
|
reserves: 20,
|
|
spice: 20,
|
|
traitors: [],
|
|
traitorChoices: [],
|
|
});
|
|
const at = g.deck.findIndex((c) => c.effect === 'ghola');
|
|
const card = g.deck.splice(at, 1)[0];
|
|
g.players[0].hand.push(card);
|
|
return { g, card };
|
|
}
|
|
function casualties(g: Game, tanks = 5, reserves = 0) {
|
|
Object.assign(g.players[0], {
|
|
tanks,
|
|
reserves,
|
|
forces: { 'red_chasm:7': 20 - tanks - reserves },
|
|
});
|
|
}
|
|
function view(g: Game, difficulty: Difficulty) {
|
|
const v = viewGame(g, 'p');
|
|
v.players[0].bot = difficulty;
|
|
return v;
|
|
}
|
|
function selected(g: Game, difficulty: Difficulty) {
|
|
const v = view(g, difficulty),
|
|
before = structuredClone(v);
|
|
const actions = botActions(v);
|
|
assert.deepEqual(v, before);
|
|
assert.ok(actions.length);
|
|
return actions[0];
|
|
}
|
|
function apply(g: Game, difficulty: Difficulty) {
|
|
const a = selected(g, difficulty),
|
|
before = structuredClone(g);
|
|
const done = applyAction(g, 'p', a);
|
|
assert.deepEqual(g, before);
|
|
return { a, done };
|
|
}
|
|
for (const level of DIFFICULTIES) {
|
|
void test(`${level} revives a useful own leader with Ghola and clears the earlier battle location`, () => {
|
|
const { g, card } = fixture();
|
|
for (const leader of g.players[0].leaders)
|
|
Object.assign(leader, { dead: true, deaths: 1, usedAt: 'carthag' });
|
|
const wanted = [...view(g, level).ghola.leaders].sort(
|
|
(a, b) => b.strength - a.strength || a.id.localeCompare(b.id),
|
|
)[0];
|
|
const { a, done } = apply(g, level);
|
|
assert.deepEqual(a, { type: 'card', card: card.id, leader: wanted.id });
|
|
const revived = done.players[0].leaders.find((l) => l.id === wanted.id)!;
|
|
assert.equal(revived.dead, false);
|
|
assert.equal(revived.usedAt, undefined);
|
|
assert.equal(revived.deaths, 1);
|
|
assert.equal(done.players[0].leaderRevived, g.players[0].leaderRevived);
|
|
assert.equal(done.players[0].spice, g.players[0].spice);
|
|
assert.equal(done.discard.filter((c) => c.id === card.id).length, 1);
|
|
});
|
|
void test(`${level} revives five needed ordinary forces without using normal revival allowance`, () => {
|
|
const { g, card } = fixture();
|
|
casualties(g);
|
|
const { a, done } = apply(g, level);
|
|
assert.deepEqual(a, { type: 'card', card: card.id, amount: 5, elite: 0 });
|
|
assert.equal(done.players[0].tanks, 0);
|
|
assert.equal(done.players[0].reserves, 5);
|
|
assert.equal(done.players[0].revived, g.players[0].revived);
|
|
assert.equal(
|
|
done.players[0].freeForcesRevived,
|
|
g.players[0].freeForcesRevived,
|
|
);
|
|
assert.equal(done.players[0].spice, g.players[0].spice);
|
|
});
|
|
void test(`${level} uses the exact feasible mixed group when elite allowance prevents five returns`, () => {
|
|
const { g, card } = fixture('emperor', true);
|
|
casualties(g);
|
|
g.players[0].elites = {
|
|
tanks: 3,
|
|
reserves: 0,
|
|
revived: 0,
|
|
forces: { 'red_chasm:7': 2 },
|
|
};
|
|
assert.equal(view(g, level).ghola.maxForces, 3);
|
|
const { a, done } = apply(g, level);
|
|
assert.deepEqual(a, { type: 'card', card: card.id, amount: 3, elite: 1 });
|
|
assert.equal(done.players[0].elites!.revived, 1);
|
|
assert.equal(done.players[0].elites!.tanks, 2);
|
|
assert.equal(done.players[0].reserves, 3);
|
|
});
|
|
void test(`${level} can choose dead Kwisatz Haderach using the same projected target`, () => {
|
|
const { g, card } = fixture('atreides', true);
|
|
g.players[0].kwisatz = {
|
|
dead: true,
|
|
available: true,
|
|
losses: 7,
|
|
usedAt: 'carthag',
|
|
} as NonNullable<Game['players'][number]['kwisatz']>;
|
|
const { a, done } = apply(g, level);
|
|
assert.deepEqual(a, { type: 'card', card: card.id, leader: 'kwisatz' });
|
|
assert.equal(done.players[0].kwisatz!.dead, false);
|
|
assert.equal(done.players[0].kwisatz!.usedAt, undefined);
|
|
});
|
|
}
|
|
void test('ordinary revival actions retain priority over a discretionary Ghola at every level', () => {
|
|
const { g } = fixture();
|
|
g.phase = 4;
|
|
g.active = null;
|
|
casualties(g);
|
|
for (const level of DIFFICULTIES) {
|
|
const a = selected(g, level);
|
|
assert.equal(a.type, 'revive');
|
|
assert.ok(applyAction(g, 'p', a));
|
|
}
|
|
});
|
|
void test('all profiles retain Ixian multi-cyborg Ghola revival rather than applying the base one-elite limit', () => {
|
|
for (const level of DIFFICULTIES) {
|
|
const { g, card } = fixture('ixians', true);
|
|
casualties(g);
|
|
g.players[0].elites = {
|
|
tanks: 3,
|
|
reserves: 0,
|
|
revived: 0,
|
|
forces: { 'red_chasm:7': 1 },
|
|
};
|
|
const { a, done } = apply(g, level);
|
|
assert.deepEqual(a, { type: 'card', card: card.id, amount: 5, elite: 3 });
|
|
assert.equal(done.players[0].elites!.tanks, 0);
|
|
assert.equal(done.players[0].elites!.reserves, 3);
|
|
}
|
|
});
|
|
void test('a real pre-leader combat opportunity keeps priority over stand-alone force recovery', () => {
|
|
const { g, card } = fixture('emperor', true);
|
|
casualties(g);
|
|
g.phase = 6;
|
|
g.players[2] = newPlayer('r', 'Richese', 'richese');
|
|
g.players[1].forces = { 'red_chasm:7': 2 };
|
|
g.players[1].reserves = 18;
|
|
const battle = applyAction(g, 'p', {
|
|
type: 'chooseBattle',
|
|
territory: 'red_chasm',
|
|
target: 'q',
|
|
});
|
|
assert.ok(battle.battle?.preLeader && !battle.battle.preLeader.closed);
|
|
for (const level of DIFFICULTIES) {
|
|
const a = selected(battle, level);
|
|
assert.equal(a.type, 'battlePreparationReady');
|
|
assert.notEqual(a.card, card.id);
|
|
assert.ok(applyAction(battle, 'p', a));
|
|
}
|
|
});
|
|
void test('foreign-ghola and negotiated early-revival bot candidates explicitly exclude a projected Duke', () => {
|
|
// Projection-only legacy boundary: these controls must not infer foreign or
|
|
// negotiated permission merely because a dead shared disc appears in a roster.
|
|
for (const level of DIFFICULTIES) {
|
|
const foreign = fixture('tleilaxu', true).g;
|
|
foreign.phase = 4;
|
|
foreign.active = null;
|
|
foreign.players[0].leaders[0].dead = true;
|
|
const fv = view(foreign, level);
|
|
const deadDuke = {
|
|
...createDukeVidal().leader,
|
|
dead: true,
|
|
deaths: 1,
|
|
controller: 'q',
|
|
};
|
|
fv.players[1].leaders.push(deadDuke);
|
|
assert.ok(
|
|
botActions(fv).every(
|
|
(a) => a.type !== 'reviveForeignGhola' || a.leader !== 'duke-vidal',
|
|
),
|
|
);
|
|
|
|
const own = fixture('ecaz').g;
|
|
own.phase = 4;
|
|
own.active = null;
|
|
own.players[1] = newPlayer('q', 'Tleilaxu', 'tleilaxu');
|
|
own.players[0].leaders.slice(0, 3).forEach((l) => {
|
|
l.dead = true;
|
|
l.deaths = 1;
|
|
});
|
|
const ov = view(own, level);
|
|
ov.players[0].leaders.push({ ...deadDuke, controller: 'p' });
|
|
assert.ok(
|
|
botActions(ov).every(
|
|
(a) => a.type !== 'requestLeaderRevival' || a.leader !== 'duke-vidal',
|
|
),
|
|
);
|
|
}
|
|
});
|
|
void test('bots respect Ghola prevention during Revival and can use it after phase expiry', () => {
|
|
const { g, card } = fixture();
|
|
g.phase = 4;
|
|
g.active = null;
|
|
casualties(g);
|
|
g.revivalPrevention = { player: 'p', turn: g.turn };
|
|
for (const level of DIFFICULTIES) {
|
|
assert.equal(view(g, level).revival.prevented, true);
|
|
assert.equal(view(g, level).ghola.available, false);
|
|
const blocked = apply(g, level);
|
|
assert.notEqual(blocked.a.card, card.id);
|
|
assert.ok(blocked.done.players[0].hand.some(c => c.id === card.id));
|
|
const expired = structuredClone(g);
|
|
expired.phase = 5;
|
|
expired.active = 'p';
|
|
const { a, done } = apply(expired, level);
|
|
assert.equal(a.card, card.id);
|
|
assert.equal(done.players[0].reserves, 5);
|
|
}
|
|
});
|
|
void test('open collection and Mentat windows can use a needed Ghola before readiness', () => {
|
|
for (const phase of [7, 8])
|
|
for (const level of DIFFICULTIES) {
|
|
const { g, card } = fixture();
|
|
g.phase = phase;
|
|
g.active = null;
|
|
casualties(g);
|
|
assert.equal(apply(g, level).a.card, card.id);
|
|
}
|
|
});
|
|
void test('missing, blocked, empty and withheld target projections do not produce guessed actions', () => {
|
|
const { g, card } = fixture();
|
|
casualties(g);
|
|
for (const level of DIFFICULTIES) {
|
|
for (const alter of [
|
|
(v: GameView) => {
|
|
v.ghola.available = false;
|
|
},
|
|
(v: GameView) => {
|
|
v.ghola.cards = [];
|
|
},
|
|
(v: GameView) => {
|
|
v.ghola.maxForces = 0;
|
|
v.ghola.kwisatz = false;
|
|
v.ghola.leaders = [];
|
|
},
|
|
]) {
|
|
const v = view(g, level);
|
|
alter(v);
|
|
assert.ok(botActions(v).every((a) => a.card !== card.id));
|
|
}
|
|
}
|
|
});
|
|
void test('specific pending decisions, response windows, and automatic continuations retain priority', () => {
|
|
const { g, card } = fixture();
|
|
casualties(g);
|
|
for (const level of DIFFICULTIES) {
|
|
const pending = view(g, level);
|
|
pending.decision = { kind: 'advisor', player: 'p' } as NonNullable<
|
|
GameView['decision']
|
|
>;
|
|
assert.equal(botActions(pending)[0].type, 'decision');
|
|
const response = view(g, level);
|
|
response.response = {
|
|
kind: 'voice',
|
|
owner: 'q',
|
|
passed: [],
|
|
} as NonNullable<GameView['response']>;
|
|
assert.ok(botActions(response).every((a) => a.card !== card.id));
|
|
const continuation = view(g, level);
|
|
continuation.automaticContinuationPending = true;
|
|
assert.deepEqual(botActions(continuation), []);
|
|
}
|
|
});
|
|
void test('stand-alone policy does not precede a positive shipment preparation witness', () => {
|
|
const { g, card } = fixture();
|
|
casualties(g);
|
|
for (const level of DIFFICULTIES) {
|
|
const v = view(g, level);
|
|
const witness = { type: 'pledgeAid', amount: 0 };
|
|
// Projection-only priority test; constructing a promise is covered by the
|
|
// genuine shipment-promise suite, not this policy fixture.
|
|
v.shipmentCompletion = { actions: [witness] } as NonNullable<
|
|
GameView['shipmentCompletion']
|
|
>;
|
|
assert.ok(botActions(v).every((a) => a.card !== card.id));
|
|
}
|
|
});
|
|
void test('the policy uses only own projected targets and is invariant to opponent private resources and identities', () => {
|
|
const { g } = fixture();
|
|
casualties(g);
|
|
const changed = structuredClone(g);
|
|
changed.players[1].hand.push(changed.deck.shift()!);
|
|
changed.players[1].spice = 1;
|
|
changed.players[1].traitors = [changed.players[2].leaders[0].id];
|
|
for (const level of DIFFICULTIES) {
|
|
assert.deepEqual(selected(g, level), selected(changed, level));
|
|
const v = view(g, level);
|
|
Object.defineProperty(v, 'dukeVidal', {
|
|
get() {
|
|
throw Error('Raw shared custody read');
|
|
},
|
|
});
|
|
assert.equal(botActions(v)[0].type, 'card');
|
|
}
|
|
});
|