Expand Basic bribe windows and fix marker guidance
Verify and publish checkpoint container / container (push) Successful in 11m0s
Verify and publish checkpoint container / container (push) Successful in 11m0s
This commit is contained in:
1 parent
7fccd62814
commit
9dc339e726
8 files changed
+362
-26
No files matched your search
@@ -34,6 +34,11 @@ export function ShipmentPromises({
|
||||
: preparationCard ? `Play ${preparationCard.name}` : null;
|
||||
const blocked = busy || !!g.truthtrance || !!g.response || !!g.decision || !!g.phaseOpening;
|
||||
const shipping = next && (next.type === 'ship' || next.type === 'guildShip');
|
||||
const markerShipment = shipping && next.type === 'ship' && typeof next.noField === 'string';
|
||||
// A concealed marker is priced as one force, regardless of its value.
|
||||
// Optional physical companions have their own paid count; a Smuggler
|
||||
// companion is free and therefore does not add to this tariff.
|
||||
const pricedAmount = markerShipment ? 1 + Number(next?.noFieldReserves ?? 0) : Number(next?.amount);
|
||||
const halfRate = g.karamaShipping?.player === me.id ||
|
||||
(!g.guildRateCanceled &&
|
||||
(me.faction === 'guild' ||
|
||||
@@ -44,13 +49,13 @@ export function ShipmentPromises({
|
||||
String(next.territory) === 'reserves'
|
||||
? 'reserves'
|
||||
: territory(String(next.territory)).type,
|
||||
Number(next.amount),
|
||||
pricedAmount,
|
||||
g.karamaShipping?.player === me.id || !g.guildRateCanceled,
|
||||
)
|
||||
: reserveShipmentCost(
|
||||
{ faction: me.faction, halfRate },
|
||||
territory(String(next.territory)).type,
|
||||
Number(next.amount),
|
||||
pricedAmount,
|
||||
)
|
||||
: null;
|
||||
const share = shipping
|
||||
@@ -63,7 +68,11 @@ export function ShipmentPromises({
|
||||
: '';
|
||||
const description = next
|
||||
? shipping
|
||||
? `Ship ${Number(next.amount)} forces${allocation} to ${territory(String(next.territory)).name}, sector ${Number(next.sector)}`
|
||||
? markerShipment
|
||||
? `Ship your No-Field to ${territory(String(next.territory)).name}, sector ${Number(next.sector)}`
|
||||
: `Ship ${Number(next.amount)} forces${allocation} to ${territory(String(next.territory)).name}, sector ${Number(next.sector)}`
|
||||
: next.type === 'revealNoField'
|
||||
? 'Reveal your current No-Field'
|
||||
: next.type === 'pledgeAid'
|
||||
? 'Reclaim your unused ally pledge'
|
||||
: `Play ${me.hand?.find((c) => c.id === next.card)?.name ?? 'your preparation card'}${next.amount ? ` to revive ${Number(next.amount)} forces${allocation}` : ' for your shipment discount'}`
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Ordinary bribe controls
|
||||
|
||||
## Basic native-faction auction and revival responses — 29 October 2026
|
||||
|
||||
The same clean auction-payment, Atreides inspection and Fremen allied-revival
|
||||
grant interruptions now support selected native Ix/Tleilaxu, CHOAM/Richese and
|
||||
Ecaz/Moritani decks and factions, including a normal Semuta preview. The
|
||||
original decision, passes and reserved bid remain; the ordinary nonallied
|
||||
payment stays in the recipient's deferred bribe ledger. Optional modules,
|
||||
active child reactions and the payer's commitments remain guarded. This is a
|
||||
provisional digital ordering under the printed bribe rule, not a publisher
|
||||
priority ruling.
|
||||
|
||||
## Classic Basic auction and revival responses — 29 October 2026
|
||||
|
||||
In supported classic Basic tables, a direct nonallied bribe can also be paid
|
||||
|
||||
@@ -17,6 +17,18 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic native bribes and No-Field guidance — 29 October. Prototyped / Partial.**
|
||||
Clean auction-payment, Atreides inspection and Fremen revival-grant windows now
|
||||
allow ordinary nonallied bribes on selected Basic native-faction tables; the
|
||||
original pending choice, passes and reserved funds remain. The private
|
||||
No-Field promise guide now names a reveal correctly and prices a marker as one
|
||||
shipment force. Mixed-faction native actions and rendered callbacks exercise
|
||||
these changes; an alliance, casualty and card swap are explicitly staged for
|
||||
the bribe fixtures. Thirteen related files pass 72 cases; types/lint and the
|
||||
production build pass. Other interruptions, optional modules, complete rules
|
||||
and deployed acceptance remain open.
|
||||
[Bribe scope](BRIBE_CONTROLS.md#basic-native-faction-auction-and-revival-responses--29-october-2026).
|
||||
|
||||
**Truthtrance and native No-Field shipment — 29 October. Prototyped / Partial.**
|
||||
The shipment question builder can ask whether Richese will ship its own
|
||||
No-Field this turn, optionally to a specific territory or sector, and combine
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic native-faction bribe interruptions — 29 October 2026
|
||||
|
||||
The supported clean auction-payment, Atreides inspection and Fremen revival
|
||||
grant windows allow ordinary nonallied bribes on selected Basic native-faction
|
||||
tables. Payments stay deferred and do not replace the parent choice. Their
|
||||
ordering is a provisional online interpretation.
|
||||
[Scope](BRIBE_CONTROLS.md#basic-native-faction-auction-and-revival-responses--29-october-2026).
|
||||
|
||||
## Truthtrance and a native No-Field shipment — 29 October 2026
|
||||
|
||||
The structured shipment question can explicitly refer to Richese's owned
|
||||
|
||||
+25
-22
@@ -17484,10 +17484,10 @@ function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, am
|
||||
* advancing their pending state. Existing whole-promise planners retain their
|
||||
* original timing boundary until those compositions are implemented. */
|
||||
function basicBribeInterrupt(g: Game, p: Player): boolean {
|
||||
if (g.status !== 'playing' || g.advanced || g.expansions.length > 1 ||
|
||||
if (g.status !== 'playing' || g.advanced ||
|
||||
g.inflation?.side === 'double' || g.techTokens || g.strongholdCards || g.leaderSkills ||
|
||||
g.homeworlds || g.nexusCards || g.discoveryEnabled || g.sandtrout || g.coalitionState ||
|
||||
g.ecazTreachery || g.semutaPreview || g.semutaRecruitsPreview || g.truthtrance ||
|
||||
g.homeworlds || g.nexusCards || g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker || g.sandtrout || g.coalitionState ||
|
||||
g.ecazTreachery || g.semutaRecruitsPreview || g.truthtrance ||
|
||||
g.pendingKarama || g.pendingKull || g.pendingTreacheryDiscard || g.pendingNullentropy ||
|
||||
g.bureaucratPayments?.pending || g.battle || g.pendingRicheseGift || g.pendingShipment ||
|
||||
g.pendingGuildTransport || g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingEmperorAuctionGift ||
|
||||
@@ -17507,32 +17507,35 @@ function basicBribeInterrupt(g: Game, p: Player): boolean {
|
||||
(g.pledgePromises ?? []).some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
|
||||
(g.deals ?? []).some(row => row.status === 'active' && row.payment?.payer === p.id && row.paymentStatus === 'pending')) return false;
|
||||
const classic = ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'];
|
||||
if (!g.expansions.length) {
|
||||
if (g.phaseOpening || !g.players.every(seat => classic.includes(seat.faction)) ||
|
||||
Object.entries(g).some(([key, value]) => key.startsWith('pending') &&
|
||||
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return false;
|
||||
const nativeRoster = !g.expansions.length
|
||||
? g.players.every(seat => classic.includes(seat.faction)) : selectedPhaseModuleRoster(g);
|
||||
if (nativeRoster && !g.phaseOpening && !g.choamMarket && !g.ixAuction &&
|
||||
!Object.entries(g).some(([key, value]) => key.startsWith('pending') &&
|
||||
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) {
|
||||
if (g.decision?.kind === 'auctionPayment' && !g.response)
|
||||
return g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale &&
|
||||
g.auction.bidder === g.decision.player && !!g.auction.cards[g.auction.index];
|
||||
const response = g.response;
|
||||
if (!response || g.decision || !Array.isArray(response.passed) ||
|
||||
if (response && (g.decision || !Array.isArray(response.passed) ||
|
||||
new Set(response.passed).size !== response.passed.length ||
|
||||
response.passed.some(id => !g.players.some(seat => seat.id === id))) return false;
|
||||
const owner = g.players.find(seat => seat.id === response.owner);
|
||||
const keys = Object.keys(response).sort().join(',');
|
||||
if (response.kind === 'atreidesAuction') return keys === 'kind,owner,passed' && g.phase === 3 &&
|
||||
owner?.faction === 'atreides' && !!g.auction && !g.richeseAuction && !g.currentAuctionSale &&
|
||||
!!g.auction.cards[g.auction.index];
|
||||
if (response.kind === 'fremenRevivalGrant') {
|
||||
if (keys !== 'fremenGrant,kind,owner,passed,recipient' || g.phase !== 4 || owner?.faction !== 'fremen' ||
|
||||
!response.fremenGrant || response.fremenGrant.owner !== response.owner ||
|
||||
response.fremenGrant.recipient !== response.recipient) return false;
|
||||
try { validateFremenRevivalGrant(g, response.fremenGrant); return true; }
|
||||
catch (error) { if (error instanceof FremenRevivalGrantError) return false; throw error; }
|
||||
response.passed.some(id => !g.players.some(seat => seat.id === id)))) return false;
|
||||
if (response) {
|
||||
const owner = g.players.find(seat => seat.id === response.owner);
|
||||
const keys = Object.keys(response).sort().join(',');
|
||||
if (response.kind === 'atreidesAuction') return keys === 'kind,owner,passed' && g.phase === 3 &&
|
||||
owner?.faction === 'atreides' && !!g.auction && !g.richeseAuction && !g.currentAuctionSale &&
|
||||
!!g.auction.cards[g.auction.index];
|
||||
if (response.kind === 'fremenRevivalGrant') {
|
||||
if (keys !== 'fremenGrant,kind,owner,passed,recipient' || g.phase !== 4 || owner?.faction !== 'fremen' ||
|
||||
!response.fremenGrant || response.fremenGrant.owner !== response.owner ||
|
||||
response.fremenGrant.recipient !== response.recipient) return false;
|
||||
try { validateFremenRevivalGrant(g, response.fremenGrant); return true; }
|
||||
catch (error) { if (error instanceof FremenRevivalGrantError) return false; throw error; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (g.response) return false;
|
||||
if (g.response || g.expansions.length !== 1 || g.semutaPreview) return false;
|
||||
if (g.expansions[0] === 'ix') return !!g.phaseOpening && !!g.amalWindowOpen && !g.decision &&
|
||||
g.players.every(seat => [...classic, 'ixians', 'tleilaxu'].includes(seat.faction));
|
||||
const extortion = g.moritaniExtortion, decision = g.decision;
|
||||
|
||||
+1
-1
@@ -296,7 +296,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Open Bribes beneath the table. Choose a non-allied faction and a positive whole amount, then use the named Pay button. The amount is public and leaves your supply immediately. A message or promise alone does not pay spice.',
|
||||
'The recipient keeps paid spice separately until the next Mentat opening. A payment after the current opening waits for next turn’s opening. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.',
|
||||
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. In supported Basic classic games, direct bribes can also interrupt a normal auction-payment decision, Atreides auction inspection or Fremen allied-revival grant. Supported Basic Ix and Moritani games also allow payments during the Amal opening or an Extortion choice. The original parent and its passes remain pending. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
|
||||
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. In supported Basic games with classic or selected native expansion factions, direct bribes can also interrupt a normal auction-payment decision, Atreides auction inspection or Fremen allied-revival grant. Supported Basic Ix and Moritani games also allow payments during the Amal opening or an Extortion choice. The original parent and its passes remain pending. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
|
||||
'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.',
|
||||
'In the Basic group preview, group allies are excluded from ordinary bribes and nonallied deals. Existing agreements remain recorded and can be released; a deferred bribe to a newly allied recipient stays pending while it is unavailable, subject to its original deadline.',
|
||||
'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.',
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import * as jsxRuntime from 'react/jsx-runtime';
|
||||
import ts from 'typescript';
|
||||
import * as catalog from '../game/catalog';
|
||||
import * as bribeOptions from '../game/bribe-options';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { bribeAction } from '../game/bribe-options';
|
||||
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
|
||||
import { truthMovementInventory as inventory } from './fixture-truth-movement';
|
||||
|
||||
const payer = 'richese', recipient = 'ecaz', atreides = 'atreides', fremen = 'fremen', emperor = 'emperor';
|
||||
const seat = (g: Game, id: string) => {
|
||||
const player = g.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const clean = (g: Game) => !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingTreacheryDiscard;
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
function next(g: Game) {
|
||||
if (g.status === 'setup' && g.setupStage === 'forces' && seat(g, fremen).reserves === 20 &&
|
||||
viewGame(g, fremen).setupPending.includes(fremen))
|
||||
return {actor:fremen, action:{type:'fremenSetup', placements:{'sietch_tabr:14':10}} satisfies Action};
|
||||
if (g.phase === 5 && g.active === emperor && clean(g))
|
||||
return {actor:emperor, action:{type:'endMovement'} satisfies Action};
|
||||
return nextTruthMovementNativeEarlyStep(g, {position:'first', method:'onceAround', direction:'clockwise'});
|
||||
}
|
||||
function reach(game: Game, until: (g: Game) => boolean): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = next(game); assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = act(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('The native Basic mixed-faction control was not reached');
|
||||
}
|
||||
/** Genuine public mixed-deck setup. Only the original unopened starting order
|
||||
* and a later conserved physical card swap are chosen to ensure an Emperor
|
||||
* Karama keeps the native Atreides/Fremen response open. */
|
||||
function originalGame() {
|
||||
let game = createGame('NATIVEBRIBES', newPlayer(atreides, 'Atreides', 'atreides'), false, ['ix','choam','ecaz']);
|
||||
for (const faction of [fremen, emperor, payer, 'choam', recipient] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
for (const p of game.players) game = act(game, p.id, {type:'ready'});
|
||||
game = act(game, game.host, {type:'start'});
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
const originalCards = inventory(game);
|
||||
game = reach(game, state => state.status === 'playing');
|
||||
assert.equal(game.semutaPreview, true);
|
||||
const holder = seat(game, emperor);
|
||||
if (!holder.hand.some(card => card.effect === 'karama')) {
|
||||
const fromDeck = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(holder.hand.length);
|
||||
if (fromDeck >= 0) [holder.hand[0], game.deck[fromDeck]] = [game.deck[fromDeck], holder.hand[0]];
|
||||
else {
|
||||
const other = game.players.find(p => p.id !== emperor && p.hand.some(card => card.effect === 'karama'));
|
||||
assert.ok(other);
|
||||
const index = other.hand.findIndex(card => card.effect === 'karama');
|
||||
[holder.hand[0], other.hand[index]] = [other.hand[index], holder.hand[0]];
|
||||
}
|
||||
}
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
return {game, originalCards};
|
||||
}
|
||||
let saved: ReturnType<typeof originalGame> | undefined;
|
||||
const source = () => saved ??= originalGame();
|
||||
const payment: Action = {type:'bribe', target:recipient, amount:1};
|
||||
function reject(g: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(g); assert.throws(() => act(g, actor, action)); assert.deepEqual(g, before);
|
||||
}
|
||||
function paid(game: Game) {
|
||||
const before = structuredClone(game), options = viewGame(game,payer).bribeOptions;
|
||||
assert.equal(options.blocked,null);
|
||||
assert.deepEqual(bribeAction(options,recipient,1),payment);
|
||||
const after = act(game,payer,payment);
|
||||
assert.deepEqual(after.response,before.response); assert.deepEqual(after.decision,before.decision);
|
||||
assert.deepEqual(after.auction,before.auction); assert.deepEqual(after.pendingRevival,before.pendingRevival);
|
||||
assert.equal(seat(after,payer).spice,seat(before,payer).spice-1);
|
||||
assert.equal(seat(after,recipient).spice,seat(before,recipient).spice);
|
||||
assert.equal(seat(after,recipient).bribes,seat(before,recipient).bribes+1);
|
||||
assert.deepEqual(inventory(after),source().originalCards);
|
||||
reject(game,payer,{type:'bribe',target:payer,amount:1});
|
||||
return after;
|
||||
}
|
||||
function inspectSource() {
|
||||
return reach(structuredClone(source().game), state => state.turn === 1 && state.phase === 3 &&
|
||||
state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction);
|
||||
}
|
||||
function auctionSource() {
|
||||
let g = inspectSource();
|
||||
g = reach(g, state => state.phase === 3 && clean(state) && !!state.auction && state.auction.active === emperor);
|
||||
g = act(g,emperor,{type:'bid',amount:1,allyPayment:0});
|
||||
return reach(g, state => state.decision?.kind === 'auctionPayment' && state.decision.player === emperor);
|
||||
}
|
||||
function grantSource() {
|
||||
let g = reach(structuredClone(source().game), state => state.turn === 1 && state.phase === 4 && clean(state));
|
||||
// The response is opened by the real grant. Stage only an otherwise valid
|
||||
// reciprocal alliance and one physically conserved casualty for its target.
|
||||
seat(g,fremen).ally = atreides; seat(g,atreides).ally = fremen;
|
||||
assert.ok(seat(g,atreides).reserves > 0);
|
||||
seat(g,atreides).reserves--; seat(g,atreides).tanks++;
|
||||
g = act(g,fremen,{type:'grantRevival'});
|
||||
assert.equal(g.response?.kind,'fremenRevivalGrant');
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('a mixed native faction table preserves Atreides inspection while a nonally pays a bribe', () => {
|
||||
const before = inspectSource(), after = paid(before);
|
||||
const control = act(before,emperor,{type:'passResponse'});
|
||||
const done = act(after,emperor,{type:'passResponse'});
|
||||
assert.deepEqual(done.auction,control.auction);
|
||||
assert.notEqual(done.response?.kind,'atreidesAuction');
|
||||
assert.equal(seat(done,recipient).bribes,seat(before,recipient).bribes+1);
|
||||
});
|
||||
|
||||
void test('a mixed native faction table preserves a winning auction invoice and purchase through a bribe', () => {
|
||||
const before = auctionSource(), lot = before.auction!.cards[before.auction!.index].id;
|
||||
const after = paid(before), done = act(after,emperor,{type:'decision',karama:false});
|
||||
const delivered = reach(done,state => seat(state,emperor).hand.some(card => card.id === lot));
|
||||
assert.equal(seat(delivered,emperor).hand.filter(card => card.id === lot).length,1);
|
||||
assert.equal(seat(delivered,recipient).bribes,seat(before,recipient).bribes+1);
|
||||
assert.deepEqual(inventory(delivered),source().originalCards);
|
||||
});
|
||||
|
||||
void test('a mixed native faction table preserves the actual Fremen grant response and its original result', () => {
|
||||
const before = grantSource(), after = paid(before);
|
||||
const control = act(before,emperor,{type:'passResponse'}), done = act(after,emperor,{type:'passResponse'});
|
||||
assert.deepEqual(done.freeRevival,control.freeRevival);
|
||||
assert.ok(done.freeRevival.includes(atreides));
|
||||
assert.equal(seat(done,recipient).bribes,seat(before,recipient).bribes+1);
|
||||
});
|
||||
|
||||
void test('the original human and bot responders retain legal choices after the bribe', () => {
|
||||
for (const g of [inspectSource(),grantSource()]) {
|
||||
const after = paid(g);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(after,emperor);
|
||||
view.players.find(p => p.id === emperor)!.bot = difficulty;
|
||||
const action = botActions(view).find(choice => choice.type === 'passResponse' || choice.type === 'card' && choice.mode === 'cancel');
|
||||
assert.ok(action,difficulty);
|
||||
assert.doesNotThrow(() => act(reload(after),emperor,action));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('the rendered mixed-faction bribe control pays once and preserves the actual inspection', () => {
|
||||
let game = inspectSource();
|
||||
const original = structuredClone(game.response), before = seat(game,payer).spice;
|
||||
const states: unknown[] = [], sent: Action[] = [];
|
||||
let cursor = 0;
|
||||
const exported: {Bribes?: (props: {game: ReturnType<typeof viewGame>; busy: boolean; act: (action: Action) => void}) => ReactNode} = {};
|
||||
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/bribes.tsx', import.meta.url), 'utf8'), {
|
||||
compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX},
|
||||
}).outputText, {exports:exported,require(name:string) {
|
||||
if (name === 'react/jsx-runtime') return jsxRuntime;
|
||||
if (name === 'react') return {useId:() => 'native-bribe',useState(initial:unknown) {
|
||||
const index = cursor++;
|
||||
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
|
||||
return [states[index], (value:unknown) => {states[index] = typeof value === 'function' ? value(states[index]) : value;}];
|
||||
}};
|
||||
if (name === '@/game/catalog') return catalog;
|
||||
if (name === '@/game/bribe-options') return bribeOptions;
|
||||
if (name === '@/components/ui/button') return {Button:'button'};
|
||||
throw Error(`Unexpected import ${name}`);
|
||||
}});
|
||||
assert.ok(exported.Bribes);
|
||||
type Element = ReactElement<{children?: ReactNode; [key: string]: unknown}>;
|
||||
const elements = (node: ReactNode): Element[] => {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{children?: ReactNode; [key: string]: unknown}>(node)) return [];
|
||||
return [node,...elements(node.props.children)];
|
||||
};
|
||||
const render = () => {
|
||||
cursor = 0;
|
||||
return exported.Bribes!({game:viewGame(game,payer),busy:false,act(action) {
|
||||
sent.push(action); game = act(game,payer,action);
|
||||
}});
|
||||
};
|
||||
let nodes = elements(render());
|
||||
const select = nodes.find(node => node.type === 'select'); assert.ok(select);
|
||||
assert.equal(select.props.disabled,false);
|
||||
(select.props.onChange as (event: {target: {value: string}}) => void)({target:{value:recipient}});
|
||||
nodes = elements(render());
|
||||
const button = nodes.find(node => node.type === 'button'); assert.ok(button);
|
||||
assert.equal(button.props.disabled,false);
|
||||
assert.match(renderToStaticMarkup(createElement(() => render())),/Mentat Pause/);
|
||||
(button.props.onClick as () => void)();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sent)),[payment]);
|
||||
assert.equal(seat(game,payer).spice,before-1);
|
||||
assert.deepEqual(game.response,original);
|
||||
const done = act(game,emperor,{type:'passResponse'});
|
||||
assert.notEqual(done.response?.kind,'atreidesAuction');
|
||||
});
|
||||
|
||||
void test('an actual Ecaz placement keeps the bribe unavailable', () => {
|
||||
const game = reach(structuredClone(source().game), state => state.phase === 4 &&
|
||||
state.decision?.kind === 'ecazPlacement');
|
||||
assert.ok(viewGame(game,payer).bribeOptions.blocked);
|
||||
reject(game,payer,payment);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement, type ButtonHTMLAttributes, type MouseEvent, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { ShipmentPromises } from '../components/shipment-promises';
|
||||
import { viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { deployRicheseNoField } from '../game/richese-no-field';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
createTruthtranceRichesePromisesFixture as fixture,
|
||||
actTruthtranceRichesePromises as act,
|
||||
askTruthtranceRichesePromises as ask,
|
||||
advanceTruthtranceRichesePromises as advance,
|
||||
truthtranceRichesePromisesClean as clean,
|
||||
truthtranceRichesePromisesPlayer as player,
|
||||
} from './fixture-truthtrance-richese-promises';
|
||||
|
||||
const question: TruthQuestion = {
|
||||
kind: 'shipment', target: 'richese',
|
||||
claim: {kind: 'noField', territory: 'carthag', sector: 11},
|
||||
};
|
||||
|
||||
/** Genuine public setup and Truthtrance action; the optional existing marker
|
||||
* is staged in conserved custody to represent a previous turn. */
|
||||
function promised(existing: boolean) {
|
||||
const f = fixture();
|
||||
const source = structuredClone(f.game);
|
||||
if (existing) {
|
||||
const owner = player(source), marker = owner.noField!.tokens.find(token => token.value === 3);
|
||||
assert.ok(marker);
|
||||
owner.noField = deployRicheseNoField(owner.noField!, {
|
||||
tokenId: marker.id, controller: owner.id, location: {territory:'polar_sink', sector:0},
|
||||
});
|
||||
}
|
||||
let game = ask(source, f, question);
|
||||
game = act(game, f.target, {type:'truthAnswer', answer:'yes'});
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
||||
return {f, game};
|
||||
}
|
||||
function button(game: GameView, actions: Action[], label: string) {
|
||||
function find(node: ReactNode): ReactElement<ButtonHTMLAttributes<HTMLButtonElement>> | undefined {
|
||||
if (!isValidElement<{children?: ReactNode; onClick?: unknown}>(node)) return;
|
||||
if (node.props.children === label && typeof node.props.onClick === 'function')
|
||||
return node as ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>;
|
||||
return Children.toArray(node.props.children).map(find).find(Boolean);
|
||||
}
|
||||
const found = find(ShipmentPromises({game, busy:false, act: action => actions.push(action)}));
|
||||
assert.ok(found, label);
|
||||
found.props.onClick!({} as MouseEvent<HTMLButtonElement>);
|
||||
}
|
||||
function html(game: GameView) {
|
||||
return renderToStaticMarkup(createElement(ShipmentPromises, {game, busy:false, act() {}}));
|
||||
}
|
||||
|
||||
void test('a real No-Field promise offers a correctly priced one-marker shipment', () => {
|
||||
const {f, game} = promised(false), projected = viewGame(game, f.target);
|
||||
const witness = projected.shipmentCompletion?.actions[0];
|
||||
assert.equal(witness?.type, 'ship'); assert.ok(witness.noField);
|
||||
const output = html(projected);
|
||||
assert.match(output, /Ship your No-Field to Carthag, sector 11/);
|
||||
assert.match(output, /Cost: 1 spice/);
|
||||
assert.doesNotMatch(output, /NaN|Ship 1 forces|Play your preparation card/);
|
||||
const sent: Action[] = [];
|
||||
button(projected, sent, 'Ship your No-Field to Carthag, sector 11');
|
||||
assert.deepEqual(sent, [witness]);
|
||||
const done = advance(act(game, f.target, sent[0]), clean);
|
||||
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(player(done).noField?.deployed?.tokenId, witness.noField);
|
||||
});
|
||||
|
||||
void test('an existing marker is labeled as a reveal and the next shipment uses its fresh event', () => {
|
||||
const {f, game: original} = promised(true), projected = viewGame(original, f.target);
|
||||
const first = projected.shipmentCompletion?.actions[0];
|
||||
assert.equal(first?.type, 'revealNoField');
|
||||
const output = html(projected);
|
||||
assert.match(output, /Reveal your current No-Field/);
|
||||
assert.doesNotMatch(output, /NaN|Play your preparation card|Cost:/);
|
||||
const sent: Action[] = [];
|
||||
button(projected, sent, 'Reveal your current No-Field');
|
||||
assert.deepEqual(sent, [first]);
|
||||
const revealed = act(original, f.target, sent[0]);
|
||||
const next = viewGame(revealed, f.target).shipmentCompletion?.actions[0];
|
||||
assert.equal(next?.type, 'ship');
|
||||
assert.notEqual(next?.noField, first?.token);
|
||||
assert.equal(next?.event, player(revealed).noFieldEvent);
|
||||
const done = advance(act(revealed, f.target, next!), clean);
|
||||
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(player(done).noField?.deployed?.tokenId, next?.noField);
|
||||
});
|
||||
Reference in new issue
Block a user