Connect bounded Richese Nexus Cunning No-Field pair
This commit is contained in:
1 parent
0c6d28a706
commit
763749f25e
14 files changed
+758
-31
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeNexusGameForAudit,
|
||||
initializePairedNexusGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
@@ -24,12 +25,13 @@ import {
|
||||
|
||||
export type NexusRicheseOwner =
|
||||
| 'atreides'
|
||||
| 'harkonnen'
|
||||
| 'emperor'
|
||||
| 'fremen'
|
||||
| 'guild'
|
||||
| 'beneGesserit'
|
||||
| 'ixians'
|
||||
| 'harkonnen';
|
||||
| 'richese';
|
||||
export type NexusRicheseFixtureOptions = {
|
||||
seatIds?: [string, string, string];
|
||||
ownerFaction?: NexusRicheseOwner;
|
||||
@@ -66,14 +68,17 @@ export function nexusRicheseFixture(
|
||||
? 'guild'
|
||||
: 'harkonnen');
|
||||
assert.notEqual(ownerFaction, opponentFaction);
|
||||
const observerFaction = (
|
||||
['fremen', 'atreides', 'emperor'] as FactionId[]
|
||||
).find((f) => f !== ownerFaction && f !== opponentFaction)!;
|
||||
const observerFaction = ownerFaction === 'richese'
|
||||
? 'choam'
|
||||
: (['fremen', 'atreides', 'emperor'] as FactionId[])
|
||||
.find((f) => f !== ownerFaction && f !== opponentFaction)!;
|
||||
let g = createGame(
|
||||
'RICHESENEXUS',
|
||||
newPlayer(owner, ownerFaction, ownerFaction),
|
||||
options.advanced ?? false,
|
||||
ownerFaction === 'ixians' || opponentFaction === 'ixians' ? ['ix'] : [],
|
||||
ownerFaction === 'ixians' || opponentFaction === 'ixians'
|
||||
? ['ix']
|
||||
: ownerFaction === 'richese' ? ['choam'] : [],
|
||||
);
|
||||
// The unfinished E3 public deck gate is separate from native Moritani audit
|
||||
// setup. Fix the final faction before readiness and genuine initialization.
|
||||
@@ -92,7 +97,9 @@ export function nexusRicheseFixture(
|
||||
g = applyAction(g, owner, { type: 'homeworlds', enabled: true });
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g = initializeNexusGameForAudit(g);
|
||||
g = ownerFaction === 'richese'
|
||||
? initializePairedNexusGameForAudit(g)
|
||||
: initializeNexusGameForAudit(g);
|
||||
for (let step = 0; g.status === 'setup' && step < 100; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
@@ -112,7 +119,7 @@ export function nexusRicheseFixture(
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const p of g.players) g.deck.push(...p.hand.splice(0));
|
||||
if (g.expansions.includes('ix')) {
|
||||
if (g.expansions.includes('ix') || ownerFaction === 'richese') {
|
||||
for (let step = 0; g.phase === 0 && step < 60; step++) g = fixtureStep(g);
|
||||
assert.equal(g.phase, 1);
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { nexusRicheseAllow, nexusRicheseFixture } from './fixture-nexus-richese';
|
||||
|
||||
function pair(g: Game, owner: string, revealedValue: 0 | 3 | 5 = 5): Action {
|
||||
const tokens = g.players.find(player => player.id === owner)!.noField!.tokens;
|
||||
const revealed = tokens.find(token => token.value === revealedValue)!;
|
||||
const concealed = tokens.find(token => token.id !== revealed.id &&
|
||||
token.id !== g.players.find(player => player.id === owner)!.noField!.lastShipped)!;
|
||||
return {
|
||||
type: 'ship', noField: concealed.id, revealedToken: revealed.id,
|
||||
nexus: viewGame(g, owner).nexusRicheseCunning!.event,
|
||||
event: g.players.find(player => player.id === owner)!.noFieldEvent,
|
||||
territory: 'arrakeen', sector: 10, allyPayment: 0,
|
||||
};
|
||||
}
|
||||
|
||||
void test('Basic and Advanced Richese pair spends one card and price, materializes one public token and retains one concealed', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const { g: before, owner, target } = nexusRicheseFixture({ ownerFaction: 'richese', advanced });
|
||||
const p = before.players.find(player => player.id === owner)!;
|
||||
const original = JSON.stringify(before);
|
||||
const request = pair(before, owner);
|
||||
const concealed = String(request.noField);
|
||||
const revealed = String(request.revealedToken);
|
||||
const after = applyAction(before, owner, request);
|
||||
const actual = after.players.find(player => player.id === owner)!;
|
||||
assert.equal(JSON.stringify(before), original);
|
||||
assert.equal(after.nexusCards!.cards!.hands[owner], null);
|
||||
assert.equal(actual.spice, p.spice - 1);
|
||||
assert.equal(actual.reserves, p.reserves - 5);
|
||||
assert.equal(actual.forces['arrakeen:10'], 5);
|
||||
assert.equal(actual.noField!.deployed!.tokenId, concealed);
|
||||
assert.equal(actual.noField!.lastShipped, concealed);
|
||||
assert.equal(presenceAt(actual, 'arrakeen'), 6);
|
||||
assert.equal(after.nexusRicheseCunningLast?.stage, 'shipped');
|
||||
assert.match(after.log.at(-1)!.text, /faceup 5 token placed 5 physical forces/);
|
||||
const rival = viewGame(after, target);
|
||||
assert.equal(rival.richeseNoField!.private, null);
|
||||
assert.equal(rival.nexusRicheseCunning, null);
|
||||
assert.equal(JSON.stringify(rival).includes(concealed), false);
|
||||
assert.equal(JSON.stringify(rival).includes(revealed), false);
|
||||
const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(after)));
|
||||
assert.equal(restored.players.find(player => player.id === owner)!.reserves, p.reserves - 5);
|
||||
assert.equal(viewGame(restored, target).richeseNoField!.public.deployed!.effectiveForces, 1);
|
||||
assert.throws(() => applyAction(restored, owner, request), /ship once|unused shipment|stale/i);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Karama cancels the two-token attempt without spending spice or tokens, while the Nexus stays spent', () => {
|
||||
const { g: before, owner, target, karama } = nexusRicheseFixture({ ownerFaction: 'richese', karama: true });
|
||||
const request = pair(before, owner);
|
||||
const declared = applyAction(before, owner, request);
|
||||
assert.equal(declared.response?.kind, 'richeseNoField');
|
||||
assert.equal(declared.nexusCards!.cards!.hands[owner], null);
|
||||
assert.equal(declared.players[0].noField!.deployed, null);
|
||||
const canceled = applyAction(JSON.parse(JSON.stringify(declared)), target,
|
||||
{ type: 'card', mode: 'cancel', card: karama! });
|
||||
assert.equal(canceled.nexusRicheseCunningLast?.stage, 'stopped');
|
||||
assert.equal(canceled.players[0].noField!.lastShipped, null);
|
||||
assert.equal(canceled.players[0].reserves, before.players[0].reserves);
|
||||
assert.equal(canceled.players[0].spice, before.players[0].spice);
|
||||
assert.equal(canceled.players[0].shipped, false);
|
||||
assert.equal(viewGame(canceled, owner).nexusRicheseCunning, null);
|
||||
assert.throws(() => applyAction(canceled, owner, request), /unavailable|stale/i);
|
||||
const ordinary = applyAction(canceled, owner,
|
||||
{ type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 });
|
||||
assert.equal(ordinary.players[0].forces['arrakeen:10'], 1);
|
||||
});
|
||||
|
||||
void test('Advanced Guild special prevention spends Cunning without moving a token or charging shipment price', () => {
|
||||
const { g, owner, target, karama } = nexusRicheseFixture({
|
||||
ownerFaction: 'richese', opponentFaction: 'guild', advanced: true, karama: true,
|
||||
});
|
||||
const request = pair(g, owner);
|
||||
const pending = nexusRicheseAllow(applyAction(g, owner, request));
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(pending.pendingShipment?.richesePair?.revealedTokenId, request.revealedToken);
|
||||
const stopped = nexusRicheseAllow(applyAction(
|
||||
JSON.parse(JSON.stringify(pending)), target,
|
||||
{ type: 'card', mode: 'special', card: karama! },
|
||||
));
|
||||
assert.equal(stopped.nexusRicheseCunningLast?.stage, 'stopped');
|
||||
assert.equal(stopped.players[0].spice, g.players[0].spice);
|
||||
assert.equal(stopped.players[0].reserves, g.players[0].reserves);
|
||||
assert.equal(stopped.players[0].noField!.deployed, null);
|
||||
assert.equal(stopped.players[0].noField!.lastShipped, null);
|
||||
assert.equal(stopped.nexusCards!.cards!.hands[owner], null);
|
||||
assert.equal(stopped.players[0].shipped, true);
|
||||
});
|
||||
|
||||
void test('restoration rejects an orphaned pair before it can lose its Karama or Guild continuation', () => {
|
||||
const basic = nexusRicheseFixture({ ownerFaction: 'richese', karama: true });
|
||||
const declared = applyAction(basic.g, basic.owner, pair(basic.g, basic.owner));
|
||||
assert.equal(declared.response?.kind, 'richeseNoField');
|
||||
const missingResponse = JSON.parse(JSON.stringify(declared)) as Game;
|
||||
missingResponse.response = null;
|
||||
assert.throws(() => normalizeAutomaticGame(missingResponse), /Karama response or Guild interception/);
|
||||
|
||||
const advanced = nexusRicheseFixture({
|
||||
ownerFaction: 'richese', opponentFaction: 'guild', advanced: true,
|
||||
});
|
||||
const pending = nexusRicheseAllow(applyAction(
|
||||
advanced.g, advanced.owner, pair(advanced.g, advanced.owner),
|
||||
));
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
const missingDecision = JSON.parse(JSON.stringify(pending)) as Game;
|
||||
missingDecision.decision = null;
|
||||
assert.throws(() => viewGame(missingDecision, advanced.owner), /Karama response or Guild interception/);
|
||||
});
|
||||
|
||||
void test('pair rejects duplicate, prior-used and stale tokens without changing the original game', () => {
|
||||
const { g, owner } = nexusRicheseFixture({ ownerFaction: 'richese' });
|
||||
const request = pair(g, owner);
|
||||
const original = JSON.stringify(g);
|
||||
for (const altered of [
|
||||
{ ...request, revealedToken: request.noField },
|
||||
{ ...request, noField: 'foreign-token' },
|
||||
{ ...request, nexus: 'stale' },
|
||||
{ ...request, event: 'stale' },
|
||||
{ ...request, extra: 'unbound' },
|
||||
]) {
|
||||
assert.throws(() => applyAction(g, owner, altered));
|
||||
assert.equal(JSON.stringify(g), original);
|
||||
}
|
||||
const old = g.players[0].noField!;
|
||||
old.lastShipped = old.tokens.find(token => token.value === 5)!.id;
|
||||
assert.throws(() => applyAction(g, owner, request));
|
||||
});
|
||||
|
||||
void test('all four profiles choose and complete a legal pair using their private own-token view', () => {
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const { g, owner } = nexusRicheseFixture({ ownerFaction: 'richese' });
|
||||
const view = viewGame(g, owner);
|
||||
view.players.find(player => player.id === owner)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.equal(action?.type, 'ship');
|
||||
assert.equal(action?.nexus, view.nexusRicheseCunning!.event, JSON.stringify(action));
|
||||
assert.ok(action?.revealedToken && action.noField && action.revealedToken !== action.noField);
|
||||
const after = applyAction(g, owner, action);
|
||||
assert.equal(after.nexusRicheseCunningLast?.stage, 'shipped');
|
||||
}
|
||||
});
|
||||
@@ -18,15 +18,16 @@ import {
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
async function fixture(options: NexusRicheseFixtureOptions = {}) {
|
||||
const store = unitStore();
|
||||
const richese = options.ownerFaction === 'richese';
|
||||
const made = await store.rooms.createRoom(
|
||||
'Richese SQL',
|
||||
options.ownerFaction ?? 'atreides',
|
||||
options.advanced ?? true,
|
||||
[],
|
||||
richese ? ['choam'] : [],
|
||||
);
|
||||
const code = made.view.code,
|
||||
tokens = [made.token];
|
||||
for (const faction of ['guild', 'fremen'] as const)
|
||||
for (const faction of ['guild', richese ? 'choam' : 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
@@ -150,6 +151,50 @@ void test('SQLite competing Richese declarations spend one Nexus and restore one
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite competing native Cunning pairs settle one concealed marker and one immediate faceup group after reload', async () => {
|
||||
const f = await fixture({ ownerFaction: 'richese', opponentFaction: 'guild', spice: 20 });
|
||||
try {
|
||||
const tokens = f.g.players[0].noField!.tokens;
|
||||
const three = tokens.find(token => token.value === 3)!.id;
|
||||
const five = tokens.find(token => token.value === 5)!.id;
|
||||
const event = viewGame(f.g, f.owner).nexusRicheseCunning!.event;
|
||||
const request = (concealed: string, revealed: string): Action => ({
|
||||
type: 'ship', territory: 'arrakeen', sector: 10,
|
||||
noField: concealed, revealedToken: revealed, event: f.g.players[0].noFieldEvent,
|
||||
nexus: event, allyPayment: 0,
|
||||
});
|
||||
await compete(f, f.g.version, [request(three, five), request(five, three)]);
|
||||
let pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.nexusRicheseCunningLast?.stage, 'pending');
|
||||
assert.equal(pending.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
await restored(f, pending);
|
||||
while (pending.response) {
|
||||
const seat = f.ids.findIndex(id => !pending.response!.passed.includes(id));
|
||||
pending = await act(f, seat, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
const revealedId = pending.pendingShipment!.richesePair!.revealedTokenId;
|
||||
await restored(f, pending);
|
||||
const done = await act(f, 1, { type: 'decision', allow: true });
|
||||
const marker = done.players[0].noField!.deployed!;
|
||||
const revealed = tokens.find(token => token.id === revealedId)!;
|
||||
assert.equal(done.nexusRicheseCunningLast?.stage, 'shipped');
|
||||
assert.equal(done.players[0].noField!.lastShipped, marker.tokenId);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], revealed.value);
|
||||
assert.equal(done.players[0].reserves, 20 - revealed.value);
|
||||
assert.equal(done.players[0].spice, 19);
|
||||
assert.equal(done.players[1].spice, 21);
|
||||
await restored(f, done);
|
||||
const before = row(f);
|
||||
await assert.rejects(() => f.restart().act(
|
||||
f.code, f.auths[0], done.version, request(marker.tokenId, revealed.id), clock,
|
||||
));
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite Guild special stop preserves physical reserves and payment while keeping Richese Nexus spent across reload', async () => {
|
||||
const f = await fixture({ spice: 1 });
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createRicheseNoField,
|
||||
validateRicheseNoField,
|
||||
deployRicheseNoField,
|
||||
deployRicheseNoFieldPair,
|
||||
moveRicheseNoField,
|
||||
revealRicheseNoField,
|
||||
shipAlliedRicheseNoField,
|
||||
@@ -284,6 +285,133 @@ void test('allied shipment reveals immediately from the ally reserves and shares
|
||||
assert.equal(zero.state.lastShipped, ids[0]);
|
||||
});
|
||||
|
||||
void test('paired shipment retains concealed custody while the other token materializes only available forces', () => {
|
||||
for (const [concealedId, revealedId, reserves, value, forces] of [
|
||||
[ids[0], ids[2], 2, 5, 2],
|
||||
[ids[2], ids[1], 0, 3, 0],
|
||||
[ids[1], ids[0], 20, 0, 0],
|
||||
[ids[0], ids[1], 3, 3, 3],
|
||||
] as const) {
|
||||
const previous = fresh();
|
||||
const snapshot = structuredClone(previous);
|
||||
const result = deployRicheseNoFieldPair(
|
||||
previous,
|
||||
{ tokenId: concealedId, controller: 'richese', location },
|
||||
revealedId,
|
||||
reserves,
|
||||
);
|
||||
assert.deepEqual(previous, snapshot);
|
||||
assert.deepEqual(result.state.deployed, {
|
||||
tokenId: concealedId,
|
||||
controller: 'richese',
|
||||
location,
|
||||
});
|
||||
assert.equal(result.state.lastShipped, concealedId);
|
||||
assert.deepEqual(
|
||||
{
|
||||
tokenId: result.revealed.tokenId,
|
||||
controller: result.revealed.controller,
|
||||
location: result.revealed.location,
|
||||
value: result.revealed.value,
|
||||
forces: result.revealed.forces,
|
||||
cause: result.revealed.cause,
|
||||
},
|
||||
{
|
||||
tokenId: revealedId,
|
||||
controller: 'richese',
|
||||
location,
|
||||
value,
|
||||
forces,
|
||||
cause: 'voluntary',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(projectRicheseNoField(result.state, false), {
|
||||
deployed: { controller: 'richese', location, effectiveForces: 1 },
|
||||
lastUsed: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
void test('paired shipment consumes neither identity nor history on invalid choices', () => {
|
||||
const previous = reveal(deployed(0)).state;
|
||||
const snapshot = JSON.stringify(previous);
|
||||
const concealed = { tokenId: ids[1], controller: 'richese', location };
|
||||
for (const [deployment, revealedId, message] of [
|
||||
[concealed, ids[1], /different physical/],
|
||||
[concealed, ids[0], /twice/],
|
||||
[{ ...concealed, tokenId: ids[0] }, ids[2], /twice/],
|
||||
[concealed, 'unknown', /physical/],
|
||||
[{ ...concealed, tokenId: 'unknown' }, ids[2], /physical/],
|
||||
] as const)
|
||||
assert.throws(
|
||||
() => deployRicheseNoFieldPair(previous, deployment, revealedId, 5),
|
||||
message,
|
||||
);
|
||||
assert.equal(JSON.stringify(previous), snapshot);
|
||||
const restored = JSON.parse(snapshot) as RicheseNoField;
|
||||
const { state, revealed } = deployRicheseNoFieldPair(
|
||||
restored,
|
||||
concealed,
|
||||
ids[2],
|
||||
1,
|
||||
);
|
||||
assert.equal(revealed.forces, 1);
|
||||
assert.equal(state.lastShipped, ids[1]);
|
||||
assert.equal(state.deployed?.tokenId, ids[1]);
|
||||
assert.equal(JSON.stringify(restored), snapshot);
|
||||
const restoredPair = JSON.parse(JSON.stringify(state)) as RicheseNoField;
|
||||
assert.deepEqual(
|
||||
projectRicheseNoField(restoredPair, true),
|
||||
projectRicheseNoField(state, true),
|
||||
);
|
||||
const afterReveal = reveal(restoredPair).state;
|
||||
assert.equal(afterReveal.lastShipped, ids[1]);
|
||||
assert.equal(afterReveal.deployed, null);
|
||||
assert.throws(
|
||||
() =>
|
||||
deployRicheseNoField(afterReveal, {
|
||||
tokenId: ids[1],
|
||||
controller: 'richese',
|
||||
location,
|
||||
}),
|
||||
/twice/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('paired shipment rejects occupied custody, malformed reserves and placement without mutation', () => {
|
||||
const prior = deployed(0);
|
||||
const priorSnapshot = JSON.stringify(prior);
|
||||
assert.throws(
|
||||
() =>
|
||||
deployRicheseNoFieldPair(
|
||||
prior,
|
||||
{ tokenId: ids[1], controller: 'richese', location },
|
||||
ids[2],
|
||||
5,
|
||||
),
|
||||
/existing/,
|
||||
);
|
||||
assert.equal(JSON.stringify(prior), priorSnapshot);
|
||||
|
||||
const previous = fresh();
|
||||
const snapshot = JSON.stringify(previous);
|
||||
const deployment = { tokenId: ids[1], controller: 'richese', location };
|
||||
for (const reserves of [-1, NaN, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1])
|
||||
assert.throws(
|
||||
() => deployRicheseNoFieldPair(previous, deployment, ids[2], reserves),
|
||||
/reserves/,
|
||||
);
|
||||
for (const invalid of [
|
||||
{ ...deployment, controller: ' ' },
|
||||
{ ...deployment, location: { territory: 'imperial_basin', sector: 0 } },
|
||||
])
|
||||
assert.throws(
|
||||
() => deployRicheseNoFieldPair(previous, invalid, ids[2], 5),
|
||||
/controller|territory/,
|
||||
);
|
||||
assert.equal(JSON.stringify(previous), snapshot);
|
||||
});
|
||||
|
||||
void test('failed allied materialization does not partially consume the selected token or rewrite history', () => {
|
||||
const state = reveal(deployed(0)).state;
|
||||
const exact = structuredClone(state);
|
||||
|
||||
Reference in new issue
Block a user