Add prepayment Guild rate Karama response

This commit is contained in:
Quintine committed 2026-09-29 15:32:49 +10:00
1 parent 745e4fa8ab
commit 8a23b5ccd2
17 files changed
+567 -21

No files matched your search

+8 -1
View File
@@ -297,7 +297,14 @@ for (const cancel of [false, true])
for (const route of ['reserve', 'cross'] as const) {
const f = await fixture(route, true);
try {
await f.action(0, f.shipment);
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');
}
const pending = await restored(f);
paidOnce(f, pending, 19);
assert.equal(pending.response?.kind, 'guildIncome');
+6 -1
View File
@@ -168,7 +168,12 @@ void test('Guild income from either contributor direction can be allowed or canc
const { g, shipper, donor } = fixture(direction, route);
const card = holdKarama(g, 'a');
const beforeGuild = player(g, 'g').spice;
const pending = applyAction(g, shipper, shipment(route, 1));
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, 'guildIncome');
assert.equal(pending.response?.owner, 'g');
assert.equal(pending.response?.amount, 1);
+259
View File
@@ -0,0 +1,259 @@
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', advanced = false) {
const g = createGame('GUILDRATE', newPlayer('g', 'Guild', 'guild'), advanced);
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'));
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.storm = 18;
g.order = ['g', 'e', 'a'];
g.movementRemaining = [...g.order];
g.active = shipper;
g.deck = baseDeck();
for (const seat of g.players) {
seat.forces = {};
seat.reserves = 20;
seat.spice = 20;
seat.hand = [];
}
if (shipper === 'e') {
player(g, 'e').ally = 'g';
player(g, 'g').ally = 'e';
}
const index = g.deck.findIndex(card => card.effect === 'karama');
const karama = g.deck.splice(index, 1)[0];
player(g, 'a').hand.push(karama);
return { g, karama };
}
function declare(g: Game, id: 'g' | 'e', amount: number, destination = 'arrakeen'): Game {
return applyAction(g, id, {
type: 'ship', territory: destination, sector: destination === 'arrakeen' ? 10 : 7,
amount, allyPayment: 0,
});
}
function cancel(g: Game, card: string): Game {
assert.equal(g.response?.kind, 'guildRate');
return applyAction(reload(g), 'a', { type: 'card', mode: 'cancel', card });
}
function reject(g: Game, actor: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
void test('Karama removes Guild own discount before any forces or spice move, then charges full stronghold cost', () => {
const { g: initial, karama } = fixture('g');
const offered = declare(initial, 'g', 3);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingShipment?.cost, 2);
assert.equal(player(offered, 'g').spice, 20);
assert.equal(player(offered, 'g').reserves, 20);
assert.equal(player(offered, 'g').shipped, false);
const ownerView = viewGame(offered, 'g');
assert.equal(ownerView.response?.kind, 'guildRate');
assert.equal('spice' in ownerView.players.find(seat => seat.id === 'a')!, false);
assert.equal(viewGame(offered, 'a').responseControls?.cancelCards.includes(karama.id), true);
assert.deepEqual(normalizeAutomaticGame(reload(offered)), reload(offered));
const done = cancel(offered, karama.id);
assert.equal(done.pendingShipment, null);
assert.equal(done.guildRateBlocked, undefined);
assert.equal(player(done, 'g').spice, 17);
assert.equal(player(done, 'g').reserves, 17);
assert.equal(player(done, 'g').forces['arrakeen:10'], 3);
assert.equal(player(done, 'g').shipped, true);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
reject(done, 'a', { type: 'card', mode: 'cancel', card: karama.id });
});
void test('the Guild ally can be repriced to full desert tariff without changing the approved payment split', () => {
const { g: initial, karama } = fixture('e');
const offered = declare(initial, 'e', 3, 'red_chasm');
assert.equal(offered.pendingShipment?.cost, 3);
const done = cancel(offered, karama.id);
assert.equal(player(done, 'e').spice, 14);
assert.equal(player(done, 'e').reserves, 17);
assert.equal(player(done, 'e').forces['red_chasm:7'], 3);
assert.equal(player(done, 'g').spice, 26);
assert.equal(done.guildRateBlocked, undefined);
});
void test('unaffordable full price releases the physical declaration, spends Karama, and preserves one full-price replacement', () => {
const { g: initial, karama } = fixture('e');
player(initial, 'e').spice = 2;
const offered = declare(initial, 'e', 4);
assert.equal(offered.pendingShipment?.cost, 2);
const withdrawn = cancel(offered, karama.id);
assert.equal(player(withdrawn, 'e').spice, 2);
assert.equal(player(withdrawn, 'e').reserves, 20);
assert.equal(player(withdrawn, 'e').shipped, false);
assert.equal(withdrawn.pendingShipment, null);
assert.deepEqual(withdrawn.guildRateBlocked, { turn: 2, player: 'e' });
assert.equal(viewGame(withdrawn, 'e').guildRateCanceled, true);
assert.equal(viewGame(withdrawn, 'a').guildRateCanceled, false);
const botState = reload(withdrawn);
player(botState, 'e').bot = 'Easy';
const botShipment = botActions(viewGame(botState, 'e')).find(action => action.type === 'ship');
assert.ok(botShipment, 'The payer must retain a legal full-price AI shipment.');
const botCompletion = applyAction(botState, 'e', botShipment);
assert.equal(player(botCompletion, 'e').shipped, true);
assert.ok(player(botCompletion, 'e').reserves < 20);
assert.equal(withdrawn.discard.filter(card => card.id === karama.id).length, 1);
reject(withdrawn, 'e', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 4 });
const replacement = declare(reload(withdrawn), 'e', 2);
assert.equal(replacement.response?.kind === 'guildRate', false);
assert.equal(player(replacement, 'e').spice, 0);
assert.equal(player(replacement, 'e').reserves, 18);
assert.equal(player(replacement, 'e').forces['arrakeen:10'], 2);
assert.equal(replacement.guildRateBlocked?.player, 'e');
});
void test('a canceled unspent Guild discount expires after a completed turn', () => {
const { g: initial, karama } = fixture('e');
player(initial, 'e').spice = 2;
let g = cancel(declare(initial, 'e', 4), karama.id);
assert.equal(viewGame(g, 'e').guildRateCanceled, true);
for (let turn = 0; g.phase === 5 && turn < g.players.length; turn++)
g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.phase, 7);
for (const phase of [7, 8]) {
assert.equal(g.phase, phase);
for (const seat of g.players) g = applyAction(g, seat.id, { type: 'ready' });
}
assert.equal(g.turn, 3);
assert.equal(g.guildRateBlocked, undefined);
assert.equal(viewGame(g, 'e').guildRateCanceled, false);
});
void test('approved allied contribution stays fixed while full-price Guild shipment routes only that contribution back', () => {
const { g: initial, karama } = fixture('g');
player(initial, 'g').ally = 'e';
player(initial, 'e').ally = 'g';
const funded = applyAction(initial, 'e', { type: 'pledgeAid', amount: 2 });
const offered = applyAction(funded, 'g', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, allyPayment: 1,
});
assert.equal(offered.pendingShipment?.cost, 2);
assert.equal(offered.pendingShipment?.allyPayment, 1);
const done = cancel(offered, karama.id);
assert.equal(player(done, 'g').spice, 19);
assert.equal(player(done, 'e').spice, 18);
assert.equal(done.aid.e.amount, 1);
assert.equal(player(done, 'g').forces['arrakeen:10'], 3);
});
void test('unused pledged aid is not silently added to a canceled split; the payer can authorize it on replacement', () => {
const { g: initial, karama } = fixture('e');
player(initial, 'e').spice = 2;
const funded = applyAction(initial, 'g', { type: 'pledgeAid', amount: 2 });
const offered = declare(funded, 'e', 4);
assert.equal(offered.pendingShipment?.allyPayment, 0);
const withdrawn = cancel(offered, karama.id);
assert.equal(player(withdrawn, 'e').spice, 2);
assert.equal(withdrawn.aid.g.amount, 2);
assert.equal(player(withdrawn, 'e').shipped, false);
const replacement = applyAction(reload(withdrawn), 'e', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, allyPayment: 2,
});
assert.equal(player(replacement, 'e').spice, 0);
assert.equal(replacement.aid.g.amount, 0);
assert.equal(player(replacement, 'e').forces['arrakeen:10'], 4);
});
void test('a tampered saved rate opportunity cannot spend Karama, forces or payment', () => {
const { g: initial, karama } = fixture('e');
const offered = declare(initial, 'e', 3);
const corrupt = reload(offered);
corrupt.response!.guildRateEvent = 'obsolete';
assert.throws(() => viewGame(corrupt, 'a'));
reject(corrupt, 'a', { type: 'card', mode: 'cancel', card: karama.id });
});
void test('the Advanced Guild stop decision precedes the separate Karama rate response', () => {
const { g: initial, karama } = fixture('e', true);
const declared = declare(initial, 'e', 3);
assert.equal(declared.decision?.kind, 'guildShipment');
assert.equal(declared.response, null);
const offered = applyAction(reload(declared), 'g', { type: 'decision', allow: true });
assert.equal(offered.decision, null);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(player(offered, 'e').spice, 20);
const done = cancel(offered, karama.id);
assert.equal(player(done, 'e').spice, 17);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
});
void test('an unstamped saved Advanced declaration gains a turn stamp before its new rate response', () => {
const { g: initial, karama } = fixture('e', true);
const legacy = declare(initial, 'e', 3);
assert.equal(legacy.decision?.kind, 'guildShipment');
delete legacy.pendingShipment!.turn;
const offered = applyAction(reload(legacy), 'g', { type: 'decision', allow: true });
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingShipment?.turn, offered.turn);
assert.equal(player(offered, 'e').spice, 20);
const done = cancel(offered, karama.id);
assert.equal(player(done, 'e').spice, 17);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
});
void test('Advanced BG Worthless converts into a saved rate cancellation without losing shipment custody', () => {
const { g: initial, karama } = fixture('g', true);
initial.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
initial.order.push('b');
initial.movementRemaining!.push('b');
player(initial, 'b').spice = 20;
player(initial, 'b').forces = {};
player(initial, 'b').reserves = 20;
const index = initial.deck.findIndex(card => card.kind === 'worthless');
const worthless = initial.deck.splice(index, 1)[0];
player(initial, 'b').hand.push(worthless);
let g = declare(initial, 'g', 3);
assert.equal(g.decision?.kind, 'guildShipment');
g = applyAction(g, 'g', { type: 'decision', allow: true });
assert.equal(g.response?.kind, 'guildRate');
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');
assert.equal(g.pendingShipment?.guildRateEvent, g.pendingKarama?.use.kind === 'cancel'
? g.pendingKarama.use.response.guildRateEvent : null);
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.response, null);
assert.equal(g.pendingShipment, null);
assert.equal(player(g, 'g').spice, 17);
assert.equal(player(g, 'g').forces['arrakeen:10'], 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('all four AI profiles can pass the physical Guild rate response and finish a legal shipment', () => {
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const { g: initial } = fixture('g');
player(initial, 'a').bot = level;
let g = declare(initial, 'g', 3);
const action = botActions(viewGame(g, 'a'))[0];
assert.ok(action, `${level} must answer the Guild rate response`);
g = applyAction(reload(g), 'a', action);
assert.equal(player(g, 'g').forces['arrakeen:10'], 3);
assert.equal(player(g, 'g').spice, 18);
assert.equal(g.pendingShipment, null);
}
});
+97
View File
@@ -0,0 +1,97 @@
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('restarted rooms expose the unpaid Guild rate choice and commit one Karama cancellation under a CAS race', async () => {
const store = unitStore();
try {
const made = await store.rooms.createRoom('Guild rate 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 = [];
}
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: 'ship', territory: 'arrakeen', sector: 10, amount: 3, 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.pendingShipment?.cost, 2);
assert.equal(offered.players[0].spice, 20);
assert.equal(offered.players[0].reserves, 20);
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);
assert.equal((await store.restart().readSeatView(code, seats[1])).response?.kind, 'guildRate');
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.pendingShipment, null);
assert.equal(done.response, null);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[0].reserves, 17);
assert.equal(done.players[0].forces['arrakeen:10'], 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();
}
});
+2 -2
View File
@@ -274,7 +274,7 @@ void test('a paid Nullentropy search suspends and restores the payer without los
g = applyAction(g, 'e', { type: 'card', card: box.id });
assert.equal(g.decision?.kind, 'nullentropy');
assert.equal(g.pendingNullentropy?.resume.decision?.kind, 'moritaniExtortion');
assert.doesNotThrow(() => viewGame(reload(g), 'e'));
assert.equal(viewGame(reload(g), 'e').decision?.kind, 'nullentropy');
const selected = g.discard[0].id;
g = applyAction(reload(g), 'e', { type: 'decision', event: g.pendingNullentropy!.event, card: selected });
assert.equal(g.decision?.kind, 'moritaniExtortion');
@@ -307,7 +307,7 @@ void test('a Richese gift response suspends its payer and restores the same save
g = applyAction(g, 'b', { type: 'richeseGift', card: gift.id });
assert.equal(g.response?.kind, 'richeseGift');
assert.equal(g.pendingRicheseGift?.resume.decision?.kind, 'moritaniExtortion');
assert.doesNotThrow(() => viewGame(reload(g), 'e'));
assert.equal(viewGame(reload(g), 'e').response?.kind, 'richeseGift');
g = applyAction(reload(g), 'e', { type: 'passResponse' });
assert.equal(g.decision?.kind, 'moritaniExtortion');
assert.equal(g.decision.event, event);