Open supported optional modules through normal lobby previews

This commit is contained in:
Quintine committed 2026-10-17 17:22:08 +11:00
1 parent b8ad2f011f
commit 210aa04811
17 files changed
+275 -78

No files matched your search

+24 -3
View File
@@ -121,6 +121,30 @@ void test('only the host can select a boolean rules mode, changes clear human re
completedSetup(act(ready(game), game.host, { type: 'start' }));
});
void test('incompatible selected modules reject the whole start instead of silently omitting independent cards', () => {
for (const option of ['nexusCards', 'discoveries']) {
let game = act(lobby(['atreides', 'emperor']), 'atreides',
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true });
game = act(game, game.host, { type: 'moduleOption', module: option, enabled: true });
game = ready(game);
reject(game, game.host, { type: 'start' });
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length));
}
});
void test('an Ecaz Homeworld Skills selection cannot start without supported combined Occupy battles', () => {
let game = createGame('ECAZLOBBYMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
game = act(game, game.host, { type: 'homeworlds', enabled: true });
game = act(game, game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
game = ready(game);
reject(game, game.host, preview);
assert.equal(game.status, 'lobby');
assert.equal(game.homeworlds!.custody, null);
assert.equal(game.leaderSkills, undefined);
});
void test('Advanced starts require the exact host opt-in and cannot dispatch audit initializers or hidden bypass options', () => {
const game = ready(lobby(BASE, true));
reject(game, game.host, { type: 'start' });
@@ -147,9 +171,6 @@ void test('the explicit preview keeps inconsistent rosters, modules and separate
...FACTIONS.filter(entry => entry.expansion !== 'base').map(entry => (game: Game) => {
game.players[1] = { ...newPlayer(game.players[1].id, entry.name, entry.id), ready: true };
}),
game => { game.homeworlds = { custody: null }; },
game => { game.nexusCards = { cards: null, phase: null }; },
game => { game.discoveryEnabled = true; },
game => { game.leaderSkills = createLeaderSkills(() => 0.25); },
game => { game.ecazTreachery = true; },
game => { game.moritaniAssassinatePreview = true; },
+30
View File
@@ -122,6 +122,36 @@ for (const ecazForces of [2, 4]) for (const lead of ['ecaz', 'ally'] as const)
assert.equal(game.phase, 7, 'Surviving allies coexist into original Collection without resettlement.');
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic ${lead} lead resolves a mandatory Hero discard before its variable-army casualties`, () => {
const fixture = basicEcazOccupyFixture({ ecazForces: 4 });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
const hero = stageEcazOccupyCard(game, fixture.opponent, card => card.kind === 'hero');
const before = structuredClone(game);
game = applyAction(game, actor, {
type: 'battlePlan', dial: 4, leader: leader(game, actor, true),
});
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, leader: hero });
while (game.battle) {
const view = viewGame(game, actor).battle!;
const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
assert.ok(voter, 'The real revealed battle must retain its Traitor voters.');
game = applyAction(game, voter, { type: 'traitorCall', call: false });
}
game = aftermath(game);
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(game.discard.filter(card => card.id === hero).length, 1);
assert.equal(seat(game, fixture.opponent).hand.some(card => card.id === hero), false);
for (const id of [fixture.ecaz, fixture.ally]) {
assert.equal(forces(game, fixture, id), 2);
assert.equal(seat(game, id).tanks - seat(before, id).tanks, 2);
}
assert.equal(forces(game, fixture, fixture.opponent), 0);
assert.equal(game.phase, 7);
});
for (const allyFaction of ['emperor', 'fremen'] as const) for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic ${allyFaction} ${lead} lead uses native ordinary inventory without Advanced elite strength`, () => {
const fixture = basicEcazOccupyFixture({ allyFaction, ecazForces: 4 });
@@ -70,9 +70,6 @@ void test('fresh E2 Discovery uses the original 35 ordinary cards, real cache, s
assert.ok(fixture.actions.some(step => step.action.type === 'richeseBid'));
assert.equal(Boolean(fixture.game.techTokens), tech);
custody(fixture.game);
const before = structuredClone(fixture.initial);
assert.throws(() => applyAction(fixture.initial, fixture.initial.host, { type: 'start' }));
assert.deepEqual(fixture.initial, before);
}
});
+1 -4
View File
@@ -45,7 +45,7 @@ function settle(state: Game): Game {
void test('fresh E1 Discovery uses original 47-card setup, Sandtrout, seven cards/eight tokens, HMS and native private deals', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const { game, initialized, lobby } = nativeTypedSetup({ advanced, tech });
const { game, initialized } = nativeTypedSetup({ advanced, tech });
assert.equal(game.discoveryEnabled, true);
assert.equal(game.leaderSkills, undefined);
assert.equal(game.strongholdCards, undefined);
@@ -63,9 +63,6 @@ void test('fresh E1 Discovery uses original 47-card setup, Sandtrout, seven card
assert.equal(game.techTokens!.axlotl.owner, 't');
assert.equal(game.techTokens!.production.owner, 'f');
} else assert.equal(game.techTokens, undefined);
const before = nativeTypedReload(lobby);
assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' }));
assert.deepEqual(lobby, before);
nativeTypedCustody(game);
}
});
-4
View File
@@ -205,10 +205,6 @@ for (const [advanced, seed] of [
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Discovery seed ${seed} completes through JSON bot batches with conserved pieces`, () =>
withEngineSeed(seed, () => {
const lobby = discoveryLobby(advanced);
const publicBefore = structuredClone(lobby);
assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' }));
assert.deepEqual(lobby, publicBefore);
let game = initializeDiscoveryGameForAudit(lobby);
game.players.forEach((player, index) => {
player.bot = DIFFICULTIES[index];
+2 -5
View File
@@ -37,12 +37,9 @@ function custody(g: Game) {
);
validateDiscoveryState(g.discoveries!);
}
void test('Discovery real setup deals seven spice cards and eight opaque tokens while normal starts remain gated', () => {
void test('Discovery real setup deals seven spice cards and eight opaque tokens', () => {
for (const advanced of [false, true]) {
const lobby = discoveryLobby(advanced),
before = structuredClone(lobby);
assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' }));
assert.deepEqual(lobby, before);
const lobby = discoveryLobby(advanced);
const g = initializeDiscoveryGameForAudit(lobby);
assert.equal(g.spiceDeck.length, spiceDeck().length + 7);
assert.equal(
+7 -4
View File
@@ -144,16 +144,19 @@ void test('Advanced Salusa low→high return does not retroactively rescore seal
g => g.turn === 2 && g.phase === 5 && g.active === f.owner && harassHomeworldArrakisClean(g));
assert.ok(viewGame(next, f.owner).homeworldMove, 'The native next movement control remains available');
const before = structuredClone(next);
next = applyAction(next, f.owner, { type: 'ship', territory: f.territory,
sector: Number(f.location.split(':').at(-1)), amount: 1, elite: 1, allyPayment: 0,
// The next real Storm can cover the original battle sector. Polar Sink is a
// legal storm-independent destination; the assertion here is native reserve use.
const destination = 'polar_sink:0';
next = applyAction(next, f.owner, { type: 'ship', territory: 'polar_sink',
sector: 0, amount: 1, elite: 1, allyPayment: 0,
homeworldSources: { 'homeworld:emperor:salusa': { normal: 0, elite: 1 } } });
next = advanceHarassHomeworldArrakis(next, harassHomeworldArrakisClean);
assert.equal(player(next, f.owner).reserves, player(before, f.owner).reserves - 1);
assert.equal(player(next, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves - 1);
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor'), pool(before, f.owner, 'homeworld:emperor'));
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 3 });
assert.equal(player(next, f.owner).forces[f.location], 1);
assert.equal(player(next, f.owner).elites!.forces[f.location], 1);
assert.equal(player(next, f.owner).forces[destination], 1);
assert.equal(player(next, f.owner).elites!.forces[destination], 1);
assert.equal(player(next, f.owner).tanks, player(before, f.owner).tanks);
assertHarassHomeworldArrakisCustody(next);
});
+2 -2
View File
@@ -207,7 +207,7 @@ function assertCardCustody(g: Game) {
assert.equal(g.ixSetupCards ?? null, null);
}
void test('Homeworld lobby choice belongs to the host, resets readiness, and preserves both public start gates', () => {
void test('Homeworld lobby choice belongs to the host, resets readiness, and opens the genuine Homeworld start', () => {
let g = lobby(['atreides', 'emperor'], false, false);
rejectUnchanged(g, 'emperor', { type: 'homeworlds', enabled: true });
rejectUnchanged(g, g.host, { type: 'homeworlds', enabled: 1 });
@@ -215,7 +215,7 @@ void test('Homeworld lobby choice belongs to the host, resets readiness, and pre
assert.deepEqual(g.homeworlds, { custody: null });
assert.ok(g.players.every((p) => !p.ready));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
rejectUnchanged(g, g.host, { type: 'start' });
assert.ok(applyAction(g, g.host, { type: 'start' }).homeworlds, 'A normal start keeps the selected Homeworld module');
const before = structuredClone(g);
assert.throws(() => initializeBaseGameForAudit(g));
assert.deepEqual(g, before);
+1 -2
View File
@@ -170,7 +170,7 @@ void test('orphaned/corrupt source receipts fail before projection, normalizatio
}
});
void test('initializer admits only explicit fresh CHOAM/Richese Nexus composition and does not bypass public starts', () => {
void test('initializer admits only explicit fresh CHOAM/Richese Nexus composition', () => {
function lobby() {
let g = createGame('RICHESEADMISSION',newPlayer('r','Richese','richese'),false,['choam']);
joinGame(g,newPlayer('c','CHOAM','choam'));
@@ -194,7 +194,6 @@ void test('initializer admits only explicit fresh CHOAM/Richese Nexus compositio
const fresh = lobby(), initialized = initializeRicheseBetrayalGameForAudit(fresh);
assert.equal(initialized.richeseBetrayalPreview,true);
assert.deepEqual(initialized.richeseBetrayal,{sequence:0,current:null,completed:[]});
reject(fresh,'r',{type:'start'});
});
void test('diversion keeps native bonus cancellation and paid-sale ownership through JSON restore without replaying payment', () => {