Connect bounded Diplomat retreat and record live deployment
This commit is contained in:
1 parent
4801ce003b
commit
c9a3625ff2
18 files changed
+601
-28
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { DiplomatRetreat } from '../components/diplomat-retreat';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type GameView } from '../game/engine';
|
||||
import { diplomatDefenseGame } from './diplomat-defense-fixture';
|
||||
|
||||
function offeredView(viewer = 'a'): GameView {
|
||||
const view = viewGame(diplomatDefenseGame(), viewer);
|
||||
view.decision = {
|
||||
kind: 'diplomatRetreat', player: 'a', event: 'retreat-event',
|
||||
destinations: [
|
||||
{ location: 'imperial_basin:9', choices: [
|
||||
{ normal: 1, elite: 0 },
|
||||
{ normal: 2, elite: 1 },
|
||||
{ normal: 1, elite: 2 },
|
||||
] },
|
||||
{ location: 'hagga_basin:12', choices: [
|
||||
{ normal: 0, elite: 1 },
|
||||
{ normal: 1, elite: 1 },
|
||||
] },
|
||||
],
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
void test('only the deciding player can select every offered destination and physical count or decline', () => {
|
||||
const view = offeredView();
|
||||
const markup = (game: GameView, busy = false) => renderToStaticMarkup(
|
||||
createElement(DiplomatRetreat, { game, act() {}, busy }),
|
||||
);
|
||||
const html = markup(view);
|
||||
assert.match(html, /aria-label="Diplomat retreat choice"/);
|
||||
assert.match(html, /Imperial Basin · sector 9/);
|
||||
assert.match(html, /Hagga Basin · sector 12/);
|
||||
assert.match(html, /2 ordinary · 1 elite/);
|
||||
assert.match(html, /1 ordinary · 2 elite/);
|
||||
assert.doesNotMatch(html, /0 ordinary · 1 elite/);
|
||||
assert.match(html, /Retreat to Imperial Basin · sector 9/);
|
||||
assert.match(html, /Decline Diplomat retreat/);
|
||||
assert.equal(markup(offeredView('d')), '');
|
||||
assert.equal((markup(view, true).match(/disabled=""/g) ?? []).length, 6);
|
||||
if (view.decision?.kind !== 'diplomatRetreat') throw new Error('Expected Diplomat retreat');
|
||||
view.decision.destinations.reverse();
|
||||
const alternate = markup(view);
|
||||
assert.match(alternate, /0 ordinary · 1 elite/);
|
||||
assert.doesNotMatch(alternate, /2 ordinary · 1 elite/);
|
||||
view.decision.destinations = [];
|
||||
assert.match(markup(view), /No legal retreat destination and force combination/);
|
||||
assert.doesNotMatch(markup(view), /Retreat to Imperial Basin/);
|
||||
assert.match(markup(view), /Decline Diplomat retreat/);
|
||||
});
|
||||
|
||||
void test('all bot profiles select the first destination and largest legal physical count, or decline if none', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = offeredView();
|
||||
view.players.find((player) => player.id === view.me)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view)[0], {
|
||||
type: 'decision', event: 'retreat-event',
|
||||
destination: 'imperial_basin:9', normal: 2, elite: 1,
|
||||
});
|
||||
if (view.decision?.kind !== 'diplomatRetreat') throw new Error('Expected Diplomat retreat');
|
||||
view.decision.destinations = [];
|
||||
assert.deepEqual(botActions(view)[0], {
|
||||
type: 'decision', event: 'retreat-event',
|
||||
destination: null, normal: 0, elite: 0,
|
||||
});
|
||||
const observer = offeredView('d');
|
||||
observer.players.find((player) => player.id === observer.me)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(observer), []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { leaderSkillBattle } from './leader-skill-battle-fixture';
|
||||
|
||||
function losingDiplomat(elite = false, territory = 'arrakeen', sector = 10, stage?: (game: Game) => void): Game {
|
||||
let game = leaderSkillBattle({ skill: 'diplomat', unsealed: true, hide: true, elite, territory, sector });
|
||||
stage?.(game);
|
||||
game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
|
||||
game = applyAction(game, 'd', { type: 'battlePlan', dial: 5, leader: 'guild-1' });
|
||||
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
|
||||
return applyAction(game, 'd', { type: 'traitorCall', call: false });
|
||||
}
|
||||
|
||||
void test('surviving skilled Diplomat retreats selected undialed ordinary and elite forces, then loses the remainder', () => {
|
||||
const pending = losingDiplomat(true);
|
||||
const decision = pending.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
assert.ok(decision.destinations[0].choices.some(choice => choice.normal === 2 && choice.elite === 2));
|
||||
const destination = decision.destinations[0].location;
|
||||
const restored = JSON.parse(JSON.stringify(pending)) as Game;
|
||||
assert.deepEqual(viewGame(restored, 'a').decision, decision);
|
||||
const resolved = applyAction(restored, 'a', {
|
||||
type: 'decision', event: decision.event, destination, normal: 2, elite: 2,
|
||||
});
|
||||
const owner = resolved.players.find(player => player.id === 'a')!;
|
||||
assert.equal(owner.forces[destination], 4);
|
||||
assert.equal(owner.elites?.forces[destination], 2);
|
||||
assert.equal(owner.forces['arrakeen:10'], undefined);
|
||||
assert.equal(owner.tanks, 1);
|
||||
assert.equal(owner.elites?.tanks, 0);
|
||||
assert.equal(resolved.phase, 7);
|
||||
assert.equal(resolved.battle, null);
|
||||
assert.equal(resolved.decision, null);
|
||||
assert.match(JSON.stringify(resolved.log), /retreated 2 normal and 2 elite/);
|
||||
});
|
||||
|
||||
void test('Diplomat may retreat through another safe sector of its battle territory to a true adjacent territory', () => {
|
||||
const pending = losingDiplomat(false, 'imperial_basin', 9);
|
||||
const decision = pending.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
assert.ok(decision.destinations.some(option => option.location === 'arsunt:11'));
|
||||
const resolved = applyAction(pending, 'a', {
|
||||
type: 'decision', event: decision.event, destination: 'arsunt:11', normal: 2, elite: 0,
|
||||
});
|
||||
assert.equal(resolved.players[0].forces['arsunt:11'], 2);
|
||||
assert.equal(resolved.players[0].tanks, 3);
|
||||
});
|
||||
|
||||
void test('split sectors cannot retreat counters from an origin blocked by the storm', () => {
|
||||
const pending = losingDiplomat(false, 'imperial_basin', 9, game => {
|
||||
game.players[0].forces = { 'imperial_basin:9': 2, 'imperial_basin:10': 3 };
|
||||
game.storm = 10;
|
||||
});
|
||||
const decision = pending.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
const exit = decision.destinations.find(option => option.location === 'oh_gap:9');
|
||||
if (!exit) throw new Error('Expected an adjacent retreat exit');
|
||||
assert.ok(exit.choices.some(choice => choice.normal === 2 && choice.elite === 0));
|
||||
assert.equal(exit.choices.some(choice => choice.normal === 3 && choice.elite === 0), false);
|
||||
assert.throws(() => applyAction(pending, 'a', {
|
||||
type: 'decision', event: decision.event, destination: exit.location, normal: 3, elite: 0,
|
||||
}));
|
||||
const resolved = applyAction(pending, 'a', {
|
||||
type: 'decision', event: decision.event, destination: exit.location, normal: 2, elite: 0,
|
||||
});
|
||||
assert.equal(resolved.players[0].forces['oh_gap:9'], 2);
|
||||
assert.equal(resolved.players[0].tanks, 3);
|
||||
});
|
||||
|
||||
void test('a legacy battle without retreat version resolves without a retroactive choice', () => {
|
||||
const resolved = losingDiplomat(false, 'arrakeen', 10, game => {
|
||||
if (game.battle) delete game.battle.diplomatRetreatVersion;
|
||||
});
|
||||
assert.notEqual(resolved.decision?.kind, 'diplomatRetreat');
|
||||
assert.equal(resolved.players[0].forces['arrakeen:10'], undefined);
|
||||
assert.equal(resolved.players[0].tanks, 5);
|
||||
assert.equal(resolved.phase, 7);
|
||||
});
|
||||
|
||||
void test('Diplomat retreat rejects stale or impossible destinations and counts without mutating the pending game', () => {
|
||||
const pending = losingDiplomat();
|
||||
const decision = pending.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
const before = JSON.stringify(pending);
|
||||
for (const action of [
|
||||
{ type: 'decision', event: 'stale', destination: decision.destinations[0].location, normal: 1, elite: 0 },
|
||||
{ type: 'decision', event: decision.event, destination: 'arrakeen:10', normal: 1, elite: 0 },
|
||||
{ type: 'decision', event: decision.event, destination: decision.destinations[0].location, normal: 6, elite: 0 },
|
||||
{ type: 'decision', event: decision.event, destination: null, normal: 1, elite: 0 },
|
||||
]) {
|
||||
assert.throws(() => applyAction(pending, 'a', action));
|
||||
assert.equal(JSON.stringify(pending), before);
|
||||
}
|
||||
assert.throws(() => applyAction(pending, 'd', {
|
||||
type: 'decision', event: decision.event, destination: null, normal: 0, elite: 0,
|
||||
}));
|
||||
const declined = applyAction(pending, 'a', {
|
||||
type: 'decision', event: decision.event, destination: null, normal: 0, elite: 0,
|
||||
});
|
||||
assert.equal(declined.players[0].forces['arrakeen:10'], undefined);
|
||||
assert.equal(declined.players[0].tanks, 5);
|
||||
assert.equal(declined.phase, 7);
|
||||
});
|
||||
|
||||
void test('a dead Diplomat has no retreat even when its faction loses', () => {
|
||||
let game = leaderSkillBattle({ skill: 'diplomat', unsealed: true, hide: true, weaponKills: true });
|
||||
const weapon = game.players[1].hand.find(card => card.kind === 'projectile')!;
|
||||
game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
|
||||
game = applyAction(game, 'd', { type: 'battlePlan', dial: 5, leader: 'guild-1', weapon: weapon.id });
|
||||
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
|
||||
assert.notEqual(game.decision?.kind, 'diplomatRetreat');
|
||||
assert.equal(game.players[0].forces['arrakeen:10'], undefined);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { leaderSkillBattle } from './leader-skill-battle-fixture';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
void test('two concurrent saved Diplomat retreats commit one exact physical transfer and one battle resolution', async () => {
|
||||
const store = unitStore();
|
||||
try {
|
||||
const created = await store.rooms.createRoom('Diplomat owner', 'emperor', false, []);
|
||||
const joined = await store.rooms.joinRoom(created.view.code, 'Opponent', 'guild');
|
||||
const code = created.view.code;
|
||||
const original = await Promise.all([created.token!, joined.token!].map(token =>
|
||||
store.rooms.authenticate(code, token)));
|
||||
for (const [index, seat] of original.entries())
|
||||
store.sqlite.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
|
||||
.run(index ? 'd' : 'a', code, seat.playerId);
|
||||
const seats = await Promise.all([created.token!, joined.token!].map(token =>
|
||||
store.restart().authenticate(code, token)));
|
||||
let game = leaderSkillBattle({ skill: 'diplomat', unsealed: true, hide: true });
|
||||
game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
|
||||
game = applyAction(game, 'd', { type: 'battlePlan', dial: 5, leader: 'guild-1' });
|
||||
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
|
||||
const decision = game.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
game.code = code;
|
||||
game.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(game), game.version, code);
|
||||
store.writes.length = 0;
|
||||
const destination = decision.destinations[0].location;
|
||||
const action = { type: 'decision', event: decision.event, destination, normal: 5, elite: 0 };
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>(resolve => { release = resolve; });
|
||||
store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await ready; };
|
||||
const results = await Promise.allSettled([
|
||||
store.rooms.act(code, seats[0], game.version, action, clock),
|
||||
store.restart().act(code, seats[0], game.version, action, clock),
|
||||
]);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(store.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const restored = await store.restart().readRoom(code);
|
||||
assert.equal(restored.version, game.version + 1);
|
||||
const owner = restored.players.find(player => player.id === 'a')!;
|
||||
assert.equal(owner.forces[destination], 5);
|
||||
assert.equal(owner.forces['arrakeen:10'], undefined);
|
||||
assert.equal(owner.tanks, 0);
|
||||
assert.equal(restored.battle, null);
|
||||
assert.equal(restored.decision, null);
|
||||
assert.equal(restored.phase, 7);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user