Keep deal deadlines current as turns advance

This commit is contained in:
Quintine committed 2026-10-28 19:38:55 +11:00
1 parent ea910e9c82
commit dd3c199e2e
4 files changed
+145 -4

No files matched your search

+6 -2
View File
@@ -126,7 +126,11 @@ export function PublicDeals({ game, act, busy }: {
const [payer, setPayer] = useState(''); const [payer, setPayer] = useState('');
const [paymentRecipient, setPaymentRecipient] = useState(''); const [paymentRecipient, setPaymentRecipient] = useState('');
const [paymentAmount, setPaymentAmount] = useState('1'); const [paymentAmount, setPaymentAmount] = useState('1');
const [paymentDueTurn, setPaymentDueTurn] = useState(() => String(game.turn)); const [deadlineDraft, setDeadlineDraft] = useState(() => ({ value: String(game.turn), turn: game.turn }));
const previousDeadline = Number(deadlineDraft.value);
const paymentDueTurn = deadlineDraft.turn !== game.turn && deadlineDraft.value.trim() !== '' &&
Number.isSafeInteger(previousDeadline) && previousDeadline < game.turn
? String(game.turn) : deadlineDraft.value;
const requests = publicDealRequests(game); const requests = publicDealRequests(game);
const needsResponse = requests.length > 0; const needsResponse = requests.length > 0;
const paymentDue = requests.some(request => request[0] === 'payment'); const paymentDue = requests.some(request => request[0] === 'payment');
@@ -216,7 +220,7 @@ export function PublicDeals({ game, act, busy }: {
</label> </label>
{paymentKind === 'deferredBribe' && <label>Payment due by the end of turn {paymentKind === 'deferredBribe' && <label>Payment due by the end of turn
<input type="number" inputMode="numeric" min={game.turn} step={1} value={paymentDueTurn} disabled={disabled} <input type="number" inputMode="numeric" min={game.turn} step={1} value={paymentDueTurn} disabled={disabled}
required aria-invalid={!dueValid} onChange={event => setPaymentDueTurn(event.target.value)} /> required aria-invalid={!dueValid} onChange={event => setDeadlineDraft({ value: event.target.value, turn: game.turn })} />
</label>} </label>}
{paymentKind === 'immediateBribe' {paymentKind === 'immediateBribe'
? <p className="fine">No spice is reserved before final acceptance. The payer loses the full amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before the share is allocated. Native payment timing and available funds are checked at final acceptance.</p> ? <p className="fine">No spice is reserved before final acceptance. The payer loses the full amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before the share is allocated. Native payment timing and available funds are checked at final acceptance.</p>
+10 -2
View File
@@ -16,6 +16,13 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
**Public-deal deadline at turn rollover — 28 October. Prototyped / Partial.**
When the mounted form observes a later turn, a past whole-number deadline
advances to the current turn. A future deadline and incomplete edits stay in
place. Three related controls files pass 18 cases, including a mounted form
across turn changes and actual submission callbacks. Browser and deployed
acceptance remain open.
**Recovered Sapho after a suspended Movement turn — 28 October. Prototyped / **Recovered Sapho after a suspended Movement turn — 28 October. Prototyped /
Partial.** The holder may recover and replay the physical card to replace its Partial.** The holder may recover and replay the physical card to replace its
own Last with First, without restoring spent shipment or movement. A different own Last with First, without restoring spent shipment or movement. A different
@@ -39,8 +46,9 @@ Partial.** The unstarted holder may recover the physical card and use First to
replace its own earlier Last, preserving completed turns and the active actor. replace its own earlier Last, preserving completed turns and the active actor.
Five related files pass 44 cases, including Basic real discard, paid Box Five related files pass 44 cases, including Basic real discard, paid Box
recovery, Semuta responses and replay; Box possession uses a conserved staging recovery, Semuta responses and replay; Box possession uses a conserved staging
exchange. Other holders' protections and suspended-own-turn replay remain exchange. The later checkpoint above extends replay to a suspended own turn;
guarded; broader combinations and deployed acceptance are open. other holders' protections, broader combinations and deployed acceptance remain
open.
[Scope](JUICE_OF_SAPHO_RUNTIME.md#recovered-sapho-after-the-same-holders-last--28-october-2026). [Scope](JUICE_OF_SAPHO_RUNTIME.md#recovered-sapho-after-the-same-holders-last--28-october-2026).
**Public-deal payments due on later turns — 28 October. Prototyped / **Public-deal payments due on later turns — 28 October. Prototyped /
+2
View File
@@ -13,6 +13,8 @@ feasibility policy applies. Unpaid terms expire when the deadline passes or the
game ends. Autopilot waits until the due turn before paying. An early payment game ends. Autopilot waits until the due turn before paying. An early payment
must respect obligations already due. These deadline and feasibility policies must respect obligations already due. These deadline and feasibility policies
are provisional implementations of public agreements, not publisher rulings. are provisional implementations of public agreements, not publisher rulings.
If a new turn begins while the offer form remains open, a past whole-number
deadline advances to the current turn; a selected future deadline is retained.
Broader combinations, free-text enforcement, browser and deployed acceptance Broader combinations, free-text enforcement, browser and deployed acceptance
remain open. remain open.
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { runInNewContext } from 'node:vm';
import { isValidElement, type ReactElement, type ReactNode } from 'react';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import * as catalog from '../game/catalog';
import * as publicDeals from '../game/public-deals';
import type { Action, GameView } from '../game/engine';
import type { PublicDeals } from '../components/public-deals';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
const base = { status: 'playing', turn: 2, me: 'a', roomControl: null,
players: [{ id: 'a', name: 'Alice', faction: 'atreides' }, { id: 'b', name: 'Basil', faction: 'harkonnen' }],
deals: [],
} as unknown as GameView;
/** Render one persistent instance and commit effects when their dependencies change. */
function controller() {
const states: unknown[] = [], refs: unknown[] = [], dependencies: unknown[][] = [], sent: Action[] = [];
let cursor = 0, dirty = false, game = base;
let effects: (() => void)[] = [];
const exported: { PublicDeals?: typeof PublicDeals } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/public-deals.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, {
exports: exported,
require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return {
useState(initial: unknown) {
const index = cursor++;
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
return [states[index], (next: unknown) => {
const value = typeof next === 'function' ? next(states[index]) : next;
if (!Object.is(states[index], value)) { states[index] = value; dirty = true; }
}];
},
useRef(initial: unknown) {
const index = cursor++;
if (!(index in refs)) refs[index] = { current: initial };
return refs[index];
},
useEffect(effect: () => void, deps: unknown[]) {
const index = cursor++, previous = dependencies[index];
if (!previous || deps.length !== previous.length || deps.some((value, i) => !Object.is(value, previous[i]))) {
dependencies[index] = [...deps]; effects.push(effect);
}
},
};
if (name === '@/components/ui/button') return { Button: 'button' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/public-deals') return publicDeals;
throw new Error(`Unexpected import ${name}`);
},
});
assert.ok(exported.PublicDeals);
const render = () => {
let nodes: Element[] = [];
for (let attempt = 0; attempt < 4; attempt++) {
cursor = 0; dirty = false; effects = [];
nodes = elements(exported.PublicDeals!({ game, busy: false, act: action => sent.push(action) }));
effects.forEach(effect => effect());
if (!dirty) return nodes;
}
throw new Error('The form did not settle after its effects.');
};
const checkbox = (index: number) => {
const input = render().filter(node => node.type === 'input' && node.props.type === 'checkbox')[index];
assert.ok(input);
(input.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: true } });
};
const select = (index: number, value: string) => {
const input = render().filter(node => node.type === 'select')[index]; assert.ok(input);
(input.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
};
checkbox(0); checkbox(1); select(0, 'deferredBribe'); select(1, 'a'); select(2, 'b');
const terms = render().find(node => node.type === 'textarea'); assert.ok(terms);
(terms.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'Terms' } });
const due = () => {
const input = render().find(node => node.type === 'input' && node.props.type === 'number' && node.props.min === game.turn);
assert.ok(input); return input;
};
return {
render, due, sent,
turn(turn: number) { game = { ...game, turn }; },
edit(value: string) { (due().props.onChange as (event: { target: { value: string } }) => void)({ target: { value } }); },
submit() {
const form = render().find(node => node.type === 'form'); assert.ok(form);
(form.props.onSubmit as (event: { preventDefault(): void }) => void)({ preventDefault() {} });
},
};
}
void test('the mounted deferred form advances an expired default and submits the new current turn', () => {
const ui = controller();
assert.equal(ui.due().props.value, '2');
ui.turn(3);
assert.equal(ui.due().props.value, '3');
assert.equal(ui.due().props['aria-invalid'], false);
ui.submit();
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), { type: 'dealOffer', target: 'b', terms: 'Terms',
payment: { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 1, dueTurn: 3 } });
});
void test('a chosen future deadline stays until the actual turn passes it', () => {
const ui = controller();
ui.edit('5'); ui.turn(3); assert.equal(ui.due().props.value, '5');
ui.turn(5); assert.equal(ui.due().props.value, '5');
ui.turn(6); assert.equal(ui.due().props.value, '6');
});
void test('invalid edits remain editable and are not overwritten on ordinary rerenders', () => {
const ui = controller();
ui.edit('1');
assert.equal(ui.due().props.value, '1'); assert.equal(ui.due().props['aria-invalid'], true);
ui.submit(); assert.equal(ui.sent.length, 0);
ui.edit(''); ui.turn(3); assert.equal(ui.due().props.value, '');
ui.edit('3.5'); ui.turn(4); assert.equal(ui.due().props.value, '3.5');
assert.equal(ui.due().props['aria-invalid'], true);
});