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,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { HomeworldShipment } from '../components/homeworld-shipment';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
|
||||
/** Personalized public view: the Atreides player has an actual physical
|
||||
* foreign garrison. Native and foreign pools are projected using the same
|
||||
* Homeworld helpers as the existing shipment-control fixture. */
|
||||
function fixture(advanced: boolean): GameView {
|
||||
const context = { advanced, players: [
|
||||
{ id: 'a', faction: 'atreides' as const, reserves: 17, eliteReserves: 0, ally: null },
|
||||
{ id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null },
|
||||
] };
|
||||
const custody: HomeworldCustody = { salusa: null,
|
||||
visitors: { 'homeworld:guild': { a: { normal: 3, elite: 0 } } } };
|
||||
const populations = homeworldPopulations(context, custody);
|
||||
return {
|
||||
me: 'a', active: 'a', advanced, status: 'playing', phase: 5,
|
||||
homeworldShipment: { event: 'shipment:1:a', blocked: null },
|
||||
aid: { available: 0, pledged: 0 },
|
||||
players: context.players.map(player => ({ ...player, name: player.faction,
|
||||
spice: player.id === 'a' ? 10 : undefined, shipped: false,
|
||||
elites: { reserves: player.eliteReserves } })),
|
||||
homeworlds: { worlds: homeworldForceGroups(context, custody).map(world => ({
|
||||
...world, ...populations.find(row => row.location === world.id)!,
|
||||
})) },
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(game: GameView): string {
|
||||
return renderToStaticMarkup(createElement(HomeworldShipment, { game, busy: false, act() {} }));
|
||||
}
|
||||
function destinationOptions(html: string): string {
|
||||
const options = /<select aria-label="Destination Homeworld"[^>]*>([\s\S]*?)<\/select>/.exec(html);
|
||||
assert.ok(options, 'The native Homeworld shipment destination selector is present');
|
||||
return options[1];
|
||||
}
|
||||
|
||||
void test('Advanced personal shipment exposes the own Homeworld, source distinction and price', () => {
|
||||
const game = fixture(true), before = structuredClone(game), html = markup(game);
|
||||
const options = destinationOptions(html);
|
||||
assert.match(options, /value="homeworld:atreides"[^>]*>Caladan · return to your Homeworld/);
|
||||
assert.match(options, /value="homeworld:guild"/);
|
||||
assert.match(html, /You may return a foreign garrison to your own Homeworld/);
|
||||
assert.match(html, /Native reserves and foreign garrisons have separate source pools/);
|
||||
assert.match(html, /Pay one spice per force/);
|
||||
const quote = homeworldShipmentChoice(game, 'homeworld:atreides',
|
||||
{ 'homeworld:guild': { normal: 2, elite: 0 } });
|
||||
assert.equal(quote.blocked, null);
|
||||
assert.equal(quote.cost, 2);
|
||||
assert.equal(quote.action?.destination, 'homeworld:atreides');
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('Basic personal shipment retains the explicit native destination restriction', () => {
|
||||
const game = fixture(false), html = markup(game);
|
||||
const options = destinationOptions(html);
|
||||
assert.doesNotMatch(options, /value="homeworld:atreides"/);
|
||||
assert.match(options, /value="homeworld:guild"/);
|
||||
assert.match(html, /Your own Homeworld cannot be a destination/);
|
||||
const quote = homeworldShipmentChoice(game, 'homeworld:atreides',
|
||||
{ 'homeworld:guild': { normal: 2, elite: 0 } });
|
||||
assert.equal(quote.action, null);
|
||||
assert.match(quote.blocked ?? '', /Basic play.*own Homeworld/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -105,13 +105,13 @@ void test('Homeworld public shipment quote preserves exact native and foreign so
|
||||
assert.deepEqual(view, before);
|
||||
});
|
||||
|
||||
void test('public shipment options reject own, allied, depleted, mixed foreign, stale and unfunded orders', () => {
|
||||
void test('public shipment options allow Advanced native return and reject allied, depleted, mixed foreign, stale and unfunded orders', () => {
|
||||
const { view } = fixture();
|
||||
const good = { 'homeworld:emperor': { normal: 2, elite: 0 } };
|
||||
assert.equal(
|
||||
homeworldShipmentChoice(view, 'homeworld:emperor:salusa', good).action,
|
||||
null,
|
||||
);
|
||||
const nativeReturn = homeworldShipmentChoice(view, 'homeworld:emperor:salusa', good);
|
||||
assert.equal(nativeReturn.action?.destination, 'homeworld:emperor:salusa');
|
||||
assert.equal(nativeReturn.cost, 2);
|
||||
assert.equal(homeworldShipmentChoice(view, 'homeworld:emperor', good).action, null);
|
||||
assert.equal(
|
||||
homeworldShipmentChoice(view, 'homeworld:atreides', {
|
||||
'homeworld:guild': { normal: 5, elite: 0 },
|
||||
@@ -439,7 +439,7 @@ void test('Guild return controls and policy preserve foreign source custody and
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
assert.match(html, /Guild may also return a foreign garrison to Junction/);
|
||||
assert.match(html, /You may return a foreign garrison to your own Homeworld/);
|
||||
assert.match(html, /Junction · return to your Homeworld/);
|
||||
assert.match(html, /Guild pays one spice per two forces, rounded up/);
|
||||
assert.doesNotMatch(html, /Your own Homeworld cannot/);
|
||||
|
||||
@@ -248,14 +248,34 @@ void test('Advanced Emperor may combine its two native origins and retains preci
|
||||
);
|
||||
});
|
||||
|
||||
void test('own and mutual ally destinations are rejected, including either Emperor world', () => {
|
||||
void test('Advanced may return an actual foreign garrison to either native Emperor World at the normal rate', () => {
|
||||
const { context, custody } = fixture();
|
||||
for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa'])
|
||||
rejected(context, custody, {
|
||||
...invasion,
|
||||
destination,
|
||||
sources: { 'homeworld:atreides': { normal: 1, elite: 0 } },
|
||||
for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa']) {
|
||||
const before = structuredClone({ context, custody });
|
||||
const result = quote(context, custody, {
|
||||
player: 'e', destination,
|
||||
sources: { 'homeworld:atreides': { normal: 4, elite: 1 } },
|
||||
});
|
||||
assert.equal(result.amount, 5);
|
||||
assert.equal(result.cost, 5);
|
||||
assert.equal(result.players[0].reserves, 20);
|
||||
assert.equal(result.players[0].eliteReserves, 5);
|
||||
assert.equal(result.state.visitors['homeworld:atreides'], undefined);
|
||||
const next = { ...context, players: result.players.map(p => ({ ...p, ally: null })) };
|
||||
assert.deepEqual(totals(next, result.state), totals(context, custody));
|
||||
assert.deepEqual({ context, custody }, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic preserves the native return restriction and all modes reject a mutual ally destination', () => {
|
||||
const { context, custody } = fixture();
|
||||
const basic = { ...context, advanced: false };
|
||||
const basicCustody = { ...custody, salusa: null };
|
||||
rejected(basic, basicCustody, {
|
||||
...invasion,
|
||||
destination: 'homeworld:emperor',
|
||||
sources: { 'homeworld:atreides': { normal: 1, elite: 0 } },
|
||||
});
|
||||
context.players[0].ally = 'a';
|
||||
context.players[1].ally = 'e';
|
||||
rejected(context, custody, {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { takeHarassCard } from './fixture-harass-withdraw';
|
||||
import {
|
||||
createReinforcementsHomeworldFixture,
|
||||
reinforcementsHomeworldPool as pool,
|
||||
reinforcementsHomeworldPlayer as player,
|
||||
REINFORCEMENTS_KAITAIN as K,
|
||||
REINFORCEMENTS_SALUSA as S,
|
||||
REINFORCEMENTS_CARD,
|
||||
} from './fixture-reinforcements-homeworld';
|
||||
|
||||
/** The original native setup moves one actual ordinary counter to Salusa.
|
||||
* Its remaining native troops, the real invading force and all cards are
|
||||
* conserved; choosing the special reserve fee leaves two extra normal
|
||||
* counters on the native battlefield compared with the legacy fee. */
|
||||
void test('a named Reinforcements promise finds a necessary special-reserve Homeworld fee', () => {
|
||||
const f = createReinforcementsHomeworldFixture({
|
||||
advanced: true, role: 'native', nativeTransfer: 1, slot: 'weapon',
|
||||
});
|
||||
const owner = player(f.game, f.owner);
|
||||
assert.deepEqual(pool(f.game, f.owner, K), { normal: 14, elite: 0 });
|
||||
assert.deepEqual(pool(f.game, f.owner, S), { normal: 1, elite: 5 });
|
||||
const support = Math.min(owner.spice, 14);
|
||||
const wantedDial = (14 + support) / 2;
|
||||
const legacyMax = (12 + Math.min(owner.spice, 12)) / 2;
|
||||
assert.ok(wantedDial > legacyMax, 'The legal plan must require preserving normal counters at Kaitain');
|
||||
const mixed = {
|
||||
[K]: { normal: 0, elite: 0 },
|
||||
[S]: { normal: 1, elite: 2 },
|
||||
};
|
||||
const manual = { ...f.planActions[0].action, type: 'battlePlan',
|
||||
weapon: REINFORCEMENTS_CARD, defense: null, dial: wantedDial, support,
|
||||
reinforcementsSources: mixed, reinforcementsElite: 2 };
|
||||
assert.doesNotThrow(() => applyAction(f.game, f.owner, manual),
|
||||
'The actual native plan establishes the chosen physical fee is legal');
|
||||
|
||||
let game = structuredClone(f.game);
|
||||
const truth = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(truth);
|
||||
takeHarassCard(game, f.observer, truth.id);
|
||||
game = applyAction(game, f.observer, { type: 'card', card: truth.id });
|
||||
while (game.truthtrance!.stage === 'priority') {
|
||||
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id;
|
||||
game = applyAction(game, actor, { type: 'truthPass' });
|
||||
}
|
||||
game = applyAction(game, f.observer, { type: 'truthAsk', question: {
|
||||
kind: 'battlePlan', target: f.owner, claim: { kind: 'and', terms: [
|
||||
{ kind: 'weapon', name: 'Reinforcements' },
|
||||
{ kind: 'dial', compare: 'eq', value: wantedDial },
|
||||
] },
|
||||
} });
|
||||
assert.ok(viewGame(game, f.owner).truthBattleAnswers!.includes('yes'),
|
||||
'The real special-reserve plan supports a truthful Yes');
|
||||
game = applyAction(game, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const witness = viewGame(game, f.owner).battle!.compliantPlan; assert.ok(witness);
|
||||
assert.equal(witness.dial, wantedDial);
|
||||
assert.ok((witness.reinforcementsElite ?? 0) >= 2);
|
||||
assert.equal(Object.values(witness.reinforcementsSources!).reduce((sum, group) => sum + group.elite, 0),
|
||||
witness.reinforcementsElite);
|
||||
const sealed = applyAction(game, f.owner, { type: 'battlePlan', ...witness });
|
||||
assert.equal(sealed.battle!.plans[f.owner].dial, wantedDial);
|
||||
assert.equal(sealed.battle!.plans[f.owner].reinforcementsElite, witness.reinforcementsElite);
|
||||
});
|
||||
Reference in new issue
Block a user