Coordinate Fremen grants in ordered revival
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
64706cc7e0
commit
220d78c779
11 files changed
+429
-12
No files matched your search
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import {
|
||||
actFremenRevivalGrant as act,
|
||||
advanceFremenRevivalGrant as advance,
|
||||
fremenRevivalGrantClean as clean,
|
||||
fremenRevivalGrantPlayer as player,
|
||||
passFremenRevivalGrantResponses as pass,
|
||||
} from './fixture-fremen-revival-grant';
|
||||
|
||||
/** Actual Advanced public setup and first ordered Revival. Reversing the two
|
||||
* original lobby seats changes the real storm order. At Revival only, the
|
||||
* reciprocal prior alliance and three ordinary Atreides casualties are staged;
|
||||
* the counters move from his reserves into Tanks, without inventing supply.
|
||||
* This controlled alliance is not a claim about Nexus history or full games. */
|
||||
function source(fremenFirstSeat: boolean) {
|
||||
const first = fremenFirstSeat ? newPlayer('fremen', 'Fremen', 'fremen')
|
||||
: newPlayer('atreides', 'Atreides', 'atreides');
|
||||
const second = fremenFirstSeat ? newPlayer('atreides', 'Atreides', 'atreides')
|
||||
: newPlayer('fremen', 'Fremen', 'fremen');
|
||||
let game = createGame('FREMENORDEREDBOT', first, true);
|
||||
joinGame(game, second);
|
||||
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
for (const p of game.players) game = act(game, p.id, { type: 'ready' });
|
||||
game = act(game, game.host, { type: 'start', advancedPreview: true });
|
||||
game = advance(game, state => state.status === 'playing');
|
||||
// Select only two original unopened ordinary blows, avoiding incidental worms.
|
||||
for (let position = 0; position < 2; position++) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.ok(game.revivalOrder);
|
||||
const ally = player(game, 'atreides'), fremen = player(game, 'fremen');
|
||||
fremen.ally = ally.id; ally.ally = fremen.id;
|
||||
assert.ok(ally.reserves >= 3);
|
||||
ally.reserves -= 3; ally.tanks += 3;
|
||||
return game;
|
||||
}
|
||||
|
||||
let cached: { earlier: Game; later: Game } | undefined;
|
||||
function sources() {
|
||||
if (cached) return cached;
|
||||
const options = [source(true), source(false)];
|
||||
const earlier = options.find(g => g.order.indexOf('fremen') < g.order.indexOf('atreides'));
|
||||
const later = options.find(g => g.order.indexOf('fremen') > g.order.indexOf('atreides'));
|
||||
assert.ok(earlier && later, 'Opposite real seat assignments must produce both relative Revival orders');
|
||||
cached = { earlier, later }; return cached;
|
||||
}
|
||||
function awaitingAlly(order: 'earlier' | 'later', level: Difficulty = 'Easy') {
|
||||
let game = structuredClone(sources()[order]);
|
||||
while (game.revivalOrder?.remaining[0] !== 'atreides') {
|
||||
const actor = game.revivalOrder?.remaining[0]; assert.ok(actor);
|
||||
game = act(game, actor, { type: 'ready' });
|
||||
}
|
||||
assert.ok(game.revivalOrder);
|
||||
assert.equal(game.revivalOrder.remaining[0], 'atreides');
|
||||
assert.equal(game.revivalOrder.completed.includes('fremen'), order === 'earlier');
|
||||
game = act(game, 'atreides', { type: 'setAutopilot', difficulty: level });
|
||||
return game;
|
||||
}
|
||||
function swapKaramaToObserver(game: Game) {
|
||||
const observer = player(game, 'emperor');
|
||||
if (observer.hand.some(card => card.effect === 'karama')) return observer.hand.find(card => card.effect === 'karama')!.id;
|
||||
const held = observer.hand[0]; assert.ok(held);
|
||||
const pools = [game.deck, game.discard, ...game.players.filter(p => p.id !== observer.id).map(p => p.hand)];
|
||||
const pool = pools.find(cards => cards.some(card => card.effect === 'karama')); assert.ok(pool);
|
||||
const index = pool.findIndex(card => card.effect === 'karama');
|
||||
const card = pool[index]; pool[index] = held; observer.hand[0] = card;
|
||||
return card.id;
|
||||
}
|
||||
|
||||
for (const order of ['earlier', 'later'] as const) for (const level of DIFFICULTIES) void test(
|
||||
`Advanced ${level} allied bot waits for human Fremen ${order} in ordered Revival and uses a real grant`, () => {
|
||||
let game = awaitingAlly(order, level);
|
||||
const before = structuredClone(game.revivalOrder), ally = player(game, 'atreides');
|
||||
const tanks = ally.tanks, spice = ally.spice;
|
||||
game = runBots(game, 1);
|
||||
assert.deepEqual(game.revivalOrder, before);
|
||||
assert.equal(player(game, 'atreides').revived, 0);
|
||||
assert.equal(player(game, 'atreides').tanks, tanks);
|
||||
game = act(game, 'fremen', { type: 'grantRevival' });
|
||||
if (game.response) game = pass(game);
|
||||
assert.equal(viewGame(game, 'atreides').revival.freeRemaining, 3);
|
||||
const offered = botActions(viewGame(game, 'atreides')).find(action => action.type === 'revive' && action.amount === 3);
|
||||
assert.ok(offered);
|
||||
game = runBots(game, 1);
|
||||
assert.equal(player(game, 'atreides').tanks, tanks - 3);
|
||||
assert.equal(player(game, 'atreides').spice, spice);
|
||||
assert.equal(player(game, 'atreides').freeForcesRevived, 3);
|
||||
},
|
||||
);
|
||||
|
||||
for (const order of ['earlier', 'later'] as const) void test(
|
||||
`Fremen ${order} in the ordered queue can release an automated ally without forfeiting a later grant`, () => {
|
||||
let game = awaitingAlly(order);
|
||||
const before = structuredClone(game.revivalOrder);
|
||||
assert.ok(!viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased);
|
||||
game = act(game, 'fremen', { type: 'releaseFremenRevivalWait', target: 'atreides' });
|
||||
assert.deepEqual(game.revivalOrder, before);
|
||||
assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased, true);
|
||||
assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.blocked, null);
|
||||
assert.equal(game.freeRevival.includes('atreides'), false);
|
||||
let granted = act(game, 'fremen', {type:'grantRevival'});
|
||||
if (granted.response) granted = pass(granted);
|
||||
assert.equal(granted.freeRevival.includes('atreides'), true);
|
||||
const resumed = runBots(game, 1);
|
||||
assert.ok(player(resumed, 'atreides').revived > 0 || resumed.ready.includes('atreides'));
|
||||
},
|
||||
);
|
||||
|
||||
void test('Fremen can release a future ally, while other seats and stale releases are rejected', () => {
|
||||
let game = structuredClone(sources().earlier);
|
||||
game = act(game, 'atreides', {type:'setAutopilot', difficulty:'Easy'});
|
||||
assert.ok(game.revivalOrder?.remaining.includes('atreides'));
|
||||
assert.notEqual(game.revivalOrder?.remaining[0], 'atreides');
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, 'emperor', {type:'releaseFremenRevivalWait', target:'atreides'}));
|
||||
assert.deepEqual(game, before);
|
||||
game = act(game, 'fremen', {type:'releaseFremenRevivalWait', target:'atreides'});
|
||||
assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased, true);
|
||||
assert.equal(viewGame(game, 'emperor').fremenRevivalGrant, null);
|
||||
const released = structuredClone(game);
|
||||
assert.throws(() => act(game, 'fremen', {type:'releaseFremenRevivalWait', target:'atreides'}));
|
||||
assert.deepEqual(game, released);
|
||||
});
|
||||
|
||||
void test('real Karama prevention releases the Advanced bot with its intrinsic free rate', () => {
|
||||
let game = awaitingAlly('later', 'Medium');
|
||||
// Conserved exchange of original Treachery cards gives Emperor a counter.
|
||||
const karama = swapKaramaToObserver(game);
|
||||
game = act(game, 'fremen', { type: 'grantRevival' });
|
||||
assert.equal(game.response?.kind, 'fremenRevivalGrant');
|
||||
game = act(game, 'emperor', { type: 'card', card: karama, mode: 'cancel' });
|
||||
assert.ok(game.revivalRules?.fremenGrantBlocked?.includes('atreides'));
|
||||
assert.equal(viewGame(game, 'atreides').revival.freeRemaining, 2);
|
||||
const resumed = runBots(game, 1);
|
||||
assert.ok(player(resumed, 'atreides').revived > 0 || resumed.ready.includes('atreides'));
|
||||
});
|
||||
|
||||
void test('Advanced bots do not wait for an unnecessary grant or an automated Fremen', () => {
|
||||
for (const variant of ['two-tanks', 'automated-fremen'] as const) {
|
||||
let game = awaitingAlly('later');
|
||||
if (variant === 'two-tanks') {
|
||||
// Return one ordinary counter from Tanks to its original reserve supply.
|
||||
player(game, 'atreides').tanks--; player(game, 'atreides').reserves++;
|
||||
} else game = act(game, 'fremen', { type: 'setAutopilot', difficulty: 'Easy' });
|
||||
game = runBots(game, 1);
|
||||
assert.ok(player(game, 'atreides').revived > 0 || game.ready.includes('atreides'));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user