447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
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,
|
|
homeworldShipmentActions,
|
|
} from '../game/homeworld-shipment-options';
|
|
import {
|
|
homeworldForceGroups,
|
|
type HomeworldCustody,
|
|
} from '../game/homeworld-custody';
|
|
import { homeworldPopulations } from '../game/homeworld-population';
|
|
import { quoteHomeworldShipment } from '../game/homeworld-shipment';
|
|
|
|
// Narrow public-view fixtures verify presentation and the shared option quote.
|
|
// Runtime dispatcher, timing and conservation are covered by engine tests.
|
|
function fixture() {
|
|
const context = {
|
|
advanced: true,
|
|
players: [
|
|
{
|
|
id: 'e',
|
|
faction: 'emperor' as const,
|
|
reserves: 12,
|
|
eliteReserves: 3,
|
|
ally: null as string | null,
|
|
},
|
|
{
|
|
id: 'a',
|
|
faction: 'atreides' as const,
|
|
reserves: 1,
|
|
eliteReserves: 0,
|
|
ally: null as string | null,
|
|
},
|
|
{
|
|
id: 'g',
|
|
faction: 'guild' as const,
|
|
reserves: 10,
|
|
eliteReserves: 0,
|
|
ally: null as string | null,
|
|
},
|
|
],
|
|
};
|
|
const custody: HomeworldCustody = {
|
|
salusa: { normal: 0, elite: 2 },
|
|
visitors: { 'homeworld:guild': { e: { normal: 4, elite: 1 } } },
|
|
};
|
|
const populations = homeworldPopulations(context, custody);
|
|
const view = {
|
|
me: 'e',
|
|
active: 'e',
|
|
advanced: true,
|
|
status: 'playing',
|
|
phase: 5,
|
|
homeworldShipment: { event: 'shipment:1:e', blocked: null },
|
|
aid: { available: 0, pledged: 0 },
|
|
players: context.players.map((player) => ({
|
|
...player,
|
|
name: player.faction,
|
|
spice: player.id === 'e' ? 12 : undefined,
|
|
shipped: false,
|
|
elites: { reserves: player.eliteReserves },
|
|
})),
|
|
homeworlds: {
|
|
worlds: homeworldForceGroups(context, custody).map((world) => ({
|
|
...world,
|
|
...populations.find((population) => population.location === world.id)!,
|
|
})),
|
|
},
|
|
} as unknown as GameView;
|
|
return { context, custody, view };
|
|
}
|
|
|
|
void test('Homeworld public shipment quote preserves exact native and foreign source pools and Emperor combination', () => {
|
|
const { view, context, custody } = fixture();
|
|
const before = structuredClone(view);
|
|
for (const sources of [
|
|
{ 'homeworld:emperor': { normal: 2, elite: 1 } },
|
|
{ 'homeworld:guild': { normal: 2, elite: 1 } },
|
|
{
|
|
'homeworld:emperor': { normal: 2, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
|
|
},
|
|
] as Record<string, { normal: number; elite: number }>[]) {
|
|
const selected = homeworldShipmentChoice(
|
|
view,
|
|
'homeworld:atreides',
|
|
sources,
|
|
);
|
|
const authoritative = quoteHomeworldShipment(context, custody, {
|
|
player: 'e',
|
|
destination: 'homeworld:atreides',
|
|
sources,
|
|
});
|
|
assert.ok(selected.action);
|
|
assert.equal(selected.cost, authoritative.cost);
|
|
assert.equal(selected.elite, authoritative.elite);
|
|
assert.equal(selected.action.event, 'shipment:1:e');
|
|
assert.deepEqual(selected.action.sources, sources);
|
|
assert.equal(selected.ownPayment, selected.cost);
|
|
}
|
|
assert.deepEqual(view, before);
|
|
});
|
|
|
|
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 } };
|
|
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 },
|
|
}).action,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
homeworldShipmentChoice(view, 'homeworld:atreides', {
|
|
...good,
|
|
'homeworld:guild': { normal: 1, elite: 0 },
|
|
}).action,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
homeworldShipmentChoice(view, 'homeworld:atreides', {}, 0).action,
|
|
null,
|
|
);
|
|
view.players[0].ally = 'a';
|
|
view.players[1].ally = 'e';
|
|
assert.equal(
|
|
homeworldShipmentChoice(view, 'homeworld:atreides', good).action,
|
|
null,
|
|
);
|
|
view.players[0].ally = 'g';
|
|
view.players[1].ally = null;
|
|
view.players[2].ally = 'e';
|
|
view.players[0].spice = 1;
|
|
view.aid.available = 2;
|
|
assert.equal(
|
|
homeworldShipmentChoice(view, 'homeworld:atreides', good).action,
|
|
null,
|
|
);
|
|
assert.ok(
|
|
homeworldShipmentChoice(view, 'homeworld:atreides', good, 1).action,
|
|
);
|
|
for (const pledge of [-1, 0.5, 3, NaN])
|
|
assert.equal(
|
|
homeworldShipmentChoice(view, 'homeworld:atreides', good, pledge).action,
|
|
null,
|
|
);
|
|
view.homeworldShipment!.blocked = 'Resolve the current response.';
|
|
assert.equal(
|
|
homeworldShipmentChoice(view, 'homeworld:atreides', good, 1).blocked,
|
|
'Resolve the current response.',
|
|
);
|
|
view.homeworldShipment!.blocked = null;
|
|
for (const patch of [
|
|
{ active: 'a' },
|
|
{ phase: 6 },
|
|
{ homeworldShipment: null },
|
|
{ status: 'setup' },
|
|
])
|
|
assert.equal(
|
|
homeworldShipmentChoice(
|
|
{ ...view, ...patch } as GameView,
|
|
'homeworld:atreides',
|
|
good,
|
|
1,
|
|
).action,
|
|
null,
|
|
);
|
|
view.players[0].shipped = true;
|
|
assert.equal(
|
|
homeworldShipmentChoice(view, 'homeworld:atreides', good, 1).action,
|
|
null,
|
|
);
|
|
});
|
|
|
|
void test('Guild rate belongs to its own Homeworld shipment and allies contribute only the selected pledge', () => {
|
|
const { view } = fixture();
|
|
view.players[0].ally = 'g';
|
|
view.players[2].ally = 'e';
|
|
view.aid.available = 3;
|
|
const sources = { 'homeworld:emperor': { normal: 3, elite: 0 } };
|
|
const normal = homeworldShipmentChoice(
|
|
view,
|
|
'homeworld:atreides',
|
|
sources,
|
|
2,
|
|
);
|
|
assert.equal(normal.cost, 3);
|
|
assert.equal(normal.ownPayment, 1);
|
|
view.me = 'g';
|
|
view.active = 'g';
|
|
view.players[2].spice = 10;
|
|
const guild = homeworldShipmentChoice(view, 'homeworld:atreides', {
|
|
'homeworld:guild': { normal: 3, elite: 0 },
|
|
});
|
|
assert.equal(guild.cost, 2);
|
|
assert.ok(guild.action);
|
|
});
|
|
|
|
void test('all four public invasion policies select legal affordable candidates and retain a native defense', () => {
|
|
const { view } = fixture();
|
|
const before = structuredClone(view);
|
|
const signatures: string[] = [];
|
|
for (let level = 0; level < 4; level++) {
|
|
const actions = homeworldShipmentActions(view, level);
|
|
assert.ok(actions.length, `level ${level} has an invasion`);
|
|
signatures.push(JSON.stringify(actions));
|
|
for (const action of actions) {
|
|
const sources = action.sources as Record<
|
|
string,
|
|
{ normal: number; elite: number }
|
|
>;
|
|
const selected = homeworldShipmentChoice(
|
|
view,
|
|
String(action.destination),
|
|
sources,
|
|
Number(action.allyPayment),
|
|
);
|
|
assert.deepEqual(selected.action, action);
|
|
for (const [id, forces] of Object.entries(sources)) {
|
|
const world = view.homeworlds!.worlds!.find(
|
|
(candidate) => candidate.id === id,
|
|
)!;
|
|
if (world.native === view.me)
|
|
assert.ok(
|
|
world.forces[view.me].normal +
|
|
world.forces[view.me].elite -
|
|
forces.normal -
|
|
forces.elite >=
|
|
3,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
assert.ok(new Set(signatures).size >= 3);
|
|
assert.deepEqual(view, before);
|
|
view.homeworldShipment!.blocked = 'Paused';
|
|
for (let level = 0; level < 4; level++)
|
|
assert.deepEqual(homeworldShipmentActions(view, level), []);
|
|
});
|
|
|
|
void test('shipment quoting and invasion policies never read private rival resources or randomness', () => {
|
|
const { view } = fixture();
|
|
const sources = { 'homeworld:emperor': { normal: 3, elite: 0 } };
|
|
const expected = homeworldShipmentChoice(view, 'homeworld:atreides', sources);
|
|
const actions = homeworldShipmentActions(view, 3);
|
|
for (const object of [view, ...view.players])
|
|
for (const key of ['hand', 'deck', 'rng', 'traitors', 'plans'])
|
|
Object.defineProperty(object, key, {
|
|
get() {
|
|
throw new Error('Private field read');
|
|
},
|
|
});
|
|
for (const player of view.players.filter(
|
|
(candidate) => candidate.id !== view.me,
|
|
))
|
|
Object.defineProperty(player, 'spice', {
|
|
get() {
|
|
throw new Error('Rival spice read');
|
|
},
|
|
});
|
|
const random = Math.random;
|
|
Math.random = () => {
|
|
throw new Error('Random state read');
|
|
};
|
|
try {
|
|
assert.deepEqual(
|
|
homeworldShipmentChoice(view, 'homeworld:atreides', sources),
|
|
expected,
|
|
);
|
|
assert.deepEqual(homeworldShipmentActions(view, 3), actions);
|
|
} finally {
|
|
Math.random = random;
|
|
}
|
|
});
|
|
|
|
void test('Homeworld shipment controls show typed real-world origins, prices and pledges without board sectors', () => {
|
|
const { view } = fixture();
|
|
view.players[0].ally = 'g';
|
|
view.players[2].ally = 'e';
|
|
view.aid.available = 3;
|
|
const before = structuredClone(view);
|
|
const html = renderToStaticMarkup(
|
|
createElement(HomeworldShipment, {
|
|
game: view,
|
|
act: () => {},
|
|
busy: false,
|
|
}),
|
|
);
|
|
assert.match(html, /Kaitain · native reserves/);
|
|
assert.match(html, /Junction · foreign garrison/);
|
|
assert.match(html, /Kaitain \+ Salusa Secundus · combined native shipment/);
|
|
assert.match(html, /Sardaukar from Kaitain/);
|
|
assert.match(html, /Pay one spice per force/);
|
|
assert.match(html, /Allied spice contribution · 3 pledged/);
|
|
assert.match(html, /you[r]? available spice: 12/i);
|
|
assert.match(html, /<button[^>]*disabled[^>]*>Ship to Caladan<\/button>/);
|
|
assert.doesNotMatch(html, /[Ss]ector|undefined|NaN/);
|
|
assert.deepEqual(view, before);
|
|
});
|
|
|
|
void test('Advanced Emperor invasion policy can combine native origins while retaining Kaitain defense and Salusa high population', () => {
|
|
const { view } = fixture();
|
|
const emperor = view.players[0];
|
|
emperor.reserves = 9;
|
|
emperor.elites!.reserves = 5;
|
|
view.homeworlds!.worlds!.find(
|
|
(world) => world.id === 'homeworld:emperor',
|
|
)!.forces.e = { normal: 4, elite: 0 };
|
|
view.homeworlds!.worlds!.find(
|
|
(world) => world.id === 'homeworld:emperor:salusa',
|
|
)!.forces.e = { normal: 0, elite: 5 };
|
|
view.homeworlds!.worlds!.find(
|
|
(world) => world.id === 'homeworld:guild',
|
|
)!.forces.e = { normal: 1, elite: 0 };
|
|
const actions = homeworldShipmentActions(view, 3);
|
|
const combined = actions.find(
|
|
(action) => Object.keys(action.sources as object).length === 2,
|
|
);
|
|
assert.ok(combined);
|
|
assert.deepEqual(combined.sources, {
|
|
'homeworld:emperor:salusa': { normal: 0, elite: 3 },
|
|
'homeworld:emperor': { normal: 1, elite: 0 },
|
|
});
|
|
assert.ok(
|
|
homeworldShipmentChoice(
|
|
view,
|
|
String(combined.destination),
|
|
combined.sources as Record<string, { normal: number; elite: number }>,
|
|
Number(combined.allyPayment),
|
|
).action,
|
|
);
|
|
});
|
|
|
|
void test('Nexus AI acceptance honors the projected Homeworld alliance restriction at every level', async () => {
|
|
const {
|
|
createGame,
|
|
newPlayer,
|
|
joinGame,
|
|
applyAction,
|
|
initializeHomeworldGameForAudit,
|
|
viewGame,
|
|
} = await import('../game/engine');
|
|
const { botActions } = await import('../game/bots');
|
|
let game = createGame(
|
|
'HOMEWORLDALLIANCECONTROLS',
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
false,
|
|
);
|
|
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
|
game = applyAction(game, 'e', { type: 'homeworlds', enabled: true });
|
|
for (const player of game.players)
|
|
game = applyAction(game, player.id, { type: 'ready' });
|
|
game = initializeHomeworldGameForAudit(game);
|
|
while (game.setupStage === 'traitors') {
|
|
const player = game.players.find(
|
|
(candidate) => candidate.traitorChoices.length,
|
|
)!;
|
|
game = applyAction(game, player.id, {
|
|
type: 'traitor',
|
|
leader: player.traitorChoices[0],
|
|
});
|
|
}
|
|
game.deck.push(...game.players[0].hand.splice(0));
|
|
Object.assign(game, {
|
|
phase: 1,
|
|
nexus: true,
|
|
phaseOpening: null,
|
|
spiceWindow: null,
|
|
response: null,
|
|
decision: null,
|
|
ready: [],
|
|
allianceOffers: { a: 'e' },
|
|
});
|
|
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
|
const view = viewGame(game, 'e');
|
|
view.players[0].bot = level;
|
|
view.homeworldAllianceBlocks = {};
|
|
const permitted = botActions(view);
|
|
if (level !== 'Easy')
|
|
assert.ok(
|
|
permitted.some(
|
|
(action) => action.type === 'alliance' && action.target === 'a',
|
|
),
|
|
);
|
|
view.homeworldAllianceBlocks = {
|
|
a: 'Foreign forces on your Homeworld prevent this alliance.',
|
|
};
|
|
const before = structuredClone(view);
|
|
const blocked = botActions(view);
|
|
assert.ok(blocked.every((action) => action.type !== 'alliance'));
|
|
assert.deepEqual(view, before);
|
|
}
|
|
});
|
|
|
|
void test('Guild return controls and policy preserve foreign source custody and restore Junction at the printed rate', () => {
|
|
const { view } = fixture();
|
|
view.me = 'g';
|
|
view.active = 'g';
|
|
view.players[2].spice = 10;
|
|
view.players[2].reserves = 3;
|
|
const junction = view.homeworlds!.worlds!.find(
|
|
(world) => world.id === 'homeworld:guild',
|
|
)!;
|
|
junction.forces.g = { normal: 3, elite: 0 };
|
|
junction.side = 'low';
|
|
junction.population = 3;
|
|
view.homeworlds!.worlds!.find(
|
|
(world) => world.id === 'homeworld:atreides',
|
|
)!.forces.g = { normal: 7, elite: 0 };
|
|
const selection = { 'homeworld:atreides': { normal: 5, elite: 0 } };
|
|
const quoted = homeworldShipmentChoice(view, 'homeworld:guild', selection);
|
|
assert.equal(quoted.cost, 3);
|
|
assert.ok(quoted.action);
|
|
for (let level = 0; level < 4; level++) {
|
|
const returns = homeworldShipmentActions(view, level).filter(
|
|
(action) => action.destination === 'homeworld:guild',
|
|
);
|
|
assert.ok(returns.length);
|
|
assert.ok(
|
|
returns.every((action) =>
|
|
Object.keys(action.sources as object).every(
|
|
(id) => id !== 'homeworld:guild',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
const html = renderToStaticMarkup(
|
|
createElement(HomeworldShipment, {
|
|
game: view,
|
|
act: () => {},
|
|
busy: false,
|
|
}),
|
|
);
|
|
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/);
|
|
});
|