245 lines
7.6 KiB
TypeScript
245 lines
7.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
quoteNexusAdvisors,
|
|
createNexusAdvisors,
|
|
validateNexusAdvisors,
|
|
type NexusAdvisorContext,
|
|
type NexusAdvisorReceipt,
|
|
} from '../game/nexus-advisors';
|
|
|
|
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
|
function context(): NexusAdvisorContext {
|
|
return {
|
|
advanced: true,
|
|
turn: 3,
|
|
storm: 18,
|
|
order: ['b', 'g', 'r'],
|
|
players: [
|
|
{
|
|
id: 'b',
|
|
faction: 'beneGesserit',
|
|
ally: null,
|
|
forces: { 'hagga_basin:12': 2, 'hagga_basin:13': 1, 'arrakeen:10': 2 },
|
|
advisors: { hagga_basin: { lockedTurn: 2 }, arrakeen: {} },
|
|
},
|
|
{
|
|
id: 'g',
|
|
faction: 'guild',
|
|
ally: null,
|
|
forces: { 'hagga_basin:12': 1, 'arrakeen:10': 1 },
|
|
},
|
|
{ id: 'r', faction: 'richese', ally: null, forces: {} },
|
|
],
|
|
};
|
|
}
|
|
void test('one territory selection includes all physical advisor sectors and permits several territory groups as one immutable receipt', () => {
|
|
const c = context(),
|
|
before = reload(c),
|
|
quote = quoteNexusAdvisors(c, 'b');
|
|
assert.deepEqual(
|
|
quote.territories
|
|
.map((t) => [t.territory, t.count, t.blocked])
|
|
.sort((one, two) => String(one[0]).localeCompare(String(two[0]))),
|
|
[
|
|
['arrakeen', 2, null],
|
|
['hagga_basin', 3, null],
|
|
],
|
|
);
|
|
const receipt = createNexusAdvisors(c, 'b', 'advisor-1', [
|
|
'hagga_basin',
|
|
'arrakeen',
|
|
]);
|
|
assert.deepEqual(receipt.selections[0], {
|
|
territory: 'hagga_basin',
|
|
count: 3,
|
|
locations: [
|
|
{ location: 'hagga_basin:12', count: 2 },
|
|
{ location: 'hagga_basin:13', count: 1 },
|
|
],
|
|
noField: null,
|
|
stance: { lockedTurn: 2 },
|
|
});
|
|
assert.deepEqual(c, before);
|
|
validateNexusAdvisors(reload(c), reload(receipt), true);
|
|
receipt.selections[0].locations[0].count = 0;
|
|
assert.deepEqual(c, before);
|
|
});
|
|
void test('Basic and automatically released lone advisors yield no choice; Homeworld stances are not selected', () => {
|
|
const c = context();
|
|
assert.deepEqual(quoteNexusAdvisors({ ...c, advanced: false }, 'b'), {
|
|
territories: [],
|
|
});
|
|
c.players[1].forces = {};
|
|
c.players[0].advisors!['homeworld:beneGesserit'] = {};
|
|
assert.deepEqual(quoteNexusAdvisors(c, 'b'), { territories: [] });
|
|
assert.throws(() =>
|
|
createNexusAdvisors(c, 'b', 'x', ['homeworld:beneGesserit']),
|
|
);
|
|
assert.throws(() => quoteNexusAdvisors(c, 'g'));
|
|
});
|
|
void test('ordinary allied occupation and a third stronghold faction block conversion using public No-Field presence', () => {
|
|
const allied = context();
|
|
allied.players[0].ally = 'g';
|
|
allied.players[1].ally = 'b';
|
|
assert.ok(
|
|
quoteNexusAdvisors(allied, 'b').territories.every((t) =>
|
|
/ally/.test(t.blocked!),
|
|
),
|
|
);
|
|
const full = context();
|
|
full.players[2].noField = {
|
|
deployed: { location: { territory: 'arrakeen', sector: 10 } },
|
|
};
|
|
assert.match(
|
|
quoteNexusAdvisors(full, 'b').territories.find(
|
|
(t) => t.territory === 'arrakeen',
|
|
)!.blocked!,
|
|
/three occupying factions/,
|
|
);
|
|
assert.equal(
|
|
quoteNexusAdvisors(full, 'b').territories.find(
|
|
(t) => t.territory === 'hagga_basin',
|
|
)!.blocked,
|
|
null,
|
|
);
|
|
});
|
|
void test('the established Ecaz shared occupancy identity is preserved for capacity and allied entry', () => {
|
|
const c = context();
|
|
c.players[1].faction = 'ecaz';
|
|
c.players[0].ally = 'g';
|
|
c.players[1].ally = 'b';
|
|
c.players[2].forces = { 'arrakeen:10': 1 };
|
|
assert.equal(
|
|
quoteNexusAdvisors(c, 'b').territories.find(
|
|
(t) => t.territory === 'arrakeen',
|
|
)!.blocked,
|
|
null,
|
|
);
|
|
});
|
|
void test('public marker counts and locations are recorded without reading hidden No-Field denomination or other secrets', () => {
|
|
const c = context();
|
|
c.players[0].noField = {
|
|
deployed: { location: { territory: 'hagga_basin', sector: 12 } },
|
|
};
|
|
for (const player of c.players)
|
|
for (const key of ['hand', 'spice', 'traitors', 'leaders'])
|
|
Object.defineProperty(player, key, {
|
|
get() {
|
|
throw Error('Private field accessed');
|
|
},
|
|
});
|
|
const marker = c.players[0].noField!;
|
|
for (const key of ['tokens', 'value', 'tokenId', 'lastShipped'])
|
|
Object.defineProperty(marker, key, {
|
|
get() {
|
|
throw Error('Hidden marker accessed');
|
|
},
|
|
});
|
|
Object.defineProperty(marker.deployed!, 'tokenId', {
|
|
get() {
|
|
throw Error('Hidden identity accessed');
|
|
},
|
|
});
|
|
const receipt = createNexusAdvisors(c, 'b', 'private', ['hagga_basin']);
|
|
assert.equal(receipt.selections[0].count, 4);
|
|
assert.equal(receipt.selections[0].noField, 'hagga_basin:12');
|
|
assert.equal(
|
|
receipt.selections[0].locations.reduce((sum, g) => sum + g.count, 0),
|
|
3,
|
|
);
|
|
validateNexusAdvisors(c, receipt, true);
|
|
const changed = reload(c);
|
|
changed.players[0].noField = null;
|
|
changed.players[0].forces = {
|
|
'hagga_basin:12': 3,
|
|
'hagga_basin:13': 1,
|
|
'arrakeen:10': 2,
|
|
};
|
|
assert.equal(
|
|
quoteNexusAdvisors(changed, 'b').territories.find(
|
|
(t) => t.territory === 'hagga_basin',
|
|
)!.count,
|
|
4,
|
|
);
|
|
assert.throws(() => validateNexusAdvisors(changed, receipt, true));
|
|
});
|
|
void test('empty, duplicate, foreign and malformed selections reject immutably and current settlement rechecks exact counts and legality', () => {
|
|
const c = context(),
|
|
before = reload(c);
|
|
for (const selected of [
|
|
[],
|
|
['arrakeen', 'arrakeen'],
|
|
['unknown'],
|
|
['arrakeen:10'],
|
|
])
|
|
assert.throws(() => createNexusAdvisors(c, 'b', 'x', selected));
|
|
assert.deepEqual(c, before);
|
|
const receipt = createNexusAdvisors(c, 'b', 'x', ['arrakeen']);
|
|
for (const change of [
|
|
(g: NexusAdvisorContext) => {
|
|
g.players[0].forces = { 'arrakeen:10': 1 };
|
|
},
|
|
(g: NexusAdvisorContext) => {
|
|
delete g.players[0].advisors!.arrakeen;
|
|
},
|
|
(g: NexusAdvisorContext) => {
|
|
g.players[0].advisors!.arrakeen.lockedTurn = 3;
|
|
},
|
|
(g: NexusAdvisorContext) => {
|
|
g.players[2].forces = { 'arrakeen:10': 1 };
|
|
},
|
|
]) {
|
|
const altered = reload(c);
|
|
change(altered);
|
|
assert.throws(() => validateNexusAdvisors(altered, receipt, true));
|
|
}
|
|
});
|
|
void test('signed receipt detects altered counts, stances and ordering while completed history survives later movement and turns', () => {
|
|
const c = context(),
|
|
receipt = createNexusAdvisors(c, 'b', 'x', ['arrakeen', 'hagga_basin']);
|
|
for (const change of [
|
|
(r: NexusAdvisorReceipt) => {
|
|
r.selections.reverse();
|
|
},
|
|
(r: NexusAdvisorReceipt) => {
|
|
r.selections[0].count++;
|
|
},
|
|
(r: NexusAdvisorReceipt) => {
|
|
r.selections[0].stance.lockedTurn = 1;
|
|
},
|
|
(r: NexusAdvisorReceipt) => {
|
|
r.selections[1].locations.reverse();
|
|
},
|
|
(r: NexusAdvisorReceipt) => {
|
|
r.owner = 'g';
|
|
},
|
|
(r: NexusAdvisorReceipt) => {
|
|
r.turn++;
|
|
},
|
|
]) {
|
|
const bad = reload(receipt);
|
|
change(bad);
|
|
assert.throws(() => validateNexusAdvisors(c, bad, false));
|
|
}
|
|
const later = reload(c);
|
|
later.turn++;
|
|
later.players[0].forces = {};
|
|
later.players[0].advisors = {};
|
|
validateNexusAdvisors(later, receipt, false);
|
|
assert.throws(() => validateNexusAdvisors(later, receipt, true));
|
|
});
|
|
|
|
void test('ordinary in-memory undefined lock metadata normalizes into the same saved stance without mutating its source', () => {
|
|
const c = context();
|
|
c.players[0].advisors!.arrakeen = { lockedTurn: undefined };
|
|
const receipt = createNexusAdvisors(c, 'b', 'undefined-lock', ['arrakeen']);
|
|
assert.deepEqual(receipt.selections[0].stance, {});
|
|
assert.equal(
|
|
Object.hasOwn(c.players[0].advisors!.arrakeen, 'lockedTurn'),
|
|
true,
|
|
);
|
|
validateNexusAdvisors(c, receipt, true);
|
|
validateNexusAdvisors(reload(c), reload(receipt), true);
|
|
});
|