Files
Dune/tests/autopilot.test.ts
Quintine 0f79f28ccb Complete forced Harkonnen returns with private saved inspection
Automatically return the full combined hand when it is the only legal
special-Karama return. Preserve real choices and restore the suspended
response or auction payment through the same settlement. Record the actual
blind draw as owner-only historical card faces, with a bound integrity
receipt; legacy forced saves describe held-card history truthfully.

Add readable private history and enlarged inspectors. Both manual and
forced returns publish only a faction/action completion tag. Wake idle
legacy exchanges through production recovery without writes for real
choices. Keep provisional auction funding rules and release gates.

Validation: types, lint, 5150 offline tests, build and 49 HTTP tests
passed. Independent review resolved history substitution and root review
completed idle recovery wiring. New tests cover 1-4 forced draws, real
choices, overlays, exact saved history, immutable rejection, all four legal
AI paths, production competing actions/recovery, private views and custody.
One genuine Advanced four-player sample completed 158 actions with
4 JSON restores and no rejected action; no difficulty tuning.

Browser QA QUTHVPTT used genuine Advanced setup and conserved Bidding
positions. The actual power returned four cards without a prompt; private
history/enlargement and exact refresh worked. Phone panel fit without
horizontal overflow with 44px inspector controls. Screenshot capture timed
out; complete visual acceptance and transient notice capture remain open.
Preserved every prior row across all ten tables, including 1586
rooms and 3354 seats. New QA/integration records bring totals to
1626 rooms and 3442 seats. No reset, restart or automation.
2026-09-21 16:45:20 +10:00

