Allow Distrans before the first ordinary auction bid

This commit is contained in:
Quintine committed 2026-10-26 08:40:44 +11:00
1 parent 34dccdfd99
commit 2ea3c58f68
19 files changed
+906 -42

No files matched your search

+13 -4
View File
@@ -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);
+182
View File
@@ -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 }]);
});
+213
View File
@@ -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);
});
}
+40
View File
@@ -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);
});
+193
View File
@@ -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);
}
+9
View File
@@ -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({