Implement Harkonnen Nexus exchanges with durable traitor declarations
This commit is contained in:
1 parent
d820c8a414
commit
d5830d260c
20 files changed
+3158
-10
No files matched your search
@@ -0,0 +1,187 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type GameView } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
nexusTraitorBotActions,
|
||||
nexusTraitorDrawAction,
|
||||
nexusTraitorReturnAction,
|
||||
} from '../game/nexus-traitor-options';
|
||||
import {
|
||||
nexusTraitorDraw,
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
} from './fixture-nexus-traitors';
|
||||
|
||||
void test('all four profiles complete actual Cunning and Face Dancer exchanges with conserved custody', () => {
|
||||
for (const ownerFaction of ['harkonnen', 'tleilaxu'] as const) {
|
||||
const initial = nexusTraitorFixture({ ownerFaction });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(initial, 'p');
|
||||
v.players[0].bot = difficulty;
|
||||
const before = structuredClone(v);
|
||||
const begin = botActions(v);
|
||||
assert.equal(begin.length, 1);
|
||||
assert.equal(begin[0].type, 'nexusTraitorDraw');
|
||||
assert.deepEqual(v, before);
|
||||
const drawn = applyAction(initial, 'p', begin[0]);
|
||||
const pending = viewGame(drawn, 'p');
|
||||
pending.players[0].bot = difficulty;
|
||||
const returns = botActions(pending);
|
||||
assert.equal(returns.length, 1);
|
||||
assert.equal(returns[0].type, 'nexusTraitorReturn');
|
||||
const cards = returns[0].cards as string[];
|
||||
assert.equal(cards.length, ownerFaction === 'harkonnen' ? 1 : 2);
|
||||
assert.equal(new Set(cards).size, cards.length);
|
||||
assert.deepEqual(
|
||||
returns[0],
|
||||
nexusTraitorReturnAction(
|
||||
pending,
|
||||
pending.nexusTraitors!.pending!.event,
|
||||
cards,
|
||||
),
|
||||
);
|
||||
const settled = applyAction(drawn, 'p', returns[0]);
|
||||
nexusTraitorInventory(settled);
|
||||
assert.equal(viewGame(settled, 'p').nexusTraitors!.pending, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('pending exchange outranks preserved controls and automatic flags while Truth outranks exchange and Nullentropy', () => {
|
||||
const g = nexusTraitorDraw(nexusTraitorFixture());
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(g, 'p');
|
||||
v.players[0].bot = difficulty;
|
||||
v.automaticContinuationPending = true;
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
assert.equal(botActions(v)[0]?.type, 'nexusTraitorReturn');
|
||||
v.truthtrance = {
|
||||
stage: 'priority',
|
||||
queue: [],
|
||||
passed: [],
|
||||
question: null,
|
||||
};
|
||||
assert.deepEqual(botActions(v), [{ type: 'truthPass' }]);
|
||||
v.truthtrance.passed = ['p'];
|
||||
assert.deepEqual(botActions(v), []);
|
||||
}
|
||||
for (const id of ['q', 'r']) {
|
||||
const v = viewGame(g, id);
|
||||
v.players.find((p) => p.id === id)!.bot = 'Brutal';
|
||||
Object.defineProperty(v.nexusTraitors!.pending!, 'choices', {
|
||||
get() {
|
||||
throw new Error('Private exchange choices read');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(botActions(v), []);
|
||||
}
|
||||
});
|
||||
|
||||
/** Ranking inputs here are explicit authorized projections. Actual exchange
|
||||
* execution/custody is covered above; these staged views isolate preferences. */
|
||||
function rankingFixture(): GameView {
|
||||
const g = nexusTraitorFixture({ phase: 6 });
|
||||
g.battle = {
|
||||
event: 'ranking-battle',
|
||||
territory: 'arrakeen',
|
||||
attacker: 'p',
|
||||
defender: 'q',
|
||||
prepared: true,
|
||||
plans: {},
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
const v = viewGame(g, 'p');
|
||||
v.nexusTraitors = {
|
||||
offer: null,
|
||||
pending: {
|
||||
event: 'ranking-exchange',
|
||||
owner: 'p',
|
||||
mode: 'cunning',
|
||||
count: 1,
|
||||
choices: [
|
||||
v.players[0].leaders[0].id,
|
||||
v.players[1].leaders[0].id,
|
||||
v.players[2].leaders[0].id,
|
||||
].map((id) => ({ id, revealed: false, drawn: false })),
|
||||
},
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
void test('ranking reads only authorized choices and public leaders, preserving a revealed opposing traitor', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = rankingFixture();
|
||||
v.players[0].bot = difficulty;
|
||||
const pending = v.nexusTraitors!.pending!;
|
||||
const own = pending.choices[0].id;
|
||||
const opposing = pending.choices[1].id;
|
||||
const other = pending.choices[2].id;
|
||||
for (const player of v.players.slice(1))
|
||||
for (const key of ['hand', 'traitors', 'faceDancers', 'spice'])
|
||||
Object.defineProperty(player, key, {
|
||||
get() {
|
||||
throw new Error(`Read opponent ${key}`);
|
||||
},
|
||||
});
|
||||
const b = v.battle!;
|
||||
Object.defineProperty(b, 'plans', {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error('Read unrevealed plans');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [own] },
|
||||
]);
|
||||
// A public dead leader is expendable when no own identity is offered.
|
||||
pending.choices = pending.choices.slice(1);
|
||||
v.players[2].leaders[0].dead = true;
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [other] },
|
||||
]);
|
||||
Object.defineProperty(b, 'plans', {
|
||||
value: { q: { leader: other, dial: 0, weapon: null, defense: null } },
|
||||
writable: true,
|
||||
});
|
||||
b.revealed = true;
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [opposing] },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('optional bot Cunning waits for an unreplied public traitor call or Mentat; authoritative blocks remain binding', () => {
|
||||
const v = rankingFixture();
|
||||
v.nexusTraitors = {
|
||||
offer: { event: 'offer', mode: 'cunning', draw: 1, blocked: null },
|
||||
pending: null,
|
||||
};
|
||||
const b = v.battle!;
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
b.revealed = true;
|
||||
b.traitorVoters = ['p', 'q'];
|
||||
b.traitorSubmitted = [];
|
||||
assert.deepEqual(nexusTraitorBotActions(v), [
|
||||
{ type: 'nexusTraitorDraw', event: 'offer', mode: 'cunning' },
|
||||
]);
|
||||
b.traitorSubmitted = ['p'];
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
v.phase = 8;
|
||||
assert.equal(nexusTraitorBotActions(v)[0]?.type, 'nexusTraitorDraw');
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
assert.ok(nexusTraitorDrawAction(v, 'offer', 'cunning'));
|
||||
v.decision = null;
|
||||
v.nexusTraitors.offer!.blocked = 'Exchange unavailable.';
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
const secret = viewGame(
|
||||
nexusTraitorFixture({ ownerFaction: 'tleilaxu' }),
|
||||
'p',
|
||||
);
|
||||
assert.equal(nexusTraitorBotActions(secret)[0]?.type, 'nexusTraitorDraw');
|
||||
secret.phase = 7;
|
||||
assert.deepEqual(nexusTraitorBotActions(secret), []);
|
||||
});
|
||||
Reference in new issue
Block a user