Open supported optional modules through normal lobby previews
This commit is contained in:
1 parent
b8ad2f011f
commit
210aa04811
17 files changed
+275
-78
No files matched your search
@@ -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; },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user