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
@@ -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