Connect Spice Banker sealed battle spending and saved settlement
Prototype one through three own spice as a separate sealed battle commitment. Reserve funds alongside actual force support, add strength only for the surviving skilled leader, and settle once through existing versioned writes. Apply the documented card/core-rule interpretation: payment survives leader death or loss, except for the sole successful traitor caller. Keep printed disc strength and bounty unchanged. Normal income awaits its deferred-custody ruling; combined modules remain gated. Validation: 54 focused tests; typecheck, lint, 4490 offline tests, production build and 43 HTTP tests passed. Independent rules, privacy, persistence and UI review completed. SQLite tests cover reserved-spice diversion, concurrent settlement, traitor waiver and legacy plans. Browser exercised a three-spice commitment, pending refresh, actual +3 strength and payment, and restored private cards and the next decision. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not use Banker spending. Following another reported outage, the server remained healthy. Preserved all 461 opening rooms and original seat/recovery rows (495 rooms after QA/HTTP). SQLite integrity and foreign keys passed. A fresh online backup captured all 495 rooms, and browser refresh restored the QA seat. No restart or reset was needed. Verified source tree: 208bf9324ab994b5e59ed50d78998b4dbf8810b3b6799b1a577ef3b84479335f
This commit is contained in:
1 parent
9381ef140d
commit
1380271bb6
22 files changed
+1166
-25
No files matched your search
@@ -25,7 +25,7 @@ export function advancedAtreidesSukOffer(): Game {
|
||||
}
|
||||
|
||||
/** Genuine module setup, then a conserved, explicitly staged battle position. */
|
||||
export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, hide = true, advanced = false, elite = false, bluff = false, captured = false, capturedNativeSkill = undefined as LeaderSkillId | undefined, weaponKills = false, dial = 4, atreides = false, territory = 'arrakeen', sector = 10 } = {}): Game {
|
||||
export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, unsealed = false, hide = true, advanced = false, elite = false, bluff = false, captured = false, capturedNativeSkill = undefined as LeaderSkillId | undefined, weaponKills = false, dial = 4, atreides = false, territory = 'arrakeen', sector = 10 } = {}): Game {
|
||||
const native = captured ? 'harkonnen' : atreides ? 'atreides' : 'emperor';
|
||||
let game = dealtSkillGame(native, advanced, skill);
|
||||
for (const id of ['a', 'd']) {
|
||||
@@ -89,6 +89,7 @@ export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, hid
|
||||
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
|
||||
if (game.decision?.kind === 'fullPlanOffer')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
if (unsealed) return game;
|
||||
game = applyAction(game, 'a', { type: 'battlePlan', dial, support: advanced ? 3 : 0,
|
||||
leader: captured ? 'guild-0' : hide && !bluff ? `${native}-0` : `${native}-1`, weapon: played.id });
|
||||
game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: enemyWeapon });
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SpiceBankerControl } from '../components/spice-banker';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
applyAction,
|
||||
viewGame,
|
||||
type GameView,
|
||||
type Plan,
|
||||
} from '../game/engine';
|
||||
import { spiceBankerGame } from './spice-banker-fixture';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
});
|
||||
const { PrivateBattlePlan } = await import('../components/private-battle-plan');
|
||||
const { RevealedBattle } = await import('../components/revealed-battle');
|
||||
aliases.deregister();
|
||||
|
||||
function renderControl(
|
||||
game: GameView,
|
||||
selectedLeader: string,
|
||||
ownSupport: number,
|
||||
value: number,
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(SpiceBankerControl, {
|
||||
game,
|
||||
selectedLeader,
|
||||
ownSupport,
|
||||
value,
|
||||
onChange() {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void test('Spice Banker renders a bounded separate own-spice commitment only for the controlled living skilled disc', () => {
|
||||
const native = spiceBankerGame();
|
||||
native.players[0].spice = 4;
|
||||
const view = viewGame(native, 'a');
|
||||
const html = renderControl(view, 'emperor-0', 2, 2);
|
||||
assert.match(html, /aria-label="Spice Banker battle commitment"/);
|
||||
assert.match(html, /id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/);
|
||||
assert.match(
|
||||
html,
|
||||
/Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/Each committed spice adds 1 strength if this skilled leader survives/,
|
||||
);
|
||||
assert.match(html, /commitment goes to the Bank after a win or loss/);
|
||||
assert.match(
|
||||
html,
|
||||
/sole winner through a traitor call spends no Battle Plan spice/,
|
||||
);
|
||||
assert.equal(renderControl(view, 'emperor-1', 0, 3), '');
|
||||
|
||||
const faceUp = structuredClone(view);
|
||||
faceUp.leaderSkills!.assignments.find(
|
||||
(assignment) => assignment.skill === 'spice-banker',
|
||||
)!.faceUp = true;
|
||||
assert.equal(renderControl(faceUp, 'emperor-0', 0, 3), '');
|
||||
|
||||
const dead = structuredClone(view);
|
||||
dead.players
|
||||
.find((player) => player.id === 'a')!
|
||||
.leaders.find((leader) => leader.id === 'emperor-0')!.dead = true;
|
||||
assert.equal(renderControl(dead, 'emperor-0', 0, 3), '');
|
||||
|
||||
const foreign = structuredClone(view);
|
||||
foreign.leaderSkills!.assignments.find(
|
||||
(assignment) => assignment.skill === 'spice-banker',
|
||||
)!.controller = 'd';
|
||||
assert.equal(renderControl(foreign, 'emperor-0', 0, 3), '');
|
||||
|
||||
const combinedModule = structuredClone(view);
|
||||
combinedModule.expansions = ['ix'];
|
||||
assert.equal(renderControl(combinedModule, 'emperor-0', 0, 3), '');
|
||||
|
||||
const capturedGame = spiceBankerGame(false, true);
|
||||
capturedGame.players[0].spice = 3;
|
||||
const captive = viewGame(capturedGame, 'a');
|
||||
assert.match(
|
||||
renderControl(captive, 'guild-0', 0, 3),
|
||||
/Spice Banker commitment/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('full-plan inspection and public reveal show the exact commitment without leaking it to another private viewer', () => {
|
||||
const plan: Plan = {
|
||||
dial: 1,
|
||||
support: 0,
|
||||
bankerSpice: 3,
|
||||
leader: 'emperor-0',
|
||||
weapon: null,
|
||||
defense: null,
|
||||
};
|
||||
const inspected = {
|
||||
me: 'atreides',
|
||||
players: [
|
||||
{ id: 'atreides', name: 'Atreides' },
|
||||
{ id: 'emperor', name: 'Emperor' },
|
||||
{ id: 'observer', name: 'Observer' },
|
||||
],
|
||||
allLeaders: [],
|
||||
battle: {
|
||||
revealed: false,
|
||||
fullPlan: { owner: 'atreides', target: 'emperor' },
|
||||
fullPlanInsight: { target: 'emperor', plan, cards: [] },
|
||||
},
|
||||
} as unknown as GameView;
|
||||
const privateHtml = renderToStaticMarkup(
|
||||
createElement(PrivateBattlePlan, { game: inspected }),
|
||||
);
|
||||
assert.match(privateHtml, /Spice Banker commitment<\/dt><dd>3<\/dd>/);
|
||||
assert.equal(
|
||||
renderToStaticMarkup(
|
||||
createElement(PrivateBattlePlan, {
|
||||
game: {
|
||||
...inspected,
|
||||
me: 'observer',
|
||||
},
|
||||
}),
|
||||
),
|
||||
'',
|
||||
);
|
||||
|
||||
const revealed = {
|
||||
...inspected,
|
||||
advanced: false,
|
||||
me: 'observer',
|
||||
battle: {
|
||||
attacker: 'emperor',
|
||||
defender: 'atreides',
|
||||
revealed: true,
|
||||
plans: {
|
||||
emperor: plan,
|
||||
atreides: {
|
||||
dial: 0,
|
||||
support: 0,
|
||||
leader: null,
|
||||
weapon: null,
|
||||
defense: null,
|
||||
},
|
||||
},
|
||||
cards: [],
|
||||
ownForces: null,
|
||||
stoneBurner: {},
|
||||
poisonTooth: {},
|
||||
lateDefense: {},
|
||||
native: null,
|
||||
nativeBattleStrength: 0,
|
||||
},
|
||||
} as unknown as GameView;
|
||||
const publicHtml = renderToStaticMarkup(
|
||||
createElement(RevealedBattle, { game: revealed }),
|
||||
);
|
||||
assert.match(publicHtml, /Spice Banker: 3 spice committed/);
|
||||
assert.equal(
|
||||
(publicHtml.match(/Spice Banker:/g) ?? []).length,
|
||||
1,
|
||||
'zero commitments stay absent from public reveal',
|
||||
);
|
||||
});
|
||||
|
||||
void test('all four bot profiles reserve only remaining own spice and emit legal skilled plans', () => {
|
||||
for (const [profile, expectedCap] of DIFFICULTIES.map(
|
||||
(difficulty, index) => [difficulty, Math.min(index + 1, 3)] as const,
|
||||
)) {
|
||||
const game = spiceBankerGame(true);
|
||||
game.players[0].spice = 4;
|
||||
const view = viewGame(game, 'a');
|
||||
view.players.find((player) => player.id === 'a')!.bot = profile;
|
||||
const plans = botActions(view).filter(
|
||||
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
|
||||
);
|
||||
assert.ok(plans.length, `${profile} omitted the skilled disc`);
|
||||
for (const action of plans) {
|
||||
const support = Number(action.support ?? 0);
|
||||
assert.equal(
|
||||
Number(action.bankerSpice ?? 0),
|
||||
Math.min(expectedCap, Math.max(0, 4 - support)),
|
||||
`${profile} did not reserve Banker spice after force support`,
|
||||
);
|
||||
}
|
||||
assert.doesNotThrow(
|
||||
() => applyAction(game, 'a', plans[0]),
|
||||
`${profile} emitted an illegal Spice Banker plan`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('bots do not emit a currently gated Banker commitment in a combined-module view', () => {
|
||||
const game = spiceBankerGame();
|
||||
const view = viewGame(game, 'a');
|
||||
view.expansions = ['ix'];
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
view.players.find((player) => player.id === 'a')!.bot = difficulty;
|
||||
const skilled = botActions(view).filter(
|
||||
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
|
||||
);
|
||||
assert.ok(skilled.length);
|
||||
assert.ok(
|
||||
skilled.every((action) => !action.bankerSpice),
|
||||
`${difficulty} emitted a gated Banker commitment`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { leaderSkillBattle } from './leader-skill-battle-fixture';
|
||||
|
||||
/** Genuine skill setup and the shared conserved battle position, before sealing either plan. */
|
||||
export function spiceBankerGame(advanced = false, captured = false) {
|
||||
return leaderSkillBattle({
|
||||
skill: 'spice-banker',
|
||||
advanced,
|
||||
captured,
|
||||
unsealed: true,
|
||||
dial: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Action } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { spiceBankerGame } from './spice-banker-fixture';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
function giveTraitor(
|
||||
game: ReturnType<typeof spiceBankerGame>,
|
||||
owner: string,
|
||||
identity: string,
|
||||
) {
|
||||
const recipient = game.players.find((player) => player.id === owner)!;
|
||||
if (recipient.traitors.includes(identity)) return;
|
||||
const replaced = recipient.traitors[0];
|
||||
for (const player of game.players) {
|
||||
const index = player.traitors.indexOf(identity);
|
||||
if (index < 0) continue;
|
||||
player.traitors[index] = replaced;
|
||||
recipient.traitors[0] = identity;
|
||||
return;
|
||||
}
|
||||
const index = game.traitorReserve!.indexOf(identity);
|
||||
assert.ok(index >= 0);
|
||||
game.traitorReserve![index] = replaced;
|
||||
recipient.traitors[0] = identity;
|
||||
}
|
||||
|
||||
async function fixture(
|
||||
options: { advanced?: boolean; traitor?: boolean; spice?: number } = {},
|
||||
) {
|
||||
const store = unitStore();
|
||||
const sqlite: DatabaseSync = store.sqlite;
|
||||
const created = await store.rooms.createRoom(
|
||||
'Banker',
|
||||
'emperor',
|
||||
options.advanced ?? false,
|
||||
[],
|
||||
);
|
||||
const joined = await store.rooms.joinRoom(
|
||||
created.view.code,
|
||||
'Guild',
|
||||
'guild',
|
||||
);
|
||||
const code = created.view.code;
|
||||
const tokens = [created.token!, joined.token!];
|
||||
const original = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
for (const [index, seat] of original.entries())
|
||||
sqlite
|
||||
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
|
||||
.run(index === 0 ? 'a' : 'd', code, seat.playerId);
|
||||
const seats = await Promise.all(
|
||||
tokens.map((token) => store.restart().authenticate(code, token)),
|
||||
);
|
||||
const state = spiceBankerGame(options.advanced ?? false);
|
||||
state.code = code;
|
||||
state.version = (await store.rooms.readRoom(code)).version;
|
||||
state.players[0].spice = options.spice ?? 20;
|
||||
if (options.traitor) giveTraitor(state, 'a', 'guild-1');
|
||||
sqlite
|
||||
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(state), state.version, code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, sqlite, code, seats, state };
|
||||
}
|
||||
|
||||
const bankerPlan = (amount?: number): Action => ({
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
support: 0,
|
||||
leader: 'emperor-0',
|
||||
...(amount === undefined ? {} : { bankerSpice: amount }),
|
||||
});
|
||||
|
||||
const guildPlan: Action = {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
support: 0,
|
||||
leader: 'guild-1',
|
||||
};
|
||||
|
||||
void test('SQLite restart keeps the Banker commitment owner-private and reserves it from other spending', async () => {
|
||||
const f = await fixture({ spice: 3 });
|
||||
try {
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
f.state.version,
|
||||
bankerPlan(3),
|
||||
clock,
|
||||
);
|
||||
const sealed = await f.restart().readRoom(f.code);
|
||||
assert.equal(sealed.battle?.plans.a.bankerSpice, 3);
|
||||
assert.equal(sealed.players[0].spice, 3);
|
||||
|
||||
const owner = await f.restart().readSeatView(f.code, f.seats[0]);
|
||||
const opponent = await f.restart().readSeatView(f.code, f.seats[1]);
|
||||
assert.equal(owner.battle?.plans.a.bankerSpice, 3);
|
||||
assert.deepEqual(owner.battle?.submitted, ['a']);
|
||||
assert.deepEqual(opponent.battle?.plans, {});
|
||||
assert.deepEqual(opponent.battle?.submitted, ['a']);
|
||||
assert.equal('bankerSpice' in (opponent.battle?.plans.a ?? {}), false);
|
||||
|
||||
await assert.rejects(() =>
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
sealed.version,
|
||||
{ type: 'bribe', target: 'd', amount: 1 },
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), sealed);
|
||||
|
||||
await f.restart().act(f.code, f.seats[1], sealed.version, guildPlan, clock);
|
||||
let revealed = await f.restart().readRoom(f.code);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
revealed.version,
|
||||
{ type: 'traitorCall', call: false },
|
||||
clock,
|
||||
);
|
||||
revealed = await f.restart().readRoom(f.code);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
revealed.version,
|
||||
{ type: 'traitorCall', call: false },
|
||||
clock,
|
||||
);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.players[0].spice, 0);
|
||||
assert.equal(
|
||||
done.log.filter(
|
||||
(entry) => entry.automatic?.name === 'Spice Banker payment',
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent final Traitor decisions waive one restored Banker payment exactly once', async () => {
|
||||
const f = await fixture({ traitor: true });
|
||||
try {
|
||||
const bounty = f.state.players
|
||||
.flatMap((player) => player.leaders)
|
||||
.find((leader) => leader.id === 'guild-1')!.strength;
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
f.state.version,
|
||||
bankerPlan(3),
|
||||
clock,
|
||||
);
|
||||
let state = await f.restart().readRoom(f.code);
|
||||
await f.restart().act(f.code, f.seats[1], state.version, guildPlan, clock);
|
||||
state = await f.restart().readRoom(f.code);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
state.version,
|
||||
{ type: 'traitorCall', call: true },
|
||||
clock,
|
||||
);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.battle?.plans.a.bankerSpice, 3);
|
||||
assert.equal(pending.players[0].spice, 20);
|
||||
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const barrier = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await barrier;
|
||||
};
|
||||
f.writes.length = 0;
|
||||
const finalCall: Action = { type: 'traitorCall', call: false };
|
||||
const attempts = await Promise.allSettled([
|
||||
f.rooms.act(f.code, f.seats[1], pending.version, finalCall, clock),
|
||||
f.restart().act(f.code, f.seats[1], pending.version, finalCall, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(
|
||||
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
attempts.filter((attempt) => attempt.status === 'rejected').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.writes.map((write) => write.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, pending.version + 1);
|
||||
assert.equal(done.battle, null);
|
||||
assert.equal(done.lastBattleContext?.result, 'traitor');
|
||||
assert.equal(done.lastBattleContext?.winner, 'a');
|
||||
assert.equal(done.players[0].spice, 20 + bounty);
|
||||
assert.equal(
|
||||
done.log.filter(
|
||||
(entry) => entry.automatic?.name === 'Spice Banker payment',
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.match(
|
||||
done.log.find(
|
||||
(entry) => entry.automatic?.name === 'Spice Banker payment',
|
||||
)!.text,
|
||||
/waived/,
|
||||
);
|
||||
const writes = f.writes.length;
|
||||
await assert.rejects(() =>
|
||||
f.restart().act(f.code, f.seats[1], pending.version, finalCall, clock),
|
||||
);
|
||||
assert.equal(f.writes.length, writes);
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), done);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a restored legacy plan without bankerSpice resolves without reserving or paying it', async () => {
|
||||
const f = await fixture({ spice: 4 });
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, bankerPlan(), clock);
|
||||
const sealed = await f.restart().readRoom(f.code);
|
||||
assert.equal('bankerSpice' in sealed.battle!.plans.a, false);
|
||||
|
||||
await f.restart().act(f.code, f.seats[1], sealed.version, guildPlan, clock);
|
||||
let state = await f.restart().readRoom(f.code);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
state.version,
|
||||
{ type: 'traitorCall', call: false },
|
||||
clock,
|
||||
);
|
||||
state = await f.restart().readRoom(f.code);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
state.version,
|
||||
{ type: 'traitorCall', call: false },
|
||||
clock,
|
||||
);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.players[0].spice, 4);
|
||||
assert.ok(
|
||||
!done.log.some(
|
||||
(entry) => entry.automatic?.name === 'Spice Banker payment',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { spiceBankerGame } from './spice-banker-fixture';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
|
||||
function reject(g: Game, owner: string, action: Action) {
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(() => applyAction(g, owner, action));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
function conserve(g: Game) {
|
||||
validateLeaderSkills(g.leaderSkills!, g.players);
|
||||
for (const p of g.players) {
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
);
|
||||
if (p.elites)
|
||||
assert.equal(
|
||||
p.elites.reserves +
|
||||
p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
||||
5,
|
||||
);
|
||||
}
|
||||
}
|
||||
const plan = (g: Game, bankerSpice?: unknown): Action => ({
|
||||
type: 'battlePlan',
|
||||
leader: 'emperor-0',
|
||||
dial: 0,
|
||||
bankerSpice,
|
||||
weapon: g.players[0].hand[0].id,
|
||||
});
|
||||
function settle(
|
||||
g: Game,
|
||||
opponent: Action = { type: 'battlePlan', leader: 'guild-1', dial: 5 },
|
||||
calls: [boolean, boolean] = [false, false],
|
||||
) {
|
||||
g = applyAction(g, 'd', opponent);
|
||||
g = applyAction(g, 'a', { type: 'traitorCall', call: calls[0] });
|
||||
return applyAction(g, 'd', { type: 'traitorCall', call: calls[1] });
|
||||
}
|
||||
function traitor(g: Game, owner: number, identity: string) {
|
||||
const p = g.players[owner];
|
||||
if (p.traitors.includes(identity)) return;
|
||||
const zone = [g.traitorReserve!, ...g.players.map((p) => p.traitors)].find(
|
||||
(zone) => zone.includes(identity),
|
||||
)!;
|
||||
assert.ok(zone);
|
||||
const old = p.traitors[0];
|
||||
zone[zone.indexOf(identity)] = old;
|
||||
p.traitors[0] = identity;
|
||||
}
|
||||
void test('Spice Banker spends one through three on win or loss and changes score without changing printed disc values', () => {
|
||||
for (const amount of [1, 2, 3]) {
|
||||
const before = spiceBankerGame();
|
||||
const done = settle(applyAction(before, 'a', plan(before, amount)));
|
||||
assert.equal(done.players[0].spice, 20 - amount);
|
||||
assert.equal(done.lastBattleContext?.winner, amount === 1 ? 'd' : 'a');
|
||||
assert.equal(
|
||||
done.players[0].leaders[0].strength,
|
||||
before.players[0].leaders[0].strength,
|
||||
);
|
||||
assert.ok(
|
||||
done.log.some((l) => l.text.includes(`gained ${amount} battle strength`)),
|
||||
);
|
||||
conserve(done);
|
||||
}
|
||||
});
|
||||
void test('Banker own spice and Advanced force support cannot spend the same funds', () => {
|
||||
const g = spiceBankerGame(true);
|
||||
g.players[0].spice = 5;
|
||||
reject(g, 'a', { ...plan(g, 3), support: 3, dial: 3 });
|
||||
const sealed = applyAction(g, 'a', { ...plan(g, 3), support: 2, dial: 2 });
|
||||
assert.equal(sealed.players[0].spice, 5);
|
||||
const done = settle(sealed, {
|
||||
type: 'battlePlan',
|
||||
leader: 'guild-1',
|
||||
dial: 0,
|
||||
});
|
||||
assert.equal(done.players[0].spice, 0);
|
||||
conserve(done);
|
||||
});
|
||||
void test('killed Bankers lose the bonus and still pay, while a sole successful traitor caller pays nothing', () => {
|
||||
const g = spiceBankerGame();
|
||||
const index = g.deck.findIndex((c) => c.kind === 'projectile');
|
||||
const weapon = g.deck.splice(index, 1)[0];
|
||||
g.players[1].hand.push(weapon);
|
||||
const dead = settle(applyAction(g, 'a', plan(g, 3)), {
|
||||
type: 'battlePlan',
|
||||
leader: 'guild-1',
|
||||
dial: 0,
|
||||
weapon: weapon.id,
|
||||
});
|
||||
assert.equal(dead.players[0].leaders[0].dead, true);
|
||||
assert.equal(dead.players[0].spice, 17);
|
||||
assert.ok(!dead.log.some((l) => l.text.includes('gained 3 battle strength')));
|
||||
conserve(dead);
|
||||
for (const calls of [
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
] as [boolean, boolean][]) {
|
||||
const g = spiceBankerGame();
|
||||
traitor(g, 0, 'guild-1');
|
||||
traitor(g, 1, 'emperor-0');
|
||||
const done = settle(
|
||||
applyAction(g, 'a', plan(g, 3)),
|
||||
{ type: 'battlePlan', leader: 'guild-1', dial: 0 },
|
||||
calls,
|
||||
);
|
||||
assert.equal(done.players[0].spice, calls[0] && !calls[1] ? 23 : 17);
|
||||
assert.equal(done.players[0].leaders[0].dead, calls[1]);
|
||||
conserve(done);
|
||||
}
|
||||
});
|
||||
void test('sealed Banker commitments stay private and reject changed funds, quantities or leader custody', () => {
|
||||
const g = spiceBankerGame();
|
||||
g.players[0].spice = 3;
|
||||
const sealed = applyAction(g, 'a', plan(g, 3));
|
||||
assert.equal(viewGame(sealed, 'a').battle?.plans.a?.bankerSpice, 3);
|
||||
assert.ok(!JSON.stringify(viewGame(sealed, 'd')).includes('bankerSpice'));
|
||||
assert.deepEqual(
|
||||
normalizeAutomaticGame(JSON.parse(JSON.stringify(sealed))),
|
||||
sealed,
|
||||
);
|
||||
for (const corrupt of [
|
||||
(g: Game) => {
|
||||
g.discoveryEnabled = true;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.battle!.plans.a.bankerSpice = 4;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.players[0].spice = 2;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.battle!.plans.a.leader = 'emperor-1';
|
||||
},
|
||||
]) {
|
||||
const bad = structuredClone(sealed);
|
||||
corrupt(bad);
|
||||
assert.throws(() => viewGame(bad, 'a'));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
reject(bad, 'd', { type: 'battlePlan', leader: 'guild-1', dial: 0 });
|
||||
}
|
||||
for (const value of [-1, 4, 1.5, '3', true, null])
|
||||
reject(g, 'a', plan(g, value));
|
||||
reject(g, 'a', { ...plan(g, 1), leader: 'emperor-1' });
|
||||
});
|
||||
void test('captured Bankers spend captor funds, and legacy omitted commitments preserve ordinary plans', () => {
|
||||
const g = spiceBankerGame(false, true);
|
||||
const done = settle(
|
||||
applyAction(g, 'a', { ...plan(g, 3), leader: 'guild-0' }),
|
||||
{ type: 'battlePlan', leader: 'guild-1', dial: 0 },
|
||||
);
|
||||
assert.equal(done.players[0].spice, 17);
|
||||
assert.equal(done.players[1].spice, 20);
|
||||
assert.equal(done.players[1].leaders[0].capturedBy, undefined);
|
||||
conserve(done);
|
||||
const legacy = spiceBankerGame();
|
||||
const old = applyAction(legacy, 'a', plan(legacy));
|
||||
assert.equal(old.battle!.plans.a.bankerSpice, undefined);
|
||||
const resumed = settle(JSON.parse(JSON.stringify(old)));
|
||||
assert.equal(resumed.players[0].spice, 20);
|
||||
assert.ok(
|
||||
!resumed.log.some((l) => l.automatic?.name === 'Spice Banker payment'),
|
||||
);
|
||||
conserve(resumed);
|
||||
});
|
||||
void test('all four AI profiles have funded Banker battle plans using their private view', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const g = spiceBankerGame();
|
||||
g.players[0].spice = 2;
|
||||
const view = viewGame(g, 'a');
|
||||
view.players[0].bot = difficulty;
|
||||
const actions = botActions(view).filter(
|
||||
(a) => a.type === 'battlePlan' && Number(a.bankerSpice) > 0,
|
||||
);
|
||||
assert.ok(actions.length, difficulty);
|
||||
for (const action of actions) {
|
||||
assert.ok(Number(action.bankerSpice) <= 2);
|
||||
const next = applyAction(g, 'a', action);
|
||||
assert.equal(next.players[0].spice, 2);
|
||||
conserve(next);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user