Sequence Advanced Charity from the First Player

This commit is contained in:
Quintine committed 2026-10-27 10:50:22 +11:00
1 parent 84fdd404c9
commit 54787c7f7b
23 files changed
+1016 -62

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import {
actAdvancedCharityOrder as act, advancedCharityOrderClean as clean,
advancedCharityOrderInventory as inventory, advancedCharityOrderPlayer as p,
advancedCharityOrderReload as reload, allowAdvancedCharityOpening as allow,
createAdvancedCharityOrderFixture as fixture, finishAdvancedCharityPhase as finishPhase,
finishAdvancedCharityTurn as finishTurn, nextAdvancedCharityOrderStep as next,
passAdvancedCharityResponses as pass, unstampedAdvancedCharitySupplement as unstamped,
type AdvancedCharityOrderStep,
} from './fixture-advanced-charity-order';
function reject(game: Game, actor: string, action: Action) {
const before = reload(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('genuine Advanced Charity source uses native First Player order and a claim retains its owned turn', () => {
const f = fixture(); let game = allow(reload(f.game));
assert.equal(f.sourceCounts.setup, f.setupActions.length);
assert.equal(f.sourceCounts.bribes, f.bribeActions.length);
assert.equal(f.sourceCounts.source, f.actions.length);
assert.ok(f.bribeActions.some(step => step.actor === 'atreides' && step.action.type === 'bribe'));
assert.equal(p(f.beforeIncome, 'choam').spice, 2); assert.equal(p(game, 'choam').spice, 8);
assert.equal(p(game, 'choam').bribes, p(f.beforeIncome, 'choam').bribes);
assert.deepEqual(game.charityOrder, { version: 1, turn: game.turn, order: game.order, remaining: game.order });
assert.equal(game.active, 'atreides');
assert.deepEqual(viewGame(game, 'atreides').charityOrder, game.charityOrder);
const queue = structuredClone(game.charityOrder);
game = act(game, 'atreides', { type: 'charity' });
assert.equal(p(game, 'atreides').spice, 2); assert.equal(p(game, 'atreides').charityTurn, game.turn);
assert.equal(p(game, 'choam').spice, 6);
assert.deepEqual(game.charityOrder, queue); assert.equal(game.active, 'atreides');
game = finishTurn(game);
assert.equal(game.active, 'beneGesserit');
assert.deepEqual(game.charityOrder?.remaining, ['beneGesserit', 'choam']);
assert.deepEqual(inventory(game), f.original);
});
void test('later and completed Advanced Charity actors cannot claim or finish out of order', () => {
const f = fixture(); let game = allow(reload(f.game));
for (const actor of ['beneGesserit', 'choam']) {
assert.ok(viewGame(game, actor).charity.orderBlocked);
reject(game, actor, { type: 'charity' }); reject(game, actor, { type: 'ready' });
}
game = finishTurn(game);
reject(game, 'atreides', { type: 'charity' }); reject(game, 'atreides', { type: 'ready' });
assert.ok(viewGame(game, 'atreides').charity.orderBlocked);
assert.equal(game.active, 'beneGesserit');
assert.deepEqual(game.charityOrder?.remaining, ['beneGesserit', 'choam']);
});
void test('original CHOAM opening response holds the whole Charity queue before any claim or Finish', () => {
const f = fixture(), before = reload(f.game), queue = structuredClone(f.game.charityOrder);
assert.equal(before.response?.kind, 'choamCharity');
assert.ok(queue);
reject(before, 'atreides', { type: 'charity' }); reject(before, 'atreides', { type: 'ready' });
let game = before;
for (let count = 0; game.response && count < 10; count++) {
assert.deepEqual(game.charityOrder, queue);
const step = next(game); assert.ok(step); assert.equal(step.action.type, 'passResponse');
game = act(game, step.actor, step.action);
}
assert.equal(game.response ?? null, null);
assert.deepEqual(game.charityOrder, queue);
assert.equal(game.active, queue.remaining[0]);
assert.equal(p(game, 'choam').spice, 8);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
assert.notEqual(p(game, 'atreides').charityTurn, game.turn);
assert.deepEqual(inventory(game), f.original);
});
void test('wealthy Bene Gesserit response retains the current Charity actor through native payment then real finishes close phase', () => {
const f = fixture(); let game = allow(reload(f.game));
game = act(game, 'atreides', { type: 'charity' }); game = finishTurn(game);
const bg = p(game, 'beneGesserit').spice, provider = p(game, 'choam').spice;
const queue = structuredClone(game.charityOrder);
assert.ok(bg >= 2);
game = act(game, 'beneGesserit', { type: 'charity' });
assert.equal(game.response?.kind, 'bgCharity');
assert.equal(game.active, 'beneGesserit'); assert.deepEqual(game.charityOrder, queue);
reject(game, 'beneGesserit', { type: 'ready' }); reject(game, 'choam', { type: 'ready' });
game = pass(game);
assert.equal(p(game, 'beneGesserit').spice, bg + 2);
assert.equal(p(game, 'choam').spice, provider - 2);
assert.equal(game.active, 'beneGesserit'); assert.deepEqual(game.charityOrder, queue);
game = finishTurn(game); assert.equal(game.active, 'choam');
game = finishTurn(game);
if (game.phase === 2) assert.ok(game.charityOrder, 'The original closing phase controls still own the frame');
game = finishPhase(game);
assert.equal(game.phase, 3); assert.equal(game.charityOrder ?? null, null);
assert.deepEqual(inventory(game), f.original);
});
void test('Basic low claimants retain concurrent Charity and no ordered frame', () => {
const f = fixture({ advanced: false, lowBG: true }); let game = allow(reload(f.game));
assert.equal(game.charityOrder ?? null, null);
assert.equal(viewGame(game, 'beneGesserit').charity.orderBlocked, null);
assert.equal(p(game, 'atreides').spice, 0); assert.equal(p(game, 'beneGesserit').spice, 0);
game = act(game, 'beneGesserit', { type: 'charity' });
assert.equal(p(game, 'beneGesserit').spice, 2);
game = act(game, 'beneGesserit', { type: 'ready' });
game = act(game, 'atreides', { type: 'charity' });
assert.equal(p(game, 'atreides').spice, 2);
assert.equal(p(game, 'choam').spice, 4);
assert.equal(game.charityOrder ?? null, null);
assert.deepEqual(inventory(game), f.original);
});
void test('explicit conserved unstamped Advanced unit boundary retains the old concurrent claim contract', () => {
const f = fixture(), ordered = allow(reload(f.game));
let game = unstamped(ordered);
const { charityOrder: ignored, ...rest } = ordered;
assert.ok(ignored); assert.deepEqual(game, rest, 'Only the frame is removed in this labelled unit supplement');
game = act(game, 'beneGesserit', { type: 'charity' });
assert.equal(game.response?.kind, 'bgCharity'); game = pass(game);
game = act(game, 'atreides', { type: 'charity' });
assert.equal(p(game, 'atreides').spice, 2);
assert.equal(game.charityOrder ?? null, null);
assert.deepEqual(inventory(game), f.original);
});
void test('all four minimal profiles claim or Finish only the current actual Advanced Charity turn', () => {
const f = fixture(), opened = allow(reload(f.game));
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
let game = reload(opened);
const actions: AdvancedCharityOrderStep[] = [];
for (let count = 0; game.phase === 2 && count < 40; count++) {
if (!clean(game)) {
const step = next(game); assert.ok(step);
game = act(game, step.actor, step.action, actions); continue;
}
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
for (const other of game.players.filter(player => player.id !== actor)) {
const view = viewGame(game, other.id); view.players.find(player => player.id === other.id)!.bot = difficulty;
assert.equal(botActions(view).some(action => ['charity', 'ready'].includes(action.type)), false);
}
const view = viewGame(game, actor); view.players.find(player => player.id === actor)!.bot = difficulty;
const action = botActions(view).find(action => ['charity', 'ready'].includes(action.type));
assert.ok(action, `${difficulty} supplies the current actor’s native claim or Finish`);
game = act(game, actor, action, actions);
}
assert.ok(actions.length > 0); assert.equal(game.phase, 3);
assert.equal(game.charityOrder ?? null, null);
assert.deepEqual(inventory(game), f.original);
}
});
+69 -4
View File
@@ -30,25 +30,35 @@ function projected({
choamCharity: { turn: 3, canceled: false },
});
game.players[0].spice = spice;
return viewGame(game, 'p');
const view = viewGame(game, 'p');
view.charityOrder = null;
view.charity.orderBlocked = null;
return view;
}
function ordered(remaining = ['p', 'other'], order = ['p', 'other']): GameView {
const game = projected({ advanced: true, spice: 1 });
game.charityOrder = { version: 1, turn: game.turn, order, remaining };
game.charity.orderBlocked = remaining[0] === 'p' ? null : 'Wait for the current Charity turn.';
return game;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(CharityClaim, { game, busy, act() {} }));
}
function click(node: ReactNode): boolean {
function click(node: ReactNode, selector = 'charity-claim-submit'): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode;
onClick?: () => void;
'data-testid'?: string;
}>(child)) continue;
if (child.props['data-testid'] === 'charity-claim-submit' && child.props.onClick) {
if (child.props['data-testid'] === selector && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children)) return true;
if (click(child.props.children, selector)) return true;
}
return false;
}
@@ -59,6 +69,12 @@ function claim(game: GameView, busy = false): Action[] {
return actions;
}
function finish(game: GameView, busy = false): Action[] {
const actions: Action[] = [];
click(CharityClaim({ game, busy, act(action) { actions.push(action); } }), 'charity-turn-finish');
return actions;
}
void test('the actual charity control submits one ordinary claim action from its current entitlement', () => {
const game = projected({ spice: 1 });
assert.match(markup(game), /If you have fewer than two spice, claim charity once to reach two/);
@@ -66,6 +82,55 @@ void test('the actual charity control submits one ordinary claim action from its
assert.deepEqual(claim(game), [{ type: 'charity' }]);
});
void test('stamped current Charity controls submit exact claim and finish packets without changing funding guidance', () => {
const game = ordered(), html = markup(game);
assert.match(html, /First Player counterclockwise, one player at a time/);
assert.match(html, /It is your Charity turn/); assert.match(html, /Finish Charity turn/);
assert.match(html, /CHOAM pays as much of the ordinary claim as it can/);
assert.deepEqual(claim(game), [{ type: 'charity' }]);
assert.deepEqual(finish(game), [{ type: 'ready' }]);
game.players[0].charityClaimed = true; game.charity.amount = 0;
assert.deepEqual(claim(game), []); assert.deepEqual(finish(game), [{ type: 'ready' }]);
});
void test('waiting and completed Charity turns show public names and cannot claim or finish', () => {
const waiting = ordered(['other', 'p'], ['other', 'p']);
const complete = ordered(['other']);
for (const game of [waiting, complete]) {
Object.defineProperty(game.players[1], 'spice', { get() { throw Error('Turn status cannot inspect a rival wallet'); } });
assert.match(markup(game), /Waiting for Other to finish Charity/);
assert.deepEqual(claim(game), []); assert.deepEqual(finish(game), []);
}
assert.match(markup(waiting), /Your turn comes later in the public order/);
assert.match(markup(complete), /Your Charity turn is complete/);
const ended = ordered([]); assert.match(markup(ended), /All Charity turns are complete/);
assert.deepEqual(finish(ended), []);
});
void test('stamped Charity claim and finish retain native pending-income and modal priority', () => {
for (const change of [
(game: GameView) => { game.charity.incomePending = true; },
(game: GameView) => { game.response = { kind: 'bgCharity', owner: 'other', passed: [], amount: 2 }; },
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
(game: GameView) => { game.phaseOpening = { passed: [] }; },
(game: GameView) => { game.truthtrance = {} as NonNullable<GameView['truthtrance']>; },
(game: GameView) => { game.nexusTraitors = { offer: null, pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']> }; },
(game: GameView) => { game.automaticContinuationPending = true; },
]) {
const game = ordered(); change(game); assert.deepEqual(claim(game), []); assert.deepEqual(finish(game), []);
}
const game = ordered(); assert.deepEqual(claim(game, true), []); assert.deepEqual(finish(game, true), []);
assert.match(markup(game, true), /data-testid="charity-turn-finish"[^>]*disabled/);
});
void test('Basic and unstamped Advanced Charity keep their existing claim control without a new finish action', () => {
for (const advanced of [false, true]) {
const game = projected({ advanced });
assert.deepEqual(claim(game), [{ type: 'charity' }]); assert.deepEqual(finish(game), []);
assert.doesNotMatch(markup(game), /charity-turn-finish|charity-turn-status|First Player counterclockwise/);
}
});
void test('busy, pending income, a completed claim and zero entitlement block the actual callback', () => {
const game = projected();
assert.deepEqual(claim(game, true), []);
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CharityOrderError,
charityOrderBlock as block,
createCharityOrder as create,
finishCharityOrder as finish,
validateCharityOrder as validate,
type CharityOrder,
type CharityOrderContext,
} from '../game/charity-order';
function context(ready: readonly string[] = []): CharityOrderContext {
return { turn: 3, order: ['c', 'a', 'b'], players: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], ready };
}
function rejects(current: CharityOrderContext, frame: CharityOrder, action: () => unknown): void {
const beforeContext = structuredClone(current), beforeFrame = structuredClone(frame);
assert.throws(action, CharityOrderError);
assert.deepEqual(current, beforeContext);
assert.deepEqual(frame, beforeFrame);
}
void test('Charity preserves the actual rotated First Player order rather than roster order', () => {
const current = context(), frame = create(current);
assert.deepEqual(frame, { version: 1, turn: 3, order: ['c', 'a', 'b'], remaining: ['c', 'a', 'b'] });
validate(current, frame);
assert.equal(block(current, frame, 'c'), null);
assert.match(block(current, frame, 'a')!, /Wait/);
assert.match(block(current, frame, 'foreign')!, /seated/);
const resumed = context(['c']);
assert.deepEqual(create(resumed), { ...frame, remaining: ['a', 'b'] });
assert.match(block(resumed, create(resumed), 'c')!, /finished/);
assert.equal(block(resumed, create(resumed), 'a'), null);
});
void test('only the current seat finishes, consuming the pre-finish prefix before the caller appends ready', () => {
const current = context(), frame = create(current);
rejects(current, frame, () => finish(current, frame, 'a'));
rejects(current, frame, () => finish(current, frame, 'foreign'));
const next = finish(current, frame, 'c');
assert.deepEqual(next.remaining, ['a', 'b']);
assert.deepEqual(current.ready, []);
validate(context(['c']), next);
rejects(current, frame, () => validate(current, next));
rejects(current, frame, () => finish(context(['c']), frame, 'c'));
const afterSecond = finish(context(['c']), next, 'a');
validate(context(['c', 'a']), afterSecond);
assert.deepEqual(afterSecond.remaining, ['b']);
rejects(context(['c']), next, () => finish(context(['c']), next, 'c'));
});
void test('the final completed Charity frame stays valid and never reopens a seat', () => {
const current = context(['c', 'a']), frame = create(current);
const done = finish(current, frame, 'b'), completed = context(['c', 'a', 'b']);
validate(completed, done);
assert.deepEqual(done.remaining, []);
assert.deepEqual(create(completed), done);
for (const player of completed.order) {
assert.match(block(completed, done, player)!, /complete/);
rejects(completed, done, () => finish(completed, done, player));
}
});
void test('the native context requires actual unique seats, their permutation and an exact completed prefix', () => {
const base = context();
for (const current of [
{ ...base, turn: 0 }, { ...base, turn: 1.5 }, { ...base, turn: Number.NaN },
{ ...base, players: [{ id: 'a' }] },
{ ...base, players: ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map(id => ({ id })) },
{ ...base, players: [{ id: 'a' }, { id: 'a' }, { id: 'c' }] },
{ ...base, players: [{ id: 'a' }, { id: 'b' }, { id: ' ' }] },
{ ...base, order: ['c', 'a'] }, { ...base, order: ['c', 'a', 'a'] },
{ ...base, order: ['c', 'a', 'foreign'] }, context(['a']), context(['c', 'b']),
context(['c', 'c']), context(['c', 'a', 'b', 'foreign']),
]) assert.throws(() => create(current), CharityOrderError);
for (const current of [null, [], { ...base, players: undefined }, { ...base, order: 'c,a,b' },
{ ...base, ready: undefined }, { ...base, order: Array.from({ length: 3 }) },
{ ...base, players: Array.from({ length: 3 }) }, { ...base, ready: Array.from({ length: 1 }) }])
assert.throws(() => create(current as unknown as CharityOrderContext), CharityOrderError);
for (const order of [['b', 'a'], ['f', 'a', 'b', 'c', 'd', 'e']]) {
const current = { turn: 1, order, players: [...order].reverse().map(id => ({ id })), ready: [] };
validate(current, create(current));
}
});
void test('stale, rearranged or extended frames cannot replace the original remaining suffix', () => {
const current = context(['c']), frame = create(current);
const missing = { ...frame } as Partial<CharityOrder>;
delete missing.remaining;
for (const value of [
null, [], missing, { ...frame, extra: true }, { ...frame, started: false },
{ ...frame, version: 2 }, { ...frame, turn: 2 },
{ ...frame, order: ['a', 'c', 'b'] }, { ...frame, order: ['c', 'a', 'a'] },
{ ...frame, remaining: ['b', 'a'] }, { ...frame, remaining: ['a'] },
{ ...frame, remaining: ['c', 'a', 'b'] }, { ...frame, remaining: ['a', 'foreign'] },
]) rejects(current, frame, () => validate(current, value as unknown as CharityOrder));
rejects(current, frame, () => validate({ ...current, turn: 4 }, frame));
rejects(current, frame, () => validate({ ...current, order: ['a', 'c', 'b'], ready: [] }, frame));
rejects(current, frame, () => finish(current, frame, ''));
});
void test('creation and finish detach both public arrays without changing the original context or frame', () => {
const current = context(['c']), before = structuredClone(current);
Object.freeze(current.order);
Object.freeze(current.ready);
Object.freeze(current.players);
Object.freeze(current);
const frame = create(current), original = structuredClone(frame);
Object.freeze(frame.order);
Object.freeze(frame.remaining);
Object.freeze(frame);
const next = finish(current, frame, 'a');
assert.notEqual(frame.order, current.order);
assert.notEqual(frame.remaining, current.order);
assert.notEqual(frame.order, frame.remaining);
assert.notEqual(next.order, frame.order);
assert.notEqual(next.remaining, frame.remaining);
next.order.pop();
next.remaining.pop();
assert.deepEqual(current, before);
assert.deepEqual(frame, original);
});
+12 -5
View File
@@ -26,7 +26,10 @@ for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native other-faction full claim uses provisional shortfall funding`, () => {
for (const choamSpice of [0, 1] as const) {
const f = fixture({ advanced, choamSpice }), before = structuredClone(f.game);
const g = act(f.game, f.guild, f.otherClaim);
let g = structuredClone(f.game);
while (g.charityOrder && g.charityOrder.remaining[0] !== f.guild)
g = act(g, g.charityOrder.remaining[0], { type: 'ready' });
g = act(g, f.guild, f.otherClaim);
assert.equal(player(g, f.choam).spice, 0);
assert.equal(player(g, f.guild).spice, 2);
assert.equal(player(g, f.recipient).bribes, 13 - choamSpice);
@@ -37,17 +40,21 @@ for (const advanced of [false, true]) {
assert.throws(() => act(g, f.guild, f.otherClaim), /once per turn|fewer than/);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} each low faction keeps exactly one independent claim in either order`, () => {
void test(`${advanced ? 'Advanced' : 'Basic'} each low faction keeps exactly one claim ${advanced ? 'in native player order' : 'in either order'}`, () => {
const f = fixture({ advanced, choamSpice: 1, guildSpice: 1 });
for (const order of [[f.choam, f.guild], [f.guild, f.choam]]) {
for (const order of advanced ? [[f.choam, f.guild]] : [[f.choam, f.guild], [f.guild, f.choam]]) {
let g = structuredClone(f.game);
for (const actor of order) g = act(g, actor, { type: 'charity' });
for (const actor of order) {
while (g.charityOrder && g.charityOrder.remaining[0] !== actor)
g = act(g, g.charityOrder.remaining[0], { type: 'ready' });
g = act(g, actor, { type: 'charity' });
}
assert.equal(player(g, f.guild).spice, 2);
assert.equal(player(g, f.choam).spice, order[0] === f.choam ? 1 : 2);
assert.equal(player(g, f.recipient).bribes, 11);
for (const actor of order) {
assert.equal(viewGame(g, actor).players.find(p => p.id === actor)!.charityClaimed, true);
assert.throws(() => act(g, actor, { type: 'charity' }), /once per turn|fewer than/);
assert.throws(() => act(g, actor, { type: 'charity' }), /once per turn|fewer than|Charity opportunity/);
}
assert.deepEqual(inventory(g), f.originalInventory);
}
+37 -5
View File
@@ -40,11 +40,21 @@ function fixture(advanced = false, ix = false) {
}
function ready(state: Game) {
let g = state;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
const players = g.phase === 2 && g.charityOrder ? [...g.charityOrder.remaining] : g.players.map(p => p.id);
for (const player of players) g = applyAction(g, player, { type: 'ready' });
while (g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
return g;
}
function charityTurn(state: Game, player: string) {
let g = state;
while (g.phase === 2 && g.charityOrder && g.charityOrder.remaining[0] !== player) {
const current = g.charityOrder.remaining[0];
assert.ok(current, 'the requested Charity opportunity must still be pending');
g = applyAction(g, current, { type: 'ready' });
}
return g;
}
function allow(state: Game) {
let g = state;
while (g.response) {
@@ -162,6 +172,7 @@ void test('clean BG charity Karama cancellation offers Semuta without awarding e
g = allow(ready(g));
contest(g);
g.players[2].spice = 12;
g = charityTurn(g, 'b');
g = applyAction(g, 'b', { type: 'charity' });
assert.equal(g.response?.kind, 'bgCharity');
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
@@ -210,9 +221,11 @@ void test('CHOAM pays ordinary charity by actual need and advanced Bene Gesserit
let g = allow(ready(fixture(advanced)));
g.players[1].spice = starting;
g.players[2].spice = starting;
g = charityTurn(g, 'e');
g = applyAction(g, 'e', { type: 'charity' });
assert.equal(g.players[1].spice, 2);
assert.equal(g.players[0].spice, 6 - (2 - starting));
g = charityTurn(g, 'b');
g = applyAction(g, 'b', { type: 'charity' });
assert.equal(g.players[2].spice, advanced ? starting + 2 : 2);
assert.equal(
@@ -230,6 +243,7 @@ void test('wealthy Bene Gesserit payment waits for its response; cancellation ne
let g = allow(ready(fixture(true)));
contest(g);
g.players[2].spice = 12;
g = charityTurn(g, 'b');
g = applyAction(g, 'b', { type: 'charity' });
assert.equal(g.response?.kind, 'bgCharity');
assert.equal(g.players[0].spice, 6);
@@ -245,6 +259,7 @@ void test('wealthy BG response preserves cancellation before provisional shortfa
contest(g);
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 });
g.players[2].spice = 12;
g = charityTurn(g, 'b');
g = applyAction(g, 'b', { type: 'charity' });
assert.equal(g.response?.kind, 'bgCharity');
assert.equal(g.players[0].spice, 0);
@@ -260,7 +275,10 @@ void test('Karama cancels CHOAM income, restores bank payments and permits CHOAM
let g = cancel(ready(contest(fixture(true))));
assert.equal(g.players[0].spice, 0);
assert.deepEqual(g.choamCharity, { turn: 1, canceled: true });
for (const id of ['c', 'e', 'b']) g = applyAction(g, id, { type: 'charity' });
for (const id of g.order) {
g = charityTurn(g, id);
g = applyAction(g, id, { type: 'charity' });
}
assert.deepEqual(
g.players.map((p) => p.spice),
[2, 2, 2],
@@ -336,6 +354,7 @@ void test('charity projections expose the payer and own entitlement without leak
incomePending: false,
incomeCanceled: false,
incomeHomeworld: 0,
orderBlocked: null,
});
assert.equal(v.players[0].spice, undefined);
assert.equal(v.players[0].handLimit, 5);
@@ -346,10 +365,13 @@ void test('ordinary funded claims still accrue and settle Spice Production once'
g.techTokens = createTechTokens();
g.techTokens.production.owner = 'e';
g = allow(ready(g));
g = applyAction(g, 'b', { type: 'charity' });
g = charityTurn(g, 'e');
assert.equal(g.techTokens!.production.spice, 0);
g = applyAction(g, 'e', { type: 'charity' });
assert.equal(g.techTokens!.production.spice, 1);
g = charityTurn(g, 'b');
g = applyAction(g, 'b', { type: 'charity' });
assert.equal(g.techTokens!.production.spice, 1);
g = ready(g);
assert.equal(g.players[1].spice, 3);
assert.equal(g.techTokens!.production.spice, 0);
@@ -419,10 +441,20 @@ void test('all AI levels proceed after automatic CHOAM income and claim funded c
assert.ok(actions.length);
g = applyAction(g, p.id, actions[0]);
}
assert.equal(g.charityOrder?.remaining[0], 'c');
const choam = botActions(viewGame(g, g.charityOrder!.remaining[0]));
assert.equal(choam[0]?.type, 'ready');
g = applyAction(g, 'c', choam[0]);
for (const id of ['e', 'b']) {
const actions = botActions(viewGame(g, id));
assert.equal(actions[0].type, 'charity');
assert.equal(g.charityOrder?.remaining[0], id);
const actions = botActions(viewGame(g, g.charityOrder!.remaining[0]));
assert.equal(actions[0]?.type, 'charity');
g = applyAction(g, id, actions[0]);
if (id === 'e') {
const finish = botActions(viewGame(g, g.charityOrder!.remaining[0]));
assert.equal(finish[0]?.type, 'ready');
g = applyAction(g, id, finish[0]);
}
}
assert.deepEqual(
g.players.map((p) => p.spice),
+28 -7
View File
@@ -35,11 +35,21 @@ function fixture(advanced = false) {
}
function ready(state: Game) {
let g = state;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
const players = g.phase === 2 && g.charityOrder ? [...g.charityOrder.remaining] : g.players.map(p => p.id);
for (const player of players) g = applyAction(g, player, { type: 'ready' });
while (g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
return g;
}
function charityTurn(state: Game, player: string) {
let g = state;
while (g.phase === 2 && g.charityOrder && g.charityOrder.remaining[0] !== player) {
const current = g.charityOrder.remaining[0];
assert.ok(current, 'the requested Charity opportunity must still be pending');
g = applyAction(g, current, { type: 'ready' });
}
return g;
}
function allow(state: Game) {
let g = state;
while (g.response) {
@@ -79,7 +89,7 @@ function nextCharity(state: Game) {
return ready(g);
}
function mentat(state: Game) {
const g = structuredClone(state);
const g = structuredClone(state.phase === 2 && state.charityOrder ? ready(state) : state);
g.phase = 7;
g.ready = [];
return ready(g);
@@ -186,8 +196,10 @@ void test('Double changes ordinary payout, preserves ordinary eligibility and do
assert.equal(charityAmount(g, g.players[1]), ordinary);
assert.equal(charityAmount(g, g.players[2]), bg);
const before = g.players[0].spice;
g = charityTurn(g, 'e');
if (ordinary) g = applyAction(g, 'e', { type: 'charity' });
else assert.throws(() => applyAction(g, 'e', { type: 'charity' }), /fewer/);
g = charityTurn(g, 'b');
g = allow(applyAction(g, 'b', { type: 'charity' }));
assert.equal(g.players[1].spice, starting + ordinary);
assert.equal(g.players[2].spice, starting + bg);
@@ -198,10 +210,12 @@ void test('Cancel suppresses all claims and CHOAM income, including wealthy adva
let g = nextCharity(allow(place(ready(fixture(true)), 'cancel')));
assert.equal(g.response, null);
assert.equal(g.players[0].spice, 20);
for (const p of g.players) {
for (const id of g.order) {
g = charityTurn(g, id);
const p = g.players.find(player => player.id === id)!;
assert.equal(charityAmount(g, p), 0);
assert.throws(() => applyAction(g, p.id, { type: 'charity' }), /fewer/);
assert.equal(viewGame(g, p.id).charity.incomePending, false);
assert.throws(() => applyAction(g, id, { type: 'charity' }), /fewer/);
assert.equal(viewGame(g, id).charity.incomePending, false);
}
g = ready(g);
assert.equal(g.phase, 3);
@@ -211,7 +225,9 @@ void test('canceling doubled CHOAM income preserves doubled charity from the ban
g.players[0].spice = 0;
g.players[1].spice = 1;
g = applyAction(g, 'c', { type: 'charity' });
g = charityTurn(g, 'e');
g = applyAction(g, 'e', { type: 'charity' });
g = charityTurn(g, 'b');
g = allow(applyAction(g, 'b', { type: 'charity' }));
assert.deepEqual(
g.players.map((p) => p.spice),
@@ -222,6 +238,7 @@ void test('canceling doubled CHOAM income preserves doubled charity from the ban
void test('canceling doubled wealthy Bene Gesserit charity does not charge CHOAM', () => {
let g = allow(nextCharity(allow(place(ready(fixture(true))))));
const before = g.players[0].spice;
g = charityTurn(g, 'b');
g = cancel(applyAction(contest(g), 'b', { type: 'charity' }));
assert.equal(g.players[0].spice, before);
assert.equal(g.players[2].spice, 20);
@@ -327,7 +344,11 @@ void test('all AI levels place Inflation legally, finish its response and do not
p.bot = difficulty;
p.spice = 0;
});
for (const p of g.players)
assert.equal(botActions(viewGame(g, p.id))[0].type, 'ready');
for (const id of g.order) {
assert.equal(g.charityOrder?.remaining[0], id);
const actions = botActions(viewGame(g, g.charityOrder!.remaining[0]));
assert.equal(actions[0]?.type, 'ready');
g = applyAction(g, id, actions[0]);
}
}
});
+139
View File
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import { actChoamCharityFunding, nextChoamCharityFundingStep,
type ChoamCharityFundingStep } from './fixture-choam-charity-funding';
export type AdvancedCharityOrderStep = ChoamCharityFundingStep;
export const actAdvancedCharityOrder = actChoamCharityFunding;
export const advancedCharityOrderReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const advancedCharityOrderPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const advancedCharityOrderClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
export function advancedCharityOrderInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
.map(card => card.id).sort();
assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort());
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices])].sort();
if (traitors.length) assert.deepEqual(traitors, traitorDeck(game.players, false).sort());
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
return { cards, traitors, spice };
}
/** Setup, original counters and current Charity Finish only; no automatic claim. */
export function nextAdvancedCharityOrderStep(game: Game): AdvancedCharityOrderStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'forces' && game.advanced &&
!advancedCharityOrderPlayer(game, 'beneGesserit').advisorSetup)
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
if (game.decision?.kind === 'advisorBattle')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
if (game.phase === 2 && !game.decision && game.charityOrder?.remaining.length)
return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } };
}
return nextChoamCharityFundingStep(game);
}
export function advanceAdvancedCharityOrder(game: Game, until: (state: Game) => boolean,
actions: AdvancedCharityOrderStep[] = []): Game {
for (let count = 0; count < 600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Charity chronology ended before its requested boundary');
const next = nextAdvancedCharityOrderStep(game); assert.ok(next);
game = actAdvancedCharityOrder(game, next.actor, next.action, actions);
}
throw Error('The original Charity programme did not reach its finite boundary');
}
export function passAdvancedCharityResponses(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
for (let count = 0; game.response && count < 40; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actAdvancedCharityOrder(game, actor.id, { type: 'passResponse' }, actions);
}
assert.equal(game.response ?? null, null); return game;
}
export function allowAdvancedCharityOpening(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
assert.equal(game.response?.kind, 'choamCharity');
game = passAdvancedCharityResponses(game, actions);
return advanceAdvancedCharityOrder(game, state => state.phase === 2 && advancedCharityOrderClean(state), actions);
}
export function finishAdvancedCharityTurn(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
return actAdvancedCharityOrder(game, actor, { type: 'ready' }, actions);
}
export function finishAdvancedCharityPhase(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
return advanceAdvancedCharityOrder(game, state => state.phase !== 2, actions);
}
/** Actual public CHOAM/BG/classic setup. Only original unopened starting-card
* and land-Spice positions plus scalar lotteries are selected. A real phase1
* bribe empties Atreides' purse; its recipient retains deferred incoming spice.
* No purse, phase, order, readiness, claim, force or receipt is installed.
*/
export function createAdvancedCharityOrderFixture(options: { advanced?: boolean; lowBG?: boolean } = {}) {
const advanced = options.advanced ?? true;
let game = createGame('ADVANCEDCHARITYORDER', newPlayer('atreides', 'Atreides', 'atreides'), advanced, ['choam']);
joinGame(game, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
joinGame(game, newPlayer('choam', 'CHOAM', 'choam'));
const initial = structuredClone(game), actions: AdvancedCharityOrderStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvancedCharityOrder(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvancedCharityOrder(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = 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));
const karama = game.deck.find(card => card.effect === 'karama');
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2);
assert.ok(karama); assert.equal(ordinary.length, 2);
const prefix = [karama, ...ordinary];
const dealOrder = prefix.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), prefix.map(card => card.id));
const original = advancedCharityOrderInventory(game);
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card); assert.ok(from >= position);
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
reach(state => state.phase === 1 && advancedCharityOrderClean(state));
const beforeBribes = structuredClone(game), bribeFrom = actions.length;
const low = ['atreides', ...(options.lowBG ? ['beneGesserit'] : [])];
for (const actor of low) {
const amount = advancedCharityOrderPlayer(game, actor).spice; assert.ok(amount > 0);
act(actor, { type: 'bribe', target: 'choam', amount });
assert.equal(advancedCharityOrderPlayer(game, actor).spice, 0);
}
const bribeActions = structuredClone(actions.slice(bribeFrom));
reach(state => state.phase === 2 && state.response?.kind === 'choamCharity');
assert.equal(advancedCharityOrderPlayer(game, 'choam').spice, 2);
assert.equal(advancedCharityOrderPlayer(game, 'atreides').spice, 0);
assert.ok(advancedCharityOrderPlayer(game, 'choam').bribes > 0);
assert.deepEqual(advancedCharityOrderInventory(game), original);
return { initial, lobby, setup, afterSetup, beforeBribes, beforeIncome: structuredClone(game), game,
advanced, lowBG: options.lowBG ?? false, karama: karama.id, original,
claimant: 'atreides', bg: 'beneGesserit', provider: 'choam', dealOrder, spiceOrder,
actions, setupActions, bribeActions,
sourceCounts: { setup: setupActions.length, bribes: bribeActions.length, source: actions.length },
sourceDisclosure: 'Original public CHOAM/BeneGesserit/Atreides setup and native first Storm/Spice/closing controls. Advanced uses the explicit original preview start. Only original unopened starting-deal and land-Spice positions plus scalar lotteries selected. Real pre-Charity bribes create actual low claimants and deferred CHOAM incoming receipts; no money, order, phase, readiness or claim marker installed. Source stops at the original first CHOAM opening income response.' };
}
/** Explicit unstamped UNIT supplement; not claimed historical saved-game proof. */
export function unstampedAdvancedCharitySupplement(game: Game): Game {
const old = advancedCharityOrderReload(game);
delete old.charityOrder;
return old;
}
+4
View File
@@ -144,6 +144,10 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
}
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
if (game.phase === 2 && game.charityOrder) {
const actor = game.charityOrder.remaining[0];
return actor ? {actor,action:{type:'ready'}} : null;
}
if (game.phase === 4 && game.revivalOrder) {
const actor = game.revivalOrder.remaining[0];
return actor ? {actor,action:{type:'ready'}} : null;
+42 -1
View File
@@ -149,7 +149,7 @@ void test('already claimed or genuinely expired Charity cannot be reserved again
game = answer(asked, expired.target, 'no'); physical(game, expired);
});
void test('a phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => {
void test('a Basic phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture({ source: 'phase2' }); let source = pay(f.game, f, 5);
source = act(source, f.target, { type: 'ready' }); assert.ok(source.ready.includes(f.target));
@@ -162,6 +162,47 @@ void test('a phase2 positive answer reopens real own Ready and cannot forfeit it
}
});
void test('Advanced future Charity can fund an answer but its real claim guide waits for the original owned turn', () => {
const f = fixture({ source: 'phase2', advanced: true });
assert.notEqual(f.game.charityOrder!.remaining[0], f.target);
let game = bind(f, 'yes').game;
game = pay(game, f, 5);
assert.equal(player(game, f.target).spice, 0);
assert.notEqual(viewGame(game, f.target).turnActionGuide?.action.type, 'charity');
rejected(game, f.target, f.charity);
while (game.charityOrder!.remaining[0] !== f.target)
game = act(game, game.charityOrder!.remaining[0], { type: 'ready' });
const guide = viewGame(game, f.target).turnActionGuide;
assert.equal(guide?.action.type, 'charity');
rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, guide!.action);
game = pay(game, f, 1);
assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 1);
physical(game, f);
});
void test('new Advanced answers cannot reopen an actually completed Charity opportunity for another claim', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture({ source: 'phase2', advanced: true }), respondent = 'atreides';
let game = act(f.game, respondent, { type: 'bribe', target: f.asker, amount: player(f.game, respondent).spice });
while (!game.ready.includes(respondent))
game = act(game, game.charityOrder!.remaining[0], { type: 'ready' });
assert.equal(game.phase, 2); assert.equal(game.active, f.target);
const queue = structuredClone(game.charityOrder), ready = [...game.ready];
const raw: TruthQuestion = { ...question(f, kind, 1), target: respondent };
const asked = ask(declare(f, game).game, f.asker, raw);
const view = viewGame(asked, respondent);
assert.deepEqual(kind === 'turnActions' ? view.truthTurnActionAnswers : view.truthBribeAnswers, ['no']);
rejected(asked, respondent, { type: 'truthAnswer', answer: 'yes' });
game = answer(asked, respondent, 'no');
assert.deepEqual(game.charityOrder, queue); assert.deepEqual(game.ready, ready);
rejected(game, respondent, f.charity);
assert.notEqual(player(game, respondent).charityTurn, game.turn);
physical(game, f);
}
});
void test('one native Charity is shared with shipment and cannot be claimed early to strand remaining payments', () => {
const f = fixture({ source: 'afterAmal' });
const impossible: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
+5 -1
View File
@@ -172,9 +172,13 @@ void test('Advanced BG forced settlement after CHOAM passes last releases the fi
const f = fixture({ advanced: true, bg: true }); assert.ok(f.bg);
assert.ok(f.providerCard); assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.providerCard]);
for (const kind of ['turnActions', 'bribe'] as const) {
let game = bind(f, 'yes', question(f, kind, f.maximum), structuredClone(f.game)).game;
let game = bind(f, 'yes', question(f, kind, f.minimum), structuredClone(f.game)).game;
game = pay(game, f, f.startCash); game = allow(reachIncome(game));
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.equal(player(game, f.bg).spice, 5);
// CHOAM's current cash meets this fixed-income answer. Finish its actual
// turn before BG acts; the closed ordinary claim cannot repair a later debit.
while (game.charityOrder!.remaining[0] !== f.bg)
game = act(game, game.charityOrder!.remaining[0], { type: 'ready' });
game = act(game, f.bg, f.charity); assert.equal(game.response?.kind, 'bgCharity');
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.ok(!promise(game, kind).released);
while (game.response?.kind === 'bgCharity') {