Support staged group Fremen revival grants
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
9081249574
commit
d8d2e7207b
9 files changed
+277
-15
No files matched your search
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { FremenRevivalGrant } from '../components/fremen-revival-grant';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { botActions } from '../game/bots';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
|
||||
const send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
|
||||
|
||||
/** The game is staged at an actual Nexus because three-player alliances are
|
||||
* not yet selectable in the lobby. Pair formation and admission use the real
|
||||
* actions; tanks are then moved from reserves without creating forces. */
|
||||
function fixture(group = true) {
|
||||
let game = createGame('GROUPFREMEN', newPlayer('f', 'Fremen', 'fremen'), false);
|
||||
game.players.push(newPlayer('a', 'Atreides', 'atreides'),
|
||||
newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['f','a','h','g'],
|
||||
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
game = send(game, 'f', {type:'alliance', target:'a'});
|
||||
game = send(game, 'a', {type:'alliance', target:'f'});
|
||||
if (group) {
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(pair);
|
||||
game = send(game, 'f', {type:'coalitionPropose', groupId:pair.id, entrant:'h',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of ['a','h']) {
|
||||
const proposal = game.coalitionState!.proposal!;
|
||||
game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
|
||||
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'f')!.members, ['a','f','h']);
|
||||
}
|
||||
Object.assign(game, {phase:4, nexus:false, ready:[], response:null, decision:null});
|
||||
for (const seat of game.players) {
|
||||
seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10;
|
||||
seat.hand = []; seat.traitors = []; seat.traitorChoices = [];
|
||||
}
|
||||
for (const id of ['a','h']) {
|
||||
player(game, id).reserves = 16;
|
||||
player(game, id).tanks = 4;
|
||||
}
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [karama] = game.deck.splice(index, 1);
|
||||
player(game, 'g').hand.push(karama);
|
||||
return {game, karama};
|
||||
}
|
||||
function passResponses(game: Game): Game {
|
||||
for (let count = 0; game.response && count < 24; count++) {
|
||||
const next = game.players.find(seat => !game.response!.passed.includes(seat.id));
|
||||
assert.ok(next, 'Each response has a remaining eligible player.');
|
||||
game = send(game, next.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response ?? null, null);
|
||||
return game;
|
||||
}
|
||||
function reject(game: Game, id: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game, id, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('Fremen grants each selected group ally three free force revivals', () => {
|
||||
let {game} = fixture();
|
||||
for (const ally of ['a','h']) {
|
||||
const originalSpice = player(game, ally).spice;
|
||||
const pending = send(game, 'f', {type:'grantRevival', target:ally});
|
||||
assert.equal(pending.response?.kind, 'fremenRevivalGrant');
|
||||
assert.equal(pending.response?.recipient, ally);
|
||||
assert.deepEqual(pending.response?.fremenGrant, {turn:2, owner:'f', recipient:ally});
|
||||
assert.equal(pending.freeRevival.includes(ally), false);
|
||||
game = passResponses(pending);
|
||||
assert.equal(game.freeRevival.filter(id => id === ally).length, 1);
|
||||
assert.equal(viewGame(game, ally).revival.freeRemaining, 3);
|
||||
game = passResponses(send(game, ally, {type:'revive', amount:3, elite:0}));
|
||||
assert.equal(player(game, ally).spice, originalSpice);
|
||||
assert.equal(player(game, ally).tanks, 1);
|
||||
assert.equal(player(game, ally).reserves, 19);
|
||||
assert.equal(player(game, ally).freeForcesRevived, 3);
|
||||
reject(game, 'f', {type:'grantRevival', target:ally});
|
||||
}
|
||||
});
|
||||
|
||||
void test('group grant rejects missing, non-allied and self targets as well as wrong actors and phases', () => {
|
||||
const {game} = fixture();
|
||||
reject(game, 'f', {type:'grantRevival'});
|
||||
for (const target of ['f','g','missing']) reject(game, 'f', {type:'grantRevival', target});
|
||||
reject(game, 'a', {type:'grantRevival', target:'h'});
|
||||
game.phase = 3;
|
||||
reject(game, 'f', {type:'grantRevival', target:'a'});
|
||||
});
|
||||
|
||||
void test('group grant rejects unsupported Advanced play and cannot retarget a pending source', () => {
|
||||
const {game} = fixture();
|
||||
const advanced = structuredClone(game);
|
||||
advanced.advanced = true;
|
||||
assert.equal(viewGame(advanced, 'f').fremenGroupRevivalGrant, null);
|
||||
reject(advanced, 'f', {type:'grantRevival', target:'a'});
|
||||
const pending = send(game, 'f', {type:'grantRevival', target:'a'});
|
||||
pending.response!.recipient = 'h';
|
||||
reject(pending, 'g', {type:'passResponse'});
|
||||
});
|
||||
|
||||
void test('Karama prevents only the selected group grant and leaves the other ally eligible', () => {
|
||||
const initial = fixture();
|
||||
let {game} = initial;
|
||||
const {karama} = initial;
|
||||
game = send(game, 'f', {type:'grantRevival', target:'a'});
|
||||
game = passResponses(send(game, 'g', {type:'card', mode:'cancel', card:karama.id}));
|
||||
assert.equal(game.freeRevival.includes('a'), false);
|
||||
assert.deepEqual(game.revivalRules?.fremenGrantBlocked, ['a']);
|
||||
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
|
||||
reject(game, 'f', {type:'grantRevival', target:'a'});
|
||||
game = passResponses(send(game, 'f', {type:'grantRevival', target:'h'}));
|
||||
assert.equal(game.freeRevival.includes('h'), true);
|
||||
assert.equal(game.freeRevival.includes('a'), false);
|
||||
assert.equal(viewGame(game, 'a').revival.freeRemaining, 2);
|
||||
assert.equal(viewGame(game, 'h').revival.freeRemaining, 3);
|
||||
});
|
||||
|
||||
void test('pair Fremen grant retains its implicit target and native Karama response', () => {
|
||||
let {game} = fixture(false);
|
||||
const pending = send(game, 'f', {type:'grantRevival'});
|
||||
assert.equal(pending.response?.recipient, 'a');
|
||||
game = passResponses(pending);
|
||||
assert.equal(game.freeRevival.includes('a'), true);
|
||||
assert.equal(viewGame(game, 'a').revival.freeRemaining, 3);
|
||||
reject(game, 'f', {type:'grantRevival'});
|
||||
});
|
||||
|
||||
void test('a Medium Fremen bot offers a legal targeted group grant', () => {
|
||||
const {game} = fixture();
|
||||
const view = viewGame(game, 'f');
|
||||
view.players.find(seat => seat.id === 'f')!.bot = 'Medium';
|
||||
const grant = botActions(view).find(action => action.type === 'grantRevival');
|
||||
assert.ok(grant);
|
||||
assert.ok(grant.target === 'a' || grant.target === 'h');
|
||||
const pending = send(game, 'f', grant);
|
||||
assert.equal(pending.response?.recipient, grant.target);
|
||||
assert.equal(passResponses(pending).freeRevival.includes(grant.target as string), true);
|
||||
});
|
||||
|
||||
void test('the actual group view renders both named grant controls only to Fremen', () => {
|
||||
const {game} = fixture();
|
||||
const own = viewGame(game, 'f');
|
||||
assert.deepEqual(own.fremenGroupRevivalGrant?.map(entry => entry.recipient).sort(), ['a','h']);
|
||||
const ownMarkup = renderToStaticMarkup(createElement(FremenRevivalGrant, {
|
||||
game:own, busy:false, act() {},
|
||||
}));
|
||||
assert.match(ownMarkup, /Grant Atreides free revival/);
|
||||
assert.match(ownMarkup, /Grant Harkonnen free revival/);
|
||||
assert.doesNotMatch(ownMarkup, /disabled=""/);
|
||||
const observer = viewGame(game, 'g');
|
||||
assert.equal(observer.fremenGroupRevivalGrant, null);
|
||||
assert.equal(renderToStaticMarkup(createElement(FremenRevivalGrant, {
|
||||
game:observer, busy:false, act() {},
|
||||
})), '');
|
||||
});
|
||||
Reference in new issue
Block a user