From f9668a577e909ae815051ed08c6eed0531d2da92 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:47:06 +1000 Subject: [PATCH] Allow clearing and replacing auction bids --- components/game-table.tsx | 18 ++++++++++-------- components/richese-auctions.tsx | 23 ++++++++++++----------- lib/integer-input.ts | 6 ++++++ tests/integer-input.test.ts | 19 +++++++++++++++++++ 4 files changed, 47 insertions(+), 19 deletions(-) create mode 100644 lib/integer-input.ts create mode 100644 tests/integer-input.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index b3a29ada..7775549f 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1,4 +1,5 @@ 'use client'; +import { integerInputValue } from '../lib/integer-input'; import { EcazSetup } from './ecaz-setup'; import { StormCardInspector, StormCardLogInspector } from './storm-cards'; import { KwisatzInspector } from './kwisatz-inspector'; @@ -340,13 +341,13 @@ export function GameTable({ const [richeseShipmentEvent, setRicheseShipmentEvent] = useState(''); const useRicheseShipment = !!g.nexusRichese && richeseShipmentEvent === g.nexusRichese.event; const [useSmuggler, setUseSmuggler] = useState(true); - const [bidDraft, setBidDraft] = useState({ auction: '', value: 1 }); - const auctionKey = `${g.code}/${g.turn}/${g.auction?.remaining ?? 0}`; + const [bidDraft, setBidDraft] = useState({ auction: '', value: '' }); const minimumBid = (g.auction?.bid ?? 0) + 1; - const bidAmount = Math.max( - minimumBid, - bidDraft.auction === auctionKey ? bidDraft.value : minimumBid, - ); + const auctionKey = `${g.code}/${g.turn}/${g.auction?.remaining ?? 0}/${minimumBid}`; + const bidText = bidDraft.auction === auctionKey + ? bidDraft.value + : String(minimumBid); + const bidAmount = integerInputValue(bidText); const maximumBid = me.hand?.some((c) => canUseAsKaramaRole(g, me, c), ) @@ -3530,11 +3531,11 @@ export function GameTable({ type="number" min={minimumBid} max={maximumBid} - value={bidAmount} + value={bidText} onChange={(e) => setBidDraft({ auction: auctionKey, - value: Number(e.target.value), + value: e.target.value, }) } /> @@ -3552,6 +3553,7 @@ export function GameTable({ ...payment, }, !Number.isSafeInteger(bidAmount) || + bidAmount < minimumBid || bidAmount > maximumBid, )} {actionButton('Pass this bid', { type: 'passBid' })} diff --git a/components/richese-auctions.tsx b/components/richese-auctions.tsx index 042d2e43..5a7a3c8d 100644 --- a/components/richese-auctions.tsx +++ b/components/richese-auctions.tsx @@ -1,5 +1,6 @@ 'use client'; +import { integerInputValue } from '../lib/integer-input'; import { useId, useState } from 'react'; import type { Action, GameView } from '@/game/engine'; import type { RicheseAuctionMethod } from '@/game/richese-auction'; @@ -247,16 +248,16 @@ export function RicheseAuctionLot({ game, act, busy }: Props) { const me = game.me; const silent = auction.method === 'silent'; const minimum = silent ? 0 : auction.bid + 1; - const amount = - draft.event === auction.event && draft.amount !== '' - ? Number(draft.amount) - : minimum; - const allyPayment = draft.event === auction.event ? Number(draft.ally) : 0; + const bidKey = `${auction.event}/${minimum}`; + const amountText = draft.event === bidKey ? draft.amount : String(minimum); + const allyText = draft.event === bidKey ? draft.ally : '0'; + const amount = integerInputValue(amountText); + const allyPayment = integerInputValue(allyText); const update = (part: Partial) => setDraft({ - event: auction.event, - amount: String(amount), - ally: String(allyPayment), + event: bidKey, + amount: amountText, + ally: allyText, ...part, }); const ownPayment = amount - allyPayment; @@ -368,7 +369,7 @@ export function RicheseAuctionLot({ game, act, busy }: Props) { min={minimum} step={1} max={auction.ownAvailable + auction.allyAvailable} - value={amount} + value={amountText} disabled={busy} onChange={(event) => update({ amount: event.target.value })} /> @@ -380,8 +381,8 @@ export function RicheseAuctionLot({ game, act, busy }: Props) { type="number" min={0} step={1} - max={Math.min(amount, auction.allyAvailable)} - value={allyPayment} + max={Number.isFinite(amount) ? Math.min(amount, auction.allyAvailable) : auction.allyAvailable} + value={allyText} disabled={busy} onChange={(event) => update({ ally: event.target.value })} /> diff --git a/lib/integer-input.ts b/lib/integer-input.ts new file mode 100644 index 00000000..c17c0126 --- /dev/null +++ b/lib/integer-input.ts @@ -0,0 +1,6 @@ +// A blank edit is not a zero. Keep input text separate from submitted numbers. +export function integerInputValue(text: string): number { + if (text.trim() === '') return Number.NaN; + const value = Number(text); + return Number.isSafeInteger(value) ? value : Number.NaN; +} diff --git a/tests/integer-input.test.ts b/tests/integer-input.test.ts new file mode 100644 index 00000000..eb2c22ca --- /dev/null +++ b/tests/integer-input.test.ts @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { integerInputValue } from '../lib/integer-input'; + +void test('clearing a numeric draft stays invalid until a replacement is entered', () => { + for (const replacement of ['2', '5', '9', '20']) { + assert.equal(integerInputValue('1'), 1); + assert.ok(Number.isNaN(integerInputValue(''))); + assert.equal(integerInputValue(replacement), Number(replacement)); + } +}); + +void test('explicit zero remains distinct from an empty or non-integer bid', () => { + assert.equal(integerInputValue('0'), 0); + assert.equal(integerInputValue('02'), 2); + for (const draft of ['', ' ', '-', '1.5', 'Infinity', '9007199254740992']) { + assert.ok(Number.isNaN(integerInputValue(draft)), draft); + } +});