Support Advanced native Homeworld return
Verify and publish checkpoint container / container (push) Successful in 11m10s
Verify and publish checkpoint container / container (push) Successful in 11m10s
This commit is contained in:
1 parent
1cf90f1328
commit
89569e92a1
14 files changed
+429
-39
No files matched your search
@@ -0,0 +1,176 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user