Keep deal deadlines current as turns advance
This commit is contained in:
1 parent
ea910e9c82
commit
dd3c199e2e
4 files changed
+145
-4
No files matched your search
@@ -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 }: {
|
||||
</label>
|
||||
{paymentKind === 'deferredBribe' && <label>Payment due by the end of turn
|
||||
<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>}
|
||||
{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>
|
||||
|
||||
+10
-2
@@ -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 /
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user