Connect live Nexus offer withdrawal and named alliance history
An unallied sender can withdraw its unanswered offer during an ordinary Nexus. The table names the outgoing recipient and keeps this distinct from breaking a formed alliance. The chronicle now records named proposals, withdrawals and breaks. Existing authoritative quote, timing locks, readiness, physical custody, Nexus-card forfeiture and request/version handling remain unchanged. Full-table rendering checks cover owner/recipient/observer controls, replacement, withdrawal, incoming offers, break, blocked timing and JSON continuation. A production SQLite race permits exactly one acceptance or withdrawal at a version; a stale withdrawal cannot break an alliance formed by the accepted request. Private restored views and seat credentials remain intact. All four current AI profiles retain legal participation; no strategy or calibration changes. Independent review found no material issue. Types, lint, all 4973 offline tests, production build and 49 HTTP tests passed on stable source. Browser QA used a fresh three-human Basic lobby, genuine setup and a controlled later-Storm boundary with existing Spice Cards reordered; actual Storm/Worm resolution opened the Nexus. This is a focused position, not a full-game sample. Browser proposal, refresh, keyboard withdrawal, retained incoming invitation, acceptance, break and named chronicle entries passed. At390px the page375px and withdraw339px by46px fit, with16px text. Final refresh restored no offers or alliances. No screenshot was captured; full visual/assistive and all-configuration acceptance remain open. Browser lost-response injection was not performed. All 1459 original rooms, 3081 seats and auxiliary rows are unchanged. SQLite integrity and foreign keys pass. The healthy server was reused without restart, migration, reset or recurring maintenance. Full rules and expansion completion remain gated. The goal retains deferred full AI development, three adjacent approximate75% targets, no usage cutoff, selective subagents and verified Git pushes. Private evidence: 20260921-nexus-offer. Verified source tree: e44e61b651e66cad23326bcca3ba2eecd6bb5a779a16e0273db8d0039f453511
This commit is contained in:
1 parent
91dcca46fb
commit
2b071a44a2
11 files changed
+301
-17
No files matched your search
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
import { nexusReady, orderNexusSpice } from './fixture-nexus-cards';
|
||||
|
||||
/** Genuine Basic setup, then a controlled later-Storm boundary. Existing spice
|
||||
* cards are reordered; actual storm/blow actions create the playable Nexus.
|
||||
* This is a focused position, not a complete first-turn simulation. */
|
||||
export function nexusOfferGame(ids: [string, string, string] = ['a', 'e', 'h'], code = 'NEXOFFER'): Game {
|
||||
let g = createGame(code, newPlayer(ids[0], 'Atreides offerer', 'atreides'));
|
||||
joinGame(g, newPlayer(ids[1], 'Emperor recipient', 'emperor'));
|
||||
joinGame(g, newPlayer(ids[2], 'Harkonnen observer', 'harkonnen'));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g = applyAction(g, ids[0], { type: 'start' });
|
||||
for (const p of g.players)
|
||||
if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
|
||||
assert.equal(g.status, 'playing');
|
||||
Object.assign(g, { turn: 2, phase: 0, storm: 18, stormDials: {}, stormPending: null, ready: [] });
|
||||
orderNexusSpice(g, ['worm', 'land']);
|
||||
for (const id of g.stormDialers) g = applyAction(g, id, { type: 'stormDial', amount: 1 });
|
||||
g = nexusReady(g);
|
||||
assert.equal(g.phase, 1);
|
||||
g = nexusReady(g);
|
||||
assert.ok(g.spiceWindow);
|
||||
g = nexusReady(g);
|
||||
assert.ok(g.nexus);
|
||||
assert.equal(g.spiceWindow, null);
|
||||
assert.equal(g.spiceResolution, null);
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
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';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
function markup(g: Game, id = 'a', busy = false) {
|
||||
return renderToStaticMarkup(createElement(GameTable, { game: viewGame(g, id), busy, send: async () => {}, onExit() {} }));
|
||||
}
|
||||
|
||||
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);
|
||||
assert.match(html, /Your alliance offer is to Emperor recipient/);
|
||||
assert.match(html, /Withdraw your alliance offer/);
|
||||
assert.doesNotMatch(html, />Break alliance</);
|
||||
assert.doesNotMatch(markup(g, 'e'), /Withdraw your alliance offer/);
|
||||
assert.match(markup(g, 'e'), /Atreides offerer · Invited you/);
|
||||
assert.doesNotMatch(markup(g, 'h'), /Withdraw your alliance offer/);
|
||||
assert.match(markup(g, 'a', true), /disabled=""[^>]*>Withdraw your alliance offer/);
|
||||
const changed = applyAction(g, 'a', { type: 'alliance', target: 'h' });
|
||||
assert.match(markup(changed), /Your alliance offer is to Harkonnen observer/);
|
||||
});
|
||||
|
||||
void test('saved withdrawal clears its own offer, resets readiness, preserves custody and records the named change', () => {
|
||||
let g = nexusOfferGame();
|
||||
g = applyAction(g, 'h', { type: 'alliance', target: 'a' });
|
||||
g = applyAction(g, 'a', { type: 'alliance', target: 'e' });
|
||||
assert.match(g.log.at(-1)!.text, /offered an alliance to Emperor recipient/);
|
||||
g.ready = ['h'];
|
||||
const before = JSON.stringify(g);
|
||||
const withdrawn = applyAction(JSON.parse(before), 'a', { type: 'alliance' });
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
assert.deepEqual(withdrawn.allianceOffers, { h: 'a' });
|
||||
assert.deepEqual(withdrawn.players, g.players);
|
||||
assert.deepEqual(withdrawn.ready, []);
|
||||
assert.match(withdrawn.log.at(-1)!.text, /withdrew the alliance offer to Emperor recipient/);
|
||||
assert.doesNotMatch(markup(withdrawn), /Withdraw your alliance offer/);
|
||||
assert.match(markup(withdrawn), /Harkonnen observer · Invited you/);
|
||||
const next = applyAction(withdrawn, 'a', { type: 'alliance', target: 'h' });
|
||||
assert.equal(next.players[0].ally, 'h');
|
||||
assert.match(markup(next), />Break alliance</);
|
||||
assert.doesNotMatch(markup(next), /Withdraw your alliance offer/);
|
||||
const broken = applyAction(next, 'a', { type: 'alliance' });
|
||||
assert.match(broken.log.at(-1)!.text, /broke the alliance with Harkonnen observer/);
|
||||
assert.ok(broken.players.every(p => p.ally === null));
|
||||
});
|
||||
|
||||
void test('closing the Nexus or opening Truthtrance never exposes a usable stale withdrawal', () => {
|
||||
const g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' });
|
||||
const closed = { ...g, nexus: false };
|
||||
assert.doesNotMatch(markup(closed), /Withdraw your alliance offer/);
|
||||
const before = JSON.stringify(closed);
|
||||
assert.throws(() => applyAction(closed, 'a', { type: 'alliance' }), /Nexus/);
|
||||
assert.equal(JSON.stringify(closed), before);
|
||||
const paused = structuredClone(g);
|
||||
paused.truthtrance = { stage: 'priority', queue: [], passed: [], question: null };
|
||||
const html = markup(paused);
|
||||
if (html.includes('Withdraw your alliance offer'))
|
||||
assert.match(html, /disabled=""[^>]*>Withdraw your alliance offer/);
|
||||
assert.throws(() => applyAction(paused, 'a', { type: 'alliance' }));
|
||||
});
|
||||
|
||||
void test('every existing AI profile has legal participation after a human withdraws an offer', () => {
|
||||
let g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' });
|
||||
g = applyAction(g, 'a', { type: 'alliance' });
|
||||
for (const level of DIFFICULTIES) {
|
||||
const view = viewGame(g, 'e');
|
||||
view.players.find(p => p.id === 'e')!.bot = level;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action);
|
||||
const before = JSON.stringify(g);
|
||||
assert.ok(applyAction(g, 'e', action));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { applyAction } from '../game/engine';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { nexusOfferGame } from './fixture-nexus-offer';
|
||||
|
||||
void test('saved offer withdrawal races acceptance without silently breaking a newly formed alliance', async () => {
|
||||
for (const withdrawFirst of [true, false]) {
|
||||
const store = unitStore();
|
||||
assert.ok(store.sqlite instanceof DatabaseSync);
|
||||
try {
|
||||
const host = await store.rooms.createRoom('Atreides offerer', 'atreides', false, []);
|
||||
const code = host.view.code;
|
||||
const peer = await store.rooms.joinRoom(code, 'Emperor recipient', 'emperor');
|
||||
const observer = await store.rooms.joinRoom(code, 'Harkonnen observer', 'harkonnen');
|
||||
const ids: [string, string, string] = [host.view.me, peer.view.me, observer.view.me];
|
||||
let game = nexusOfferGame(ids, code);
|
||||
game = applyAction(game, ids[0], { type: 'alliance', target: ids[1] });
|
||||
game.ready = [ids[2]]; game.version = 41;
|
||||
store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?').run(JSON.stringify(game), 41, code);
|
||||
const seats = store.sqlite.prepare('SELECT * FROM seats').all();
|
||||
const hostAuth = await store.rooms.authenticate(code, host.token);
|
||||
const peerAuth = await store.rooms.authenticate(code, peer.token!);
|
||||
const offered = await store.restart().readSeatView(code, hostAuth);
|
||||
assert.deepEqual(offered.allianceOffers, { [ids[0]]: ids[1] });
|
||||
const actions = [
|
||||
{ auth: hostAuth, action: { type: 'alliance' } },
|
||||
{ auth: peerAuth, action: { type: 'alliance', target: ids[0] } },
|
||||
];
|
||||
if (!withdrawFirst) actions.reverse();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
let arrived = 0;
|
||||
store.hooks.beforeWrite = async () => { if (++arrived === 2) release(); await gate; };
|
||||
const results = await Promise.allSettled(actions.map(({auth, action}) => store.restart().act(code, auth, 41, action)));
|
||||
store.hooks.beforeWrite = undefined;
|
||||
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
|
||||
const after = await store.restart().readRoom(code);
|
||||
assert.equal(after.version, 42);
|
||||
assert.deepEqual(after.allianceOffers, {});
|
||||
assert.deepEqual(after.ready, []);
|
||||
const winner = actions[results.findIndex(r => r.status === 'fulfilled')];
|
||||
const accepted = winner.auth.playerId === ids[1];
|
||||
assert.equal(after.players[0].ally, accepted ? ids[1] : null);
|
||||
assert.equal(after.players[1].ally, accepted ? ids[0] : null);
|
||||
assert.deepEqual(after.players.map(p => ({ spice:p.spice,forces:p.forces,reserves:p.reserves,tanks:p.tanks,hand:p.hand,traitors:p.traitors })),
|
||||
game.players.map(p => ({ spice:p.spice,forces:p.forces,reserves:p.reserves,tanks:p.tanks,hand:p.hand,traitors:p.traitors })));
|
||||
await assert.rejects(store.restart().act(code, hostAuth, 41, { type: 'alliance' }), /table changed/);
|
||||
for (const auth of [hostAuth, peerAuth]) {
|
||||
const restored = await store.restart().readSeatView(code, auth);
|
||||
assert.deepEqual(restored.allianceOffers, {});
|
||||
for (const rival of restored.players.filter(p => p.id !== auth.playerId)) {
|
||||
assert.equal(rival.hand, undefined); assert.equal(rival.traitors, undefined); assert.equal(rival.spice, undefined);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT * FROM seats').all(), seats);
|
||||
} finally { store.sqlite.close(); }
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user