From dd3c199e2e277729db4c2caa2da8c2fe3522e378 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:38:55 +1100 Subject: [PATCH] Keep deal deadlines current as turns advance --- components/public-deals.tsx | 8 +- docs/CURRENT_STATUS.md | 12 +- docs/PUBLIC_DEALS.md | 2 + tests/public-deals-deadline-rollover.test.tsx | 127 ++++++++++++++++++ 4 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 tests/public-deals-deadline-rollover.test.tsx diff --git a/components/public-deals.tsx b/components/public-deals.tsx index e8ce7fd4..6bd1aeee 100644 --- a/components/public-deals.tsx +++ b/components/public-deals.tsx @@ -126,7 +126,11 @@ export function PublicDeals({ game, act, busy }: { const [payer, setPayer] = useState(''); const [paymentRecipient, setPaymentRecipient] = useState(''); 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 needsResponse = requests.length > 0; const paymentDue = requests.some(request => request[0] === 'payment'); @@ -216,7 +220,7 @@ export function PublicDeals({ game, act, busy }: { {paymentKind === 'deferredBribe' && } {paymentKind === 'immediateBribe' ?

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.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 956ee339..4473d344 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,13 @@ open mode gates. ## 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 / 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 @@ -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. Five related files pass 44 cases, including Basic real discard, paid Box recovery, Semuta responses and replay; Box possession uses a conserved staging -exchange. Other holders' protections and suspended-own-turn replay remain -guarded; broader combinations and deployed acceptance are open. +exchange. The later checkpoint above extends replay to a suspended own turn; +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). **Public-deal payments due on later turns — 28 October. Prototyped / diff --git a/docs/PUBLIC_DEALS.md b/docs/PUBLIC_DEALS.md index 11c42e3a..8c60c540 100644 --- a/docs/PUBLIC_DEALS.md +++ b/docs/PUBLIC_DEALS.md @@ -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 must respect obligations already due. These deadline and feasibility policies 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 remain open. diff --git a/tests/public-deals-deadline-rollover.test.tsx b/tests/public-deals-deadline-rollover.test.tsx new file mode 100644 index 00000000..8ecc276e --- /dev/null +++ b/tests/public-deals-deadline-rollover.test.tsx @@ -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); +});