Extend Guild rate Karama to cross and return transport
This commit is contained in:
1 parent
8a23b5ccd2
commit
8ddd138e87
19 files changed
+898
-109
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user