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