Sequence Advanced Charity from the First Player
This commit is contained in:
1 parent
84fdd404c9
commit
54787c7f7b
23 files changed
+1016
-62
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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), []);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in new issue
Block a user