From f29f14128f9c72691875231a1e86e59b34c6315c Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:30:34 +1100 Subject: [PATCH] Connect provisional Basic full-hand Nullentropy Box exchange --- components/nullentropy-box.tsx | 5 +++++ docs/CURRENT_STATUS.md | 6 ++++++ docs/NULLENTROPY_BOX_ENGINE_AUDIT.md | 29 ++++++++++++++++++++++++++-- docs/RICHESE_ACQUISITION_RULES.md | 5 +++++ docs/RULE_DECISIONS.md | 1 + game/engine.ts | 14 +++++++++++--- game/nullentropy-box.ts | 10 ++++++---- game/reference.ts | 7 ++++--- tests/nullentropy-box-engine.test.ts | 8 ++++---- 9 files changed, 69 insertions(+), 16 deletions(-) diff --git a/components/nullentropy-box.tsx b/components/nullentropy-box.tsx index 4e304590..9c783bc3 100644 --- a/components/nullentropy-box.tsx +++ b/components/nullentropy-box.tsx @@ -5,6 +5,8 @@ import type { Action, GameView } from '@/game/engine'; import { Button } from './ui/button'; import { CardInspector, CardRules } from './card-inspector'; +const fullHandPolicy = 'Provisional Basic full-hand exchange: retrieval and the played Box discard settle atomically, leaving your final hand at its limit. The printed intermediate-capacity timing remains unresolved.'; + type BoxProps = { game: GameView; act: (action: Action) => void; @@ -21,6 +23,7 @@ export function NullentropyBox({ game, act, busy }: BoxProps) {

Your Nullentropy Box search is paid. Complete the private card choice in the decision panel above. + {box.fullHandExchange && {fullHandPolicy}}

); return ( @@ -39,6 +42,7 @@ export function NullentropyBox({ game, act, busy }: BoxProps) { the cards. If only one card is eligible, the server takes it automatically.

+ {box.fullHandExchange &&

{fullHandPolicy}

} {box.blocked && ( @@ -80,6 +84,7 @@ export function NullentropySearch({ game, act, busy }: BoxProps) { These discard cards are visible only to you during this paid search. Inspection does not select a card.

+ {game.nullentropy?.fullHandExchange &&

{fullHandPolicy}

}
c.id), handLimit(owner), + !g.advanced, ); } catch (error) { throw new RuleError( @@ -4194,6 +4198,7 @@ function finishNullentropy( cardId, order, handLimit(owner), + !g.advanced, ); owner.hand = result.ownerHand; g.discard = result.discard; @@ -4257,10 +4262,12 @@ function nullentropyView(g: Game, owner: Player) { (c) => richeseCardDefinition(c)?.card.effect === 'nullentropyBox', ); if (!card) return null; + const fullHandExchange = !g.advanced && owner.hand.length === handLimit(owner); if (pending?.player === owner.id) { const blocked = nullentropyIntegrity(g); return { card, + ...(fullHandExchange ? { fullHandExchange: true as const } : {}), blocked, search: blocked ? null @@ -4280,7 +4287,8 @@ function nullentropyView(g: Game, owner: Player) { if (!(error instanceof RuleError)) throw error; blocked = error.message; } - return { card, blocked, search: null }; + return { card, blocked, search: null, + ...(fullHandExchange ? { fullHandExchange: true as const } : {}) }; } function savedTransferResponses(g: Game) { const canceled = (pending: Game['pendingKarama']) => diff --git a/game/nullentropy-box.ts b/game/nullentropy-box.ts index cd8b0e5d..958f3116 100644 --- a/game/nullentropy-box.ts +++ b/game/nullentropy-box.ts @@ -37,6 +37,7 @@ export function resolveNullentropyBox( selectedId: string, remainingOrderIds: readonly string[], handLimit: number, + allowFullHandExchange = false, ): NullentropyBoxResult { if (!Number.isSafeInteger(handLimit) || handLimit < 0) throw new RangeError('The hand limit must be a nonnegative safe integer.'); @@ -46,10 +47,11 @@ export function resolveNullentropyBox( throw new Error( 'Use the canonical Nullentropy Box physically in your hand.', ); - if (ownerHand.length >= handLimit) - throw new Error( - 'Full-hand Nullentropy Box use is unresolved; a pre-existing free hand slot is required by the current guard.', - ); + if (ownerHand.length > handLimit || + (!allowFullHandExchange && ownerHand.length === handLimit)) + throw new Error(allowFullHandExchange + ? 'The final exchanged hand must not exceed its hand limit.' + : 'Full-hand Nullentropy Box use is unresolved; a pre-existing free hand slot is required by the current guard.'); const selected = discard.find((card) => card.id === selectedId); if (!selected || isAnyBox(selected)) throw new Error('Choose a discard card other than any Nullentropy Box.'); diff --git a/game/reference.ts b/game/reference.ts index 53bae9a9..e80d613a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -940,6 +940,7 @@ export const RULE_TOPICS: RuleTopic[] = [ title: 'Nullentropy Box: paid private discard search', category: 'Cards', coverage: 'Partial', + developmentStage: 'Prototyped', summary: 'Pay two spice to search privately, recover one non-Box card, shuffle the remainder and discard the Box on top.', steps: [ @@ -948,10 +949,10 @@ export const RULE_TOPICS: RuleTopic[] = [ 'During the paid decision, inspect the cards and use Take on one card to finish. Inspection itself does not select anything. There is no free cancellation after viewing the pile and no second confirmation after selection. If exactly one legal card is available, the server selects it automatically without asking for an identical acknowledgement.', 'Payment is recorded once before inspection. Refresh or reconnect restores an unfinished paid search for its owner without charging again. While it is open, other gameplay actions wait so they cannot change the reserved Box or discard pile. The completed search saves the recovered card and shuffled pile before restoring an interrupted response, decision or phase opening. Recovery does not charge, select or shuffle again, and does not replay the earlier action.', 'The selected card enters your hand privately. Other players do not receive the selected identity or the shuffled discard faces. Temporary search access ends on completion; the normal discard pile remains private, even though the played Box is placed face up on top. Past inspection records do not become a permanent live discard browser.', - 'An active Truthtrance question and exact card or transaction commitments must resolve as required. A full hand is currently guarded: the printed sequence adds the recovered card before discarding the Box, and the temporary capacity question is unresolved. The current implementation requires a pre-existing free hand slot. This is not a verified prohibition on every net-zero exchange.', + 'An active Truthtrance question and exact card or transaction commitments must resolve as required. Basic full-hand holders now have a visibly provisional atomic exchange: retrieve one non-Box and discard the played Box, leaving the final hand at its current limit. The printed intermediate-capacity question remains unresolved. Advanced still requires a pre-existing spare slot, and Basic over-limit hands are rejected.', 'A pending Guild refund claim on a discarded shipping Karama also blocks starting the search while that provisional refund policy is unresolved. The guard protects the competing claim to that card; it is not a general ban on Box during Shipment. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.', 'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.', - 'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation and the provisional Guild refund interaction remain unresolved. In the explicit Semuta preview, another holder may recover the used Box after a clean completed search without replaying payment or shuffle; other Semuta discard sources remain unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.', + 'The paid search and continuations are integrated in development fixtures, with a connected Basic full-hand prototype. Advanced full-hand activation and the provisional Guild refund interaction remain guarded. The explicit Semuta preview can recover the used Box after a clean completed search without replaying payment or shuffle; other discard compositions remain separate. Full Richese starts and complete browser/mobile/full-game acceptance remain gated.', ], related: [ 'card-richese-nullentropy-box', @@ -965,7 +966,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Paid inspection, exact private custody, server-shuffled remainder, Box on top, sole-choice automatic completion and restored parent continuations are integrated. Full-hand and provisional Guild-refund guards remain; future discard reactions are unfinished.', + 'Paid inspection, exact private custody, shuffled remainder, Box on top, sole-choice completion and restored parent continuations are integrated. Basic full-hand exchange has a connected provisional final-capacity policy. Advanced full-hand and Guild-refund guards remain; wider discard/commitment refinement is separate.', }, { area: 'Player controls', diff --git a/tests/nullentropy-box-engine.test.ts b/tests/nullentropy-box-engine.test.ts index c29bb5f0..72a72842 100644 --- a/tests/nullentropy-box-engine.test.ts +++ b/tests/nullentropy-box-engine.test.ts @@ -26,7 +26,7 @@ void test('all twelve holders can pay and search across nine phases in both mode }); void test('prepayment projection has no candidates and selection entitlement ends after exact private recovery',()=>{ const g=fixture(), selected=g.discard[0]; - const unpaid=viewGame(g,'p').nullentropy!;assert.equal(unpaid.search,null);assert.deepEqual(Object.keys(unpaid).sort(),['blocked','card','search']); + const unpaid=viewGame(g,'p').nullentropy!;assert.equal(unpaid.search,null); assert.equal(viewGame(g,'q').nullentropy,null); const paid=begin(g);assert.equal(viewGame(paid,'p').nullentropy!.search!.cards.length,3); assert.equal(viewGame(paid,'q').nullentropy,null);assert.equal(JSON.stringify(viewGame(paid,'q')).includes(selected.id),false); @@ -38,9 +38,9 @@ void test('a sole eligible card is taken automatically while every other Box sta const done=begin(g);assert.equal(done.pendingNullentropy,null);assert.equal(done.decision,null);assert.equal(done.players[0].spice,8); assert.deepEqual(done.players[0].hand,[selected]);assert.deepEqual(done.discard.map(c=>c.id),['other-box',boxId]); }); -void test('illegal starts do not charge or expose a search, including unresolved full-hand and Guild-refund cases',()=>{ - for(const change of ['full','funds','empty','onlyBox','reservedRefund','preselect'] as const){ - const g=fixture();if(change==='full')g.players[0].hand.push(...g.deck.splice(0,3)); +void test('illegal starts do not charge or expose a search, including Guild-refund cases',()=>{ + for(const change of ['funds','empty','onlyBox','reservedRefund','preselect'] as const){ + const g=fixture(); if(change==='funds')g.players[0].spice=1;if(change==='empty')g.discard=[]; if(change==='onlyBox')g.discard=[{...g.players[0].hand[0],id:'other-box'}]; if(change==='reservedRefund'){g.players[1].faction='guild';g.karamaShipping={owner:'p',player:'p',card:g.discard[0].id};}