Implement Guild Nexus Cunning second shipment and recovery
This commit is contained in:
1 parent
6ba1050a92
commit
90bf699627
27 files changed
+2657
-43
No files matched your search
@@ -0,0 +1,251 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createNexusGuildCunning,
|
||||
validateNexusGuildCunning,
|
||||
nexusGuildCunningMoves,
|
||||
type NexusGuildCunningContext,
|
||||
type NexusGuildCunningReceipt,
|
||||
} from '../game/nexus-guild-cunning';
|
||||
|
||||
const context = {
|
||||
turn: 3,
|
||||
phase: 5,
|
||||
active: 'g',
|
||||
players: [
|
||||
{ id: 'a', faction: 'atreides' as const },
|
||||
{ id: 'g', faction: 'guild' as const },
|
||||
{ id: 'f', faction: 'fremen' as const },
|
||||
],
|
||||
};
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const make = (moved = 0, hajrUsed = false, shipped = false) =>
|
||||
createNexusGuildCunning(context, 'g', { shipped, moved, hajrUsed });
|
||||
|
||||
void test('Guild Cunning records the ended original combined turn whether its shipment and movement were used or declined', () => {
|
||||
for (const shipped of [false, true])
|
||||
for (const hajrUsed of [false, true])
|
||||
for (let moved = 0; moved <= (hajrUsed ? 2 : 1); moved++) {
|
||||
const before = { shipped, moved, hajrUsed },
|
||||
original = clone(before);
|
||||
const receipt = createNexusGuildCunning(context, 'g', before);
|
||||
assert.equal(
|
||||
receipt.event,
|
||||
JSON.stringify(['nexusGuildCunning', 3, 'g']),
|
||||
);
|
||||
assert.deepEqual(receipt.before, original);
|
||||
assert.deepEqual(before, original);
|
||||
assert.notEqual(receipt.before, before);
|
||||
assert.equal(receipt.mode, 'cunning');
|
||||
validateNexusGuildCunning(context, clone(receipt));
|
||||
}
|
||||
});
|
||||
|
||||
void test('second shipment never supplies an ordinary move, including a declined first movement', () => {
|
||||
for (const moved of [0, 1]) {
|
||||
const receipt = make(moved, false, true);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, false), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, false, 1));
|
||||
assert.equal(
|
||||
nexusGuildCunningMoves(receipt, true),
|
||||
1,
|
||||
'later Hajr grants exactly its extra slot',
|
||||
);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('previously played Hajr keeps an unused extra slot after decline, but an already consumed Hajr grants no third move', () => {
|
||||
for (const moved of [0, 1]) {
|
||||
const receipt = make(moved, true);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, false));
|
||||
}
|
||||
const consumed = make(2, true);
|
||||
assert.equal(nexusGuildCunningMoves(consumed, true), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(consumed, true, 1));
|
||||
for (const count of [-1, 0.5, 2, NaN, Infinity])
|
||||
assert.throws(() => nexusGuildCunningMoves(make(1, true), true, count));
|
||||
});
|
||||
|
||||
void test('only native unallied Guild can create the receipt at its own phase-five boundary', () => {
|
||||
const before = { shipped: false, moved: 0, hajrUsed: false };
|
||||
for (const phase of [0, 4, 6, 8])
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning({ ...context, phase }, 'g', before),
|
||||
);
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning({ ...context, active: 'a' }, 'g', before),
|
||||
);
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning({ ...context, active: 'a' }, 'a', before),
|
||||
);
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning(
|
||||
{
|
||||
...context,
|
||||
players: context.players.map((p) => ({
|
||||
...p,
|
||||
ally: p.id === 'g' ? 'a' : null,
|
||||
})),
|
||||
},
|
||||
'g',
|
||||
before,
|
||||
),
|
||||
);
|
||||
for (const bad of [
|
||||
{ ...before, moved: 2 },
|
||||
{ ...before, moved: 3, hajrUsed: true },
|
||||
{ ...before, moved: -1 },
|
||||
{ ...before, moved: 0.5 },
|
||||
{ ...before, moved: NaN },
|
||||
{ ...before, shipped: 0 },
|
||||
{ ...before, hajrUsed: 1 },
|
||||
{ ...before, extra: 1 },
|
||||
])
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning(context, 'g', bad as typeof before),
|
||||
);
|
||||
});
|
||||
|
||||
void test('completed original receipts survive later alliances, turns and resource changes without reading private fields', () => {
|
||||
const receipt = make(1, true, true);
|
||||
const trap = () => {
|
||||
throw new Error('private field was read');
|
||||
};
|
||||
const later: NexusGuildCunningContext = {
|
||||
turn: 7,
|
||||
players: context.players.map((p) => ({
|
||||
...p,
|
||||
get ally() {
|
||||
return trap();
|
||||
},
|
||||
get hand() {
|
||||
return trap();
|
||||
},
|
||||
get spice() {
|
||||
return trap();
|
||||
},
|
||||
get shipped() {
|
||||
return trap();
|
||||
},
|
||||
get moved() {
|
||||
return trap();
|
||||
},
|
||||
get reserves() {
|
||||
return trap();
|
||||
},
|
||||
})),
|
||||
};
|
||||
validateNexusGuildCunning(later, receipt);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
|
||||
assert.throws(() =>
|
||||
validateNexusGuildCunning({ ...later, turn: 2 }, receipt),
|
||||
);
|
||||
assert.throws(() =>
|
||||
validateNexusGuildCunning(
|
||||
{ ...later, players: later.players.slice(0, 2) },
|
||||
receipt,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
void test('creation reads only public roster, owner alliance, timing and the supplied original allowance', () => {
|
||||
const trap = () => {
|
||||
throw new Error('private field was read');
|
||||
};
|
||||
const publicContext = {
|
||||
...context,
|
||||
players: context.players.map((p) =>
|
||||
Object.freeze({
|
||||
...p,
|
||||
ally: null,
|
||||
get hand() {
|
||||
return trap();
|
||||
},
|
||||
get spice() {
|
||||
return trap();
|
||||
},
|
||||
get forces() {
|
||||
return trap();
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
Object.freeze(publicContext.players);
|
||||
Object.freeze(publicContext);
|
||||
const before = Object.freeze({ shipped: true, moved: 1, hajrUsed: true });
|
||||
const receipt = createNexusGuildCunning(publicContext, 'g', before);
|
||||
assert.deepEqual(Object.keys(receipt).sort(), [
|
||||
'before',
|
||||
'event',
|
||||
'mode',
|
||||
'owner',
|
||||
'phase',
|
||||
'roster',
|
||||
'signature',
|
||||
'turn',
|
||||
'version',
|
||||
]);
|
||||
assert.equal(JSON.stringify(receipt).includes('hand'), false);
|
||||
assert.equal(nexusGuildCunningMoves(Object.freeze(receipt), true, 1), 0);
|
||||
});
|
||||
|
||||
void test('changed original facts, foreign fields, deleted proofs and malformed roster identities reject immutably', () => {
|
||||
const receipt = make(1, true, true);
|
||||
const mutations: ((r: NexusGuildCunningReceipt) => void)[] = [
|
||||
(r) => {
|
||||
r.before.moved = 0;
|
||||
},
|
||||
(r) => {
|
||||
r.before.shipped = false;
|
||||
},
|
||||
(r) => {
|
||||
r.before.hajrUsed = false;
|
||||
},
|
||||
(r) => {
|
||||
r.event = 'stale';
|
||||
},
|
||||
(r) => {
|
||||
r.owner = 'a';
|
||||
},
|
||||
(r) => {
|
||||
r.turn = 2;
|
||||
},
|
||||
(r) => {
|
||||
r.signature = '';
|
||||
},
|
||||
(r) => {
|
||||
r.roster.reverse();
|
||||
},
|
||||
(r) => {
|
||||
r.roster[0].id = 'changed';
|
||||
},
|
||||
(r) => {
|
||||
delete (r as Partial<NexusGuildCunningReceipt>).before;
|
||||
},
|
||||
(r) => {
|
||||
Object.assign(r, { stage: 'secondShipment' });
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const bad = clone(receipt);
|
||||
mutate(bad);
|
||||
const original = clone(bad);
|
||||
assert.throws(() => validateNexusGuildCunning(context, bad));
|
||||
assert.deepEqual(bad, original);
|
||||
}
|
||||
for (const players of [
|
||||
[...context.players, context.players[0]],
|
||||
context.players.map((p) => ({ ...p, id: 'same' })),
|
||||
context.players.map((p) => ({ ...p, faction: 'guild' as const })),
|
||||
context.players.map((p) => ({
|
||||
...p,
|
||||
id: p.id === 'a' ? '__proto__' : p.id,
|
||||
})),
|
||||
])
|
||||
assert.throws(() =>
|
||||
validateNexusGuildCunning({ ...context, players }, receipt),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user