Open expansion-faction starts from the normal lobby

This commit is contained in:
Quintine committed 2026-10-17 14:39:09 +11:00
1 parent 5a32c1ac85
commit 5961c75a29
8 files changed
+36 -48

No files matched your search

+2 -5
View File
@@ -1085,10 +1085,7 @@ export default function Home() {
disabled={busy || claimUncertain} disabled={busy || claimUncertain}
onChange={(value) => { onChange={(value) => {
setAdvanced(value); setAdvanced(value);
if (value) { if (value && f.expansion !== 'base' && !expansions.includes(f.expansion)) setSelected('atreides');
setExpansions([]);
if (f.expansion !== 'base') setSelected('atreides');
}
}} }}
/> />
<p className="fine">2–6 players · up to 10 turns</p> <p className="fine">2–6 players · up to 10 turns</p>
@@ -1101,7 +1098,7 @@ export default function Home() {
<label className="expansion" key={x.id}> <label className="expansion" key={x.id}>
<input <input
type="checkbox" type="checkbox"
disabled={busy || claimUncertain || advanced} disabled={busy || claimUncertain}
checked={expansions.includes(x.id)} checked={expansions.includes(x.id)}
onChange={(e) => { onChange={(e) => {
setExpansions( setExpansions(
+2 -2
View File
@@ -2060,8 +2060,8 @@ export function GameTable({
!g.players.every((p) => p.ready), !g.players.every((p) => p.ready),
)} )}
<p className="development-note"> <p className="development-note">
Playable core under development. Advanced is available as an Playable core under development. Advanced and expansion factions
unfinished preview. Expansion games are not yet available. are unfinished previews; some interactions may still need fixes.
</p> </p>
</> </>
) : g.status === 'setup' && !g.decision ? ( ) : g.status === 'setup' && !g.decision ? (
+12
View File
@@ -29,6 +29,18 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented. remain deferred until all rules are implemented.
**Expansion-faction lobby starts open — 17 October (user-directed speed pass).**
Hosts may now start Basic or Advanced-preview games with Ix, CHOAM and/or Ecaz
factions from the normal lobby; the start routes through the existing native
faction setup. Optional Tech Tokens follow the native profile; Stronghold Cards,
Homeworlds, Nexus, Discovery, Skills and independent cards keep their dev entries.
Eight all-AI 10-turn games (Basic/Advanced × Ix, CHOAM, Ecaz, all-three six-seat)
finished with no stuck or rejected actions. The lobby note warns of unfinished
interactions. **Known regression (fix later):** the full suite shows133/9868
failures, mostly Nexus inspection/Cunning/Sardaukar and Bureaucrat suites; they
began between `bc09ac3` (14 Oct, passing) and `82f1aae` (15 Oct) and predate
this change. Not yet triaged.
**Independent36/all14 Skills composition — 17 October.** Fresh ready classic **Independent36/all14 Skills composition — 17 October.** Fresh ready classic
Basic/Advanced all14 now admits the printed independent three-card variant Basic/Advanced all14 now admits the printed independent three-card variant
(original36, no other modules, no played-game conversion). Real Recruits after a (original36, no other modules, no played-game conversion). Real Recruits after a
+15 -6
View File
@@ -8404,9 +8404,12 @@ function baseStartProfileBlock(g: Game): string | null {
if (g.ecazTreachery) return 'Ecaz Treachery Cards are still being implemented.'; if (g.ecazTreachery) return 'Ecaz Treachery Cards are still being implemented.';
if (g.moritaniAssassinatePreview || g.moritaniAssassinate) if (g.moritaniAssassinatePreview || g.moritaniAssassinate)
return 'Separate development profiles cannot be enabled by a normal start.'; return 'Separate development profiles cannot be enabled by a normal start.';
if (!g.players.every(p => FACTIONS.some(entry => entry.id === p.faction && entry.expansion === 'base'))) const expansionFactions = g.expansions.length > 0 ||
return 'Expansion factions are still being implemented.'; !g.players.every(p => FACTIONS.some(entry => entry.id === p.faction && entry.expansion === 'base'));
if (g.expansions.length !== 0) return 'Expansion rules are still being implemented.'; if (expansionFactions && g.strongholdCards)
return 'Stronghold Cards with expansion factions still use a development profile.';
if (expansionFactions && g.techTokens && !nativeFactionTechProfile(g))
return 'Tech Tokens with expansion factions need a supported three-through-six-seat native roster.';
return null; return null;
} }
function advancedPreviewIntegrity(g: Game) { function advancedPreviewIntegrity(g: Game) {
@@ -8465,7 +8468,7 @@ function requireFreshBaseRuntime(g: Game) {
JSON.stringify(g[key]) === JSON.stringify(fresh[key])), JSON.stringify(g[key]) === JSON.stringify(fresh[key])),
'Starting a base table cannot retain existing game resources, histories or continuations.'); 'Starting a base table cannot retain existing game resources, histories or continuations.');
} }
function start(g: Game, advancedPreview = false) { function start(g: Game, advancedPreview = false): Game {
requireRule(g.players.length >= 2, 'At least two players are needed.'); requireRule(g.players.length >= 2, 'At least two players are needed.');
requireRule( requireRule(
g.players.every((p) => p.ready), g.players.every((p) => p.ready),
@@ -8476,6 +8479,12 @@ function start(g: Game, advancedPreview = false) {
requireRule(!advancedPreview || g.advanced, 'Choose Advanced preview rules before starting that preview.'); requireRule(!advancedPreview || g.advanced, 'Choose Advanced preview rules before starting that preview.');
const blocked = baseStartProfileBlock(g); const blocked = baseStartProfileBlock(g);
requireRule(!blocked, blocked ?? 'This rules profile is unavailable.'); requireRule(!blocked, blocked ?? 'This rules profile is unavailable.');
if (g.expansions.length) {
// Expansion factions use the genuine native faction setup (elites, decks, cache).
const initialized = initializeFactionExpansionsGameForAudit(g);
if (advancedPreview) initialized.advancedPreview = true;
return initialized;
}
requireFreshSetup(g); requireFreshSetup(g);
requireRule(!g.techTokens || JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens()), requireRule(!g.techTokens || JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens()),
'Start with unused Tech Tokens before assigning their initial owners.'); 'Start with unused Tech Tokens before assigning their initial owners.');
@@ -8484,6 +8493,7 @@ function start(g: Game, advancedPreview = false) {
requireFreshBaseRuntime(g); requireFreshBaseRuntime(g);
initializeSetup(g); initializeSetup(g);
if (advancedPreview) g.advancedPreview = true; if (advancedPreview) g.advancedPreview = true;
return g;
} }
function skillRule<T>(operation: () => T): T { function skillRule<T>(operation: () => T): T {
try { try {
@@ -32324,8 +32334,7 @@ function applyActionInner(
requireRule(Object.keys(action).every(key => key === 'type' || key === 'advancedPreview') && requireRule(Object.keys(action).every(key => key === 'type' || key === 'advancedPreview') &&
(action.advancedPreview === undefined || action.advancedPreview === true), (action.advancedPreview === undefined || action.advancedPreview === true),
'Start this table, or explicitly choose Advanced preview.'); 'Start this table, or explicitly choose Advanced preview.');
start(g, action.advancedPreview === true); return start(g, action.advancedPreview === true);
return g;
} }
if (t === 'faction') { if (t === 'faction') {
const f = action.faction as FactionId; const f = action.faction as FactionId;
+2 -1
View File
@@ -532,7 +532,8 @@ export function classicIndependentLeaderSkillsProfile(game: LeaderSkillProfile):
/** Shared by rule quotes, controls and minimal legal bot participation. */ /** Shared by rule quotes, controls and minimal legal bot participation. */
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean { export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
return classicIndependentLeaderSkillsProfile(game) || (noOtherLeaderSkillModules(game) && return classicIndependentLeaderSkillsProfile(game) || (noOtherLeaderSkillModules(game) &&
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) || (!game.expansions.length || nativeExpansionLeaderSkillsProfile(game) ||
(game.expansions.length === 1 && game.expansions[0] === 'choam'))) ||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||
guildBetrayalNexusLeaderSkillsProfile(game) || guildBetrayalNexusLeaderSkillsProfile(game) ||
+1 -14
View File
@@ -11,7 +11,6 @@ import {
newPlayer, newPlayer,
joinGame, joinGame,
viewGame, viewGame,
initializeBaseGameForAudit,
type Game, type Game,
} from '../game/engine'; } from '../game/engine';
import { baseDeck, createAuditorLeader, CHOAM_AUDITOR_ID } from '../game/cards'; import { baseDeck, createAuditorLeader, CHOAM_AUDITOR_ID } from '../game/cards';
@@ -97,7 +96,7 @@ function setupModule() {
return exports.initializeSetup!; return exports.initializeSetup!;
} }
void test('real isolated initializer adds the Advanced Auditor before prediction and traitor dealing; public gates stay closed', () => { void test('real isolated initializer adds the Advanced Auditor before prediction and traitor dealing', () => {
const initialize = setupModule(); const initialize = setupModule();
for (const advanced of [false, true]) { for (const advanced of [false, true]) {
let g = createGame( let g = createGame(
@@ -107,18 +106,6 @@ void test('real isolated initializer adds the Advanced Auditor before prediction
); );
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit')); joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
for (const id of ['c', 'b']) g = applyAction(g, id, { type: 'ready' }); for (const id of ['c', 'b']) g = applyAction(g, id, { type: 'ready' });
const before = structuredClone(g);
assert.throws(
() => applyAction(g, 'c', { type: 'start' }),
advanced ? /Advanced preview/ : /still being implemented/,
);
if (advanced)
assert.throws(
() => applyAction(g, 'c', { type: 'start', advancedPreview: true }),
/Expansion factions are still being implemented/,
);
assert.throws(() => initializeBaseGameForAudit(g), /base factions/);
assert.deepEqual(g, before);
initialize(g); initialize(g);
assert.equal(g.setupStage, 'prediction'); assert.equal(g.setupStage, 'prediction');
assert.equal( assert.equal(
+1 -12
View File
@@ -299,18 +299,7 @@ for (const [advanced, seed] of [
); );
})); }));
void test('the Ix prototype keeps public gates and rejects unsupported or nonempty lobbies without mutation', () => { void test('the Ix prototype rejects unsupported or nonempty lobbies without mutation', () => {
const publicLobby = lobby(false, [
['a', 'atreides'],
['h', 'harkonnen'],
]);
const publicBefore = structuredClone(publicLobby);
assert.throws(
() => applyAction(publicLobby, publicLobby.host, { type: 'start' }),
/Expansion rules are still being implemented/,
);
assert.deepEqual(publicLobby, publicBefore);
const absent = createGame('NOIXPROTO', newPlayer('a', 'a', 'atreides')); const absent = createGame('NOIXPROTO', newPlayer('a', 'a', 'atreides'));
absent.players.push(newPlayer('h', 'h', 'harkonnen')); absent.players.push(newPlayer('h', 'h', 'harkonnen'));
absent.players.forEach((p) => (p.ready = true)); absent.players.forEach((p) => (p.ready = true));
+1 -8
View File
@@ -272,7 +272,7 @@ void test('Atreides inspection grants only its entitled view and survives JSON b
assert.equal(viewGame(g, 'e').richeseAuction!.cardId, null); assert.equal(viewGame(g, 'e').richeseAuction!.cardId, null);
assert.equal(g.players[0].hand[0].id, card.id); assert.equal(g.players[0].hand[0].id, card.id);
}); });
void test('connected cache-cancel restores the ordinary pool while seller self-bid and lobby gates still reject atomically', () => { void test('connected cache-cancel restores the ordinary pool while seller self-bid rejects atomically', () => {
const before = fixture(); const before = fixture();
const k = before.deck.find((c) => c.effect === 'karama')!; const k = before.deck.find((c) => c.effect === 'karama')!;
before.players[2].hand = [k]; before.players[2].hand = [k];
@@ -295,13 +295,6 @@ void test('connected cache-cancel restores the ordinary pool while seller self-b
const exact = structuredClone(g); const exact = structuredClone(g);
assert.throws(() => bid(g, 'r', 1), /self-bid/); assert.throws(() => bid(g, 'r', 1), /self-bid/);
assert.deepEqual(g, exact); assert.deepEqual(g, exact);
const lobby = createGame('RICHESE1', newPlayer('r', 'Richese', 'richese'));
lobby.players.push(newPlayer('e', 'Emperor', 'emperor'));
lobby.players.forEach((p) => (p.ready = true));
assert.throws(
() => applyAction(lobby, 'r', { type: 'start' }),
/still being implemented/,
);
}); });
void test('an outbid ally-funded player whose hand fills is automatically skipped without reserving losing credit', () => { void test('an outbid ally-funded player whose hand fills is automatically skipped without reserving losing credit', () => {