Extend Guild rate Karama to cross and return transport

This commit is contained in:
Quintine committed 2026-09-29 16:22:13 +10:00
1 parent 8a23b5ccd2
commit 8ddd138e87
19 files changed
+898 -109

No files matched your search

+5 -7
View File
@@ -298,13 +298,11 @@ for (const cancel of [false, true])
const f = await fixture(route, true);
try {
const announced = await f.action(0, f.shipment);
if (route === 'reserve') {
assert.equal(announced.response?.kind, 'guildRate');
assert.equal(announced.players[0].spice, 20);
assert.equal(announced.players[0].shipped, false);
const allowed = await f.action(2, { type: 'passResponse' });
assert.equal(allowed.response?.kind, 'guildIncome');
}
assert.equal(announced.response?.kind, 'guildRate');
assert.equal(announced.players[0].spice, 20);
assert.equal(announced.players[0].shipped, false);
const allowed = await f.action(2, { type: 'passResponse' });
assert.equal(allowed.response?.kind, 'guildIncome');
const pending = await restored(f);
paidOnce(f, pending, 19);
assert.equal(pending.response?.kind, 'guildIncome');
+3 -5
View File
@@ -169,11 +169,9 @@ void test('Guild income from either contributor direction can be allowed or canc
const card = holdKarama(g, 'a');
const beforeGuild = player(g, 'g').spice;
let pending = applyAction(g, shipper, shipment(route, 1));
if (route === 'reserve') {
assert.equal(pending.response?.kind, 'guildRate');
assert.equal(player(pending, shipper).shipped, false);
pending = applyAction(pending, 'a', { type: 'passResponse' });
}
assert.equal(pending.response?.kind, 'guildRate');
assert.equal(player(pending, shipper).shipped, false);
pending = applyAction(pending, 'a', { type: 'passResponse' });
assert.equal(pending.response?.kind, 'guildIncome');
assert.equal(pending.response?.owner, 'g');
assert.equal(pending.response?.amount, 1);
+352
View File
@@ -0,0 +1,352 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
const player = (g: Game, id: string) => g.players.find(seat => seat.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(shipper: 'g' | 'e' | 'f' = 'g', advanced = false) {
const g = createGame('GUILDTRANSPORT', newPlayer('g', 'Guild', 'guild'), advanced);
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
newPlayer('a', 'Atreides', 'atreides'));
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.storm = 18;
g.order = ['g', 'e', 'f', 'a'];
g.movementRemaining = [...g.order];
g.active = shipper;
g.deck = baseDeck();
for (const seat of g.players) {
seat.spice = 20;
seat.reserves = 20;
seat.forces = {};
seat.hand = [];
}
if (shipper !== 'g') {
player(g, shipper).ally = 'g';
player(g, 'g').ally = shipper;
}
const karama = g.deck.splice(g.deck.findIndex(card => card.effect === 'karama'), 1)[0];
player(g, 'a').hand.push(karama);
return { g, karama };
}
function stationed(g: Game, id: string, sources: Record<string, number>) {
const seat = player(g, id);
seat.forces = { ...sources };
seat.reserves -= Object.values(sources).reduce((sum, n) => sum + n, 0);
}
function declare(g: Game, id: string, sources: Record<string, number> | 'reserves',
destination: string, sector = 0, allyPayment = 0, eliteForces?: Record<string, number>): Game {
const selection = sources === 'reserves'
? { from: 'reserves', amount: 3 }
: { forces: sources, ...(eliteForces ? { eliteForces } : {}) };
return applyAction(g, id, {
type: 'guildShip', ...selection, territory: destination, sector, allyPayment,
});
}
function canceled(g: Game, card: string): Game {
assert.equal(g.response?.kind, 'guildRate');
return applyAction(reload(g), 'a', { type: 'card', mode: 'cancel', card });
}
function rejectUnchanged(g: Game, actor: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
function physical(g: Game, id: string) {
const seat = player(g, id);
return { spice: seat.spice, reserves: seat.reserves, forces: seat.forces,
elites: seat.elites, shipped: seat.shipped };
}
void test('multi-sector Guild cross-shipment waits unpaid, then Karama charges full for the exact selected forces', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const before = physical(initial, 'g');
const offered = declare(initial, 'g', { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'carthag', 11);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(offered.pendingGuildTransport?.amount, 3);
assert.deepEqual(physical(offered, 'g'), before);
assert.equal(viewGame(offered, 'a').responseControls?.cancelCards.includes(karama.id), true);
assert.equal(viewGame(offered, 'a').players.find(seat => seat.id === 'g')?.spice, undefined);
assert.deepEqual(normalizeAutomaticGame(reload(offered)), reload(offered));
const done = canceled(offered, karama.id);
assert.equal(done.pendingGuildTransport, null);
assert.equal(done.response, null);
assert.equal(player(done, 'g').spice, 17);
assert.equal(player(done, 'g').reserves, 15);
assert.equal(player(done, 'g').forces['pasty_mesa:5'], 1);
assert.equal(player(done, 'g').forces['pasty_mesa:6'], 1);
assert.equal(player(done, 'g').forces['carthag:11'], 3);
assert.equal(player(done, 'g').shipped, true);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
rejectUnchanged(done, 'a', { type: 'card', mode: 'cancel', card: karama.id });
});
void test('Guild return to abstract reserves is still a cancelable shipment and restores its exact force group', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const offered = declare(initial, 'g', { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'reserves');
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(player(offered, 'g').reserves, 15);
assert.equal(player(offered, 'g').spice, 20);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'g').spice, 17);
assert.equal(player(done, 'g').reserves, 18);
assert.equal(player(done, 'g').forces['pasty_mesa:5'], 1);
assert.equal(player(done, 'g').forces['pasty_mesa:6'], 1);
assert.equal(player(done, 'g').shipped, true);
});
void test('passing a Guild transport rate response retains the half tariff and its Guild income', () => {
const { g: initial } = fixture('e');
stationed(initial, 'e', { 'imperial_basin:10': 2, 'imperial_basin:11': 3 });
const offered = declare(initial, 'e', { 'imperial_basin:10': 1, 'imperial_basin:11': 2 }, 'red_chasm', 7);
assert.equal(offered.pendingGuildTransport?.cost, 3);
assert.equal(player(offered, 'e').spice, 20);
assert.equal(player(offered, 'g').spice, 20);
const pendingIncome = applyAction(reload(offered), 'a', { type: 'passResponse' });
assert.equal(pendingIncome.pendingGuildTransport, null);
assert.equal(pendingIncome.response?.kind, 'guildIncome');
assert.equal(player(pendingIncome, 'e').spice, 17);
assert.equal(player(pendingIncome, 'g').spice, 20);
const done = applyAction(reload(pendingIncome), 'a', { type: 'passResponse' });
assert.equal(player(done, 'g').spice, 23);
assert.equal(player(done, 'e').forces['red_chasm:7'], 3);
assert.equal(player(done, 'e').shipped, true);
});
void test('the reciprocal Guild ally retains elite selections and original aid split at canceled full desert price', () => {
const { g: initial, karama } = fixture('e');
stationed(initial, 'e', { 'imperial_basin:10': 2, 'imperial_basin:11': 3 });
player(initial, 'e').elites = { reserves: 3, tanks: 0, forces: { 'imperial_basin:10': 1, 'imperial_basin:11': 1 }, revived: 0 };
player(initial, 'e').spice = 5;
const funded = applyAction(initial, 'g', { type: 'pledgeAid', amount: 2 });
const offered = declare(funded, 'e', { 'imperial_basin:10': 1, 'imperial_basin:11': 2 },
'red_chasm', 7, 1, { 'imperial_basin:10': 1, 'imperial_basin:11': 1 });
assert.equal(offered.pendingGuildTransport?.cost, 3);
assert.equal(offered.pendingGuildTransport?.allyPayment, 1);
assert.equal(player(offered, 'e').spice, 5);
assert.equal(player(offered, 'e').elites?.forces['imperial_basin:10'], 1);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'e').spice, 0);
assert.equal(done.aid.g.amount, 1);
assert.equal(player(done, 'e').forces['imperial_basin:10'], 1);
assert.equal(player(done, 'e').forces['imperial_basin:11'], 1);
assert.equal(player(done, 'e').forces['red_chasm:7'], 3);
assert.equal(player(done, 'e').elites?.forces['red_chasm:7'], 2);
assert.equal(player(done, 'e').elites?.forces['imperial_basin:10'] ?? 0, 0);
assert.equal(player(done, 'e').elites?.forces['imperial_basin:11'] ?? 0, 0);
assert.equal(player(done, 'g').spice, 23);
assert.equal(done.guildRateBlocked, undefined);
});
void test('an unfunded full-price return withdraws intact; its replacement pays full using newly approved aid', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
player(initial, 'g').spice = 2;
player(initial, 'g').ally = 'e';
player(initial, 'e').ally = 'g';
const funded = applyAction(initial, 'e', { type: 'pledgeAid', amount: 2 });
const offered = declare(funded, 'g', { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'reserves');
assert.equal(offered.pendingGuildTransport?.allyPayment, 0);
const untouched = physical(offered, 'g');
const withdrawn = canceled(offered, karama.id);
assert.deepEqual(physical(withdrawn, 'g'), untouched);
assert.equal(withdrawn.aid.e.amount, 2);
assert.equal(withdrawn.pendingGuildTransport, null);
assert.equal(withdrawn.guildRateBlocked?.player, 'g');
assert.equal(viewGame(withdrawn, 'g').guildRateCanceled, true);
assert.equal(viewGame(withdrawn, 'e').guildRateCanceled, false);
assert.equal(withdrawn.discard.filter(card => card.id === karama.id).length, 1);
rejectUnchanged(withdrawn, 'g', {
type: 'guildShip', forces: { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, territory: 'reserves', allyPayment: 0,
});
const replacement = declare(reload(withdrawn), 'g',
{ 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'reserves', 0, 1);
assert.notEqual(replacement.response?.kind, 'guildRate');
assert.equal(player(replacement, 'g').spice, 1);
assert.equal(player(replacement, 'g').reserves, 18);
assert.equal(replacement.aid.e.amount, 1);
assert.equal(player(replacement, 'g').shipped, true);
});
void test('an allied Fremen southern-reserve cross uses the cancelable Guild rate, not off-planet reserves', () => {
const { g: initial, karama } = fixture('f');
const offered = declare(initial, 'f', 'reserves', 'carthag', 11);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(player(offered, 'f').reserves, 20);
assert.equal(player(offered, 'f').spice, 20);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'f').reserves, 17);
assert.equal(player(done, 'f').spice, 17);
assert.equal(player(done, 'f').forces['carthag:11'], 3);
assert.equal(player(done, 'g').spice, 23);
assert.equal(player(done, 'f').shipped, true);
});
void test('a withdrawn Fremen southern-reserve declaration preserves its native forces and requires full-price replacement', () => {
const { g: initial, karama } = fixture('f');
player(initial, 'f').spice = 2;
const offered = declare(initial, 'f', 'reserves', 'carthag', 11);
const withdrawn = canceled(offered, karama.id);
assert.equal(withdrawn.pendingGuildTransport, null);
assert.equal(player(withdrawn, 'f').reserves, 20);
assert.equal(player(withdrawn, 'f').forces['carthag:11'] ?? 0, 0);
assert.equal(player(withdrawn, 'f').spice, 2);
assert.equal(player(withdrawn, 'f').shipped, false);
assert.equal(viewGame(withdrawn, 'f').guildRateCanceled, true);
rejectUnchanged(withdrawn, 'f', {
type: 'guildShip', from: 'reserves', amount: 3, territory: 'carthag', sector: 11,
});
const replacement = applyAction(reload(withdrawn), 'f', {
type: 'guildShip', from: 'reserves', amount: 2, territory: 'carthag', sector: 11,
});
assert.notEqual(replacement.response?.kind, 'guildRate');
assert.equal(player(replacement, 'f').spice, 0);
assert.equal(player(replacement, 'f').reserves, 18);
assert.equal(player(replacement, 'f').forces['carthag:11'], 2);
});
void test('Advanced cross-transport retains the Guild rate response separately from the reserve shipment stop', () => {
const { g: initial, karama } = fixture('e', true);
stationed(initial, 'e', { 'imperial_basin:10': 3 });
const offered = declare(initial, 'e', { 'imperial_basin:10': 3 }, 'arrakeen', 10);
assert.equal(offered.decision, null);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(player(offered, 'e').forces['arrakeen:10'] ?? 0, 0);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'e').spice, 17);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
assert.equal(player(done, 'e').shipped, true);
});
void test('independently purchased Karama shipping remains half-price and bank-paid, not Guild-cancelable', () => {
const { g: initial, karama } = fixture('e');
player(initial, 'a').hand.splice(player(initial, 'a').hand.indexOf(karama), 1);
player(initial, 'e').hand.push(karama);
stationed(initial, 'e', { 'imperial_basin:10': 3 });
const purchased = applyAction(initial, 'e', { type: 'card', mode: 'shipment', card: karama.id });
assert.equal(purchased.karamaShipping?.player, 'e');
const done = declare(purchased, 'e', { 'imperial_basin:10': 3 }, 'arrakeen', 10);
assert.notEqual(done.response?.kind, 'guildRate');
assert.equal(player(done, 'e').spice, 18);
assert.equal(player(done, 'g').spice, 20);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
assert.equal(done.karamaShipping, null);
});
void test('BG Worthless conversion preserves the signed cross-shipment while its cancellation waits', () => {
const { g: initial, karama } = fixture('g', true);
initial.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
initial.order.push('b');
initial.movementRemaining!.push('b');
const bg = player(initial, 'b');
bg.spice = 20;
bg.reserves = 20;
bg.forces = {};
const index = initial.deck.findIndex(card => card.kind === 'worthless');
const worthless = initial.deck.splice(index, 1)[0];
bg.hand.push(worthless);
stationed(initial, 'g', { 'pasty_mesa:5': 3 });
let g = declare(initial, 'g', { 'pasty_mesa:5': 3 }, 'carthag', 11);
const signature = g.pendingGuildTransport!.signature;
g = applyAction(reload(g), 'b', { type: 'card', mode: 'cancel', card: worthless.id });
assert.equal(g.response?.kind, 'worthlessKarama');
assert.equal(g.pendingKarama?.use.kind === 'cancel'
? g.pendingKarama.use.response.kind : null, 'guildRate');
assert.equal(g.pendingGuildTransport?.signature, signature);
assert.equal(viewGame(reload(g), 'a').response?.kind, 'worthlessKarama');
for (let turn = 0; g.response && turn < 12; turn++) {
const responder = g.players.find(seat => !g.response!.passed.includes(seat.id))!;
g = applyAction(reload(g), responder.id, { type: 'passResponse' });
}
assert.equal(g.pendingGuildTransport, null);
assert.equal(g.response, null);
assert.equal(player(g, 'g').spice, 17);
assert.equal(player(g, 'g').forces['carthag:11'], 3);
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
assert.equal(player(g, 'a').hand.some(card => card.id === karama.id), true);
});
void test('a restored pending cross rejects stale or tampered physical and response data without mutation', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const offered = reload(declare(initial, 'g',
{ 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'carthag', 11));
const cancel: Action = { type: 'card', mode: 'cancel', card: karama.id };
rejectUnchanged(offered, 'g', { type: 'guildShip', from: 'pasty_mesa:5', amount: 1, territory: 'reserves' });
for (const corrupt of [
(g: Game) => { g.response!.guildRateEvent = 'stale'; },
(g: Game) => { g.pendingGuildTransport!.signature = 'stale'; },
(g: Game) => { g.pendingGuildTransport!.turn = g.turn - 1; },
(g: Game) => { g.pendingGuildTransport!.group[0][1] = 2; },
(g: Game) => { g.pendingGuildTransport!.cost = 1; },
]) {
const stale = reload(offered);
corrupt(stale);
const before = structuredClone(stale);
assert.throws(() => viewGame(stale, 'a'));
assert.throws(() => normalizeAutomaticGame(stale));
assert.deepEqual(stale, before);
rejectUnchanged(stale, 'a', cancel);
}
});
void test('a depleted saved source cannot spend or arrive from the obsolete force group', () => {
const { g: initial } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const offered = reload(declare(initial, 'g',
{ 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'carthag', 11));
player(offered, 'g').forces['pasty_mesa:5'] = 0;
const spice = player(offered, 'g').spice;
const remaining = structuredClone(player(offered, 'g').forces);
const released = applyAction(offered, 'a', { type: 'passResponse' });
assert.equal(released.pendingGuildTransport, null);
assert.equal(player(released, 'g').spice, spice);
assert.deepEqual(player(released, 'g').forces, remaining);
assert.equal(player(released, 'g').forces['carthag:11'] ?? 0, 0);
assert.equal(player(released, 'g').shipped, false);
});
void test('all AI profiles pass pending transport and a blocked ally still selects a legal full-price shipment', () => {
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const { g: initial } = fixture();
player(initial, 'a').bot = level;
stationed(initial, 'g', { 'pasty_mesa:5': 3 });
const offered = declare(initial, 'g', { 'pasty_mesa:5': 3 }, 'carthag', 11);
const reply = botActions(viewGame(offered, 'a'))[0];
assert.ok(reply, `${level} must respond to a legal Guild transport`);
const completed = applyAction(reload(offered), 'a', reply);
assert.equal(completed.pendingGuildTransport, null);
assert.equal(player(completed, 'g').forces['carthag:11'], 3);
assert.equal(player(completed, 'g').spice, 18);
}
const { g: initial, karama } = fixture('e');
stationed(initial, 'e', { 'imperial_basin:10': 4 });
player(initial, 'e').spice = 2;
const withdrawn = canceled(declare(initial, 'e',
{ 'imperial_basin:10': 4 }, 'arrakeen', 10), karama.id);
player(withdrawn, 'e').bot = 'Easy';
const candidate = botActions(viewGame(withdrawn, 'e')).find(action =>
action.type === 'ship' || action.type === 'guildShip');
assert.ok(candidate, 'A canceled discount leaves the allied bot a legal full-price shipment.');
const done = applyAction(reload(withdrawn), 'e', candidate);
assert.equal(player(done, 'e').shipped, true);
assert.ok(player(done, 'e').spice < 2);
});
@@ -0,0 +1,99 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck } from '../game/cards';
import { newPlayer, type Game } from '../game/engine';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('a restarted room resolves exactly one canceled multi-sector Guild transport across a CAS race', async () => {
const store = unitStore();
try {
const made = await store.rooms.createRoom('Guild transport CAS', 'guild', false, []);
const code = made.view.code;
const joinedEmperor = await store.rooms.joinRoom(code, 'Emperor', 'emperor');
const joinedAtreides = await store.rooms.joinRoom(code, 'Atreides', 'atreides');
const tokens = [made.token, joinedEmperor.token!, joinedAtreides.token!];
const seats = await Promise.all(tokens.map(token => store.restart().authenticate(code, token)));
const ids = seats.map(seat => seat.playerId);
const initial = await store.restart().readRoom(code);
const g: Game = {
...initial,
players: [newPlayer(ids[0], 'Guild', 'guild'), newPlayer(ids[1], 'Emperor', 'emperor'),
newPlayer(ids[2], 'Atreides', 'atreides')],
status: 'playing', phase: 5, turn: 2, storm: 18,
order: ids, movementRemaining: ids, active: ids[0],
ready: [], decision: null, response: null, phaseOpening: null,
deck: baseDeck(), discard: [],
};
for (const player of g.players) {
player.spice = 20;
player.reserves = 20;
player.forces = {};
player.hand = [];
}
g.players[0].forces = { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
g.players[0].reserves = 15;
const index = g.deck.findIndex(card => card.effect === 'karama');
const karama = g.deck.splice(index, 1)[0];
g.players[2].hand.push(karama);
store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
await store.restart().act(code, seats[0], g.version, {
type: 'guildShip', forces: { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 },
territory: 'carthag', sector: 11, allyPayment: 0,
}, clock);
const offered = await store.restart().readRoom(code);
assert.equal(offered.version, g.version + 1);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(offered.players[0].spice, 20);
assert.equal(offered.players[0].reserves, 15);
assert.deepEqual(offered.players[0].forces, g.players[0].forces);
const responderView = await store.restart().readSeatView(code, seats[2]);
assert.equal(responderView.responseControls?.cancelCards.includes(karama.id), true);
assert.equal(responderView.players.find(player => player.id === ids[0])?.spice, undefined);
const before = JSON.parse(JSON.stringify(offered));
await assert.rejects(store.restart().act(code, seats[0], offered.version,
{ type: 'card', mode: 'cancel', card: karama.id }, clock));
assert.deepEqual(await store.restart().readRoom(code), before);
let arrivals = 0;
let release!: () => void;
const waiting = new Promise<void>(resolve => { release = resolve; });
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await waiting;
};
let results: PromiseSettledResult<unknown>[];
try {
results = await Promise.allSettled([0, 1].map(() => store.restart().act(
code, seats[2], offered.version,
{ type: 'card', mode: 'cancel', card: karama.id }, clock,
)));
} finally {
delete store.hooks.beforeWrite;
}
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.deepEqual(store.writes.slice(-2).map(write => write.changes).sort((a, b) => a - b), [0, 1]);
const done = await store.restart().readRoom(code);
assert.equal(done.version, offered.version + 1);
assert.equal(done.pendingGuildTransport, null);
assert.equal(done.response, null);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[0].reserves, 15);
assert.equal(done.players[0].forces['pasty_mesa:5'], 1);
assert.equal(done.players[0].forces['pasty_mesa:6'], 1);
assert.equal(done.players[0].forces['carthag:11'], 3);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
await assert.rejects(store.restart().act(code, seats[2], offered.version,
{ type: 'card', mode: 'cancel', card: karama.id }, clock));
assert.deepEqual(await store.restart().readRoom(code), done);
} finally {
store.sqlite.close();
}
});
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { guildTransportQuote } from '../game/transport-quote';
import { guildShipmentCost } from '../game/shipment-price';
function fixture(fremen = false): Game {
const g = createGame('GUILDTRANSPORTPRICE', newPlayer('p', 'Shipper', fremen ? 'fremen' : 'guild'), false);
g.players.push(newPlayer('q', 'Ally', fremen ? 'guild' : 'atreides'));
Object.assign(g, {
status: 'playing', phase: 5, turn: 2, active: 'p',
order: ['p', 'q'], movementRemaining: ['p', 'q'], storm: 18,
});
const shipper = g.players[0];
shipper.ally = 'q';
g.players[1].ally = 'p';
shipper.hand = [];
shipper.spice = 3;
shipper.forces = fremen ? {} : { 'red_chasm:7': 5 };
shipper.reserves = fremen ? 20 : 15;
return g;
}
const cross = (territory: string): Action => ({
type: 'guildShip', from: 'red_chasm:7', amount: 2,
territory, sector: territory === 'carthag' ? 11 : 0,
});
const quote = (g: Game, action: Action) => guildTransportQuote(viewGame(g, 'p'), action);
const botTransport = (g: Game) => botActions(viewGame(g, 'p')).filter(a => a.type === 'guildShip');
void test('a canceled Guild rate doubles cross and return quotes, with purchased Karama restoring the half rate', () => {
const g = fixture();
const returned = cross('reserves');
const desertDestination: Action = {
type: 'guildShip', from: 'red_chasm:7', amount: 2,
territory: 'imperial_basin', sector: 10,
};
assert.equal(quote(g, returned).quote?.cost, 1);
assert.equal(quote(g, desertDestination).quote?.cost, 2);
g.guildRateBlocked = { turn: 2, player: 'p' };
assert.equal(quote(g, returned).quote?.cost, 2);
assert.deepEqual(quote(g, returned).unavailableReasons, []);
assert.equal(quote(g, desertDestination).quote?.cost, 4);
assert.ok(quote(g, desertDestination).unavailableReasons.some(reason => reason.includes('spice')));
assert.equal(guildShipmentCost('reserves', 2, false), 2);
assert.equal(guildShipmentCost('other', 2, false), 4);
g.karamaShipping = { player: 'p', owner: 'q' };
assert.equal(quote(g, returned).quote?.cost, 1);
assert.equal(quote(g, desertDestination).quote?.cost, 2);
assert.deepEqual(quote(g, desertDestination).unavailableReasons, []);
});
void test('after an unaffordable Guild rate cancellation bots omit discounted-only cross and allied southern transports', () => {
const guild = fixture();
guild.players[0].bot = 'Medium';
const discounted = botTransport(guild)[0];
assert.ok(discounted);
assert.equal(applyAction(guild, 'p', discounted).players[0].shipped, true);
guild.guildRateBlocked = { turn: 2, player: 'p' };
assert.deepEqual(botTransport(guild), []);
const fremen = fixture(true);
fremen.players[0].bot = 'Medium';
fremen.players[0].spice = 2;
const southern = botTransport(fremen).find(a => a.from === 'reserves');
assert.ok(southern);
assert.equal(applyAction(fremen, 'p', southern).players[0].shipped, true);
fremen.guildRateBlocked = { turn: 2, player: 'p' };
assert.deepEqual(botTransport(fremen), []);
fremen.karamaShipping = { player: 'p', owner: 'q' };
assert.ok(botTransport(fremen).some(a => a.from === 'reserves'));
});
@@ -106,3 +106,24 @@ void test('all profiles can select a physically typed cross-shipment with the ex
assert.ok(botActions(other).every((a) => a.nexus === undefined));
}
});
void test('an unallied Guild Secret Ally bot budgets the independent half-price cross-shipment', () => {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'emperor', advanced: true, spice: 2,
});
const owner = f.g.players[0];
owner.forces['false_wall_west:16'] = 4;
owner.reserves -= 4;
const view = viewGame(f.g, f.owner);
view.players[0].bot = 'Medium';
const action = botActions(view).find(candidate =>
candidate.type === 'guildShip' &&
candidate.nexus === view.nexusGuildSecretAlly?.event &&
candidate.from === 'false_wall_west:16' &&
Number(candidate.amount) === 4);
assert.ok(action, 'Four physical forces at Guild price two must remain affordable.');
const done = nexusAllow(applyAction(f.g, f.owner, action));
assert.equal(done.players[0].spice, 0);
assert.equal(done.players[0].shipped, true);
nexusGuildSecretAllyInventory(done);
});