From 5d0c15d41d53aead2ebcae6fdddbb3e8f69c073e Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:05:45 -0600 Subject: [PATCH 1/4] Count only wind plants as ecological, behind one switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benelux's extra market slot and Québec's protected plants now share one isEcological() helper. Fusion is not ecological unless FUSION_IS_ECOLOGICAL is flipped. New Benelux and Québec games store the ruling as options.fusionEcological, so flipping it later only affects new games. Québec's deck setup used to protect fusion plant 50 while its bureaucracy and Benelux did not. Games without the stored flag rebuild their Québec deck the old way via reconstructionOptions. Co-Authored-By: Claude Opus 5.5 --- engine/src/analysis.ts | 5 +- engine/src/ecological.spec.ts | 88 +++++++++++++++++++++++++++++++++++ engine/src/engine.ts | 32 ++++++++++--- engine/src/gamestate.ts | 7 +++ engine/src/maps.ts | 4 +- engine/src/maps/quebec.ts | 41 +++++++--------- engine/src/powerPlants.ts | 25 +++++++++- 7 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 engine/src/ecological.spec.ts diff --git a/engine/src/analysis.ts b/engine/src/analysis.ts index 21de7898..3ccf346f 100644 --- a/engine/src/analysis.ts +++ b/engine/src/analysis.ts @@ -2,7 +2,7 @@ import seedrandom from 'seedrandom'; import { availableMoves } from './available-moves'; import { defaultSetupDeck, reconstructState, setup, stripSecret } from './engine'; import { GameState, PowerPlant, PowerPlantType } from './gamestate'; -import { plantCatalogue } from './powerPlants'; +import { isEcological, plantCatalogue } from './powerPlants'; import { shuffle } from './utils'; /** Unknown cards come from the published catalogue, never the live draw pile. */ @@ -115,7 +115,8 @@ export function createAnalysisScenario( candidates = candidates.filter((p) => !top.some((t) => t.number === p.number)); const mandatory = (p: PowerPlant) => (name === 'Brazil' && p.type === PowerPlantType.Garbage && p.number > 14) || - (name === 'Quebec' && [PowerPlantType.Wind, PowerPlantType.Nuclear].includes(p.type)) || + // Games without the stored ruling built their Québec deck protecting fusion too. + (name === 'Quebec' && isEcological(p, s.options.fusionEcological ?? true)) || (name === 'Russia' && original && [10, 11].includes(p.number)); // A plug-back top card is public information in the recharged edition. if ( diff --git a/engine/src/ecological.spec.ts b/engine/src/ecological.spec.ts new file mode 100644 index 00000000..502ef2ce --- /dev/null +++ b/engine/src/ecological.spec.ts @@ -0,0 +1,88 @@ +import { expect } from 'chai'; +import { range } from 'lodash'; +import { init, moveAI, replay } from '../wrapper'; +import { addPowerPlant, getPowerPlant, reconstructionOptions, setup } from './engine'; +import { GameState, PowerPlant, PowerPlantType, Variant } from './gamestate'; +import { FUSION_IS_ECOLOGICAL, isEcological } from './powerPlants'; + +const json = (G: GameState) => JSON.parse(JSON.stringify(G)); + +function playAI(G: GameState, moves: number): GameState { + for (let i = 0; i < moves && G.currentPlayers.length > 0; i++) G = moveAI(G, G.currentPlayers[0]); + return G; +} + +const allPlants = (G: GameState) => [...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck].map((pp) => pp.number); + +const variants: Variant[] = ['original', 'recharged']; + +describe('ecological power plants', () => { + it('counts wind plants as ecological, and fusion plants only under the ruling', () => { + expect(FUSION_IS_ECOLOGICAL).to.equal(false); + expect(isEcological(getPowerPlant(13), false)).to.equal(true); + expect(isEcological(getPowerPlant(50), false)).to.equal(false); + expect(isEcological(getPowerPlant(50), true)).to.equal(true); + expect(isEcological(getPowerPlant(11), true)).to.equal(false); + }); + + it('stores the ruling only on the maps with ecological rules', async () => { + for (const variant of variants) { + for (const map of ['Benelux', 'Quebec'] as const) { + const G = await init(4, [], { map, variant }, 'eco'); + expect(G.options.fusionEcological).to.equal(false); + } + for (const map of ['Germany', 'France'] as const) { + const G = await init(4, [], { map, variant }, 'eco'); + expect(G.options).to.not.have.property('fusionEcological'); + } + } + }); + + it('lets Québec remove fusion plant 50 at setup, unless fusion is ecological', () => { + for (const variant of variants) { + const seeds = range(40).map(String); + const without50 = seeds.filter( + (seed) => !allPlants(setup(2, { map: 'Quebec', variant }, seed)).includes(50) + ); + expect(without50, variant).to.not.be.empty; + + for (const seed of seeds) { + const G = setup(2, { map: 'Quebec', variant, fusionEcological: true }, seed); + expect(allPlants(G), `${variant} ${seed}`).to.include(50); + } + } + }); + + it('replays Québec games created before the ruling with fusion protected', () => { + for (const variant of variants) { + const legacyOptions = reconstructionOptions({ map: 'Quebec', variant }); + for (const seed of range(40).map(String)) { + expect(allPlants(setup(2, legacyOptions, seed)), `${variant} ${seed}`).to.include(50); + } + + const legacy = playAI(setup(3, legacyOptions, 'old'), 60); + expect(legacy.options).to.not.have.property('fusionEcological'); + expect(legacy.options).to.not.have.property('legacyEcological'); + expect(json(replay(legacy))).to.deep.equal(json(legacy)); + + const current = playAI(setup(3, { map: 'Quebec', variant }, 'new'), 60); + expect(current.options.fusionEcological).to.equal(false); + expect(json(replay(current))).to.deep.equal(json(current)); + } + }); + + it('adds a fusion plant to the Benelux market only under the ruling', () => { + // A fusion plant numbered low enough to be the smallest in the future market. + const fusion: PowerPlant = { number: 30, type: PowerPlantType.Nuclear, cost: 0, citiesPowered: 6 }; + for (const fusionEcological of [false, true]) { + const G = setup(3, { map: 'Benelux', variant: 'recharged', fusionEcological }, 'benelux'); + G.actualMarket = [20, 21, 23, 24].map((n) => getPowerPlant(n)); + G.futureMarket = [39, 40, 42].map((n) => getPowerPlant(n)); + G.powerPlantsDeck = [fusion]; + addPowerPlant(G); + expect(G.actualMarket.map((pp) => pp.number)).to.deep.equal( + fusionEcological ? [20, 21, 23, 24, 30] : [20, 21, 23, 24] + ); + } + }); +}); diff --git a/engine/src/engine.ts b/engine/src/engine.ts index 47dc5f4a..8a60f54f 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -34,7 +34,14 @@ import { } from './gamestate'; import { GameMap, maps, mapsRecharged } from './maps'; import { Move, MoveName, Moves } from './move'; -import { plantCatalogue, powerPlants, usesAlternateDeck } from './powerPlants'; +import { + ECOLOGICAL_RULE_MAPS, + FUSION_IS_ECOLOGICAL, + isEcological, + plantCatalogue, + powerPlants, + usesAlternateDeck, +} from './powerPlants'; import prices from './prices'; import { createRandomizedMap } from './randomizeMap'; import { asserts, shuffle } from './utils'; @@ -133,6 +140,8 @@ export function setup( chooseColors = false, alternatePowerPlants = false, rechargedStartingResources = true, + fusionEcological = FUSION_IS_ECOLOGICAL, + legacyEcological = false, }: GameOptions, seed?: string, forceDeck?: PowerPlant[], @@ -168,6 +177,7 @@ export function setup( // The option is only honoured on the maps that support the alternate deck. const alternateDeck = usesAlternateDeck(chosenMap.name, alternatePowerPlants); const deck = plantCatalogue(chosenMap.name, alternateDeck); + const ecologicalRules = ECOLOGICAL_RULE_MAPS.includes(chosenMap.name) && !legacyEcological; let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; @@ -183,7 +193,8 @@ export function setup( numPlayers, variant, rng, - alternateDeck ? deck : undefined + alternateDeck ? deck : undefined, + legacyEcological ? true : fusionEcological )); } else { ({ actualMarket, futureMarket, powerPlantsDeck } = defaultSetupDeck( @@ -460,6 +471,7 @@ export function setup( // Only stored when in effect, so states of every other game are unchanged. ...(alternateDeck ? { alternatePowerPlants: true } : {}), ...(variantStartingResources && rechargedStartingResources ? { rechargedStartingResources: true } : {}), + ...(ecologicalRules ? { fusionEcological } : {}), }, log: [], hiddenLog: [], @@ -1389,7 +1401,9 @@ export function move( let powerPlantToPush: PowerPlant | undefined; if (G.map.name == 'Quebec') { // For the Quebec map, ecological plants will never be put on the bottom of the deck. - const nonEcoPlants = G.futureMarket.filter((pp) => pp.type != PowerPlantType.Wind); + const nonEcoPlants = G.futureMarket.filter( + (pp) => !isEcological(pp, G.options.fusionEcological) + ); powerPlantToPush = nonEcoPlants.pop(); if (powerPlantToPush) { @@ -1397,7 +1411,9 @@ export function move( (pp) => pp.number != powerPlantToPush?.number ); } else { - const nonEcoPlants = G.actualMarket.filter((pp) => pp.type != PowerPlantType.Wind); + const nonEcoPlants = G.actualMarket.filter( + (pp) => !isEcological(pp, G.options.fusionEcological) + ); powerPlantToPush = nonEcoPlants.pop(); if (powerPlantToPush) { @@ -2377,7 +2393,7 @@ export function addPowerPlant(G: GameState): void { // Only 3 plants in actual market and 3 in future market. G.actualMarket = market.slice(0, 3); G.futureMarket = market.slice(3); - } else if (G.map.name == 'Benelux' && market[4].type == PowerPlantType.Wind) { + } else if (G.map.name == 'Benelux' && isEcological(market[4], G.options.fusionEcological)) { // Add extra ecological plant to actual market. G.actualMarket = market.slice(0, 5); G.futureMarket = market.slice(5); @@ -2797,7 +2813,11 @@ export function getPowerPlant(num: number, mapName = '', alternatePowerPlants = // record explicitly must fall back to their old behaviour when absent, so games // created before them replay as they were played. export function reconstructionOptions(options: GameOptions): GameOptions { - return { ...options, rechargedStartingResources: options.rechargedStartingResources ?? false }; + return { + ...options, + rechargedStartingResources: options.rechargedStartingResources ?? false, + legacyEcological: options.fusionEcological === undefined, + }; } function getBaseState(G: GameState): GameState { diff --git a/engine/src/gamestate.ts b/engine/src/gamestate.ts index b8a97dad..e3dc4a42 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -61,6 +61,13 @@ export interface GameOptions { // created before this existed started Recharged with the Original market, so // reconstructing one without the flag keeps that (see reconstructionOptions). rechargedStartingResources?: boolean; + // Whether fusion plants count as ecological (see FUSION_IS_ECOLOGICAL). Stored by + // setup on the maps in ECOLOGICAL_RULE_MAPS; older games have none and play as + // wind-only, except that their Québec deck setup protected fusion too. + fusionEcological?: boolean; + // Set only by reconstructionOptions, for games without fusionEcological, so their + // Québec deck is rebuilt the old way. Never stored. + legacyEcological?: boolean; } export enum ResourceType { diff --git a/engine/src/maps.ts b/engine/src/maps.ts index e0ed2e46..dcd20c77 100644 --- a/engine/src/maps.ts +++ b/engine/src/maps.ts @@ -120,11 +120,13 @@ export interface GameMap { uraniumPrices?: number[]; // `deck` is the alternate power plant list when the game uses it (only on maps // in ALTERNATE_DECK_MAPS); otherwise undefined and the map uses its usual deck. + // `fusionEcological` is the game's ruling, for maps that protect ecological plants. setupDeck?: ( numPlayers: number, variant: string, rng: seedrandom.prng, - deck?: PowerPlant[] + deck?: PowerPlant[], + fusionEcological?: boolean ) => { actualMarket: PowerPlant[]; futureMarket: PowerPlant[]; diff --git a/engine/src/maps/quebec.ts b/engine/src/maps/quebec.ts index 248d7906..a6d122b4 100644 --- a/engine/src/maps/quebec.ts +++ b/engine/src/maps/quebec.ts @@ -1,7 +1,7 @@ import { cloneDeep } from 'lodash'; import { getPowerPlant } from '../engine'; -import { PowerPlant, PowerPlantType } from '../gamestate'; -import { powerPlants } from '../powerPlants'; +import { PowerPlant } from '../gamestate'; +import { isEcological, powerPlants } from '../powerPlants'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; @@ -206,7 +206,14 @@ export const map: GameMap = { ], ], startingResources: [21, 21, 6, 2], - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { + setupDeck( + numPlayers: number, + variant: string, + rng: seedrandom.prng, + _deck?: PowerPlant[], + fusionEcological?: boolean + ) { + const ecological = (pp: PowerPlant) => isEcological(pp, fusionEcological); let powerPlantsDeck = cloneDeep(powerPlants); let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; @@ -215,14 +222,8 @@ export const map: GameMap = { // No ecological plants will be discarded. // 13, 18, 22 will be set aside and placed on top of the deck. const setAsidePlants = powerPlantsDeck.filter((pp) => [13, 18, 22].includes(pp.number)); - const otherEcoPlants = powerPlantsDeck.filter( - (pp) => - (pp.type == PowerPlantType.Wind || pp.type == PowerPlantType.Nuclear) && - ![13, 18, 22].includes(pp.number) - ); - const nonEcoPlantsDeck = powerPlantsDeck.filter( - (pp) => pp.type != PowerPlantType.Wind && pp.type != PowerPlantType.Nuclear - ); + const otherEcoPlants = powerPlantsDeck.filter((pp) => ecological(pp) && ![13, 18, 22].includes(pp.number)); + const nonEcoPlantsDeck = powerPlantsDeck.filter((pp) => !ecological(pp)); // Remove first eight plants for actual and future market. powerPlantsDeck = nonEcoPlantsDeck.slice(8); @@ -260,17 +261,9 @@ export const map: GameMap = { const powerPlantsDeckLow = powerPlantsDeck.filter((pp) => pp.number >= 3 && pp.number <= 10); const plant13 = powerPlantsDeck.filter((pp) => pp.number == 13); const otherInitialEcoPlants = powerPlantsDeck.filter((pp) => pp.number == 18 || pp.number == 22); - const otherEcoPlants = powerPlantsDeck.filter( - (pp) => - ![13, 18, 22].includes(pp.number) && - (pp.type == PowerPlantType.Wind || pp.type == PowerPlantType.Nuclear) - ); - let otherPlants1 = powerPlantsDeck.filter( - (pp) => pp.type != PowerPlantType.Wind && pp.number >= 11 && pp.number <= 15 - ); - let otherPlants2 = powerPlantsDeck.filter( - (pp) => pp.type != PowerPlantType.Wind && pp.type != PowerPlantType.Nuclear && pp.number >= 16 - ); + const otherEcoPlants = powerPlantsDeck.filter((pp) => ![13, 18, 22].includes(pp.number) && ecological(pp)); + let otherPlants1 = powerPlantsDeck.filter((pp) => !ecological(pp) && pp.number >= 11 && pp.number <= 15); + let otherPlants2 = powerPlantsDeck.filter((pp) => !ecological(pp) && pp.number >= 16); if (numPlayers == 2) { otherPlants1 = shuffle(otherPlants1, rng() + '').slice(1); otherPlants2 = shuffle(otherPlants2, rng() + '').slice(5); @@ -304,8 +297,8 @@ export const map: GameMap = { recharged: 'the starting market is drawn from the low ' + 'plants (including 13); one more low plant goes on top of the deck, ' + - 'with 18 and 22 placed directly beneath it. Ecological plants (wind ' + - 'and fusion) are never removed during setup.', + 'with 18 and 22 placed directly beneath it. Ecological plants are ' + + 'never removed during setup.', original: 'power plants 13, 18, 22 are set aside and ' + 'placed on top of the deck (13 topmost); ecological plants are ' + diff --git a/engine/src/powerPlants.ts b/engine/src/powerPlants.ts index 74ed16af..a034ee7c 100644 --- a/engine/src/powerPlants.ts +++ b/engine/src/powerPlants.ts @@ -122,4 +122,27 @@ function plantCatalogue(mapName: string, alternate?: boolean): PowerPlant[] { return usesAlternateDeck(mapName, alternate) ? alternatePowerPlants : powerPlants; } -export { powerPlants, indiaPowerPlants, alternatePowerPlants, ALTERNATE_DECK_MAPS, usesAlternateDeck, plantCatalogue }; +// Wind plants are always ecological. Whether fusion plants are too is a ruling: +// new games take this value and store it as options.fusionEcological on the maps +// in ECOLOGICAL_RULE_MAPS, so changing it only affects games created afterwards. +const FUSION_IS_ECOLOGICAL = false; + +// The maps whose rules single out ecological plants: Benelux's extra market slot +// and Québec's protected plants. +const ECOLOGICAL_RULE_MAPS: string[] = ['Benelux', 'Quebec']; + +function isEcological(pp: PowerPlant, fusionEcological: boolean | undefined): boolean { + return pp.type == PowerPlantType.Wind || (!!fusionEcological && pp.type == PowerPlantType.Nuclear); +} + +export { + powerPlants, + indiaPowerPlants, + alternatePowerPlants, + ALTERNATE_DECK_MAPS, + usesAlternateDeck, + plantCatalogue, + FUSION_IS_ECOLOGICAL, + ECOLOGICAL_RULE_MAPS, + isEcological, +}; From 9ac08a2e98b5f730fdf906651154dc49d27e25c7 Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:15:42 -0600 Subject: [PATCH 2/4] Add a power plant deck option with Deck 1, Deck 2 and Random New games choose a deck with options.powerPlantDeck: standard, deck1 (The New Power Plants / Set 1, without 52-60), deck2 (Set 2) or random, which resolves from the seed and stores the result. Only a non-standard deck is stored. Deck 1 deals from 01-08 with 11 on top in the Original edition and from the 01-16 plug cards in Recharged. Deck 1 and Deck 2 are offered on maps whose setup doesn't name standard-deck plant numbers; the rest fall back to the standard deck until their per-deck changes land. Germany's uranium phase-out triggers on 38 (Deck 1) or 37 (Deck 2). The legacy alternatePowerPlants flag keeps its Europe/North America-only behaviour and storage, so existing games set up identically. Co-Authored-By: Claude Opus 5.5 --- engine/src/analysis.ts | 7 +- engine/src/decks.spec.ts | 148 ++++++++++++++++++++++++ engine/src/engine.ts | 54 ++++++--- engine/src/gamestate.ts | 13 ++- engine/src/maps/europe.ts | 16 +-- engine/src/powerPlants.ts | 113 ++++++++++++++++-- viewer/src/self-contained.ts | 4 +- viewer/src/util/journal-plant.ts | 3 +- viewer/tests/unit/journal-plant.spec.ts | 15 ++- 9 files changed, 330 insertions(+), 43 deletions(-) create mode 100644 engine/src/decks.spec.ts diff --git a/engine/src/analysis.ts b/engine/src/analysis.ts index 3ccf346f..557489c8 100644 --- a/engine/src/analysis.ts +++ b/engine/src/analysis.ts @@ -2,7 +2,7 @@ import seedrandom from 'seedrandom'; import { availableMoves } from './available-moves'; import { defaultSetupDeck, reconstructState, setup, stripSecret } from './engine'; import { GameState, PowerPlant, PowerPlantType } from './gamestate'; -import { isEcological, plantCatalogue } from './powerPlants'; +import { gameDeck, isEcological, lastPlugPlant, plantCatalogue } from './powerPlants'; import { shuffle } from './utils'; /** Unknown cards come from the published catalogue, never the live draw pile. */ @@ -26,7 +26,8 @@ export function createAnalysisScenario( excluded = n <= 3 ? [3, 4, 9, 11, 16, 18, 20, 24, 30, 33, 46] : n === 4 ? [3, 4, 11, 18, 24, 33, 46] : [3, 4, 33]; if (name === 'Spain & Portugal' && s.step === 1) excluded = [18, 22, 27]; - const catalogue = JSON.parse(JSON.stringify(plantCatalogue(name, s.options.alternatePowerPlants))) as PowerPlant[]; + const deckCatalogue = plantCatalogue(name, gameDeck(s.options)); + const catalogue = JSON.parse(JSON.stringify(deckCatalogue)) as PowerPlant[]; for (const region of new Set(s.regionDraft ? [] : s.map.cities.map((c) => c.region))) { for (const replacement of s.map.regionalPowerPlants?.[region] || []) { const i = catalogue.findIndex((p) => p.number === replacement.number); @@ -152,7 +153,7 @@ export function createAnalysisScenario( s.options.useNewRechargedSetup ?? true ) : setup(n, s.options, seed + ':catalogue-counts'); - const threshold = name === 'India' ? 16 : 15; + const threshold = name === 'India' ? 16 : lastPlugPlant(deckCatalogue); const low = (p: PowerPlant) => p.number <= threshold; const initialLow = [...initial.actualMarket, ...initial.futureMarket, ...initial.powerPlantsDeck].filter( low diff --git a/engine/src/decks.spec.ts b/engine/src/decks.spec.ts new file mode 100644 index 00000000..423cf87b --- /dev/null +++ b/engine/src/decks.spec.ts @@ -0,0 +1,148 @@ +import { expect } from 'chai'; +import { range } from 'lodash'; +import 'mocha'; +import { availableMoves } from './available-moves'; +import { ended, move, moveAI, setup } from './engine'; +import { GameState, MapName, PowerPlantDeck, Variant } from './gamestate'; +import { Move, MoveName } from './move'; +import { alternatePowerPlants, deck1PowerPlants, powerPlants } from './powerPlants'; + +const numbers = (plants: { number: number }[]) => plants.map((pp) => pp.number); +const visible = (G: GameState) => [...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck]; +const catalogues = { standard: powerPlants, deck1: deck1PowerPlants, deck2: alternatePowerPlants }; + +describe('power plant decks', () => { + it('deals Deck 1 in the Original edition from plants 01–08, with 11 on top', () => { + for (const players of [2, 3, 4, 5]) { + const G = setup(players, { map: 'Germany', variant: 'original', powerPlantDeck: 'deck1' }, 'deck1'); + expect(numbers(G.actualMarket)).to.deep.equal([1, 2, 3, 4]); + expect(numbers(G.futureMarket)).to.deep.equal([5, 6, 7, 8]); + expect(G.powerPlantsDeck[0].number).to.equal(11); + expect(G.powerPlantsDeck[G.powerPlantsDeck.length - 1].number).to.equal(99); + expect(G.options.powerPlantDeck).to.equal('deck1'); + } + }); + + it('deals each deck in the Recharged edition from its own plug cards', () => { + for (const [deck, lastPlug] of [ + ['deck1', 16], + ['deck2', 15], + ['standard', 15], + ] as [PowerPlantDeck, number][]) { + for (const seed of range(10).map(String)) { + const G = setup(4, { map: 'USA', variant: 'recharged', powerPlantDeck: deck }, seed); + const market = [...G.actualMarket, ...G.futureMarket]; + expect( + market.every((pp) => pp.number <= lastPlug), + `${deck} ${seed}` + ).to.be.true; + expect(G.powerPlantsDeck[0].number).to.be.at.most(lastPlug); + for (const pp of visible(G)) { + expect(pp, `${deck} ${pp.number}`).to.deep.equal( + catalogues[deck].find((c) => c.number == pp.number) + ); + } + } + } + }); + + it('stores the deck only when it is not the standard one', () => { + expect(setup(3, { map: 'USA' }, 's').options).to.not.have.property('powerPlantDeck'); + expect(setup(3, { map: 'USA', powerPlantDeck: 'standard' }, 's').options).to.not.have.property( + 'powerPlantDeck' + ); + expect(setup(3, { map: 'Korea', powerPlantDeck: 'deck2' }, 's').options.powerPlantDeck).to.equal('deck2'); + }); + + it('falls back to the standard deck on maps that need deck-specific changes', () => { + for (const map of ['France', 'China', 'Quebec', 'Italy'] as MapName[]) { + const G = setup(3, { map, variant: 'recharged', powerPlantDeck: 'deck1' }, 'fallback'); + expect(G.options).to.not.have.property('powerPlantDeck'); + expect(visible(G).some((pp) => pp.number == 1)).to.be.false; + } + }); + + it('resolves a random deck from the seed, without shifting the rest of the setup', () => { + const picked = new Set(); + for (const seed of range(30).map(String)) { + const G = setup(4, { map: 'Germany', variant: 'recharged', powerPlantDeck: 'random' }, seed); + const resolved = (G.options.powerPlantDeck ?? 'standard') as PowerPlantDeck; + picked.add(resolved); + const direct = setup(4, { map: 'Germany', variant: 'recharged', powerPlantDeck: resolved }, seed); + expect(JSON.stringify(G)).to.equal(JSON.stringify(direct)); + } + expect([...picked].sort()).to.deep.equal(['deck1', 'deck2', 'standard']); + + const france = setup(4, { map: 'France', powerPlantDeck: 'random' }, 'r'); + expect(france.options).to.not.have.property('powerPlantDeck'); + }); + + it('keeps the legacy alternate deck flag working where it applied', () => { + const legacy = setup(4, { map: 'Europe', variant: 'recharged', alternatePowerPlants: true }, 'legacy'); + expect(legacy.options.alternatePowerPlants).to.equal(true); + expect(legacy.options).to.not.have.property('powerPlantDeck'); + expect(legacy.players[0].garbageOilHybridCapacity).to.equal(0); + for (const pp of visible(legacy)) { + expect(pp).to.deep.equal(alternatePowerPlants.find((c) => c.number == pp.number)); + } + + const ignored = setup(4, { map: 'Germany', alternatePowerPlants: true }, 'legacy'); + expect(ignored.options).to.not.have.property('alternatePowerPlants'); + expect(ignored.options).to.not.have.property('powerPlantDeck'); + + const overridden = setup(4, { map: 'Europe', alternatePowerPlants: true, powerPlantDeck: 'deck1' }, 'legacy'); + expect(overridden.options).to.not.have.property('alternatePowerPlants'); + expect(overridden.options.powerPlantDeck).to.equal('deck1'); + }); + + it("ends Germany's uranium resupply on each deck's own nuclear plant", () => { + for (const [deck, plant] of [ + ['standard', 39], + ['deck1', 38], + ['deck2', 37], + ] as [PowerPlantDeck, number][]) { + for (const [number, triggers] of [ + [plant, true], + [plant == 39 ? 38 : 39, false], + ] as [number, boolean][]) { + const G = setup(3, { map: 'Germany', variant: 'recharged', powerPlantDeck: deck }, 'phase-out'); + G.actualMarket[0] = catalogues[deck].find((c) => c.number == number)!; + const player = G.currentPlayers[0]; + G.players[player].money = 100; + G.players[player].availableMoves = availableMoves(G, G.players[player]); + const after = move(G, { name: MoveName.ChoosePowerPlant, data: number } as Move, player); + expect(after.card39Bought, `${deck} ${number}`).to.equal(triggers); + } + } + }); + + for (const [map, variant, deck] of [ + ['Germany', 'recharged', 'deck1'], + ['USA', 'original', 'deck1'], + ['Benelux', 'recharged', 'deck2'], + ['Japan', 'original', 'deck2'], + ] as [MapName, Variant, PowerPlantDeck][]) { + it(`plays a whole bot game on ${map} ${variant} with ${deck} and replays it`, () => { + let G = setup(4, { map, variant, powerPlantDeck: deck }, `bots-${map}-${deck}`); + G.players.forEach((p, i) => { + p.name = `AI ${i}`; + p.isAI = true; + }); + for (let turn = 0; turn < 5000 && !ended(G) && G.currentPlayers.length > 0; turn++) { + G = moveAI(G, G.currentPlayers[0]); + } + expect(ended(G), 'game finished').to.be.true; + + let replay = setup(4, G.options, G.seed); + replay.players.forEach((p, i) => { + p.name = `AI ${i}`; + p.isAI = true; + }); + for (const item of G.log) { + if (item.type === 'move') replay = move(replay, item.move, item.player); + } + expect(replay.players.map((p) => p.money)).to.deep.equal(G.players.map((p) => p.money)); + expect(replay.players.map((p) => p.citiesPowered)).to.deep.equal(G.players.map((p) => p.citiesPowered)); + }); + } +}); diff --git a/engine/src/engine.ts b/engine/src/engine.ts index 8a60f54f..998259d9 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -27,6 +27,7 @@ import { Player, playerColors, PowerPlant, + PowerPlantDeck, PowerPlantType, ResourceType, resupplyUraniumMine, @@ -35,9 +36,12 @@ import { import { GameMap, maps, mapsRecharged } from './maps'; import { Move, MoveName, Moves } from './move'; import { + availableDecks, ECOLOGICAL_RULE_MAPS, FUSION_IS_ECOLOGICAL, isEcological, + lastPlugPlant, + PHASE_OUT_PLANT, plantCatalogue, powerPlants, usesAlternateDeck, @@ -71,11 +75,12 @@ export function defaultSetupDeck( let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; let powerPlantsDeck: PowerPlant[] = cloneDeep(deck); - const plant = (num: number) => deck.find((p) => p.number == num)!; if (variant == 'original') { + // The eight smallest plants open the market (03–10, or 01–08 in Deck 1); the + // third plant after them goes on top of the deck (13, or 11 in Deck 1). powerPlantsDeck = powerPlantsDeck.slice(8); - const powerPlant13 = powerPlantsDeck.splice(2, 1)[0]; + const topPlant = powerPlantsDeck.splice(2, 1)[0]; const step3 = powerPlantsDeck.pop()!; powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); @@ -85,13 +90,14 @@ export function defaultSetupDeck( powerPlantsDeck = powerPlantsDeck.slice(4); } - powerPlantsDeck.unshift(powerPlant13); + powerPlantsDeck.unshift(topPlant); powerPlantsDeck.push(step3); - actualMarket = [plant(3), plant(4), plant(5), plant(6)]; - futureMarket = [plant(7), plant(8), plant(9), plant(10)]; + actualMarket = deck.slice(0, 4); + futureMarket = deck.slice(4, 8); } else { - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, 13), rng() + ''); + const plugCount = deck.filter((p) => p.number <= lastPlugPlant(deck)).length; + let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, plugCount), rng() + ''); let initialPlantMarket = initialPowerPlants.splice(0, 8); initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); actualMarket = initialPlantMarket.slice(0, 4); @@ -139,6 +145,7 @@ export function setup( chooseRegions = false, chooseColors = false, alternatePowerPlants = false, + powerPlantDeck, rechargedStartingResources = true, fusionEcological = FUSION_IS_ECOLOGICAL, legacyEcological = false, @@ -174,9 +181,18 @@ export function setup( ? chosenMap.startingResources.recharged : chosenMap.startingResources.original; if (startingResources) chosenMap.startingResources = startingResources; - // The option is only honoured on the maps that support the alternate deck. - const alternateDeck = usesAlternateDeck(chosenMap.name, alternatePowerPlants); - const deck = plantCatalogue(chosenMap.name, alternateDeck); + const decks = availableDecks(chosenMap.name); + // Dedicated rng so the pick doesn't shift the rest of the setup's draws. + const requestedDeck = + powerPlantDeck == 'random' ? decks[Math.floor(seedrandom(seed + '-deck')() * decks.length)] : powerPlantDeck; + // The legacy option only applies when no deck is asked for, and only on its own maps. + const legacyAlternateDeck = !requestedDeck && usesAlternateDeck(chosenMap.name, alternatePowerPlants); + const chosenDeck: PowerPlantDeck = legacyAlternateDeck + ? 'deck2' + : requestedDeck && decks.includes(requestedDeck) + ? requestedDeck + : 'standard'; + const deck = plantCatalogue(chosenMap.name, chosenDeck); const ecologicalRules = ECOLOGICAL_RULE_MAPS.includes(chosenMap.name) && !legacyEcological; let actualMarket: PowerPlant[]; @@ -193,7 +209,7 @@ export function setup( numPlayers, variant, rng, - alternateDeck ? deck : undefined, + chosenDeck != 'standard' ? deck : undefined, legacyEcological ? true : fusionEcological )); } else { @@ -202,7 +218,7 @@ export function setup( variant, rng, useNewRechargedSetup, - alternateDeck ? deck : undefined + chosenDeck != 'standard' ? deck : undefined )); } } @@ -220,7 +236,7 @@ export function setup( uraniumLeft: 0, hybridCapacity: 0, hybridLeft: 0, - ...(alternateDeck ? { garbageOilHybridCapacity: 0 } : {}), + ...(chosenDeck == 'deck2' ? { garbageOilHybridCapacity: 0 } : {}), money: 50, housesLeft: 22, cities: [], @@ -469,7 +485,11 @@ export function setup( chooseRegions, chooseColors, // Only stored when in effect, so states of every other game are unchanged. - ...(alternateDeck ? { alternatePowerPlants: true } : {}), + ...(legacyAlternateDeck + ? { alternatePowerPlants: true } + : chosenDeck != 'standard' + ? { powerPlantDeck: chosenDeck } + : {}), ...(variantStartingResources && rechargedStartingResources ? { rechargedStartingResources: true } : {}), ...(ecologicalRules ? { fusionEcological } : {}), }, @@ -753,7 +773,9 @@ export function move( G.chosenPowerPlant = G.actualMarket.find((p) => p.number === move.data)!; G.auctioningPlayer = player.id; - if (move.data == 39) { + // Named card39Bought from the standard deck's plant; old games, which have + // no powerPlantDeck, keep keying it on 39. + if (move.data == PHASE_OUT_PLANT[(G.options.powerPlantDeck as PowerPlantDeck) ?? 'standard']) { G.card39Bought = true; } @@ -2805,8 +2827,8 @@ function removePowerPlant(G: GameState, powerPlant: PowerPlant) { ); } -export function getPowerPlant(num: number, mapName = '', alternatePowerPlants = false): PowerPlant { - return plantCatalogue(mapName, alternatePowerPlants).find((p) => p.number == num)!; +export function getPowerPlant(num: number, mapName = '', deck: PowerPlantDeck = 'standard'): PowerPlant { + return plantCatalogue(mapName, deck).find((p) => p.number == num)!; } // Options for rebuilding an existing game's starting state. Settings that new games diff --git a/engine/src/gamestate.ts b/engine/src/gamestate.ts index e3dc4a42..c72c7bd6 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -35,6 +35,9 @@ export type MapName = | 'Bremen' | 'Manhattan'; export type Variant = 'original' | 'recharged'; +// 'standard' is the base game's deck; deck1 and deck2 are The New Power Plants +// (Set 1) and Set 2. See plantCatalogue in powerPlants.ts. +export type PowerPlantDeck = 'standard' | 'deck1' | 'deck2'; export interface GameOptions { fastBid?: boolean; @@ -53,10 +56,14 @@ export interface GameOptions { // When set, each player picks their own color (in turn) at the start of the // game instead of colors being assigned by seat. Runs before the region draft. chooseColors?: boolean; - // When set, the game uses the alternate power plant deck (see - // alternatePowerPlants in powerPlants.ts) instead of the standard one. Only - // honoured on the maps in ALTERNATE_DECK_MAPS; ignored everywhere else. + // Legacy: the alternate deck (Deck 2), honoured only on the maps in + // ALTERNATE_DECK_MAPS. Games created with it keep storing it; new games use + // powerPlantDeck, which wins when both are given. alternatePowerPlants?: boolean; + // The power plant deck. 'random' picks one of the map's available decks at + // setup; the resolved deck is what gets stored, and only when it isn't the + // standard one. A deck the map doesn't support falls back to the standard deck. + powerPlantDeck?: PowerPlantDeck | 'random'; // Stored by setup on maps whose starting resources differ by variant. Games // created before this existed started Recharged with the Original market, so // reconstructing one without the flag keeps that (see reconstructionOptions). diff --git a/engine/src/maps/europe.ts b/engine/src/maps/europe.ts index 64e0176a..586f870d 100644 --- a/engine/src/maps/europe.ts +++ b/engine/src/maps/europe.ts @@ -4,7 +4,7 @@ import seedrandom from 'seedrandom'; import { PowerPlant, PowerPlantType } from '../gamestate'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; -import { powerPlants } from './../powerPlants'; +import { lastPlugPlant, powerPlants } from './../powerPlants'; export enum Regions { Pink = 'pink', // UK / Ireland / Low Countries @@ -377,13 +377,15 @@ export const map: GameMap = { // remaining 8 plants into 4 actual + 4 future without drawing from the deck. setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, catalogue?: PowerPlant[]) { const powerPlantsDeck = cloneDeep(catalogue ?? powerPlants); + const lastPlug = lastPlugPlant(catalogue ?? powerPlants); const step3Index = powerPlantsDeck.findIndex((p) => p.type === PowerPlantType.Step3); const step3 = powerPlantsDeck.splice(step3Index, 1)[0]; - // powerPlants is sorted by number, so the first 13 cards are the plug pool (3–15). - const plugPool = shuffle(powerPlantsDeck.splice(0, 13), rng() + ''); - const socketPool = shuffle(powerPlantsDeck, rng() + ''); // the remaining >= 16 cards + // The catalogue is sorted by number, so the plug pool (03–15, or 01–16 in Deck 1) comes first. + const plugCount = powerPlantsDeck.filter((p) => p.number <= lastPlug).length; + const plugPool = shuffle(powerPlantsDeck.splice(0, plugCount), rng() + ''); + const socketPool = shuffle(powerPlantsDeck, rng() + ''); // the remaining socket cards const initialPlants = plugPool.splice(0, 9).sort((a, b) => a.number - b.number); const actualMarket = initialPlants.slice(0, 4); @@ -407,10 +409,10 @@ export const map: GameMap = { const remainingPlug = plugPool.slice(plugToRemove); const remainingSocket = socketPool.slice(socketToRemove + plugShortfall); - // Build the draw stack — rule: never leave a plug card (<= 15) on top. + // Build the draw stack — rule: never leave a plug card on top. const deck = shuffle(remainingPlug.concat(remainingSocket), rng() + ''); - if (deck.length > 0 && deck[0].number <= 15) { - const socketIndex = deck.findIndex((p) => p.number > 15); + if (deck.length > 0 && deck[0].number <= lastPlug) { + const socketIndex = deck.findIndex((p) => p.number > lastPlug); if (socketIndex > 0) { [deck[0], deck[socketIndex]] = [deck[socketIndex], deck[0]]; } diff --git a/engine/src/powerPlants.ts b/engine/src/powerPlants.ts index a034ee7c..1fc21b29 100644 --- a/engine/src/powerPlants.ts +++ b/engine/src/powerPlants.ts @@ -1,5 +1,5 @@ import { cloneDeep } from 'lodash'; -import { PowerPlant, PowerPlantType } from './gamestate'; +import { GameOptions, PowerPlant, PowerPlantDeck, PowerPlantType } from './gamestate'; const powerPlants: PowerPlant[] = [ { number: 3, type: PowerPlantType.Oil, cost: 2, citiesPowered: 1 }, @@ -56,9 +56,9 @@ indiaPowerPlants.forEach((pp) => { } }); -// Alternate power plant deck: the same card numbers as the standard deck, with -// different fuels and outputs. Its water plants count as garbage plants. Plants -// 8, 22 and 35 burn any mix of garbage and oil. +// Deck 2, The New Power Plants – Set 2 (the "alternate" deck): the same card +// numbers as the standard deck, with different fuels and outputs. Plants 8, 22 +// and 35 burn any mix of garbage and oil. const alternatePowerPlants: PowerPlant[] = [ { number: 3, type: PowerPlantType.Coal, cost: 2, citiesPowered: 1 }, { number: 4, type: PowerPlantType.Coal, cost: 2, citiesPowered: 1 }, @@ -105,23 +105,114 @@ const alternatePowerPlants: PowerPlant[] = [ { number: 99, type: PowerPlantType.Step3, cost: 0, citiesPowered: 6 }, ]; -// The maps the alternate deck can be played on. The alternatePowerPlants option is -// ignored on every other map. +// Deck 1, The New Power Plants (Set 1 in Recharged), as its Variant 1 plays it: +// the big plants 52, 54, 57 and 60 stay in the box. Hybrids burn coal and oil; +// 44 is a fusion plant. +const deck1PowerPlants: PowerPlant[] = [ + { number: 1, type: PowerPlantType.Coal, cost: 3, citiesPowered: 1 }, + { number: 2, type: PowerPlantType.Oil, cost: 2, citiesPowered: 1 }, + { number: 3, type: PowerPlantType.Coal, cost: 1, citiesPowered: 1 }, + { number: 4, type: PowerPlantType.Hybrid, cost: 1, citiesPowered: 1 }, + { number: 5, type: PowerPlantType.Hybrid, cost: 3, citiesPowered: 2 }, + { number: 6, type: PowerPlantType.Garbage, cost: 1, citiesPowered: 2 }, + { number: 7, type: PowerPlantType.Oil, cost: 2, citiesPowered: 2 }, + { number: 8, type: PowerPlantType.Uranium, cost: 1, citiesPowered: 2 }, + { number: 9, type: PowerPlantType.Coal, cost: 3, citiesPowered: 3 }, + { number: 10, type: PowerPlantType.Wind, cost: 0, citiesPowered: 1 }, + { number: 11, type: PowerPlantType.Wind, cost: 0, citiesPowered: 1 }, + { number: 12, type: PowerPlantType.Oil, cost: 1, citiesPowered: 2 }, + { number: 13, type: PowerPlantType.Hybrid, cost: 1, citiesPowered: 2 }, + { number: 14, type: PowerPlantType.Coal, cost: 3, citiesPowered: 4 }, + { number: 15, type: PowerPlantType.Wind, cost: 0, citiesPowered: 2 }, + { number: 16, type: PowerPlantType.Wind, cost: 0, citiesPowered: 2 }, + { number: 19, type: PowerPlantType.Uranium, cost: 1, citiesPowered: 3 }, + { number: 20, type: PowerPlantType.Oil, cost: 1, citiesPowered: 3 }, + { number: 21, type: PowerPlantType.Garbage, cost: 3, citiesPowered: 5 }, + { number: 22, type: PowerPlantType.Oil, cost: 2, citiesPowered: 4 }, + { number: 23, type: PowerPlantType.Coal, cost: 2, citiesPowered: 4 }, + { number: 24, type: PowerPlantType.Uranium, cost: 1, citiesPowered: 4 }, + { number: 25, type: PowerPlantType.Oil, cost: 3, citiesPowered: 5 }, + { number: 26, type: PowerPlantType.Wind, cost: 0, citiesPowered: 3 }, + { number: 27, type: PowerPlantType.Garbage, cost: 1, citiesPowered: 4 }, + { number: 28, type: PowerPlantType.Coal, cost: 3, citiesPowered: 5 }, + { number: 29, type: PowerPlantType.Hybrid, cost: 3, citiesPowered: 5 }, + { number: 30, type: PowerPlantType.Coal, cost: 1, citiesPowered: 4 }, + { number: 31, type: PowerPlantType.Uranium, cost: 1, citiesPowered: 5 }, + { number: 32, type: PowerPlantType.Wind, cost: 0, citiesPowered: 4 }, + { number: 33, type: PowerPlantType.Garbage, cost: 2, citiesPowered: 5 }, + { number: 34, type: PowerPlantType.Coal, cost: 3, citiesPowered: 6 }, + { number: 35, type: PowerPlantType.Coal, cost: 1, citiesPowered: 5 }, + { number: 36, type: PowerPlantType.Hybrid, cost: 2, citiesPowered: 6 }, + { number: 37, type: PowerPlantType.Oil, cost: 3, citiesPowered: 7 }, + { number: 38, type: PowerPlantType.Uranium, cost: 1, citiesPowered: 6 }, + { number: 39, type: PowerPlantType.Wind, cost: 0, citiesPowered: 5 }, + { number: 40, type: PowerPlantType.Oil, cost: 1, citiesPowered: 6 }, + { number: 42, type: PowerPlantType.Garbage, cost: 2, citiesPowered: 7 }, + { number: 44, type: PowerPlantType.Nuclear, cost: 0, citiesPowered: 6 }, + { number: 46, type: PowerPlantType.Coal, cost: 2, citiesPowered: 7 }, + { number: 50, type: PowerPlantType.Uranium, cost: 2, citiesPowered: 8 }, + { number: 99, type: PowerPlantType.Step3, cost: 0, citiesPowered: 6 }, +]; + +// The maps the legacy alternatePowerPlants option was honoured on. Games created +// with it keep exactly that behaviour; new games pick a deck with powerPlantDeck. const ALTERNATE_DECK_MAPS: string[] = ['Europe', 'North America']; function usesAlternateDeck(mapName: string, alternate: boolean | undefined): boolean { return !!alternate && ALTERNATE_DECK_MAPS.includes(mapName); } +// Maps whose setup or rules name plant numbers of the standard deck. They take +// only the standard deck until their changes for the other decks are in. +const STANDARD_DECK_ONLY_MAPS: string[] = [ + 'Australia', + 'Brazil', + 'Bremen', + 'China', + 'France', + 'India', + 'Italy', + 'Manhattan', + 'Middle East', + 'Northern Europe', + 'Quebec', + 'Russia', + 'South Africa', + 'Spain & Portugal', + 'UK & Ireland', +]; + +function availableDecks(mapName: string): PowerPlantDeck[] { + return STANDARD_DECK_ONLY_MAPS.includes(mapName) ? ['standard'] : ['standard', 'deck1', 'deck2']; +} + +// The deck a game plays with. Games from before powerPlantDeck may carry the +// legacy alternatePowerPlants flag, which setup only stored where it applied. +function gameDeck(options: GameOptions): PowerPlantDeck { + return ( + (options.powerPlantDeck as PowerPlantDeck | undefined) ?? (options.alternatePowerPlants ? 'deck2' : 'standard') + ); +} + // The full card list a game on this map draws from. -function plantCatalogue(mapName: string, alternate?: boolean): PowerPlant[] { +function plantCatalogue(mapName: string, deck: PowerPlantDeck = 'standard'): PowerPlant[] { if (mapName == 'India') { return indiaPowerPlants; } - return usesAlternateDeck(mapName, alternate) ? alternatePowerPlants : powerPlants; + return deck == 'deck1' ? deck1PowerPlants : deck == 'deck2' ? alternatePowerPlants : powerPlants; } +// Plants with a plug on the back form the weak opening pool: 03–15 in the +// standard and alternate decks, 01–16 in Deck 1. +function lastPlugPlant(catalogue: PowerPlant[]): number { + return catalogue === deck1PowerPlants ? 16 : 15; +} + +// The nuclear plant whose purchase ends uranium resupply in Recharged Germany, +// per each deck's map-changes sheet. +const PHASE_OUT_PLANT: Record = { standard: 39, deck1: 38, deck2: 37 }; + // Wind plants are always ecological. Whether fusion plants are too is a ruling: // new games take this value and store it as options.fusionEcological on the maps // in ECOLOGICAL_RULE_MAPS, so changing it only affects games created afterwards. @@ -139,9 +230,15 @@ export { powerPlants, indiaPowerPlants, alternatePowerPlants, + deck1PowerPlants, ALTERNATE_DECK_MAPS, usesAlternateDeck, + STANDARD_DECK_ONLY_MAPS, + availableDecks, + gameDeck, plantCatalogue, + lastPlugPlant, + PHASE_OUT_PLANT, FUSION_IS_ECOLOGICAL, ECOLOGICAL_RULE_MAPS, isEcological, diff --git a/viewer/src/self-contained.ts b/viewer/src/self-contained.ts index 0f9110b4..008c19ea 100644 --- a/viewer/src/self-contained.ts +++ b/viewer/src/self-contained.ts @@ -1,6 +1,6 @@ import { cloneDeep } from 'lodash'; import { Move, Phase, setup, stripSecret } from 'powergrid-engine'; -import type { GameState, MapName, Variant } from 'powergrid-engine/src/gamestate'; +import type { GameState, MapName, PowerPlantDeck, Variant } from 'powergrid-engine/src/gamestate'; import type { PremoveCommand } from 'powergrid-engine/src/premoves'; import { move as wrapperMove, moveAI as wrapperAI } from 'powergrid-engine/wrapper'; import { installLocalChat } from './game-chat'; @@ -29,6 +29,8 @@ function launchSelfContained(selector = '#app') { randomizeMap: params.get('randomize') === '1', chooseRegions: params.get('regions') === '1', alternatePowerPlants: params.get('altdeck') === '1', + // deck=deck1|deck2|random; altdeck=1 is the legacy Deck 2 switch (Europe/North America only). + powerPlantDeck: (params.get('deck') || undefined) as PowerPlantDeck | 'random' | undefined, }; let gameState = setup(Number(params.get('players')) || 6, gameOptions, params.get('seed') || '0'); diff --git a/viewer/src/util/journal-plant.ts b/viewer/src/util/journal-plant.ts index f6165687..38c29227 100644 --- a/viewer/src/util/journal-plant.ts +++ b/viewer/src/util/journal-plant.ts @@ -1,6 +1,7 @@ import type { GameState } from 'powergrid-engine'; import { getPowerPlant } from 'powergrid-engine/src/engine'; import type { PowerPlant } from 'powergrid-engine/src/gamestate'; +import { gameDeck } from 'powergrid-engine/src/powerPlants'; export function journalPlant(state: GameState, number: number): PowerPlant | undefined { const livePlant = [ @@ -12,7 +13,7 @@ export function journalPlant(state: GameState, number: number): PowerPlant | und // Discarded cards still appear in the journal; reconstruct the regional deck // using the same region order as setup, without needing the hidden draw pile. - let plant = getPowerPlant(number, state.map.name, !!state.options.alternatePowerPlants); + let plant = getPowerPlant(number, state.map.name, gameDeck(state.options)); for (const region of new Set(state.map.cities.map((city) => city.region))) { plant = state.map.regionalPowerPlants?.[region]?.find((card) => card.number === number) ?? plant; } diff --git a/viewer/tests/unit/journal-plant.spec.ts b/viewer/tests/unit/journal-plant.spec.ts index 850359bb..14e7c053 100644 --- a/viewer/tests/unit/journal-plant.spec.ts +++ b/viewer/tests/unit/journal-plant.spec.ts @@ -20,12 +20,19 @@ describe('journal plant illustrations', () => { } }); - it('retains the standard and alternate deck lookup for discarded cards', () => { - for (const alternatePowerPlants of [false, true]) { - const state = setup(3, { map: 'Germany', alternatePowerPlants }, 'journal'); + it("looks up discarded cards in the game's deck", () => { + for (const powerPlantDeck of ['standard', 'deck1', 'deck2'] as const) { + const state = setup(3, { map: 'Germany', powerPlantDeck }, 'journal'); state.actualMarket = []; state.futureMarket = []; - expect(journalPlant(state, 18)).to.deep.equal(getPowerPlant(18, 'Germany', alternatePowerPlants)); + expect(journalPlant(state, 20)).to.deep.equal(getPowerPlant(20, 'Germany', powerPlantDeck)); } }); + + it('reads the legacy alternate deck flag where it applied', () => { + const state = setup(3, { map: 'Europe', alternatePowerPlants: true }, 'journal'); + state.actualMarket = []; + state.futureMarket = []; + expect(journalPlant(state, 20)).to.deep.equal(getPowerPlant(20, 'Europe', 'deck2')); + }); }); From 4070ac632574ce80c9e05842c432df478f929b3d Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:50:09 -0600 Subject: [PATCH 3/4] Offer Deck 1 and Deck 2 on every map Map rules that name plant numbers now look them up per deck in deckChanges.ts: removed plants, market openers, top-of-deck plants and Spain & Portugal's Step 2 plants, from the decks' 2024 map-changes sheets. Map setup and the deck analysis share that table. Original-edition games use the same numbers, except France with Deck 1, which follows The New Power Plants rulebook (remove 11). Australia, India, South Africa, Spain & Portugal and UK & Ireland now use the shared default setup instead of their own copies. India has a version of each deck. Italy's uranium phase-out uses Germany's plant per deck. Northern Europe's regional cards replace the deck's plants by number, and a regional plant the deck lacks is shuffled into the draw pile. Middle East closes garbage purchases in Step 1, for Deck 2's garbage/oil hybrids. Standard-deck setups are unchanged for old and new games. Also stops the ecological replay test from comparing the transient pendingMessages queue. Co-Authored-By: Claude Opus 5.5 --- engine/src/analysis.ts | 60 ++++---- engine/src/available-moves.ts | 5 +- engine/src/deckChanges.ts | 108 +++++++++++++ engine/src/decks.spec.ts | 251 +++++++++++++++++++++++++++---- engine/src/ecological.spec.ts | 7 +- engine/src/engine.ts | 94 +++++++++--- engine/src/maps.ts | 10 +- engine/src/maps/australia.ts | 61 +------- engine/src/maps/brazil.ts | 45 +++--- engine/src/maps/bremen.ts | 40 ++--- engine/src/maps/china.ts | 19 +-- engine/src/maps/europe.ts | 10 +- engine/src/maps/france.ts | 88 +++++------ engine/src/maps/indian.ts | 77 +--------- engine/src/maps/manhattan.ts | 22 ++- engine/src/maps/quebec.ts | 59 ++++++-- engine/src/maps/russia.ts | 34 +++-- engine/src/maps/southafrica.ts | 62 +------- engine/src/maps/spainportugal.ts | 64 +------- engine/src/maps/ukireland.ts | 68 +-------- engine/src/powerPlants.ts | 63 ++++---- 21 files changed, 670 insertions(+), 577 deletions(-) create mode 100644 engine/src/deckChanges.ts diff --git a/engine/src/analysis.ts b/engine/src/analysis.ts index 557489c8..0b0f8570 100644 --- a/engine/src/analysis.ts +++ b/engine/src/analysis.ts @@ -1,5 +1,12 @@ import seedrandom from 'seedrandom'; import { availableMoves } from './available-moves'; +import { + originalTopPlants, + QUEBEC_RECHARGED_TOP_PLANTS, + removedPlants, + RUSSIA_ORIGINAL_NEXT_PLANTS, + SPAIN_STEP_2_PLANTS, +} from './deckChanges'; import { defaultSetupDeck, reconstructState, setup, stripSecret } from './engine'; import { GameState, PowerPlant, PowerPlantType } from './gamestate'; import { gameDeck, isEcological, lastPlugPlant, plantCatalogue } from './powerPlants'; @@ -15,19 +22,12 @@ export function createAnalysisScenario( const rng = seedrandom(seed); const name = s.map.name; const n = s.players.length; - let excluded: number[] = []; - if (name === 'France') excluded = [13]; - if (name === 'Russia') excluded = [6, 14]; - if (name === 'Australia') excluded = [17]; - if (name === 'South Africa') excluded = [7]; - if (name === 'Bremen') excluded = [11, 17, 23, 28, 34, 36, 38, 39, 46, ...(n <= 4 ? [31, 50] : [])]; - if (name === 'Manhattan') excluded = [99, ...(n <= 3 ? [20, 22, 37] : [])]; - if (name === 'China') - excluded = - n <= 3 ? [3, 4, 9, 11, 16, 18, 20, 24, 30, 33, 46] : n === 4 ? [3, 4, 11, 18, 24, 33, 46] : [3, 4, 33]; - if (name === 'Spain & Portugal' && s.step === 1) excluded = [18, 22, 27]; - const deckCatalogue = plantCatalogue(name, gameDeck(s.options)); - const catalogue = JSON.parse(JSON.stringify(deckCatalogue)) as PowerPlant[]; + const powerPlantDeck = gameDeck(s.options); + // Spain & Portugal's held-back plants return at Step 2; Manhattan boxes its Step 3 card. + let excluded = removedPlants(name, powerPlantDeck, n, s.options.variant!); + if (name === 'Spain & Portugal' && s.step !== 1) excluded = []; + if (name === 'Manhattan') excluded = [99, ...excluded]; + const catalogue = JSON.parse(JSON.stringify(plantCatalogue(name, powerPlantDeck))) as PowerPlant[]; for (const region of new Set(s.regionDraft ? [] : s.map.cities.map((c) => c.region))) { for (const replacement of s.map.regionalPowerPlants?.[region] || []) { const i = catalogue.findIndex((p) => p.number === replacement.number); @@ -101,24 +101,22 @@ export function createAnalysisScenario( const fixedTop: number[] = []; const original = s.options.variant === 'original'; if (original && name !== 'China' && name !== 'Manhattan') { - const opening = - name === 'France' ? [11] : name === 'Quebec' ? [13, 18, 22] : name === 'Brazil' ? [13, 14] : [13]; - for (const number of opening) if (!seen.has(number)) fixedTop.push(number); + for (const number of originalTopPlants(name, powerPlantDeck)) if (!seen.has(number)) fixedTop.push(number); } if (!original && name === 'Quebec') { // The first low plant is followed by the two guaranteed ecological plants. - for (const number of [18, 22]) if (!seen.has(number)) fixedTop.push(number); + for (const number of QUEBEC_RECHARGED_TOP_PLANTS[powerPlantDeck]) if (!seen.has(number)) fixedTop.push(number); } if (name === 'Spain & Portugal' && s.step === 2) { - for (const number of [18, 22, 27]) if (!seen.has(number)) fixedTop.push(number); + for (const number of SPAIN_STEP_2_PLANTS[powerPlantDeck]) if (!seen.has(number)) fixedTop.push(number); } const top = fixedTop.map(byNumber).filter((p) => candidates.some((c) => c.number === p.number)); candidates = candidates.filter((p) => !top.some((t) => t.number === p.number)); const mandatory = (p: PowerPlant) => - (name === 'Brazil' && p.type === PowerPlantType.Garbage && p.number > 14) || + (name === 'Brazil' && p.type === PowerPlantType.Garbage && p.number > lastPlugPlant(powerPlantDeck)) || // Games without the stored ruling built their Québec deck protecting fusion too. (name === 'Quebec' && isEcological(p, s.options.fusionEcological ?? true)) || - (name === 'Russia' && original && [10, 11].includes(p.number)); + (name === 'Russia' && original && RUSSIA_ORIGINAL_NEXT_PLANTS[powerPlantDeck].includes(p.number)); // A plug-back top card is public information in the recharged edition. if ( (!original || name === 'Manhattan') && @@ -129,7 +127,8 @@ export function createAnalysisScenario( candidates = candidates.filter(mandatory).concat(candidates.filter((p) => !mandatory(p))); const index = candidates.findIndex( (p) => - p.number <= 15 === (name === 'Manhattan' && s.knownPowerPlantDeck.length === 8 ? true : s.nextCardWeak) + p.number <= lastPlugPlant(powerPlantDeck) === + (name === 'Manhattan' && s.knownPowerPlantDeck.length === 8 ? true : s.nextCardWeak) ); if (index >= 0) top.unshift(...candidates.splice(index, 1)); } @@ -150,10 +149,12 @@ export function createAnalysisScenario( n, s.options.variant!, seedrandom(seed + ':catalogue-counts'), - s.options.useNewRechargedSetup ?? true + s.options.useNewRechargedSetup ?? true, + powerPlantDeck, + name ) : setup(n, s.options, seed + ':catalogue-counts'); - const threshold = name === 'India' ? 16 : lastPlugPlant(deckCatalogue); + const threshold = name === 'India' ? 16 : lastPlugPlant(powerPlantDeck); const low = (p: PowerPlant) => p.number <= threshold; const initialLow = [...initial.actualMarket, ...initial.futureMarket, ...initial.powerPlantsDeck].filter( low @@ -173,9 +174,11 @@ export function createAnalysisScenario( if (name === 'Russia' && s.step < 3) { if (original) { const remainingWindow = Math.max(0, 6 - Math.max(0, s.knownPowerPlantDeck.length - 6)); - const first = deck.filter((p) => p.number === 13); - const guaranteed = deck.filter((p) => [10, 11].includes(p.number)); - const others = deck.filter((p) => ![10, 11, 13].includes(p.number)); + const topNumber = originalTopPlants(name, powerPlantDeck)[0]; + const nextNumbers = RUSSIA_ORIGINAL_NEXT_PLANTS[powerPlantDeck]; + const first = deck.filter((p) => p.number === topNumber); + const guaranteed = deck.filter((p) => nextNumbers.includes(p.number)); + const others = deck.filter((p) => p.number !== topNumber && !nextNumbers.includes(p.number)); deck = first.concat( shuffle( guaranteed.concat( @@ -185,7 +188,10 @@ export function createAnalysisScenario( ), others ); - } else deck = deck.filter((p) => p.number <= 15).concat(deck.filter((p) => p.number > 15)); + } else { + const lastPlug = lastPlugPlant(powerPlantDeck); + deck = deck.filter((p) => p.number <= lastPlug).concat(deck.filter((p) => p.number > lastPlug)); + } } if (name === 'China') { const low = deck.filter((p) => p.number <= 30).sort((a, b) => a.number - b.number); diff --git a/engine/src/available-moves.ts b/engine/src/available-moves.ts index daa61915..5e06da78 100644 --- a/engine/src/available-moves.ts +++ b/engine/src/available-moves.ts @@ -444,7 +444,10 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves { } } - if (allowSouth && G.garbageMarket > 0) { + // Middle East: no garbage in Step 1. Only Deck 2's garbage/oil hybrids could + // burn it then, since garbage plants leave the Step 1 market. + const garbageClosed = G.map.name == 'Middle East' && G.step == 1; + if (allowSouth && G.garbageMarket > 0 && !garbageClosed) { const garbagePrices = G.garbagePrices ?? prices[ResourceType.Garbage]; let price = garbagePrices[garbagePrices.length - G.garbageMarket]; diff --git a/engine/src/deckChanges.ts b/engine/src/deckChanges.ts new file mode 100644 index 00000000..da9370d2 --- /dev/null +++ b/engine/src/deckChanges.ts @@ -0,0 +1,108 @@ +import { PowerPlantDeck } from './gamestate'; + +// Map rules that name plant numbers, per deck: the standard deck's from each map's +// rules, Deck 1's and Deck 2's from their map-changes sheets (2F-Spiele, 2024). The +// sheets are written for Recharged; Original games use the same numbers, except +// where The New Power Plants rulebook covers the Original edition itself. + +type PerDeck = Record; + +// Plants out of the game from the start. Manhattan's boxed Step 3 card and India's +// plant (see plantCatalogue) are handled where those decks are built. +export function removedPlants(mapName: string, deck: PowerPlantDeck, numPlayers: number, variant: string): number[] { + const fewPlayers = numPlayers <= 3; + switch (mapName) { + case 'France': + // The New Power Plants rulebook: on France, simply remove plant 11. + return { standard: [13], deck1: variant == 'original' ? [11] : [10], deck2: [11] }[deck]; + case 'Russia': + return { standard: [6, 14], deck1: [6, 21], deck2: [5, 14] }[deck]; + case 'Australia': + return { standard: [17], deck1: [19], deck2: [21] }[deck]; + case 'South Africa': + return { standard: [7], deck1: [7], deck2: [10] }[deck]; + case 'Spain & Portugal': + // Held back until Step 2, then put on top of the deck. + return SPAIN_STEP_2_PLANTS[deck]; + case 'Bremen': { + const table: PerDeck = { + standard: [11, 17, 23, 28, 34, 36, 38, 39, 46, ...(numPlayers <= 4 ? [31, 50] : [])], + deck1: [8, 19, 24, 31, 37, 38, 42, 46, 50, ...(numPlayers <= 4 ? [34, 44] : [])], + deck2: [13, 21, 32, 33, 34, 37, 38, 46, 50, ...(numPlayers <= 4 ? [22, 44] : [])], + }; + return table[deck]; + } + case 'Manhattan': + return fewPlayers ? { standard: [20, 22, 37], deck1: [16, 28, 38], deck2: [22, 28, 36] }[deck] : []; + case 'China': { + const table: PerDeck = { + standard: [ + [3, 4, 9, 11, 16, 18, 20, 24, 30, 33, 46], + [3, 4, 11, 18, 24, 33, 46], + [3, 4, 33], + ], + deck1: [ + [1, 2, 8, 12, 15, 22, 27, 28, 32, 36, 42], + [1, 2, 8, 15, 27, 32, 36], + [1, 2, 32], + ], + deck2: [ + [3, 6, 10, 13, 17, 18, 25, 26, 31, 34, 35], + [3, 6, 13, 17, 26, 31, 35], + [3, 6, 31], + ], + }; + return table[deck][fewPlayers ? 0 : numPlayers == 4 ? 1 : 2]; + } + default: + return []; + } +} + +// Original edition: the plants placed on top of the deck, topmost first, once the +// eight smallest plants have opened the market. Plants not in the game are skipped. +export function originalTopPlants(mapName: string, deck: PowerPlantDeck): number[] { + switch (mapName) { + case 'France': + // The nuclear plant comes up first; Deck 1's (08) already opens the market. + return { standard: [11], deck1: [], deck2: [13] }[deck]; + case 'Brazil': + // The low biogas plants that don't open the market follow the usual top plant. + return { standard: [13, 14], deck1: [11], deck2: [13, 14] }[deck]; + case 'Quebec': + return { standard: [13, 18, 22], deck1: [11, 15, 16], deck2: [11, 17, 24] }[deck]; + default: + return { standard: [13], deck1: [11], deck2: [13] }[deck]; + } +} + +// Original Russia: the two plants shuffled into the top five cards, beneath the top +// plant; they are the two plants after the six-plant opening market. +export const RUSSIA_ORIGINAL_NEXT_PLANTS: PerDeck = { standard: [10, 11], deck1: [8, 9], deck2: [10, 11] }; + +// Recharged edition: plants that always open the market; the rest of its eight are +// drawn from the remaining plug cards. +export function rechargedMarketPlants(mapName: string, deck: PowerPlantDeck): number[] { + switch (mapName) { + case 'France': + return { standard: [11], deck1: [8], deck2: [13] }[deck]; + case 'Brazil': + return { standard: [6, 14], deck1: [6], deck2: [5, 14] }[deck]; + case 'Quebec': + // The standard deck's Québec market is drawn from 03–10 plus 13 instead. + return { standard: [], deck1: [11, 15, 16], deck2: [11] }[deck]; + default: + return []; + } +} + +// Recharged Québec: plants set aside and placed on top of the finished deck, topmost +// first. The standard deck puts them beneath one plug card; Deck 2 on the very top. +export const QUEBEC_RECHARGED_TOP_PLANTS: PerDeck = { standard: [18, 22], deck1: [], deck2: [17, 24] }; + +// Spain & Portugal: the ecological plants held back until Step 2, then drawn first. +export const SPAIN_STEP_2_PLANTS: PerDeck = { + standard: [18, 22, 27], + deck1: [15, 16, 26], + deck2: [17, 24, 28], +}; diff --git a/engine/src/decks.spec.ts b/engine/src/decks.spec.ts index 423cf87b..9308543d 100644 --- a/engine/src/decks.spec.ts +++ b/engine/src/decks.spec.ts @@ -2,10 +2,12 @@ import { expect } from 'chai'; import { range } from 'lodash'; import 'mocha'; import { availableMoves } from './available-moves'; -import { ended, move, moveAI, setup } from './engine'; -import { GameState, MapName, PowerPlantDeck, Variant } from './gamestate'; +import { QUEBEC_RECHARGED_TOP_PLANTS, removedPlants, SPAIN_STEP_2_PLANTS } from './deckChanges'; +import { ended, move, moveAI, setup, updatePlayerCapacity } from './engine'; +import { GameState, MapName, Phase, PowerPlantDeck, PowerPlantType, ResourceType, Variant } from './gamestate'; +import { maps, mapsRecharged } from './maps'; import { Move, MoveName } from './move'; -import { alternatePowerPlants, deck1PowerPlants, powerPlants } from './powerPlants'; +import { alternatePowerPlants, deck1PowerPlants, plantCatalogue, powerPlants } from './powerPlants'; const numbers = (plants: { number: number }[]) => plants.map((pp) => pp.number); const visible = (G: GameState) => [...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck]; @@ -54,11 +56,14 @@ describe('power plant decks', () => { expect(setup(3, { map: 'Korea', powerPlantDeck: 'deck2' }, 's').options.powerPlantDeck).to.equal('deck2'); }); - it('falls back to the standard deck on maps that need deck-specific changes', () => { - for (const map of ['France', 'China', 'Quebec', 'Italy'] as MapName[]) { - const G = setup(3, { map, variant: 'recharged', powerPlantDeck: 'deck1' }, 'fallback'); - expect(G.options).to.not.have.property('powerPlantDeck'); - expect(visible(G).some((pp) => pp.number == 1)).to.be.false; + it('offers every deck on every map', () => { + for (const variant of ['original', 'recharged'] as Variant[]) { + for (const map of (variant == 'original' ? maps : mapsRecharged).map((m) => m.name as MapName)) { + for (const deck of ['deck1', 'deck2'] as PowerPlantDeck[]) { + const G = setup(4, { map, variant, powerPlantDeck: deck }, 'every-map'); + expect(G.options.powerPlantDeck, `${map} ${variant} ${deck}`).to.equal(deck); + } + } } }); @@ -72,9 +77,6 @@ describe('power plant decks', () => { expect(JSON.stringify(G)).to.equal(JSON.stringify(direct)); } expect([...picked].sort()).to.deep.equal(['deck1', 'deck2', 'standard']); - - const france = setup(4, { map: 'France', powerPlantDeck: 'random' }, 'r'); - expect(france.options).to.not.have.property('powerPlantDeck'); }); it('keeps the legacy alternate deck flag working where it applied', () => { @@ -95,25 +97,26 @@ describe('power plant decks', () => { expect(overridden.options.powerPlantDeck).to.equal('deck1'); }); - it("ends Germany's uranium resupply on each deck's own nuclear plant", () => { - for (const [deck, plant] of [ - ['standard', 39], - ['deck1', 38], - ['deck2', 37], - ] as [PowerPlantDeck, number][]) { - for (const [number, triggers] of [ - [plant, true], - [plant == 39 ? 38 : 39, false], - ] as [number, boolean][]) { - const G = setup(3, { map: 'Germany', variant: 'recharged', powerPlantDeck: deck }, 'phase-out'); - G.actualMarket[0] = catalogues[deck].find((c) => c.number == number)!; - const player = G.currentPlayers[0]; - G.players[player].money = 100; - G.players[player].availableMoves = availableMoves(G, G.players[player]); - const after = move(G, { name: MoveName.ChoosePowerPlant, data: number } as Move, player); - expect(after.card39Bought, `${deck} ${number}`).to.equal(triggers); + it("ends Germany's and Italy's uranium resupply on each deck's own nuclear plant", () => { + for (const map of ['Germany', 'Italy'] as MapName[]) + for (const [deck, plant] of [ + ['standard', 39], + ['deck1', 38], + ['deck2', 37], + ] as [PowerPlantDeck, number][]) { + for (const [number, triggers] of [ + [plant, true], + [plant == 39 ? 38 : 39, false], + ] as [number, boolean][]) { + const G = setup(3, { map, variant: 'recharged', powerPlantDeck: deck }, 'phase-out'); + G.actualMarket[0] = catalogues[deck].find((c) => c.number == number)!; + const player = G.currentPlayers[0]; + G.players[player].money = 100; + G.players[player].availableMoves = availableMoves(G, G.players[player]); + const after = move(G, { name: MoveName.ChoosePowerPlant, data: number } as Move, player); + expect(after.card39Bought, `${map} ${deck} ${number}`).to.equal(triggers); + } } - } }); for (const [map, variant, deck] of [ @@ -146,3 +149,193 @@ describe('power plant decks', () => { }); } }); + +describe('power plant decks on maps with deck-specific changes', () => { + const decks: PowerPlantDeck[] = ['deck1', 'deck2']; + const variants: Variant[] = ['original', 'recharged']; + const seeds = range(6).map(String); + + it("leaves each deck's removed plants out and deals only that deck's cards", () => { + const maps: MapName[] = [ + 'France', + 'Russia', + 'Australia', + 'India', + 'South Africa', + 'Spain & Portugal', + 'Bremen', + 'Manhattan', + 'China', + 'Brazil', + 'Quebec', + 'UK & Ireland', + 'Middle East', + ]; + for (const map of maps) { + for (const deck of decks) { + for (const variant of variants) { + for (const players of [2, 3, 4, 5]) { + const G = setup(players, { map, variant, powerPlantDeck: deck }, `removed-${players}`); + const label = `${map} ${variant} ${deck} ${players}P`; + expect(G.options.powerPlantDeck, label).to.equal(deck); + const dealt = numbers(visible(G)); + for (const removed of removedPlants(map, deck, players, variant)) { + expect(dealt, label).to.not.include(removed); + } + expect(new Set(dealt).size, `${label} has no duplicates`).to.equal(dealt.length); + const catalogue = plantCatalogue(map, deck); + for (const pp of visible(G)) { + expect(pp, `${label} ${pp.number}`).to.deep.equal( + catalogue.find((c) => c.number == pp.number) + ); + } + } + } + } + } + }); + + it("opens the France market with each deck's nuclear plant", () => { + for (const seed of seeds) { + const deck1 = setup(4, { map: 'France', variant: 'recharged', powerPlantDeck: 'deck1' }, seed); + expect(numbers([...deck1.actualMarket, ...deck1.futureMarket])).to.include(8); + const deck2 = setup(4, { map: 'France', variant: 'recharged', powerPlantDeck: 'deck2' }, seed); + expect(numbers([...deck2.actualMarket, ...deck2.futureMarket])).to.include(13); + } + // Original: Deck 1 just loses plant 11 (its nuclear 08 opens the market); Deck 2's 13 goes on top. + const original1 = setup(4, { map: 'France', variant: 'original', powerPlantDeck: 'deck1' }, 'o'); + expect(numbers(original1.actualMarket.concat(original1.futureMarket))).to.deep.equal([1, 2, 3, 4, 5, 6, 7, 8]); + expect(numbers(visible(original1))).to.not.include(11); + const original2 = setup(4, { map: 'France', variant: 'original', powerPlantDeck: 'deck2' }, 'o'); + expect(original2.powerPlantsDeck[0].number).to.equal(13); + }); + + it('opens the Brazil market with the low biogas plants and keeps every biogas plant', () => { + for (const seed of seeds) { + for (const [deck, opening] of [ + ['deck1', [6]], + ['deck2', [5, 14]], + ] as [PowerPlantDeck, number[]][]) { + const G = setup(2, { map: 'Brazil', variant: 'recharged', powerPlantDeck: deck }, seed); + const market = numbers([...G.actualMarket, ...G.futureMarket]); + for (const number of opening) expect(market, `${deck} ${seed}`).to.include(number); + const garbage = plantCatalogue('Brazil', deck).filter((pp) => pp.type == PowerPlantType.Garbage); + for (const pp of garbage) expect(numbers(visible(G)), `${deck} ${seed}`).to.include(pp.number); + } + } + }); + + it("sets up Québec with each deck's ecological plants", () => { + for (const seed of seeds) { + const deck1 = setup(2, { map: 'Quebec', variant: 'recharged', powerPlantDeck: 'deck1' }, seed); + expect(numbers([...deck1.actualMarket, ...deck1.futureMarket])).to.include.members([11, 15, 16]); + const deck2 = setup(2, { map: 'Quebec', variant: 'recharged', powerPlantDeck: 'deck2' }, seed); + expect(numbers([...deck2.actualMarket, ...deck2.futureMarket])).to.include(11); + expect(numbers(deck2.powerPlantsDeck.slice(0, 2))).to.deep.equal(QUEBEC_RECHARGED_TOP_PLANTS.deck2); + for (const G of [deck1, deck2]) { + const deck = G.options.powerPlantDeck as PowerPlantDeck; + const wind = plantCatalogue('Quebec', deck).filter((pp) => pp.type == PowerPlantType.Wind); + expect(numbers(visible(G))).to.include.members(numbers(wind)); + } + } + const original1 = setup(3, { map: 'Quebec', variant: 'original', powerPlantDeck: 'deck1' }, 'o'); + expect(numbers(original1.powerPlantsDeck.slice(0, 3))).to.deep.equal([11, 15, 16]); + const original2 = setup(3, { map: 'Quebec', variant: 'original', powerPlantDeck: 'deck2' }, 'o'); + expect(numbers(original2.powerPlantsDeck.slice(0, 3))).to.deep.equal([11, 17, 24]); + }); + + it('opens China with the smallest remaining plants, one per player', () => { + const deck1 = setup(3, { map: 'China', variant: 'recharged', powerPlantDeck: 'deck1' }, 'c'); + expect(numbers(deck1.actualMarket)).to.deep.equal([3, 4, 5]); + const deck2 = setup(3, { map: 'China', variant: 'recharged', powerPlantDeck: 'deck2' }, 'c'); + expect(numbers(deck2.actualMarket)).to.deep.equal([4, 5, 7]); + }); + + it('holds the Spain & Portugal ecological plants back until Step 2', () => { + for (const deck of decks) { + let G = setup(4, { map: 'Spain & Portugal', variant: 'recharged', powerPlantDeck: deck }, `spain-${deck}`); + G.players.forEach((p, i) => { + p.name = `AI ${i}`; + p.isAI = true; + }); + const held = SPAIN_STEP_2_PLANTS[deck]; + for (const number of held) expect(numbers(visible(G)), deck).to.not.include(number); + for (let turn = 0; turn < 5000 && G.step == 1 && G.currentPlayers.length > 0; turn++) { + G = moveAI(G, G.currentPlayers[0]); + } + expect(G.step).to.equal(2); + // The first replaces the plant removed at the start of Step 2; the other two follow. + const known = numbers([...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck.slice(0, 2)]); + expect(known, deck).to.include.members(held); + } + }); + + it("swaps Northern Europe's regional cards in by the map's numbers", () => { + const regions = (G: GameState) => new Set(G.map.cities.map((city) => city.region)); + const G = range(40) + .map((seed) => + setup(5, { map: 'Northern Europe', variant: 'recharged', powerPlantDeck: 'deck1' }, `${seed}`) + ) + .find((game) => regions(game).has('yellow') && regions(game).has('pink'))!; + expect(G, 'a game with Sweden North and the Baltic States').to.exist; + const dealt = visible(G); + const byNumber = (number: number) => dealt.filter((pp) => pp.number == number); + // Sweden North's 21 and 25 replace Deck 1's; the Baltic 18, which Deck 1 lacks, is added. + expect(byNumber(21).map((pp) => pp.type)).to.deep.equal([PowerPlantType.Wind]); + expect(byNumber(25).map((pp) => pp.type)).to.deep.equal([PowerPlantType.Uranium]); + expect(byNumber(18).map((pp) => pp.type)).to.deep.equal([PowerPlantType.Oil]); + expect(G.powerPlantsDeck[G.powerPlantsDeck.length - 1].type).to.equal(PowerPlantType.Step3); + }); + + it('closes the garbage market in Middle East Step 1, even for Deck 2 hybrids', () => { + const G = setup(3, { map: 'Middle East', variant: 'recharged', powerPlantDeck: 'deck2' }, 'me'); + const player = G.players[0]; + player.powerPlants = [alternatePowerPlants.find((pp) => pp.number == 8)!]; + player.powerPlantsNotUsed = [8]; + updatePlayerCapacity(player); + player.money = 200; + G.phase = Phase.Resources; + const offered = (step: number) => { + G.step = step; + return (availableMoves(G, player)[MoveName.BuyResource] ?? []).map((r) => r.resource); + }; + expect(offered(1)).to.include(ResourceType.Oil); + expect(offered(1)).to.not.include(ResourceType.Garbage); + expect(offered(2)).to.include(ResourceType.Garbage); + }); + + for (const [map, variant, deck] of [ + ['Russia', 'original', 'deck1'], + ['China', 'recharged', 'deck2'], + ['Brazil', 'recharged', 'deck1'], + ['Quebec', 'original', 'deck2'], + ['Middle East', 'recharged', 'deck2'], + ['India', 'recharged', 'deck1'], + ['Bremen', 'recharged', 'deck1'], + ['Northern Europe', 'recharged', 'deck2'], + ['Northern Europe', 'original', 'deck1'], + ['Italy', 'recharged', 'deck1'], + ] as [MapName, Variant, PowerPlantDeck][]) { + it(`plays a whole bot game on ${map} ${variant} with ${deck} and replays it`, () => { + let G = setup(4, { map, variant, powerPlantDeck: deck }, `bots-${map}-${deck}`); + G.players.forEach((p, i) => { + p.name = `AI ${i}`; + p.isAI = true; + }); + for (let turn = 0; turn < 8000 && !ended(G) && G.currentPlayers.length > 0; turn++) { + G = moveAI(G, G.currentPlayers[0]); + } + expect(ended(G), 'game finished').to.be.true; + + let replay = setup(4, G.options, G.seed); + replay.players.forEach((p, i) => { + p.name = `AI ${i}`; + p.isAI = true; + }); + for (const item of G.log) { + if (item.type === 'move') replay = move(replay, item.move, item.player); + } + expect(replay.players.map((p) => p.money)).to.deep.equal(G.players.map((p) => p.money)); + }); + } +}); diff --git a/engine/src/ecological.spec.ts b/engine/src/ecological.spec.ts index 502ef2ce..b5ce5d6c 100644 --- a/engine/src/ecological.spec.ts +++ b/engine/src/ecological.spec.ts @@ -5,7 +5,12 @@ import { addPowerPlant, getPowerPlant, reconstructionOptions, setup } from './en import { GameState, PowerPlant, PowerPlantType, Variant } from './gamestate'; import { FUSION_IS_ECOLOGICAL, isEcological } from './powerPlants'; -const json = (G: GameState) => JSON.parse(JSON.stringify(G)); +// Pending step messages are an outbound queue the wrapper drains, and replay clears it. +const json = (G: GameState) => { + const copy = JSON.parse(JSON.stringify(G)); + delete copy.pendingMessages; + return copy; +}; function playAI(G: GameState, moves: number): GameState { for (let i = 0; i < moves && G.currentPlayers.length > 0; i++) G = moveAI(G, G.currentPlayers[0]); diff --git a/engine/src/engine.ts b/engine/src/engine.ts index 998259d9..002783a6 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -17,6 +17,7 @@ import { replacePoweringLog, restorePowering, } from './choice-revisions'; +import { originalTopPlants, removedPlants, SPAIN_STEP_2_PLANTS } from './deckChanges'; import { countHeldPowerPlants, GameOptions, @@ -39,11 +40,11 @@ import { availableDecks, ECOLOGICAL_RULE_MAPS, FUSION_IS_ECOLOGICAL, + gameDeck, isEcological, lastPlugPlant, PHASE_OUT_PLANT, plantCatalogue, - powerPlants, usesAlternateDeck, } from './powerPlants'; import prices from './prices'; @@ -65,22 +66,33 @@ const citiesToEndGameManhattan = [18, 17, 17, 15, 14]; const cityIncome = [10, 22, 33, 44, 54, 64, 73, 82, 90, 98, 105, 112, 118, 124, 129, 134, 138, 142, 145, 148, 150, 150]; const regionsInPlay = [3, 3, 4, 5, 5]; +// The base game's deck setup, with each map's removed and opening plants for the +// deck in play (see deckChanges.ts). Several maps call it with their own name. +// `step3FromBottom` puts the Step 3 card that many plants above the bottom. export function defaultSetupDeck( numPlayers: number, variant: string, rng: seedrandom.prng, useNewRechargedSetup: boolean, - deck: PowerPlant[] = powerPlants + deck: PowerPlantDeck = 'standard', + mapName = '', + step3FromBottom = 0 ) { let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; - let powerPlantsDeck: PowerPlant[] = cloneDeep(deck); + const removed = removedPlants(mapName, deck, numPlayers, variant); + const catalogue = plantCatalogue(mapName, deck).filter((p) => !removed.includes(p.number)); + let powerPlantsDeck: PowerPlant[] = cloneDeep(catalogue); if (variant == 'original') { - // The eight smallest plants open the market (03–10, or 01–08 in Deck 1); the - // third plant after them goes on top of the deck (13, or 11 in Deck 1). + // The eight smallest plants open the market; the map's opening plants (13 in + // the standard deck, 11 in Deck 1) then go on top of the deck. powerPlantsDeck = powerPlantsDeck.slice(8); - const topPlant = powerPlantsDeck.splice(2, 1)[0]; + const topPlants: PowerPlant[] = []; + for (const number of originalTopPlants(mapName, deck)) { + const index = powerPlantsDeck.findIndex((pp) => pp.number == number); + if (index >= 0) topPlants.push(powerPlantsDeck.splice(index, 1)[0]); + } const step3 = powerPlantsDeck.pop()!; powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); @@ -90,13 +102,13 @@ export function defaultSetupDeck( powerPlantsDeck = powerPlantsDeck.slice(4); } - powerPlantsDeck.unshift(topPlant); - powerPlantsDeck.push(step3); + powerPlantsDeck.unshift(...topPlants); + powerPlantsDeck.splice(powerPlantsDeck.length - step3FromBottom, 0, step3); - actualMarket = deck.slice(0, 4); - futureMarket = deck.slice(4, 8); + actualMarket = catalogue.slice(0, 4); + futureMarket = catalogue.slice(4, 8); } else { - const plugCount = deck.filter((p) => p.number <= lastPlugPlant(deck)).length; + const plugCount = catalogue.filter((p) => p.number <= lastPlugPlant(deck)).length; let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, plugCount), rng() + ''); let initialPlantMarket = initialPowerPlants.splice(0, 8); initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); @@ -125,7 +137,7 @@ export function defaultSetupDeck( } powerPlantsDeck.unshift(first); - powerPlantsDeck.push(step3); + powerPlantsDeck.splice(powerPlantsDeck.length - step3FromBottom, 0, step3); } return { actualMarket, futureMarket, powerPlantsDeck }; @@ -192,7 +204,6 @@ export function setup( : requestedDeck && decks.includes(requestedDeck) ? requestedDeck : 'standard'; - const deck = plantCatalogue(chosenMap.name, chosenDeck); const ecologicalRules = ECOLOGICAL_RULE_MAPS.includes(chosenMap.name) && !legacyEcological; let actualMarket: PowerPlant[]; @@ -209,7 +220,7 @@ export function setup( numPlayers, variant, rng, - chosenDeck != 'standard' ? deck : undefined, + chosenDeck, legacyEcological ? true : fusionEcological )); } else { @@ -218,7 +229,8 @@ export function setup( variant, rng, useNewRechargedSetup, - chosenDeck != 'standard' ? deck : undefined + chosenDeck, + chosenMap.name )); } } @@ -389,7 +401,15 @@ export function setup( } finalMap = filterMapToRegions(chosenMap, playRegions); - applyRegionalPowerPlants(chosenMap, playRegions, actualMarket, futureMarket, powerPlantsDeck); + applyRegionalPowerPlants( + chosenMap, + playRegions, + actualMarket, + futureMarket, + powerPlantsDeck, + chosenDeck, + seed + ); } } @@ -608,16 +628,22 @@ function filterMapToRegions(map: GameMap, playRegions: Set): GameMap { // Applies the map's regionalPowerPlants overrides for the chosen regions, swapping // the replacement plants into whichever of the three decks currently hold the // matching plant numbers. Mutates the passed arrays in place. +// The map's numbers win over the deck's: a regional card the deck has no plant for +// (Deck 1 has no 18) is shuffled into the draw pile, below its top card. function applyRegionalPowerPlants( map: GameMap, playRegions: Set, actualMarket: PowerPlant[], futureMarket: PowerPlant[], - powerPlantsDeck: PowerPlant[] + powerPlantsDeck: PowerPlant[], + deck: PowerPlantDeck, + seed: string ) { if (!map.regionalPowerPlants) { return; } + const catalogue = plantCatalogue(map.name, deck); + const rng = seedrandom(seed + '-regional'); for (const region of playRegions) { const replacements = map.regionalPowerPlants[region]; if (replacements) { @@ -629,6 +655,11 @@ function applyRegionalPowerPlants( swapIn(actualMarket); swapIn(futureMarket); swapIn(powerPlantsDeck); + if (!catalogue.some((p) => p.number === newPlant.number)) { + // Between the top card and the Step 3 card at the bottom. + const index = 1 + Math.floor(rng() * (powerPlantsDeck.length - 1)); + powerPlantsDeck.splice(index, 0, { ...newPlant }); + } } } } @@ -642,7 +673,15 @@ function finalizeRegions(G: GameState) { const fullMap = G.map; G.map = filterMapToRegions(fullMap, playRegions); - applyRegionalPowerPlants(fullMap, playRegions, G.actualMarket, G.futureMarket, G.powerPlantsDeck); + applyRegionalPowerPlants( + fullMap, + playRegions, + G.actualMarket, + G.futureMarket, + G.powerPlantsDeck, + gameDeck(G.options), + G.seed + ); // knownPowerPlantDeck mirrors actualMarket+futureMarket and must reflect any swap. G.knownPowerPlantDeck = G.actualMarket.concat(G.futureMarket); @@ -1106,12 +1145,14 @@ export function move( const powerPlant = G.actualMarket.shift()!; startStep(G, 2, `Starting Step 2, Power Plant ${powerPlant?.number} discarded.`); - // Spain & Portugal: put plants 18, 22 and 27 on top + // Spain & Portugal: put the held-back plants on top (18, 22, 27 in the standard deck) if (G.map.name == 'Spain & Portugal') { - if (!G.powerPlantsDeck.find((p) => p.number == 18)) { - G.powerPlantsDeck.unshift(getPowerPlant(27)); - G.powerPlantsDeck.unshift(getPowerPlant(22)); - G.powerPlantsDeck.unshift(getPowerPlant(18)); + const deck = gameDeck(G.options); + const held = SPAIN_STEP_2_PLANTS[deck]; + if (!G.powerPlantsDeck.find((p) => p.number == held[0])) { + G.powerPlantsDeck.unshift( + ...held.map((n) => getPowerPlant(n, G.map.name, deck)) + ); } } @@ -2203,7 +2244,9 @@ export function reconstructState(gameState: GameState, to?: number, forcePublicD G.players.length, G.options.variant!, seedrandom('public-replay'), - G.options.useNewRechargedSetup ?? true + G.options.useNewRechargedSetup ?? true, + gameDeck(G.options), + G.map.name ).powerPlantsDeck; removePlantsForMiddleEastStep1(G); G.players[G.currentPlayers[0]].availableMoves = availableMoves(G, G.players[G.currentPlayers[0]]); @@ -2594,7 +2637,8 @@ function removePlantsForMiddleEastStep1(G: GameState) { while (plantToRemove) { removePowerPlant(G, plantToRemove); - if ([6, 11, 14].includes(plantToRemove.number)) { + // Plug plants leave the game (06, 11 and 14 in the standard deck); others go under. + if (plantToRemove.number <= lastPlugPlant(gameDeck(G.options))) { G.log.push({ type: 'event', event: `Removing Power Plant ${plantToRemove.number} from game.`, diff --git a/engine/src/maps.ts b/engine/src/maps.ts index dcd20c77..9aafd4d4 100644 --- a/engine/src/maps.ts +++ b/engine/src/maps.ts @@ -1,5 +1,5 @@ import seedrandom from 'seedrandom'; -import { PowerPlant } from './gamestate'; +import { PowerPlant, PowerPlantDeck } from './gamestate'; import { map as america, mapRecharged as americaRecharged } from './maps/america'; import { map as australia } from './maps/australia'; import { map as badenwurttemberg } from './maps/badenwurttemberg'; @@ -118,14 +118,14 @@ export interface GameMap { oilPrices?: number[]; garbagePrices?: number[]; uraniumPrices?: number[]; - // `deck` is the alternate power plant list when the game uses it (only on maps - // in ALTERNATE_DECK_MAPS); otherwise undefined and the map uses its usual deck. - // `fusionEcological` is the game's ruling, for maps that protect ecological plants. + // `deck` is the power plant deck in play (see deckChanges.ts for the plant numbers + // map rules name per deck). `fusionEcological` is the game's ruling, for maps + // that protect ecological plants. setupDeck?: ( numPlayers: number, variant: string, rng: seedrandom.prng, - deck?: PowerPlant[], + deck?: PowerPlantDeck, fusionEcological?: boolean ) => { actualMarket: PowerPlant[]; diff --git a/engine/src/maps/australia.ts b/engine/src/maps/australia.ts index 0a488437..06f85ac2 100644 --- a/engine/src/maps/australia.ts +++ b/engine/src/maps/australia.ts @@ -1,11 +1,8 @@ // by John and Cici -import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; -import { getPowerPlant } from '../engine'; -import { PowerPlant } from '../gamestate'; -import { shuffle } from '../utils'; +import { defaultSetupDeck } from '../engine'; +import { PowerPlantDeck } from '../gamestate'; import { GameMap } from './../maps'; -import { powerPlants } from './../powerPlants'; export enum Regions { Pink = 'pink', @@ -206,58 +203,8 @@ export const map: GameMap = { // Everything else follows the standard recharged/original deck setup. Note #17 is // a socket card (>15), so unlike South Africa (which removes weak plant 07 and // shrinks its starter pool to 12), Australia's plug pool stays 13. - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - let powerPlantsDeck = cloneDeep(powerPlants).filter((pp) => pp.number !== 17); - let actualMarket: PowerPlant[]; - let futureMarket: PowerPlant[]; - - if (variant == 'original') { - powerPlantsDeck = powerPlantsDeck.slice(8); - const powerPlant13Idx = powerPlantsDeck.findIndex((pp) => pp.number == 13); - const powerPlant13 = powerPlantsDeck.splice(powerPlant13Idx, 1)[0]; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2 || numPlayers == 3) { - powerPlantsDeck = powerPlantsDeck.slice(8); - } else if (numPlayers == 4) { - powerPlantsDeck = powerPlantsDeck.slice(4); - } - - powerPlantsDeck.unshift(powerPlant13); - powerPlantsDeck.push(step3); - - actualMarket = [getPowerPlant(3), getPowerPlant(4), getPowerPlant(5), getPowerPlant(6)]; - futureMarket = [getPowerPlant(7), getPowerPlant(8), getPowerPlant(9), getPowerPlant(10)]; - } else { - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, 13), rng() + ''); - let initialPlantMarket = initialPowerPlants.splice(0, 8); - initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); - actualMarket = initialPlantMarket.slice(0, 4); - futureMarket = initialPlantMarket.slice(4); - - const first = initialPowerPlants.shift()!; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2) { - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(5).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 3) { - initialPowerPlants = initialPowerPlants.slice(2); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(6).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 4) { - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(3).concat(initialPowerPlants), rng() + ''); - } else { - powerPlantsDeck = shuffle(powerPlantsDeck.concat(initialPowerPlants), rng() + ''); - } - - powerPlantsDeck.unshift(first); - powerPlantsDeck.push(step3); - } - - return { actualMarket, futureMarket, powerPlantsDeck }; + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, deck: PowerPlantDeck = 'standard') { + return defaultSetupDeck(numPlayers, variant, rng, true, deck, 'Australia'); }, mapSpecificRules: 'Uranium mines: the five uranium power plants (11, 23, 28, 34, 39) are ' + diff --git a/engine/src/maps/brazil.ts b/engine/src/maps/brazil.ts index 940fe7b0..9c49703e 100644 --- a/engine/src/maps/brazil.ts +++ b/engine/src/maps/brazil.ts @@ -1,10 +1,10 @@ import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; -import { getPowerPlant } from '../engine'; -import { PowerPlant, PowerPlantType } from '../gamestate'; +import { originalTopPlants, rechargedMarketPlants } from '../deckChanges'; +import { PowerPlant, PowerPlantDeck, PowerPlantType } from '../gamestate'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; -import { powerPlants } from './../powerPlants'; +import { lastPlugPlant, plantCatalogue } from './../powerPlants'; export enum Regions { Red = 'red', @@ -233,18 +233,25 @@ export const map: GameMap = { ], // Recharged rulebook: coal 1–8, oil 3–8, biogas 6–8, uranium 14–16. startingResources: { original: [24, 18, 6, 2], recharged: [24, 18, 9, 2] }, - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - let powerPlantsDeck = cloneDeep(powerPlants); + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, deck: PowerPlantDeck = 'standard') { + const catalogue = plantCatalogue('Brazil', deck); + let powerPlantsDeck = cloneDeep(catalogue); let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; - // remove garbage power plants - const garbagePlants = powerPlantsDeck.filter((pp) => pp.type == PowerPlantType.Garbage && pp.number > 14); + // The biogas (garbage) plants beyond the plug cards sit out the player-count removal. + const garbagePlants = powerPlantsDeck.filter( + (pp) => pp.type == PowerPlantType.Garbage && pp.number > lastPlugPlant(deck) + ); if (variant == 'original') { + // 13, then the low biogas plant 14, go on top (see originalTopPlants). powerPlantsDeck = powerPlantsDeck.slice(8); - const powerPlant13 = powerPlantsDeck.splice(2, 1)[0]; - const powerPlant14 = powerPlantsDeck.splice(2, 1)[0]; + const topPlants: PowerPlant[] = []; + for (const number of originalTopPlants('Brazil', deck)) { + const index = powerPlantsDeck.findIndex((pp) => pp.number == number); + if (index >= 0) topPlants.push(powerPlantsDeck.splice(index, 1)[0]); + } const step3 = powerPlantsDeck.pop()!; powerPlantsDeck = shuffle( @@ -260,19 +267,21 @@ export const map: GameMap = { powerPlantsDeck = powerPlantsDeck.concat(garbagePlants); powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - powerPlantsDeck.unshift(powerPlant14); - powerPlantsDeck.unshift(powerPlant13); + powerPlantsDeck.unshift(...topPlants); powerPlantsDeck.push(step3); - actualMarket = [getPowerPlant(3), getPowerPlant(4), getPowerPlant(5), getPowerPlant(6)]; - futureMarket = [getPowerPlant(7), getPowerPlant(8), getPowerPlant(9), getPowerPlant(10)]; + actualMarket = catalogue.slice(0, 4); + futureMarket = catalogue.slice(4, 8); } else { - const powerPlant6 = powerPlantsDeck.splice(3, 1)[0]; - const powerPlant14 = powerPlantsDeck.splice(10, 1)[0]; + // The low biogas plants (06 and 14 in the standard deck) always open the market. + const marketPlants = rechargedMarketPlants('Brazil', deck); + const biogasPlants = powerPlantsDeck.filter((pp) => marketPlants.includes(pp.number)); + powerPlantsDeck = powerPlantsDeck.filter((pp) => !marketPlants.includes(pp.number)); + const plugCount = powerPlantsDeck.filter((pp) => pp.number <= lastPlugPlant(deck)).length; - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, 11), rng() + ''); - let initialPlantMarket = initialPowerPlants.splice(0, 6); - initialPlantMarket.push(powerPlant6, powerPlant14); + let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, plugCount), rng() + ''); + let initialPlantMarket = initialPowerPlants.splice(0, 8 - biogasPlants.length); + initialPlantMarket.push(...biogasPlants); initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); actualMarket = initialPlantMarket.slice(0, 4); futureMarket = initialPlantMarket.slice(4); diff --git a/engine/src/maps/bremen.ts b/engine/src/maps/bremen.ts index 4f85b923..bead0e66 100644 --- a/engine/src/maps/bremen.ts +++ b/engine/src/maps/bremen.ts @@ -1,11 +1,11 @@ // by John and Cici import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; -import { getPowerPlant } from '../engine'; -import { PowerPlant } from '../gamestate'; +import { originalTopPlants, removedPlants } from '../deckChanges'; +import { PowerPlant, PowerPlantDeck } from '../gamestate'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; -import { powerPlants } from './../powerPlants'; +import { lastPlugPlant, plantCatalogue } from './../powerPlants'; export enum Regions { Blue = 'blue', @@ -225,13 +225,10 @@ export const map: GameMap = { // market is then drawn from the remaining "weak" pool (≤15) per the recharged // convention — 8 open the market, one goes on top of the deck and the leftovers // are shuffled in — and NO further player-count deck reduction is applied. - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - const removed = new Set([11, 17, 23, 28, 34, 36, 38, 39, 46]); - if (numPlayers <= 4) { - removed.add(31); - removed.add(50); - } - const deck = cloneDeep(powerPlants).filter((pp) => !removed.has(pp.number)); + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, powerPlantDeck: PowerPlantDeck = 'standard') { + const removed = new Set(removedPlants('Bremen', powerPlantDeck, numPlayers, variant)); + const catalogue = plantCatalogue('Bremen', powerPlantDeck).filter((pp) => !removed.has(pp.number)); + const deck = cloneDeep(catalogue); // The Step 3 card is the last entry of the master list and is never removed. const step3 = deck.pop()!; @@ -239,24 +236,27 @@ export const map: GameMap = { let futureMarket: PowerPlant[]; if (variant == 'original') { - // Fixed opening market 3–6 / 7–10 (none of these are removed on Bremen). - actualMarket = [getPowerPlant(3), getPowerPlant(4), getPowerPlant(5), getPowerPlant(6)]; - futureMarket = [getPowerPlant(7), getPowerPlant(8), getPowerPlant(9), getPowerPlant(10)]; - const inMarket = new Set([3, 4, 5, 6, 7, 8, 9, 10]); - const plant13 = deck.find((pp) => pp.number == 13)!; - let rest = deck.filter((pp) => !inMarket.has(pp.number) && pp.number != 13); + // The eight smallest plants open the market (3–6 / 7–10 in the standard deck); + // the top plant (13) follows, unless the deck removes it here. + actualMarket = catalogue.slice(0, 4); + futureMarket = catalogue.slice(4, 8); + const inMarket = new Set(catalogue.slice(0, 8).map((pp) => pp.number)); + const topNumber = originalTopPlants('Bremen', powerPlantDeck)[0]; + const plant13 = deck.find((pp) => pp.number == topNumber); + let rest = deck.filter((pp) => !inMarket.has(pp.number) && pp != plant13); rest = shuffle(rest, rng() + ''); - rest.unshift(plant13); + if (plant13) rest.unshift(plant13); rest.push(step3); return { actualMarket, futureMarket, powerPlantsDeck: rest }; } - // Recharged: the opening 8 plants come from the ≤15 weak pool. + // Recharged: the opening 8 plants come from the weak (plug) pool. + const lastPlug = lastPlugPlant(powerPlantDeck); const weak = shuffle( - deck.filter((pp) => pp.number <= 15), + deck.filter((pp) => pp.number <= lastPlug), rng() + '' ); - let rest = deck.filter((pp) => pp.number > 15); + let rest = deck.filter((pp) => pp.number > lastPlug); const initialMarket = weak.splice(0, 8).sort((a, b) => a.number - b.number); actualMarket = initialMarket.slice(0, 4); futureMarket = initialMarket.slice(4); diff --git a/engine/src/maps/china.ts b/engine/src/maps/china.ts index 184d12a2..015950d4 100644 --- a/engine/src/maps/china.ts +++ b/engine/src/maps/china.ts @@ -1,6 +1,7 @@ import { cloneDeep } from 'lodash'; -import { PowerPlant, PowerPlantType } from '../gamestate'; -import { powerPlants } from '../powerPlants'; +import { removedPlants } from '../deckChanges'; +import { PowerPlant, PowerPlantDeck, PowerPlantType } from '../gamestate'; +import { plantCatalogue } from '../powerPlants'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; @@ -223,7 +224,7 @@ export const map: GameMap = { ], ], startingResources: [12, 12, 6, 0], - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, deck: PowerPlantDeck = 'standard') { /* Instructions for power plant setup: Depending on the number of players, remove the following power plants from the game and place them in the box: with 2 and 3 players: 3, 4, 9, 11, 16, 18, 20, 24, 30, 33, 46. @@ -235,15 +236,9 @@ export const map: GameMap = { already placed on the supply plants 5 – 30: sort in ascending order with 30 on the bottom and 5 on the top and place face-down on the supply */ - const allPlants = cloneDeep(powerPlants); - let plantsToRemove: number[] = []; - if (numPlayers == 2 || numPlayers == 3) { - plantsToRemove = [3, 4, 9, 11, 16, 18, 20, 24, 30, 33, 46]; - } else if (numPlayers == 4) { - plantsToRemove = [3, 4, 11, 18, 24, 33, 46]; - } else if (numPlayers == 5 || numPlayers == 6) { - plantsToRemove = [3, 4, 33]; - } + // The other decks remove their own plants (see removedPlants) but sort the same way. + const allPlants = cloneDeep(plantCatalogue('China', deck)); + const plantsToRemove = removedPlants('China', deck, numPlayers, variant); const filteredPlants = allPlants.filter((p) => !plantsToRemove.includes(p.number)); const lowPlants = filteredPlants.filter((p) => p.number <= 30); diff --git a/engine/src/maps/europe.ts b/engine/src/maps/europe.ts index 586f870d..da64f94d 100644 --- a/engine/src/maps/europe.ts +++ b/engine/src/maps/europe.ts @@ -1,10 +1,10 @@ // by John and Cici import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; -import { PowerPlant, PowerPlantType } from '../gamestate'; +import { PowerPlantDeck, PowerPlantType } from '../gamestate'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; -import { lastPlugPlant, powerPlants } from './../powerPlants'; +import { lastPlugPlant, plantCatalogue } from './../powerPlants'; export enum Regions { Pink = 'pink', // UK / Ireland / Low Countries @@ -375,9 +375,9 @@ export const map: GameMap = { // The Step 2 trigger for Europe is handled in engine.ts (special branch): it // removes the lowest plant from the current market once, then re-sorts the // remaining 8 plants into 4 actual + 4 future without drawing from the deck. - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, catalogue?: PowerPlant[]) { - const powerPlantsDeck = cloneDeep(catalogue ?? powerPlants); - const lastPlug = lastPlugPlant(catalogue ?? powerPlants); + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, powerPlantDeck: PowerPlantDeck = 'standard') { + const powerPlantsDeck = cloneDeep(plantCatalogue('Europe', powerPlantDeck)); + const lastPlug = lastPlugPlant(powerPlantDeck); const step3Index = powerPlantsDeck.findIndex((p) => p.type === PowerPlantType.Step3); const step3 = powerPlantsDeck.splice(step3Index, 1)[0]; diff --git a/engine/src/maps/france.ts b/engine/src/maps/france.ts index d7c85dd4..8b8e8aa8 100644 --- a/engine/src/maps/france.ts +++ b/engine/src/maps/france.ts @@ -1,10 +1,11 @@ import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; -import { getPowerPlant } from '../engine'; -import { PowerPlant } from '../gamestate'; +import { rechargedMarketPlants, removedPlants } from '../deckChanges'; +import { defaultSetupDeck } from '../engine'; +import { PowerPlantDeck } from '../gamestate'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; -import { powerPlants } from './../powerPlants'; +import { lastPlugPlant, plantCatalogue } from './../powerPlants'; export enum Regions { Brown = 'brown', @@ -236,60 +237,47 @@ export const map: GameMap = { ], // Recharged rulebook: coal 1–8, oil 3–8, garbage 6–8, uranium 5–16. startingResources: { original: [24, 18, 6, 8], recharged: [24, 18, 9, 8] }, - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - let powerPlantsDeck = cloneDeep(powerPlants); - let actualMarket: PowerPlant[]; - let futureMarket: PowerPlant[]; - + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, deck: PowerPlantDeck = 'standard') { + // Original: an ecological plant leaves the game and the nuclear plant goes on + // top (13 and 11 in the standard deck), as deckChanges.ts lists per deck. if (variant == 'original') { - powerPlantsDeck = powerPlantsDeck.slice(8); - const powerPlant11 = powerPlantsDeck.splice(0, 1)[0]; - powerPlantsDeck.splice(1, 1)[0]; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2 || numPlayers == 3) { - powerPlantsDeck = powerPlantsDeck.slice(8); - } else if (numPlayers == 4) { - powerPlantsDeck = powerPlantsDeck.slice(4); - } - - powerPlantsDeck.unshift(powerPlant11); - powerPlantsDeck.push(step3); - - actualMarket = [getPowerPlant(3), getPowerPlant(4), getPowerPlant(5), getPowerPlant(6)]; - futureMarket = [getPowerPlant(7), getPowerPlant(8), getPowerPlant(9), getPowerPlant(10)]; - } else { - // Remove power plant 13 - powerPlantsDeck.splice(10, 1)[0]; - const powerPlant11 = powerPlantsDeck.splice(8, 1)[0]; + return defaultSetupDeck(numPlayers, variant, rng, true, deck, 'France'); + } - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, 11), rng() + ''); - let initialPlantMarket = initialPowerPlants.splice(0, 7); - initialPlantMarket.push(powerPlant11); - initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); - actualMarket = initialPlantMarket.slice(0, 4); - futureMarket = initialPlantMarket.slice(4); + // Recharged: the ecological plant leaves the game and the nuclear plant (11 in + // the standard deck) always opens the market, with seven more plug cards. + const removed = removedPlants('France', deck, numPlayers, variant); + let powerPlantsDeck = cloneDeep(plantCatalogue('France', deck)).filter((p) => !removed.includes(p.number)); + const marketPlants = rechargedMarketPlants('France', deck); + const powerPlant11 = powerPlantsDeck.filter((p) => marketPlants.includes(p.number)); + powerPlantsDeck = powerPlantsDeck.filter((p) => !marketPlants.includes(p.number)); + const plugCount = powerPlantsDeck.filter((p) => p.number <= lastPlugPlant(deck)).length; - const first = initialPowerPlants.shift()!; - const step3 = powerPlantsDeck.pop()!; + let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, plugCount), rng() + ''); + let initialPlantMarket = initialPowerPlants.splice(0, 8 - powerPlant11.length); + initialPlantMarket.push(...powerPlant11); + initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); + const actualMarket = initialPlantMarket.slice(0, 4); + const futureMarket = initialPlantMarket.slice(4); - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2) { - powerPlantsDeck = shuffle(powerPlantsDeck.slice(6).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 3) { - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(6).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 4) { - powerPlantsDeck = shuffle(powerPlantsDeck.slice(3).concat(initialPowerPlants), rng() + ''); - } else { - powerPlantsDeck = shuffle(powerPlantsDeck.concat(initialPowerPlants), rng() + ''); - } + const first = initialPowerPlants.shift()!; + const step3 = powerPlantsDeck.pop()!; - powerPlantsDeck.unshift(first); - powerPlantsDeck.push(step3); + powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); + if (numPlayers == 2) { + powerPlantsDeck = shuffle(powerPlantsDeck.slice(6).concat(initialPowerPlants), rng() + ''); + } else if (numPlayers == 3) { + initialPowerPlants = initialPowerPlants.slice(1); + powerPlantsDeck = shuffle(powerPlantsDeck.slice(6).concat(initialPowerPlants), rng() + ''); + } else if (numPlayers == 4) { + powerPlantsDeck = shuffle(powerPlantsDeck.slice(3).concat(initialPowerPlants), rng() + ''); + } else { + powerPlantsDeck = shuffle(powerPlantsDeck.concat(initialPowerPlants), rng() + ''); } + powerPlantsDeck.unshift(first); + powerPlantsDeck.push(step3); + return { actualMarket, futureMarket, powerPlantsDeck }; }, deckBuild: { diff --git a/engine/src/maps/indian.ts b/engine/src/maps/indian.ts index b85e370d..8cafbddb 100644 --- a/engine/src/maps/indian.ts +++ b/engine/src/maps/indian.ts @@ -1,8 +1,5 @@ -import { cloneDeep } from 'lodash'; -import { getPowerPlant } from '../engine'; -import { PowerPlant } from '../gamestate'; -import { indiaPowerPlants } from '../powerPlants'; -import { shuffle } from '../utils'; +import { defaultSetupDeck } from '../engine'; +import { PowerPlantDeck } from '../gamestate'; import { GameMap } from './../maps'; export enum Regions { @@ -231,74 +228,8 @@ export const map: GameMap = { garbagePrices: [1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6], uraniumPrices: [1, 2, 3, 4, 5, 6, 7, 8], - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - let powerPlantsDeck = cloneDeep(indiaPowerPlants); - let actualMarket: PowerPlant[]; - let futureMarket: PowerPlant[]; - - // Except for adjusting the garbage plant cost, the setup is identical to the normal game. - if (variant == 'original') { - powerPlantsDeck = powerPlantsDeck.slice(8); - // Set aside plant 13 BY NUMBER: the pre-#113 splice(2, 1) assumed - // the standard list where index 2 is plant 13, but India has no - // plant 11, so it grabbed 14. Standard rule applies (Mike's - // follow-up ruling, 2026-08-13): 13 goes on top. - const powerPlant13Idx = powerPlantsDeck.findIndex((pp) => pp.number == 13); - const powerPlant13 = powerPlantsDeck.splice(powerPlant13Idx, 1)[0]; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2 || numPlayers == 3) { - powerPlantsDeck = powerPlantsDeck.slice(8); - } else if (numPlayers == 4) { - powerPlantsDeck = powerPlantsDeck.slice(4); - } - - powerPlantsDeck.unshift(powerPlant13); - powerPlantsDeck.push(step3); - - actualMarket = [ - getPowerPlant(3, 'India'), - getPowerPlant(4, 'India'), - getPowerPlant(5, 'India'), - getPowerPlant(6, 'India'), - ]; - futureMarket = [ - getPowerPlant(7, 'India'), - getPowerPlant(8, 'India'), - getPowerPlant(9, 'India'), - getPowerPlant(10, 'India'), - ]; - } else { - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, 12), rng() + ''); - let initialPlantMarket = initialPowerPlants.splice(0, 8); - initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); - actualMarket = initialPlantMarket.slice(0, 4); - futureMarket = initialPlantMarket.slice(4); - - const first = initialPowerPlants.shift()!; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2) { - // Recharged 2P removes 1 plug + 5 socket (was incorrectly lumped with 3P). - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(5).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 3) { - initialPowerPlants = initialPowerPlants.slice(2); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(6).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 4) { - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(3).concat(initialPowerPlants), rng() + ''); - } else { - powerPlantsDeck = shuffle(powerPlantsDeck.concat(initialPowerPlants), rng() + ''); - } - - powerPlantsDeck.unshift(first); - powerPlantsDeck.push(step3); - } - - return { actualMarket, futureMarket, powerPlantsDeck }; + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, deck: PowerPlantDeck = 'standard') { + return defaultSetupDeck(numPlayers, variant, rng, true, deck, 'India'); }, mapSpecificRules: 'The power grid will suffer a power outage if the number of cities built in one round is more than twice the number of players — 9 or more cities in a 4-player game. Every player is then penalized $3 for each city they own, though income for the round never falls below $0.\nPlayers take turns buying one resource at a time. The resource market is limited by step: only resources costing $3 or less may be bought in step 1, $5 or less in step 2, and the whole market is open from step 3.\nGarbage plants are less efficient. Their cost is one higher, but their storage capacity is not changed.\nPlayers must power as many cities as possible in each round.', diff --git a/engine/src/maps/manhattan.ts b/engine/src/maps/manhattan.ts index 1537d03e..b4cf993d 100644 --- a/engine/src/maps/manhattan.ts +++ b/engine/src/maps/manhattan.ts @@ -1,9 +1,11 @@ // by John and Cici import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; +import { removedPlants } from '../deckChanges'; +import { PowerPlantDeck } from '../gamestate'; import { shuffle } from '../utils'; import { City, Connection, GameMap } from './../maps'; -import { powerPlants } from './../powerPlants'; +import { lastPlugPlant, plantCatalogue } from './../powerPlants'; // Manhattan (RIO 686 Bremen/Manhattan expansion) is unlike every other board: // • A SINGLE region. Every building space holds exactly ONE house, at its printed @@ -336,27 +338,23 @@ export const map: GameMap = { // ≤ 15): box 4, keep 9 on top of the stack; the opening 8-card market is drawn // from them. "Socket" cards (> 15) form the rest of the stack. The deck-depletion // market lifecycle (separate-pile reshuffle → all-buyable → shrink) is Session 2. - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - const boxed = new Set(); - if (numPlayers <= 3) { - boxed.add(20); - boxed.add(22); - boxed.add(37); - } - const deck = cloneDeep(powerPlants).filter((pp) => !boxed.has(pp.number)); + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, powerPlantDeck: PowerPlantDeck = 'standard') { + const boxed = new Set(removedPlants('Manhattan', powerPlantDeck, numPlayers, variant)); + const deck = cloneDeep(plantCatalogue('Manhattan', powerPlantDeck)).filter((pp) => !boxed.has(pp.number)); // Last entry of the master list is the Step 3 card → boxed (dropped), so the // game can never advance out of Step 1. deck.pop(); + const lastPlug = lastPlugPlant(powerPlantDeck); const plug = shuffle( - deck.filter((pp) => pp.number <= 15), + deck.filter((pp) => pp.number <= lastPlug), rng() + '' ); const socket = shuffle( - deck.filter((pp) => pp.number > 15), + deck.filter((pp) => pp.number > lastPlug), rng() + '' ); - plug.splice(0, 4); // four plug cards to the box + plug.splice(0, plug.length - 9); // plug cards to the box, leaving nine (four in the standard deck) // Nine plug cards remain on top; draw the topmost 8 → market (ascending). const initialMarket = plug.splice(0, 8).sort((a, b) => a.number - b.number); const actualMarket = initialMarket.slice(0, 4); diff --git a/engine/src/maps/quebec.ts b/engine/src/maps/quebec.ts index a6d122b4..3a993902 100644 --- a/engine/src/maps/quebec.ts +++ b/engine/src/maps/quebec.ts @@ -1,7 +1,7 @@ import { cloneDeep } from 'lodash'; -import { getPowerPlant } from '../engine'; -import { PowerPlant } from '../gamestate'; -import { isEcological, powerPlants } from '../powerPlants'; +import { originalTopPlants, QUEBEC_RECHARGED_TOP_PLANTS, rechargedMarketPlants } from '../deckChanges'; +import { PowerPlant, PowerPlantDeck } from '../gamestate'; +import { isEcological, lastPlugPlant, plantCatalogue } from '../powerPlants'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; @@ -210,25 +210,26 @@ export const map: GameMap = { numPlayers: number, variant: string, rng: seedrandom.prng, - _deck?: PowerPlant[], + deck: PowerPlantDeck = 'standard', fusionEcological?: boolean ) { const ecological = (pp: PowerPlant) => isEcological(pp, fusionEcological); - let powerPlantsDeck = cloneDeep(powerPlants); + let powerPlantsDeck = cloneDeep(plantCatalogue('Quebec', deck)); let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; if (variant == 'original') { // No ecological plants will be discarded. - // 13, 18, 22 will be set aside and placed on top of the deck. - const setAsidePlants = powerPlantsDeck.filter((pp) => [13, 18, 22].includes(pp.number)); - const otherEcoPlants = powerPlantsDeck.filter((pp) => ecological(pp) && ![13, 18, 22].includes(pp.number)); + // 13, 18, 22 (in the standard deck) will be set aside and placed on top of the deck. + const topPlants = originalTopPlants('Quebec', deck); + const setAsidePlants = powerPlantsDeck.filter((pp) => topPlants.includes(pp.number)); + const otherEcoPlants = powerPlantsDeck.filter((pp) => ecological(pp) && !topPlants.includes(pp.number)); const nonEcoPlantsDeck = powerPlantsDeck.filter((pp) => !ecological(pp)); // Remove first eight plants for actual and future market. powerPlantsDeck = nonEcoPlantsDeck.slice(8); - actualMarket = [getPowerPlant(3), getPowerPlant(4), getPowerPlant(5), getPowerPlant(6)]; - futureMarket = [getPowerPlant(7), getPowerPlant(8), getPowerPlant(9), getPowerPlant(10)]; + actualMarket = nonEcoPlantsDeck.slice(0, 4); + futureMarket = nonEcoPlantsDeck.slice(4, 8); // Set aside Step 3 card. const step3 = powerPlantsDeck.pop()!; @@ -250,6 +251,44 @@ export const map: GameMap = { // Put Step 3 card on the bottom. powerPlantsDeck.push(step3); + } else if (deck != 'standard') { + // The decks' map-changes sheets: their ecological plants open the market (Deck + // 1: 11, 15, 16; Deck 2: 11) beside plug cards, and Deck 2 puts 17 and 24 on + // top of the finished deck. As on the standard deck, no ecological plant is removed. + const step3 = powerPlantsDeck.pop()!; + const marketPlants = rechargedMarketPlants('Quebec', deck); + const heldPlants = QUEBEC_RECHARGED_TOP_PLANTS[deck]; + const openingPlants = powerPlantsDeck.filter((pp) => marketPlants.includes(pp.number)); + const held = powerPlantsDeck.filter((pp) => heldPlants.includes(pp.number)); + const rest = powerPlantsDeck.filter( + (pp) => !marketPlants.includes(pp.number) && !heldPlants.includes(pp.number) + ); + const lastPlug = lastPlugPlant(deck); + + let plugs = shuffle( + rest.filter((pp) => pp.number <= lastPlug), + rng() + '' + ); + const initialPlantMarket = openingPlants + .concat(plugs.splice(0, 8 - openingPlants.length)) + .sort((a, b) => a.number - b.number); + actualMarket = initialPlantMarket.slice(0, 4); + futureMarket = initialPlantMarket.slice(4); + const first = plugs.shift()!; + + // Remove plug and socket plants by player count, skipping ecological ones. + const withoutNonEco = (pool: PowerPlant[], count: number) => { + const removed = pool.filter((pp) => !ecological(pp)).slice(0, count); + return pool.filter((pp) => !removed.includes(pp)); + }; + let sockets = shuffle( + rest.filter((pp) => pp.number > lastPlug), + rng() + '' + ); + plugs = withoutNonEco(plugs, [1, 2, 1][numPlayers - 2] ?? 0); + sockets = withoutNonEco(sockets, [5, 6, 3][numPlayers - 2] ?? 0); + + powerPlantsDeck = held.concat(first, shuffle(plugs.concat(sockets), rng() + ''), step3); } else { // No ecological plants will be discarded. // For recharged 2 player, remove a random 1 from 3-15 and random 5 from 16-50. diff --git a/engine/src/maps/russia.ts b/engine/src/maps/russia.ts index f660bb34..977ddfc9 100644 --- a/engine/src/maps/russia.ts +++ b/engine/src/maps/russia.ts @@ -1,7 +1,7 @@ import { cloneDeep } from 'lodash'; -import { getPowerPlant } from '../engine'; -import { PowerPlant } from '../gamestate'; -import { powerPlants } from '../powerPlants'; +import { originalTopPlants, removedPlants, RUSSIA_ORIGINAL_NEXT_PLANTS } from '../deckChanges'; +import { PowerPlant, PowerPlantDeck } from '../gamestate'; +import { lastPlugPlant, plantCatalogue } from '../powerPlants'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; @@ -230,24 +230,26 @@ export const map: GameMap = { ], ], startingResources: [18, 24, 0, 7], - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - let powerPlantsDeck = cloneDeep(powerPlants); + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, deck: PowerPlantDeck = 'standard') { + // Two plants leave the game: 06 and 14 in the standard deck. + const removed = removedPlants('Russia', deck, numPlayers, variant); + let powerPlantsDeck = cloneDeep(plantCatalogue('Russia', deck)).filter((p) => !removed.includes(p.number)); let actualMarket: PowerPlant[] = []; let futureMarket: PowerPlant[] = []; const step3 = powerPlantsDeck.pop()!; if (variant == 'original') { - // Set actual and future markets (plant 6 is skipped). - actualMarket = powerPlantsDeck.filter((p: PowerPlant) => p.number >= 3 && p.number <= 5); - futureMarket = powerPlantsDeck.filter((p: PowerPlant) => p.number >= 7 && p.number <= 9); + // The six smallest plants open the market (03–05 / 07–09 in the standard deck). + actualMarket = powerPlantsDeck.slice(0, 3); + futureMarket = powerPlantsDeck.slice(3, 6); - // Set aside plants 10, 11, 13. - const plant10 = getPowerPlant(10); - const plant11 = getPowerPlant(11); - const plant13 = getPowerPlant(13); + // Set aside the top plant (13) and the next two plants after the market (10, 11). + const rest = powerPlantsDeck.slice(6); + const plant13 = rest.find((p) => p.number == originalTopPlants('Russia', deck)[0])!; + const [plant10, plant11] = RUSSIA_ORIGINAL_NEXT_PLANTS[deck].map((n) => rest.find((p) => p.number == n)!); - // Shuffle rest of plants (skipping plant 14). - const mainPlants = powerPlantsDeck.filter((p: PowerPlant) => p.number == 12 || p.number >= 15); + // Shuffle the rest of the plants. + const mainPlants = rest.filter((p) => p != plant13 && p != plant10 && p != plant11); let mainPlantsShuffled = shuffle(mainPlants, rng() + ''); // Remove random set of plants based on number of players. @@ -264,10 +266,10 @@ export const map: GameMap = { // Place plant 13 on top of deck, shuffled cards with 10 and 11 next, then the rest of the deck, then the step 3 card. powerPlantsDeck = [plant13].concat(topPlants, mainPlantsShuffled.slice(3), step3); } else { - const initialPlants = powerPlantsDeck.filter((p) => p.number <= 15 && p.number != 6 && p.number != 14); + const initialPlants = powerPlantsDeck.filter((p) => p.number <= lastPlugPlant(deck)); const initialPlantsShuffled = shuffle(initialPlants, rng() + ''); - const mainPlants = powerPlantsDeck.filter((p) => p.number >= 16); + const mainPlants = powerPlantsDeck.filter((p) => p.number > lastPlugPlant(deck)); let mainPlantsShuffled = shuffle(mainPlants, rng() + ''); if (numPlayers == 2) { diff --git a/engine/src/maps/southafrica.ts b/engine/src/maps/southafrica.ts index 64bc42d6..741945b0 100644 --- a/engine/src/maps/southafrica.ts +++ b/engine/src/maps/southafrica.ts @@ -1,11 +1,8 @@ // by John and Cici -import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; -import { getPowerPlant } from '../engine'; -import { PowerPlant } from '../gamestate'; -import { shuffle } from '../utils'; +import { defaultSetupDeck } from '../engine'; +import { PowerPlantDeck } from '../gamestate'; import { GameMap } from './../maps'; -import { powerPlants } from './../powerPlants'; export enum Regions { Orange = 'orange', @@ -215,59 +212,8 @@ export const map: GameMap = { // recharged/original deck setup. With 07 gone the "starter pool" is one // plant shorter (12 instead of 13 for recharged; future market shifts to // 8/9/10/11 instead of 7/8/9/10 for original). - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - let powerPlantsDeck = cloneDeep(powerPlants).filter((pp) => pp.number !== 7); - let actualMarket: PowerPlant[]; - let futureMarket: PowerPlant[]; - - if (variant == 'original') { - powerPlantsDeck = powerPlantsDeck.slice(8); - const powerPlant13Idx = powerPlantsDeck.findIndex((pp) => pp.number == 13); - const powerPlant13 = powerPlantsDeck.splice(powerPlant13Idx, 1)[0]; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2 || numPlayers == 3) { - powerPlantsDeck = powerPlantsDeck.slice(8); - } else if (numPlayers == 4) { - powerPlantsDeck = powerPlantsDeck.slice(4); - } - - powerPlantsDeck.unshift(powerPlant13); - powerPlantsDeck.push(step3); - - actualMarket = [getPowerPlant(3), getPowerPlant(4), getPowerPlant(5), getPowerPlant(6)]; - futureMarket = [getPowerPlant(8), getPowerPlant(9), getPowerPlant(10), getPowerPlant(11)]; - } else { - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, 12), rng() + ''); - let initialPlantMarket = initialPowerPlants.splice(0, 8); - initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); - actualMarket = initialPlantMarket.slice(0, 4); - futureMarket = initialPlantMarket.slice(4); - - const first = initialPowerPlants.shift()!; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2) { - // Recharged 2P removes 1 plug + 5 socket (was incorrectly lumped with 3P). - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(5).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 3) { - initialPowerPlants = initialPowerPlants.slice(2); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(6).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 4) { - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(3).concat(initialPowerPlants), rng() + ''); - } else { - powerPlantsDeck = shuffle(powerPlantsDeck.concat(initialPowerPlants), rng() + ''); - } - - powerPlantsDeck.unshift(first); - powerPlantsDeck.push(step3); - } - - return { actualMarket, futureMarket, powerPlantsDeck }; + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, deck: PowerPlantDeck = 'standard') { + return defaultSetupDeck(numPlayers, variant, rng, true, deck, 'South Africa'); }, mapSpecificRules: 'Coal storage: there is a separate storage space below the resource market, ' + diff --git a/engine/src/maps/spainportugal.ts b/engine/src/maps/spainportugal.ts index a6a8ccfe..bc35cbd2 100644 --- a/engine/src/maps/spainportugal.ts +++ b/engine/src/maps/spainportugal.ts @@ -1,8 +1,5 @@ -import { cloneDeep } from 'lodash'; -import { getPowerPlant } from '../engine'; -import { PowerPlant } from '../gamestate'; -import { powerPlants } from '../powerPlants'; -import { shuffle } from '../utils'; +import { defaultSetupDeck } from '../engine'; +import { PowerPlantDeck } from '../gamestate'; import { GameMap } from './../maps'; export enum Regions { @@ -241,61 +238,8 @@ export const map: GameMap = { ], ], startingResources: [24, 18, 9, 8], - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - let powerPlantsDeck = cloneDeep(powerPlants); - let actualMarket: PowerPlant[]; - let futureMarket: PowerPlant[]; - - // remove plants 18 22 and 27 - powerPlantsDeck = powerPlantsDeck.filter((pp) => ![18, 22, 27].includes(pp.number)); - - if (variant == 'original') { - powerPlantsDeck = powerPlantsDeck.slice(8); - const powerPlant13 = powerPlantsDeck.splice(2, 1)[0]; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2 || numPlayers == 3) { - powerPlantsDeck = powerPlantsDeck.slice(8); - } else if (numPlayers == 4) { - powerPlantsDeck = powerPlantsDeck.slice(4); - } - - powerPlantsDeck.unshift(powerPlant13); - powerPlantsDeck.push(step3); - - actualMarket = [getPowerPlant(3), getPowerPlant(4), getPowerPlant(5), getPowerPlant(6)]; - futureMarket = [getPowerPlant(7), getPowerPlant(8), getPowerPlant(9), getPowerPlant(10)]; - } else { - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, 13), rng() + ''); - let initialPlantMarket = initialPowerPlants.splice(0, 8); - initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); - actualMarket = initialPlantMarket.slice(0, 4); - futureMarket = initialPlantMarket.slice(4); - - const first = initialPowerPlants.shift()!; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2) { - // Recharged 2P removes 1 plug + 5 socket (was incorrectly lumped with 3P). - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(5).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 3) { - initialPowerPlants = initialPowerPlants.slice(2); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(6).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 4) { - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(3).concat(initialPowerPlants), rng() + ''); - } else { - powerPlantsDeck = shuffle(powerPlantsDeck.concat(initialPowerPlants), rng() + ''); - } - - powerPlantsDeck.unshift(first); - powerPlantsDeck.push(step3); - } - - return { actualMarket, futureMarket, powerPlantsDeck }; + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, deck: PowerPlantDeck = 'standard') { + return defaultSetupDeck(numPlayers, variant, rng, true, deck, 'Spain & Portugal'); }, mapSpecificRules: 'You cannot buy a nuclear power plant if you only have cities in Portugal.\n', deckBuild: diff --git a/engine/src/maps/ukireland.ts b/engine/src/maps/ukireland.ts index d7f1d49e..da39c0c3 100644 --- a/engine/src/maps/ukireland.ts +++ b/engine/src/maps/ukireland.ts @@ -1,11 +1,8 @@ // by John and Cici -import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; -import { getPowerPlant } from '../engine'; -import { PowerPlant } from '../gamestate'; -import { shuffle } from '../utils'; +import { defaultSetupDeck } from '../engine'; +import { PowerPlantDeck } from '../gamestate'; import { GameMap } from './../maps'; -import { powerPlants } from './../powerPlants'; export enum Regions { Red = 'red', @@ -240,63 +237,10 @@ export const map: GameMap = { // Step 3 is placed 3rd from last in the deck (two plants below it) so Step // 3 starts earlier than on most maps. Otherwise standard recharged/original // deck prep. - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - let powerPlantsDeck = cloneDeep(powerPlants); - let actualMarket: PowerPlant[]; - let futureMarket: PowerPlant[]; - - if (variant == 'original') { - powerPlantsDeck = powerPlantsDeck.slice(8); - const powerPlant13Idx = powerPlantsDeck.findIndex((pp) => pp.number == 13); - const powerPlant13 = powerPlantsDeck.splice(powerPlant13Idx, 1)[0]; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2 || numPlayers == 3) { - powerPlantsDeck = powerPlantsDeck.slice(8); - } else if (numPlayers == 4) { - powerPlantsDeck = powerPlantsDeck.slice(4); - } - - powerPlantsDeck.unshift(powerPlant13); - // UK&I: Step 3 sits 3rd from last (two plants below it). - powerPlantsDeck.splice(powerPlantsDeck.length - 2, 0, step3); - - actualMarket = [getPowerPlant(3), getPowerPlant(4), getPowerPlant(5), getPowerPlant(6)]; - futureMarket = [getPowerPlant(7), getPowerPlant(8), getPowerPlant(9), getPowerPlant(10)]; - } else { - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, 13), rng() + ''); - let initialPlantMarket = initialPowerPlants.splice(0, 8); - initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); - actualMarket = initialPlantMarket.slice(0, 4); - futureMarket = initialPlantMarket.slice(4); - - const first = initialPowerPlants.shift()!; - const step3 = powerPlantsDeck.pop()!; - - powerPlantsDeck = shuffle(powerPlantsDeck, rng() + ''); - if (numPlayers == 2) { - // Recharged 2P removes 1 plug + 5 socket (was incorrectly lumped with 3P). - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(5).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 3) { - initialPowerPlants = initialPowerPlants.slice(2); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(6).concat(initialPowerPlants), rng() + ''); - } else if (numPlayers == 4) { - initialPowerPlants = initialPowerPlants.slice(1); - powerPlantsDeck = shuffle(powerPlantsDeck.slice(3).concat(initialPowerPlants), rng() + ''); - } else { - powerPlantsDeck = shuffle(powerPlantsDeck.concat(initialPowerPlants), rng() + ''); - } - - powerPlantsDeck.unshift(first); - // UK&I: Step 3 sits 3rd from last (two plants below it). The engine's - // standard Step 3 trigger already reshuffles plants below + discard - // into the new face-down deck, so we only need the placement here. - powerPlantsDeck.splice(powerPlantsDeck.length - 2, 0, step3); - } - - return { actualMarket, futureMarket, powerPlantsDeck }; + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, deck: PowerPlantDeck = 'standard') { + // UK&I: Step 3 sits 3rd from last (two plants below it). The engine's standard + // Step 3 trigger reshuffles plants below + discard into the new face-down deck. + return defaultSetupDeck(numPlayers, variant, rng, true, deck, 'UK & Ireland', 2); }, mapSpecificRules: 'Two networks: Ireland (Green and Orange regions) and Great Britain ' + diff --git a/engine/src/powerPlants.ts b/engine/src/powerPlants.ts index 1fc21b29..f581f07c 100644 --- a/engine/src/powerPlants.ts +++ b/engine/src/powerPlants.ts @@ -47,15 +47,6 @@ const powerPlants: PowerPlant[] = [ { number: 99, type: PowerPlantType.Step3, cost: 0, citiesPowered: 6 }, ]; -const indiaPowerPlants = cloneDeep(powerPlants).filter((pp) => pp.number != 11); -// Garbage plants cost one more garbage to run, but have no additional storage. -indiaPowerPlants.forEach((pp) => { - if (pp.type == PowerPlantType.Garbage) { - pp.storage = 2 * pp.cost; - pp.cost++; - } -}); - // Deck 2, The New Power Plants – Set 2 (the "alternate" deck): the same card // numbers as the standard deck, with different fuels and outputs. Plants 8, 22 // and 35 burn any mix of garbage and oil. @@ -162,28 +153,29 @@ function usesAlternateDeck(mapName: string, alternate: boolean | undefined): boo return !!alternate && ALTERNATE_DECK_MAPS.includes(mapName); } -// Maps whose setup or rules name plant numbers of the standard deck. They take -// only the standard deck until their changes for the other decks are in. -const STANDARD_DECK_ONLY_MAPS: string[] = [ - 'Australia', - 'Brazil', - 'Bremen', - 'China', - 'France', - 'India', - 'Italy', - 'Manhattan', - 'Middle East', - 'Northern Europe', - 'Quebec', - 'Russia', - 'South Africa', - 'Spain & Portugal', - 'UK & Ireland', -]; +// India's version of a deck: one plant leaves the game, and garbage plants cost one +// more garbage to run but have no additional storage. +function indiaCatalogue(deck: PowerPlant[], removed: number): PowerPlant[] { + const plants = cloneDeep(deck).filter((pp) => pp.number != removed); + plants.forEach((pp) => { + if (pp.type == PowerPlantType.Garbage) { + pp.storage = 2 * pp.cost; + pp.cost++; + } + }); + return plants; +} + +const indiaPowerPlants = indiaCatalogue(powerPlants, 11); +const indiaCatalogues: Record = { + standard: indiaPowerPlants, + deck1: indiaCatalogue(deck1PowerPlants, 8), + deck2: indiaCatalogue(alternatePowerPlants, 13), +}; -function availableDecks(mapName: string): PowerPlantDeck[] { - return STANDARD_DECK_ONLY_MAPS.includes(mapName) ? ['standard'] : ['standard', 'deck1', 'deck2']; +// The decks a map can be played with: every map takes every single deck. +function availableDecks(_mapName: string): PowerPlantDeck[] { + return ['standard', 'deck1', 'deck2']; } // The deck a game plays with. Games from before powerPlantDeck may carry the @@ -197,7 +189,7 @@ function gameDeck(options: GameOptions): PowerPlantDeck { // The full card list a game on this map draws from. function plantCatalogue(mapName: string, deck: PowerPlantDeck = 'standard'): PowerPlant[] { if (mapName == 'India') { - return indiaPowerPlants; + return indiaCatalogues[deck]; } return deck == 'deck1' ? deck1PowerPlants : deck == 'deck2' ? alternatePowerPlants : powerPlants; @@ -205,12 +197,12 @@ function plantCatalogue(mapName: string, deck: PowerPlantDeck = 'standard'): Pow // Plants with a plug on the back form the weak opening pool: 03–15 in the // standard and alternate decks, 01–16 in Deck 1. -function lastPlugPlant(catalogue: PowerPlant[]): number { - return catalogue === deck1PowerPlants ? 16 : 15; +function lastPlugPlant(deck: PowerPlantDeck): number { + return deck == 'deck1' ? 16 : 15; } -// The nuclear plant whose purchase ends uranium resupply in Recharged Germany, -// per each deck's map-changes sheet. +// The nuclear plant whose purchase ends uranium resupply in Recharged Germany and +// Italy: Germany's per each deck's map-changes sheet, and Italy ends the same way. const PHASE_OUT_PLANT: Record = { standard: 39, deck1: 38, deck2: 37 }; // Wind plants are always ecological. Whether fusion plants are too is a ruling: @@ -233,7 +225,6 @@ export { deck1PowerPlants, ALTERNATE_DECK_MAPS, usesAlternateDeck, - STANDARD_DECK_ONLY_MAPS, availableDecks, gameDeck, plantCatalogue, From 91034ac6a04cd03ad39cf9534c2d9a708c160381 Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:13:56 -0600 Subject: [PATCH 4/4] Add shuffled power plant decks: all decks, or one per number powerPlantDeck now also takes 'shuffled' (every plant of the standard deck, Deck 1 and Deck 2 together) and 'shuffledUnique' (one random copy of each number, without Deck 1's 52-60). Extra copies are numbered n.1 (Deck 1) and n.2 (Deck 2), so the market ranks duplicates Deck 2 > Deck 1 > Original and moves keep identifying plants by number. Cards show the printed number, and logs read "Power Plant 46 (Deck 2)"; the journal and the deck analysis parse that label back to the copy. Bids start at the printed number, the Original discard rule and plug cards compare it, and each deck's own nuclear plant ends Germany's and Italy's uranium resupply. The shuffled decks are only offered on maps whose rules don't name plant numbers; Random still picks a single deck. Standard-deck setups are unchanged for old and new games. Co-Authored-By: Claude Opus 5.5 --- engine/src/analysis.ts | 54 ++++++-- engine/src/auction-pricing.ts | 3 +- engine/src/deckChanges.ts | 12 +- engine/src/decks.spec.ts | 156 +++++++++++++++++++++++- engine/src/engine.ts | 113 ++++++++++------- engine/src/gamestate.ts | 6 +- engine/src/maps/europe.ts | 22 ++-- engine/src/powerPlants.ts | 107 ++++++++++++++-- viewer/src/components/Game.vue | 13 +- viewer/src/components/InlineLog.vue | 3 +- viewer/src/components/pieces/Card.vue | 9 +- viewer/src/localization/journal.js | 20 +-- viewer/tests/unit/journal-plant.spec.ts | 10 ++ 13 files changed, 435 insertions(+), 93 deletions(-) diff --git a/engine/src/analysis.ts b/engine/src/analysis.ts index 0b0f8570..f58cfd19 100644 --- a/engine/src/analysis.ts +++ b/engine/src/analysis.ts @@ -9,7 +9,15 @@ import { } from './deckChanges'; import { defaultSetupDeck, reconstructState, setup, stripSecret } from './engine'; import { GameState, PowerPlant, PowerPlantType } from './gamestate'; -import { gameDeck, isEcological, lastPlugPlant, plantCatalogue } from './powerPlants'; +import { + gameDeck, + isEcological, + isPlugPlant, + labelledPlant, + lastPlugPlant, + plantCatalogue, + PLANT_LABEL_PATTERN, +} from './powerPlants'; import { shuffle } from './utils'; /** Unknown cards come from the published catalogue, never the live draw pile. */ @@ -43,8 +51,10 @@ export function createAnalysisScenario( const recyclePile: number[] = []; for (const entry of s.log) { if (entry.type !== 'event') continue; - const setAside = /Setting Power Plant (\d+) aside on the recycle pile/.exec(entry.event); - if (setAside) recyclePile.push(Number(setAside[1])); + const setAside = new RegExp(`Setting Power Plant ${PLANT_LABEL_PATTERN} aside on the recycle pile`).exec( + entry.event + ); + if (setAside) recyclePile.push(labelledPlant(setAside[1], setAside[2])); if (/reshuffling the recycle pile/.test(entry.event)) { recyclePile.forEach((number) => recycled.add(number)); recyclePile.length = 0; @@ -57,17 +67,21 @@ export function createAnalysisScenario( bottom.forEach((number) => recycled.add(number)); bottom.length = 0; } - const rotation = /(?:Putting|Sending) Power Plant (\d+) (?:on|to) the bottom of the deck/.exec(entry.event); + const rotation = new RegExp( + `(?:Putting|Sending) Power Plant ${PLANT_LABEL_PATTERN} (?:on|to) the bottom of the deck` + ).exec(entry.event); if (rotation) { - const number = Number(rotation[1]); + const number = labelledPlant(rotation[1], rotation[2]); const i = bottom.indexOf(number); if (i >= 0) bottom.splice(i, 1); bottom.push(number); recycled.delete(number); } - const drawn = /Power Plant (\d+) (?:drawn from the deck|discarded\.)/i.exec(entry.event); + const drawn = new RegExp(`Power Plant ${PLANT_LABEL_PATTERN} (?:drawn from the deck|discarded\\.)`, 'i').exec( + entry.event + ); if (drawn) { - const number = Number(drawn[1]); + const number = labelledPlant(drawn[1], drawn[2]); const i = bottom.indexOf(number); if (i >= 0) bottom.splice(i, 1); recycled.delete(number); @@ -82,13 +96,28 @@ export function createAnalysisScenario( ].map((p) => p.number) ); const fixedBottom = bottom.filter((number) => !unavailable.has(number)); - const pool = catalogue.filter( + let pool = catalogue.filter( (p) => !excluded.includes(p.number) && !unavailable.has(p.number) && ((!seen.has(p.number) && !(name === 'Manhattan' && s.manhattanDepletion)) || recycled.has(p.number)) && !fixedBottom.includes(p.number) ); + // One per number: a copy already known rules out the number's other copies, and + // each still-unknown number is in the game exactly once. + if (powerPlantDeck === 'shuffledUnique') { + const known = new Set([...seen, ...unavailable, ...fixedBottom]); + const knownPrinted = new Set([...known].map((number) => Math.floor(number))); + const unknown = (p: PowerPlant) => p.number !== 99 && !known.has(p.number); + pool = pool.filter((p) => !unknown(p) || !knownPrinted.has(Math.floor(p.number))); + const copies = new Map(); + for (const p of pool.filter(unknown)) { + copies.set(Math.floor(p.number), [...(copies.get(Math.floor(p.number)) ?? []), p]); + } + const pick = seedrandom(seed + ':unique'); + const chosen = new Set([...copies.values()].map((options) => options[Math.floor(pick() * options.length)])); + pool = pool.filter((p) => !unknown(p) || chosen.has(p)); + } // Step 3 can be returned underneath the deck for the Middle East's second pass. if (name === 'Middle East' && s.step === 2 && !unavailable.has(99) && !pool.some((p) => p.number === 99)) pool.push(byNumber(99)); @@ -127,7 +156,7 @@ export function createAnalysisScenario( candidates = candidates.filter(mandatory).concat(candidates.filter((p) => !mandatory(p))); const index = candidates.findIndex( (p) => - p.number <= lastPlugPlant(powerPlantDeck) === + isPlugPlant(p, powerPlantDeck) === (name === 'Manhattan' && s.knownPowerPlantDeck.length === 8 ? true : s.nextCardWeak) ); if (index >= 0) top.unshift(...candidates.splice(index, 1)); @@ -154,13 +183,12 @@ export function createAnalysisScenario( name ) : setup(n, s.options, seed + ':catalogue-counts'); - const threshold = name === 'India' ? 16 : lastPlugPlant(powerPlantDeck); - const low = (p: PowerPlant) => p.number <= threshold; + const low = (p: { number: number }) => (name === 'India' ? p.number <= 16 : isPlugPlant(p, powerPlantDeck)); const initialLow = [...initial.actualMarket, ...initial.futureMarket, ...initial.powerPlantsDeck].filter( low ).length; - const seenLow = [...seen].filter((number) => number <= threshold).length; - const recycledLow = [...recycled].filter((number) => number <= threshold && !unavailable.has(number)).length; + const seenLow = [...seen].filter((number) => low({ number })).length; + const recycledLow = [...recycled].filter((number) => low({ number }) && !unavailable.has(number)).length; const lowCount = initialLow - seenLow + recycledLow - top.filter(low).length; rest = select(candidates.filter(low), Math.max(0, lowCount)).concat( select( diff --git a/engine/src/auction-pricing.ts b/engine/src/auction-pricing.ts index 73a88e6f..2309c189 100644 --- a/engine/src/auction-pricing.ts +++ b/engine/src/auction-pricing.ts @@ -4,5 +4,6 @@ import type { GameState, PowerPlant } from './gamestate'; export function minimumPlantBid(G: GameState, plant: PowerPlant): number { return G.options.variant === 'recharged' && G.plantDiscountActive && plant.number === G.actualMarket[0]?.number ? 1 - : plant.number; + : // The shuffled decks number extra copies 46.1, 46.2: bids start at the printed number. + Math.floor(plant.number); } diff --git a/engine/src/deckChanges.ts b/engine/src/deckChanges.ts index da9370d2..c5afa957 100644 --- a/engine/src/deckChanges.ts +++ b/engine/src/deckChanges.ts @@ -1,15 +1,20 @@ -import { PowerPlantDeck } from './gamestate'; +import { PowerPlantDeck, SingleDeck } from './gamestate'; // Map rules that name plant numbers, per deck: the standard deck's from each map's // rules, Deck 1's and Deck 2's from their map-changes sheets (2F-Spiele, 2024). The // sheets are written for Recharged; Original games use the same numbers, except // where The New Power Plants rulebook covers the Original edition itself. -type PerDeck = Record; +type PerDeck = Record; + +// The shuffled decks are only offered on maps without these rules (see availableDecks). +const isShuffled = (deck: PowerPlantDeck): deck is 'shuffled' | 'shuffledUnique' => + deck == 'shuffled' || deck == 'shuffledUnique'; // Plants out of the game from the start. Manhattan's boxed Step 3 card and India's // plant (see plantCatalogue) are handled where those decks are built. export function removedPlants(mapName: string, deck: PowerPlantDeck, numPlayers: number, variant: string): number[] { + if (isShuffled(deck)) return []; const fewPlayers = numPlayers <= 3; switch (mapName) { case 'France': @@ -62,6 +67,8 @@ export function removedPlants(mapName: string, deck: PowerPlantDeck, numPlayers: // Original edition: the plants placed on top of the deck, topmost first, once the // eight smallest plants have opened the market. Plants not in the game are skipped. export function originalTopPlants(mapName: string, deck: PowerPlantDeck): number[] { + // The shuffled decks put the standard deck's 13 on top. + if (isShuffled(deck)) return [13]; switch (mapName) { case 'France': // The nuclear plant comes up first; Deck 1's (08) already opens the market. @@ -83,6 +90,7 @@ export const RUSSIA_ORIGINAL_NEXT_PLANTS: PerDeck = { standard: [10, 1 // Recharged edition: plants that always open the market; the rest of its eight are // drawn from the remaining plug cards. export function rechargedMarketPlants(mapName: string, deck: PowerPlantDeck): number[] { + if (isShuffled(deck)) return []; switch (mapName) { case 'France': return { standard: [11], deck1: [8], deck2: [13] }[deck]; diff --git a/engine/src/decks.spec.ts b/engine/src/decks.spec.ts index 9308543d..8b624818 100644 --- a/engine/src/decks.spec.ts +++ b/engine/src/decks.spec.ts @@ -7,7 +7,16 @@ import { ended, move, moveAI, setup, updatePlayerCapacity } from './engine'; import { GameState, MapName, Phase, PowerPlantDeck, PowerPlantType, ResourceType, Variant } from './gamestate'; import { maps, mapsRecharged } from './maps'; import { Move, MoveName } from './move'; -import { alternatePowerPlants, deck1PowerPlants, plantCatalogue, powerPlants } from './powerPlants'; +import { + alternatePowerPlants, + deck1PowerPlants, + isPlugPlant, + labelledPlant, + NUMBERED_SETUP_MAPS, + plantCatalogue, + plantLabel, + powerPlants, +} from './powerPlants'; const numbers = (plants: { number: number }[]) => plants.map((pp) => pp.number); const visible = (G: GameState) => [...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck]; @@ -339,3 +348,148 @@ describe('power plant decks on maps with deck-specific changes', () => { }); } }); + +describe('shuffled power plant decks', () => { + const shuffled = plantCatalogue('USA', 'shuffled'); + const printed = (plants: { number: number }[]) => plants.map((pp) => Math.floor(pp.number)); + + it('holds every plant of all three decks, extra copies numbered .1 and .2', () => { + const step3 = shuffled.filter((pp) => pp.type == PowerPlantType.Step3); + expect(step3.map((pp) => pp.number)).to.deep.equal([99]); + expect(shuffled[shuffled.length - 1].number).to.equal(99); + expect(new Set(numbers(shuffled)).size).to.equal(shuffled.length); + for (const [source, offset] of [ + [powerPlants, 0], + [deck1PowerPlants, 0.1], + [alternatePowerPlants, 0.2], + ] as [typeof powerPlants, number][]) { + for (const pp of source.filter((c) => c.type != PowerPlantType.Step3)) { + expect(shuffled.find((c) => c.number == pp.number + offset)).to.deep.equal({ + ...pp, + number: pp.number + offset, + }); + } + } + expect(numbers(shuffled)).to.deep.equal([...numbers(shuffled)].sort((a, b) => a - b)); + }); + + it('labels the extra copies with their deck', () => { + expect(plantLabel(46)).to.equal('46'); + expect(plantLabel(46.1)).to.equal('46 (Deck 1)'); + expect(plantLabel(46.2)).to.equal('46 (Deck 2)'); + expect(labelledPlant('46', '2')).to.equal(46.2); + expect(labelledPlant('46', undefined)).to.equal(46); + }); + + it('is only offered on maps whose rules name no plant numbers', () => { + for (const deck of ['shuffled', 'shuffledUnique'] as PowerPlantDeck[]) { + for (const map of NUMBERED_SETUP_MAPS as MapName[]) { + const G = setup(4, { map, variant: 'recharged', powerPlantDeck: deck }, 'blocked'); + expect(G.options, `${map} ${deck}`).to.not.have.property('powerPlantDeck'); + } + for (const map of ['USA', 'Germany', 'Italy', 'Benelux', 'Europe', 'UK & Ireland'] as MapName[]) { + const G = setup(4, { map, variant: 'recharged', powerPlantDeck: deck }, 'offered'); + expect(G.options.powerPlantDeck, `${map} ${deck}`).to.equal(deck); + } + } + }); + + it("opens the Recharged market from every deck's plug cards", () => { + for (const seed of range(8).map(String)) { + const G = setup(4, { map: 'USA', variant: 'recharged', powerPlantDeck: 'shuffled' }, seed); + const market = [...G.actualMarket, ...G.futureMarket]; + expect( + market.every((pp) => isPlugPlant(pp, 'shuffled')), + seed + ).to.be.true; + expect(numbers(market)).to.deep.equal([...numbers(market)].sort((a, b) => a - b)); + expect(isPlugPlant(G.powerPlantsDeck[0], 'shuffled')).to.be.true; + expect(G.powerPlantsDeck[G.powerPlantsDeck.length - 1].number).to.equal(99); + } + // Deck 1's 16 is a plug card; the standard deck's and Deck 2's are not. + expect(isPlugPlant({ number: 16.1 }, 'shuffled')).to.be.true; + expect(isPlugPlant({ number: 16 }, 'shuffled')).to.be.false; + expect(isPlugPlant({ number: 16.2 }, 'shuffled')).to.be.false; + }); + + it('opens the Original market with the eight smallest plants and 13 on top', () => { + const G = setup(4, { map: 'USA', variant: 'original', powerPlantDeck: 'shuffled' }, 'o'); + expect(numbers([...G.actualMarket, ...G.futureMarket])).to.deep.equal([1.1, 2.1, 3, 3.1, 3.2, 4, 4.1, 4.2]); + expect(G.powerPlantsDeck[0].number).to.equal(13); + }); + + it('keeps one random copy of each number in the one-per-number deck', () => { + const dealt = (seed: string) => + visible(setup(5, { map: 'USA', variant: 'recharged', powerPlantDeck: 'shuffledUnique' }, seed)); + const first = dealt('a'); + const once = printed(first); + expect(new Set(once).size, 'one copy per number').to.equal(once.length); + expect(new Set(once)).to.deep.equal(new Set(printed(shuffled))); + expect(once.some((n) => n > 50 && n != 99)).to.be.false; + expect(numbers(dealt('a')).sort()).to.deep.equal(numbers(first).sort()); + expect(numbers(dealt('b')).sort()).to.not.deep.equal(numbers(first).sort()); + }); + + it('starts the bidding at the printed number and logs the copy', () => { + const G = setup(3, { map: 'Germany', variant: 'original', powerPlantDeck: 'shuffled' }, 'bid'); + const copy = shuffled.find((pp) => pp.number == 46.2)!; + G.actualMarket[0] = copy; + const player = G.currentPlayers[0]; + G.players[player].money = 100; + G.players[player].availableMoves = availableMoves(G, G.players[player]); + expect(G.players[player].availableMoves![MoveName.ChoosePowerPlant]).to.include(46.2); + const after = move(G, { name: MoveName.ChoosePowerPlant, data: 46.2 } as Move, player); + expect(after.minimunBid).to.equal(46); + expect(after.chosenPowerPlant).to.deep.equal(copy); + const entry = after.log[after.log.length - 1]; + expect(entry.type == 'move' && entry.simple).to.contain('Power Plant 46 (Deck 2)'); + }); + + it("ends Germany's uranium resupply on any deck's own nuclear plant", () => { + for (const [number, triggers] of [ + [39, true], + [38.1, true], + [37.2, true], + [38, false], + [39.1, false], + ] as [number, boolean][]) { + const G = setup(3, { map: 'Germany', variant: 'recharged', powerPlantDeck: 'shuffled' }, 'phase-out'); + G.actualMarket[0] = shuffled.find((pp) => pp.number == number)!; + const player = G.currentPlayers[0]; + G.players[player].money = 100; + G.players[player].availableMoves = availableMoves(G, G.players[player]); + const after = move(G, { name: MoveName.ChoosePowerPlant, data: number } as Move, player); + expect(after.card39Bought, `${number}`).to.equal(triggers); + } + }); + + for (const [map, variant, deck] of [ + ['USA', 'original', 'shuffled'], + ['Germany', 'recharged', 'shuffledUnique'], + ['Europe', 'recharged', 'shuffled'], + ['UK & Ireland', 'original', 'shuffledUnique'], + ['Benelux', 'recharged', 'shuffled'], + ] as [MapName, Variant, PowerPlantDeck][]) { + it(`plays a whole bot game on ${map} ${variant} with ${deck} and replays it`, () => { + let G = setup(4, { map, variant, powerPlantDeck: deck }, `bots-${map}-${deck}`); + G.players.forEach((p, i) => { + p.name = `AI ${i}`; + p.isAI = true; + }); + for (let turn = 0; turn < 8000 && !ended(G) && G.currentPlayers.length > 0; turn++) { + G = moveAI(G, G.currentPlayers[0]); + } + expect(ended(G), 'game finished').to.be.true; + + let replay = setup(4, G.options, G.seed); + replay.players.forEach((p, i) => { + p.name = `AI ${i}`; + p.isAI = true; + }); + for (const item of G.log) { + if (item.type === 'move') replay = move(replay, item.move, item.player); + } + expect(replay.players.map((p) => p.money)).to.deep.equal(G.players.map((p) => p.money)); + }); + } +}); diff --git a/engine/src/engine.ts b/engine/src/engine.ts index 002783a6..b646de88 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -38,13 +38,16 @@ import { GameMap, maps, mapsRecharged } from './maps'; import { Move, MoveName, Moves } from './move'; import { availableDecks, + dealtCatalogue, ECOLOGICAL_RULE_MAPS, FUSION_IS_ECOLOGICAL, gameDeck, isEcological, + isPlugPlant, lastPlugPlant, - PHASE_OUT_PLANT, + phaseOutPlants, plantCatalogue, + plantLabel, usesAlternateDeck, } from './powerPlants'; import prices from './prices'; @@ -81,7 +84,7 @@ export function defaultSetupDeck( let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; const removed = removedPlants(mapName, deck, numPlayers, variant); - const catalogue = plantCatalogue(mapName, deck).filter((p) => !removed.includes(p.number)); + const catalogue = dealtCatalogue(mapName, deck, rng).filter((p) => !removed.includes(p.number)); let powerPlantsDeck: PowerPlant[] = cloneDeep(catalogue); if (variant == 'original') { @@ -108,8 +111,12 @@ export function defaultSetupDeck( actualMarket = catalogue.slice(0, 4); futureMarket = catalogue.slice(4, 8); } else { - const plugCount = catalogue.filter((p) => p.number <= lastPlugPlant(deck)).length; - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, plugCount), rng() + ''); + // Plug cards come first in the single decks; the shuffled decks interleave them. + let initialPowerPlants = shuffle( + powerPlantsDeck.filter((p) => isPlugPlant(p, deck)), + rng() + '' + ); + powerPlantsDeck = powerPlantsDeck.filter((p) => !isPlugPlant(p, deck)); let initialPlantMarket = initialPowerPlants.splice(0, 8); initialPlantMarket = initialPlantMarket.sort((a, b) => a.number - b.number); actualMarket = initialPlantMarket.slice(0, 4); @@ -194,9 +201,13 @@ export function setup( : chosenMap.startingResources.original; if (startingResources) chosenMap.startingResources = startingResources; const decks = availableDecks(chosenMap.name); - // Dedicated rng so the pick doesn't shift the rest of the setup's draws. + // A random deck is one of the three single decks. Dedicated rng so the pick + // doesn't shift the rest of the setup's draws. + const singleDecks: PowerPlantDeck[] = ['standard', 'deck1', 'deck2']; const requestedDeck = - powerPlantDeck == 'random' ? decks[Math.floor(seedrandom(seed + '-deck')() * decks.length)] : powerPlantDeck; + powerPlantDeck == 'random' + ? singleDecks[Math.floor(seedrandom(seed + '-deck')() * singleDecks.length)] + : powerPlantDeck; // The legacy option only applies when no deck is asked for, and only on its own maps. const legacyAlternateDeck = !requestedDeck && usesAlternateDeck(chosenMap.name, alternatePowerPlants); const chosenDeck: PowerPlantDeck = legacyAlternateDeck @@ -814,7 +825,7 @@ export function move( // Named card39Bought from the standard deck's plant; old games, which have // no powerPlantDeck, keep keying it on 39. - if (move.data == PHASE_OUT_PLANT[(G.options.powerPlantDeck as PowerPlantDeck) ?? 'standard']) { + if (phaseOutPlants((G.options.powerPlantDeck as PowerPlantDeck) ?? 'standard').includes(move.data)) { G.card39Bought = true; } @@ -827,7 +838,7 @@ export function move( G.plantDiscountActive = false; move.usedPlantDiscount = true; } else { - G.minimunBid = move.data; + G.minimunBid = Math.floor(move.data); } const notPassed = G.players.filter((p) => !p.skipAuction && !p.isDropped); @@ -836,8 +847,8 @@ export function move( type: 'move', player: playerNumber, move, - simple: `${player.name} chooses Power Plant ${move.data}.`, - pretty: `${playerNameHTML(player)} chooses Power Plant ${move.data}.`, + simple: `${player.name} chooses Power Plant ${plantLabel(move.data)}.`, + pretty: `${playerNameHTML(player)} chooses Power Plant ${plantLabel(move.data)}.`, }); const winningPlayer = notPassed[0]; @@ -871,8 +882,10 @@ export function move( type: 'move', player: playerNumber, move, - simple: `${player.name} chooses Power Plant ${move.data} to initiate an auction.`, - pretty: `${playerNameHTML(player)} chooses Power Plant ${move.data} to initiate an auction.`, + simple: `${player.name} chooses Power Plant ${plantLabel(move.data)} to initiate an auction.`, + pretty: `${playerNameHTML(player)} chooses Power Plant ${plantLabel( + move.data + )} to initiate an auction.`, }); if (G.options.fastBid) { @@ -1143,7 +1156,11 @@ export function move( G.map.name != 'Manhattan' ) { const powerPlant = G.actualMarket.shift()!; - startStep(G, 2, `Starting Step 2, Power Plant ${powerPlant?.number} discarded.`); + startStep( + G, + 2, + `Starting Step 2, Power Plant ${plantLabel(powerPlant?.number)} discarded.` + ); // Spain & Portugal: put the held-back plants on top (18, 22, 27 in the standard deck) if (G.map.name == 'Spain & Portugal') { @@ -1433,7 +1450,9 @@ export function move( let powerPlantToPush: PowerPlant = G.futureMarket.pop()!; G.log.push({ type: 'event', - event: `Putting Power Plant ${powerPlantToPush.number} on the bottom of the deck.`, + event: `Putting Power Plant ${plantLabel( + powerPlantToPush.number + )} on the bottom of the deck.`, }); G.powerPlantsDeck.push(powerPlantToPush); addPowerPlant(G); @@ -1442,7 +1461,9 @@ export function move( powerPlantToPush = G.futureMarket.length ? G.futureMarket.pop()! : G.actualMarket.pop()!; G.log.push({ type: 'event', - event: `Putting Power Plant ${powerPlantToPush.number} on the bottom of the deck.`, + event: `Putting Power Plant ${plantLabel( + powerPlantToPush.number + )} on the bottom of the deck.`, }); G.powerPlantsDeck.push(powerPlantToPush); addPowerPlant(G); @@ -1454,7 +1475,7 @@ export function move( const removedPlant = G.actualMarket[0]; G.log.push({ type: 'event', - event: `Removing smallest plant, Power Plant ${removedPlant.number}.`, + event: `Removing smallest plant, Power Plant ${plantLabel(removedPlant.number)}.`, }); G.actualMarket.shift(); addPowerPlant(G); @@ -1494,13 +1515,18 @@ export function move( if (powerPlantToPush) { G.log.push({ type: 'event', - event: `Putting Power Plant ${powerPlantToPush.number} on the bottom of the deck.`, + event: `Putting Power Plant ${plantLabel( + powerPlantToPush.number + )} on the bottom of the deck.`, }); G.powerPlantsDeck.push(powerPlantToPush); addPowerPlant(G); } } else if (G.actualMarket.length > 0 && (G.map.name != 'China' || G.step == 3)) { - G.log.push({ type: 'event', event: `Discarding Power Plant ${G.actualMarket[0].number}.` }); + G.log.push({ + type: 'event', + event: `Discarding Power Plant ${plantLabel(G.actualMarket[0].number)}.`, + }); G.actualMarket.shift(); addPowerPlant(G); } @@ -1554,8 +1580,8 @@ export function move( type: 'move', player: playerNumber, move, - simple: `${player.name} discards Power Plant ${move.data}.`, - pretty: `${playerNameHTML(player)} discards Power Plant ${move.data}.`, + simple: `${player.name} discards Power Plant ${plantLabel(move.data)}.`, + pretty: `${playerNameHTML(player)} discards Power Plant ${plantLabel(move.data)}.`, }); } @@ -1626,10 +1652,10 @@ export function move( type: 'move', player: playerNumber, move, - simple: `${player.name} discards Power Plant ${move.data} and ${discarded.join(', ')}.`, - pretty: `${playerNameHTML(player)} discards Power Plant ${move.data} and ${discarded.join( - ', ' - )}.`, + simple: `${player.name} discards Power Plant ${plantLabel(move.data)} and ${discarded.join(', ')}.`, + pretty: `${playerNameHTML(player)} discards Power Plant ${plantLabel( + move.data + )} and ${discarded.join(', ')}.`, }); refillMarketAfterPurchase(G); @@ -1957,8 +1983,8 @@ export function move( type: 'move', player: playerNumber, move, - simple: `${player.name} uses Power Plant ${move.data.powerPlant}.`, - pretty: `${playerNameHTML(player)} uses Power Plant ${move.data.powerPlant}.`, + simple: `${player.name} uses Power Plant ${plantLabel(move.data.powerPlant)}.`, + pretty: `${playerNameHTML(player)} uses Power Plant ${plantLabel(move.data.powerPlant)}.`, }); break; @@ -2359,10 +2385,10 @@ export function addPowerPlant(G: GameState): void { if (G.options.variant == 'original' && G.map.name != 'China') { const maxCities = Math.max(...G.players.map((p) => p.cities.length)); - while (powerPlant.number <= maxCities) { + while (Math.floor(powerPlant.number) <= maxCities) { G.log.push({ type: 'event', - event: `Power plant ${powerPlant?.number} discarded.`, + event: `Power plant ${plantLabel(powerPlant?.number)} discarded.`, }); if (G.powerPlantsDeck.length > 0) { @@ -2413,7 +2439,9 @@ export function addPowerPlant(G: GameState): void { const powerPlantDiscarded = G.actualMarket.shift(); G.log.push({ type: 'event', - event: `Step 3 will begin next phase, Power Plant ${powerPlantDiscarded?.number} discarded.`, + event: `Step 3 will begin next phase, Power Plant ${plantLabel( + powerPlantDiscarded?.number + )} discarded.`, }); const market = [...G.actualMarket, ...G.futureMarket]; @@ -2426,13 +2454,16 @@ export function addPowerPlant(G: GameState): void { if (G.plantDiscountActive && powerPlant.number < G.actualMarket[0].number) { G.log.push({ type: 'event', - event: `Power Plant ${powerPlant.number} drawn from the deck and discarded.`, + event: `Power Plant ${plantLabel(powerPlant.number)} drawn from the deck and discarded.`, }); G.plantDiscountActive = false; addPowerPlant(G); return; } else { - G.log.push({ type: 'event', event: `Power Plant ${powerPlant.number} drawn from the deck.` }); + G.log.push({ + type: 'event', + event: `Power Plant ${plantLabel(powerPlant.number)} drawn from the deck.`, + }); } } @@ -2519,7 +2550,7 @@ export function applyManhattanMarketLifecycle(G: GameState) { if (G.knownPowerPlantDeck) { G.knownPowerPlantDeck.push(drawn); } - G.log.push({ type: 'event', event: `Power Plant ${drawn.number} drawn from the deck.` }); + G.log.push({ type: 'event', event: `Power Plant ${plantLabel(drawn.number)} drawn from the deck.` }); G.futureMarket.push(drawn); } const market = [...G.actualMarket, ...G.futureMarket].sort((a, b) => a.number - b.number); @@ -2536,7 +2567,7 @@ export function applyManhattanMarketLifecycle(G: GameState) { G.manhattanRecyclePile.push(plant); G.log.push({ type: 'event', - event: `Setting Power Plant ${plant.number} aside on the recycle pile.`, + event: `Setting Power Plant ${plantLabel(plant.number)} aside on the recycle pile.`, }); } refillMarketTo(8); @@ -2557,7 +2588,7 @@ export function applyManhattanMarketLifecycle(G: GameState) { G.powerPlantsDeck.push(plant); G.log.push({ type: 'event', - event: `Putting Power Plant ${plant.number} on the bottom of the deck.`, + event: `Putting Power Plant ${plantLabel(plant.number)} on the bottom of the deck.`, }); } refillMarketTo(8); @@ -2584,7 +2615,7 @@ export function applyManhattanMarketLifecycle(G: GameState) { const removed = G.actualMarket.shift()!; G.log.push({ type: 'event', - event: `Removing the smallest plant, Power Plant ${removed.number}, from the game.`, + event: `Removing the smallest plant, Power Plant ${plantLabel(removed.number)}, from the game.`, }); } } @@ -2641,13 +2672,13 @@ function removePlantsForMiddleEastStep1(G: GameState) { if (plantToRemove.number <= lastPlugPlant(gameDeck(G.options))) { G.log.push({ type: 'event', - event: `Removing Power Plant ${plantToRemove.number} from game.`, + event: `Removing Power Plant ${plantLabel(plantToRemove.number)} from game.`, }); } else { G.powerPlantsDeck.push(plantToRemove); G.log.push({ type: 'event', - event: `Sending Power Plant ${plantToRemove.number} to the bottom of the deck.`, + event: `Sending Power Plant ${plantLabel(plantToRemove.number)} to the bottom of the deck.`, }); } @@ -2703,7 +2734,7 @@ function enterStepTwoMiddleEast(G: GameState) { if (powerPlantDiscarded1) { G.log.push({ type: 'event', - event: `Power Plant ${powerPlantDiscarded1.number} discarded to start step 2.`, + event: `Power Plant ${plantLabel(powerPlantDiscarded1.number)} discarded to start step 2.`, }); addPowerPlant(G); } @@ -2712,7 +2743,7 @@ function enterStepTwoMiddleEast(G: GameState) { if (powerPlantDiscarded2) { G.log.push({ type: 'event', - event: `Power Plant ${powerPlantDiscarded2.number} discarded to start step 2.`, + event: `Power Plant ${plantLabel(powerPlantDiscarded2.number)} discarded to start step 2.`, }); addPowerPlant(G); } @@ -3043,7 +3074,7 @@ function toResourcesPhase(G: GameState) { if (G.plantDiscountActive) { G.plantDiscountActive = false; if (G.actualMarket.length > 0) { - G.log.push({ type: 'event', event: `Discarding Power Plant ${G.actualMarket[0].number}.` }); + G.log.push({ type: 'event', event: `Discarding Power Plant ${plantLabel(G.actualMarket[0].number)}.` }); G.actualMarket.shift(); addPowerPlant(G); } @@ -3058,7 +3089,7 @@ function toResourcesPhase(G: GameState) { } else { const powerPlantDiscarded = G.actualMarket.shift(); G.futureMarket.pop(); - startStep(G, 3, `Starting Step 3, Power Plant ${powerPlantDiscarded?.number} discarded.`); + startStep(G, 3, `Starting Step 3, Power Plant ${plantLabel(powerPlantDiscarded?.number)} discarded.`); applyAustraliaStep3Shift(G); G.actualMarket = [...G.actualMarket, ...G.futureMarket]; diff --git a/engine/src/gamestate.ts b/engine/src/gamestate.ts index c72c7bd6..1a452208 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -36,8 +36,10 @@ export type MapName = | 'Manhattan'; export type Variant = 'original' | 'recharged'; // 'standard' is the base game's deck; deck1 and deck2 are The New Power Plants -// (Set 1) and Set 2. See plantCatalogue in powerPlants.ts. -export type PowerPlantDeck = 'standard' | 'deck1' | 'deck2'; +// (Set 1) and Set 2. 'shuffled' plays every plant of all three decks together; +// 'shuffledUnique' keeps one random copy of each plant number. See powerPlants.ts. +export type SingleDeck = 'standard' | 'deck1' | 'deck2'; +export type PowerPlantDeck = SingleDeck | 'shuffled' | 'shuffledUnique'; export interface GameOptions { fastBid?: boolean; diff --git a/engine/src/maps/europe.ts b/engine/src/maps/europe.ts index da64f94d..78a54040 100644 --- a/engine/src/maps/europe.ts +++ b/engine/src/maps/europe.ts @@ -1,10 +1,10 @@ // by John and Cici import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; -import { PowerPlantDeck, PowerPlantType } from '../gamestate'; +import { PowerPlant, PowerPlantDeck, PowerPlantType } from '../gamestate'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; -import { lastPlugPlant, plantCatalogue } from './../powerPlants'; +import { dealtCatalogue, isPlugPlant } from './../powerPlants'; export enum Regions { Pink = 'pink', // UK / Ireland / Low Countries @@ -376,16 +376,18 @@ export const map: GameMap = { // removes the lowest plant from the current market once, then re-sorts the // remaining 8 plants into 4 actual + 4 future without drawing from the deck. setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, powerPlantDeck: PowerPlantDeck = 'standard') { - const powerPlantsDeck = cloneDeep(plantCatalogue('Europe', powerPlantDeck)); - const lastPlug = lastPlugPlant(powerPlantDeck); + const powerPlantsDeck = cloneDeep(dealtCatalogue('Europe', powerPlantDeck, rng)); + const isPlug = (p: PowerPlant) => isPlugPlant(p, powerPlantDeck); const step3Index = powerPlantsDeck.findIndex((p) => p.type === PowerPlantType.Step3); const step3 = powerPlantsDeck.splice(step3Index, 1)[0]; - // The catalogue is sorted by number, so the plug pool (03–15, or 01–16 in Deck 1) comes first. - const plugCount = powerPlantsDeck.filter((p) => p.number <= lastPlug).length; - const plugPool = shuffle(powerPlantsDeck.splice(0, plugCount), rng() + ''); - const socketPool = shuffle(powerPlantsDeck, rng() + ''); // the remaining socket cards + // The plug pool: 03–15, or 01–16 for Deck 1's plants. + const plugPool = shuffle(powerPlantsDeck.filter(isPlug), rng() + ''); + const socketPool = shuffle( + powerPlantsDeck.filter((p) => !isPlug(p)), + rng() + '' + ); // the socket cards const initialPlants = plugPool.splice(0, 9).sort((a, b) => a.number - b.number); const actualMarket = initialPlants.slice(0, 4); @@ -411,8 +413,8 @@ export const map: GameMap = { // Build the draw stack — rule: never leave a plug card on top. const deck = shuffle(remainingPlug.concat(remainingSocket), rng() + ''); - if (deck.length > 0 && deck[0].number <= lastPlug) { - const socketIndex = deck.findIndex((p) => p.number > lastPlug); + if (deck.length > 0 && isPlug(deck[0])) { + const socketIndex = deck.findIndex((p) => !isPlug(p)); if (socketIndex > 0) { [deck[0], deck[socketIndex]] = [deck[socketIndex], deck[0]]; } diff --git a/engine/src/powerPlants.ts b/engine/src/powerPlants.ts index f581f07c..58e463c1 100644 --- a/engine/src/powerPlants.ts +++ b/engine/src/powerPlants.ts @@ -1,5 +1,5 @@ import { cloneDeep } from 'lodash'; -import { GameOptions, PowerPlant, PowerPlantDeck, PowerPlantType } from './gamestate'; +import { GameOptions, PowerPlant, PowerPlantDeck, PowerPlantType, SingleDeck } from './gamestate'; const powerPlants: PowerPlant[] = [ { number: 3, type: PowerPlantType.Oil, cost: 2, citiesPowered: 1 }, @@ -167,15 +167,69 @@ function indiaCatalogue(deck: PowerPlant[], removed: number): PowerPlant[] { } const indiaPowerPlants = indiaCatalogue(powerPlants, 11); -const indiaCatalogues: Record = { +const indiaCatalogues: Record = { standard: indiaPowerPlants, deck1: indiaCatalogue(deck1PowerPlants, 8), deck2: indiaCatalogue(alternatePowerPlants, 13), }; +// The shuffled decks hold copies of the same number. The standard deck's copy keeps +// its number; Deck 1's and Deck 2's copies get .1 and .2, so each copy has its own +// number and the market ranks duplicates Deck 2 > Deck 1 > Original. plantLabel() +// prints them as "46 (Deck 2)"; rules compare the printed number. +const shuffledPowerPlants: PowerPlant[] = [ + ...powerPlants.filter((pp) => pp.type != PowerPlantType.Step3), + ...deck1PowerPlants + .filter((pp) => pp.type != PowerPlantType.Step3) + .map((pp) => ({ ...pp, number: pp.number + 0.1 })), + ...alternatePowerPlants + .filter((pp) => pp.type != PowerPlantType.Step3) + .map((pp) => ({ ...pp, number: pp.number + 0.2 })), +] + .sort((a, b) => a.number - b.number) + .concat(powerPlants.filter((pp) => pp.type == PowerPlantType.Step3)); + +// Which deck a plant's copy came from in the shuffled decks: 0 standard, 1, or 2. +function plantCopy(number: number): number { + return Math.round((number - Math.floor(number)) * 10); +} + +// A plant number as printed on the card, marking the shuffled decks' extra copies. +function plantLabel(number: number | undefined): string { + if (number === undefined) return `${number}`; + const copy = plantCopy(number); + return copy ? `${Math.floor(number)} (Deck ${copy})` : `${number}`; +} + +// Matches a plantLabel() in log text: group 1 is the printed number, group 2 the copy. +const PLANT_LABEL_PATTERN = String.raw`(\d+)(?: \(Deck ([12])\))?`; + +function labelledPlant(printed: string, copy: string | undefined): number { + return Number(printed) + (copy ? Number(copy) / 10 : 0); +} + +// Maps whose setup or rules name plant numbers: the shuffled decks, which have no +// official changes for them, aren't offered there. +const NUMBERED_SETUP_MAPS: string[] = [ + 'Australia', + 'Brazil', + 'Bremen', + 'China', + 'France', + 'India', + 'Manhattan', + 'Middle East', + 'Northern Europe', + 'Quebec', + 'Russia', + 'South Africa', + 'Spain & Portugal', +]; + // The decks a map can be played with: every map takes every single deck. -function availableDecks(_mapName: string): PowerPlantDeck[] { - return ['standard', 'deck1', 'deck2']; +function availableDecks(mapName: string): PowerPlantDeck[] { + const singleDecks: PowerPlantDeck[] = ['standard', 'deck1', 'deck2']; + return NUMBERED_SETUP_MAPS.includes(mapName) ? singleDecks : [...singleDecks, 'shuffled', 'shuffledUnique']; } // The deck a game plays with. Games from before powerPlantDeck may carry the @@ -186,8 +240,12 @@ function gameDeck(options: GameOptions): PowerPlantDeck { ); } -// The full card list a game on this map draws from. +// The full card list a game on this map draws from. A 'shuffledUnique' game deals +// a subset of it (see dealtCatalogue), so lookups by number still work. function plantCatalogue(mapName: string, deck: PowerPlantDeck = 'standard'): PowerPlant[] { + if (deck == 'shuffled' || deck == 'shuffledUnique') { + return shuffledPowerPlants; + } if (mapName == 'India') { return indiaCatalogues[deck]; } @@ -195,15 +253,42 @@ function plantCatalogue(mapName: string, deck: PowerPlantDeck = 'standard'): Pow return deck == 'deck1' ? deck1PowerPlants : deck == 'deck2' ? alternatePowerPlants : powerPlants; } +// The plants a game is dealt from: the catalogue, or for 'shuffledUnique' one random +// copy of each number (the Step 3 card stays last). +function dealtCatalogue(mapName: string, deck: PowerPlantDeck, rng: () => number): PowerPlant[] { + const catalogue = plantCatalogue(mapName, deck); + if (deck != 'shuffledUnique') { + return catalogue; + } + const copies = new Map(); + for (const pp of catalogue) { + copies.set(Math.floor(pp.number), [...(copies.get(Math.floor(pp.number)) ?? []), pp]); + } + return [...copies.values()].map((options) => options[Math.floor(rng() * options.length)]); +} + // Plants with a plug on the back form the weak opening pool: 03–15 in the -// standard and alternate decks, 01–16 in Deck 1. +// standard and alternate decks, 01–16 in Deck 1. Only for the single decks. function lastPlugPlant(deck: PowerPlantDeck): number { return deck == 'deck1' ? 16 : 15; } +// Whether a plant has a plug on the back, in any deck: Deck 1's copies are plug +// cards up to 16, the others up to 15. +function isPlugPlant(pp: { number: number }, deck: PowerPlantDeck): boolean { + const fromDeck1 = + deck == 'deck1' || ((deck == 'shuffled' || deck == 'shuffledUnique') && plantCopy(pp.number) == 1); + return Math.floor(pp.number) <= (fromDeck1 ? 16 : 15); +} + // The nuclear plant whose purchase ends uranium resupply in Recharged Germany and // Italy: Germany's per each deck's map-changes sheet, and Italy ends the same way. -const PHASE_OUT_PLANT: Record = { standard: 39, deck1: 38, deck2: 37 }; +// In the shuffled decks each deck's copy keeps its own trigger. +const PHASE_OUT_PLANT: Record = { standard: 39, deck1: 38, deck2: 37 }; + +function phaseOutPlants(deck: PowerPlantDeck): number[] { + return deck == 'shuffled' || deck == 'shuffledUnique' ? [39, 38.1, 37.2] : [PHASE_OUT_PLANT[deck]]; +} // Wind plants are always ecological. Whether fusion plants are too is a ruling: // new games take this value and store it as options.fusionEcological on the maps @@ -228,8 +313,16 @@ export { availableDecks, gameDeck, plantCatalogue, + dealtCatalogue, + plantCopy, + plantLabel, + PLANT_LABEL_PATTERN, + labelledPlant, lastPlugPlant, + isPlugPlant, PHASE_OUT_PLANT, + phaseOutPlants, + NUMBERED_SETUP_MAPS, FUSION_IS_ECOLOGICAL, ECOLOGICAL_RULE_MAPS, isEcological, diff --git a/viewer/src/components/Game.vue b/viewer/src/components/Game.vue index a0e41f8b..dda547cf 100644 --- a/viewer/src/components/Game.vue +++ b/viewer/src/components/Game.vue @@ -639,11 +639,10 @@