Allow Distrans before the first ordinary auction bid
This commit is contained in:
1 parent
34dccdfd99
commit
2ea3c58f68
19 files changed
+906
-42
No files matched your search
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { Card } from '@/game/cards';
|
||||
import type { Action } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export interface DistransTransferActionProps {
|
||||
card: Pick<Card, 'id'>;
|
||||
recipient: { id: string; name?: string } | undefined;
|
||||
given: Pick<Card, 'id' | 'name'> | undefined;
|
||||
reason: string | null;
|
||||
reasonId: string;
|
||||
busy: boolean;
|
||||
act: (action: Action) => void;
|
||||
}
|
||||
|
||||
export function DistransTransferAction({
|
||||
card,
|
||||
recipient,
|
||||
given,
|
||||
reason,
|
||||
reasonId,
|
||||
busy,
|
||||
act,
|
||||
}: DistransTransferActionProps) {
|
||||
const disabled = busy || !!reason || !recipient || !given;
|
||||
return (
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
data-testid="distrans-transfer"
|
||||
disabled={disabled}
|
||||
aria-describedby={reason ? reasonId : undefined}
|
||||
onClick={() => {
|
||||
if (!disabled && recipient && given)
|
||||
act({
|
||||
type: 'card',
|
||||
card: card.id,
|
||||
target: recipient.id,
|
||||
give: given.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Give {given?.name ?? 'a card'} to {recipient?.name ?? 'the recipient'}{' '}
|
||||
and discard Distrans
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+16
-18
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
import { CardInspector, CardRules } from './card-inspector';
|
||||
import { DistransTransferAction } from './distrans-transfer-action';
|
||||
|
||||
export function Distrans({
|
||||
game,
|
||||
@@ -49,6 +49,12 @@ export function Distrans({
|
||||
discard Distrans. No spice is paid. The transfer happens immediately,
|
||||
without a recipient confirmation or Karama response.
|
||||
</p>
|
||||
<p className="m-0 text-sm leading-6" aria-label="Provisional Distrans bid timing">
|
||||
Provisional bid timing: you may transfer before the first bid or pass
|
||||
on an ordinary auction lot. Once anyone bids or passes, that lot stays
|
||||
blocked while unresolved. Special auctions keep their existing
|
||||
restrictions.
|
||||
</p>
|
||||
<CardInspector card={distrans.card} />
|
||||
{choice && (
|
||||
<>
|
||||
@@ -103,23 +109,15 @@ export function Distrans({
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reason && choice && given)
|
||||
act({
|
||||
type: 'card',
|
||||
card: distrans.card.id,
|
||||
target: choice.recipient,
|
||||
give: given.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Give {given?.name ?? 'a card'} to {recipient?.name ?? 'the recipient'}{' '}
|
||||
and discard Distrans
|
||||
</Button>
|
||||
<DistransTransferAction
|
||||
card={distrans.card}
|
||||
recipient={choice ? { id: choice.recipient, name: recipient?.name } : undefined}
|
||||
given={given}
|
||||
reason={reason}
|
||||
reasonId={`${id}-reason`}
|
||||
busy={busy}
|
||||
act={act}
|
||||
/>
|
||||
{!!choice?.unavailable.length && (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-3"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TraitorDecisionPromises } from './traitor-decision-promises';
|
||||
import { MovementPromises } from './movement-promises';
|
||||
import { HomeworldEcazDuke } from './homeworld-ecaz-duke';
|
||||
import { integerInputValue } from '../lib/integer-input';
|
||||
import { normalAuctionHandBlock } from '@/game/normal-auction';
|
||||
import { EcazSetup } from './ecaz-setup';
|
||||
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
|
||||
import { stormCardDistance } from '@/game/storm-cards';
|
||||
@@ -4319,6 +4320,8 @@ export function GameTable({
|
||||
</label>
|
||||
<p className="fine">
|
||||
Minimum bid: {minimumBid} spice.
|
||||
{normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) &&
|
||||
' Your Treachery hand is full; pass this bid.'}
|
||||
{maximumBid < minimumBid &&
|
||||
' You cannot afford a higher bid; pass this card.'}
|
||||
</p>
|
||||
@@ -4329,7 +4332,8 @@ export function GameTable({
|
||||
amount: bidAmount,
|
||||
...payment,
|
||||
},
|
||||
!Number.isSafeInteger(bidAmount) ||
|
||||
!!normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) ||
|
||||
!Number.isSafeInteger(bidAmount) ||
|
||||
bidAmount < minimumBid ||
|
||||
bidAmount > maximumBid,
|
||||
)}
|
||||
|
||||
+35
-7
@@ -16,6 +16,41 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Distrans before the first bid/pass — 26 October. Prototyped / Partial.**
|
||||
An untouched ordinary lot now allows the private card transfer before its first
|
||||
bid or pass, with the same dealt pool/index/opener/order/active seat. The span is
|
||||
explicitly provisional; active bids, unpaid special auctions and self-transfer
|
||||
keep their restrictions. Current capacity controls new bids after a gift: a full
|
||||
active seat sees Raise disabled, can Pass, and all four minimal profiles do so.
|
||||
Existing card action/projection and the paid-sale allowance remain.
|
||||
|
||||
Two disjoint native/control lanes and root integration pass **10 files,68/68**,
|
||||
types/lint/build and **eight native engine/GameTable SSR/actual leaf-callback
|
||||
paths** on unchanged source. Original public R/G/A/E sources buy Distrans for1
|
||||
and give the original Guild Shield. Native purchases earn full-donor and final-
|
||||
slot recipient positions; real Pass and payment finish the same lot. A genuinely
|
||||
full recipient rejects at turn2. All45 original Treachery remain. Source actions
|
||||
are Basic33/42/44/87 and Advanced38/47/49/96 for ordinary/donor-full/final-slot/
|
||||
already-full paths. Fourteen native cases and seven controlled rendering cases
|
||||
cover the new span; existing quote/bot/auction regressions remain green.
|
||||
|
||||
Types/lint/build/test proof: private `20261026-distrans-prebid-proof-v2/`;
|
||||
runtime companion and composed same-source evidence:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-distrans-prebid-runtime-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-distrans-prebid-runtime/`.
|
||||
The private runtime selector was repaired for the button's actual arrow SVG;
|
||||
application source stayed unchanged. No saved-room/D1/server, reset/restart,
|
||||
deployment or recovery operation occurs. SSR/callbacks do not prove DOM/hydration,
|
||||
browser/mobile/deployed acceptance. Complete rules/combinations and final
|
||||
assurance remain open. [Source and policy](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-ordinary-lot--26-october-2026).
|
||||
|
||||
Next concrete Basic function gap: Truthtrance's structured-question engine
|
||||
accepts two through eight facts, but the authoring panel only exposes two.
|
||||
Connect add/remove fact controls to the existing AND/OR action with actual
|
||||
aggregate-answer/resumption evidence. See `components/truthtrance.tsx`,
|
||||
`game/truthtrance.ts`, `tests/fixture-truth-movement-early.ts` and
|
||||
[the source contract](TRUTHTRANCE_CARD_COUNT.md).
|
||||
|
||||
**Shared Ecaz Ambassador Duke — 26 October. Prototyped / Partial.**
|
||||
The triggered token can now grant its living Duke to Ecaz's current reciprocal
|
||||
ally. Per-recipient controls and all four minimal profiles use the server's
|
||||
@@ -42,13 +77,6 @@ Actual hook-free callbacks and SSR are not DOM/hydration or browser/mobile/
|
||||
deployed acceptance. Complete rules/combinations, mode gates and final assurance
|
||||
remain open. [Source and selected tenure](ECAZ_DUKE_ACQUISITION.md#existing-ally-direct-acquisition--26-october-2026).
|
||||
|
||||
Next concrete Basic function gap: Distrans rejects even an untouched ordinary
|
||||
auction lot before the first bid/pass. Scope that pre-bid transfer under the
|
||||
14 October recommended-policy authority, retaining the actual card, lot/order,
|
||||
hand-limit eligibility and active-bid restrictions. See
|
||||
[the timing audit](RICHESE_DISTRANS_ENGINE_AUDIT.md), `distransBlock` and
|
||||
`tests/distrans-engine.test.ts`; broader special-auction timing remains separate.
|
||||
|
||||
**Caladan reinforcement before Face Dance — 26 October. Prototyped / Partial.**
|
||||
The original winning army may now take its optional reinforcement before native
|
||||
replacement. The later reveal uses the live10/11 Arrakis or3/4 Homeworld force
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Distrans before the first ordinary bid/pass
|
||||
|
||||
**Prototyped / Partial.** Select a provisional first-bid/pass span under the
|
||||
14 October authority. A real untouched ordinary lot now admits the existing
|
||||
private transfer with its dealt pool, index, opener, order and active seat intact.
|
||||
First bid/pass, unpaid special-auction and self-transfer restrictions remain;
|
||||
completed paid-sale use keeps its existing allowance. Current hand capacity is
|
||||
shared by engine/table/minimal bots, so a now-full active bidder cannot raise
|
||||
but may Pass, then the original cycle skips ineligible hands. No new saved
|
||||
field or response is introduced. Persistent controls explain the interpretation.
|
||||
|
||||
Frozen **10-file68/68**, types/lint/build and **eight native engine/GameTable
|
||||
SSR/actual hook-free transfer-callback paths** pass. Public R/G/A/E starts buy
|
||||
the actual cache Distrans for1 and give the original Guild Shield. Real ordinary
|
||||
purchases earn both capacity cases and the full-recipient next-turn refusal.
|
||||
Source actions are Basic33/42/44/87 and Advanced38/47/49/96 for ordinary/full-
|
||||
donor/final-slot/already-full paths. Actual Pass, payment and native retirement
|
||||
finish the same lot; all45 original cards remain. Fourteen native cases, seven
|
||||
controlled rendering cases and the related existing quote/bot/auction tests
|
||||
cover the slice. Feedback corrected the controls' test expansion identifier,
|
||||
the optional own-hand count fallback and a private SSR selector's SVG assumption.
|
||||
|
||||
Test/types/lint/build: private `20261026-distrans-prebid-proof-v2/`;
|
||||
runtime/composed same-source proof:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-distrans-prebid-runtime-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-distrans-prebid-runtime/`.
|
||||
Only the private selector changed before the runtime companion; source stayed
|
||||
unchanged. No saved-room/D1/server, reset/restart, deployment or recovery
|
||||
operation. Browser/mobile/deployed acceptance, broader auction timing, complete
|
||||
rules/combinations and final assurance remain open.
|
||||
[Source and selected policy](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-ordinary-lot--26-october-2026).
|
||||
|
||||
## 26 October 2026 — Existing-ally Ecaz Ambassador Duke acquisition
|
||||
|
||||
**Prototyped / Partial.** Connect the printed benefit-sharing branch to the
|
||||
|
||||
@@ -97,7 +97,7 @@ The updated physical Karama explicitly includes alliance abilities among cancell
|
||||
|
||||
**Directly supported by the face.** Its owner gives another player one Treachery Card from the owner's hand, provided the other player's hand is not full. Any other faction can receive it, without requiring an alliance. The effect has no printed spice fee. Distrans is discarded after use; the gifted card transfers without being discarded. The printed exception is “other than during a bid.” The effect is a card effect, not itself a faction/alliance power that generic Karama cancels. No recipient accept/refuse step is printed.
|
||||
|
||||
**Unresolved exact span.** No retrieved primary answer defines whether “a bid” means the complete auction of one card or only an individual bid declaration. Treating the whole Bidding phase as forbidden is broader than the face. A defensible bounded policy blocks Distrans throughout an unresolved lot, including Silent submissions and payment, while allowing it between lots when no bid is pending. Record that as an interpretation until clarified. Never permit it between Silent submissions to probe hidden commitments or invalidate fixed eligibility without a settlement policy.
|
||||
**Selected provisional span — 26 October 2026.** No retrieved primary answer defines whether “a bid” means the complete auction of one card or only an individual bid declaration. Treating the whole Bidding phase as forbidden is broader than the face. Under the 14 October recommended-policy authority, an untouched ordinary lot now permits use before its first bid or pass, with its dealt pool and cycle intact. Active bids and unpaid special lots, including Silent submissions, remain guarded; paid-sale use keeps its existing separate allowance. [Current timing and live hand-capacity contract](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-ordinary-lot--26-october-2026). Never use a transfer between Silent submissions to probe hidden commitments or invalidate fixed eligibility without a settlement policy.
|
||||
|
||||
Distrans itself as the gifted card is not resolved by the face's give-then-discard instruction. Do not put the same physical card in both the recipient's hand and discard; reject that target as unsupported pending clarification. Other reserved/committed-card and answer-first constraints are the same continuation concerns as the alliance gift. A successful ordinary transfer consumes exactly the chosen separate card plus Distrans from the donor's hand, adds the chosen card to the recipient, and puts only Distrans in normal discard. This produces a new discard event relevant to future Semuta integration.
|
||||
|
||||
|
||||
@@ -2,6 +2,56 @@
|
||||
|
||||
Audit date: 2026-09-06. Read `docs/RICHESE_ACQUISITION_RULES.md` first. This audit does not activate the card or certify the remaining Richese effects.
|
||||
|
||||
## Untouched ordinary lot — 26 October 2026
|
||||
|
||||
The printed exception excludes use during a bid, without defining its complete
|
||||
span. Under the 14 October choose-recommended authority, allow Distrans on a
|
||||
real untouched ordinary lot before its first bid or pass. This is a provisional
|
||||
timing composition, not a retrieved publisher clarification. It supersedes only
|
||||
that part of the original blanket unpaid-lot boundary below. Later lots may
|
||||
qualify independently; already committed lot quantity and physical deck order
|
||||
do not change. The existing completed paid-sale allowance remains.
|
||||
|
||||
`game/distrans-timing.ts` supplies the authoritative timing block. It requires
|
||||
Bidding, an actual current ordinary card, zero bid, no bidder/passes/contribution
|
||||
and no Richese special lot. Current inspection does not consume this window.
|
||||
The first accepted bid or pass closes it for that lot; no action resets the
|
||||
bid history to permit a transfer. Once Around/Silent unpaid lots, self-transfer,
|
||||
Truthtrance, exact physical reservations and pending capacity commitments keep
|
||||
their existing restrictions. This does not certify every auction substep.
|
||||
|
||||
The existing action, private projection and selectors remain. Persistent human
|
||||
guidance identifies the provisional span. Both outgoing cards and the recipient
|
||||
are validated before the normal private transfer/discard; no recipient consent,
|
||||
payment or extra deal is added. The current lot/cards/index/opener/order/active
|
||||
seat remain. Full donor hands can free space, while a recipient whose final
|
||||
slot is filled cannot buy beyond its limit. Shared `normalAuctionHandBlock`
|
||||
is used by the engine, minimal AI and table controls: a now-full active player
|
||||
may pass, then the existing cycle skips ineligible hands. No new saved field,
|
||||
source kind or transfer response is introduced.
|
||||
|
||||
Frozen evidence: **10 files,68/68**, types/lint/build and eight native
|
||||
engine/GameTable SSR/actual hook-free transfer-callback paths. The public
|
||||
R/G/A/E source buys the physical cache Distrans for1 and retains its original
|
||||
starting Shield. Actual ordinary purchases create full-donor, final-slot active
|
||||
recipient and already-full recipient positions. The last rejects at turn2;
|
||||
successful transfers finish the original lot through native bidding/payment.
|
||||
All45 original cards remain. Source actions: Basic33/42/44/87 and
|
||||
Advanced38/47/49/96 in that four-path order. Fourteen native engine and seven
|
||||
controlled rendering/callback cases supplement the existing related regressions.
|
||||
Test/types/lint/build logs are in private `20261026-distrans-prebid-proof-v2/`;
|
||||
runtime/composed same-source evidence:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-distrans-prebid-runtime-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-distrans-prebid-runtime/`.
|
||||
The private smoke selector was corrected for the rendered arrow SVG without
|
||||
changing application source. No saved-room/D1/server or recovery operation;
|
||||
SSR/callbacks do not prove DOM/hydration or browser/mobile/deployed acceptance.
|
||||
|
||||
The official publisher URL still appears in indexed search, but a fresh direct
|
||||
fetch returned403; this change uses the recorded face text and selected policy,
|
||||
not a claim of a newly published ruling. Full Richese games, wider auction
|
||||
timing, combinations and final assurance remain open.
|
||||
|
||||
## Verified printed effect
|
||||
|
||||
Distrans is a Special Treachery Card. Its holder gives **one other player one Treachery Card from the holder's hand**, provided the recipient has room. It can be played at any time except during a bid. Distrans is discarded after use.
|
||||
@@ -28,7 +78,7 @@ The distinction between an ordinary Treachery effect and a cancellable faction/a
|
||||
## Explicit unresolved guards
|
||||
|
||||
1. **Giving the activating Distrans itself.** The give-then-discard wording does not explain self-selection. Do not put the same physical card in both the recipient's hand and the discard, discard a different card, or silently omit the discard. Until clarified, require a separate transferred card and label self-selection unsupported, not a printed prohibition.
|
||||
2. **Exact scope of “during a bid.”** The face does not define whether this means one bid declaration or the complete auction of a card. It does not say the entire Bidding phase. A temporary guard over unresolved normal/Once Around/Silent lots, including unsettled payment, is an explicit conservative implementation boundary. Pre-bid declaration/selection, between-lot windows and post-purchase income are distinct contexts; do not silently present a blanket phase-3 ban as the official rule. The user has already been asked about this boundary by root; this audit adds no question or assumed answer.
|
||||
2. **Exact scope of “during a bid.”** The face does not define whether this means one bid declaration or the complete auction of a card. It does not say the entire Bidding phase. The 26 October selected policy above now permits an untouched ordinary lot before its first bid/pass; live normal/Once Around/Silent lots, including unsettled payment, retain the explicit timing boundary. Pre-bid declaration/selection, between-lot windows and post-purchase income are distinct contexts; do not present a blanket phase-3 ban as the official rule. The earlier open question is superseded only by that selected provisional scope.
|
||||
3. **Active Truthtrance answer priority.** Existing question/priority resolution prevents changing the queried hand mid-question. No retrieved Distrans-specific rule overrides it. Keep this narrow existing priority boundary, rather than disabling Distrans for every unrelated decision or response.
|
||||
4. **Semuta discard reaction — updated 11 October 2026.** The explicit development preview reacts after a clean completed transfer, including during battle. Only the used Distrans is fresh; the privately transferred card is never a Semuta target. Claim or decline cannot repeat the transfer or change a sealed plan. The recipient may itself hold Semuta and claim the used card while keeping the earlier gift. Competing parents retain their separate boundaries; the neutral response and approved full-hand exchange are documented in [the Semuta source/runtime contract](SEMUTA_SOURCE_UPDATE_20260907.md).
|
||||
|
||||
@@ -98,7 +148,7 @@ Recommended regressions: atomic distinct-card conservation and fresh-version rep
|
||||
|
||||
## Runtime integration checkpoint
|
||||
|
||||
The ordinary card action and owner-only projected transfer choices are integrated. Shared exact-card reservations cover saved parent Ix replacements and pending gifts; prospective checks preserve compulsory exchanges and binding donor promises. No new response or recipient confirmation is created. The supported post-payment context uses currentAuctionSale only after the existing code has paid and transferred the purchased card. Open unpaid lots and self-transfer remain explicit unresolved guards.
|
||||
The ordinary card action and owner-only projected transfer choices are integrated. Shared exact-card reservations cover saved parent Ix replacements and pending gifts; prospective checks preserve compulsory exchanges and binding donor promises. No new response or recipient confirmation is created. The supported post-payment context uses currentAuctionSale only after the existing code has paid and transferred the purchased card. The 26 October prototype admits untouched ordinary lots; live bids, unpaid special lots and self-transfer remain guarded.
|
||||
|
||||
Six root engine, six independent review, five AI and three real SQLite/API cases pass, in addition to six pure custody cases. Full registered suites pass1,247 rules/client and96 persisted/API tests; TypeScript, lint and production build pass. The real browser gave Shield from a synthetic Harkonnen hand using Distrans while preserving a separate pending Richese gift; fresh-tab recovery did not replay it. Exact logs and fixtures are in IMPLEMENTATION_STATUS.md. Mobile, future Semuta, bid/self-transfer rulings and full Richese games remain incomplete.
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Rule decision index
|
||||
|
||||
## Distrans before the first ordinary bid/pass — 26 October 2026
|
||||
|
||||
The printed card permits use except during a bid (publisher E2 p5). Under
|
||||
14 October choose-recommended authority, a physical ordinary lot with no bid,
|
||||
bidder, pass or committed contribution may accept Distrans before its first bid
|
||||
or pass. This selects a provisional span; inspection and opening the lot do not
|
||||
themselves begin that bid. Keep the original dealt pool, lot index, opener,
|
||||
cycle and active seat. Live bids, unpaid special auctions and self-transfer keep
|
||||
their existing separate restrictions; completed paid-sale use stays available.
|
||||
After a transfer, current hand capacity controls new ordinary bids: a now-full
|
||||
active seat can pass and the existing cycle recalculates eligibility.
|
||||
[Source and execution](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-ordinary-lot--26-october-2026).
|
||||
|
||||
## Existing-ally Ecaz Ambassador Duke acquisition — 26 October 2026
|
||||
|
||||
Connect printed Ambassador benefit sharing (publisher E3 p8, authorized
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { discoveryBotActions } from './discovery-options';
|
||||
import { normalAuctionHandBlock } from './normal-auction';
|
||||
import { atreidesFullPlanAction } from './atreides-full-plan';
|
||||
import { liveMovementPromises } from './movement-promises';
|
||||
import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options';
|
||||
@@ -3153,6 +3154,8 @@ function policyActions(g: GameView): Action[] {
|
||||
];
|
||||
}
|
||||
if (g.auction?.active !== me.id) return [];
|
||||
if (normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit))
|
||||
return [{ type: 'passBid' }];
|
||||
const budget = Math.min(
|
||||
(me.spice ?? 0) + g.aid.available,
|
||||
[1, 3, 5, 7][level] + ((me.hand?.length ?? 0) === 0 ? 2 : 0),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
type DistransAuctionContext = {
|
||||
phase: number;
|
||||
auction: {
|
||||
cards: readonly unknown[];
|
||||
index: number;
|
||||
bid: number;
|
||||
bidder: string | null;
|
||||
passed: readonly string[];
|
||||
allyPayment?: number;
|
||||
} | null;
|
||||
richeseAuction?: unknown;
|
||||
currentAuctionSale?: unknown;
|
||||
};
|
||||
|
||||
/** The printed card excludes use during a bid. Provisionally, an ordinary
|
||||
* lot has not begun bidding until its first bid or pass. Inspection and
|
||||
* opening the physical lot do not start that bid or change the dealt pool.
|
||||
* Other unpaid auctions retain their separate timing boundary. */
|
||||
export function distransAuctionBlock(g: DistransAuctionContext): string | null {
|
||||
if (g.currentAuctionSale || (!g.auction && !g.richeseAuction)) return null;
|
||||
const lot = g.auction;
|
||||
if (
|
||||
g.phase === 3 && !g.richeseAuction && lot &&
|
||||
Number.isSafeInteger(lot.index) && lot.index >= 0 &&
|
||||
lot.index < lot.cards.length && !!lot.cards[lot.index] &&
|
||||
lot.bid === 0 && lot.bidder === null && lot.passed.length === 0 &&
|
||||
(lot.allyPayment ?? 0) === 0
|
||||
) return null;
|
||||
return 'Distrans is unavailable after bidding starts on this lot. Finish its auction first; special-auction timing remains guarded.';
|
||||
}
|
||||
+5
-5
@@ -284,6 +284,7 @@ import {
|
||||
type AuctionContinuationQuote,
|
||||
} from './auction-continuation-quote';
|
||||
import {
|
||||
normalAuctionHandBlock,
|
||||
quoteNormalAuctionBid,
|
||||
quoteNormalAuctionNext,
|
||||
} from './normal-auction';
|
||||
@@ -438,6 +439,7 @@ import {
|
||||
resolveNullentropyBox,
|
||||
} from './nullentropy-box';
|
||||
import { transferDistrans } from './distrans';
|
||||
import { distransAuctionBlock } from './distrans-timing';
|
||||
import { SEMUTA_DRUG_ID, committedSemutaCandidates, resolveSemutaDrug, type FreshDiscardBatch, type SemutaContext } from './semuta-drug';
|
||||
import {
|
||||
prepareRicheseGift,
|
||||
@@ -5028,11 +5030,7 @@ function distransBlock(g: Game, owner: Player, card: Card): string | null {
|
||||
return 'Resolve the active Truthtrance question before changing hands.';
|
||||
const reserved = transferCardBlock(g, owner, card);
|
||||
if (reserved) return reserved;
|
||||
// The face excludes a bid, not the complete Bidding phase. A live unpaid lot
|
||||
// remains guarded pending the requested ruling; already-paid income is separate.
|
||||
if ((g.auction || g.richeseAuction) && !g.currentAuctionSale)
|
||||
return 'Distrans during an unresolved auction lot awaits a ruling. It remains available outside that bid.';
|
||||
return null;
|
||||
return distransAuctionBlock(g);
|
||||
}
|
||||
function validateDistrans(
|
||||
g: Game,
|
||||
@@ -37543,6 +37541,8 @@ function applyActionInner(
|
||||
);
|
||||
const a = g.auction;
|
||||
if (t === 'bid') {
|
||||
const capacity = normalAuctionHandBlock(p.hand.length, handLimit(p));
|
||||
requireRule(!capacity, capacity ?? 'Your Treachery hand is full.');
|
||||
const quotedBid = quoteNormalAuctionBid({
|
||||
currentBid: a.bid,
|
||||
amount: action.amount,
|
||||
|
||||
@@ -7,6 +7,12 @@ export type NormalAuctionBid =
|
||||
| { ok: true; amount: number }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/** Capacity can change after a lot opens, for example through Distrans. The
|
||||
* existing active player may still pass; only a new purchase bid is barred. */
|
||||
export function normalAuctionHandBlock(held: number, limit: number): string | null {
|
||||
return held >= limit ? 'Your Treachery hand is full; pass this bid.' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote the next ordinary auction step. The engine owns card custody, payment,
|
||||
* phase progression and the hand-limit eligibility calculation.
|
||||
|
||||
+11
-4
@@ -738,7 +738,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Any faction holding Distrans may use it. Choose another player whose hand has room and a different Treachery Card from your own hand. The recipient need not be your ally. Transfer that card intact, then discard Distrans into the ordinary discard pile.',
|
||||
'Open the Distrans transfer panel in your hand, select the recipient and card, inspect either card, then press Give and discard Distrans. There is no spice payment, purchase bonus, recipient acknowledgement or faction-power Karama response. Other players see the played Distrans, not the transferred card identity.',
|
||||
'The printed exception is during a bid. Its exact span remains unresolved: an unpaid open auction lot is currently guarded, while a pre-bid context or completed purchase income response may allow the effect. This is not a prohibition on the entire Bidding phase. Giving Distrans itself also remains guarded pending clarification.',
|
||||
'The printed exception is during a bid. Provisionally, an untouched ordinary auction lot allows Distrans before its first bid or pass. Opening or inspecting that lot does not start the bid; its dealt cards and bidding order stay the same. Active bids and unpaid special auctions remain guarded, while a completed purchase income response may allow use. Giving Distrans itself remains guarded.',
|
||||
'Current hand space still controls bidding after a transfer. A full donor can free space; if the active bidder receives its final card, it must pass instead of raising. The existing auction skips full hands without adding a lot or resetting earlier passes.',
|
||||
'Distrans resolves in one server action and keeps an existing response, decision or phase opening in place. Active Truthtrance retains priority. Both outgoing cards must be uncommitted; a sealed plan, fixed prescience answer, pending Richese gift or accepted replacement cannot lose its reserved card.',
|
||||
'A transfer must preserve binding battle answers, compulsory discards and both sides of a pending Harkonnen hand exchange. The recipient must retain room for its mandatory incoming return. Optional proposals that become unavailable can still finish through their existing decline or no-effect path.',
|
||||
'A different legal transfer can fill or free space for a pending Richese gift. The ordinary Distrans transfer resolves first, and the pending gift rechecks capacity before moving its own card. A canceled alliance gift does not automatically prevent using this separate card effect.',
|
||||
@@ -757,13 +758,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Synchronous two-card custody, private transfer, normal Distrans disposal, both-card reservations and prospective transaction/promise checks are integrated. The explicit Semuta preview offers a neutral reaction only after a clean completed transfer; open-lot timing, competing transactions and self-transfer remain guarded.',
|
||||
'Synchronous two-card custody, private transfer, normal disposal, reservations and prospective checks are integrated. The provisional untouched ordinary-lot window admits use before its first bid/pass and rechecks live bid capacity. Active bids, unpaid special auctions, competing transactions and self-transfer retain their separate boundaries.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Recipient and given-card selectors, both inspectors, unavailable reasons and an explicit give-and-discard action are integrated. Desktop selection, real transfer and fresh-tab recovery passed in a synthetic table, including a preserved pending gift. Mobile acceptance remains pending.',
|
||||
'Recipient/card selectors, both inspectors, unavailable reasons and explicit give-and-discard controls are integrated. Persistent provisional pre-bid guidance and the actual hook-free submission are connected. Browser/mobile acceptance of this extension remains pending.',
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
@@ -781,10 +782,16 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Six pure custody, six engine matrix, six independent review, five all-profile AI and three persisted/API cases cover the implemented boundaries. The integrated suites pass 1,258 registered rules/client/component and 96 persisted/API tests. Desktop transfer and fresh-tab recovery passed in a synthetic table. Mobile and full Richese games remain unverified.',
|
||||
'Pure timing/custody, existing engine/bot regressions, fourteen genuine Basic/Advanced pre-bid cases and seven controlled rendering/callback cases cover the implemented span. Native sources buy the actual cache card, earn capacities through purchases and complete the same ordinary lot. Full Richese games, browser/mobile/deployed acceptance and final assurance remain due.',
|
||||
evidence: [
|
||||
'tests/distrans.test.ts',
|
||||
'tests/distrans-engine.test.ts',
|
||||
'game/distrans-timing.ts',
|
||||
'tests/distrans-timing.test.ts',
|
||||
'tests/fixture-distrans-prebid.ts',
|
||||
'tests/distrans-prebid-engine.test.ts',
|
||||
'tests/distrans-prebid-controls.test.ts',
|
||||
'components/distrans-transfer-action.tsx',
|
||||
'tests/distrans-engine-review.test.ts',
|
||||
'tests/distrans-bots.test.ts',
|
||||
'tests/distrans-recovery.test.ts',
|
||||
|
||||
@@ -37,13 +37,22 @@ void test('projection exposes choices only to the holder and public history reve
|
||||
assert.equal(JSON.stringify(viewGame(done,'o')).includes(card.id),false);
|
||||
assert.match(done.log.at(-1)!.text,/Distrans/);assert.equal(done.log.some(l=>l.text.includes(card.name)),false);
|
||||
});
|
||||
void test('an unresolved ordinary lot is an explicit timing guard while a phase-three pre-bid context remains playable',()=>{
|
||||
void test('an untouched ordinary lot permits Distrans but its first bid or pass closes the provisional window',()=>{
|
||||
const {g,card}=fixture('richese',false,3);
|
||||
assert.deepEqual(play(g,card.id).players[1].hand,[card]);
|
||||
g.auction={cards:[g.deck.shift()!],index:0,bid:0,bidder:null,active:'t',passed:[],opener:0};
|
||||
const before=structuredClone(g);
|
||||
assert.throws(()=>play(g,card.id),/unresolved auction lot awaits a ruling/);assert.deepEqual(g,before);
|
||||
assert.match(viewGame(g,'d').distrans!.blocked!,/awaits a ruling/);
|
||||
assert.equal(viewGame(g,'d').distrans!.blocked,null);
|
||||
const transferred=play(g,card.id);
|
||||
assert.deepEqual(transferred.auction,g.auction);
|
||||
assert.deepEqual(transferred.players[1].hand,[card]);
|
||||
for(const progress of ['bid','pass'] as const){
|
||||
const during=reload(g);
|
||||
if(progress==='bid'){during.auction!.bid=1;during.auction!.bidder='t';}
|
||||
else during.auction!.passed=['o'];
|
||||
const before=structuredClone(during);
|
||||
assert.throws(()=>play(during,card.id),/after bidding starts/);assert.deepEqual(during,before);
|
||||
assert.match(viewGame(during,'d').distrans!.blocked!,/after bidding starts/);
|
||||
}
|
||||
});
|
||||
void test('an already-paid purchase response allows Distrans without changing its completed sale or creating a second response',()=>{
|
||||
const {g,card}=fixture('richese',true,3);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { DistransTransferAction, type DistransTransferActionProps } from '../components/distrans-transfer-action';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
||||
},
|
||||
});
|
||||
const { Distrans } = await import('../components/distrans');
|
||||
aliases.deregister();
|
||||
|
||||
// Controlled entitled projections supplement the separate native pre-bid sources.
|
||||
// No authoritative hand, auction, payment or rule result is installed in a Game.
|
||||
// React renders the stateful wrapper; only the hook-free action leaf is called
|
||||
// directly to exercise its actual callback.
|
||||
function projected(advanced = false): GameView {
|
||||
const game = createGame('DISTRANSPREBIDCONTROLS', newPlayer('d', 'Donor', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('t', 'Target', 'atreides'));
|
||||
joinGame(game, newPlayer('o', 'Other player', 'emperor'));
|
||||
const view = viewGame(game, 'd');
|
||||
const card = richeseCards().find(candidate => candidate.effect === 'distrans');
|
||||
const given = baseDeck().find(candidate => candidate.kind === 'shield');
|
||||
const other = baseDeck().find(candidate => candidate.kind === 'projectile');
|
||||
assert.ok(card && given && other);
|
||||
view.status = 'playing'; view.phase = 3; view.active = 't';
|
||||
view.auction = { bid: 0, bidder: null, active: 't', remaining: 2, card: null, inspectionBlocked: null };
|
||||
view.distrans = {
|
||||
card, blocked: null,
|
||||
choices: [
|
||||
{ recipient: 't', blocked: null, cards: [given, other], unavailable: [] },
|
||||
{ recipient: 'o', blocked: null, cards: [other, given], unavailable: [] },
|
||||
],
|
||||
};
|
||||
return view;
|
||||
}
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(Distrans, { game, busy, act() {} }));
|
||||
}
|
||||
function transferButton(html: string) {
|
||||
const button = html.match(/<button\b(?=[^>]*data-testid="distrans-transfer")[^>]*>[\s\S]*?<\/button>/)?.[0];
|
||||
assert.ok(button, 'The projected transfer action is present');
|
||||
return button;
|
||||
}
|
||||
function propsFor(game: GameView, recipient = 't', index = 0): DistransTransferActionProps {
|
||||
const distrans = game.distrans!;
|
||||
const choice = distrans.choices.find(candidate => candidate.recipient === recipient);
|
||||
return {
|
||||
card: distrans.card,
|
||||
recipient: choice ? { id: choice.recipient, name: game.players.find(player => player.id === choice.recipient)?.name } : undefined,
|
||||
given: choice?.cards[index], reason: distrans.blocked ?? choice?.blocked ?? null,
|
||||
reasonId: 'controlled-distrans-reason', busy: false, act() {},
|
||||
};
|
||||
}
|
||||
function click(props: DistransTransferActionProps): Action[] {
|
||||
const actions: Action[] = [];
|
||||
const button = DistransTransferAction({ ...props, act(action) { actions.push(action); } });
|
||||
button.props.onClick();
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('an entitled untouched ordinary lot keeps the transfer enabled in Basic and Advanced projections', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = projected(advanced), before = structuredClone(game);
|
||||
const html = markup(game);
|
||||
assert.doesNotMatch(transferButton(html), /\sdisabled=/);
|
||||
assert.match(transferButton(html), /Give Shield to Target and discard Distrans/);
|
||||
assert.equal((html.match(/<select\b/g) ?? []).length, 2);
|
||||
assert.match(html, /<label[^>]*>Recipient<\/label>/);
|
||||
assert.match(html, /<label[^>]*>Your card to transfer<\/label>/);
|
||||
assert.match(html, /aria-label="Inspect card: Distrans"/);
|
||||
assert.match(html, /aria-label="Inspect card: Shield"/);
|
||||
assert.deepEqual(click(propsFor(game)), [{ type: 'card', card: game.distrans!.card.id, target: 't', give: game.distrans!.choices[0].cards[0].id }]);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('live bid, pass-only and special-auction blocks come from the authoritative projection', () => {
|
||||
for (const context of ['live bid', 'pass-only lot', 'special auction']) {
|
||||
const game = projected();
|
||||
const reason = `Controlled ${context}: Distrans is unavailable until this lot resolves.`;
|
||||
if (context === 'live bid') { game.auction!.bid = 2; game.auction!.bidder = 't'; }
|
||||
// A pass-only lot has the same public zero-bid descriptor as an untouched
|
||||
// lot. The controls must follow the private projection rather than guess.
|
||||
game.distrans!.blocked = reason;
|
||||
for (const choice of game.distrans!.choices) {
|
||||
choice.blocked = reason;
|
||||
choice.unavailable = choice.cards.map(card => ({ card, reason }));
|
||||
choice.cards = [];
|
||||
}
|
||||
const html = markup(game), button = transferButton(html);
|
||||
assert.match(button, /\sdisabled=/);
|
||||
assert.ok(html.includes(reason));
|
||||
const reasonId = button.match(/aria-describedby="([^"]+)"/)?.[1];
|
||||
assert.ok(reasonId);
|
||||
assert.ok(html.includes(`<p id="${reasonId}" class="notice">${reason}</p>`));
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('each actual leaf callback sends exactly its offered recipient and physical card', () => {
|
||||
const game = projected(), before = structuredClone(game);
|
||||
for (const choice of game.distrans!.choices) for (let index = 0; index < choice.cards.length; index++) {
|
||||
const actions = click(propsFor(game, choice.recipient, index));
|
||||
assert.deepEqual(actions, [{ type: 'card', card: game.distrans!.card.id, target: choice.recipient, give: choice.cards[index].id }]);
|
||||
assert.deepEqual(Object.keys(actions[0]).sort(), ['card', 'give', 'target', 'type']);
|
||||
}
|
||||
assert.deepEqual(click(propsFor(game, 'not-offered')), []);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('busy, missing recipient and missing card guard the actual callback and disabled control', () => {
|
||||
const game = projected(), available = propsFor(game);
|
||||
assert.match(transferButton(markup(game, true)), /\sdisabled=/);
|
||||
for (const props of [
|
||||
{ ...available, busy: true },
|
||||
{ ...available, recipient: undefined },
|
||||
{ ...available, given: undefined },
|
||||
{ ...available, reason: 'This transfer is currently blocked.' },
|
||||
]) {
|
||||
assert.deepEqual(click(props), []);
|
||||
const html = renderToStaticMarkup(createElement(DistransTransferAction, props));
|
||||
assert.match(transferButton(html), /\sdisabled=/);
|
||||
}
|
||||
game.distrans!.choices = [];
|
||||
assert.match(markup(game), /No recipient is available/);
|
||||
assert.match(transferButton(markup(game)), /\sdisabled=/);
|
||||
const noCards = projected(); noCards.distrans!.choices.forEach(choice => { choice.cards = []; });
|
||||
assert.match(markup(noCards), /No other card is available to transfer to this player/);
|
||||
assert.match(transferButton(markup(noCards)), /\sdisabled=/);
|
||||
noCards.distrans = null;
|
||||
assert.equal(markup(noCards), '');
|
||||
});
|
||||
|
||||
void test('projected recipient and card reservations retain fallback selection, private reasons and inspection', () => {
|
||||
const game = projected(), first = game.distrans!.choices[0];
|
||||
first.blocked = 'The recipient has no free slot.';
|
||||
first.cards = [];
|
||||
let html = markup(game);
|
||||
assert.match(transferButton(html), /to Other player and discard Distrans/);
|
||||
assert.doesNotMatch(transferButton(html), /\sdisabled=/);
|
||||
assert.match(html, /Target — unavailable/);
|
||||
const choice = game.distrans!.choices[1], reserved = choice.cards.pop()!;
|
||||
choice.unavailable = [{ card: reserved, reason: 'Your Shield is committed to the current sealed plan.' }];
|
||||
html = markup(game);
|
||||
assert.match(html, /Your cards unavailable for this transfer/);
|
||||
assert.match(html, /Your Shield is committed to the current sealed plan/);
|
||||
assert.match(html, /aria-label="Inspect card: Shield"/);
|
||||
assert.doesNotMatch(transferButton(html), /Give Shield/);
|
||||
assert.deepEqual(click(propsFor(game, 't')), []);
|
||||
});
|
||||
|
||||
void test('brief provisional timing guidance stays visible without an extra confirmation flow', () => {
|
||||
const game = projected();
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /aria-label="Provisional Distrans bid timing"/);
|
||||
assert.match(html, /Provisional bid timing: you may transfer before the first bid or pass on an ordinary auction lot/);
|
||||
assert.match(html, /Once anyone bids or passes, that lot stays blocked while unresolved/);
|
||||
assert.match(html, /Special auctions keep their existing restrictions/);
|
||||
assert.match(html, /No spice is paid/);
|
||||
assert.match(html, /without a recipient confirmation or Karama response/);
|
||||
assert.doesNotMatch(html, /Confirm transfer|Accept transfer|Acknowledge|countdown/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the panel and actual transfer action never read private hands, capacity or auction internals', () => {
|
||||
const game = projected();
|
||||
Object.defineProperty(game, 'auction', { get() { throw Error('Distrans controls recreated auction timing'); } });
|
||||
for (const field of ['richeseAuction', 'discard', 'decision', 'response', 'truthtrance'])
|
||||
Object.defineProperty(game, field, { get() { throw Error(`Distrans controls read unrelated state ${field}`); } });
|
||||
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'leaders', 'forces', 'handLimit'])
|
||||
Object.defineProperty(player, field, { get() { throw Error(`Distrans controls read private or capacity field ${field}`); } });
|
||||
assert.doesNotMatch(transferButton(markup(game)), /\sdisabled=/);
|
||||
assert.deepEqual(click(propsFor(game)), [{ type: 'card', card: game.distrans!.card.id, target: 't', give: game.distrans!.choices[0].cards[0].id }]);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
actDistransPrebid, buyDistransPrebidLot, createDistransPrebidFixture,
|
||||
distransPrebidClean, distransPrebidInventory, distransPrebidPlayer as player,
|
||||
distransPrebidPosition, distransPrebidUntouched, finishDistransPrebidLot,
|
||||
playDistransPrebid, type DistransPrebidFixture, type DistransPrebidStep,
|
||||
} from './fixture-distrans-prebid';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => actDistransPrebid(game, actor, action));
|
||||
assert.deepEqual(game, before, 'The rejected public action leaves the native table unchanged');
|
||||
}
|
||||
function source(fixture: DistransPrebidFixture) {
|
||||
const f = fixture;
|
||||
assert.equal(f.initial.status, 'lobby');
|
||||
assert.equal(f.setup.status, 'setup');
|
||||
assert.ok(f.setup.players.every(seat => seat.hand.length === 0));
|
||||
assert.equal(f.afterSetup.status, 'playing');
|
||||
assert.equal(f.afterSetup.turn, 1);
|
||||
assert.ok(f.afterSetup.players.every(seat => seat.hand.length === 1));
|
||||
for (const { actor, card } of f.startingDealSelection)
|
||||
assert.ok(player(f.afterSetup, actor).hand.some(held => held.id === card));
|
||||
assert.ok(player(f.afterSetup, f.holder).hand.some(held => held.id === f.give));
|
||||
assert.ok(!player(f.afterSetup, f.holder).hand.some(held => held.id === f.card));
|
||||
assert.ok(f.acquisition.before.richeseCache!.some(card => card.id === f.card));
|
||||
assert.ok(f.acquisition.actions.some(step => step.actor === f.holder &&
|
||||
step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquired, f.holder).spice, player(f.acquisition.before, f.holder).spice - 1);
|
||||
assert.ok(player(f.acquired, f.holder).hand.some(card => card.id === f.card));
|
||||
assert.equal(f.acquired.richeseCache!.some(card => card.id === f.card), false);
|
||||
assert.equal(f.firstLot.auction!.cards.length, 3);
|
||||
assert.ok(distransPrebidUntouched(f.firstLot));
|
||||
assert.ok(distransPrebidUntouched(f.game));
|
||||
assert.ok(distransPrebidClean(f.game));
|
||||
assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(distransPrebidInventory(f.game), f.originalInventory);
|
||||
assert.equal(f.actions.length, f.sourceActionCount);
|
||||
assert.equal(f.actions[4].action.type, 'start');
|
||||
assert.equal(f.actions[4].action.advancedPreview, f.advanced ? true : undefined);
|
||||
assert.ok(f.game.players.every(seat => seat.ally === null));
|
||||
for (const purchase of f.purchases) {
|
||||
assert.ok(purchase.actions.some(step => step.actor === purchase.winner &&
|
||||
step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.equal(player(purchase.after, purchase.winner).hand.filter(card => card.id === purchase.card).length, 1);
|
||||
assert.equal(player(purchase.after, purchase.winner).spice,
|
||||
player(purchase.before, purchase.winner).spice - 1);
|
||||
}
|
||||
}
|
||||
function transferred(f: DistransPrebidFixture, before: Game, after: Game) {
|
||||
assert.deepEqual(distransPrebidPosition(after), distransPrebidPosition(before),
|
||||
'Transfer retains the original physical pool, index, opener, active owner, order and every wallet');
|
||||
assert.deepEqual(player(after, f.holder).hand,
|
||||
player(before, f.holder).hand.filter(card => card.id !== f.card && card.id !== f.give));
|
||||
const original = player(before, f.holder).hand.find(card => card.id === f.give)!;
|
||||
assert.deepEqual(player(after, f.recipient).hand, [...player(before, f.recipient).hand, original]);
|
||||
assert.deepEqual(after.discard, [...before.discard, player(before, f.holder).hand.find(card => card.id === f.card)!]);
|
||||
assert.equal(player(after, f.holder).hand.length, player(before, f.holder).hand.length - 2);
|
||||
assert.equal(player(after, f.recipient).hand.length, player(before, f.recipient).hand.length + 1);
|
||||
assert.equal(after.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(distransPrebidInventory(after), f.originalInventory);
|
||||
assert.ok(distransPrebidUntouched(after), 'The transfer does not count as the first bid or pass');
|
||||
}
|
||||
function completeBid(game: Game, bidder: string, actions: DistransPrebidStep[] = []) {
|
||||
assert.ok(game.auction);
|
||||
const before = structuredClone(game), index = game.auction.index, turn = game.turn;
|
||||
const card = game.auction.cards[index].id;
|
||||
const done = finishDistransPrebidLot(game, turn, index, actions);
|
||||
assert.equal(player(done, bidder).hand.filter(held => held.id === card).length, 1);
|
||||
assert.equal(player(done, bidder).spice, player(before, bidder).spice - 1);
|
||||
assert.ok(!done.auction || done.auction.index !== index || done.turn !== turn || done.phase !== 3);
|
||||
assert.equal(done.currentAuctionSale ?? null, null);
|
||||
assert.ok(distransPrebidClean(done));
|
||||
return done;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${mode} genuine paid-cache Distrans transfers its original starting card before the first ordinary bid and completes that exact lot`, () => {
|
||||
const f = createDistransPrebidFixture({ advanced });
|
||||
source(f);
|
||||
const offered = viewGame(f.game, f.holder).distrans!;
|
||||
assert.equal(offered.blocked, null);
|
||||
assert.ok(offered.choices.find(choice => choice.recipient === f.recipient)!.cards.some(card => card.id === f.give));
|
||||
const before = structuredClone(f.game), actions: DistransPrebidStep[] = [];
|
||||
let game = playDistransPrebid(f, f.game, actions);
|
||||
assert.deepEqual(f.game, before);
|
||||
transferred(f, before, game);
|
||||
rejected(game, f.holder, f.action);
|
||||
const bidder = game.auction!.active;
|
||||
game = actDistransPrebid(game, bidder, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
|
||||
assert.equal(game.auction!.bid, 1);
|
||||
assert.equal(game.auction!.bidder, bidder);
|
||||
game = completeBid(game, bidder, actions);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
|
||||
assert.ok(actions.some(step => step.action.type === 'passBid'));
|
||||
});
|
||||
|
||||
for (const opening of ['bid', 'passBid'] as const) {
|
||||
void test(`${mode} first real ${opening} closes Distrans until the next untouched ordinary lot`, () => {
|
||||
const f = createDistransPrebidFixture({ advanced });
|
||||
const actor = f.game.auction!.active;
|
||||
let game = actDistransPrebid(f.game, actor,
|
||||
opening === 'bid' ? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' });
|
||||
assert.equal(distransPrebidUntouched(game), false);
|
||||
assert.ok(viewGame(game, f.holder).distrans!.blocked);
|
||||
rejected(game, f.holder, f.action);
|
||||
let bidder = actor;
|
||||
if (opening === 'passBid') {
|
||||
assert.equal(game.auction!.bid, 0);
|
||||
assert.deepEqual(game.auction!.passed, [actor]);
|
||||
bidder = game.auction!.active;
|
||||
game = actDistransPrebid(game, bidder, { type: 'bid', amount: 1, allyPayment: 0 });
|
||||
rejected(game, f.holder, f.action);
|
||||
}
|
||||
game = completeBid(game, bidder);
|
||||
assert.equal(game.auction!.index, 1);
|
||||
assert.ok(distransPrebidUntouched(game));
|
||||
assert.equal(viewGame(game, f.holder).distrans!.blocked, null);
|
||||
const before = structuredClone(game);
|
||||
game = playDistransPrebid(f, game);
|
||||
transferred(f, before, game);
|
||||
rejected(game, f.holder, f.action);
|
||||
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${mode} two genuine ordinary purchases fill the donor, whose two-card Distrans removal reopens real bidding capacity`, () => {
|
||||
const f = createDistransPrebidFixture({ advanced, capacity: 'donorFull' });
|
||||
source(f);
|
||||
assert.equal(f.purchases.length, 2);
|
||||
assert.equal(f.game.auction!.index, 2);
|
||||
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
|
||||
const before = structuredClone(f.game);
|
||||
let game = playDistransPrebid(f);
|
||||
transferred(f, before, game);
|
||||
assert.equal(player(game, f.holder).hand.length, 2);
|
||||
const purchased = buyDistransPrebidLot(game, f.holder);
|
||||
game = purchased.game;
|
||||
assert.equal(player(game, f.holder).hand.length, 3);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test(`${mode} a native last-slot active recipient keeps its opener, cannot bid when filled, can Pass, and all profiles finish the original lot`, () => {
|
||||
const f = createDistransPrebidFixture({ advanced, capacity: 'recipientLastSlot' });
|
||||
source(f);
|
||||
assert.equal(f.purchases.length, 2);
|
||||
assert.equal(f.game.auction!.index, 2);
|
||||
assert.equal(f.game.auction!.active, f.recipient);
|
||||
const before = structuredClone(f.game);
|
||||
const filled = playDistransPrebid(f);
|
||||
transferred(f, before, filled);
|
||||
assert.equal(filled.auction!.active, f.recipient);
|
||||
assert.equal(filled.active, f.recipient);
|
||||
assert.equal(player(filled, f.recipient).hand.length, handLimit(player(filled, f.recipient)));
|
||||
rejected(filled, f.recipient, { type: 'bid', amount: 1, allyPayment: 0 });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(filled, f.recipient);
|
||||
view.players.find(seat => seat.id === f.recipient)!.bot = difficulty;
|
||||
const choices = botActions(view);
|
||||
assert.ok(choices.length, `${difficulty} has an actual legal continuation`);
|
||||
assert.ok(choices.every(action => action.type !== 'bid'), `${difficulty} cannot raise with a full hand`);
|
||||
assert.equal(choices[0].type, 'passBid', `${difficulty} chooses the live Pass`);
|
||||
let game = actDistransPrebid(filled, f.recipient, choices[0]);
|
||||
assert.notEqual(game.auction!.active, f.recipient);
|
||||
assert.equal(game.auction!.bid, 0);
|
||||
assert.deepEqual(game.auction!.passed, [f.recipient]);
|
||||
const bidder = game.auction!.active, recipientSpice = player(game, f.recipient).spice;
|
||||
game = actDistransPrebid(game, bidder, { type: 'bid', amount: 1, allyPayment: 0 });
|
||||
game = completeBid(game, bidder);
|
||||
assert.equal(player(game, f.recipient).hand.length, handLimit(player(game, f.recipient)));
|
||||
// Emperor may receive the other buyer's ordinary payment, but the full
|
||||
// recipient never pays or gains a fifth physical card.
|
||||
assert.equal(player(game, f.recipient).spice,
|
||||
recipientSpice + Number(player(game, f.recipient).faction === 'emperor' && bidder !== f.recipient));
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${mode} an opponent's genuinely purchased full hand rejects Distrans at the native next-turn untouched lot`, () => {
|
||||
const f = createDistransPrebidFixture({ advanced, capacity: 'recipientFull' });
|
||||
source(f);
|
||||
assert.equal(f.purchases.length, 3);
|
||||
assert.equal(f.game.turn, 2);
|
||||
assert.equal(player(f.game, f.recipient).hand.length, handLimit(player(f.game, f.recipient)));
|
||||
const choice = viewGame(f.game, f.holder).distrans!.choices.find(choice => choice.recipient === f.recipient)!;
|
||||
assert.equal(choice.cards.length, 0);
|
||||
assert.ok(choice.blocked);
|
||||
rejected(f.game, f.holder, f.action);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.card));
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.give));
|
||||
assert.equal(f.game.discard.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(distransPrebidInventory(f.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test(`${mode} genuine prebid source rejects self, absent targets and stale repeated custody atomically`, () => {
|
||||
const f = createDistransPrebidFixture({ advanced });
|
||||
rejected(f.game, f.holder, { ...f.action, target: f.holder });
|
||||
rejected(f.game, f.holder, { ...f.action, give: f.card });
|
||||
rejected(f.game, f.holder, { ...f.action, target: 'absent-seat' });
|
||||
rejected(f.game, f.holder, { ...f.action, give: player(f.game, f.recipient).hand[0].id });
|
||||
const game = playDistransPrebid(f);
|
||||
rejected(game, f.holder, f.action);
|
||||
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { distransAuctionBlock as block } from '../game/distrans-timing';
|
||||
|
||||
const fresh = () => ({ phase: 3, auction: { cards: ['original-lot'], index: 0,
|
||||
bid: 0, bidder: null as string | null, active: 'a', passed: [] as string[],
|
||||
opener: 0, peekKnown: true, allyPayment: 0 } });
|
||||
|
||||
void test('Distrans quotes a virgin physical ordinary lot without rewriting its inspected card or cycle', () => {
|
||||
const g = fresh(), before = structuredClone(g);
|
||||
assert.equal(block(g), null);
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(block({ ...g, auction: { ...g.auction, cards: ['earlier-sold', 'current'], index: 1 } }), null);
|
||||
assert.equal(block({ phase: 3, auction: null }), null);
|
||||
});
|
||||
|
||||
void test('Distrans first bid or pass, committed aid and every special unpaid lot retain their timing boundary', () => {
|
||||
const g = fresh();
|
||||
for (const auction of [
|
||||
{ ...g.auction, bid: 1, bidder: 'a' },
|
||||
{ ...g.auction, passed: ['a'] },
|
||||
{ ...g.auction, bidder: 'a' },
|
||||
{ ...g.auction, allyPayment: 1 },
|
||||
{ ...g.auction, index: -1 },
|
||||
{ ...g.auction, index: 1 },
|
||||
{ ...g.auction, index: 0.5 },
|
||||
{ ...g.auction, cards: [] },
|
||||
]) assert.match(block({ ...g, auction })!, /after bidding starts/);
|
||||
assert.ok(block({ ...g, phase: 4 }));
|
||||
for (const method of ['onceAround', 'silent']) {
|
||||
assert.ok(block({ ...g, richeseAuction: { method, bid: 0, acted: [] } }));
|
||||
assert.ok(block({ phase: 3, auction: null, richeseAuction: { method, bid: 0, acted: [] } }));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Distrans keeps the existing paid-sale allowance independently of the later income response', () => {
|
||||
const g = fresh();
|
||||
assert.equal(block({ ...g, auction: { ...g.auction, bid: 2, bidder: 'a' },
|
||||
currentAuctionSale: { winner: 'a', amount: 2, free: false, origin: 'normal', seller: null } }), null);
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
|
||||
|
||||
export const DISTRANS_PREBID_CARD = 'richese-distrans';
|
||||
export type DistransPrebidStep = { actor: string; action: Action };
|
||||
export type DistransPrebidCapacity = 'ordinary' | 'donorFull' | 'recipientLastSlot' | 'recipientFull';
|
||||
export type DistransPrebidOptions = { advanced?: boolean; capacity?: DistransPrebidCapacity };
|
||||
|
||||
export function distransPrebidPlayer(game: Game, id: string) {
|
||||
const player = game.players.find(player => player.id === id);
|
||||
assert.ok(player, `Original Distrans seat ${id} must remain present`);
|
||||
return player;
|
||||
}
|
||||
export const distransPrebidClean = (game: Game): boolean =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.truthtrance &&
|
||||
!game.pendingTreacheryDiscard && !game.pendingNullentropy;
|
||||
export function distransPrebidUntouched(game: Game): boolean {
|
||||
const lot = game.auction;
|
||||
return game.status === 'playing' && game.phase === 3 && distransPrebidClean(game) &&
|
||||
!game.richeseAuction && !game.currentAuctionSale && !!lot &&
|
||||
!!lot.cards[lot.index] && lot.bid === 0 && lot.bidder === null &&
|
||||
lot.passed.length === 0 && (lot.allyPayment ?? 0) === 0;
|
||||
}
|
||||
/** Sold ordinary prefixes alias the delivered card; only unpaid pool entries
|
||||
* are physical custody. Cache offers remain in their original cache. */
|
||||
export function distransPrebidInventory(game: Game): string[] {
|
||||
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Each original Treachery Card occupies one physical zone');
|
||||
return ids;
|
||||
}
|
||||
/** The native table, lot and physical resources excluding the two changed hands
|
||||
* and the used card's discard. No declaration, price or opening is replayed. */
|
||||
export function distransPrebidPosition(game: Game) {
|
||||
return {
|
||||
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
|
||||
order: game.order, ready: game.ready, auction: game.auction,
|
||||
richeseBidding: game.richeseBidding, richeseAuction: game.richeseAuction,
|
||||
currentAuctionSale: game.currentAuctionSale, richeseFunding: game.richeseFunding,
|
||||
deck: game.deck, richeseCache: game.richeseCache, richeseRemoved: game.richeseRemoved,
|
||||
response: game.response, decision: game.decision, phaseOpening: game.phaseOpening,
|
||||
players: game.players.map(player => ({ ...player, hand: undefined })),
|
||||
};
|
||||
}
|
||||
export function actDistransPrebid(game: Game, actor: string, action: Action,
|
||||
actions: DistransPrebidStep[] = []): Game {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
}
|
||||
/** Real cache selection and one-spice Guild bid. Every other setup, response,
|
||||
* auction and phase action retains the existing neutral native policy. */
|
||||
export function nextDistransPrebidStep(game: Game): DistransPrebidStep {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'richeseCache' &&
|
||||
game.richeseCache?.some(card => card.id === DISTRANS_PREBID_CARD))
|
||||
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: DISTRANS_PREBID_CARD, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
if (!decision && game.richeseAuction?.cardId === DISTRANS_PREBID_CARD &&
|
||||
!game.richeseAuction.outcome && game.richeseAuction.active === 'g' && game.richeseAuction.bid === 0)
|
||||
return { actor: 'g', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
||||
}
|
||||
return nextSemutaGuildIncomeNativeStep(game);
|
||||
}
|
||||
export function advanceDistransPrebid(game: Game, until: (game: Game) => boolean,
|
||||
actions: DistransPrebidStep[] = []): Game {
|
||||
for (let count = 0; count < 1500; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Distrans table ended before its requested boundary');
|
||||
const next = nextDistransPrebidStep(game);
|
||||
game = actDistransPrebid(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Distrans programme did not reach its finite boundary');
|
||||
}
|
||||
/** Buy the actual current ordinary card for one spice, passing only live owners.
|
||||
* This may close a preceding lot; it never installs a hand, price or opener. */
|
||||
export function buyDistransPrebidLot(game: Game, winner: string,
|
||||
actions: DistransPrebidStep[] = []) {
|
||||
assert.ok(distransPrebidUntouched(game));
|
||||
const before = structuredClone(game), start = actions.length, card = game.auction!.cards[game.auction!.index].id;
|
||||
assert.ok(distransPrebidPlayer(game, winner).hand.length < handLimit(distransPrebidPlayer(game, winner)));
|
||||
for (let count = 0; count < 200; count++) {
|
||||
if (distransPrebidPlayer(game, winner).hand.some(held => held.id === card) &&
|
||||
distransPrebidClean(game) && !game.currentAuctionSale)
|
||||
return { game, purchase: { before, card, winner, amount: 1,
|
||||
actions: structuredClone(actions.slice(start)), after: structuredClone(game) } };
|
||||
let next: DistransPrebidStep;
|
||||
if (distransPrebidClean(game) && game.auction && !game.currentAuctionSale) {
|
||||
const actor = game.auction.active;
|
||||
next = { actor, action: actor === winner && game.auction.bid === 0
|
||||
? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' } };
|
||||
} else next = nextDistransPrebidStep(game);
|
||||
game = actDistransPrebid(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The actual one-spice ordinary purchase did not finish');
|
||||
}
|
||||
/** Retire only the original now-bid lot and its real payment/response. Stop
|
||||
* at the next untouched lot or actual next phase, before another declaration. */
|
||||
export function finishDistransPrebidLot(game: Game, turn: number, index: number,
|
||||
actions: DistransPrebidStep[] = []): Game {
|
||||
return advanceDistransPrebid(game, state => distransPrebidClean(state) && !state.currentAuctionSale &&
|
||||
(state.turn !== turn || state.phase !== 3 || !state.auction || state.auction.index !== index), actions);
|
||||
}
|
||||
|
||||
/** Fresh public R/G/A/E start in Basic or explicit Advanced preview. Only the
|
||||
* unopened four-card starting deal, four unopened non-worm Spice positions and
|
||||
* scalar lotteries are selected. Guild really pays1 for canonical Distrans.
|
||||
* Optional capacities use actual one-spice ordinary purchases; the full target
|
||||
* retains its earned hand through native no-spend play into turn2. No played
|
||||
* hand, wallet, alliance, board, clock, lot, payment or bonus is installed. */
|
||||
export function createDistransPrebidFixture(options: DistransPrebidOptions = {}) {
|
||||
const advanced = options.advanced ?? false, capacity = options.capacity ?? 'ordinary';
|
||||
let game = createGame('DISTRANSPREBID', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const [id, name, faction] of [['g', 'Guild', 'guild'], ['a', 'Atreides', 'atreides'],
|
||||
['e', 'Emperor', 'emperor']] as const) joinGame(game, newPlayer(id, name, faction));
|
||||
const actions: DistransPrebidStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actDistransPrebid(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceDistransPrebid(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const initial = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const startingDealSelection: { actor: string; position: number; from: number; card: string }[] = [];
|
||||
for (const [actor, kind] of [['r', 'projectile'], ['g', 'shield'], ['a', 'poison'], ['e', 'snooper']] as const) {
|
||||
const position = game.players.findIndex(player => player.id === actor);
|
||||
const from = game.deck.findIndex((card, index) => index >= position && card.kind === kind);
|
||||
assert.ok(from >= position, `The original ${actor} starting card remains unopened`);
|
||||
const card = game.deck[from].id;
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
startingDealSelection.push({ actor, position, from, card });
|
||||
}
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), originalInventory = distransPrebidInventory(game);
|
||||
assert.equal(originalInventory.length, 45);
|
||||
for (const { actor, card } of startingDealSelection)
|
||||
assert.ok(distransPrebidPlayer(game, actor).hand.some(held => held.id === card));
|
||||
const land: Game['spiceDeck'] = [];
|
||||
for (let count = 0; count < 4; count++) {
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
||||
assert.ok(index >= 0); land.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...land);
|
||||
const beforeStorm = structuredClone(game);
|
||||
reach(state => state.decision?.kind === 'richeseCache');
|
||||
const beforePurchase = structuredClone(game), acquisitionStart = actions.length;
|
||||
reach(state => distransPrebidPlayer(state, 'g').hand.some(card => card.id === DISTRANS_PREBID_CARD) &&
|
||||
distransPrebidUntouched(state));
|
||||
const acquired = structuredClone(game), acquisition = { before: beforePurchase,
|
||||
actions: structuredClone(actions.slice(acquisitionStart)), after: acquired };
|
||||
assert.ok(acquisition.actions.some(step => step.actor === 'g' && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(distransPrebidPlayer(game, 'g').spice, distransPrebidPlayer(beforePurchase, 'g').spice - 1);
|
||||
const firstLot = structuredClone(game);
|
||||
assert.equal(game.auction!.cards.length, 3);
|
||||
const thirdOpener = game.order[(game.auction!.opener + 2) % game.order.length];
|
||||
assert.notEqual(thirdOpener, 'g', 'The native third opener is a distinct transfer recipient');
|
||||
const recipient = capacity.startsWith('recipient') ? thirdOpener : 'a';
|
||||
const purchases: ReturnType<typeof buyDistransPrebidLot>['purchase'][] = [];
|
||||
const purchaseCount = capacity === 'recipientFull' ? 3 : capacity === 'ordinary' ? 0 : 2;
|
||||
for (let count = 0; count < purchaseCount; count++) {
|
||||
const result = buyDistransPrebidLot(game, capacity === 'donorFull' ? 'g' : recipient, actions);
|
||||
game = result.game; purchases.push(result.purchase);
|
||||
}
|
||||
if (capacity === 'recipientFull') reach(state => state.turn === 2 && distransPrebidUntouched(state));
|
||||
assert.ok(distransPrebidUntouched(game));
|
||||
if (capacity === 'donorFull') assert.equal(distransPrebidPlayer(game, 'g').hand.length, handLimit(distransPrebidPlayer(game, 'g')));
|
||||
if (capacity === 'recipientLastSlot') {
|
||||
assert.equal(game.auction!.active, recipient);
|
||||
assert.equal(game.active, recipient);
|
||||
assert.equal(distransPrebidPlayer(game, recipient).hand.length, handLimit(distransPrebidPlayer(game, recipient)) - 1);
|
||||
}
|
||||
if (capacity === 'recipientFull') assert.equal(distransPrebidPlayer(game, recipient).hand.length, handLimit(distransPrebidPlayer(game, recipient)));
|
||||
assert.deepEqual(distransPrebidInventory(game), originalInventory);
|
||||
const give = startingDealSelection.find(row => row.actor === 'g')!.card;
|
||||
const action: Action = { type: 'card', card: DISTRANS_PREBID_CARD, target: recipient, give };
|
||||
return { game, advanced, capacity, holder: 'g', donor: 'g', recipient, card: DISTRANS_PREBID_CARD, give, action,
|
||||
initial, setup, beforeStartingDeal, startingDealSelection, afterSetup, beforeStorm,
|
||||
acquisition, acquired, firstLot, purchases, originalInventory, actions, sourceActionCount: actions.length,
|
||||
staging: ['Only unopened starting Treachery/Spice positions and scalar lotteries are selected. All cache/ordinary purchases, hands, prices, clocks and continuations are native public actions.'] };
|
||||
}
|
||||
export type DistransPrebidFixture = ReturnType<typeof createDistransPrebidFixture>;
|
||||
export function playDistransPrebid(fixture: DistransPrebidFixture, game = structuredClone(fixture.game),
|
||||
actions: DistransPrebidStep[] = []): Game {
|
||||
return actDistransPrebid(game, fixture.holder, fixture.action, actions);
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
normalAuctionHandBlock,
|
||||
quoteNormalAuctionBid,
|
||||
quoteNormalAuctionNext,
|
||||
} from '../game/normal-auction';
|
||||
|
||||
void test('a hand filled after an ordinary lot opens cannot bid, while faction-specific free capacity remains usable', () => {
|
||||
assert.equal(normalAuctionHandBlock(3, 4), null);
|
||||
assert.match(normalAuctionHandBlock(4, 4)!, /hand is full; pass/);
|
||||
assert.equal(normalAuctionHandBlock(7, 8), null);
|
||||
assert.match(normalAuctionHandBlock(8, 8)!, /hand is full; pass/);
|
||||
assert.match(normalAuctionHandBlock(5, 4)!, /hand is full; pass/);
|
||||
});
|
||||
|
||||
void test('ordinary auction follows table order and skips full-hand seats and the high bidder', () => {
|
||||
assert.deepEqual(
|
||||
quoteNormalAuctionNext({
|
||||
|
||||
Reference in new issue
Block a user