224 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import { baseDeck } from '../game/cards';
function lobby() {
const g = createGame('AUTOPIL', newPlayer('a', 'Owner', 'atreides'));
joinGame(g, newPlayer('e', 'Guest', 'emperor'));
return g;
}
function playing() {
const g = lobby();
g.status = 'playing';
g.phase = 4;
g.order = ['a', 'e'];
g.players.forEach((p) => {
p.traitors = [p.leaders[0].id];
p.traitorChoices = [];
p.hand = [];
});
return applyAction(g, 'a', { type: 'advanceBots' });
}
function control(g: Game, difficulty: Difficulty | null, id = 'a') {
return applyAction(g, id, { type: 'setAutopilot', difficulty });
}
function gameplay(g: Game) {
const copy = structuredClone(g);
copy.log = [];
for (const p of copy.players) delete p.autopilot;
return copy;
}
function rejectsUnchanged(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
void test('human owners can enable, change and disable all policies without becoming native bots', () => {
let g = playing();
const original = structuredClone(g);
for (const difficulty of DIFFICULTIES) {
const before = structuredClone(g);
g = control(g, difficulty);
assert.deepEqual(gameplay(g), gameplay(before));
assert.equal(g.players[0].autopilot, difficulty);
assert.equal(g.players[0].bot, undefined);
assert.equal(g.players[1].autopilot, undefined);
assert.ok(g.log.length > before.log.length);
}
g = control(g, null);
assert.equal(g.players[0].autopilot, undefined);
assert.deepEqual(gameplay(g), gameplay(original));
assert.equal(original.players[0].autopilot, undefined);
g = control(g, 'Medium', 'e');
assert.equal(g.players[1].autopilot, 'Medium');
assert.equal(g.players[0].autopilot, undefined);
assert.deepEqual(control(g, 'Medium', 'e'), g);
assert.deepEqual(gameplay(g), gameplay(original));
});
void test('control rejects other-seat targets, invalid difficulty, native bots and inactive games atomically', () => {
const g = playing();
for (const target of ['e', 'a', null])
rejectsUnchanged(g, 'a', {
type: 'setAutopilot',
target,
difficulty: 'Hard',
});
for (const difficulty of ['Cheat', 'hard', '', 1, undefined])
rejectsUnchanged(g, 'a', { type: 'setAutopilot', difficulty });
rejectsUnchanged(g, 'absent', { type: 'setAutopilot', difficulty: 'Hard' });
for (const status of ['lobby', 'finished'] as const) {
const inactive = structuredClone(g);
inactive.status = status;
rejectsUnchanged(inactive, 'a', {
type: 'setAutopilot',
difficulty: 'Hard',
});
}
g.players[0].bot = 'Easy';
for (const difficulty of ['Hard', null])
rejectsUnchanged(g, 'a', { type: 'setAutopilot', difficulty });
});
void test('setup autopilot selects only its own traitor, then stops for the remaining human', () => {
let g = lobby();
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
const guestChoices = structuredClone(g.players[1].traitorChoices);
const ownChoices = structuredClone(g.players[0].traitorChoices);
const declared = control(g, 'Hard');
assert.deepEqual(gameplay(declared), gameplay(g));
g = runBots(declared, 8);
assert.equal(g.status, 'setup');
assert.equal(g.players[0].traitors.length, 1);
assert.ok(ownChoices.includes(g.players[0].traitors[0]));
assert.deepEqual(g.players[1].traitorChoices, guestChoices);
assert.deepEqual(g.players[1].traitors, []);
assert.equal(g.players[0].bot, undefined);
assert.equal(g.botsPending, false);
});
void test('retaking control preserves a sealed storm dial and every pending gameplay field', () => {
let g = playing();
g.phase = 0;
g.stormDialers = ['a', 'e'];
g.lastBattle = ['a', 'e'];
g.stormDials = { a: 17 };
g = control(g, 'Brutal');
const before = structuredClone(g);
g = control(JSON.parse(JSON.stringify(g)), null);
assert.deepEqual(
JSON.parse(JSON.stringify(gameplay(g))),
JSON.parse(JSON.stringify(gameplay(before))),
);
assert.deepEqual(g.stormDials, { a: 17 });
assert.equal(viewGame(g, 'e').stormRevealed, null);
assert.deepEqual(botActions(viewGame(g, 'a')), []);
const stopped = runBots(g, 4);
assert.deepEqual(stopped.stormDials, { a: 17 });
assert.equal(stopped.botsPending, false);
});
void test('owner can retake control through Truthtrance, phase opening, response and private decision locks', () => {
const windows: ((g: Game) => void)[] = [
(g) => {
g.truthtrance = {
stage: 'priority',
queue: [],
passed: [],
question: null,
};
},
(g) => {
g.phaseOpening = { passed: ['a'], initialize: false };
},
(g) => {
g.response = {
kind: 'revivalIncome',
owner: 'e',
recipient: 'a',
amount: 2,
passed: [],
};
},
(g) => {
// A valid legacy Advanced exchange with a genuine return choice.
g.advanced = true; g.phase = 3;
g.deck = baseDeck();
const take = (kind: string) => g.deck.splice(g.deck.findIndex(card => card.kind === kind || card.effect === kind), 1)[0];
g.discard = [take('karama')];
g.players[0] = { ...newPlayer('a', 'Owner', 'harkonnen'),
autopilot: 'Medium', specialKaramaUsed: true, hand: [take('shield'), take('projectile')] };
g.pendingExchange = { response: null, decision: null };
g.decision = { kind: 'handExchange', player: 'a', target: 'e', count: 1 };
},
];
for (const install of windows) {
let g = control(playing(), 'Medium');
install(g);
const before = structuredClone(g);
g = control(g, null);
assert.deepEqual(gameplay(g), gameplay(before));
assert.equal(g.players[0].autopilot, undefined);
}
});
void test('autopilot difficulty and own private view produce the same actions as the native policy', () => {
for (const difficulty of DIFFICULTIES) {
let g = playing();
g.phase = 3;
g.auction = {
cards: [baseDeck()[0]],
index: 0,
bid: 4,
bidder: 'e',
active: 'a',
passed: [],
peekKnown: false,
opener: 0,
};
g.players[0].spice = 20;
g = control(g, difficulty);
const automatic = botActions(viewGame(g, 'a'));
assert.ok(automatic.length > 0, difficulty);
const native = structuredClone(g);
native.players[0].bot = difficulty;
delete native.players[0].autopilot;
assert.deepEqual(automatic, botActions(viewGame(native, 'a')), difficulty);
g.players[1].hand = baseDeck().slice(3, 7);
g.players[1].traitors = ['hidden-other-choice'];
g.deck.reverse();
assert.deepEqual(botActions(viewGame(g, 'a')), automatic, difficulty);
}
});
void test('public control mode survives JSON without disclosing another owner’s hand or traitors', () => {
let g = playing();
g.players[0].hand = [baseDeck()[0]];
g = control(g, 'Hard');
g = JSON.parse(JSON.stringify(g));
const own = viewGame(g, 'a');
const other = viewGame(g, 'e');
assert.equal(own.players[0].autopilot, 'Hard');
assert.equal(other.players[0].autopilot, 'Hard');
assert.equal(other.players[0].bot, undefined);
assert.equal(other.players[0].hand, undefined);
assert.equal(other.players[0].traitors, undefined);
assert.deepEqual(own.players[0].hand, g.players[0].hand);
assert.deepEqual(own.players[0].traitors, g.players[0].traitors);
assert.equal(own.me, 'a');
assert.equal(other.me, 'e');
});