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:
Quintine committed 2026-09-21 10:47:06 +10:00
1 parent 91dcca46fb
commit 2b071a44a2
11 files changed
+301 -17

No files matched your search

+4
View File
@@ -61,6 +61,10 @@ Use **Bribes** beneath the table to pay a non-allied faction. The recipient's
share becomes spendable at the next Mentat Pause; the panel shows your own
incoming bribes and the current payment limit. See [bribe controls](docs/BRIBE_CONTROLS.md).
At a Nexus, an unanswered alliance offer names its recipient and offers
**Withdraw your alliance offer**. Formed alliances keep the separate **Break
alliance** action. See [Nexus offers](docs/NEXUS_OFFERS.md).
The table header offers optional **Sound effects**, with mute, volume and a test
sample. Short cues mark phase changes and automatic action notices. Sound starts
off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md)
+3
View File
@@ -1949,3 +1949,6 @@ footer {
.bribe-fields label { display: grid; gap: .35rem; flex: 1 1 12rem; min-width: 0; }
.bribe-fields select, .bribe-fields input { min-height: 44px; width: 100%; min-width: 0; font-size: 1rem; }
.bribe-fields button { font-size: 1rem; min-height: 44px; max-width: 100%; height: auto; white-space: normal; overflow-wrap: anywhere; }
.nexus-offer .notice, .nexus-offer .game-action { font-size: 1rem; }
.nexus-offer .game-action { min-height: 44px; height: auto; max-width: 100%; white-space: normal; }
+11
View File
@@ -210,6 +210,8 @@ export function GameTable({
exitDisabled?: boolean;
}) {
const me = g.players.find((p) => p.id === g.me)!;
const outgoingAlliance = g.allianceOffers[me.id];
const outgoingAllianceName = g.players.find((p) => p.id === outgoingAlliance)?.name;
const shipmentAvailable = nexusGuildShipmentAvailable(g);
const movementAvailable = nexusGuildMovementAvailable(g);
const guildCunning = nexusGuildCunningActive(g);
@@ -3392,6 +3394,15 @@ export function GameTable({
<p className="muted">
A sandworm has opened a Nexus. Alliances may change now.
</p>
{outgoingAlliance && !me.ally && (
<div className="nexus-offer">
<p className="notice">
Your alliance offer is to {outgoingAllianceName ?? 'a player no longer seated'}.
You remain unallied until both players agree.
</p>
{actionButton('Withdraw your alliance offer', { type: 'alliance' })}
</div>
)}
<label>
Alliance partner
<select
+15 -16
View File
@@ -6,23 +6,22 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Latest delivered checkpoint: `7814f74`, [durable table discussion](TABLE_DISCUSSION.md).
Types, lint, **4,963 offline tests**, production build and **48 HTTP tests** passed.
All 1,373 opening rooms, 2,883 seats and existing auxiliary rows stayed unchanged;
new isolated QA brought the local baseline to 1,418 rooms and 2,991 seats. The
running server was reused. Earlier checkpoints include `b2fcef8` for Storm/Spice
practice and `66c41b2` for Kwisatz Haderach inspection. The `8f7a62c` policy
checkpoint removed the usage cutoff and deferred AI refinement until feature
completion, with three adjacent 75% targets.
Latest delivered checkpoint: `91dcca4`, [ordinary bribe controls](BRIBE_CONTROLS.md).
Types, lint, **4,968 offline tests**, production build and **49 HTTP tests** passed.
All 1,418 opening rooms, 2,991 seats and auxiliary rows stayed unchanged. Isolated
QA brought the local baseline to 1,459 rooms and 3,081 seats. Browser payment,
refresh, keyboard controls and a 390px layout passed; screenshot capture and full
visual acceptance remain open. The healthy server was reused. Prior checkpoints
include `7814f74` for table discussion and `b2fcef8` for Storm/Spice practice.
Current work: [ordinary bribe controls](BRIBE_CONTROLS.md) connect existing engine
payments to named recipients, private deferred balances and authoritative
spending limits. Binding Truthtrance answers, reserved costs and Bureaucrat
continuations remain intact. Source-bound checks, independent review, browser
and preservation/Git evidence belong to the private checkpoint. Strategic AI
negotiation and complete rules/multiplayer acceptance remain open. The independent
Bureaucrat/Emperor gift source review found an unresolved payment-classification
and disclosure boundary; the existing user question remains pending.
Current work: [live Nexus offer withdrawal](NEXUS_OFFERS.md) connects the existing
unanswered-offer action, shows the recipient and records named proposal/withdrawal
history. Current alliance rules, pending-window locks, readiness and version
fences remain intact. Source-bound checks, independent review, browser and
preservation/Git evidence belong to the private checkpoint. The broader game
remains unfinished. The separate Bureaucrat/Emperor gift question is pending.
The `8f7a62c` policy checkpoint removed usage checks/cutoffs and deferred AI
refinement until feature completion, followed by three adjacent 75% targets.
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
still cross recorded pending rulings. Continue independent features rather than
+11
View File
@@ -1,5 +1,16 @@
# Dune implementation status
## 21 September 2026 — live Nexus offer withdrawal
An unallied sender can now see the recipient of its outgoing Nexus offer and
withdraw it through the existing action. Public history distinguishes proposals,
withdrawals and alliance breaks. [Nexus offer controls](NEXUS_OFFERS.md) retain
the current quote, timing, readiness, card forfeiture and version fences.
Focused full-table rendering, JSON continuation, legal AI participation and
production SQLite race checks accompany the private source-bound browser,
preservation, independent review and Git record. No strategy, mode or publication
gate changes; the broader game remains unfinished.
## 21 September 2026 — ordinary bribe controls
Players can now initiate the existing out-of-turn bribe action, with server-derived
+40
View File
@@ -0,0 +1,40 @@
# Live Nexus alliance offers
21 September 2026. Ordinary Nexus negotiations now show the owner's named
outgoing offer and let an unallied sender withdraw it. This connects an existing
engine action that previously had controls only in the introduction.
Choose a partner to propose or reciprocate an invitation. Until both agree,
each player remains unallied. The sender may replace the offer or use **Withdraw
your alliance offer**. Withdrawal clears only that player's outgoing offer;
another player's invitation can remain. Existing partners still use **Break
alliance**, which ends their alliance during the Nexus.
The public chronicle distinguishes proposals, withdrawals, formation and breaks.
The player view already exposes ordinary alliance offers; no additional private
information is disclosed. Existing Nexus timing, Homeworld restrictions, Nexus
card forfeiture, readiness reset and authoritative version handling are unchanged.
The underlying [ordinary quote](../game/nexus-alliance.ts) retains the settled
base [rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
contract; this is a control and feedback change, not a new alliance ruling.
A saved unanswered offer remains withdrawable after refresh. If acceptance and
withdrawal race, only one action can commit at that version. A stale withdrawal
is rejected rather than breaking an alliance that formed in the meantime; the
updated table provides the current choices. Unsaved partner selection is only
a local draft. Pending Truthtrance and other response/decision controls preserve
their existing locks.
Focused rendering and engine checks cover sender/recipient/observer views,
replacement, withdrawal, break, incoming-offer preservation, named history,
blocked timing and JSON continuation. A production SQLite case races acceptance
and withdrawal through independent room-module instances, then restores each
seat and verifies unchanged resources, private fields and credentials. Existing
AI profiles retain legal participation; strategy has not been tuned.
The fixture completes genuine three-player Basic setup, then stages a later
Storm boundary and reorders existing Spice Cards. Real storm and worm/blow
actions open the test Nexus. This is a focused position, not a complete first-turn
simulation. Final browser, source-bound checks, preservation, independent review
and Git evidence remain in the private checkpoint. Full diplomacy, special
alliance powers and all expansion/visual acceptance remain unfinished.
+10 -1
View File
@@ -22825,6 +22825,9 @@ function applyActionInner(
'Alliances change only during a Nexus.',
);
const target = action.target ? stringField(action.target) : null;
const formerAlly = p.ally ? getPlayer(g, p.ally).name : null;
const formerOffer = g.allianceOffers[id];
const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name;
let other: Player | null = null;
if (target !== null) {
other = getPlayer(g, target);
@@ -22848,12 +22851,18 @@ function applyActionInner(
for (const player of g.players) player.ally = quote.allies[player.id];
g.allianceOffers = quote.offers;
if (target === null) {
log(g, `${p.name} is unallied.`);
log(g, formerAlly
? `${p.name} broke the alliance with ${formerAlly}; both are unallied.`
: formerOffer
? `${p.name} withdrew the alliance offer to ${formerOfferName ?? 'a player no longer seated'} and remains unallied.`
: `${p.name} is unallied.`);
} else if (quote.formed) {
requireRule(other, 'The alliance partner is no longer seated.');
p.allySinceTurn = other.allySinceTurn = g.turn;
discardAllianceNexusCards(g, p, other);
log(g, `${p.name} and ${other.name} formed an alliance.`);
} else {
log(g, `${p.name} offered an alliance to ${other!.name}; both players must agree before an alliance forms.`);
}
g.ready = [];
return g;
+24
View File
@@ -2083,6 +2083,30 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover private forecast authorization, public reveal records, saved continuation, original versus modified distance and malformed component rejection. Broader rules and visual acceptance remain separate.', evidence: ['tests/storm-card-inspection.test.ts'] },
],
},
{
id: 'nexus-offers',
title: 'Propose, withdraw and break alliances',
category: 'Getting started',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Manage ordinary alliance offers during a Nexus; each partner must agree before an alliance forms.',
steps: [
'When a Nexus opens, choose a partner and use Propose / accept alliance. A player who has invited you is marked in the partner list. Both players must be unallied and the current rules must permit the pairing.',
'Your outgoing offer names its recipient. You remain unallied until the other player agrees. Choose another partner to replace the offer, or use Withdraw your alliance offer to cancel it.',
'Withdrawal removes only your outgoing offer. Another player’s invitation may remain, and you can still accept it while the Nexus is open. Once allied, use the separate Break alliance action to leave during a Nexus.',
'The table chronicle records proposals, formation, withdrawals and breaks. Changing an offer clears Nexus readiness so the table can review the updated negotiations.',
'Refresh restores saved offers and alliances. If acceptance and withdrawal arrive together, only one action is committed; refresh or reconcile a changed table before choosing again. Pending responses and decisions must finish first.',
'Allied forces, cards and spice keep their owners. Alliance funding and individual faction powers use their own controls; special alliances and expansion restrictions remain separate.',
],
related: ['spice-blow', 'alliance-funding', 'table-discussion', 'privacy'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Existing ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. Broader special diplomacy remains unfinished.', evidence: ['game/nexus-alliance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'Named outgoing offers now have live withdrawal, alongside existing proposal, acceptance and break controls.', evidence: ['components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles can continue legally after withdrawal. Full diplomacy and difficulty tuning remain deferred.' },
{ area: 'Documentation', status: 'Implemented', detail: 'Offer versus alliance, incoming invitations, readiness, refresh and concurrent choices are explained.', evidence: ['docs/NEXUS_OFFERS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Full-table rendering, JSON continuation, pending-window rejection and SQLite acceptance/withdrawal races are covered. All-configuration and full visual acceptance remain open.', evidence: ['tests/nexus-offer-controls.test.tsx', 'tests/nexus-offer-recovery.test.ts', 'tests/nexus-alliance.test.ts'] },
],
},
{
id: 'alliance-funding',
title: 'Fund an ally’s bid or shipment',
+29
View File
@@ -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;
}
+94
View File
@@ -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);
}
});
+60
View File
@@ -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(); }
}
});