177 lines
9.4 KiB
TypeScript
177 lines
9.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, createGame, initializeHomeworldGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { homeworldShipmentActions, homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
|
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import { homeworldForceGroups } from '../game/homeworld-custody';
|
|
|
|
const owner = (g: Game) => g.players.find(p => p.id === 'emperor')!;
|
|
const groups = (g: Game) => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!);
|
|
const forces = (g: Game, id: string) => groups(g).find(world => world.id === id)!.forces.emperor ?? {normal:0, elite:0};
|
|
const total = (g: Game) => {
|
|
const p = owner(g);
|
|
return p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0) +
|
|
groups(g).filter(world => world.native !== p.id).reduce((sum, world) => {
|
|
const f = world.forces[p.id]; return sum + (f ? f.normal + f.elite : 0);
|
|
}, 0);
|
|
};
|
|
/** Advance genuine Homeworld setup; only the Shipment and Movement opportunity
|
|
* is staged. The foreign garrison is created through an actual shipment. */
|
|
function setup(advanced: boolean, actor: 'emperor' | 'guild' = 'emperor') {
|
|
let game = createGame('RETURNWORLD', newPlayer(actor, actor, actor), advanced);
|
|
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
|
|
joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
|
|
game = applyAction(game, actor, {type:'homeworlds', enabled:true});
|
|
for (const seat of game.players) game = applyAction(game, seat.id, {type:'ready'});
|
|
game = initializeHomeworldGameForAudit(game);
|
|
for (let step = 0; game.status === 'setup' && step < 80; step++) {
|
|
let next: Game | undefined;
|
|
for (const seat of game.players) {
|
|
const view = viewGame(game, seat.id);
|
|
view.players.find(p => p.id === seat.id)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
if (action) { next = applyAction(game, seat.id, action); break; }
|
|
}
|
|
assert.ok(next, 'Native setup needs a legal next action'); game = next;
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
|
const stageOpportunity = (state: Game) => {
|
|
Object.assign(state, {phase:5, phaseOpening:null, response:null, decision:null,
|
|
active:actor, order:state.players.map(p=>p.id),
|
|
movementRemaining:state.players.map(p=>p.id), ready:[], storm:18});
|
|
for (const p of state.players) {p.shipped=false; p.moved=0;}
|
|
};
|
|
stageOpportunity(game);
|
|
const initialReserves = game.players.find(p => p.id === actor)!.reserves;
|
|
const first = homeworldShipmentChoice(viewGame(game,actor),'homeworld:atreides',
|
|
{[`homeworld:${actor}`]:{normal:4,elite:0}});
|
|
assert.ok(first.action, first.blocked ?? 'The initial invasion must be legal');
|
|
game = applyAction(game,actor,first.action);
|
|
assert.deepEqual(groups(game).find(w => w.id === 'homeworld:atreides')!.forces[actor],{normal:4,elite:0});
|
|
assert.equal(game.players.find(p => p.id === actor)!.reserves,initialReserves - 4);
|
|
if (actor === 'emperor') assert.equal(total(game),20);
|
|
game.turn++;
|
|
stageOpportunity(game);
|
|
homeworldGameIntegrity(game);
|
|
return game;
|
|
}
|
|
|
|
for (const advanced of [false,true]) void test(`${advanced ? 'Advanced' : 'Basic'} foreign Homeworld return to native reserves`, () => {
|
|
const before = setup(advanced);
|
|
const source = {'homeworld:atreides':{normal:2,elite:0}};
|
|
const raw: Action = {type:'homeworldShip', event:viewGame(before,'emperor').homeworldShipment!.event,
|
|
destination:'homeworld:emperor', sources:source};
|
|
const offer = homeworldShipmentChoice(viewGame(before,'emperor'), 'homeworld:emperor', source);
|
|
if (!advanced) {
|
|
assert.equal(offer.action,null);
|
|
const snapshot = structuredClone(before);
|
|
assert.throws(() => applyAction(before,'emperor',raw));
|
|
assert.deepEqual(before,snapshot);
|
|
return;
|
|
}
|
|
assert.ok(offer.action, offer.blocked ?? 'Advanced return should be offered');
|
|
assert.equal(offer.cost,2);
|
|
const original = structuredClone(before);
|
|
let after = applyAction(before,'emperor',offer.action);
|
|
assert.deepEqual(before,original);
|
|
assert.equal(owner(after).spice,owner(before).spice-2);
|
|
assert.equal(owner(after).reserves,owner(before).reserves+2);
|
|
assert.deepEqual(forces(after,'homeworld:atreides'),{normal:2,elite:0});
|
|
assert.deepEqual(forces(after,'homeworld:emperor'),{normal:13,elite:0});
|
|
assert.equal(owner(after).shipped,true);
|
|
assert.equal(owner(after).moved,0);
|
|
assert.equal(total(after),20);
|
|
homeworldGameIntegrity(after);
|
|
assert.throws(() => applyAction(after,'emperor',raw), 'The used/stale shipment cannot be replayed');
|
|
after = applyAction(after,'emperor',{type:'endMovement'});
|
|
assert.notEqual(after.active,'emperor');
|
|
homeworldGameIntegrity(after);
|
|
});
|
|
|
|
void test('all bot profiles retain legal movement participation in the Advanced return opportunity', () => {
|
|
const game = setup(true);
|
|
for (const [level, difficulty] of DIFFICULTIES.entries()) {
|
|
const view = viewGame(game,'emperor'); view.players.find(p=>p.id==='emperor')!.bot=difficulty;
|
|
const action = botActions(view)[0]; assert.ok(action, `${difficulty} must have a legal action`);
|
|
assert.doesNotThrow(() => applyAction(game,'emperor',action));
|
|
const returned = homeworldShipmentActions(view,level).find(candidate =>
|
|
candidate.type === 'homeworldShip' && candidate.destination === 'homeworld:emperor');
|
|
assert.ok(returned, `${difficulty} must offer a legal native return`);
|
|
assert.doesNotThrow(() => applyAction(game,'emperor',returned));
|
|
}
|
|
});
|
|
|
|
for (const advanced of [false,true]) for (const canceled of [false,true])
|
|
void test(`${advanced ? 'Advanced' : 'Basic'} Guild native return ${canceled ? 'with canceled transport' : 'with native transport'}`, () => {
|
|
const game = setup(advanced,'guild');
|
|
if (canceled) game.guildTransportBlocked = {turn:game.turn,player:'guild',opportunity:'ordinary'};
|
|
const view = viewGame(game,'guild');
|
|
const source = {'homeworld:atreides':{normal:2,elite:0}};
|
|
const offer = homeworldShipmentChoice(view, 'homeworld:guild', source);
|
|
const raw: Action = {type:'homeworldShip',event:view.homeworldShipment!.event,
|
|
destination:'homeworld:guild',sources:source};
|
|
if (canceled && !advanced) {
|
|
assert.equal(offer.action,null);
|
|
const snapshot=structuredClone(game);
|
|
assert.throws(() => applyAction(game,'guild',raw));
|
|
assert.deepEqual(game,snapshot);
|
|
return;
|
|
}
|
|
assert.ok(offer.action,offer.blocked ?? 'Guild return should be offered');
|
|
assert.equal(offer.cost,1, 'Native Guild rate remains distinct from transport permission');
|
|
const before = structuredClone(game);
|
|
const result = applyAction(game,'guild',offer.action);
|
|
assert.deepEqual(game,before);
|
|
const guild = result.players.find(p=>p.id==='guild')!;
|
|
assert.equal(guild.reserves,game.players.find(p=>p.id==='guild')!.reserves+2);
|
|
assert.equal(guild.spice,game.players.find(p=>p.id==='guild')!.spice-1);
|
|
assert.deepEqual(groups(result).find(w=>w.id==='homeworld:atreides')!.forces.guild,{normal:2,elite:0});
|
|
assert.equal(guild.shipped,true);
|
|
homeworldGameIntegrity(result);
|
|
});
|
|
|
|
void test('an already-open native Guild Homeworld return response survives the Advanced cutover', () => {
|
|
// The response is obtained through the native Basic declaration, then staged
|
|
// into an otherwise real Advanced game. Rebind only its source event to the
|
|
// Advanced context; no counters, spice or physical cards are manufactured.
|
|
const withKarama = (g: Game) => {
|
|
const card = g.deck.find(c => c.effect === 'karama'); assert.ok(card);
|
|
g.deck.splice(g.deck.indexOf(card),1);
|
|
g.players.find(p => p.id === 'atreides')!.hand.push(card);
|
|
return card.id;
|
|
};
|
|
const basic = setup(false,'guild');
|
|
withKarama(basic);
|
|
const offer = homeworldShipmentChoice(viewGame(basic,'guild'), 'homeworld:guild',
|
|
{'homeworld:atreides':{normal:2,elite:0}});
|
|
assert.ok(offer.action,offer.blocked ?? 'The original native return must be available');
|
|
const declared = applyAction(basic,'guild',offer.action);
|
|
assert.equal(declared.response?.kind,'guildTransportPermission');
|
|
assert.ok(declared.pendingHomeworldShipment?.guildTransportEvent);
|
|
|
|
const advanced = setup(true,'guild');
|
|
withKarama(advanced);
|
|
const event = viewGame(advanced,'guild').homeworldShipment!.event;
|
|
advanced.pendingHomeworldShipment = structuredClone(declared.pendingHomeworldShipment!);
|
|
advanced.pendingHomeworldShipment.event = event;
|
|
advanced.response = structuredClone(declared.response!);
|
|
let result = JSON.parse(JSON.stringify(advanced)) as Game;
|
|
for (let n=0; result.response && n<20; n++) {
|
|
const seat = result.players.find(p => !result.response!.passed.includes(p.id));
|
|
assert.ok(seat, 'The saved native response must be passable');
|
|
result = applyAction(result,seat.id,{type:'passResponse'});
|
|
}
|
|
assert.equal(result.response,null);
|
|
assert.equal(result.pendingHomeworldShipment,null);
|
|
assert.equal(result.players.find(p=>p.id==='guild')!.reserves,
|
|
advanced.players.find(p=>p.id==='guild')!.reserves+2);
|
|
assert.equal(result.players.find(p=>p.id==='guild')!.spice,
|
|
advanced.players.find(p=>p.id==='guild')!.spice-1);
|
|
assert.deepEqual(groups(result).find(w=>w.id==='homeworld:atreides')!.forces.guild,{normal:2,elite:0});
|
|
homeworldGameIntegrity(result);
|
|
});
|