Connect Moritani Extortion through Mentat settlement
This commit is contained in:
1 parent
88829b7057
commit
745e4fa8ab
19 files changed
+901
-55
No files matched your search
@@ -123,14 +123,6 @@ void test('denial tolerates changed placement feasibility but rejects malformed
|
||||
grummanToken(bad, 'assassination').status = 'removed';
|
||||
},
|
||||
],
|
||||
[
|
||||
'extortion source',
|
||||
(bad) => {
|
||||
const token = grummanToken(bad, 'extortion');
|
||||
token.status = 'extortion';
|
||||
bad.pendingMoritaniPlacement!.token = token.id;
|
||||
},
|
||||
],
|
||||
[
|
||||
'same source and target',
|
||||
(bad) => {
|
||||
|
||||
@@ -281,22 +281,26 @@ void test('Karama cancels the alliance offer while preserving reveal or decline
|
||||
assert.equal(revealed.pendingTerrorEntry?.stage, 'robbery');
|
||||
});
|
||||
|
||||
void test('Ecaz and presently unsupported refusal effects cannot receive an alliance offer', () => {
|
||||
for (const initial of [
|
||||
fixture('robbery', 'ecaz'),
|
||||
fixture('atomics'),
|
||||
fixture('extortion'),
|
||||
]) {
|
||||
const g = enter(initial);
|
||||
assert.equal(viewGame(g, 'm').terrorEntry!.canOfferAlliance, false);
|
||||
assert.ok(viewGame(g, 'm').terrorEntry!.allianceBlockedReason);
|
||||
assert.equal(
|
||||
'allianceBlockedReason' in viewGame(g, 'e').terrorEntry!,
|
||||
false,
|
||||
);
|
||||
rejected(g, 'm', { type: 'decision', alliance: true });
|
||||
assert.equal(decision(g, 'm', { decline: true }).pendingTerrorEntry, null);
|
||||
}
|
||||
void test('Ecaz entrants cannot be offered Enemy of My Enemy', () => {
|
||||
const g = enter(fixture('robbery', 'ecaz'));
|
||||
assert.equal(viewGame(g, 'm').terrorEntry!.canOfferAlliance, false);
|
||||
rejected(g, 'm', { type: 'decision', alliance: true });
|
||||
assert.equal(decision(g, 'm', { decline: true }).pendingTerrorEntry, null);
|
||||
});
|
||||
|
||||
void test('Extortion refusal reserves the award while acceptance returns the token without collecting it', () => {
|
||||
const entered = enter(fixture('extortion'));
|
||||
assert.equal(viewGame(entered, 'm').terrorEntry!.canOfferAlliance, true);
|
||||
const declined = decision(reload(allow(offer(entered))), 'e', { accept: false });
|
||||
assert.equal(declined.moritaniExtortion?.bank.reserved, 5);
|
||||
assert.equal(player(declined, 'm').spice, player(entered, 'm').spice);
|
||||
assert.equal(declined.moritaniTerror!.tokens.find(token => token.kind === 'extortion')?.status, 'extortion');
|
||||
|
||||
const accepted = decision(reload(allow(offer(entered))), 'e', { accept: true });
|
||||
assert.equal(accepted.moritaniExtortion, undefined);
|
||||
assert.equal(player(accepted, 'm').spice, player(entered, 'm').spice);
|
||||
assert.equal(accepted.moritaniTerror!.tokens.find(token => token.kind === 'extortion')?.status, 'available');
|
||||
assert.equal(viewGame(accepted, 'e').extortion.deferred, 0);
|
||||
});
|
||||
|
||||
void test('alliance declarations and replies do not expose different hidden Terror faces', () => {
|
||||
|
||||
@@ -104,21 +104,6 @@ void test('shipment opens a private Terror choice after settlement and decline s
|
||||
rejected(declined, 'e', shipment);
|
||||
});
|
||||
|
||||
void test('unsupported hidden faces can be declined but cannot be revealed or leaked', () => {
|
||||
for (const kind of ['atomics', 'extortion'] as const) {
|
||||
const g = enter(fixture(kind));
|
||||
assert.equal(viewGame(g, 'm').terrorEntry!.canReveal, false);
|
||||
assert.equal('kind' in viewGame(g, 'e').terrorEntry!, false);
|
||||
rejected(g, 'm', { type: 'decision', reveal: true });
|
||||
const done = decide(g, { decline: true });
|
||||
assert.equal(done.decision, null);
|
||||
assert.equal(
|
||||
done.moritaniTerror!.tokens.find((token) => token.kind === kind)!.status,
|
||||
'placed',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Moritani and its ally do not trigger Terror when shipping', () => {
|
||||
const allied = fixture();
|
||||
player(allied, 'm').ally = 'e';
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
||||
import { homeworldRevivalFixture, holdRevivalCard } from './fixture-homeworld-revival';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
const extortionToken = (g: Game) =>
|
||||
g.moritaniTerror!.tokens.find((t) => t.kind === 'extortion')!;
|
||||
const payment = (g: Game, pay: boolean): Action => ({
|
||||
type: 'decision',
|
||||
event: (g.decision as Extract<NonNullable<Game['decision']>, { kind: 'moritaniExtortion' }>).event,
|
||||
pay,
|
||||
});
|
||||
function reject(g: Game, id: string, action: Action) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, id, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
function fixture(choam = false) {
|
||||
const g = createGame('EXTORT01', newPlayer('m', 'Moritani', 'moritani'));
|
||||
g.players.push(
|
||||
newPlayer('e', 'Emperor', 'emperor'),
|
||||
newPlayer('a', 'Atreides', 'atreides'),
|
||||
newPlayer('b', 'Harkonnen', 'harkonnen'),
|
||||
);
|
||||
if (choam) g.players.push(newPlayer('c', 'CHOAM', 'choam'));
|
||||
g.status = 'playing';
|
||||
g.phase = 5;
|
||||
g.turn = 2;
|
||||
g.storm = 18;
|
||||
g.active = 'e';
|
||||
g.order = g.players.map((p) => p.id);
|
||||
g.movementRemaining = [...g.order];
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.spice = 12;
|
||||
p.hand = [];
|
||||
}
|
||||
g.moritaniTerror = createTerrorState(() => 0);
|
||||
const token = extortionToken(g);
|
||||
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
|
||||
return g;
|
||||
}
|
||||
function reveal(initial = fixture()) {
|
||||
const offered = applyAction(initial, 'e', {
|
||||
type: 'ship', territory: 'arrakeen', sector: 10, amount: 2,
|
||||
});
|
||||
assert.equal(offered.decision?.kind, 'moritaniTerror');
|
||||
assert.equal(viewGame(offered, 'm').terrorEntry!.kind, 'extortion');
|
||||
assert.equal(viewGame(offered, 'm').terrorEntry!.canReveal, true);
|
||||
const before = player(offered, 'm').spice;
|
||||
const g = applyAction(offered, 'm', { type: 'decision', reveal: true });
|
||||
assert.equal(player(g, 'm').spice, before, 'bank spice remains unspendable until Mentat');
|
||||
assert.equal(extortionToken(g).status, 'extortion');
|
||||
assert.equal(extortionToken(g).location, null);
|
||||
assert.equal(viewGame(g, 'e').extortion.deferred, 5);
|
||||
assert.equal(viewGame(g, 'e').extortion.pending, null);
|
||||
return g;
|
||||
}
|
||||
function mentat(input: Game) {
|
||||
const g = structuredClone(input);
|
||||
g.phase = 7;
|
||||
g.ready = [];
|
||||
let next = g;
|
||||
for (const p of g.players) next = applyAction(next, p.id, { type: 'ready' });
|
||||
if (next.decision?.kind === 'choamMarket')
|
||||
next = applyAction(next, 'c', { type: 'decision', done: true });
|
||||
assert.equal(next.phase, 8);
|
||||
assert.equal(next.decision?.kind, 'moritaniPlacement');
|
||||
return next;
|
||||
}
|
||||
function collect(g: Game) {
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
const before = player(g, 'm').spice;
|
||||
const next = applyAction(g, 'm', { type: 'decision', decline: true });
|
||||
assert.equal(player(next, 'm').spice, before + 5);
|
||||
assert.equal(viewGame(next, 'e').extortion.deferred, 0);
|
||||
assert.equal(next.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(extortionToken(next).status, 'extortion');
|
||||
return next;
|
||||
}
|
||||
|
||||
void test('real entry reserves five, then placement precedes one Mentat award and storm-order payer choices', () => {
|
||||
const revealed = reveal();
|
||||
reject(revealed, 'e', { type: 'decision', event: 'old', pay: true });
|
||||
let g = mentat(reload(revealed));
|
||||
assert.equal(player(g, 'm').spice, player(revealed, 'm').spice);
|
||||
reject(g, 'e', { type: 'decision', event: 'old', pay: true });
|
||||
g = collect(g);
|
||||
const first = g.decision;
|
||||
assert.ok(first?.kind === 'moritaniExtortion');
|
||||
assert.equal(first.player, g.order.filter((id) => id !== 'm')[0]);
|
||||
assert.equal(viewGame(g, first.player).extortion.pending?.amount, 3);
|
||||
assert.equal(viewGame(g, first.player).extortion.pending?.canPay, true);
|
||||
assert.equal(viewGame(g, 'm').extortion.pending?.event, first.event);
|
||||
for (const observer of g.players.filter(seat => seat.id !== first.player))
|
||||
assert.equal(viewGame(g, observer.id).extortion.pending?.canPay, null);
|
||||
reject(g, 'm', payment(g, true));
|
||||
reject(g, first.player, { type: 'decision', event: 'stale', pay: false });
|
||||
const declined = applyAction(g, first.player, payment(g, false));
|
||||
assert.equal(player(declined, 'm').spice, player(g, 'm').spice);
|
||||
assert.equal(extortionToken(declined).status, 'extortion');
|
||||
const second = declined.decision;
|
||||
assert.ok(second?.kind === 'moritaniExtortion');
|
||||
assert.equal(second.player, g.order.filter((id) => id !== 'm')[1]);
|
||||
assert.notEqual(second.event, first.event);
|
||||
reject(declined, second.player, { type: 'decision', event: first.event, pay: true });
|
||||
const ownerBefore = player(declined, 'm').spice;
|
||||
const payerBefore = player(declined, second.player).spice;
|
||||
const paid = applyAction(declined, second.player, payment(declined, true));
|
||||
assert.equal(player(paid, second.player).spice, payerBefore - 3);
|
||||
assert.equal(player(paid, 'm').spice, ownerBefore + 3);
|
||||
assert.equal(extortionToken(paid).status, 'removed');
|
||||
assert.equal(extortionToken(paid).location, null);
|
||||
assert.equal(paid.decision?.kind === 'moritaniExtortion', false);
|
||||
assert.equal(viewGame(paid, 'a').extortion.pending, null);
|
||||
reject(paid, second.player, payment(declined, true));
|
||||
assert.equal(paid.moritaniTerror!.tokens.filter((t) => t.kind === 'extortion').length, 1);
|
||||
});
|
||||
|
||||
void test('a declared placement response settles before deferred Extortion collection, without a second same-turn placement', () => {
|
||||
let g = mentat(reveal());
|
||||
const before = player(g, 'm').spice;
|
||||
const supplyToken = g.moritaniTerror!.tokens.find((t) => t.status === 'available')!;
|
||||
player(g, 'a').hand = [baseDeck().find(card => card.effect === 'karama')!];
|
||||
g = applyAction(g, 'm', {
|
||||
type: 'decision', token: supplyToken.id, territory: 'carthag',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'moritaniPlacement');
|
||||
assert.equal(player(g, 'm').spice, before);
|
||||
assert.equal(viewGame(g, 'a').extortion.deferred, 5);
|
||||
for (let i = 0; g.response && i < g.players.length; i++) {
|
||||
const responder = g.players.find((p) => !g.response!.passed.includes(p.id))!;
|
||||
g = applyAction(g, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(player(g, 'm').spice, before + 5);
|
||||
assert.equal(g.moritaniTerror!.tokens.find((t) => t.id === supplyToken.id)?.location, 'carthag');
|
||||
assert.equal(extortionToken(g).status, 'extortion');
|
||||
reject(g, 'm', { type: 'decision', token: supplyToken.id, territory: 'tueks_sietch' });
|
||||
});
|
||||
|
||||
void test('Karama cancellation consumes placement before the reserved award and Extortion choices', () => {
|
||||
let g = mentat(reveal());
|
||||
const before = player(g, 'm').spice;
|
||||
const supplyToken = g.moritaniTerror!.tokens.find(token => token.status === 'available')!;
|
||||
const karama = baseDeck().find(card => card.effect === 'karama')!;
|
||||
player(g, 'a').hand = [karama];
|
||||
g = applyAction(g, 'm', { type: 'decision', token: supplyToken.id, territory: 'carthag' });
|
||||
assert.equal(g.response?.kind, 'moritaniPlacement');
|
||||
g = applyAction(reload(g), 'a', { type: 'card', mode: 'cancel', card: karama.id });
|
||||
assert.equal(g.moritaniTerror!.tokens.find(token => token.id === supplyToken.id)?.status, 'available');
|
||||
assert.equal(g.moritaniTerror!.placementTurn, g.turn);
|
||||
assert.equal(player(g, 'm').spice, before + 5);
|
||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(viewGame(g, 'e').extortion.pending?.player, 'e');
|
||||
});
|
||||
|
||||
void test('all players including an ally and an insolvent payer get a decline; all pass returns and rotates hidden supply', () => {
|
||||
const initial = fixture();
|
||||
player(initial, 'm').ally = 'a';
|
||||
player(initial, 'a').ally = 'm';
|
||||
player(initial, 'b').spice = 0;
|
||||
let g = collect(mentat(reveal(initial)));
|
||||
const oldSupply = new Set(g.moritaniTerror!.tokens.map((t) => t.id));
|
||||
const formerToken = extortionToken(g).id;
|
||||
const expected = g.order.filter((id) => id !== 'm');
|
||||
for (const id of expected) {
|
||||
assert.ok(g.decision?.kind === 'moritaniExtortion');
|
||||
assert.equal(g.decision.player, id);
|
||||
const projection = viewGame(g, id).extortion.pending!;
|
||||
assert.equal(projection.player, id);
|
||||
assert.equal(projection.canPay, player(g, id).spice >= 3);
|
||||
if (id === 'b') reject(g, id, payment(g, true));
|
||||
const before = player(g, 'm').spice;
|
||||
g = applyAction(reload(g), id, payment(g, false));
|
||||
assert.equal(player(g, 'm').spice, before);
|
||||
}
|
||||
assert.equal(g.decision?.kind === 'moritaniExtortion', false);
|
||||
assert.equal(extortionToken(g).status, 'available');
|
||||
assert.equal(extortionToken(g).location, null);
|
||||
assert.equal(g.moritaniTerror!.tokens.some((t) => t.id === formerToken), false);
|
||||
assert.ok(g.moritaniTerror!.tokens.every((t) => !oldSupply.has(t.id)));
|
||||
assert.equal(g.moritaniTerror!.tokens.length, 6);
|
||||
assert.equal(viewGame(g, 'e').moritaniTerror!.tokens.length, 0);
|
||||
assert.equal(viewGame(g, 'e').extortion.pending, null);
|
||||
reject(g, expected.at(-1)!, { type: 'decision', event: 'expired', pay: false });
|
||||
});
|
||||
|
||||
void test('reloaded reveal and payer preserve private identity, event, and exactly one bank collection', () => {
|
||||
const revealed = reveal();
|
||||
const publicBefore = viewGame(revealed, 'a');
|
||||
assert.equal(JSON.stringify(publicBefore).includes(extortionToken(revealed).id), true);
|
||||
assert.equal(JSON.stringify(publicBefore).includes('terror-supply-'), false);
|
||||
const opened = mentat(reload(revealed));
|
||||
reject(opened, 'e', { type: 'decision', event: 'unissued', pay: false });
|
||||
const pending = collect(reload(opened));
|
||||
const event = pending.decision?.kind === 'moritaniExtortion' ? pending.decision.event : '';
|
||||
assert.ok(event);
|
||||
const restored = reload(pending);
|
||||
assert.ok(restored.decision?.kind === 'moritaniExtortion');
|
||||
assert.equal(restored.decision.event, event);
|
||||
assert.equal(player(restored, 'm').spice, player(revealed, 'm').spice + 5);
|
||||
const next = applyAction(restored, restored.decision.player, payment(restored, false));
|
||||
assert.equal(player(next, 'm').spice, player(restored, 'm').spice);
|
||||
reject(next, restored.decision.player, payment(restored, true));
|
||||
});
|
||||
|
||||
void test('Extortion choices delay an otherwise won table; CHOAM keeps its later market', () => {
|
||||
for (const choam of [false, true]) {
|
||||
const initial = fixture(choam);
|
||||
player(initial, 'a').forces = {
|
||||
'carthag:11': 1, 'sietch_tabr:14': 1, 'tueks_sietch:5': 1,
|
||||
};
|
||||
player(initial, 'a').reserves = 17;
|
||||
let g = collect(mentat(reveal(initial)));
|
||||
assert.equal(g.status, 'playing');
|
||||
assert.deepEqual(g.winner, []);
|
||||
assert.equal(viewGame(g, 'a').mentatVictoryPending, true);
|
||||
assert.ok(g.decision?.kind === 'moritaniExtortion');
|
||||
g = applyAction(g, g.decision.player, payment(g, true));
|
||||
if (choam) {
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'choamMarket');
|
||||
} else {
|
||||
assert.equal(g.status, 'finished');
|
||||
assert.deepEqual(g.winner, ['a']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('bot payers can finish saved Extortion choices without stalling or leaking hidden supply', () => {
|
||||
let g = collect(mentat(reveal()));
|
||||
const originalId = extortionToken(g).id;
|
||||
for (const payer of g.players) if (payer.id !== 'm') payer.bot = 'Easy';
|
||||
for (let turn = 0; g.decision?.kind === 'moritaniExtortion' && turn < g.players.length; turn++) {
|
||||
const payer = g.decision.player;
|
||||
const action = botActions(viewGame(reload(g), payer))[0];
|
||||
assert.ok(action, `${payer} must have a legal AI payment decision`);
|
||||
g = applyAction(g, payer, action);
|
||||
}
|
||||
assert.notEqual(g.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(g.moritaniTerror!.tokens.some(token => token.id === originalId), false);
|
||||
assert.equal(extortionToken(g).status, 'available');
|
||||
});
|
||||
|
||||
void test('a paid Nullentropy search suspends and restores the payer without losing Extortion or a second bank award', () => {
|
||||
const initial = fixture();
|
||||
initial.expansions = ['choam'];
|
||||
initial.deck = baseDeck();
|
||||
initial.discard = initial.deck.splice(0, 3);
|
||||
const box = richeseCards().find(card => card.id === 'richese-nullentropy-box')!;
|
||||
player(initial, 'e').hand.push(box);
|
||||
let g = collect(mentat(reveal(initial)));
|
||||
const ownerSpice = player(g, 'm').spice;
|
||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||
const event = g.decision.event;
|
||||
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'));
|
||||
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');
|
||||
assert.equal(g.decision.event, event);
|
||||
g = applyAction(reload(g), 'e', payment(g, false));
|
||||
assert.equal(player(g, 'm').spice, ownerSpice);
|
||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||
});
|
||||
|
||||
void test('a Richese gift response suspends its payer and restores the same saved offer', () => {
|
||||
const initial = fixture();
|
||||
initial.expansions = ['choam'];
|
||||
initial.deck = baseDeck();
|
||||
initial.players[3] = newPlayer('b', 'Richese', 'richese');
|
||||
player(initial, 'b').spice = 12;
|
||||
player(initial, 'b').forces = {};
|
||||
player(initial, 'b').reserves = 20;
|
||||
player(initial, 'a').ally = 'b';
|
||||
player(initial, 'b').ally = 'a';
|
||||
const gift = richeseCards().find(card => card.id === 'richese-karama')!;
|
||||
player(initial, 'b').hand.push(gift);
|
||||
initial.richeseCache = richeseCards().filter(card => card.id !== gift.id);
|
||||
const karama = initial.deck.splice(initial.deck.findIndex(card => card.effect === 'karama'), 1)[0];
|
||||
player(initial, 'e').hand.push(karama);
|
||||
let g = collect(mentat(reveal(initial)));
|
||||
for (const payer of ['e', 'a']) g = applyAction(g, payer, payment(g, false));
|
||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(g.decision.player, 'b');
|
||||
const event = g.decision.event;
|
||||
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'));
|
||||
g = applyAction(reload(g), 'e', { type: 'passResponse' });
|
||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(g.decision.event, event);
|
||||
g = applyAction(g, 'b', payment(g, true));
|
||||
assert.equal(extortionToken(g).status, 'removed');
|
||||
});
|
||||
|
||||
void test('a genuine late Mentat Ambassador entry settles Extortion after placement before the next turn', () => {
|
||||
let g = homeworldRevivalFixture({ advanced: true, extraSeats: [
|
||||
{ id: 'ec', faction: 'ecaz' },
|
||||
{ id: 'm', faction: 'moritani' },
|
||||
{ id: 'a', faction: 'atreides' },
|
||||
] });
|
||||
g.expansions.push('ecaz');
|
||||
g.phase = 8;
|
||||
g.ready = [];
|
||||
g.phaseOpening = null;
|
||||
g.decision = null;
|
||||
g.response = null;
|
||||
g.moritaniTerror ??= createTerrorState(() => 0);
|
||||
const token = extortionToken(g);
|
||||
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'carthag', 1);
|
||||
g.moritaniTerror.placementTurn = g.turn;
|
||||
const supply = createAmbassadors(() => 0);
|
||||
const guild = supply.tokens.find(candidate => candidate.effect === 'guild')!;
|
||||
supply.cohort = [guild.id, ...supply.tokens.filter(candidate =>
|
||||
candidate.effect !== 'ecaz' && candidate.id !== guild.id).slice(0, 4).map(candidate => candidate.id)];
|
||||
for (const candidate of supply.tokens) {
|
||||
candidate.zone = candidate.effect === 'ecaz' || supply.cohort.includes(candidate.id) ? 'supply' : 'pool';
|
||||
candidate.location = null;
|
||||
}
|
||||
g.ecazAmbassadors = placeAmbassador(supply, guild.id, { turn: 1, availableSpice: 20,
|
||||
destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
|
||||
}).state;
|
||||
const ghola = holdRevivalCard(g, 'f', 'ghola');
|
||||
g = applyAction(g, 'f', { type: 'card', card: ghola, amount: 1, elite: 1 });
|
||||
assert.equal(g.decision?.kind, 'homeworldRevivalDeployment');
|
||||
g = applyAction(reload(g), 'f', { type: 'decision', event: g.decision.event,
|
||||
destination: 'arrakeen:10', amount: 1 });
|
||||
assert.equal(g.pendingAmbassador?.stage, 'offer');
|
||||
g = applyAction(g, 'ec', { type: 'decision', event: g.pendingAmbassador!.event,
|
||||
trigger: true, beneficiary: 'ec' });
|
||||
g = applyAction(reload(g), 'ec', { type: 'decision', event: g.pendingAmbassador!.event,
|
||||
amount: 2, territory: 'carthag', sector: 11 });
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
||||
const before = player(g, 'm').spice;
|
||||
g = applyAction(reload(g), 'm', { type: 'decision', reveal: true });
|
||||
assert.equal(g.phase, 8);
|
||||
assert.equal(g.homeworldRevivalReturn?.stage, 'complete');
|
||||
assert.equal(g.pendingAmbassador, null);
|
||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(player(g, 'm').spice, before + 5);
|
||||
assert.equal(viewGame(g, 'ec').extortion.deferred, 0);
|
||||
g = applyAction(reload(g), g.decision.player, payment(g, true));
|
||||
assert.equal(extortionToken(g).status, 'removed');
|
||||
for (const seat of g.players) g = applyAction(g, seat.id, { type: 'ready' });
|
||||
assert.equal(g.turn, 3);
|
||||
assert.equal(g.moritaniExtortion, undefined);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import * as engine from '../game/engine';
|
||||
import * as bots from '../game/bots';
|
||||
import * as seatAiDelegation from '../lib/seat-ai-delegation';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
||||
|
||||
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
/** Real room module and migrations, with a barrier immediately before the room CAS. */
|
||||
function unitStore() {
|
||||
const sqlite = new DatabaseSync(':memory:');
|
||||
const hooks: { beforeWrite?: () => Promise<void> } = {};
|
||||
const writes: { expected: number; changes: number }[] = [];
|
||||
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
||||
.filter((name) => name.endsWith('.sql'))
|
||||
.sort())
|
||||
sqlite.exec(readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'));
|
||||
class Statement {
|
||||
values: (string | number | null)[] = [];
|
||||
constructor(readonly sql: string) {}
|
||||
bind(...values: (string | number | null)[]) {
|
||||
this.values = values;
|
||||
return this;
|
||||
}
|
||||
async first() {
|
||||
return sqlite.prepare(this.sql).get(...this.values) ?? null;
|
||||
}
|
||||
async run() {
|
||||
const continuation = this.sql.startsWith('UPDATE rooms SET state');
|
||||
if (continuation) await hooks.beforeWrite?.();
|
||||
const changes = Number(sqlite.prepare(this.sql).run(...this.values).changes);
|
||||
if (continuation) writes.push({ expected: Number(this.values[3]), changes });
|
||||
return { meta: { changes } };
|
||||
}
|
||||
}
|
||||
const database = {
|
||||
prepare: (sql: string) => new Statement(sql),
|
||||
batch: async (statements: Statement[]) => {
|
||||
sqlite.exec('BEGIN');
|
||||
try {
|
||||
const results = [];
|
||||
for (const statement of statements) results.push(await statement.run());
|
||||
sqlite.exec('COMMIT');
|
||||
return results;
|
||||
} catch (error) {
|
||||
sqlite.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
function loadRooms() {
|
||||
const exports = {};
|
||||
runInNewContext(
|
||||
ts.transpileModule(readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText,
|
||||
{
|
||||
exports,
|
||||
crypto: webcrypto,
|
||||
TextEncoder,
|
||||
structuredClone,
|
||||
JSON,
|
||||
require: (name: string) => {
|
||||
if (name === 'cloudflare:workers') return { env: { DB: database } };
|
||||
if (name === '@/game/engine') return engine;
|
||||
if (name === '@/game/bots') return bots;
|
||||
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
|
||||
throw new Error('Unexpected module ' + name);
|
||||
},
|
||||
},
|
||||
);
|
||||
return exports as typeof Rooms;
|
||||
}
|
||||
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const store = unitStore();
|
||||
const created = await store.rooms.createRoom('Extortion seat', 'moritani', false, []);
|
||||
const code = created.view.code;
|
||||
const joined = await store.rooms.joinRoom(code, 'Entrant', 'emperor');
|
||||
const witness = await store.rooms.joinRoom(code, 'Witness', 'atreides');
|
||||
const tokens = [created.token, joined.token!, witness.token!];
|
||||
const seats = await Promise.all(tokens.map((token) => store.restart().authenticate(code, token)));
|
||||
const ids = seats.map((seat) => seat.playerId);
|
||||
let g = await store.restart().readRoom(code);
|
||||
g.players = [
|
||||
engine.newPlayer(ids[0], 'Moritani', 'moritani'),
|
||||
engine.newPlayer(ids[1], 'Entrant', 'emperor'),
|
||||
engine.newPlayer(ids[2], 'Witness', 'atreides'),
|
||||
];
|
||||
Object.assign(g, {
|
||||
status: 'playing', phase: 5, turn: 2, storm: 18, active: ids[1],
|
||||
order: [ids[1], ids[0], ids[2]], movementRemaining: [ids[1], ids[0], ids[2]],
|
||||
decision: null, response: null, phaseOpening: null, ready: [],
|
||||
});
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.spice = 12;
|
||||
p.hand = [];
|
||||
}
|
||||
g.moritaniTerror = createTerrorState(() => 0);
|
||||
const token = g.moritaniTerror.tokens.find((item) => item.kind === 'extortion')!;
|
||||
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
|
||||
store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
store.writes.length = 0;
|
||||
async function act(index: number, action: engine.Action) {
|
||||
const current = await store.restart().readRoom(code);
|
||||
await store.restart().act(code, seats[index], current.version, action, clock);
|
||||
return store.restart().readRoom(code);
|
||||
}
|
||||
g = await act(1, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 });
|
||||
assert.equal(g.decision?.kind, 'moritaniTerror');
|
||||
g = await act(0, { type: 'decision', reveal: true });
|
||||
assert.equal(g.moritaniTerror!.tokens.find((item) => item.kind === 'extortion')!.status, 'extortion');
|
||||
assert.equal(g.players[0].spice, 12);
|
||||
assert.equal((await store.restart().readSeatView(code, seats[1])).extortion.deferred, 5);
|
||||
return { ...store, code, tokens, seats, ids, act, revealed: g, token: token.id };
|
||||
}
|
||||
|
||||
void test('SQLite reconnect preserves reserved bank award, payer privacy, and races one payment commit', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
let g = await f.restart().readRoom(f.code);
|
||||
g.phase = 7;
|
||||
g.ready = [];
|
||||
f.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
|
||||
.run(JSON.stringify(g), g.version, f.code);
|
||||
f.writes.length = 0;
|
||||
for (const [i] of f.seats.entries()) g = await f.act(i, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
assert.equal(g.players[0].spice, 12);
|
||||
g = await f.act(0, { type: 'decision', decline: true });
|
||||
assert.equal(g.players[0].spice, 17);
|
||||
assert.ok(g.decision?.kind === 'moritaniExtortion');
|
||||
const event = g.decision.event;
|
||||
const payer = g.decision.player;
|
||||
const payerIndex = f.ids.indexOf(payer);
|
||||
assert.ok(payerIndex >= 0);
|
||||
for (const [i, seat] of f.seats.entries()) {
|
||||
const seatView = await f.restart().readSeatView(f.code, seat);
|
||||
assert.equal(seatView.extortion.deferred, 0);
|
||||
assert.equal(seatView.extortion.pending?.player, payer);
|
||||
assert.equal(seatView.extortion.pending?.canPay, i === payerIndex ? true : null);
|
||||
if (i !== 0)
|
||||
assert.equal(seatView.moritaniTerror!.tokens.some((item) => item.id !== f.token), false);
|
||||
}
|
||||
const before = structuredClone(g);
|
||||
await assert.rejects(f.restart().act(f.code, f.seats[0], g.version,
|
||||
{ type: 'decision', event, pay: true }, clock));
|
||||
await assert.rejects(f.restart().act(f.code, f.seats[payerIndex], g.version,
|
||||
{ type: 'decision', event: 'obsolete', pay: true }, clock));
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), before);
|
||||
assert.equal(f.writes.length, f.seats.length + 1);
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled([0, 1].map(() => f.restart().act(
|
||||
f.code, f.seats[payerIndex], g.version,
|
||||
{ type: 'decision', event, pay: true }, clock,
|
||||
)));
|
||||
} finally {
|
||||
delete f.hooks.beforeWrite;
|
||||
}
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.slice(-2).map((write) => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, g.version + 1);
|
||||
assert.equal(done.players[0].spice, 20);
|
||||
assert.equal(done.players[payerIndex].spice, g.players[payerIndex].spice - 3);
|
||||
assert.equal(done.moritaniTerror!.tokens.find((item) => item.kind === 'extortion')!.status, 'removed');
|
||||
await assert.rejects(f.restart().act(f.code, f.seats[payerIndex], g.version,
|
||||
{ type: 'decision', event, pay: true }, clock), /table changed/i);
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), done);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user