Prototype public deal agreements
This commit is contained in:
1 parent
c3f37a574d
commit
86c51b4aa7
15 files changed
+799
-7
No files matched your search
@@ -4,6 +4,8 @@ import { createFactionDraft, validateFactionDraft, currentFactionDrafter, choose
|
||||
FactionDraftError, type FactionDraft } from './faction-draft';
|
||||
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
|
||||
FactionSwapError, type FactionSwap } from './faction-swap';
|
||||
import { createPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
|
||||
releasePublicDeal, PublicDealError, type PublicDeal } from './public-deals';
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
|
||||
@@ -1413,6 +1415,8 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
/** Public written agreements. Their prose is honored by players, not parsed. */
|
||||
deals?: PublicDeal[];
|
||||
/** A human-to-human lobby offer, pending the recipient's consent. */
|
||||
factionSwap?: FactionSwap;
|
||||
/** Public prediction-number faction selection, present only while in progress. */
|
||||
@@ -5796,6 +5800,16 @@ function factionSwapIntegrity(g: Game): void {
|
||||
g.players.some(player => player.id === g.factionSwap!.to && !player.bot),
|
||||
'Faction swap participants must be human seats.');
|
||||
}
|
||||
function publicDealRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
catch (error) {
|
||||
if (error instanceof PublicDealError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function publicDealIntegrity(g: Game): void {
|
||||
if (g.deals !== undefined) publicDealRule(() => validatePublicDeals(g.deals, g.players));
|
||||
}
|
||||
function assignLobbyFaction(player: Player, chosen: FactionId): void {
|
||||
player.faction = chosen;
|
||||
if (player.bot) player.name = `${faction(chosen).name} AI`;
|
||||
@@ -40001,6 +40015,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
factionSwapIntegrity(state);
|
||||
publicDealIntegrity(state);
|
||||
if (state.factionSwap) requireRule(
|
||||
['factionSwapRespond', 'factionSwapCancel', 'advanceBots'].includes(action?.type),
|
||||
'Resolve the faction swap before changing the lobby.',
|
||||
@@ -40070,6 +40085,40 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
ambassadorRevivalIncomeIntegrity(state);
|
||||
ecazCollectionIntegrity(state);
|
||||
ecazAllianceIntegrity(state);
|
||||
if (['dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type)) {
|
||||
requireRule(state.status === 'playing', 'Public deals require a game in progress.');
|
||||
const player = getPlayer(state, id);
|
||||
requireRule(!player.bot && !player.autopilot, 'Only a human controlling their seat can manage a deal.');
|
||||
const g = structuredClone(state);
|
||||
const deals = g.deals ?? [];
|
||||
if (action.type === 'dealOffer') {
|
||||
requireRule(Object.keys(action).sort().join(',') === 'target,terms,type' &&
|
||||
typeof action.target === 'string' && typeof action.terms === 'string', 'Choose a recipient and write the public terms.');
|
||||
const offer = publicDealRule(() => createPublicDeal(g.players, id, action.target as string,
|
||||
action.terms as string, g.turn, crypto.randomUUID()));
|
||||
requireRule(!deals.some(deal => deal.id === offer.id), 'This deal identifier is already in use.');
|
||||
g.deals = [...deals, offer];
|
||||
} else {
|
||||
const keys = action.type === 'dealRespond' ? 'accept,deal,type' :
|
||||
action.type === 'dealRelease' ? 'agree,deal,type' : 'deal,type';
|
||||
requireRule(Object.keys(action).sort().join(',') === keys && typeof action.deal === 'string',
|
||||
'Choose the current deal action.');
|
||||
const index = deals.findIndex(deal => deal.id === action.deal);
|
||||
requireRule(index >= 0, 'This deal is no longer available.');
|
||||
const current = deals[index];
|
||||
let next: PublicDeal;
|
||||
if (action.type === 'dealRespond') {
|
||||
requireRule(typeof action.accept === 'boolean', 'Choose whether to accept or decline.');
|
||||
next = publicDealRule(() => respondPublicDeal(current, g.players, id, action.accept as boolean));
|
||||
} else if (action.type === 'dealRelease') {
|
||||
requireRule(typeof action.agree === 'boolean', 'Choose whether to request cancellation.');
|
||||
next = publicDealRule(() => releasePublicDeal(current, g.players, id, action.agree as boolean));
|
||||
} else next = publicDealRule(() => withdrawPublicDeal(current, g.players, id));
|
||||
g.deals = deals.map((deal, position) => position === index ? next : deal);
|
||||
}
|
||||
publicDealIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
// Seat control is independent of gameplay locks and must preserve their exact continuation.
|
||||
if (action?.type === 'setAutopilot') {
|
||||
requireRule(
|
||||
@@ -40627,6 +40676,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
factionSwapIntegrity(state);
|
||||
publicDealIntegrity(state);
|
||||
victoryContinuationIntegrity(state);
|
||||
if (state.victoryContinuation) return state;
|
||||
bidPromiseIntegrity(state);
|
||||
@@ -44960,6 +45010,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
factionSwapIntegrity(state);
|
||||
publicDealIntegrity(state);
|
||||
victoryContinuationIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
charityOrderIntegrity(state);
|
||||
@@ -45258,6 +45309,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
version: g.version,
|
||||
host: g.host,
|
||||
status: g.status,
|
||||
deals: g.deals ?? [],
|
||||
factionDraft: g.factionDraft ?? null,
|
||||
factionSwap: g.factionSwap ?? null,
|
||||
twoPlayerControllers: g.twoPlayerControllers ?? null,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/** A public agreement between two human seats. Terms are recorded, not parsed
|
||||
* or automatically enforced by the game engine. */
|
||||
export type PublicDeal = {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
terms: string;
|
||||
turn: number;
|
||||
status: 'offered' | 'active' | 'declined' | 'withdrawn' | 'released';
|
||||
releaseVotes: string[];
|
||||
};
|
||||
|
||||
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null };
|
||||
|
||||
export class PublicDealError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PublicDealError';
|
||||
}
|
||||
}
|
||||
|
||||
const id = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
function requireDeal(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new PublicDealError(message);
|
||||
}
|
||||
|
||||
function roster(players: readonly PublicDealSeat[]): void {
|
||||
requireDeal(Array.isArray(players) && players.length >= 1 && players.length <= 6 &&
|
||||
players.every(player => object(player) && id(player.id)) &&
|
||||
new Set(players.map(player => player.id)).size === players.length,
|
||||
'A deal requires distinct seated players.');
|
||||
}
|
||||
function participants(players: readonly PublicDealSeat[], from: string, to: string): [PublicDealSeat, PublicDealSeat] {
|
||||
const proposer = players.find(player => player.id === from);
|
||||
const recipient = players.find(player => player.id === to);
|
||||
requireDeal(proposer && recipient && proposer !== recipient && !proposer.bot && !recipient.bot,
|
||||
'A deal requires two different human seats.');
|
||||
return [proposer, recipient];
|
||||
}
|
||||
function notAllied(from: PublicDealSeat, to: PublicDealSeat): void {
|
||||
requireDeal(from.ally !== to.id && to.ally !== from.id,
|
||||
'This deal is available only between seats that are not already allied.');
|
||||
}
|
||||
|
||||
/** Validate the persisted public ledger without imposing current alliance state
|
||||
* on earlier offers or active agreements. */
|
||||
export function validatePublicDeals(value: unknown, players: readonly PublicDealSeat[]): asserts value is PublicDeal[] {
|
||||
roster(players);
|
||||
requireDeal(Array.isArray(value), 'The public deal ledger is malformed.');
|
||||
const ids = new Set<string>();
|
||||
for (const deal of value) {
|
||||
requireDeal(object(deal) && Object.keys(deal).sort().join(',') === 'from,id,releaseVotes,status,terms,to,turn' &&
|
||||
id(deal.id) && id(deal.from) && id(deal.to) && deal.from !== deal.to &&
|
||||
typeof deal.terms === 'string' && deal.terms.length > 0 && deal.terms.length <= 500 && deal.terms === deal.terms.trim() &&
|
||||
Number.isSafeInteger(deal.turn) && (deal.turn as number) > 0 &&
|
||||
typeof deal.status === 'string' && ['offered', 'active', 'declined', 'withdrawn', 'released'].includes(deal.status) &&
|
||||
Array.isArray(deal.releaseVotes) && deal.releaseVotes.every(id),
|
||||
'A public deal is malformed.');
|
||||
requireDeal(!ids.has(deal.id), 'Public deal IDs must be unique.');
|
||||
ids.add(deal.id);
|
||||
participants(players, deal.from, deal.to);
|
||||
const votes = deal.releaseVotes as string[];
|
||||
requireDeal(new Set(votes).size === votes.length && votes.every(vote => vote === deal.from || vote === deal.to) &&
|
||||
(deal.status === 'active' ? votes.length <= 1 : deal.status === 'released' ? votes.length === 2 : votes.length === 0),
|
||||
'The public deal has invalid release votes.');
|
||||
}
|
||||
}
|
||||
|
||||
export function createPublicDeal(
|
||||
players: readonly PublicDealSeat[], actor: string, target: string, terms: string, turn: number, event: string,
|
||||
): PublicDeal {
|
||||
roster(players);
|
||||
requireDeal(id(actor) && id(target) && actor !== target && id(event) && Number.isSafeInteger(turn) && turn > 0,
|
||||
'Choose a current public deal and two different seats.');
|
||||
requireDeal(typeof terms === 'string' && terms.trim().length > 0 && terms.trim().length <= 500,
|
||||
'Write deal terms between 1 and 500 characters.');
|
||||
const [from, to] = participants(players, actor, target);
|
||||
notAllied(from, to);
|
||||
return { id: event, from: actor, to: target, terms: terms.trim(), turn, status: 'offered', releaseVotes: [] };
|
||||
}
|
||||
|
||||
export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, accept: boolean): PublicDeal {
|
||||
validatePublicDeals([deal], players);
|
||||
requireDeal(deal.status === 'offered' && actor === deal.to && typeof accept === 'boolean',
|
||||
'Only the recipient may accept or decline an outstanding deal.');
|
||||
if (accept) notAllied(...participants(players, deal.from, deal.to));
|
||||
return { ...deal, status: accept ? 'active' : 'declined', releaseVotes: [] };
|
||||
}
|
||||
|
||||
export function withdrawPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string): PublicDeal {
|
||||
validatePublicDeals([deal], players);
|
||||
requireDeal(deal.status === 'offered' && actor === deal.from,
|
||||
'Only the proposer may withdraw an outstanding deal.');
|
||||
return { ...deal, status: 'withdrawn', releaseVotes: [] };
|
||||
}
|
||||
|
||||
/** Each participant can vote to release an active deal or withdraw their vote.
|
||||
* Both votes release it permanently, even if the players later become allies. */
|
||||
export function releasePublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, agree: boolean): PublicDeal {
|
||||
validatePublicDeals([deal], players);
|
||||
requireDeal(deal.status === 'active' && (actor === deal.from || actor === deal.to) && typeof agree === 'boolean',
|
||||
'Only a participant may change a release vote on an active deal.');
|
||||
const votes = deal.releaseVotes.filter(vote => vote !== actor);
|
||||
if (agree) votes.push(actor);
|
||||
return { ...deal, status: votes.length === 2 ? 'released' : 'active', releaseVotes: votes };
|
||||
}
|
||||
+9
-7
@@ -288,26 +288,28 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
{
|
||||
id: 'bribes',
|
||||
title: 'Paying and collecting bribes',
|
||||
title: 'Public deals and bribes',
|
||||
category: 'Getting started',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Pay a non-allied faction now; its recipient share becomes spendable at the next Mentat opening, including next turn for a late Mentat payment.',
|
||||
summary: 'Record a public agreement with another human, or pay a non-allied faction a bribe collected at the next Mentat opening.',
|
||||
steps: [
|
||||
'Open Bribes beneath the table. Choose a non-allied faction and a positive whole amount, then use the named Pay button. The amount is public and leaves your supply immediately. A message or promise alone does not pay spice.',
|
||||
'The recipient keeps paid spice separately until the next Mentat opening. A payment after the current opening waits for next turn’s opening. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.',
|
||||
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
|
||||
'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.',
|
||||
'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.',
|
||||
'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. Strategic bribe initiation and negotiation remain deferred. Discuss other agreements with human players; broader prose deals are not automatically enforced.',
|
||||
'In Public deals, propose written terms to another non-allied human. The recipient can accept or decline, and the proposer can withdraw an outstanding offer. Once accepted, either party may request release; both must agree. The public record remains active until mutually released, even when players consider it fulfilled.',
|
||||
'Players must honor legal deal terms; an impossible part is void. The game does not interpret or enforce free-text promises or determine impossibility, and an agreement does not itself pay spice. Use Bribes for an actual payment. Deals must not transfer cards, leaders, forces or faction advantages or contradict the rules.',
|
||||
'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. AI deal negotiation, automatic enforcement of prose, and agreements with more than two parties remain unfinished.',
|
||||
],
|
||||
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Existing payment and escrow include quiet Mentat payments deferred to next turn’s opening, with private server-derived limits and supported Bureaucrat continuation. Complete negotiation and all module combinations remain open.', evidence: ['game/bribe-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'Named recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/bribes.tsx'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Public two-human offer, consent and mutual release are recorded, while prose compliance remains player managed. Bribe payment and escrow include quiet Mentat payments deferred to next turn’s opening, with private server-derived limits and supported Bureaucrat continuation. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.ts', 'game/bribe-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/public-deals.tsx', 'components/bribes.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection and existing Bureaucrat responses work. Strategic bribe initiation and negotiation wait for feature completion.' },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Payment, escrow, restrictions, saved continuation and remaining boundaries are recorded.', evidence: ['docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original late Mentat/next-turn receipt journeys supplement existing projection/JSON, committed budgets, privacy, target validation, Bureaucrat, controls and concurrent HTTP evidence. Full integrated acceptance remains open.', evidence: ['tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts', 'tests/battle-promises.test.ts', 'tests/shipment-promises.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, player-managed terms, payment, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Public deal consent, release, pending decisions and authenticated visibility have focused coverage. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user