Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,367 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
joinGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
const worthless = baseDeck().filter((c) => c.kind === 'worthless');
|
||||
const karama = baseDeck().filter((c) => c.effect === 'karama');
|
||||
const auctionCard = baseDeck().find((c) => c.kind === 'projectile')!;
|
||||
function fixture() {
|
||||
let g = createGame(
|
||||
'BGKARAM2',
|
||||
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
|
||||
);
|
||||
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
||||
g.players.forEach((p) => (p.ready = true));
|
||||
g = applyAction(g, 'b', { type: 'start' });
|
||||
g = applyAction(g, 'b', { type: 'predict', faction: 'emperor', turn: 3 });
|
||||
for (const p of g.players)
|
||||
g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
|
||||
g.advanced = true;
|
||||
g.phase = 2;
|
||||
g.players.forEach((p) => {
|
||||
p.spice = 20;
|
||||
p.hand = [];
|
||||
});
|
||||
g.players[0].hand = [worthless[0]];
|
||||
g.players[1].hand = [karama[0]];
|
||||
return g;
|
||||
}
|
||||
function allow(state: Game) {
|
||||
let g = state;
|
||||
while (g.response)
|
||||
g = applyAction(
|
||||
g,
|
||||
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
||||
{ type: 'passResponse' },
|
||||
);
|
||||
return g;
|
||||
}
|
||||
function cancel(g: Game) {
|
||||
return applyAction(g, 'e', {
|
||||
type: 'card',
|
||||
card: karama[0].id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
}
|
||||
function auction() {
|
||||
const g = fixture();
|
||||
g.phase = 3;
|
||||
g.active = 'b';
|
||||
g.order = ['b', 'e', 'g'];
|
||||
g.auction = {
|
||||
cards: [auctionCard],
|
||||
index: 0,
|
||||
bid: 0,
|
||||
bidder: null,
|
||||
active: 'b',
|
||||
passed: [],
|
||||
opener: 0,
|
||||
peekKnown: false,
|
||||
};
|
||||
g.deck = [];
|
||||
return g;
|
||||
}
|
||||
function winBid(state: Game, amount: number) {
|
||||
let g = applyAction(state, 'b', { type: 'bid', amount });
|
||||
g = applyAction(g, 'e', { type: 'passBid' });
|
||||
return applyAction(g, 'g', { type: 'passBid' });
|
||||
}
|
||||
void test('advanced Bene Gesserit receives two charity spice at every wealth level, with cancellation only at two or more', () => {
|
||||
for (const starting of [0, 1, 2, 20]) {
|
||||
let g = fixture();
|
||||
g.players[0].spice = starting;
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(g.response?.kind, starting >= 2 ? 'bgCharity' : undefined);
|
||||
if (starting >= 2) assert.equal(g.players[0].spice, starting);
|
||||
g = allow(g);
|
||||
assert.equal(g.players[0].spice, starting + 2);
|
||||
assert.equal(viewGame(g, 'b').players[0].charityClaimed, true);
|
||||
assert.equal(viewGame(g, 'e').players[0].charityClaimed, undefined);
|
||||
assert.throws(
|
||||
() => applyAction(g, 'b', { type: 'charity' }),
|
||||
/once per turn/,
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('canceling rich Bene Gesserit charity spends the attempt; ordinary charity cannot be farmed by bribing spice away', () => {
|
||||
let g = cancel(applyAction(fixture(), 'b', { type: 'charity' }));
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.throws(
|
||||
() => applyAction(g, 'b', { type: 'charity' }),
|
||||
/once per turn/,
|
||||
);
|
||||
g.turn++;
|
||||
g = allow(applyAction(g, 'b', { type: 'charity' }));
|
||||
assert.equal(g.players[0].spice, 22);
|
||||
g = fixture();
|
||||
g.advanced = false;
|
||||
g.players[0].spice = 1;
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(g.players[0].spice, 2);
|
||||
g = applyAction(g, 'b', { type: 'bribe', target: 'e', amount: 2 });
|
||||
assert.throws(
|
||||
() => applyAction(g, 'b', { type: 'charity' }),
|
||||
/once per turn/,
|
||||
);
|
||||
});
|
||||
void test('Worthless conversion is limited to advanced Bene Gesserit, and invalid shipment attempts do not discard a card', () => {
|
||||
const g = fixture();
|
||||
const action = {
|
||||
type: 'card',
|
||||
card: worthless[0].id,
|
||||
mode: 'shipment',
|
||||
target: 'e',
|
||||
};
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'b', action), /active player/);
|
||||
assert.deepEqual(g, before);
|
||||
g.phase = 5;
|
||||
g.active = 'e';
|
||||
g.advanced = false;
|
||||
assert.throws(() => applyAction(g, 'b', action), /special card/);
|
||||
g.advanced = true;
|
||||
g.players[2].hand = [worthless[1]];
|
||||
assert.throws(
|
||||
() => applyAction(g, 'g', { ...action, card: worthless[1].id }),
|
||||
/special card/,
|
||||
);
|
||||
});
|
||||
void test('a Worthless shipment benefit resolves after its response or is canceled without consuming the recipient shipment', () => {
|
||||
let g = fixture();
|
||||
g.phase = 5;
|
||||
g.active = 'e';
|
||||
g = applyAction(g, 'b', {
|
||||
type: 'card',
|
||||
card: worthless[0].id,
|
||||
mode: 'shipment',
|
||||
target: 'e',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
assert.equal(g.karamaShipping, null);
|
||||
assert.equal(g.players[0].hand.length, 0);
|
||||
assert.equal(g.discard.filter((c) => c.id === worthless[0].id).length, 1);
|
||||
const denied = cancel(g);
|
||||
assert.equal(denied.karamaShipping, null);
|
||||
assert.equal(denied.players[1].shipped, false);
|
||||
assert.equal(denied.pendingKarama, null);
|
||||
const granted = allow(g);
|
||||
assert.deepEqual(granted.karamaShipping, {
|
||||
player: 'e',
|
||||
owner: 'b',
|
||||
card: worthless[0].id,
|
||||
});
|
||||
assert.equal(granted.pendingKarama, null);
|
||||
});
|
||||
void test('canceling a Worthless cancellation restores the exact suspended response without exposing private payloads', () => {
|
||||
let g = fixture();
|
||||
g.players[0].hand.push(worthless[1]);
|
||||
g.players[1].ally = 'g';
|
||||
g.players[2].ally = 'e';
|
||||
g.response = {
|
||||
kind: 'emperorGift',
|
||||
owner: 'e',
|
||||
recipient: 'g',
|
||||
amount: 7,
|
||||
passed: ['g'],
|
||||
};
|
||||
const suspended = structuredClone(g.response);
|
||||
g = applyAction(g, 'b', {
|
||||
type: 'card',
|
||||
card: worthless[0].id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
const publicView = viewGame(JSON.parse(JSON.stringify(g)), 'b');
|
||||
assert.equal('pendingKarama' in publicView, false);
|
||||
assert.equal(publicView.response?.amount, undefined);
|
||||
assert.equal(publicView.response?.intent, 'Cancel a power used by Emperor.');
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAction(g, 'b', { type: 'card', card: karama[1].id, mode: 'cancel' }),
|
||||
/another faction/,
|
||||
);
|
||||
const blocked = cancel(g);
|
||||
assert.deepEqual(blocked.response, suspended);
|
||||
const resumed = allow(blocked);
|
||||
assert.equal(resumed.players[1].spice, 13);
|
||||
assert.equal(resumed.players[2].spice, 27);
|
||||
const successfulCancel = allow(g);
|
||||
assert.equal(successfulCancel.players[1].spice, 20);
|
||||
assert.equal(successfulCancel.players[2].spice, 20);
|
||||
assert.equal(successfulCancel.response, null);
|
||||
});
|
||||
void test('a real Karama cancels a faction power directly without opening a counter-Karama window', () => {
|
||||
const g = fixture();
|
||||
g.phase = 5; // Guild income is a completed shipment receipt.
|
||||
g.response = { kind: 'guildIncome', owner: 'g', amount: 5, passed: [] };
|
||||
const result = cancel(g);
|
||||
assert.equal(result.response, null);
|
||||
assert.equal(result.pendingKarama ?? null, null);
|
||||
assert.equal(result.players[2].spice, 20);
|
||||
});
|
||||
void test('Worthless direct acquisition retains the preexisting bid when canceled and enforces hand eligibility before discarding', () => {
|
||||
let g = auction();
|
||||
g.auction!.bidder = 'e';
|
||||
g.auction!.bid = 3;
|
||||
g = applyAction(g, 'b', {
|
||||
type: 'card',
|
||||
card: worthless[0].id,
|
||||
mode: 'purchase',
|
||||
});
|
||||
const denied = cancel(g);
|
||||
assert.equal(denied.auction!.bidder, 'e');
|
||||
assert.equal(denied.auction!.bid, 3);
|
||||
assert.equal(denied.players[0].hand.length, 0);
|
||||
const won = allow(g);
|
||||
assert.equal(won.players[0].hand[0].id, auctionCard.id);
|
||||
assert.equal(won.players[0].spice, 20);
|
||||
g = auction();
|
||||
g.players[0].hand = worthless.slice(0, 4);
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAction(g, 'b', {
|
||||
type: 'card',
|
||||
card: worthless[0].id,
|
||||
mode: 'purchase',
|
||||
}),
|
||||
/eligible to bid/,
|
||||
);
|
||||
assert.equal(g.players[0].hand.length, 4);
|
||||
});
|
||||
void test('holding Worthless permits an overbid without discarding or revealing it if outbid', () => {
|
||||
let g = auction();
|
||||
g.players[0].spice = 1;
|
||||
g = applyAction(g, 'b', { type: 'bid', amount: 15 });
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.players[0].hand[0].id, worthless[0].id);
|
||||
g = applyAction(g, 'e', { type: 'bid', amount: 16 });
|
||||
g = applyAction(g, 'g', { type: 'passBid' });
|
||||
g = applyAction(g, 'b', { type: 'passBid' });
|
||||
g = allow(applyAction(g, 'e', { type: 'decision', karama: false }));
|
||||
assert.equal(g.players[0].hand[0].id, worthless[0].id);
|
||||
assert.equal(g.players[0].spice, 1);
|
||||
});
|
||||
void test('auction payment may spend a selected Worthless or real Karama and only grants the card after conversion succeeds', () => {
|
||||
let g = auction();
|
||||
g.players[0].hand.push(karama[1]);
|
||||
g = winBid(g, 7);
|
||||
const pending = applyAction(g, 'b', {
|
||||
type: 'decision',
|
||||
karama: true,
|
||||
card: worthless[0].id,
|
||||
});
|
||||
assert.equal(pending.response?.kind, 'worthlessKarama');
|
||||
assert.equal(
|
||||
pending.players[0].hand.some((c) => c.id === auctionCard.id),
|
||||
false,
|
||||
);
|
||||
const completed = allow(pending);
|
||||
assert.equal(
|
||||
completed.players[0].hand.some((c) => c.id === auctionCard.id),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
completed.players[0].hand.some((c) => c.id === karama[1].id),
|
||||
true,
|
||||
);
|
||||
const real = applyAction(g, 'b', {
|
||||
type: 'decision',
|
||||
karama: true,
|
||||
card: karama[1].id,
|
||||
});
|
||||
assert.equal(real.response, null);
|
||||
assert.equal(
|
||||
real.players[0].hand.some((c) => c.id === worthless[0].id),
|
||||
true,
|
||||
);
|
||||
});
|
||||
void test('canceled payment automatically uses its only funded alternative; provisional unfunded recovery restarts the same card without charging spice', () => {
|
||||
let g = winBid(auction(), 7);
|
||||
g = cancel(
|
||||
applyAction(g, 'b', {
|
||||
type: 'decision',
|
||||
karama: true,
|
||||
card: worthless[0].id,
|
||||
}),
|
||||
);
|
||||
assert.notEqual(g.decision?.kind, 'auctionPayment');
|
||||
g = allow(g);
|
||||
assert.equal(g.players[0].spice, 13);
|
||||
assert.equal(
|
||||
g.players[0].hand.filter((c) => c.id === auctionCard.id).length,
|
||||
1,
|
||||
);
|
||||
const paid = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'b', { type: 'decision', karama: false }));
|
||||
assert.deepEqual(g, paid, 'a stale payment cannot charge again');
|
||||
g = auction();
|
||||
g.players[0].spice = 1;
|
||||
g = winBid(g, 70);
|
||||
g = cancel(
|
||||
applyAction(g, 'b', {
|
||||
type: 'decision',
|
||||
karama: true,
|
||||
card: worthless[0].id,
|
||||
}),
|
||||
);
|
||||
assert.equal(g.auction!.bid, 0);
|
||||
assert.equal(g.auction!.bidder, null);
|
||||
assert.equal(g.auction!.cards[0].id, auctionCard.id);
|
||||
assert.equal(g.active, 'b');
|
||||
assert.equal(g.players[0].spice, 1);
|
||||
assert.equal(g.players[0].hand.length, 0);
|
||||
assert.equal(g.decision, null);
|
||||
});
|
||||
void test('sealed or prescience-committed Worthless cards cannot be spent as Karama', () => {
|
||||
const g = fixture();
|
||||
g.response = { kind: 'guildIncome', owner: 'g', amount: 5, passed: [] };
|
||||
g.battle = {
|
||||
prepared: true,
|
||||
territory: 'arrakeen',
|
||||
attacker: 'b',
|
||||
defender: 'e',
|
||||
plans: {
|
||||
b: {
|
||||
dial: 0,
|
||||
leader: 'beneGesserit-0',
|
||||
weapon: worthless[0].id,
|
||||
defense: null,
|
||||
support: 0,
|
||||
},
|
||||
},
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
const action = { type: 'card', card: worthless[0].id, mode: 'cancel' };
|
||||
assert.throws(() => applyAction(g, 'b', action), /sealed battle card/);
|
||||
g.battle.plans = {};
|
||||
g.battle.prescience = {
|
||||
player: 'e',
|
||||
field: 'weapon',
|
||||
value: worthless[0].id,
|
||||
};
|
||||
assert.throws(() => applyAction(g, 'b', action), /committed to prescience/);
|
||||
assert.equal(g.players[0].hand.length, 1);
|
||||
});
|
||||
void test('advanced AI claims rich charity once and can select Worthless for a required auction payment from its private view', () => {
|
||||
let g = fixture();
|
||||
g.players[0].bot = 'Hard';
|
||||
assert.deepEqual(botActions(viewGame(g, 'b'))[0], { type: 'charity' });
|
||||
g = allow(applyAction(g, 'b', { type: 'charity' }));
|
||||
assert.ok(botActions(viewGame(g, 'b')).every((a) => a.type !== 'charity'));
|
||||
g = auction();
|
||||
g.players[0].bot = 'Hard';
|
||||
g.players[0].spice = 1;
|
||||
g = winBid(g, 5);
|
||||
const action = botActions(viewGame(g, 'b'))[0];
|
||||
assert.equal(action.karama, true);
|
||||
assert.equal(applyAction(g, 'b', action).response?.kind, 'worthlessKarama');
|
||||
});
|
||||
Reference in new issue
Block a user