diff --git a/engine/scripts/deck-table.ts b/engine/scripts/deck-table.ts new file mode 100644 index 00000000..f212ed34 --- /dev/null +++ b/engine/scripts/deck-table.ts @@ -0,0 +1,296 @@ +// Prints how every map prepares Set 1 and Set 2 under Recharged, from the deck notes, +// the maps' own preparation and real setups, for checking by eye. +// +// npx ts-node --transpile-only scripts/deck-table.ts [--json] +import { + deckBox, + deckNote, + DeckNote, + deckNotes, + NewDeck, + noGarbageInStepOne, + SHUFFLED_EXCLUDED_MAPS, + standardNotes, + uraniumStopPlant, +} from '../src/deck-notes'; +import { setup } from '../src/engine'; +import { hasPlug, MapName, PowerPlant, PowerPlantType } from '../src/gamestate'; +import { mapsRecharged } from '../src/maps'; +import { mapPreparation } from '../src/new-decks'; +import { plantCatalogue } from '../src/powerPlants'; + +export interface DeckRow { + deck: NewDeck | 'shuffled'; + // Recharged unless set. + variant?: 'original'; + map: string; + boxed: string; + market: string; + plugPool: string; + top: string; + removals: string; + protected: string; + stepTwoTop: string; + stepThree: string; + uraniumStop: string; + other: string; + // Plants in the game (market and stack) with 2–6 players, from a real setup. + inGame: number[]; +} + +const list = (numbers: number[]) => (numbers.length ? numbers.join(', ') : '—'); +// Northern Europe's regions by their colour on the board. +const regionNames: Record = { + orange: 'Denmark', + red: 'Norway', + purple: 'Sweden South', + yellow: 'Sweden North', + brown: 'Finland', + pink: 'Baltic States', +}; +const players = [2, 3, 4, 5, 6]; + +function row(deck: NewDeck, mapName: string): DeckRow { + const note = deckNote(deck, mapName); + const prep = mapPreparation(mapName); + const catalogue = plantCatalogue(mapName, false, deck); + const boxed = [...deckBox[deck], ...(note.box ?? [])]; + // Player counts that box the same plants are grouped: "2–3P: …". + const groups: { counts: number[]; text: string }[] = []; + for (const [n, nums] of Object.entries(note.boxByPlayers ?? {})) { + const text = list(nums); + const last = groups[groups.length - 1]; + if (last && last.text == text) last.counts.push(Number(n)); + else groups.push({ counts: [Number(n)], text }); + } + const byPlayers = groups + .map( + (g) => + `${g.counts.length > 1 ? `${g.counts[0]}–${g.counts[g.counts.length - 1]}` : g.counts[0]}P: ${g.text}` + ) + .join('; '); + const aside = [...(note.market ?? []), ...(note.topOfStack ?? []), ...(note.stepTwoTop ?? [])]; + const plugPool = catalogue + .filter((p) => hasPlug(p) && !boxed.includes(p.number) && !aside.includes(p.number)) + .map((p) => p.number); + const marketSize = + prep.stack == 'china' + ? 'the lowest plant for each player' + : prep.stack == 'manhattan' + ? '8' + : mapName == 'Europe' + ? '4 + 5' + : `${prep.market[0]} + ${prep.market[1]}`; + const ecological = prep.protect + ? catalogue.filter((p) => prep.protect!(p) && !boxed.includes(p.number)).map((p) => p.number) + : []; + + const removals = + prep.stack == 'china' + ? 'none (box lists)' + : prep.stack == 'manhattan' + ? `none; ${note.boxPlugs} plug cards boxed` + : prep.plugRemovals.every((n) => n == 0) && prep.socketRemovals.every((n) => n == 0) + ? 'none' + : [2, 3, 4].map((n) => `${n}P ${prep.plugRemovals[n - 2]}+${prep.socketRemovals[n - 2]}`).join(', '); + + const other: string[] = []; + if (deck == 'set2' && ['Europe', 'North America'].includes(mapName)) other.push('alternate deck path'); + if (noGarbageInStepOne({ plantDeck: deck }, mapName)) other.push('no garbage in Step 1'); + if (mapName == 'Middle East') other.push('garbage/nuclear plug plants boxed from the current market in Step 1'); + if (mapName == 'India') other.push('garbage plants burn one more garbage'); + if (mapName == 'Australia') other.push('nuclear plants are uranium mines'); + if (note.regional) { + other.push( + Object.entries(note.regional) + .map(([region, swap]) => `${regionNames[region] ?? region} boxes ${list(swap.box)}`) + .join('; ') + '; other regions box their own numbers; a duplicate number gets +0.1' + ); + } else if (mapName == 'Northern Europe') { + other.push('regions box their own numbers'); + } + + const inGame = players.map((n) => { + const G = setup(n, { map: mapName as MapName, variant: 'recharged', plantDeck: deck }, `table-${n}`); + const plants: PowerPlant[] = [...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck]; + return plants.filter((p) => p.type != PowerPlantType.Step3).length; + }); + + return { + deck, + map: mapName, + boxed: [list(boxed), byPlayers].filter((t) => t && t != '—').join('; ') || '—', + market: `${marketSize}${note.market ? `; ${list(note.market)} placed first` : ''}`, + plugPool: prep.stack == 'china' ? '—' : list(plugPool), + top: + prep.stack == 'china' + ? 'plants up to 30, ascending' + : prep.stack == 'manhattan' + ? 'the plug cards left over' + : [ + note.topOfStack ? list(note.topOfStack) : '', + { one: 'one plug card', all: 'all undrawn plug cards', none: 'no plug card' }[prep.plugsOnTop], + ] + .filter((t) => t) + .join(', then '), + removals, + protected: list([...(note.protect ?? []), ...ecological].sort((a, b) => a - b)), + stepTwoTop: list(note.stepTwoTop ?? []), + stepThree: + prep.stack == 'china' + ? 'shuffled with 31–35' + : prep.stack == 'manhattan' + ? 'boxed' + : prep.belowStepThree + ? `${prep.belowStepThree} plants below` + : 'bottom', + uraniumStop: String(uraniumStopPlant({ plantDeck: deck }, mapName) ?? '—'), + other: other.join('; ') || '—', + inGame, + }; +} + +// A shuffled deck: each deck's notes, applying only to that deck's cards. +// Under Original (rulings O1–O8): the market and the top plant from a real 4-player setup. +function originalRow(deck: NewDeck, mapName: string): DeckRow { + const recharged = row(deck, mapName); + const prep = mapPreparation(mapName); + const G = setup(4, { map: mapName as MapName, variant: 'original', plantDeck: deck }, 'table-4'); + const market = [...G.actualMarket, ...G.futureMarket].map((p) => p.number); + const topPlant = G.powerPlantsDeck[0]; + const inGame = players.map((n) => { + const g = setup(n, { map: mapName as MapName, variant: 'original', plantDeck: deck }, `table-${n}`); + return [...g.actualMarket, ...g.futureMarket, ...g.powerPlantsDeck].filter( + (p) => p.type != PowerPlantType.Step3 + ).length; + }); + const note = deckNote(deck, mapName); + return { + ...recharged, + variant: 'original', + market: + mapName == 'Europe' + ? "4 + 5 at random from the plug cards (Europe's own)" + : prep.stack == 'china' + ? 'the lowest plant for each player' + : `${list(market)} at 4P${note.market ? `; ${list(note.market)} placed first` : ''}`, + plugPool: '—', + top: + prep.stack == 'china' + ? 'plants up to 30, ascending' + : prep.stack == 'manhattan' + ? `the plug cards left over (4P: ${hasPlug(topPlant) ? topPlant.number : 'none'})` + : mapName == 'Europe' + ? 'no plant' + : [ + note.topOfStack ? list(note.topOfStack) : '', + // Ruling O2's plant, if a note didn't take it. + G.powerPlantsDeck[note.topOfStack?.length ?? 0].number == (deck == 'set1' ? 10 : 11) + ? String(deck == 'set1' ? 10 : 11) + : 'no plant (a note takes it)', + ] + .filter((t) => t) + .join(', then '), + removals: + prep.stack == 'china' + ? 'none (box lists)' + : prep.stack == 'manhattan' + ? `none; the ${note.boxPlugs} highest plug cards boxed` + : prep.originalRemovals.every((n) => n == 0) + ? 'none' + : [2, 3, 4].map((n) => `${n}P ${prep.originalRemovals[n - 2]}`).join(', '), + inGame, + }; +} + +function shuffledRow(mapName: string): DeckRow { + const excluded = SHUFFLED_EXCLUDED_MAPS.includes(mapName); + const notes: [string, DeckNote][] = [ + ['standard', standardNotes[mapName] ?? {}], + ['Set 1', deckNotes.set1[mapName] ?? {}], + ['Set 2', deckNotes.set2[mapName] ?? {}], + ]; + const byDeck = (field: keyof DeckNote) => + notes + .filter(([, note]) => Array.isArray(note[field]) && (note[field] as number[]).length) + .map(([name, note]) => `${name}'s ${list(note[field] as number[])}`) + .join('; ') || '—'; + const prep = mapPreparation(mapName); + const inGame = excluded + ? [] + : players.map((n) => { + const G = setup( + n, + { map: mapName as MapName, variant: 'recharged', plantDeck: 'shuffled' }, + `table-${n}` + ); + return [...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck].filter( + (p) => p.type != PowerPlantType.Step3 + ).length; + }); + const stops = notes.filter(([, note]) => note.uraniumStop).map(([name, note]) => `${name}'s ${note.uraniumStop}`); + const other: string[] = []; + if (excluded) other.push('not allowed: the map notes rebuild the whole deck'); + if (deckNotes.set2[mapName]?.noGarbageInStepOne) + other.push("no garbage in Step 1 when one of Set 2's garbage/oil hybrids is in the game"); + if (mapName == 'Middle East') other.push('garbage/nuclear plug plants boxed from the current market in Step 1'); + if (mapName == 'India') other.push('garbage plants burn one more garbage'); + if (mapName == 'Australia') other.push('nuclear plants are uranium mines'); + const market = byDeck('market'); + return { + deck: 'shuffled', + map: mapName, + boxed: excluded ? '—' : byDeck('box'), + market: excluded + ? '—' + : `${mapName == 'Europe' ? '4 + 5' : `${prep.market[0]} + ${prep.market[1]}`}${ + market != '—' ? `; ${market} placed first` : '' + }`, + plugPool: excluded ? '—' : "each card's own: 01–15, and 16 when it comes from Set 1", + top: excluded + ? '—' + : [byDeck('topOfStack'), 'one plug card', byDeck('belowTopPlug')].filter((t) => t != '—').join(', then '), + removals: excluded + ? '—' + : [2, 3, 4].map((n) => `${n}P ${prep.plugRemovals[n - 2]}+${prep.socketRemovals[n - 2]}`).join(', '), + protected: excluded + ? '—' + : [byDeck('protect'), prep.protect ? 'ecological plants (map rule)' : '—'] + .filter((t) => t != '—') + .join('; ') || '—', + stepTwoTop: excluded ? '—' : byDeck('stepTwoTop'), + stepThree: excluded ? '—' : prep.belowStepThree ? `${prep.belowStepThree} plants below` : 'bottom', + uraniumStop: excluded ? '—' : stops.join(', ') || '—', + other: other.join('; ') || '—', + inGame, + }; +} + +export function deckTable(): DeckRow[] { + const rows: DeckRow[] = []; + for (const deck of Object.keys(deckNotes) as NewDeck[]) { + for (const map of mapsRecharged.map((m) => m.name)) rows.push(row(deck, map)); + } + for (const map of mapsRecharged.map((m) => m.name)) rows.push(shuffledRow(map)); + for (const deck of Object.keys(deckNotes) as NewDeck[]) { + for (const map of mapsRecharged.map((m) => m.name)) rows.push(originalRow(deck, map)); + } + return rows; +} + +if (require.main === module) { + const rows = deckTable(); + if (process.argv.includes('--json')) { + console.log(JSON.stringify(rows, null, 2)); + } else { + const columns: (keyof DeckRow)[] = ['deck', 'map', 'boxed', 'market', 'plugPool', 'top', 'removals']; + console.log( + `| ${columns.join(' | ')} | protected | step 2 top | step 3 | uranium stop | in game 2–6P | other |` + ); + console.log(`|${' --- |'.repeat(columns.length + 6)}`); + for (const r of rows) { + const cells = [...columns.map((c) => r[c]), r.protected, r.stepTwoTop, r.stepThree, r.uraniumStop]; + console.log(`| ${cells.join(' | ')} | ${r.inGame.join(' / ')} | ${r.other} |`); + } + } +} diff --git a/engine/scripts/stress.ts b/engine/scripts/stress.ts index 93e76668..47b296dd 100644 --- a/engine/scripts/stress.ts +++ b/engine/scripts/stress.ts @@ -4,7 +4,7 @@ // // npx ts-node --transpile-only scripts/stress.ts [--games 200] [--cap 3000] // [--variant original,recharged] [--maps USA,Germany] [--players 2,3,4,5,6] -// [--workers 8] [--json out.json] +// [--deck standard,set1,set2,shuffled,random] [--workers 8] [--json out.json] // // A game is "stuck" when it hasn't ended and nobody has a move, and "capped" when it // hits the move cap first. @@ -12,13 +12,15 @@ import { fork } from 'child_process'; import { writeFileSync } from 'fs'; import seedrandom from 'seedrandom'; import { availableMoves } from '../src/available-moves'; +import { SHUFFLED_EXCLUDED_MAPS } from '../src/deck-notes'; import { ended, move, setup } from '../src/engine'; -import { GameState, MapName, Phase, Variant } from '../src/gamestate'; +import { GameState, MapName, Phase, PlantDeck, Variant } from '../src/gamestate'; import { maps, mapsRecharged } from '../src/maps'; import { Move, MoveName } from '../src/move'; interface Job { variant: Variant; + deck: PlantDeck; map: MapName; players: number; games: number; @@ -79,9 +81,16 @@ function randomMove(G: GameState, rng: seedrandom.prng): { player: number; move: return { player, move: { name, data: pick(rng, options) } as Move }; } -function playGame(job: Job, seed: string): { G: GameState; failure?: Failure } { +function playGame(job: Job, seed: string): { G?: GameState; failure?: Failure } { const rng = seedrandom(seed + '-moves'); - let G = setup(job.players, { map: job.map, variant: job.variant }, seed); + let G: GameState; + try { + G = setup(job.players, { map: job.map, variant: job.variant, plantDeck: job.deck }, seed); + } catch (err) { + return { + failure: { seed, kind: 'error', moves: 0, round: 0, phase: Phase.Auction, message: (err as Error).message }, + }; + } let moves = 0; const fail = (kind: Failure['kind'], message?: string) => ({ G, @@ -114,10 +123,11 @@ function playGame(job: Job, seed: string): { G: GameState; failure?: Failure } { function runJob(job: Job): JobResult { const result: JobResult = { ...job, ended: 0, totalMoves: 0, totalRounds: 0, failures: [] }; for (let i = 0; i < job.games; i++) { - const seed = `stress-${job.variant}-${job.map}-${job.players}p-${i}`; + const deck = job.deck == 'standard' ? '' : `${job.deck}-`; + const seed = `stress-${deck}${job.variant}-${job.map}-${job.players}p-${i}`; const { G, failure } = playGame(job, seed); - if (failure) { - result.failures.push(failure); + if (failure || !G) { + result.failures.push(failure!); } else { result.ended++; result.totalMoves += G.log.length; @@ -140,13 +150,18 @@ function main() { const cap = Number(a.cap ?? 3000); const variants = (a.variant ?? 'original,recharged').split(',') as Variant[]; const players = (a.players ?? '2,3,4,5,6').split(',').map(Number); + const decks = (a.deck ?? 'standard').split(',') as PlantDeck[]; const workers = Number(a.workers ?? 8); const jobs: Job[] = []; - for (const variant of variants) { - const mapNames = (variant == 'original' ? maps : mapsRecharged).map((m) => m.name as MapName); - for (const map of a.maps ? mapNames.filter((m) => a.maps.split(',').includes(m)) : mapNames) { - for (const n of players) jobs.push({ variant, map, players: n, games, cap }); + for (const deck of decks) { + for (const variant of variants) { + const mapNames = (variant == 'original' ? maps : mapsRecharged).map((m) => m.name as MapName); + for (const map of a.maps ? mapNames.filter((m) => a.maps.split(',').includes(m)) : mapNames) { + // A shuffled deck is not allowed on the maps whose notes rebuild the deck. + if (deck == 'shuffled' && SHUFFLED_EXCLUDED_MAPS.includes(map)) continue; + for (const n of players) jobs.push({ variant, deck, map, players: n, games, cap }); + } } } @@ -166,7 +181,7 @@ function main() { child.on('message', (r: JobResult) => { results.push(r); const bad = r.failures.length ? ` FAILURES ${r.failures.length}` : ''; - console.error(`[${results.length}/${jobs.length}] ${r.variant} ${r.map} ${r.players}p${bad}`); + console.error(`[${results.length}/${jobs.length}] ${r.deck} ${r.variant} ${r.map} ${r.players}p${bad}`); sendNext(); }); child.on('exit', () => { @@ -178,14 +193,17 @@ function main() { } function report(results: JobResult[], ms: number, json?: string) { - results.sort((x, y) => (x.variant + x.map + x.players).localeCompare(y.variant + y.map + y.players)); + results.sort((x, y) => + (x.deck + x.variant + x.map + x.players).localeCompare(y.deck + y.variant + y.map + y.players) + ); const count = (r: JobResult, kind: Failure['kind']) => r.failures.filter((f) => f.kind == kind).length; const total = results.reduce((s, r) => s + r.games, 0); - console.log('variant\tmap\tplayers\tgames\tended\terrors\tstuck\tcapped\tavgLog\tavgRounds'); + console.log('deck\tvariant\tmap\tplayers\tgames\tended\terrors\tstuck\tcapped\tavgLog\tavgRounds'); for (const r of results) { console.log( [ + r.deck, r.variant, r.map, r.players, diff --git a/engine/src/auction-pricing.ts b/engine/src/auction-pricing.ts index 73a88e6f..ae56bc5f 100644 --- a/engine/src/auction-pricing.ts +++ b/engine/src/auction-pricing.ts @@ -1,8 +1,8 @@ -import type { GameState, PowerPlant } from './gamestate'; +import { faceValue, GameState, PowerPlant } from './gamestate'; /** The Recharged discount also applies when deciding whether a plant can be nominated. */ export function minimumPlantBid(G: GameState, plant: PowerPlant): number { return G.options.variant === 'recharged' && G.plantDiscountActive && plant.number === G.actualMarket[0]?.number ? 1 - : plant.number; + : faceValue(plant); } diff --git a/engine/src/available-moves.ts b/engine/src/available-moves.ts index f3e4bf68..57f715e1 100644 --- a/engine/src/available-moves.ts +++ b/engine/src/available-moves.ts @@ -1,5 +1,6 @@ import { range } from 'lodash'; import { minimumPlantBid } from './auction-pricing'; +import { noGarbageInStepOne } from './deck-notes'; import { countHeldPowerPlants, GameState, @@ -447,7 +448,10 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves { } } - if (allowSouth && G.garbageMarket > 0) { + // Middle East with Set 2: no garbage in Step 1, only oil for the garbage/oil + // hybrids (plants-new-power-plants-set2-map-notes.md). + const garbageClosed = G.step == 1 && noGarbageInStepOne(G.options, G.map.name, G.shuffledDeck); + if (allowSouth && G.garbageMarket > 0 && !garbageClosed) { const garbagePrices = G.garbagePrices ?? prices[ResourceType.Garbage]; let price = garbagePrices[garbagePrices.length - G.garbageMarket]; @@ -803,9 +807,12 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves { case PowerPlantType.GarbageOilHybrid: { // Every garbage/oil split the player can afford, garbage first - // (the viewer sorts the chosen resources the same way). - for (let garbage = powerPlant.cost; garbage >= 0; garbage--) { - const oil = powerPlant.cost - garbage; + // (the viewer sorts the chosen resources the same way). In India a + // run with any garbage needs one more token; oil alone burns the + // printed amount (ruling R10). + const extra = G.map.name == 'India' ? 1 : 0; + for (let garbage = powerPlant.cost + extra; garbage >= 0; garbage--) { + const oil = powerPlant.cost + (garbage > 0 ? extra : 0) - garbage; if (garbage <= player.garbageLeft && oil <= player.oilLeft) { toUse.push({ powerPlant: powerPlant.number, diff --git a/engine/src/deck-notes.ts b/engine/src/deck-notes.ts new file mode 100644 index 00000000..234dbaf9 --- /dev/null +++ b/engine/src/deck-notes.ts @@ -0,0 +1,240 @@ +// 2F-Spiele's notes for playing The New Power Plants on each map under Recharged +// rules, as data. Set 1: plants-new-power-plants-set1-map-notes.pdf. Set 2: +// plants-new-power-plants-set2-map-notes.md (Mike Isom's transcription). Plant +// numbers are the printed numbers. A deck/map pair without a note cannot be set up. + +import { CardDeck, faceValue, GameOptions, GameState, PowerPlant, PowerPlantType } from './gamestate'; +import { alternatePowerPlants } from './powerPlants'; + +export type NewDeck = 'set1' | 'set2'; + +export interface DeckNote { + // Boxed before the market and stack are prepared. + box?: number[]; + // Placed in the opening market first; the rest of the market is drawn from the plug cards. + market?: number[]; + // Kept out of the player-count removal, then shuffled back in. + protect?: number[]; + // Set aside, then put on top of the stack once it is prepared, top first. + topOfStack?: number[]; + // Set aside, then put directly below the plug card on top of the stack, top first. + belowTopPlug?: number[]; + // Set aside, then put on top of the stack at the start of Step 2, top first. + stepTwoTop?: number[]; + // Uranium resupply stops once a player buys this plant (Germany, Italy). + uraniumStop?: number; + // No garbage can be bought during Step 1 (Middle East with Set 2). + noGarbageInStepOne?: boolean; + // Boxed before preparation, by player count (China, Bremen, Manhattan). + boxByPlayers?: Record; + // Manhattan: plug cards boxed at random before the rest go on top of the stack. + boxPlugs?: number; + // Northern Europe: for a region in play, the deck's plants boxed when it adds its two + // plants (by default the plants with the same numbers). + regional?: Record; +} + +// Boxed for every map. Set 1: 52, 54, 57 and 60 (plants-new-power-plants-set1.pdf p.2). +export const deckBox: Record = { + set1: [52, 54, 57, 60], + set2: [], +}; + +// "No changes are required" (Set 1) / "None" (Set 2). +const noChanges: DeckNote = {}; + +export const deckNotes: Record> = { + set1: { + USA: noChanges, + Germany: { uraniumStop: 38 }, + Italy: { uraniumStop: 38 }, + France: { box: [10], market: [8] }, + Brazil: { market: [6], protect: [21, 27, 33, 42] }, + 'Spain & Portugal': { stepTwoTop: [15, 16, 26] }, + Russia: { box: [6, 21] }, + Australia: { box: [19] }, + India: { box: [8] }, + 'South Africa': { box: [7] }, + Quebec: { market: [11, 15, 16] }, + // Sweden North (yellow) boxes Set 1's 21 and 26, the Baltic States (pink) its 19 + // and 22; every other region the plants with its plants' numbers. Sweden North's + // 25 joins Set 1's 25 as 25.1. + 'Northern Europe': { regional: { yellow: { box: [21, 26] }, pink: { box: [19, 22] } } }, + China: { + boxByPlayers: { + 2: [1, 2, 8, 12, 15, 22, 27, 28, 32, 36, 42], + 3: [1, 2, 8, 12, 15, 22, 27, 28, 32, 36, 42], + 4: [1, 2, 8, 15, 27, 32, 36], + 5: [1, 2, 32], + 6: [1, 2, 32], + }, + }, + Bremen: { + boxByPlayers: { + 2: [8, 19, 24, 31, 34, 37, 38, 42, 44, 46, 50], + 3: [8, 19, 24, 31, 34, 37, 38, 42, 44, 46, 50], + 4: [8, 19, 24, 31, 34, 37, 38, 42, 44, 46, 50], + 5: [8, 19, 24, 31, 37, 38, 42, 46, 50], + 6: [8, 19, 24, 31, 37, 38, 42, 46, 50], + }, + }, + // With 2 and 3 players, 16 is boxed and only 15 plug cards are left: boxing 7 + // leaves 8, all of them for the market (owner's ruling, 2026-10-29). + Manhattan: { boxByPlayers: { 2: [16, 28, 38], 3: [16, 28, 38] }, boxPlugs: 7 }, + // 06 and 08 leave the current market by Middle East's own Step 1 rule: they are + // its garbage and nuclear plants with a plug on the back. + 'Middle East': noChanges, + Benelux: noChanges, + 'Central Europe': noChanges, + Korea: noChanges, + Japan: noChanges, + 'UK & Ireland': noChanges, + Europe: noChanges, + 'North America': noChanges, + 'Baden-Württemberg': noChanges, + }, + set2: { + USA: noChanges, + Germany: { uraniumStop: 37 }, + Italy: { uraniumStop: 37 }, + France: { box: [11], market: [13] }, + Brazil: { market: [5, 14], protect: [16, 19, 26, 39] }, + 'Spain & Portugal': { stepTwoTop: [17, 24, 28] }, + Russia: { box: [5, 14] }, + Australia: { box: [21] }, + India: { box: [13] }, + 'South Africa': { box: [10] }, + Quebec: { market: [11], topOfStack: [17, 24] }, + 'Northern Europe': noChanges, + China: { + boxByPlayers: { + 2: [3, 6, 10, 13, 17, 18, 25, 26, 31, 34, 35], + 3: [3, 6, 10, 13, 17, 18, 25, 26, 31, 34, 35], + 4: [3, 6, 13, 17, 26, 31, 35], + 5: [3, 6, 31], + 6: [3, 6, 31], + }, + }, + Bremen: { + boxByPlayers: { + 2: [13, 21, 22, 32, 33, 34, 37, 38, 44, 46, 50], + 3: [13, 21, 22, 32, 33, 34, 37, 38, 44, 46, 50], + 4: [13, 21, 22, 32, 33, 34, 37, 38, 44, 46, 50], + 5: [13, 21, 32, 33, 34, 37, 38, 46, 50], + 6: [13, 21, 32, 33, 34, 37, 38, 46, 50], + }, + }, + Manhattan: { boxByPlayers: { 2: [22, 28, 36], 3: [22, 28, 36] }, boxPlugs: 4 }, + // 05, 13 and 14 leave the current market by Middle East's own Step 1 rule. + 'Middle East': { noGarbageInStepOne: true }, + Benelux: noChanges, + 'Central Europe': noChanges, + Korea: noChanges, + Japan: noChanges, + 'UK & Ireland': noChanges, + Europe: noChanges, + 'North America': noChanges, + 'Baden-Württemberg': noChanges, + }, +}; + +// The standard deck's own map rules as notes, for its cards in a shuffled deck: the map +// booklets' plant numbers, as the engine's standard Recharged setups use them. +export const standardNotes: Record = { + USA: noChanges, + Germany: { uraniumStop: 39 }, // base-recharged.pdf p.7 + Italy: { uraniumStop: 39 }, // maps-france-italy.pdf p.2 + France: { box: [13], market: [11] }, // maps-france-italy.pdf p.2 + Brazil: { market: [6, 14], protect: [19, 24, 30, 38] }, // maps-brazil-spain-portugal.pdf p.2 + 'Spain & Portugal': { stepTwoTop: [18, 22, 27] }, // maps-brazil-spain-portugal.pdf p.2 + Russia: { box: [6, 14] }, // maps-russia-japan.pdf p.2 + Australia: { box: [17] }, // maps-australia-indian-subcontinent.pdf p.2 + India: { box: [11] }, // maps-australia-indian-subcontinent.pdf p.3 + 'South Africa': { box: [7] }, // maps-middle-east-south-africa.pdf p.2 + // The engine's Recharged Québec setup: 13 among the plug cards, 18 and 22 below the + // plug card on top (maps-quebec-baden-wurttemberg.pdf p.2 sets 13, 18, 22 aside). + Quebec: { belowTopPlug: [18, 22] }, + 'Middle East': noChanges, + Benelux: noChanges, + 'Central Europe': noChanges, + Korea: noChanges, + Japan: noChanges, + 'UK & Ireland': noChanges, + Europe: noChanges, + 'North America': noChanges, + 'Baden-Württemberg': noChanges, +}; + +// The maps whose notes rebuild the whole deck cannot take a shuffled deck. +export const SHUFFLED_EXCLUDED_MAPS = ['China', 'Bremen', 'Manhattan', 'Northern Europe']; + +function cardDeckNote(deck: CardDeck, mapName: string): DeckNote { + if (deck != 'standard') return deckNote(deck, mapName); + const note = standardNotes[mapName]; + if (!note) throw new Error(`There are no standard-deck notes for ${mapName}.`); + return note; +} + +// A shuffled deck's notes: each card follows its own deck's notes, so a note's plant +// applies only when the card with that number comes from the note's deck. +export function shuffledNote(mapName: string, cards: PowerPlant[]): DeckNote { + if (SHUFFLED_EXCLUDED_MAPS.includes(mapName)) { + throw new Error(`The shuffled deck cannot be used on ${mapName}, whose map notes rebuild the whole deck.`); + } + const fields = ['box', 'market', 'protect', 'topOfStack', 'belowTopPlug', 'stepTwoTop'] as const; + const merged: DeckNote = {}; + for (const deck of ['standard', 'set1', 'set2'] as CardDeck[]) { + const note = cardDeckNote(deck, mapName); + for (const field of fields) { + const numbers = (note[field] ?? []).filter((n) => cards.find((c) => c.number == n)?.origin == deck); + if (numbers.length) merged[field] = [...(merged[field] ?? []), ...numbers].sort((a, b) => a - b); + } + } + return merged; +} + +// The plant whose purchase stops uranium resupply on Germany and Italy: 39 in the +// standard deck, else the plant the deck's map notes name (if any). +export function uraniumStopPlant(options: GameOptions, mapName: string): number | undefined { + const deck = options.plantDeck; + return deck == 'set1' || deck == 'set2' ? deckNotes[deck][mapName]?.uraniumStop : 39; +} + +// Whether buying this plant stops uranium resupply. In a shuffled deck any plant named +// by its own deck's notes does, so the first of them bought stops it (owner's ruling). +export function isUraniumStopPlant(G: GameState, plant: PowerPlant): boolean { + if (G.options.plantDeck == 'shuffled') { + // The standard deck has no German stop under Original (owner's ruling). + if (plant.origin == 'standard' && G.map.name == 'Germany' && G.options.variant == 'original') return false; + const stop = plant.origin ? cardDeckNote(plant.origin, G.map.name).uraniumStop : undefined; + return stop !== undefined && faceValue(plant) === stop; + } + return faceValue(plant) === uraniumStopPlant(G.options, G.map.name); +} + +// Middle East with Set 2: no garbage in Step 1. In a shuffled deck, whenever one of Set 2's +// garbage/oil hybrids is in the game (owner's ruling). +export function noGarbageInStepOne( + options: GameOptions, + mapName: string, + shuffledDeck?: Record +): boolean { + const deck = options.plantDeck; + if (deck == 'shuffled') { + return ( + !!deckNotes.set2[mapName]?.noGarbageInStepOne && + alternatePowerPlants.some( + (p) => p.type == PowerPlantType.GarbageOilHybrid && shuffledDeck?.[p.number] == 'set2' + ) + ); + } + return (deck == 'set1' || deck == 'set2') && !!deckNotes[deck][mapName]?.noGarbageInStepOne; +} + +export function deckNote(deck: NewDeck, mapName: string): DeckNote { + const note = deckNotes[deck][mapName]; + if (!note) { + throw new Error(`There are no ${deck == 'set1' ? 'Set 1' : 'Set 2'} map notes for ${mapName}.`); + } + return note; +} diff --git a/engine/src/engine.ts b/engine/src/engine.ts index 64dec385..5f7afe0c 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -17,14 +17,19 @@ import { replacePoweringLog, restorePowering, } from './choice-revisions'; +import { deckNote, isUraniumStopPlant, SHUFFLED_EXCLUDED_MAPS } from './deck-notes'; import { countHeldPowerPlants, + faceValue, GameOptions, GameState, + hasPlug, isEcological, isUraniumMine, MapName, Phase, + PlantDeck, + plantLabel, Player, playerColors, PowerPlant, @@ -36,7 +41,8 @@ import { } from './gamestate'; import { GameMap, maps, mapsRecharged } from './maps'; import { Move, MoveName, Moves } from './move'; -import { plantCatalogue, powerPlants, usesAlternateDeck } from './powerPlants'; +import { PreparedDeck, prepareNewDeck } from './new-decks'; +import { plantCatalogue, powerPlants, shuffledPicks, usesAlternateDeck } from './powerPlants'; import prices from './prices'; import { createRandomizedMap } from './randomizeMap'; import { rulesApply } from './rule-versions'; @@ -135,6 +141,8 @@ export function setup( chooseRegions = false, chooseColors = false, alternatePowerPlants = false, + plantDeck = 'standard', + randomPlantDeck = false, version, }: GameOptions, seed?: string, @@ -156,26 +164,46 @@ export function setup( throw new Error(`Map "${map}" not found for variant "${variant}"`); } const chosenMap = cloneDeep(chosenMapRaw); - // 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); + if (plantDeck == 'random') { + // A dedicated rng, so the pick doesn't shift the rest of the setup's draws. Never + // a shuffled deck where it is not allowed. + const deckRng = seedrandom(seed + '-deck'); + const choices: PlantDeck[] = ['standard', 'set1', 'set2', 'shuffled']; + const allowed = choices.filter((d) => d != 'shuffled' || !SHUFFLED_EXCLUDED_MAPS.includes(chosenMap.name)); + plantDeck = allowed[Math.floor(deckRng() * allowed.length)]; + randomPlantDeck = true; + } + if (plantDeck == 'shuffled' && SHUFFLED_EXCLUDED_MAPS.includes(chosenMap.name)) { + throw new Error( + `The shuffled deck cannot be used on ${chosenMap.name}, whose map notes rebuild the whole deck.` + ); + } + // A dedicated rng, so picking the cards doesn't shift the rest of the setup's draws. + const shuffledDeck = plantDeck == 'shuffled' ? shuffledPicks(seedrandom(seed + '-shuffled')) : undefined; + // The alternate deck option is only honoured on the maps that support it, where it + // is Set 2. Set 2 there is the alternate deck; elsewhere, and Set 1 anywhere, is a + // new deck prepared with its map notes. + const alternateDeck = usesAlternateDeck(chosenMap.name, alternatePowerPlants || plantDeck == 'set2'); + const newDeck = plantDeck != 'standard' && !alternateDeck ? plantDeck : undefined; + const deck = plantCatalogue(chosenMap.name, alternateDeck, newDeck, shuffledDeck); let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; let powerPlantsDeck: PowerPlant[]; + let stepTwoTopPlants: PowerPlant[] | undefined; if (forceDeck) { powerPlantsDeck = forceDeck; actualMarket = powerPlantsDeck.splice(0, 4); futureMarket = powerPlantsDeck.splice(0, 4); } else { - ({ actualMarket, futureMarket, powerPlantsDeck } = prepareDeck( + ({ actualMarket, futureMarket, powerPlantsDeck, stepTwoTopPlants } = prepareDeck( chosenMap, numPlayers, variant, rng, - { useNewRechargedSetup, version }, - alternateDeck ? deck : undefined + { useNewRechargedSetup, version, plantDeck: newDeck }, + alternateDeck || newDeck ? deck : undefined )); } @@ -192,7 +220,7 @@ export function setup( uraniumLeft: 0, hybridCapacity: 0, hybridLeft: 0, - ...(alternateDeck ? { garbageOilHybridCapacity: 0 } : {}), + ...(deck.some((p) => p.type == PowerPlantType.GarbageOilHybrid) ? { garbageOilHybridCapacity: 0 } : {}), money: 50, housesLeft: 22, cities: [], @@ -347,13 +375,13 @@ export function setup( finalMap = filterMapToRegions(chosenMap, playRegions); if (chosenMap.regionalPowerPlants && !forceDeck) { // A fresh rng on the same seed replays the preparation's own draws. - ({ actualMarket, futureMarket, powerPlantsDeck } = prepareDeck( + ({ actualMarket, futureMarket, powerPlantsDeck, stepTwoTopPlants } = prepareDeck( chosenMap, numPlayers, variant, seedrandom(seed), - { useNewRechargedSetup, version }, - withRegionalPlants(deck, chosenMap, playRegions) + { useNewRechargedSetup, version, plantDeck: newDeck }, + withRegionalPlants(deck, chosenMap, playRegions, newDeck) )); } } @@ -458,6 +486,8 @@ export function setup( chooseColors, // Only stored when in effect, so states of every other game are unchanged. ...(alternateDeck ? { alternatePowerPlants: true } : {}), + // A random deck is stored as the deck picked, so a replay builds the same deck. + ...(randomPlantDeck ? { plantDeck, randomPlantDeck: true } : newDeck ? { plantDeck: newDeck } : {}), ...(version !== undefined ? { version } : {}), }, log: [], @@ -511,15 +541,26 @@ export function setup( card39Bought: false, knownPowerPlantDeck: actualMarket.concat(futureMarket), knownPowerPlantDeckStep3: [], + ...(stepTwoTopPlants ? { stepTwoTopPlants } : {}), + ...(shuffledDeck ? { shuffledDeck } : {}), powerPlantDeckAfterStep3: undefined, } as GameState; G.log.push({ type: 'event', event: 'Game Start!' }); + if (randomPlantDeck) { + const names: Record = { + standard: 'the standard deck', + set1: 'The New Power Plants, Set 1', + set2: 'The New Power Plants, Set 2', + shuffled: 'a shuffled deck', + }; + G.log.push({ type: 'event', event: `The power plant deck was picked at random: ${names[plantDeck]}.` }); + } if (G.map.name == 'Middle East') { removePlantsForMiddleEastStep1(G); G.cardsLeft = G.powerPlantsDeck.length; - G.nextCardWeak = variant === 'recharged' && G.cardsLeft > 0 && G.powerPlantsDeck[0].number <= 15; + G.nextCardWeak = variant === 'recharged' && G.cardsLeft > 0 && hasPlug(G.powerPlantsDeck[0]); } // chooseRegions: start in the region draft instead of the auction. The map @@ -583,25 +624,43 @@ function prepareDeck( rng: seedrandom.prng, options: GameOptions, catalogue?: PowerPlant[] -) { +): PreparedDeck { + if (options.plantDeck == 'set1' || options.plantDeck == 'set2' || options.plantDeck == 'shuffled') { + return prepareNewDeck(map, numPlayers, options.plantDeck, rng, catalogue!, variant); + } return map.setupDeck ? map.setupDeck(numPlayers, variant, rng, catalogue, options) : defaultSetupDeck(numPlayers, variant, rng, options.useNewRechargedSetup ?? true, catalogue); } -// Northern Europe: each region in play adds its two plants and boxes the plants with -// the same numbers, before the market and stack are prepared -// (maps-northern-europe-uk-ireland.pdf p.2). -function withRegionalPlants(catalogue: PowerPlant[], map: GameMap, regions: Set): PowerPlant[] { - const added = [...regions].reduce( - (plants, region) => plants.concat(map.regionalPowerPlants?.[region] ?? []), - [] as PowerPlant[] - ); - const numbers = new Set(added.map((p) => p.number)); - return catalogue - .filter((p) => !numbers.has(p.number)) - .concat(cloneDeep(added)) - .sort((a, b) => a.number - b.number); +// Northern Europe: each region in play adds its two plants and boxes the deck's plants +// with the same numbers, before the market and stack are prepared +// (maps-northern-europe-uk-ireland.pdf p.2). A new deck's notes may box other numbers +// instead. A regional plant has a plug on the back if its deck's cards with that number +// do (Norway's 07 and Sweden South's 10 join the plug cards). One whose number is still +// in the deck becomes number + 0.1, as Sweden North's 25 with Set 1 (ruling R11). +export function withRegionalPlants( + catalogue: PowerPlant[], + map: GameMap, + regions: Set, + plantDeck?: PlantDeck +): PowerPlant[] { + const swaps = plantDeck == 'set1' || plantDeck == 'set2' ? deckNote(plantDeck, map.name).regional ?? {} : {}; + const plugMax = Math.max(0, ...catalogue.filter(hasPlug).map(faceValue)); + const boxed = new Set(); + let added: PowerPlant[] = []; + for (const region of regions) { + const plants = map.regionalPowerPlants?.[region] ?? []; + (swaps[region]?.box ?? plants.map((p) => p.number)).forEach((n) => boxed.add(n)); + added = added.concat(plants); + } + const kept = catalogue.filter((p) => !boxed.has(p.number)); + const regional = cloneDeep(added).map((p) => ({ + ...p, + plug: faceValue(p) <= plugMax, + ...(kept.some((k) => k.number == p.number) ? { number: p.number + 0.1 } : {}), + })); + return [...kept, ...regional].sort((a, b) => a.number - b.number); } // Completes the chooseRegions draft: filters the map to the picked regions, applies @@ -617,7 +676,12 @@ function finalizeRegions(G: GameState) { if (mapData.regionalPowerPlants) { // The deck is prepared again now that the regions are known, replaying the // setup's draws (setup's first use of its rng was the deck preparation). - const catalogue = plantCatalogue(mapData.name, G.options.alternatePowerPlants); + const catalogue = plantCatalogue( + mapData.name, + G.options.alternatePowerPlants, + G.options.plantDeck, + G.shuffledDeck + ); ({ actualMarket: G.actualMarket, futureMarket: G.futureMarket, @@ -628,7 +692,7 @@ function finalizeRegions(G: GameState) { variant, seedrandom(G.seed), G.options, - withRegionalPlants(catalogue, mapData, playRegions) + withRegionalPlants(catalogue, mapData, playRegions, G.options.plantDeck) )); } // knownPowerPlantDeck mirrors actualMarket+futureMarket and must reflect any swap. @@ -761,7 +825,9 @@ export function move( G.chosenPowerPlant = G.actualMarket.find((p) => p.number === move.data)!; G.auctioningPlayer = player.id; - if (move.data == 39) { + // The uranium stop plant (base-recharged.pdf p.7, maps-france-italy.pdf p.2; + // with a new deck, its map notes). An auction always ends in a sale. + if (isUraniumStopPlant(G, G.chosenPowerPlant)) { G.card39Bought = true; } @@ -774,7 +840,7 @@ export function move( G.plantDiscountActive = false; move.usedPlantDiscount = true; } else { - G.minimunBid = move.data; + G.minimunBid = faceValue(G.chosenPowerPlant); } const notPassed = G.players.filter((p) => !p.skipAuction && !p.isDropped); @@ -783,8 +849,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(G.chosenPowerPlant)}.`, + pretty: `${playerNameHTML(player)} chooses Power Plant ${plantLabel(G.chosenPowerPlant)}.`, }); const winningPlayer = notPassed[0]; @@ -818,8 +884,12 @@ 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( + G.chosenPowerPlant + )} to initiate an auction.`, + pretty: `${playerNameHTML(player)} chooses Power Plant ${plantLabel( + G.chosenPowerPlant + )} to initiate an auction.`, }); if (G.options.fastBid) { @@ -933,7 +1003,9 @@ export function move( if (G.auctionSkips == 1 && G.map.name == 'Russia' && G.actualMarket?.length > 0) { G.log.push({ type: 'event', - event: `First pass, removing lowest numbered Power Plant (${G.actualMarket[0].number}).`, + event: `First pass, removing lowest numbered Power Plant (${plantLabel( + G.actualMarket[0] + )}).`, }); G.actualMarket.shift(); @@ -946,9 +1018,9 @@ export function move( if (G.auctionSkips == G.players.length && G.map.name == 'Baden-Württemberg') { // Baden-Württemberg: remove the two lowest plants when no one buys. // This applies regardless of variant. - const removed: number[] = []; + const removed: string[] = []; for (let i = 0; i < 2 && G.actualMarket.length > 0; i++) { - removed.push(G.actualMarket[0].number); + removed.push(plantLabel(G.actualMarket[0])); G.actualMarket.shift(); addPowerPlant(G); } @@ -966,7 +1038,9 @@ export function move( ) { G.log.push({ type: 'event', - event: `Everyone passed, removing lowest numbered Power Plant (${G.actualMarket[0].number}).`, + event: `Everyone passed, removing lowest numbered Power Plant (${plantLabel( + G.actualMarket[0] + )}).`, }); G.actualMarket.shift(); @@ -1327,7 +1401,10 @@ export function move( ((G.map.name == 'Italy' && (G.options.variant == 'recharged' || rulesApply(G.options, 'italyUraniumStopOriginal'))) || - (G.map.name == 'Germany' && G.options.variant == 'recharged')); + // A new deck's German stop applies under Original too (ruling O4). + (G.map.name == 'Germany' && + (G.options.variant == 'recharged' || + ['set1', 'set2', 'shuffled'].includes(G.options.plantDeck ?? 'standard')))); let uraniumResupplyValue = 0; if (!uraniumPhasedOut) { uraniumResupplyValue = Math.min( @@ -1361,7 +1438,7 @@ 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)} on the bottom of the deck.`, }); G.powerPlantsDeck.push(powerPlantToPush); addPowerPlant(G); @@ -1370,7 +1447,7 @@ 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)} on the bottom of the deck.`, }); G.powerPlantsDeck.push(powerPlantToPush); addPowerPlant(G); @@ -1382,7 +1459,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)}.`, }); G.actualMarket.shift(); addPowerPlant(G); @@ -1418,13 +1495,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 + )} 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])}.`, + }); G.actualMarket.shift(); addPowerPlant(G); } @@ -1472,14 +1554,15 @@ export function move( case MoveName.DiscardPowerPlant: { asserts(move); + const discardedPlant = player.powerPlants.find((pp) => pp.number == move.data); if (!move.extra) { G.log.push({ 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(discardedPlant)}.`, + pretty: `${playerNameHTML(player)} discards Power Plant ${plantLabel(discardedPlant)}.`, }); } @@ -1550,10 +1633,12 @@ 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(discardedPlant)} and ${discarded.join( ', ' )}.`, + pretty: `${playerNameHTML(player)} discards Power Plant ${plantLabel( + discardedPlant + )} and ${discarded.join(', ')}.`, }); refillMarketAfterPurchase(G); @@ -1830,7 +1915,7 @@ export function move( if (updateBuildingMarket && G.options.variant == 'original') { if ( G.actualMarket.length > 0 && - player.cities.length >= G.actualMarket[0].number && + player.cities.length >= faceValue(G.actualMarket[0]) && G.map.name != 'China' && G.map.name != 'Russia' && G.map.name != 'Baden-Württemberg' @@ -1901,7 +1986,7 @@ export function move( : move; G.cardsLeft = G.powerPlantsDeck.length; - G.nextCardWeak = G.options.variant == 'recharged' && G.cardsLeft > 0 && G.powerPlantsDeck[0].number <= 15; + G.nextCardWeak = G.options.variant == 'recharged' && G.cardsLeft > 0 && hasPlug(G.powerPlantsDeck[0]); updateClocks(G, move.poweringClock?.at ?? move.serverTime ?? move.time); if (move.poweringClock) { @@ -2296,10 +2381,10 @@ export function addPowerPlant(G: GameState): void { !(G.map.name == 'Russia' && rulesApply(G.options, 'russiaKeepsDrawnPlants')) ) { const maxCities = Math.max(...G.players.map((p) => p.cities.length)); - while (powerPlant.number <= maxCities) { + while (faceValue(powerPlant) <= maxCities) { G.log.push({ type: 'event', - event: `Power plant ${powerPlant?.number} discarded.`, + event: `Power plant ${plantLabel(powerPlant)} discarded.`, }); if (G.powerPlantsDeck.length > 0) { @@ -2381,7 +2466,7 @@ 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)} discarded.`, }); const market = [...G.actualMarket, ...G.futureMarket]; @@ -2390,16 +2475,18 @@ export function addPowerPlant(G: GameState): void { G.futureMarket = []; } } else { - if (G.plantDiscountActive && powerPlant.number < G.actualMarket[0].number) { + // "a printed number lower than the printed number of the discounted power plant" + // (base-recharged.pdf p.4) + if (G.plantDiscountActive && faceValue(powerPlant) < faceValue(G.actualMarket[0])) { G.log.push({ type: 'event', - event: `Power Plant ${powerPlant.number} drawn from the deck and discarded.`, + event: `Power Plant ${plantLabel(powerPlant)} 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)} drawn from the deck.` }); } } @@ -2494,7 +2581,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)} drawn from the deck.` }); G.futureMarket.push(drawn); } const market = [...G.actualMarket, ...G.futureMarket].sort((a, b) => a.number - b.number); @@ -2511,7 +2598,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)} aside on the recycle pile.`, }); } refillMarketTo(8); @@ -2532,7 +2619,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)} on the bottom of the deck.`, }); } refillMarketTo(8); @@ -2559,7 +2646,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)}, from the game.`, }); } } @@ -2613,7 +2700,8 @@ export function applyManhattanDepletionOnEmptyDeck(G: GameState): void { } // During step 1 for the Middle East map, we remove garbage and uranium plants from the actual market. -// If the number is 6, 11, or 14, the plant is removed from the game. Otherwise, it's put under the deck. +// A plant with a plug on the back is removed from the game; a bigger one is put under the deck +// (maps-middle-east-south-africa.pdf p.2). In the standard deck these are plants 6, 11 and 14. function removePlantsForMiddleEastStep1(G: GameState) { let plantToRemove: PowerPlant | undefined = G.actualMarket.find( (pp: PowerPlant) => pp.type == PowerPlantType.Garbage || pp.type == PowerPlantType.Uranium @@ -2622,16 +2710,16 @@ function removePlantsForMiddleEastStep1(G: GameState) { while (plantToRemove) { removePowerPlant(G, plantToRemove); - if ([6, 11, 14].includes(plantToRemove.number)) { + if (hasPlug(plantToRemove)) { G.log.push({ type: 'event', - event: `Removing Power Plant ${plantToRemove.number} from game.`, + event: `Removing Power Plant ${plantLabel(plantToRemove)} 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)} to the bottom of the deck.`, }); } @@ -2665,10 +2753,14 @@ function removePlantsForMiddleEastStep1(G: GameState) { // (base-recharged.pdf p.7, base-original-2013.pdf p.6), with the maps' own changes. function enterStepTwo(G: GameState, reason = 'Starting Step 2') { const powerPlant = G.actualMarket.shift()!; - startStep(G, 2, `${reason}, Power Plant ${powerPlant?.number} discarded.`); - - // Spain & Portugal: put plants 18, 22 and 27 on top - if (G.map.name == 'Spain & Portugal') { + startStep(G, 2, `${reason}, Power Plant ${plantLabel(powerPlant)} discarded.`); + + // Spain & Portugal: put plants 18, 22 and 27 on top; with a new deck, the plants + // its map notes name. + if (G.stepTwoTopPlants) { + G.powerPlantsDeck.unshift(...G.stepTwoTopPlants); + G.stepTwoTopPlants = undefined; + } else if (G.map.name == 'Spain & Portugal') { if (!G.powerPlantsDeck.find((p) => p.number == 18)) { G.powerPlantsDeck.unshift(getPowerPlant(27)); G.powerPlantsDeck.unshift(getPowerPlant(22)); @@ -2717,7 +2809,7 @@ function enterStepTwoMiddleEast(G: GameState) { if (powerPlantDiscarded) { G.log.push({ type: 'event', - event: `Power Plant ${powerPlantDiscarded.number} discarded to start step 2.`, + event: `Power Plant ${plantLabel(powerPlantDiscarded)} discarded to start step 2.`, }); addPowerPlant(G); } @@ -2740,7 +2832,7 @@ function enterStepTwoMiddleEastAtOnce(G: GameState) { if (powerPlantDiscarded) { G.log.push({ type: 'event', - event: `Power Plant ${powerPlantDiscarded.number} discarded to start step 2.`, + event: `Power Plant ${plantLabel(powerPlantDiscarded)} discarded to start step 2.`, }); addPowerPlant(G); } @@ -2959,7 +3051,7 @@ export function calculateMaxCitiesPowered(G: GameState, player: Player) { let max = 0; permutations.forEach((permutation) => { - if (isValid(player, permutation)) { + if (isValid(player, permutation, G.map.name == 'India')) { const citiesPowered = permutation.map((p) => p.citiesPowered).reduce((a, b) => a + b, 0); max = Math.max(max, citiesPowered); } @@ -2968,7 +3060,7 @@ export function calculateMaxCitiesPowered(G: GameState, player: Player) { return Math.min(player.cities.length, max); } -function isValid(player: Player, powerPlants: PowerPlant[]) { +function isValid(player: Player, powerPlants: PowerPlant[], india = false) { const coalUsed = powerPlants .filter((pp) => pp.type == PowerPlantType.Coal) .map((pp) => pp.cost) @@ -3006,23 +3098,33 @@ function isValid(player: Player, powerPlants: PowerPlant[]) { // Garbage/oil hybrids draw on the leftover garbage and oil, sharing that oil with // any coal/oil hybrids: each kind must fit its own fuels, and both together must // fit everything that is left. - const garbageOilUsed = powerPlants - .filter((pp) => pp.type == PowerPlantType.GarbageOilHybrid) - .map((pp) => pp.cost) - .reduce((a, b) => a + b, 0); - if (garbageOilUsed > 0) { - const coalRemaining = player.coalLeft - coalUsed; - const oilRemaining = player.oilLeft - oilUsed; - const garbageRemaining = player.garbageLeft - garbageUsed; + const garbageOilPlants = powerPlants.filter((pp) => pp.type == PowerPlantType.GarbageOilHybrid); + const coalRemaining = player.coalLeft - coalUsed; + const oilRemaining = player.oilLeft - oilUsed; + const garbageRemaining = player.garbageLeft - garbageUsed; + const fits = (garbageOilUsed: number) => + garbageOilUsed <= garbageRemaining + oilRemaining && + hybridUsed + garbageOilUsed <= coalRemaining + oilRemaining + garbageRemaining; + if (!india) { + return fits(garbageOilPlants.map((pp) => pp.cost).reduce((a, b) => a + b, 0)); + } + + // India (ruling R10): each garbage/oil hybrid runs on its printed amount of oil, or + // on one more token with at least one garbage. Try every choice. + for (let mixed = 0; mixed < Math.pow(2, garbageOilPlants.length); mixed++) { + const withGarbage = garbageOilPlants.filter((_, i) => mixed & Math.pow(2, i)); + const oilOnly = garbageOilPlants.filter((_, i) => !(mixed & Math.pow(2, i))); + const oilOnlyUsed = oilOnly.map((pp) => pp.cost).reduce((a, b) => a + b, 0); + const withGarbageUsed = withGarbage.map((pp) => pp.cost + 1).reduce((a, b) => a + b, 0); if ( - garbageOilUsed > garbageRemaining + oilRemaining || - hybridUsed + garbageOilUsed > coalRemaining + oilRemaining + garbageRemaining + oilOnlyUsed <= oilRemaining && + withGarbage.length <= garbageRemaining && + fits(oilOnlyUsed + withGarbageUsed) ) { - return false; + return true; } } - - return true; + return false; } function toResourcesPhase(G: GameState) { @@ -3054,7 +3156,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])}.` }); G.actualMarket.shift(); addPowerPlant(G); } @@ -3077,7 +3179,7 @@ function toResourcesPhase(G: GameState) { } 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)} discarded.`); applyAustraliaStep3Shift(G); G.actualMarket = [...G.actualMarket, ...G.futureMarket]; diff --git a/engine/src/gamestate.ts b/engine/src/gamestate.ts index 04f96c1e..6892d1ff 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -35,6 +35,9 @@ export type MapName = | 'Bremen' | 'Manhattan'; export type Variant = 'original' | 'recharged'; +export type PlantDeck = 'standard' | 'set1' | 'set2' | 'shuffled' | 'random'; +// The decks a card can come from. +export type CardDeck = 'standard' | 'set1' | 'set2'; export interface GameOptions { fastBid?: boolean; @@ -57,6 +60,13 @@ export interface GameOptions { // alternatePowerPlants in powerPlants.ts) instead of the standard one. Only // honoured on the maps in ALTERNATE_DECK_MAPS; ignored everywhere else. alternatePowerPlants?: boolean; + // The power plant deck: the standard deck, or The New Power Plants Set 1 or Set 2 + // with 2F-Spiele's map notes (deck-notes.ts). Recharged only for now. Stored only + // when it is not the standard deck. alternatePowerPlants still means Set 2 on + // Europe and North America. + plantDeck?: PlantDeck; + // Set by setup when plantDeck was 'random'; plantDeck then holds the deck picked. + randomPlantDeck?: boolean; // Replaying an old game only: the site engine version it was played under // (game.version in a boardgamers.space export). Rule changes newer than this // version are not applied (see rule-versions.ts). New games never set it. @@ -91,6 +101,13 @@ export interface PowerPlant { cost: number; citiesPowered: number; storage?: number; + // A plug on the card's back: the weak plants used for the opening market and + // the "next card is weak" hint (standard and Set 2: 03–15, Set 1: 01–16). + plug?: boolean; + // Printed with a "+" after the number (Set 1: 46+, 50+, 52+, 54+, 57+, 60+). Display only. + plus?: boolean; + // The deck the card comes from, in a shuffled deck only. + origin?: CardDeck; } export interface CityPosition { @@ -254,12 +271,18 @@ export interface GameState { minimunBid: number; plantDiscountActive: boolean; discountBonusPlayer?: number; // Manhattan: id of the player who bought the discounted plant and may purchase one more this phase. + // A shuffled deck: the deck each plant number's card was picked from. + shuffledDeck?: Record; + // Plants put on top of the stack at the start of Step 2, top first (map notes of the + // new decks, e.g. Spain & Portugal). Undefined once used and in every other game. + stepTwoTopPlants?: PowerPlant[]; // Middle East: the first Step 3 card was drawn; Step 2 begins at the start of the next phase. middleEastStep2Pending?: boolean; pendingMarketRefill?: boolean; // A purchase is owed its replacement plant. Set in endAuction, consumed by whichever path finishes the purchase (#67). discardSmallestPlant: boolean; // For Benelux map, there are times when we remove the smallest plant. nextCardWeak: boolean; cardsLeft: number; + // The uranium stop plant was bought (39 in the standard deck; see uraniumStopPlant). card39Bought: boolean; knownPowerPlantDeck: PowerPlant[]; knownPowerPlantDeckStep3: PowerPlant[]; @@ -296,6 +319,23 @@ export function isUraniumMine(G: GameState, plant: PowerPlant): boolean { return G.map.name === 'Australia' && plant.type === PowerPlantType.Uranium; } +// The number printed on the card. Plant numbers are identities and may be fractional +// to keep two cards with the same printed number apart (25.1 sorts just above 25). +export function faceValue(plant: PowerPlant): number { + return Math.floor(plant.number); +} + +// How a plant's number is shown: its face value, with the printed "+" if any. +export function plantLabel(plant: PowerPlant | undefined): string { + return plant ? `${faceValue(plant)}${plant.plus ? '+' : ''}` : String(plant); +} + +// States saved before cards carried the plug flag hold only standard-deck cards, +// whose plug cards are exactly 03–15. +export function hasPlug(plant: PowerPlant): boolean { + return plant.plug ?? faceValue(plant) <= 15; +} + // Ecological plants need no fuel: the green wind plants and the blue fusion plant // (PowerPlantType.Nuclear). base-recharged.pdf p.3: "Ecological power plants have a // green or blue color." Québec, an Original-edition map, counts only the green ones. diff --git a/engine/src/maps/europe.ts b/engine/src/maps/europe.ts index 227dcc8d..4c45496a 100644 --- a/engine/src/maps/europe.ts +++ b/engine/src/maps/europe.ts @@ -1,7 +1,7 @@ // by John and Cici import { cloneDeep } from 'lodash'; import seedrandom from 'seedrandom'; -import { GameOptions, PowerPlant, PowerPlantType } from '../gamestate'; +import { GameOptions, hasPlug, PowerPlant, PowerPlantType } from '../gamestate'; import { rulesApply } from '../rule-versions'; import { shuffle } from '../utils'; import { GameMap } from './../maps'; @@ -357,11 +357,10 @@ export const map: GameMap = { // Total cubes in the game (used cubes return here; refill draws from here). startingSupply: [24, 24, 24, 12], // Europe market setup (official rules): the opening market is drawn ONLY from the - // plug-back / Set 2 cards. The engine ships one combined deck, so we follow the - // established convention that the 13 lowest plants (numbers 3–15) are the "plug" - // pool — the same band the default Recharged setup treats separately. Shuffle that - // pool, draw 9, and place the 4 lowest in the current market and the next 5 in the - // future market. Everything >= 16 is the "socket" pool. + // plug-back / Set 2 cards. The plug pool is the cards with a plug on the back + // (03–15 in the standard deck and Set 2, 01–16 in Set 1). Shuffle that pool, draw 9, + // and place the 4 lowest in the current market and the next 5 in the future + // market. The other cards are the "socket" pool. // // Then reduce the draw deck by player count, split plug vs socket per the Recharged // rulebook table: @@ -371,7 +370,7 @@ export const map: GameMap = { // The 4 leftover plug cards (13 drawn down to 9 in the market) cover the plug removals. // // Finally, per the rules the draw stack must NOT have a plug card on top: if the top - // is <= 15, swap a socket card up. The Step 3 card is buried at the bottom. + // card has a plug, swap a socket card up. The Step 3 card is buried at the bottom. // // 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 @@ -388,9 +387,11 @@ export const map: GameMap = { 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 + const plugPool = shuffle(powerPlantsDeck.filter(hasPlug), rng() + ''); + const socketPool = shuffle( + powerPlantsDeck.filter((p) => !hasPlug(p)), + rng() + '' + ); const initialPlants = plugPool.splice(0, 9).sort((a, b) => a.number - b.number); const actualMarket = initialPlants.slice(0, 4); @@ -424,10 +425,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 && hasPlug(deck[0])) { + const socketIndex = deck.findIndex((p) => !hasPlug(p)); if (socketIndex > 0) { [deck[0], deck[socketIndex]] = [deck[socketIndex], deck[0]]; } diff --git a/engine/src/maps/germany.ts b/engine/src/maps/germany.ts index e66a3c59..a5dc6e1a 100644 --- a/engine/src/maps/germany.ts +++ b/engine/src/maps/germany.ts @@ -324,5 +324,6 @@ export const mapRecharged: GameMap = { ], layout: 'Landscape', mapPosition: [20, 20], - mapSpecificRules: 'Stop resupplying uranium after power plant 39 has been bought.', + mapSpecificRules: + 'Stop resupplying uranium after power plant 39 has been bought (with The New Power Plants: 38 in Set 1, 37 in Set 2).', }; diff --git a/engine/src/maps/italy.ts b/engine/src/maps/italy.ts index a6fbe541..514f5729 100644 --- a/engine/src/maps/italy.ts +++ b/engine/src/maps/italy.ts @@ -184,6 +184,7 @@ export const map: GameMap = { { nodes: [Cities.Ferrara, Cities.Modena], cost: 4 }, ], mapPosition: [-20, 320], - mapSpecificRules: 'Stop resupplying uranium after power plant 39 has been bought.', + mapSpecificRules: + 'Stop resupplying uranium after power plant 39 has been bought (with The New Power Plants: 38 in Set 1, 37 in Set 2).', startingResources: [18, 15, 12, 2], }; diff --git a/engine/src/new-decks.spec.ts b/engine/src/new-decks.spec.ts new file mode 100644 index 00000000..f48662ff --- /dev/null +++ b/engine/src/new-decks.spec.ts @@ -0,0 +1,576 @@ +import { expect } from 'chai'; +import 'mocha'; +import seedrandom from 'seedrandom'; +import { availableMoves } from './available-moves'; +import { deckNote } from './deck-notes'; +import { calculateMaxCitiesPowered, move, reconstructState, setup, updatePlayerCapacity } from './engine'; +import { GameState, hasPlug, MapName, Phase, plantLabel, PowerPlant, PowerPlantType, ResourceType } from './gamestate'; +import { mapsRecharged } from './maps'; +import { Move, MoveName } from './move'; +import { alternatePowerPlants, plantCatalogue, powerPlants, set1PowerPlants } from './powerPlants'; + +const { Coal, Oil, Garbage, Uranium, Hybrid, Wind, Nuclear, GarbageOilHybrid } = PowerPlantType; + +// "number type cost→cities" from Appendix A of engine-update-scope.md. +function card(number: number, type: PowerPlantType, cost: number, citiesPowered: number) { + return { number, type, cost, citiesPowered }; +} +const shape = (plants: PowerPlant[]) => + plants.filter((p) => p.type != PowerPlantType.Step3).map((p) => card(p.number, p.type, p.cost, p.citiesPowered)); + +// Every current player passes until the phase changes. +function passPhase(G: GameState): GameState { + const phase = G.phase; + while (G.phase == phase && G.currentPlayers.length > 0) { + const p = G.currentPlayers[0]; + G.players[p].availableMoves = availableMoves(G, G.players[p]); + G = move(G, { name: MoveName.Pass, data: true }, p); + } + return G; +} + +const set1Appendix = [ + card(1, Coal, 3, 1), card(2, Oil, 2, 1), card(3, Coal, 1, 1), card(4, Hybrid, 1, 1), card(5, Hybrid, 3, 2), + card(6, Garbage, 1, 2), card(7, Oil, 2, 2), card(8, Uranium, 1, 2), card(9, Coal, 3, 3), card(10, Wind, 0, 1), + card(11, Wind, 0, 1), card(12, Oil, 1, 2), card(13, Hybrid, 1, 2), card(14, Coal, 3, 4), card(15, Wind, 0, 2), + card(16, Wind, 0, 2), card(19, Uranium, 1, 3), card(20, Oil, 1, 3), card(21, Garbage, 3, 5), card(22, Oil, 2, 4), + card(23, Coal, 2, 4), card(24, Uranium, 1, 4), card(25, Oil, 3, 5), card(26, Wind, 0, 3), card(27, Garbage, 1, 4), + card(28, Coal, 3, 5), card(29, Hybrid, 3, 5), card(30, Coal, 1, 4), card(31, Uranium, 1, 5), card(32, Wind, 0, 4), + card(33, Garbage, 2, 5), card(34, Coal, 3, 6), card(35, Coal, 1, 5), card(36, Hybrid, 2, 6), card(37, Oil, 3, 7), + card(38, Uranium, 1, 6), card(39, Wind, 0, 5), card(40, Oil, 1, 6), card(42, Garbage, 2, 7), card(44, Nuclear, 0, 6), + card(46, Coal, 2, 7), card(50, Uranium, 2, 8), card(52, Garbage, 3, 8), card(54, Oil, 2, 8), card(57, Oil, 3, 9), + card(60, Nuclear, 0, 8), +]; // prettier-ignore + +const set2Appendix = [ + card(3, Coal, 2, 1), card(4, Coal, 2, 1), card(5, Garbage, 2, 1), card(6, Oil, 1, 1), card(7, Coal, 1, 1), + card(8, GarbageOilHybrid, 3, 2), card(9, Coal, 3, 2), card(10, Oil, 2, 2), card(11, Wind, 0, 1), card(12, Coal, 2, 2), + card(13, Uranium, 1, 2), card(14, Garbage, 1, 2), card(15, Coal, 1, 2), card(16, Garbage, 2, 3), card(17, Wind, 0, 2), + card(18, Oil, 2, 3), card(19, Garbage, 1, 3), card(20, Coal, 3, 4), card(21, Uranium, 1, 3), card(22, GarbageOilHybrid, 3, 5), + card(23, Oil, 2, 4), card(24, Wind, 0, 3), card(25, Coal, 2, 5), card(26, Garbage, 1, 4), card(27, Coal, 1, 4), + card(28, Wind, 0, 3), card(29, Coal, 2, 5), card(30, Oil, 2, 5), card(31, Wind, 0, 4), card(32, Uranium, 2, 5), + card(33, Coal, 3, 6), card(34, Garbage, 3, 6), card(35, GarbageOilHybrid, 2, 5), card(36, Wind, 0, 5), card(37, Uranium, 2, 6), + card(38, Oil, 3, 6), card(39, Garbage, 2, 6), card(40, Coal, 2, 6), card(42, Oil, 2, 6), card(44, Wind, 0, 6), + card(46, Garbage, 2, 7), card(50, Uranium, 2, 7), +]; // prettier-ignore + +describe('New power plant decks', () => { + it('matches the repo alternate deck to the Set 2 list in Appendix A', () => { + expect(shape(alternatePowerPlants)).to.deep.equal(set2Appendix); + }); + + it('holds the Set 1 list from Appendix A, with the plus flags and a Step 3 card', () => { + expect(shape(set1PowerPlants)).to.deep.equal(set1Appendix); + expect(set1PowerPlants.filter((p) => p.plus).map((p) => p.number)).to.deep.equal([46, 50, 52, 54, 57, 60]); + expect(set1PowerPlants.filter((p) => p.type == PowerPlantType.Step3)).to.have.length(1); + }); + + it('flags plug cards: 01–16 in Set 1', () => { + expect(set1PowerPlants.filter((p) => p.plug).map((p) => p.number)).to.deep.equal([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + ]); + }); + + it('flags plug cards: 03–15 in the standard deck and Set 2', () => { + for (const deck of [powerPlants, alternatePowerPlants]) { + const plugs = deck.filter((p) => p.plug).map((p) => p.number); + expect(plugs).to.deep.equal([3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); + expect(deck.every((p) => typeof p.plug == 'boolean')).to.be.true; + } + }); + + it('treats cards saved without a plug flag as standard cards', () => { + expect(hasPlug({ number: 15, type: Coal, cost: 2, citiesPowered: 3 })).to.be.true; + expect(hasPlug({ number: 16, type: Oil, cost: 2, citiesPowered: 3 })).to.be.false; + expect(hasPlug({ number: 16, type: Wind, cost: 0, citiesPowered: 2, plug: true })).to.be.true; + }); + + it('throws a clear error for a deck and map without map notes', () => { + expect(() => deckNote('set1', 'Atlantis')).to.throw('There are no Set 1 map notes for Atlantis.'); + expect(() => deckNote('set2', 'Atlantis')).to.throw('There are no Set 2 map notes for Atlantis.'); + }); + + it('stores the deck only when it is not the standard deck', () => { + expect(setup(3, { map: 'USA', variant: 'recharged' }, 'd').options).not.to.have.property('plantDeck'); + expect(setup(3, { map: 'USA', variant: 'recharged', plantDeck: 'standard' }, 'd').options).not.to.have.property( + 'plantDeck' + ); + expect(setup(3, { map: 'USA', variant: 'recharged', plantDeck: 'set1' }, 'd').options.plantDeck).to.equal( + 'set1' + ); + }); + + it('plays Set 2 on Europe and North America as the alternate deck', () => { + for (const map of ['Europe', 'North America'] as MapName[]) { + const viaDeck = setup(4, { map, variant: 'recharged', plantDeck: 'set2' }, 'alt'); + const viaOption = setup(4, { map, variant: 'recharged', alternatePowerPlants: true }, 'alt'); + expect(viaDeck.options.alternatePowerPlants, map).to.be.true; + expect(viaDeck.options, map).not.to.have.property('plantDeck'); + expect(JSON.stringify(viaDeck), map).to.equal(JSON.stringify(viaOption)); + } + }); + + const noChangeMaps: MapName[] = [ + 'USA', + 'Benelux', + 'Central Europe', + 'Korea', + 'Japan', + 'UK & Ireland', + 'Europe', + 'North America', + 'Baden-Württemberg', + ]; + const inGame = (G: GameState) => + [...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck].filter((p) => p.type != PowerPlantType.Step3); + + it('prepares Set 1 and Set 2 on the maps whose notes need no changes', () => { + // 46 Set 1 cards less 52, 54, 57 and 60; 42 Set 2 cards. Recharged removals: + // 1+5, 2+6 and 1+3 plants with 2, 3 and 4 players (base-recharged.pdf p.3). + const removed = [6, 8, 4, 0, 0]; + for (const [deck, total] of [ + ['set1', 42], + ['set2', 42], + ] as const) { + for (const map of noChangeMaps) { + for (const n of [2, 3, 4, 5, 6]) { + const label = `${deck} ${map} ${n}p`; + const G = setup(n, { map, variant: 'recharged', plantDeck: deck }, `nc-${label}`); + const market = [...G.actualMarket, ...G.futureMarket]; + expect(market.length, label).to.equal(map == 'Europe' ? 9 : 8); + expect(market.every(hasPlug), label).to.be.true; + expect(inGame(G).length, label).to.equal(total - removed[n - 2]); + expect( + inGame(G).some((p) => [52, 54, 57, 60].includes(p.number)), + label + ).to.be.false; + const step3At = G.powerPlantsDeck.findIndex((p) => p.type == PowerPlantType.Step3); + const below = map == 'UK & Ireland' ? 2 : 0; + expect(step3At, label).to.equal(G.powerPlantsDeck.length - 1 - below); + if (map != 'Europe') expect(hasPlug(G.powerPlantsDeck[0]), `${label}: plug card on top`).to.be.true; + expect(G.players[0].garbageOilHybridCapacity !== undefined, label).to.equal(deck == 'set2'); + } + } + } + }); + + const game = (deck: 'set1' | 'set2', map: MapName, n: number) => + setup(n, { map, variant: 'recharged', plantDeck: deck }, `${deck}-${map}-${n}`); + const numbers = (plants: PowerPlant[]) => plants.map((p) => p.number); + const marketOf = (G: GameState) => numbers([...G.actualMarket, ...G.futureMarket]); + const players = [2, 3, 4, 5, 6]; + const recharged = [6, 8, 4, 0, 0]; + + it('boxes the plants named in the map notes', () => { + const boxed: [MapName, number[], number[]][] = [ + ['France', [10], [11]], + ['Russia', [6, 21], [5, 14]], + ['Australia', [19], [21]], + ['India', [8], [13]], + ['South Africa', [7], [10]], + ]; + for (const [map, set1, set2] of boxed) { + for (const [deck, plants] of [ + ['set1', set1], + ['set2', set2], + ] as const) { + for (const n of players) { + const G = game(deck, map, n); + expect(numbers(inGame(G)), `${deck} ${map} ${n}p`).not.to.include.members(plants); + expect( + numbers(inGame(G)).some((p) => plants.includes(p)), + `${deck} ${map} ${n}p` + ).to.be.false; + } + } + } + }); + + it('prepares France: the note plant in the market and France removals', () => { + // France removes no further plug card with 2 and 4 players and one with 3; the + // socket removals stay 5/6/3 (maps-france-italy.pdf p.2). + const removed = [5, 7, 3, 0, 0]; + for (const [deck, fixed] of [ + ['set1', 8], + ['set2', 13], + ] as const) { + for (const n of players) { + const G = game(deck, 'France', n); + expect(marketOf(G), `${deck} ${n}p`).to.include(fixed); + expect(inGame(G).length, `${deck} ${n}p`).to.equal(42 - 1 - removed[n - 2]); + } + } + }); + + it('prepares Brazil: the note plants in the market and the biogas plants protected', () => { + const brazil: [string, number[], number[]][] = [ + ['set1', [6], [21, 27, 33, 42]], + ['set2', [5, 14], [16, 19, 26, 39]], + ]; + for (const [deck, fixed, protectedPlants] of brazil) { + for (const n of players) { + const G = game(deck as 'set1' | 'set2', 'Brazil', n); + expect(marketOf(G), `${deck} ${n}p`).to.include.members(fixed); + expect(numbers(inGame(G)), `${deck} ${n}p`).to.include.members(protectedPlants); + expect(inGame(G).length, `${deck} ${n}p`).to.equal(42 - recharged[n - 2]); + } + } + }); + + it('prepares Russia: a market of 3 + 3, the plug cards on top and only socket cards removed', () => { + for (const deck of ['set1', 'set2'] as const) { + for (const n of players) { + const G = game(deck, 'Russia', n); + expect([G.actualMarket.length, G.futureMarket.length], `${deck} ${n}p`).to.deep.equal([3, 3]); + const plugs = G.powerPlantsDeck.filter(hasPlug).length; + expect(G.powerPlantsDeck.slice(0, plugs).every(hasPlug), `${deck} ${n}p: plug cards on top`).to.be.true; + expect(inGame(G).length, `${deck} ${n}p`).to.equal(42 - 2 - [6, 8, 4, 0, 0][n - 2]); + } + } + }); + + it('prepares Quebec: the note plants in the market, green (wind) plants never removed', () => { + for (const n of players) { + const set1 = game('set1', 'Quebec', n); + expect(marketOf(set1), `set1 ${n}p`).to.include.members([11, 15, 16]); + const green = numbers(inGame(set1).filter((p) => p.type == PowerPlantType.Wind)); + expect(green, `set1 ${n}p`).to.have.members([10, 11, 15, 16, 26, 32, 39]); + + const set2 = game('set2', 'Quebec', n); + expect(marketOf(set2), `set2 ${n}p`).to.include(11); + expect(numbers(set2.powerPlantsDeck.slice(0, 2)), `set2 ${n}p: 17 and 24 on top`).to.deep.equal([17, 24]); + expect(hasPlug(set2.powerPlantsDeck[2]), `set2 ${n}p: then the plug card`).to.be.true; + } + // Fusion plant 44 is not green, so the random removals can take it. + const without44 = [...Array(20).keys()].filter((i) => { + const G = setup(2, { map: 'Quebec', variant: 'recharged', plantDeck: 'set1' }, `quebec-44-${i}`); + return !inGame(G).some((p) => p.number == 44); + }); + expect(without44.length).to.be.above(0); + }); + + it('prepares Spain & Portugal: the note plants go on top of the stack at the start of Step 2', () => { + const spain: ['set1' | 'set2', number[]][] = [ + ['set1', [15, 16, 26]], + ['set2', [17, 24, 28]], + ]; + for (const [deck, aside] of spain) { + let G = game(deck, 'Spain & Portugal', 3); + expect( + numbers(inGame(G)).some((p) => aside.includes(p)), + deck + ).to.be.false; + expect(numbers(G.stepTwoTopPlants!), deck).to.deep.equal(aside); + + G.phase = Phase.Building; + G.currentPlayers = [G.playerOrder[2]]; + G.players[0].cities = G.map.cities.slice(0, 7).map((c) => ({ name: c.name, position: 0 })); + G = passPhase(G); + expect(G.step, deck).to.equal(2); + expect(marketOf(G), `${deck}: the first replaces the smallest plant`).to.include(aside[0]); + expect(numbers(G.powerPlantsDeck.slice(0, 2)), deck).to.deep.equal(aside.slice(1)); + expect(G.stepTwoTopPlants, deck).to.be.undefined; + } + }); + + it('gives India garbage plants of the new decks one more garbage to run, but no more storage', () => { + const G = game('set1', 'India', 4); + const plant = [...inGame(G), ...(G.stepTwoTopPlants ?? [])].find((p) => p.number == 21)!; + expect([plant.cost, plant.storage]).to.deep.equal([4, 6]); + }); + + it('stops Germany and Italy uranium after the plant named in the map notes is bought', () => { + for (const [deck, stop] of [ + ['set1', 38], + ['set2', 37], + ] as const) { + for (const map of ['Germany', 'Italy'] as MapName[]) { + const bought = (plant: number) => { + let G = game(deck, map, 3); + G.round = 2; + G.plantDiscountActive = false; + G.actualMarket = [...G.actualMarket.slice(0, 3), card(plant, PowerPlantType.Uranium, 1, 6)].map( + (p) => ({ ...p }) + ) as PowerPlant[]; + const p = G.currentPlayers[0]; + G.players[p].money = 200; + G.players[p].availableMoves = availableMoves(G, G.players[p]); + G = move(G, { name: MoveName.ChoosePowerPlant, data: plant }, p); + return G.card39Bought; + }; + expect(bought(stop)).to.be.true; + expect(bought(39)).to.be.false; + } + } + }); + + it('removes the Middle East plants the notes name from the opening current market', () => { + // The notes' plants are exactly the garbage and nuclear plants with a plug. + const byRule = (plants: PowerPlant[]) => + numbers(plants.filter((p) => p.plug && [PowerPlantType.Garbage, PowerPlantType.Uranium].includes(p.type))); + expect(byRule(set1PowerPlants)).to.deep.equal([6, 8]); + expect(byRule(alternatePowerPlants)).to.deep.equal([5, 13, 14]); + for (const [deck, gone] of [ + ['set1', [6, 8]], + ['set2', [5, 13, 14]], + ] as const) { + for (const n of players) { + for (let seed = 0; seed < 10; seed++) { + const G = setup(n, { map: 'Middle East', variant: 'recharged', plantDeck: deck }, `me-${seed}`); + const current = numbers(G.actualMarket); + expect( + current.some((p) => (gone as readonly number[]).includes(p)), + `${deck} ${n}p` + ).to.be.false; + } + } + } + }); + + it('closes garbage buying in Step 1 on Middle East with Set 2', () => { + const garbageOffered = (deck: 'set1' | 'set2', step: number) => { + const G = game(deck, 'Middle East', 3); + G.phase = Phase.Resources; + G.step = step; + const player = G.players[G.currentPlayers[0]]; + player.powerPlants = [{ number: 26, type: PowerPlantType.Garbage, cost: 1, citiesPowered: 4 }]; + updatePlayerCapacity(player); + G.garbageMarket = 6; + return !!availableMoves(G, player)[MoveName.BuyResource]?.some((b) => b.resource == ResourceType.Garbage); + }; + expect(garbageOffered('set2', 1)).to.be.false; + expect(garbageOffered('set2', 2)).to.be.true; + expect(garbageOffered('set1', 1)).to.be.true; + }); + + it('prepares China: lowest plants for round 1, then up to 30 ascending, 31–35 with Step 3, the rest', () => { + const china: ['set1' | 'set2', Record][] = [ + ['set1', { 2: [1, 2, 8, 12, 15, 22, 27, 28, 32, 36, 42], 4: [1, 2, 8, 15, 27, 32, 36], 5: [1, 2, 32] }], + ['set2', { 2: [3, 6, 10, 13, 17, 18, 25, 26, 31, 34, 35], 4: [3, 6, 13, 17, 26, 31, 35], 5: [3, 6, 31] }], + ]; + for (const [deck, boxedBy] of china) { + for (const n of players) { + const boxed = boxedBy[n <= 3 ? 2 : n == 4 ? 4 : 5]; + const label = `${deck} ${n}p`; + const G = game(deck, 'China', n); + const all = numbers(inGame(G)); + expect( + all.some((p) => boxed.includes(p)), + label + ).to.be.false; + expect(G.futureMarket, label).to.be.empty; + const low = numbers([...G.actualMarket, ...G.powerPlantsDeck]).filter((p) => p <= 30); + expect(numbers(G.actualMarket), label).to.deep.equal(low.slice(0, n)); + expect(low, `${label}: ascending`).to.deep.equal([...low].sort((a, b) => a - b)); + const deckNumbers = numbers(G.powerPlantsDeck); + const lowInDeck = low.length - n; + const middle = deckNumbers.slice( + lowInDeck, + lowInDeck + deckNumbers.filter((p) => (p > 30 && p <= 35) || p == 99).length + ); + expect(middle, label).to.include(99); + expect( + middle.every((p) => (p > 30 && p <= 35) || p == 99), + label + ).to.be.true; + expect( + deckNumbers.slice(lowInDeck + middle.length).every((p) => p > 35), + label + ).to.be.true; + } + } + }); + + it('prepares Bremen: the note plants boxed and no further removal', () => { + for (const deck of ['set1', 'set2'] as const) { + for (const n of players) { + const G = game(deck, 'Bremen', n); + expect(inGame(G).length, `${deck} ${n}p`).to.equal(42 - (n <= 4 ? 11 : 9)); + expect(hasPlug(G.powerPlantsDeck[0]), `${deck} ${n}p`).to.be.true; + } + } + }); + + it('prepares Manhattan: plug cards boxed, 8 to the market, any left on top, no Step 3 card', () => { + // In game: the deck less the 2–3P plants and the boxed plug cards (7 for Set 1, 4 for Set 2). + const expected = { set1: [32, 32, 35, 35, 35], set2: [35, 35, 38, 38, 38] }; + for (const deck of ['set1', 'set2'] as const) { + for (const n of players) { + const label = `${deck} ${n}p`; + const G = game(deck, 'Manhattan', n); + expect( + G.powerPlantsDeck.some((p) => p.type == PowerPlantType.Step3), + label + ).to.be.false; + expect(inGame(G).length, label).to.equal(expected[deck][n - 2]); + expect([...G.actualMarket, ...G.futureMarket].every(hasPlug), label).to.be.true; + const plugOnTop = !(deck == 'set1' && n <= 3); + expect(hasPlug(G.powerPlantsDeck[0]), label).to.equal(plugOnTop); + expect(G.powerPlantsDeck.slice(1).some(hasPlug), label).to.be.false; + } + } + }); + + // A Northern Europe game at 5 players (no player-count removal) with these regions in play. + const northernEurope = (deck: 'set1' | 'set2', regions: string[]) => { + for (let seed = 0; seed < 500; seed++) { + const G = setup(5, { map: 'Northern Europe', variant: 'recharged', plantDeck: deck }, `ne-${seed}`); + const inPlay = new Set(G.map.cities.map((c) => c.region)); + if (regions.every((r) => inPlay.has(r))) return G; + } + throw new Error(`no seed with ${regions}`); + }; + const plant = (G: GameState, n: number) => inGame(G).filter((p) => p.number == n); + + it('swaps Northern Europe regional plants into Set 1 as its notes say', () => { + // Denmark (orange), Sweden North (yellow) and the Baltic States (pink). + const G = northernEurope('set1', ['orange', 'yellow', 'pink']); + // Denmark's oil 19 and 26 replace Set 1's; Sweden North and the Baltic States box + // Set 1's 26 and 19 again, which leaves Denmark's alone. + expect(plant(G, 19).map((p) => p.type)).to.deep.equal([PowerPlantType.Oil]); + expect(plant(G, 26).map((p) => p.type)).to.deep.equal([PowerPlantType.Oil]); + // Sweden North: wind 21 replaces Set 1's 21; its uranium 25 joins Set 1's oil 25 as 25.1. + expect(plant(G, 21).map((p) => p.type)).to.deep.equal([PowerPlantType.Wind]); + expect(plant(G, 25).map((p) => p.type)).to.deep.equal([PowerPlantType.Oil]); + expect(plant(G, 25.1).map((p) => [p.type, p.cost, p.citiesPowered])).to.deep.equal([ + [PowerPlantType.Uranium, 1, 4], + ]); + // Baltic States: oil 18 is new to Set 1, wind 22 replaces Set 1's 22. + expect(plant(G, 18).map((p) => p.type)).to.deep.equal([PowerPlantType.Oil]); + expect(plant(G, 22).map((p) => p.type)).to.deep.equal([PowerPlantType.Wind]); + }); + + it('swaps Northern Europe regional plants into Set 2 by number, with no fractional plant', () => { + const G = northernEurope('set2', ['yellow', 'pink']); + expect(plant(G, 25).map((p) => p.type)).to.deep.equal([PowerPlantType.Uranium]); + expect(plant(G, 25.1)).to.be.empty; + expect(plant(G, 18).map((p) => p.type)).to.deep.equal([PowerPlantType.Oil]); + expect(inGame(G).every((p) => Number.isInteger(p.number))).to.be.true; + }); + + it("puts Norway's 07 and Sweden South's 10 among the plug cards with both decks", () => { + for (const deck of ['set1', 'set2'] as const) { + const G = northernEurope(deck, ['red', 'purple']); + expect( + plant(G, 7).map((p) => [p.type, p.plug]), + deck + ).to.deep.equal([[PowerPlantType.Wind, true]]); + expect( + plant(G, 10).map((p) => [p.type, p.plug]), + deck + ).to.deep.equal([[PowerPlantType.Uranium, true]]); + } + }); + + it('plays a game with both 25s: 25.1 bids from 25, shows as 25 and sorts just above 25', () => { + let G = northernEurope('set1', ['yellow']); + const both = inGame(G).filter((p) => Math.floor(p.number) == 25); + G.round = 2; + G.plantDiscountActive = false; + G.actualMarket = [...G.actualMarket.slice(0, 2), ...both]; + G.futureMarket = [...G.futureMarket]; + expect(numbers(G.actualMarket).slice(2)).to.deep.equal([25, 25.1]); + const p = G.currentPlayers[0]; + G.players[p].money = 100; + // A city in Sweden, so the nuclear 25.1 may be bought. + G.players[p].cities = [{ name: G.map.cities.find((c) => c.region == 'yellow')!.name, position: 0 }]; + G.players[p].availableMoves = availableMoves(G, G.players[p]); + expect(G.players[p].availableMoves!.ChoosePowerPlant).to.include.members([25, 25.1]); + G = move(G, { name: MoveName.ChoosePowerPlant, data: 25.1 }, p); + expect(G.chosenPowerPlant!.type).to.equal(PowerPlantType.Uranium); + expect(G.minimunBid).to.equal(25); + const chose = G.log.filter((l) => l.type == 'move').pop()!; + expect(chose.type == 'move' && chose.simple).to.contain('chooses Power Plant 25'); + expect(chose.type == 'move' && chose.simple).not.to.contain('25.1'); + }); + + it('runs an India garbage/oil hybrid on its printed oil, or one more token with garbage', () => { + const G = game('set2', 'India', 3); + G.phase = Phase.Bureaucracy; + const player = G.players[0]; + const hybrid = plantCatalogue('India', false, 'set2').find((p) => p.number == 22)!; // G/O 3→5 + player.powerPlants = [hybrid]; + updatePlayerCapacity(player); + expect(player.garbageOilHybridCapacity, 'storage stays twice the printed 3').to.equal(6); + player.garbageLeft = 4; + player.oilLeft = 3; + player.powerPlantsNotUsed = [22]; + const runs = availableMoves(G, player)[MoveName.UsePowerPlant]!.map((u) => u.resourcesSpent.join(',')); + expect(runs).to.deep.equal([ + 'garbage,garbage,garbage,garbage', + 'garbage,garbage,garbage,oil', + 'garbage,garbage,oil,oil', + 'garbage,oil,oil,oil', + 'oil,oil,oil', + ]); + + player.cities = G.map.cities.slice(0, 5).map((c) => ({ name: c.name, position: 0 })); + const target = (garbage: number, oil: number) => { + player.garbageLeft = garbage; + player.oilLeft = oil; + return calculateMaxCitiesPowered(G, player); + }; + expect(target(0, 3), 'oil alone').to.equal(5); + expect(target(2, 2), 'two garbage and two oil').to.equal(5); + expect(target(1, 2), 'three tokens with garbage are one short').to.equal(0); + }); + + it('labels plants by their printed number, with the printed plus', () => { + expect(plantLabel({ number: 25.1, type: PowerPlantType.Uranium, cost: 1, citiesPowered: 4 })).to.equal('25'); + expect(plantLabel(set1PowerPlants.find((p) => p.number == 46))).to.equal('46+'); + expect(plantLabel(powerPlants.find((p) => p.number == 46))).to.equal('46'); + }); + + it('sets up every deck, map and player count reproducibly, and replays a seeded game', () => { + const randomMoves = (G: GameState, count: number, seed: string) => { + const rng = seedrandom(seed); + for (let i = 0; i < count && G.phase != Phase.GameEnd; i++) { + const p = G.currentPlayers.find((c) => Object.keys(G.players[c].availableMoves ?? {}).length > 0); + if (p === undefined) break; + const moves = G.players[p].availableMoves!; + const names = (Object.keys(moves) as MoveName[]).filter( + (n) => n != MoveName.ReopenPowering && (moves[n] as unknown[] | undefined)?.length + ); + const name = + names.includes(MoveName.Pass) && rng() < 0.3 + ? MoveName.Pass + : names[Math.floor(rng() * names.length)]; + const options = moves[name] as unknown[]; + G = move(G, { name, data: options[Math.floor(rng() * options.length)] } as Move, p); + } + return G; + }; + const summary = (G: GameState) => + JSON.stringify([ + G.round, + G.phase, + G.step, + numbers(G.actualMarket), + numbers(G.futureMarket), + G.players.map((p) => [p.money, numbers(p.powerPlants), p.cities.length]), + ]); + for (const deck of ['set1', 'set2'] as const) { + for (const map of mapsRecharged.map((m) => m.name as MapName)) { + for (const n of players) { + const label = `${deck} ${map} ${n}p`; + const first = game(deck, map, n); + expect(JSON.stringify(game(deck, map, n)), `${label}: same seed, same setup`).to.equal( + JSON.stringify(first) + ); + const fixed = deckNote(deck, map).market ?? []; + const market = [...first.actualMarket, ...first.futureMarket]; + // China deals from its ordered stack; Middle East replaces removed + // garbage and nuclear plants with draws from the stack. + if (map != 'China' && map != 'Middle East') { + expect( + market.every((p) => hasPlug(p) || fixed.includes(p.number)), + `${label}: plug market` + ).to.be.true; + } + const played = randomMoves(first, 120, label); + expect(summary(reconstructState(played)), `${label}: replay`).to.equal(summary(played)); + } + } + } + }).timeout(60000); +}); diff --git a/engine/src/new-decks.ts b/engine/src/new-decks.ts new file mode 100644 index 00000000..98e390b7 --- /dev/null +++ b/engine/src/new-decks.ts @@ -0,0 +1,231 @@ +import seedrandom from 'seedrandom'; +import { deckBox, deckNote, NewDeck, shuffledNote } from './deck-notes'; +import { CardDeck, faceValue, hasPlug, PowerPlant, PowerPlantType, Variant } from './gamestate'; +import { GameMap } from './maps'; +import { shuffle } from './utils'; + +// How a map prepares its market and stack under Recharged, apart from the deck notes. +interface MapPreparation { + // Plants in the opening current and future markets. + market: [number, number]; + // Plug cards put on top of the stack: one set aside (the standard rule), all the + // undrawn ones, or none. + plugsOnTop: 'one' | 'all' | 'none'; + // Plug and socket cards removed at random, indexed by player count - 2. + plugRemovals: number[]; + socketRemovals: number[]; + // Under Original: plants removed at random, face down, by player count - 2. + originalRemovals: number[]; + // Plants kept out of the player-count removal and shuffled back in. + protect?: (plant: PowerPlant) => boolean; + // Plants placed under the Step 3 card. + belowStepThree: number; + // A map that builds its own stack instead of the standard preparation. + stack?: 'china' | 'manhattan'; +} + +// base-recharged.pdf p.3: 8 plug cards to the market, one more set aside for the top of +// the stack, then remove 1/2/1 plug and 5/6/3 socket cards with 2/3/4 players. +const standard: MapPreparation = { + market: [4, 4], + plugsOnTop: 'one', + plugRemovals: [1, 2, 1, 0, 0], + socketRemovals: [5, 6, 3, 0, 0], + // base-original-2013.pdf p.7: 8 with 2 and 3 players, 4 with 4 (ruling O3). + originalRemovals: [8, 8, 4, 0, 0], + belowStepThree: 0, +}; + +const mapPreparations: Record> = { + // Bremen: the market and stack are prepared without removing further plants + // (maps-bremen-manhattan.pdf p.2). + Bremen: { plugRemovals: [0, 0, 0, 0, 0], socketRemovals: [0, 0, 0, 0, 0], originalRemovals: [0, 0, 0, 0, 0] }, + // China: plants up to 30 in ascending order on top, then 31–35 shuffled with the + // Step 3 card, then the rest shuffled; the round 1 market is the lowest plant for + // each player, with no future market (maps-china-korea.pdf p.2). + China: { stack: 'china' }, + // France: no further plug removal with 2 and 4 players, one with 3 (maps-france-italy.pdf p.2). + France: { plugRemovals: [0, 1, 0, 0, 0] }, + // Québec: ecological plants are never removed at random (maps-quebec-baden-wurttemberg.pdf p.2). + // Only the green (wind) plants count: base-original-2013.pdf p.4 keeps "Ecological and + // Fusion Power Plants" apart (owner's ruling). + Quebec: { protect: (p) => p.type == PowerPlantType.Wind }, + // Manhattan: the Step 3 card is boxed; some plug cards are boxed, 8 of the others + // go to the market and any left go on top of the shuffled socket cards + // (maps-bremen-manhattan.pdf p.2). + Manhattan: { stack: 'manhattan' }, + // Russia: a market of 3 + 3, the undrawn plug cards on top of the stack, and only + // socket cards removed: 6, 8 and 4 with 2, 3 and 4 players (maps-russia-japan.pdf p.2). + Russia: { market: [3, 3], plugsOnTop: 'all', plugRemovals: [0, 0, 0, 0, 0], socketRemovals: [6, 8, 4, 0, 0] }, + // UK & Ireland: two plants below the Step 3 card (maps-northern-europe-uk-ireland.pdf p.2). + 'UK & Ireland': { belowStepThree: 2 }, +}; + +export function mapPreparation(mapName: string): MapPreparation { + return { ...standard, ...mapPreparations[mapName] }; +} + +export interface PreparedDeck { + actualMarket: PowerPlant[]; + futureMarket: PowerPlant[]; + powerPlantsDeck: PowerPlant[]; + // Put on top of the stack at the start of Step 2, top first. + stepTwoTopPlants?: PowerPlant[]; +} + +// Under Original, the plant set on top of the stack in place of the standard 13 +// (ruling O2): Set 1's 10, Set 2's 11, or in a shuffled deck the lowest of standard 13, +// Set 1's 10 and Set 2's 11 in the game. +function originalTopPlant(deck: NewDeck | 'shuffled', inGame: PowerPlant[]): number | undefined { + const candidates: [number, CardDeck][] = + deck == 'set1' + ? [[10, 'set1']] + : deck == 'set2' + ? [[11, 'set2']] + : [ + [10, 'set1'], + [11, 'set2'], + [13, 'standard'], + ]; + return candidates.find(([n, origin]) => + inGame.some((p) => p.number == n && (deck != 'shuffled' || p.origin == origin)) + )?.[0]; +} + +// Prepares the opening market and stack for Set 1, Set 2 or a shuffled deck: the deck's +// map notes first, then the map's own preparation with the cards of the deck. +export function prepareNewDeck( + map: GameMap, + numPlayers: number, + deck: NewDeck | 'shuffled', + rng: seedrandom.prng, + catalogue: PowerPlant[], + variant: Variant = 'recharged' +): PreparedDeck { + const note = deck == 'shuffled' ? shuffledNote(map.name, catalogue) : deckNote(deck, map.name); + const prep = mapPreparation(map.name); + const deckWide = deck == 'shuffled' ? [] : deckBox[deck]; + const boxed = new Set([...deckWide, ...(note.box ?? []), ...(note.boxByPlayers?.[numPlayers] ?? [])]); + let cards = catalogue.filter((p) => !boxed.has(p.number)).map((p) => ({ ...p })); + + // Europe prepares its own market and stack from the plug cards of any deck, under + // either rules (owner's ruling for Original). + if (map.name == 'Europe' && map.setupDeck) { + return map.setupDeck(numPlayers, variant, rng, cards); + } + const inGame = [...cards]; + + const take = (numbers: number[] = []) => { + const taken = numbers.map((n) => cards.find((p) => p.number == n)).filter((p): p is PowerPlant => !!p); + cards = cards.filter((p) => !taken.includes(p)); + return taken; + }; + const stepTwoTop = take(note.stepTwoTop); + const topOfStack = take(note.topOfStack); + const belowTopPlug = take(note.belowTopPlug); + const fixedMarket = take(note.market); + const step3 = cards.find((p) => p.type == PowerPlantType.Step3)!; + cards = cards.filter((p) => p != step3); + + if (prep.stack == 'china') { + const low = cards.filter((p) => faceValue(p) <= 30).sort((a, b) => a.number - b.number); + const middle = shuffle([...cards.filter((p) => faceValue(p) > 30 && faceValue(p) <= 35), step3], rng() + ''); + const high = shuffle( + cards.filter((p) => faceValue(p) > 35), + rng() + '' + ); + return { + actualMarket: low.splice(0, numPlayers), + futureMarket: [], + powerPlantsDeck: [...low, ...middle, ...high], + }; + } + if (prep.stack == 'manhattan') { + // Under Original the highest plug plants are boxed, not random ones (ruling O5). + const plugCards = + variant == 'original' + ? cards + .filter(hasPlug) + .sort((a, b) => a.number - b.number) + .slice(0, Math.max(0, cards.filter(hasPlug).length - (note.boxPlugs ?? 0))) + : shuffle(cards.filter(hasPlug), rng() + '').slice(note.boxPlugs ?? 0); + const socketCards = shuffle( + cards.filter((p) => !hasPlug(p)), + rng() + '' + ); + const opening = plugCards.splice(0, 8).sort((a, b) => a.number - b.number); + return { + actualMarket: opening.slice(0, 4), + futureMarket: opening.slice(4), + powerPlantsDeck: [...plugCards, ...socketCards], + }; + } + + const isProtected = (p: PowerPlant) => !!prep.protect?.(p) || !!note.protect?.includes(p.number); + + if (variant == 'original') { + // O1 and O5: the note's plants, then the lowest others, as the opening market. + const size = prep.market[0] + prep.market[1]; + const lowest = [...cards].sort((a, b) => a.number - b.number); + const market = [...fixedMarket, ...lowest.slice(0, size - fixedMarket.length)].sort( + (a, b) => a.number - b.number + ); + let rest = lowest.slice(size - fixedMarket.length); + // O2: the top plant, unless a note boxed it or put it in the market (owner's ruling). + const topNumber = originalTopPlant(deck, inGame); + const top = rest.filter((p) => p.number == topNumber); + rest = rest.filter((p) => p.number != topNumber); + // O3: remove plants at random, face down, after setting the top plant aside. + const protectedPlants = rest.filter(isProtected); + const removable = shuffle( + rest.filter((p) => !isProtected(p)), + rng() + '' + ).slice(prep.originalRemovals[numPlayers - 2]); + const stack = [ + ...topOfStack, + ...top, + ...belowTopPlug, + ...shuffle([...removable, ...protectedPlants], rng() + ''), + ]; + stack.splice(stack.length - prep.belowStepThree, 0, step3); + return { + actualMarket: market.slice(0, prep.market[0]), + futureMarket: market.slice(prep.market[0]), + powerPlantsDeck: stack, + ...(stepTwoTop.length ? { stepTwoTopPlants: stepTwoTop } : {}), + }; + } + + let plugs = shuffle( + cards.filter((p) => hasPlug(p)), + rng() + '' + ); + let sockets = shuffle( + cards.filter((p) => !hasPlug(p)), + rng() + '' + ); + + const market = [...fixedMarket, ...plugs.splice(0, prep.market[0] + prep.market[1] - fixedMarket.length)].sort( + (a, b) => a.number - b.number + ); + const onTop = prep.plugsOnTop == 'one' ? plugs.splice(0, 1) : prep.plugsOnTop == 'all' ? plugs.splice(0) : []; + + const protectedPlants = [...plugs, ...sockets].filter(isProtected); + plugs = plugs.filter((p) => !isProtected(p)).slice(prep.plugRemovals[numPlayers - 2]); + sockets = sockets.filter((p) => !isProtected(p)).slice(prep.socketRemovals[numPlayers - 2]); + + const stack = [ + ...topOfStack, + ...onTop, + ...belowTopPlug, + ...shuffle([...plugs, ...sockets, ...protectedPlants], rng() + ''), + ]; + stack.splice(stack.length - prep.belowStepThree, 0, step3); + + return { + actualMarket: market.slice(0, prep.market[0]), + futureMarket: market.slice(prep.market[0]), + powerPlantsDeck: stack, + ...(stepTwoTop.length ? { stepTwoTopPlants: stepTwoTop } : {}), + }; +} diff --git a/engine/src/original-decks.spec.ts b/engine/src/original-decks.spec.ts new file mode 100644 index 00000000..3581b982 --- /dev/null +++ b/engine/src/original-decks.spec.ts @@ -0,0 +1,189 @@ +import { expect } from 'chai'; +import 'mocha'; +import seedrandom from 'seedrandom'; +import { availableMoves } from './available-moves'; +import { SHUFFLED_EXCLUDED_MAPS } from './deck-notes'; +import { move, reconstructState, setup } from './engine'; +import { CardDeck, GameState, hasPlug, MapName, Phase, PlantDeck, PowerPlant, PowerPlantType } from './gamestate'; +import { mapsRecharged } from './maps'; +import { Move, MoveName } from './move'; +import { alternatePowerPlants, powerPlants, set1PowerPlants } from './powerPlants'; + +// The new decks under Original rules (engine-update-scope.md, Part 7, rulings O1–O8). +const players = [2, 3, 4, 5, 6]; +const original = (map: MapName, n: number, plantDeck: PlantDeck, seed = `${plantDeck}-${map}-${n}`) => + setup(n, { map, variant: 'original', plantDeck }, seed); +const numbers = (plants: PowerPlant[]) => plants.map((p) => p.number); +const marketOf = (G: GameState) => numbers([...G.actualMarket, ...G.futureMarket]); +const inGame = (G: GameState) => + [...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck].filter((p) => p.type != PowerPlantType.Step3); +const removed = [8, 8, 4, 0, 0]; + +describe('New power plant decks under Original', () => { + it('opens with the 8 lowest plants, sets the O2 plant on top and removes 8/8/4 plants (O1–O3)', () => { + for (const [deck, market, top] of [ + ['set1', [1, 2, 3, 4, 5, 6, 7, 8], 10], + ['set2', [3, 4, 5, 6, 7, 8, 9, 10], 11], + ] as const) { + for (const n of players) { + const label = `${deck} ${n}p`; + const G = original('USA', n, deck); + expect(numbers(G.actualMarket), label).to.deep.equal(market.slice(0, 4)); + expect(numbers(G.futureMarket), label).to.deep.equal(market.slice(4)); + expect(G.powerPlantsDeck[0].number, label).to.equal(top); + expect(G.powerPlantsDeck[G.powerPlantsDeck.length - 1].type, label).to.equal(PowerPlantType.Step3); + expect(inGame(G).length, label).to.equal(42 - removed[n - 2]); + } + } + }); + + it('opens a shuffled deck with its 8 lowest cards and the lowest of 13, Set 1 10 and Set 2 11 on top', () => { + const candidates: [number, CardDeck][] = [ + [10, 'set1'], + [11, 'set2'], + [13, 'standard'], + ]; + for (let i = 0; i < 20; i++) { + const G = original('USA', 5, 'shuffled', `shuffled-${i}`); + const lowest = numbers(inGame(G)).sort((a, b) => a - b); + expect(marketOf(G)).to.deep.equal(lowest.slice(0, 8)); + const top = candidates.find(([n, d]) => G.shuffledDeck![n] == d)?.[0]; + if (top !== undefined && !marketOf(G).includes(top)) expect(G.powerPlantsDeck[0].number).to.equal(top); + } + }); + + it("puts a note's market plants first, then the lowest others (O5)", () => { + // Brazil Set 2: 05 and 14, then the 6 lowest others; Set 2's 11 on top. + let G = original('Brazil', 3, 'set2'); + expect(marketOf(G)).to.deep.equal([3, 4, 5, 6, 7, 8, 9, 14]); + expect(G.powerPlantsDeck[0].number).to.equal(11); + expect(numbers(inGame(G))).to.include.members([16, 19, 26, 39]); + // Quebec Set 2: 11 in the market, 17 and 24 on top; 11 is not set on top again. + G = original('Quebec', 3, 'set2'); + expect(marketOf(G)).to.deep.equal([3, 4, 5, 6, 7, 8, 9, 11]); + expect(numbers(G.powerPlantsDeck.slice(0, 2))).to.deep.equal([17, 24]); + // France Set 1: 08 in the market and 10 boxed, so nothing is set on top. + G = original('France', 3, 'set1'); + expect(marketOf(G)).to.deep.equal([1, 2, 3, 4, 5, 6, 7, 8]); + expect(numbers(inGame(G))).not.to.include(10); + }); + + it('boxes the highest plug plants on Manhattan (O5)', () => { + let G = original('Manhattan', 4, 'set2'); // plug 03–15, box 12–15 + expect(marketOf(G)).to.deep.equal([3, 4, 5, 6, 7, 8, 9, 10]); + expect(G.powerPlantsDeck[0].number).to.equal(11); + expect(G.powerPlantsDeck.slice(1).some(hasPlug)).to.be.false; + G = original('Manhattan', 4, 'set1'); // plug 01–16, box 10–16 + expect(marketOf(G)).to.deep.equal([1, 2, 3, 4, 5, 6, 7, 8]); + expect(G.powerPlantsDeck[0].number).to.equal(9); + G = original('Manhattan', 3, 'set1'); // 16 boxed first: plug 01–15, box 09–15 + expect(marketOf(G)).to.deep.equal([1, 2, 3, 4, 5, 6, 7, 8]); + expect(hasPlug(G.powerPlantsDeck[0])).to.be.false; + }); + + it('opens Russia with a 3 + 3 market of the lowest plants (owner’s ruling)', () => { + for (const n of players) { + const G = original('Russia', n, 'set1'); // boxes 06 and 21 + expect([numbers(G.actualMarket), numbers(G.futureMarket)], `${n}p`).to.deep.equal([ + [1, 2, 3], + [4, 5, 7], + ]); + expect(G.powerPlantsDeck[0].number, `${n}p`).to.equal(10); + expect(inGame(G).length, `${n}p`).to.equal(40 - removed[n - 2]); + } + }); + + it("keeps Europe's own Original preparation with the new decks (owner’s ruling)", () => { + for (const deck of ['set1', 'set2'] as const) { + for (const n of players) { + const G = original('Europe', n, deck); + const market = [...G.actualMarket, ...G.futureMarket]; + expect(market.length, `${deck} ${n}p`).to.equal(9); + expect(market.every(hasPlug), `${deck} ${n}p`).to.be.true; + expect(inGame(G).length, `${deck} ${n}p`).to.equal(42 - removed[n - 2]); + } + } + }); + + it('keeps the number-based notes under Original: Bremen and China lists, no Bremen removal (O4)', () => { + for (const n of players) { + const bremen = original('Bremen', n, 'set2'); + expect(inGame(bremen).length, `Bremen ${n}p`).to.equal(42 - (n <= 4 ? 11 : 9)); + const china = original('China', n, 'set1'); + expect(numbers(china.actualMarket), `China ${n}p`).to.have.length(n); + expect(numbers(inGame(china)), `China ${n}p`).not.to.include(32); + } + }); + + it('stops Germany and Italy uranium under Original on the plants the notes name (O4)', () => { + const stopped = (map: MapName, deck: PlantDeck, number: number, origin?: CardDeck) => { + let G = original(map, 3, deck, 'stop'); + G.round = 2; + const cards = origin == 'set1' ? set1PowerPlants : origin == 'set2' ? alternatePowerPlants : powerPlants; + const plant = { ...cards.find((p) => p.number == number)!, ...(origin ? { origin } : {}) }; + G.actualMarket = [...G.actualMarket.slice(0, 3), plant]; + const p = G.currentPlayers[0]; + G.players[p].money = 200; + G.players[p].availableMoves = availableMoves(G, G.players[p]); + G = move(G, { name: MoveName.ChoosePowerPlant, data: number }, p); + G.uraniumMarket = 0; + G.phase = Phase.Bureaucracy; + G.currentPlayers = [...G.playerOrder]; + while (G.phase == Phase.Bureaucracy) { + const q = G.currentPlayers[0]; + G.players[q].availableMoves = availableMoves(G, G.players[q]); + G = move(G, { name: MoveName.Pass, data: true }, q); + } + return G.uraniumMarket == 0; + }; + expect(stopped('Germany', 'set1', 38, 'set1'), 'Germany Set 1 38').to.be.true; + expect(stopped('Italy', 'set2', 37, 'set2'), 'Italy Set 2 37').to.be.true; + expect(stopped('Germany', 'standard', 39), 'Germany standard 39').to.be.false; + expect(stopped('Germany', 'shuffled', 39, 'standard'), 'Germany shuffled, standard 39').to.be.false; + expect(stopped('Germany', 'shuffled', 38, 'set1'), "Germany shuffled, Set 1's 38").to.be.true; + expect(stopped('Italy', 'shuffled', 39, 'standard'), 'Italy shuffled, standard 39').to.be.true; + }); + + it('sets up every deck, map and player count reproducibly under Original, and replays a seeded game', () => { + const randomMoves = (G: GameState, count: number, seed: string) => { + const rng = seedrandom(seed); + for (let i = 0; i < count && G.phase != Phase.GameEnd; i++) { + const p = G.currentPlayers.find((c) => Object.keys(G.players[c].availableMoves ?? {}).length > 0); + if (p === undefined) break; + const moves = G.players[p].availableMoves!; + const names = (Object.keys(moves) as MoveName[]).filter( + (n) => n != MoveName.ReopenPowering && (moves[n] as unknown[] | undefined)?.length + ); + const name = + names.includes(MoveName.Pass) && rng() < 0.3 + ? MoveName.Pass + : names[Math.floor(rng() * names.length)]; + const options = moves[name] as unknown[]; + G = move(G, { name, data: options[Math.floor(rng() * options.length)] } as Move, p); + } + return G; + }; + const summary = (G: GameState) => + JSON.stringify([ + G.round, + G.phase, + G.step, + marketOf(G), + G.players.map((p) => [p.money, numbers(p.powerPlants), p.cities.length]), + ]); + for (const deck of ['set1', 'set2', 'shuffled', 'random'] as PlantDeck[]) { + for (const map of mapsRecharged.map((m) => m.name as MapName)) { + if (deck == 'shuffled' && SHUFFLED_EXCLUDED_MAPS.includes(map)) continue; + for (const n of players) { + const label = `${deck} ${map} ${n}p`; + const first = original(map, n, deck, label); + expect(JSON.stringify(original(map, n, deck, label)), `${label}: same seed`).to.equal( + JSON.stringify(first) + ); + const played = randomMoves(first, 120, label); + expect(summary(reconstructState(played)), `${label}: replay`).to.equal(summary(played)); + } + } + } + }).timeout(120000); +}); diff --git a/engine/src/powerPlants.ts b/engine/src/powerPlants.ts index 74ed16af..83d0a2be 100644 --- a/engine/src/powerPlants.ts +++ b/engine/src/powerPlants.ts @@ -1,5 +1,6 @@ import { cloneDeep } from 'lodash'; -import { PowerPlant, PowerPlantType } from './gamestate'; +import seedrandom from 'seedrandom'; +import { CardDeck, PlantDeck, PowerPlant, PowerPlantType } from './gamestate'; const powerPlants: PowerPlant[] = [ { number: 3, type: PowerPlantType.Oil, cost: 2, citiesPowered: 1 }, @@ -47,6 +48,9 @@ const powerPlants: PowerPlant[] = [ { number: 99, type: PowerPlantType.Step3, cost: 0, citiesPowered: 6 }, ]; +// Plug on the back: 03–15 in the standard deck. +powerPlants.forEach((pp) => (pp.plug = pp.number <= 15)); + 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) => { @@ -105,6 +109,64 @@ const alternatePowerPlants: PowerPlant[] = [ { number: 99, type: PowerPlantType.Step3, cost: 0, citiesPowered: 6 }, ]; +// Plug on the back: 03–15, as in the standard deck. +alternatePowerPlants.forEach((pp) => (pp.plug = pp.number <= 15)); + +// The New Power Plants, Set 1. Hybrids burn coal and oil; the fusion plants (44, 60+) +// are modelled like the standard 50. 52, 54, 57 and 60 are boxed in normal play +// (plants-new-power-plants-set1.pdf p.2). +const set1PowerPlants: 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, plus: true }, + { number: 50, type: PowerPlantType.Uranium, cost: 2, citiesPowered: 8, plus: true }, + { number: 52, type: PowerPlantType.Garbage, cost: 3, citiesPowered: 8, plus: true }, + { number: 54, type: PowerPlantType.Oil, cost: 2, citiesPowered: 8, plus: true }, + { number: 57, type: PowerPlantType.Oil, cost: 3, citiesPowered: 9, plus: true }, + { number: 60, type: PowerPlantType.Nuclear, cost: 0, citiesPowered: 8, plus: true }, + { number: 99, type: PowerPlantType.Step3, cost: 0, citiesPowered: 6 }, +]; +// Plug on the back: 01–16. +set1PowerPlants.forEach((pp) => (pp.plug = pp.number <= 16)); + // The maps the alternate deck can be played on. The alternatePowerPlants option is // ignored on every other map. const ALTERNATE_DECK_MAPS: string[] = ['Europe', 'North America']; @@ -113,8 +175,67 @@ function usesAlternateDeck(mapName: string, alternate: boolean | undefined): boo 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 garbage plants need one more garbage token to run but store no more +// (maps-australia-indian-subcontinent.pdf p.3). +function withIndiaGarbage(deck: PowerPlant[]): PowerPlant[] { + return cloneDeep(deck).map((pp) => + pp.type == PowerPlantType.Garbage ? { ...pp, storage: 2 * pp.cost, cost: pp.cost + 1 } : pp + ); +} +const indiaSet1 = withIndiaGarbage(set1PowerPlants); +const indiaSet2 = withIndiaGarbage(alternatePowerPlants); + +const cardDecks: Record = { + standard: powerPlants, + // 52, 54, 57 and 60 never join a shuffled deck. + set1: set1PowerPlants.filter((p) => ![52, 54, 57, 60].includes(p.number)), + set2: alternatePowerPlants, +}; + +// The shuffled deck (engine-update-scope.md, Part 6): for each plant number, one card +// picked at random from the decks that print it, so 01 and 02 always come from Set 1 +// and 17 and 18 never do. +function shuffledPicks(rng: seedrandom.prng): Record { + const decks = Object.keys(cardDecks) as CardDeck[]; + const numbers = [...new Set(([] as number[]).concat(...decks.map((d) => cardDecks[d].map((p) => p.number))))] + .filter((n) => n != 99) + .sort((a, b) => a - b); + const picks: Record = {}; + for (const n of numbers) { + const printing = decks.filter((d) => cardDecks[d].some((p) => p.number == n)); + picks[n] = printing[Math.floor(rng() * printing.length)]; + } + return picks; +} + +// The cards of a shuffled deck, each keeping its own plug flag, with one Step 3 card. +function shuffledCards(picks: Record): PowerPlant[] { + const cards = Object.entries(picks).map(([n, deck]) => ({ + ...cardDecks[deck].find((p) => p.number == Number(n))!, + origin: deck, + })); + return [...cards.sort((a, b) => a.number - b.number), powerPlants.find((p) => p.number == 99)!]; +} + +// The full card list a game on this map draws from. Set 1 and Set 2 (plantDeck) are +// the whole cards of their sets, and a shuffled deck the cards picked for it; the map +// notes decide which of them are used. +function plantCatalogue( + mapName: string, + alternate?: boolean, + plantDeck?: PlantDeck, + shuffledDeck?: Record +): PowerPlant[] { + if (plantDeck == 'shuffled' && shuffledDeck) { + const cards = shuffledCards(shuffledDeck); + return mapName == 'India' ? withIndiaGarbage(cards) : cards; + } + if (plantDeck == 'set1') { + return mapName == 'India' ? indiaSet1 : set1PowerPlants; + } + if (plantDeck == 'set2') { + return mapName == 'India' ? indiaSet2 : alternatePowerPlants; + } if (mapName == 'India') { return indiaPowerPlants; } @@ -122,4 +243,13 @@ function plantCatalogue(mapName: string, alternate?: boolean): PowerPlant[] { return usesAlternateDeck(mapName, alternate) ? alternatePowerPlants : powerPlants; } -export { powerPlants, indiaPowerPlants, alternatePowerPlants, ALTERNATE_DECK_MAPS, usesAlternateDeck, plantCatalogue }; +export { + powerPlants, + indiaPowerPlants, + alternatePowerPlants, + set1PowerPlants, + shuffledPicks, + ALTERNATE_DECK_MAPS, + usesAlternateDeck, + plantCatalogue, +}; diff --git a/engine/src/rule-fixes.spec.ts b/engine/src/rule-fixes.spec.ts index 432e3ade..9b39f18f 100644 --- a/engine/src/rule-fixes.spec.ts +++ b/engine/src/rule-fixes.spec.ts @@ -300,6 +300,7 @@ describe('Rule fixes', () => { type: PowerPlantType.Wind, cost: 0, citiesPowered: 5, + plug: false, }); try { for (const region of Object.keys(regional)) regional[region] = [...regional[region], extra(region)]; diff --git a/engine/src/shuffled-deck.spec.ts b/engine/src/shuffled-deck.spec.ts new file mode 100644 index 00000000..918b42f9 --- /dev/null +++ b/engine/src/shuffled-deck.spec.ts @@ -0,0 +1,220 @@ +import { expect } from 'chai'; +import 'mocha'; +import seedrandom from 'seedrandom'; +import { availableMoves } from './available-moves'; +import { SHUFFLED_EXCLUDED_MAPS } from './deck-notes'; +import { move, reconstructState, setup, updatePlayerCapacity } from './engine'; +import { CardDeck, GameState, hasPlug, MapName, Phase, PowerPlant, PowerPlantType, ResourceType } from './gamestate'; +import { mapsRecharged } from './maps'; +import { Move, MoveName } from './move'; +import { alternatePowerPlants, powerPlants, set1PowerPlants } from './powerPlants'; + +const players = [2, 3, 4, 5, 6]; +const allowedMaps = mapsRecharged.map((m) => m.name as MapName).filter((m) => !SHUFFLED_EXCLUDED_MAPS.includes(m)); +const shuffled = (map: MapName, n: number, seed: string) => + setup(n, { map, variant: 'recharged', plantDeck: 'shuffled' }, seed); +const inGame = (G: GameState) => + [...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck].filter((p) => p.type != PowerPlantType.Step3); +const numbers = (plants: PowerPlant[]) => plants.map((p) => p.number); +const decks: Record = { + standard: powerPlants, + set1: set1PowerPlants, + set2: alternatePowerPlants, +}; +// A 5-player game (no player-count removal) on this map whose cards meet the condition. +const findGame = (map: MapName, condition: (G: GameState) => boolean) => { + for (let i = 0; i < 300; i++) { + const G = shuffled(map, 5, `find-${i}`); + if (condition(G)) return G; + } + throw new Error('no seed found'); +}; + +describe('Shuffled power plant deck', () => { + it('picks one card for each number, from a deck that prints it', () => { + const origins = new Set(); + for (let i = 0; i < 30; i++) { + const G = shuffled('USA', 5, `one-${i}`); + const picks = G.shuffledDeck!; + const expected = [...Array(40).keys()].map((n) => n + 1).concat([42, 44, 46, 50]); + expect(Object.keys(picks).map(Number)).to.deep.equal(expected); + expect([picks[1], picks[2]]).to.deep.equal(['set1', 'set1']); + expect(picks[17]).not.to.equal('set1'); + expect(picks[18]).not.to.equal('set1'); + const cards = [...inGame(G), ...G.powerPlantsDeck.filter((p) => p.type == PowerPlantType.Step3)]; + expect(cards.filter((p) => p.type == PowerPlantType.Step3)).to.have.length(1); + for (const card of inGame(G)) { + const { origin, ...printed } = card; + expect(origin).to.equal(picks[card.number]); + expect(printed, `card ${card.number}`).to.deep.equal( + decks[origin!].find((p) => p.number == card.number) + ); + } + Object.values(picks).forEach((d) => origins.add(d)); + } + expect([...origins].sort()).to.deep.equal(['set1', 'set2', 'standard']); + }); + + it('gives the same cards for the same seed and records them in the game', () => { + const a = shuffled('Germany', 3, 'same'); + expect(JSON.stringify(shuffled('Germany', 3, 'same'))).to.equal(JSON.stringify(a)); + expect(a.options.plantDeck).to.equal('shuffled'); + expect(a.shuffledDeck).to.be.an('object'); + }); + + it('is not allowed on the maps whose notes rebuild the whole deck', () => { + for (const map of ['China', 'Bremen', 'Manhattan', 'Northern Europe'] as MapName[]) { + expect(() => shuffled(map, 3, 'x'), map).to.throw(`cannot be used on ${map}`); + } + }); + + it('applies a note only to the card version it names', () => { + // France: box Set 1's 10 / Set 2's 11 / standard 13; Set 1's 08, standard 11 and + // Set 2's 13 start in the market. + const has = (G: GameState, n: number) => numbers(inGame(G)).includes(n); + const market = (G: GameState) => numbers([...G.actualMarket, ...G.futureMarket]); + let G = findGame('France', (g) => g.shuffledDeck![10] == 'set1'); + expect(has(G, 10), "Set 1's 10 boxed").to.be.false; + G = findGame('France', (g) => g.shuffledDeck![10] != 'set1'); + expect(has(G, 10), 'another 10 stays').to.be.true; + G = findGame('France', (g) => g.shuffledDeck![13] == 'standard' && g.shuffledDeck![8] == 'set1'); + expect(has(G, 13), 'standard 13 boxed').to.be.false; + expect(market(G), "Set 1's 08 in the market").to.include(8); + G = findGame('France', (g) => g.shuffledDeck![13] == 'set2' && g.shuffledDeck![11] == 'standard'); + expect(market(G), "Set 2's 13 and standard 11 in the market").to.include.members([11, 13]); + }); + + it('puts the standard 18 and 22 below the plug card on top on Quebec', () => { + const G = findGame('Quebec', (g) => g.shuffledDeck![18] == 'standard' && g.shuffledDeck![22] == 'standard'); + // Set 2's 17 and 24, if in the game, go above the plug card (their own note). + const above = [17, 24].filter((n) => G.shuffledDeck![n] == 'set2'); + expect(numbers(G.powerPlantsDeck.slice(0, above.length))).to.deep.equal(above); + expect(hasPlug(G.powerPlantsDeck[above.length])).to.be.true; + expect(numbers(G.powerPlantsDeck.slice(above.length + 1, above.length + 3))).to.deep.equal([18, 22]); + }); + + it('sets aside the Spain & Portugal plants of every deck for Step 2, in ascending order', () => { + const G = findGame( + 'Spain & Portugal', + (g) => g.shuffledDeck![18] == 'standard' && g.shuffledDeck![15] == 'set1' + ); + const named: Record = { standard: [18, 22, 27], set1: [15, 16, 26], set2: [17, 24, 28] }; + const expected = (Object.keys(named) as CardDeck[]) + .reduce((all, d) => all.concat(named[d].filter((n) => G.shuffledDeck![n] == d)), [] as number[]) + .sort((a, b) => a - b); + expect(numbers(G.stepTwoTopPlants!)).to.deep.equal(expected); + expect(expected).to.include.members([15, 18]); + }); + + it('stops Germany uranium on the first named plant bought, standard 39, Set 1 38 or Set 2 37', () => { + const bought = (number: number, origin: CardDeck) => { + let G = shuffled('Germany', 3, 'stop'); + G.round = 2; + G.plantDiscountActive = false; + const plant = { ...decks[origin].find((p) => p.number == number)!, origin }; + G.actualMarket = [...G.actualMarket.slice(0, 3), plant]; + const p = G.currentPlayers[0]; + G.players[p].money = 200; + G.players[p].availableMoves = availableMoves(G, G.players[p]); + G = move(G, { name: MoveName.ChoosePowerPlant, data: number }, p); + return G.card39Bought; + }; + expect(bought(39, 'standard')).to.be.true; + expect(bought(38, 'set1')).to.be.true; + expect(bought(37, 'set2')).to.be.true; + expect(bought(39, 'set2')).to.be.false; + expect(bought(38, 'standard')).to.be.false; + }); + + it("closes Middle East garbage in Step 1 when one of Set 2's garbage/oil hybrids is in the game", () => { + const garbageOffered = (G: GameState) => { + G.phase = Phase.Resources; + const player = G.players[G.currentPlayers[0]]; + player.powerPlants = [{ number: 26, type: PowerPlantType.Garbage, cost: 1, citiesPowered: 4 }]; + updatePlayerCapacity(player); + G.garbageMarket = 6; + return !!availableMoves(G, player)[MoveName.BuyResource]?.some((b) => b.resource == ResourceType.Garbage); + }; + const hybrids = (g: GameState) => [8, 22, 35].filter((n) => g.shuffledDeck![n] == 'set2').length; + expect(garbageOffered(findGame('Middle East', (g) => hybrids(g) > 0))).to.be.false; + expect(garbageOffered(findGame('Middle East', (g) => hybrids(g) == 0))).to.be.true; + }); + + it('sets up every allowed map reproducibly and replays a seeded game', () => { + const randomMoves = (G: GameState, count: number, seed: string) => { + const rng = seedrandom(seed); + for (let i = 0; i < count && G.phase != Phase.GameEnd; i++) { + const p = G.currentPlayers.find((c) => Object.keys(G.players[c].availableMoves ?? {}).length > 0); + if (p === undefined) break; + const moves = G.players[p].availableMoves!; + const names = (Object.keys(moves) as MoveName[]).filter( + (n) => n != MoveName.ReopenPowering && (moves[n] as unknown[] | undefined)?.length + ); + const name = + names.includes(MoveName.Pass) && rng() < 0.3 + ? MoveName.Pass + : names[Math.floor(rng() * names.length)]; + const options = moves[name] as unknown[]; + G = move(G, { name, data: options[Math.floor(rng() * options.length)] } as Move, p); + } + return G; + }; + const summary = (G: GameState) => + JSON.stringify([ + G.round, + G.phase, + G.step, + numbers(G.actualMarket), + numbers(G.futureMarket), + G.players.map((p) => [p.money, numbers(p.powerPlants), p.cities.length]), + ]); + for (const map of allowedMaps) { + for (const n of players) { + const label = `${map} ${n}p`; + const first = shuffled(map, n, label); + expect(JSON.stringify(shuffled(map, n, label)), `${label}: same seed`).to.equal(JSON.stringify(first)); + const played = randomMoves(first, 120, label); + expect(summary(reconstructState(played)), `${label}: replay`).to.equal(summary(played)); + } + } + }).timeout(60000); +}); + +describe('Random power plant deck', () => { + const random = (map: MapName, seed: string) => setup(4, { map, variant: 'recharged', plantDeck: 'random' }, seed); + + it('picks the same deck for the same seed, and every deck across seeds', () => { + const picks = new Set(); + for (let i = 0; i < 40; i++) { + const pick = random('USA', `pick-${i}`).options.plantDeck; + expect(random('USA', `pick-${i}`).options.plantDeck).to.equal(pick); + picks.add(pick!); + } + expect([...picks].sort()).to.deep.equal(['set1', 'set2', 'shuffled', 'standard']); + }); + + it('never picks a shuffled deck on China, Bremen, Manhattan or Northern Europe', () => { + for (const map of SHUFFLED_EXCLUDED_MAPS as MapName[]) { + const picks = new Set(); + for (let i = 0; i < 40; i++) picks.add(random(map, `x-${i}`).options.plantDeck!); + expect([...picks].sort(), map).to.deep.equal(['set1', 'set2', 'standard']); + } + }); + + it('records the pick in the game state and the log, and replays it', () => { + for (let i = 0; i < 12; i++) { + const G = random('Germany', `rec-${i}`); + const pick = G.options.plantDeck!; + expect(G.options.randomPlantDeck).to.be.true; + const direct = setup(4, { map: 'Germany', variant: 'recharged', plantDeck: pick }, `rec-${i}`); + expect(numbers([...G.actualMarket, ...G.futureMarket, ...G.powerPlantsDeck]), pick).to.deep.equal( + numbers([...direct.actualMarket, ...direct.futureMarket, ...direct.powerPlantsDeck]) + ); + const logged = G.log.filter( + (l) => l.type == 'event' && l.event.startsWith('The power plant deck was picked at random') + ); + expect(logged, pick).to.have.length(1); + expect(reconstructState(G).log, pick).to.deep.equal(G.log); + } + }); +}); diff --git a/viewer/src/components/Game.vue b/viewer/src/components/Game.vue index a0e41f8b..e1982fb3 100644 --- a/viewer/src/components/Game.vue +++ b/viewer/src/components/Game.vue @@ -639,9 +639,9 @@