Connect staged group Nexus negotiations
Verify and publish checkpoint container / container (push) Successful in 10m24s
Verify and publish checkpoint container / container (push) Successful in 10m24s
This commit is contained in:
1 parent
4798a3579b
commit
624f95dde4
9 files changed
+348
-14
No files matched your search
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { coalitionGroups, coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
|
||||
/** Controlled Basic Nexus and empty group ledger; no host-facing group option is
|
||||
* enabled. Pair formation and all subsequent negotiations use real actions. */
|
||||
function fixture(): Game {
|
||||
const game = createGame('GROUPNEXUS', newPlayer('a', 'Atreides', 'atreides'), false);
|
||||
game.players.push(newPlayer('b', 'Emperor', 'emperor'),
|
||||
newPlayer('c', 'Fremen', 'fremen'), newPlayer('d', 'Harkonnen', 'harkonnen'));
|
||||
game.status = 'playing'; game.phase = 1; game.turn = 2; game.nexus = true;
|
||||
game.order = ['a', 'b', 'c', 'd']; game.ready = [];
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
return game;
|
||||
}
|
||||
function act(game: Game, id: string, action: Action): Game {
|
||||
return applyAction(game, id, action);
|
||||
}
|
||||
function originalPair(): Game {
|
||||
let game = fixture();
|
||||
game = act(game, 'a', {type: 'alliance', target: 'b'});
|
||||
game = act(game, 'b', {type: 'alliance', target: 'a'});
|
||||
assert.equal(game.players[0].ally, 'b');
|
||||
assert.equal(game.players[1].ally, 'a');
|
||||
return game;
|
||||
}
|
||||
function propose(game: Game): Game {
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a');
|
||||
assert.ok(pair);
|
||||
return act(game, 'a', {type: 'coalitionPropose', groupId: pair.id,
|
||||
entrant: 'c', expectedRevision: game.coalitionState!.revision});
|
||||
}
|
||||
function answer(game: Game, id: string, accept = true): Game {
|
||||
const proposal = game.coalitionState!.proposal;
|
||||
assert.ok(proposal);
|
||||
return act(game, id, {type: 'coalitionAnswer', event: proposal.event,
|
||||
nexus: proposal.nexus, turn: proposal.turn, accept,
|
||||
expectedRevision: game.coalitionState!.revision});
|
||||
}
|
||||
function rejected(game: Game, id: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, id, action));
|
||||
assert.deepEqual(game, before, 'A rejected negotiation leaves its original state unchanged.');
|
||||
}
|
||||
|
||||
void test('native pair expands only after three separate group approvals', () => {
|
||||
const original = originalPair();
|
||||
let game = propose(original);
|
||||
const proposal = game.coalitionState!.proposal; assert.ok(proposal);
|
||||
assert.ok(proposal.event && proposal.nexus);
|
||||
assert.equal(proposal.turn, game.turn);
|
||||
assert.deepEqual(proposal.approvals, ['a']);
|
||||
assert.equal(coalitionGroups(game.players, game.coalitionState)[0].members.length, 2);
|
||||
game = answer(game, 'b');
|
||||
assert.deepEqual(game.coalitionState!.proposal!.approvals, ['a', 'b']);
|
||||
assert.equal(coalitionGroups(game.players, game.coalitionState)[0].members.length, 2);
|
||||
game = answer(game, 'c');
|
||||
assert.equal(game.coalitionState!.proposal, null);
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a', 'b', 'c']);
|
||||
assert.deepEqual(game.players.slice(0, 3).map(player => player.ally), [null, null, null]);
|
||||
assert.equal(game.players[3].ally, null);
|
||||
assert.deepEqual(game.ready, []);
|
||||
});
|
||||
|
||||
void test('any participant may decline and the original pair remains unchanged', () => {
|
||||
const original = originalPair();
|
||||
const pending = propose(original);
|
||||
const declined = answer(pending, 'c', false);
|
||||
assert.equal(declined.coalitionState!.proposal, null);
|
||||
assert.deepEqual(coalitionOf(declined.players, declined.coalitionState, 'a')!.members, ['a', 'b']);
|
||||
assert.equal(declined.players[0].ally, 'b');
|
||||
assert.equal(declined.players[1].ally, 'a');
|
||||
assert.equal(declined.players[2].ally, null);
|
||||
});
|
||||
|
||||
void test('stale revision, wrong event, outsider and duplicate approval cannot mutate an invitation', () => {
|
||||
let pending = propose(originalPair());
|
||||
const invitation = pending.coalitionState!.proposal!;
|
||||
const expectedRevision = pending.coalitionState!.revision;
|
||||
const action = {type: 'coalitionAnswer', event: invitation.event, nexus: invitation.nexus,
|
||||
turn: invitation.turn, accept: true, expectedRevision};
|
||||
rejected(pending, 'b', {...action, expectedRevision: expectedRevision - 1});
|
||||
rejected(pending, 'b', {...action, event: 'stale-event'});
|
||||
rejected(pending, 'b', {...action, nexus: 'stale-nexus'});
|
||||
rejected(pending, 'd', action);
|
||||
rejected(pending, 'a', action);
|
||||
pending = answer(pending, 'b');
|
||||
rejected(pending, 'c', action);
|
||||
});
|
||||
|
||||
void test('a pending invitation blocks Nexus readiness and other ordinary alliance actions', () => {
|
||||
const pending = propose(originalPair());
|
||||
rejected(pending, 'a', {type: 'ready'});
|
||||
rejected(pending, 'b', {type: 'alliance'});
|
||||
rejected(pending, 'd', {type: 'alliance', target: 'c'});
|
||||
});
|
||||
|
||||
void test('leaving a triple keeps the other two allied, and ordinary pair break remains valid', () => {
|
||||
let game = propose(originalPair());
|
||||
game = answer(game, 'b'); game = answer(game, 'c');
|
||||
const triple = coalitionOf(game.players, game.coalitionState, 'a')!;
|
||||
const age = triple.edges.find(edge => edge.members[0] === 'a' && edge.members[1] === 'b')!.formedTurn;
|
||||
game = act(game, 'c', {type: 'coalitionLeave', expectedRevision: game.coalitionState!.revision});
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
|
||||
assert.deepEqual(pair.members, ['a', 'b']);
|
||||
assert.equal(pair.edges[0].formedTurn, age);
|
||||
assert.equal(game.players[0].ally, 'b'); assert.equal(game.players[1].ally, 'a');
|
||||
assert.equal(game.players[2].ally, null);
|
||||
game = act(game, 'a', {type: 'alliance'});
|
||||
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
|
||||
assert.equal(game.players[1].ally, null);
|
||||
});
|
||||
|
||||
void test('each seated player sees the public group negotiation and its current revision', () => {
|
||||
const pending = propose(originalPair());
|
||||
const proposal = pending.coalitionState!.proposal!;
|
||||
for (const seat of pending.players) {
|
||||
const view = viewGame(pending, seat.id).coalitionNexus;
|
||||
assert.ok(view);
|
||||
assert.equal(view.me, seat.id);
|
||||
assert.equal(view.revision, pending.coalitionState!.revision);
|
||||
assert.deepEqual(view.proposal, proposal);
|
||||
assert.deepEqual(view.groups.map(group => group.members), [['a', 'b']]);
|
||||
assert.deepEqual(view.players.map(player => player.id), ['a', 'b', 'c', 'd']);
|
||||
}
|
||||
});
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(`${difficulty} gives a legal group answer and uninvolved bots wait`, () => {
|
||||
const pending = propose(originalPair());
|
||||
const proposal = pending.coalitionState!.proposal!;
|
||||
for (const id of ['b', 'c']) {
|
||||
const view = viewGame(pending, id);
|
||||
view.players.find(player => player.id === id)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action);
|
||||
assert.deepEqual(action, {type: 'coalitionAnswer', event: proposal.event,
|
||||
nexus: proposal.nexus, turn: proposal.turn, accept: true,
|
||||
expectedRevision: pending.coalitionState!.revision});
|
||||
const after = act(pending, id, action);
|
||||
assert.ok(after.coalitionState!.proposal?.approvals.includes(id));
|
||||
}
|
||||
for (const id of ['a', 'd']) {
|
||||
const view = viewGame(pending, id);
|
||||
view.players.find(player => player.id === id)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [], 'The approved proposer and outsider wait while the invitation is pending.');
|
||||
}
|
||||
const approved = answer(pending, 'b');
|
||||
const view = viewGame(approved, 'b');
|
||||
view.players.find(player => player.id === 'b')!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [], 'A player who already consented waits for the remaining participant.');
|
||||
});
|
||||
|
||||
void test('closed or earlier Nexus cannot be resumed using an otherwise current group invitation', () => {
|
||||
const pending = propose(originalPair());
|
||||
const invitation = pending.coalitionState!.proposal!;
|
||||
const action: Action = {type: 'coalitionAnswer', event: invitation.event, nexus: invitation.nexus,
|
||||
turn: invitation.turn, accept: true, expectedRevision: pending.coalitionState!.revision};
|
||||
const closed = structuredClone(pending); closed.nexus = false;
|
||||
rejected(closed, 'b', action);
|
||||
const laterTurn = structuredClone(pending); laterTurn.turn++;
|
||||
rejected(laterTurn, 'b', action);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { nexusOfferGame } from './fixture-nexus-offer';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
@@ -26,6 +27,22 @@ function markup(g: Game, id = 'a', busy = false) {
|
||||
return renderToStaticMarkup(createElement(GameTable, { game: viewGame(g, id), busy, send: async () => {}, onExit() {} }));
|
||||
}
|
||||
|
||||
void test('staged group mode renders its real Nexus invitation and hides conflicting pair controls', () => {
|
||||
let game = nexusOfferGame();
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
game = applyAction(game, 'a', {type: 'alliance', target: 'e'});
|
||||
game = applyAction(game, 'e', {type: 'alliance', target: 'a'});
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
|
||||
assert.match(markup(game, 'a'), /Invite Harkonnen observer to Atreides offerer, Emperor recipient/);
|
||||
game = applyAction(game, 'a', {type: 'coalitionPropose', groupId: pair.id,
|
||||
entrant: 'h', expectedRevision: game.coalitionState!.revision});
|
||||
const html = markup(game, 'h');
|
||||
assert.match(html, /Accept invitation/);
|
||||
assert.match(html, /Decline and cancel invitation/);
|
||||
assert.doesNotMatch(html, /Propose \/ accept alliance/);
|
||||
assert.match(markup(game, 'h', true), /disabled=""[^>]*>Accept invitation/);
|
||||
});
|
||||
|
||||
void test('live outgoing offer is named and withdrawable only by its sender, separately from an incoming invitation', () => {
|
||||
const g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' });
|
||||
const html = markup(g);
|
||||
|
||||
Reference in new issue
Block a user