diff --git a/engine/src/analysis.ts b/engine/src/analysis.ts index 21de7898..f58cfd19 100644 --- a/engine/src/analysis.ts +++ b/engine/src/analysis.ts @@ -1,8 +1,23 @@ 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 { 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. */ @@ -15,18 +30,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 catalogue = JSON.parse(JSON.stringify(plantCatalogue(name, s.options.alternatePowerPlants))) 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); @@ -42,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; @@ -56,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); @@ -81,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)); @@ -100,23 +130,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 === 'Quebec' && [PowerPlantType.Wind, PowerPlantType.Nuclear].includes(p.type)) || - (name === 'Russia' && original && [10, 11].includes(p.number)); + (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 && RUSSIA_ORIGINAL_NEXT_PLANTS[powerPlantDeck].includes(p.number)); // A plug-back top card is public information in the recharged edition. if ( (!original || name === 'Manhattan') && @@ -127,7 +156,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) + isPlugPlant(p, powerPlantDeck) === + (name === 'Manhattan' && s.knownPowerPlantDeck.length === 8 ? true : s.nextCardWeak) ); if (index >= 0) top.unshift(...candidates.splice(index, 1)); } @@ -148,16 +178,17 @@ 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 : 15; - 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( @@ -171,9 +202,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( @@ -183,7 +216,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/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/available-moves.ts b/engine/src/available-moves.ts index a6b77694..2b3e6cdf 100644 --- a/engine/src/available-moves.ts +++ b/engine/src/available-moves.ts @@ -454,7 +454,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]; const price = garbagePrice(G, player, garbagePrices[garbagePrices.length - G.garbageMarket]); diff --git a/engine/src/deckChanges.ts b/engine/src/deckChanges.ts new file mode 100644 index 00000000..c5afa957 --- /dev/null +++ b/engine/src/deckChanges.ts @@ -0,0 +1,116 @@ +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; + +// 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': + // 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[] { + // 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. + 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[] { + if (isShuffled(deck)) return []; + 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 new file mode 100644 index 00000000..8b624818 --- /dev/null +++ b/engine/src/decks.spec.ts @@ -0,0 +1,495 @@ +import { expect } from 'chai'; +import { range } from 'lodash'; +import 'mocha'; +import { availableMoves } from './available-moves'; +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, + 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]; +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('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); + } + } + } + }); + + 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']); + }); + + 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 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 [ + ['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)); + }); + } +}); + +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)); + }); + } +}); + +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/ecological.spec.ts b/engine/src/ecological.spec.ts new file mode 100644 index 00000000..b5ce5d6c --- /dev/null +++ b/engine/src/ecological.spec.ts @@ -0,0 +1,93 @@ +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'; + +// 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]); + 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 86b632e0..c27d5af5 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -18,6 +18,7 @@ import { replacePoweringLog, restorePowering, } from './choice-revisions'; +import { originalTopPlants, removedPlants, SPAIN_STEP_2_PLANTS } from './deckChanges'; import { countHeldPowerPlants, GameOptions, @@ -28,6 +29,7 @@ import { Player, playerColors, PowerPlant, + PowerPlantDeck, PowerPlantType, ResourceType, resupplyUraniumMine, @@ -36,7 +38,20 @@ import { import { LogItem } from './log'; import { GameMap, maps, mapsRecharged } from './maps'; import { Move, MoveName, Moves } from './move'; -import { plantCatalogue, powerPlants, usesAlternateDeck } from './powerPlants'; +import { + availableDecks, + dealtCatalogue, + ECOLOGICAL_RULE_MAPS, + FUSION_IS_ECOLOGICAL, + gameDeck, + isEcological, + isPlugPlant, + lastPlugPlant, + phaseOutPlants, + plantCatalogue, + plantLabel, + usesAlternateDeck, +} from './powerPlants'; import prices from './prices'; import { createRandomizedMap } from './randomizeMap'; import { asserts, shuffle } from './utils'; @@ -56,21 +71,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 plant = (num: number) => deck.find((p) => p.number == num)!; + const removed = removedPlants(mapName, deck, numPlayers, variant); + const catalogue = dealtCatalogue(mapName, deck, rng).filter((p) => !removed.includes(p.number)); + let powerPlantsDeck: PowerPlant[] = cloneDeep(catalogue); if (variant == 'original') { + // 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 powerPlant13 = 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() + ''); @@ -80,13 +107,18 @@ export function defaultSetupDeck( powerPlantsDeck = powerPlantsDeck.slice(4); } - powerPlantsDeck.unshift(powerPlant13); - powerPlantsDeck.push(step3); + powerPlantsDeck.unshift(...topPlants); + powerPlantsDeck.splice(powerPlantsDeck.length - step3FromBottom, 0, step3); - actualMarket = [plant(3), plant(4), plant(5), plant(6)]; - futureMarket = [plant(7), plant(8), plant(9), plant(10)]; + actualMarket = catalogue.slice(0, 4); + futureMarket = catalogue.slice(4, 8); } else { - let initialPowerPlants = shuffle(powerPlantsDeck.splice(0, 13), 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); @@ -114,7 +146,7 @@ export function defaultSetupDeck( } powerPlantsDeck.unshift(first); - powerPlantsDeck.push(step3); + powerPlantsDeck.splice(powerPlantsDeck.length - step3FromBottom, 0, step3); } return { actualMarket, futureMarket, powerPlantsDeck }; @@ -134,7 +166,10 @@ export function setup( chooseRegions = false, chooseColors = false, alternatePowerPlants = false, + powerPlantDeck, rechargedStartingResources = true, + fusionEcological = FUSION_IS_ECOLOGICAL, + legacyEcological = false, wienFreeGarbage = false, }: GameOptions, seed?: string, @@ -168,9 +203,22 @@ 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); + // 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' + ? 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 + ? 'deck2' + : requestedDeck && decks.includes(requestedDeck) + ? requestedDeck + : 'standard'; + const ecologicalRules = ECOLOGICAL_RULE_MAPS.includes(chosenMap.name) && !legacyEcological; let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; @@ -186,7 +234,8 @@ export function setup( numPlayers, variant, rng, - alternateDeck ? deck : undefined + chosenDeck, + legacyEcological ? true : fusionEcological )); } else { ({ actualMarket, futureMarket, powerPlantsDeck } = defaultSetupDeck( @@ -194,7 +243,8 @@ export function setup( variant, rng, useNewRechargedSetup, - alternateDeck ? deck : undefined + chosenDeck, + chosenMap.name )); } } @@ -212,7 +262,7 @@ export function setup( uraniumLeft: 0, hybridCapacity: 0, hybridLeft: 0, - ...(alternateDeck ? { garbageOilHybridCapacity: 0 } : {}), + ...(chosenDeck == 'deck2' ? { garbageOilHybridCapacity: 0 } : {}), money: 50, housesLeft: 22, cities: [], @@ -365,7 +415,15 @@ export function setup( } finalMap = filterMapToRegions(chosenMap, playRegions); - applyRegionalPowerPlants(chosenMap, playRegions, actualMarket, futureMarket, powerPlantsDeck); + applyRegionalPowerPlants( + chosenMap, + playRegions, + actualMarket, + futureMarket, + powerPlantsDeck, + chosenDeck, + seed + ); } } @@ -461,8 +519,13 @@ 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 } : {}), ...(wienFreeGarbage ? { wienFreeGarbage: true } : {}), }, log: [], @@ -580,16 +643,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) { @@ -601,6 +670,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 }); + } } } } @@ -614,7 +688,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); @@ -745,7 +827,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 (phaseOutPlants((G.options.powerPlantDeck as PowerPlantDeck) ?? 'standard').includes(move.data)) { G.card39Bought = true; } @@ -758,7 +842,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); @@ -767,8 +851,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]; @@ -802,8 +886,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) { @@ -1074,14 +1160,20 @@ 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 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)) + ); } } @@ -1362,7 +1454,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); @@ -1371,7 +1465,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); @@ -1383,7 +1479,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); @@ -1393,7 +1489,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) { @@ -1401,7 +1499,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) { @@ -1419,13 +1519,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); } @@ -1479,8 +1584,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)}.`, }); } @@ -1551,10 +1656,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); @@ -1873,8 +1978,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; @@ -2160,7 +2265,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]]); @@ -2273,10 +2380,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) { @@ -2327,7 +2434,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]; @@ -2340,13 +2449,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.`, + }); } } @@ -2372,7 +2484,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); @@ -2433,7 +2545,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); @@ -2450,7 +2562,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); @@ -2471,7 +2583,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); @@ -2498,7 +2610,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.`, }); } } @@ -2551,16 +2663,17 @@ 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.`, + 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.`, }); } @@ -2616,7 +2729,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); } @@ -2625,7 +2738,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); } @@ -2784,8 +2897,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 @@ -2801,6 +2914,7 @@ export function reconstructionOptions(options: GameOptions, log: LogItem[] = []) return { ...options, rechargedStartingResources: options.rechargedStartingResources ?? false, + legacyEcological: options.fusionEcological === undefined, ...(wienFreeGarbage ? { wienFreeGarbage: true } : {}), }; } @@ -2962,7 +3076,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); } @@ -2977,7 +3091,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 51855b60..0ff7a1c5 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -35,6 +35,11 @@ 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. '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; @@ -53,14 +58,25 @@ 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). 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; // Central Europe's Wien discount once let garbage cost $0; both rulebooks say // never less than $1. Set only when rebuilding a game whose log already shows a // $0 garbage buy, so it replays as it was played (see reconstructionOptions). diff --git a/engine/src/maps.ts b/engine/src/maps.ts index e0ed2e46..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,13 +118,15 @@ 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. + // `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[]; futureMarket: 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 64e0176a..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 { PowerPlant, PowerPlantType } from '../gamestate'; +import { PowerPlant, PowerPlantDeck, PowerPlantType } from '../gamestate'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; -import { powerPlants } from './../powerPlants'; +import { dealtCatalogue, isPlugPlant } from './../powerPlants'; export enum Regions { Pink = 'pink', // UK / Ireland / Low Countries @@ -375,15 +375,19 @@ 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); + setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng, powerPlantDeck: PowerPlantDeck = 'standard') { + 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]; - // 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 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); @@ -407,10 +411,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 && 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/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 248d7906..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, PowerPlantType } from '../gamestate'; -import { 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'; @@ -206,28 +206,30 @@ export const map: GameMap = { ], ], startingResources: [21, 21, 6, 2], - setupDeck(numPlayers: number, variant: string, rng: seedrandom.prng) { - let powerPlantsDeck = cloneDeep(powerPlants); + setupDeck( + numPlayers: number, + variant: string, + rng: seedrandom.prng, + deck: PowerPlantDeck = 'standard', + fusionEcological?: boolean + ) { + const ecological = (pp: PowerPlant) => isEcological(pp, fusionEcological); + 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) => - (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 - ); + // 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()!; @@ -249,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. @@ -260,17 +300,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 +336,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/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 3199c12d..4c39c8cc 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 74ed16af..58e463c1 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, SingleDeck } from './gamestate'; const powerPlants: PowerPlant[] = [ { number: 3, type: PowerPlantType.Oil, cost: 2, citiesPowered: 1 }, @@ -47,18 +47,9 @@ 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++; - } -}); - -// 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,21 +96,234 @@ 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); } -// The full card list a game on this map draws from. -function plantCatalogue(mapName: string, alternate?: boolean): PowerPlant[] { +// 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), +}; + +// 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[] { + 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 +// 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. 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 indiaPowerPlants; + return indiaCatalogues[deck]; + } + + 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. 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. +// 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 +// 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']; - return usesAlternateDeck(mapName, alternate) ? alternatePowerPlants : powerPlants; +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 }; +export { + powerPlants, + indiaPowerPlants, + alternatePowerPlants, + deck1PowerPlants, + ALTERNATE_DECK_MAPS, + usesAlternateDeck, + 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 edf654de..9de99d29 100644 --- a/viewer/src/components/Game.vue +++ b/viewer/src/components/Game.vue @@ -640,11 +640,10 @@