From a2201533d62bacf574a4b9533f963e5c0b752db8 Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:15:31 -0600 Subject: [PATCH 01/19] Give every power plant card a plug flag The plug on a card's back marks the weak plants: 03-15 in the standard deck and the alternate deck (Set 2), 01-16 in Set 1. The engine assumed "15 and below" for the next-card-is-weak hint and for which Middle East Step 1 plants are boxed; both now read the flag through hasPlug, which falls back to 15 and below for cards in states saved without it. Adds faceValue for plant numbers read as values. No behaviour changes. The alternate deck is checked card by card against the Set 2 list. --- engine/src/engine.ts | 10 ++++---- engine/src/gamestate.ts | 15 ++++++++++++ engine/src/new-decks.spec.ts | 45 ++++++++++++++++++++++++++++++++++++ engine/src/powerPlants.ts | 6 +++++ 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 engine/src/new-decks.spec.ts diff --git a/engine/src/engine.ts b/engine/src/engine.ts index 64dec385..3cfbc48a 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -21,6 +21,7 @@ import { countHeldPowerPlants, GameOptions, GameState, + hasPlug, isEcological, isUraniumMine, MapName, @@ -519,7 +520,7 @@ export function setup( 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 @@ -1901,7 +1902,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) { @@ -2613,7 +2614,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,7 +2624,7 @@ 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.`, diff --git a/engine/src/gamestate.ts b/engine/src/gamestate.ts index 04f96c1e..1ad91060 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -91,6 +91,9 @@ 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; } export interface CityPosition { @@ -296,6 +299,18 @@ 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); +} + +// 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/new-decks.spec.ts b/engine/src/new-decks.spec.ts new file mode 100644 index 00000000..7c54d8b2 --- /dev/null +++ b/engine/src/new-decks.spec.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import 'mocha'; +import { hasPlug, PowerPlant, PowerPlantType } from './gamestate'; +import { alternatePowerPlants, powerPlants } from './powerPlants'; + +const { Coal, Oil, Garbage, Uranium, Wind, 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)); + +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('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; + }); +}); diff --git a/engine/src/powerPlants.ts b/engine/src/powerPlants.ts index 74ed16af..f99ffbe4 100644 --- a/engine/src/powerPlants.ts +++ b/engine/src/powerPlants.ts @@ -47,6 +47,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 +108,9 @@ 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 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']; From f97282f9f0edde59c1d5bf80a71f445fbad00abb Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:16:45 -0600 Subject: [PATCH 02/19] Add the Set 1 power plant cards The New Power Plants, Set 1: 46 plants and a Step 3 card, with plug backs on 01-16. Cards printed with a "+" (46, 50, 52, 54, 57, 60) keep their numbers and carry a plus display flag. Fusion plants are modelled like the standard 50. The list is checked card by card against Appendix A. Not used by any game yet. --- engine/src/gamestate.ts | 2 ++ engine/src/new-decks.spec.ts | 29 ++++++++++++++-- engine/src/powerPlants.ts | 65 +++++++++++++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/engine/src/gamestate.ts b/engine/src/gamestate.ts index 1ad91060..b7593a0d 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -94,6 +94,8 @@ export interface PowerPlant { // 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; } export interface CityPosition { diff --git a/engine/src/new-decks.spec.ts b/engine/src/new-decks.spec.ts index 7c54d8b2..832fdddf 100644 --- a/engine/src/new-decks.spec.ts +++ b/engine/src/new-decks.spec.ts @@ -1,9 +1,9 @@ import { expect } from 'chai'; import 'mocha'; import { hasPlug, PowerPlant, PowerPlantType } from './gamestate'; -import { alternatePowerPlants, powerPlants } from './powerPlants'; +import { alternatePowerPlants, powerPlants, set1PowerPlants } from './powerPlants'; -const { Coal, Oil, Garbage, Uranium, Wind, GarbageOilHybrid } = PowerPlantType; +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) { @@ -12,6 +12,19 @@ function card(number: number, type: PowerPlantType, cost: number, citiesPowered: const shape = (plants: PowerPlant[]) => plants.filter((p) => p.type != PowerPlantType.Step3).map((p) => card(p.number, p.type, p.cost, p.citiesPowered)); +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), @@ -29,6 +42,18 @@ describe('New power plant decks', () => { 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); diff --git a/engine/src/powerPlants.ts b/engine/src/powerPlants.ts index f99ffbe4..01293a04 100644 --- a/engine/src/powerPlants.ts +++ b/engine/src/powerPlants.ts @@ -111,6 +111,61 @@ const alternatePowerPlants: PowerPlant[] = [ // 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']; @@ -128,4 +183,12 @@ 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, + ALTERNATE_DECK_MAPS, + usesAlternateDeck, + plantCatalogue, +}; From 9ccb42f95e5194bd8fb9611aa09218f42f898333 Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:21:14 -0600 Subject: [PATCH 03/19] Add the plantDeck option with Set 1 and Set 2 under Recharged New game option plantDeck: 'standard' (default), 'set1', 'set2', 'shuffled' or 'random'. Set 1 and Set 2 are prepared under Recharged from their cards and 2F-Spiele's map notes (deck-notes.ts): - Set 1 boxes 52, 54, 57 and 60 and uses the plug cards 01-16 for the opening market (plants-new-power-plants-set1.pdf p.2, Variant 1); Set 2 uses its plug cards 03-15 (plants-new-power-plants-set2.pdf p.4, Variant 1). - Then the normal Recharged preparation (base-recharged.pdf p.3), with each map's own changes (new-decks.ts); so far UK & Ireland's two plants below the Step 3 card, and Europe's own market of 9. Set 2 on Europe and North America is the existing alternate deck, so those games are stored and play exactly as with alternatePowerPlants. Only the maps whose notes need no changes are in the notes table yet; any other deck/map pair throws a clear error. 'shuffled' and 'random', and the new decks under Original, are not available yet. Standard-deck games are unchanged. --- engine/src/deck-notes.ts | 53 ++++++++++++++++++++ engine/src/engine.ts | 31 +++++++++--- engine/src/gamestate.ts | 6 +++ engine/src/maps/europe.ts | 27 +++++----- engine/src/new-decks.spec.ts | 81 +++++++++++++++++++++++++++++- engine/src/new-decks.ts | 95 ++++++++++++++++++++++++++++++++++++ engine/src/powerPlants.ts | 23 +++++++-- 7 files changed, 291 insertions(+), 25 deletions(-) create mode 100644 engine/src/deck-notes.ts create mode 100644 engine/src/new-decks.ts diff --git a/engine/src/deck-notes.ts b/engine/src/deck-notes.ts new file mode 100644 index 00000000..75c84884 --- /dev/null +++ b/engine/src/deck-notes.ts @@ -0,0 +1,53 @@ +// 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. + +export type NewDeck = 'set1' | 'set2'; + +export interface DeckNote { + // Boxed before the market and stack are prepared. + box?: number[]; +} + +// 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, + Benelux: noChanges, + 'Central Europe': noChanges, + Korea: noChanges, + Japan: noChanges, + 'UK & Ireland': noChanges, + Europe: noChanges, + 'North America': noChanges, + 'Baden-Württemberg': noChanges, + }, + set2: { + USA: noChanges, + Benelux: noChanges, + 'Central Europe': noChanges, + Korea: noChanges, + Japan: noChanges, + 'UK & Ireland': noChanges, + Europe: noChanges, + 'North America': noChanges, + 'Baden-Württemberg': noChanges, + }, +}; + +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 3cfbc48a..bab043ba 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -37,6 +37,7 @@ import { } from './gamestate'; import { GameMap, maps, mapsRecharged } from './maps'; import { Move, MoveName, Moves } from './move'; +import { prepareNewDeck } from './new-decks'; import { plantCatalogue, powerPlants, usesAlternateDeck } from './powerPlants'; import prices from './prices'; import { createRandomizedMap } from './randomizeMap'; @@ -136,6 +137,7 @@ export function setup( chooseRegions = false, chooseColors = false, alternatePowerPlants = false, + plantDeck = 'standard', version, }: GameOptions, seed?: string, @@ -157,9 +159,18 @@ 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 == 'shuffled' || plantDeck == 'random') { + throw new Error(`The ${plantDeck} power plant deck is not available yet.`); + } + if (plantDeck != 'standard' && variant == 'original') { + throw new Error('The New Power Plants decks are only available under Recharged rules for now.'); + } + // 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); let actualMarket: PowerPlant[]; let futureMarket: PowerPlant[]; @@ -175,8 +186,8 @@ export function setup( numPlayers, variant, rng, - { useNewRechargedSetup, version }, - alternateDeck ? deck : undefined + { useNewRechargedSetup, version, plantDeck: newDeck }, + alternateDeck || newDeck ? deck : undefined )); } @@ -193,7 +204,7 @@ export function setup( uraniumLeft: 0, hybridCapacity: 0, hybridLeft: 0, - ...(alternateDeck ? { garbageOilHybridCapacity: 0 } : {}), + ...(alternateDeck || newDeck == 'set2' ? { garbageOilHybridCapacity: 0 } : {}), money: 50, housesLeft: 22, cities: [], @@ -353,7 +364,7 @@ export function setup( numPlayers, variant, seedrandom(seed), - { useNewRechargedSetup, version }, + { useNewRechargedSetup, version, plantDeck: newDeck }, withRegionalPlants(deck, chosenMap, playRegions) )); } @@ -459,6 +470,7 @@ export function setup( chooseColors, // Only stored when in effect, so states of every other game are unchanged. ...(alternateDeck ? { alternatePowerPlants: true } : {}), + ...(newDeck ? { plantDeck: newDeck } : {}), ...(version !== undefined ? { version } : {}), }, log: [], @@ -585,6 +597,9 @@ function prepareDeck( options: GameOptions, catalogue?: PowerPlant[] ) { + if (options.plantDeck == 'set1' || options.plantDeck == 'set2') { + return prepareNewDeck(map, numPlayers, options.plantDeck, rng, catalogue!); + } return map.setupDeck ? map.setupDeck(numPlayers, variant, rng, catalogue, options) : defaultSetupDeck(numPlayers, variant, rng, options.useNewRechargedSetup ?? true, catalogue); @@ -618,7 +633,7 @@ 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); ({ actualMarket: G.actualMarket, futureMarket: G.futureMarket, diff --git a/engine/src/gamestate.ts b/engine/src/gamestate.ts index b7593a0d..c18051c0 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -35,6 +35,7 @@ export type MapName = | 'Bremen' | 'Manhattan'; export type Variant = 'original' | 'recharged'; +export type PlantDeck = 'standard' | 'set1' | 'set2' | 'shuffled' | 'random'; export interface GameOptions { fastBid?: boolean; @@ -57,6 +58,11 @@ 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; // 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. 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/new-decks.spec.ts b/engine/src/new-decks.spec.ts index 832fdddf..d57bb78d 100644 --- a/engine/src/new-decks.spec.ts +++ b/engine/src/new-decks.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import 'mocha'; -import { hasPlug, PowerPlant, PowerPlantType } from './gamestate'; +import { setup } from './engine'; +import { GameState, hasPlug, MapName, PowerPlant, PowerPlantType } from './gamestate'; import { alternatePowerPlants, powerPlants, set1PowerPlants } from './powerPlants'; const { Coal, Oil, Garbage, Uranium, Hybrid, Wind, Nuclear, GarbageOilHybrid } = PowerPlantType; @@ -67,4 +68,82 @@ describe('New power plant decks', () => { 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('rejects decks that are not available yet', () => { + expect(() => setup(3, { map: 'USA', variant: 'recharged', plantDeck: 'shuffled' })).to.throw( + 'not available yet' + ); + expect(() => setup(3, { map: 'USA', variant: 'recharged', plantDeck: 'random' })).to.throw('not available yet'); + expect(() => setup(3, { map: 'USA', variant: 'original', plantDeck: 'set1' })).to.throw('Recharged'); + }); + + it('throws a clear error for a deck and map without map notes', () => { + expect(() => setup(3, { map: 'Germany', variant: 'recharged', plantDeck: 'set1' }, 'n')).to.throw( + 'There are no Set 1 map notes for Germany.' + ); + }); + + 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'); + } + } + } + }); }); diff --git a/engine/src/new-decks.ts b/engine/src/new-decks.ts new file mode 100644 index 00000000..d8d53ed7 --- /dev/null +++ b/engine/src/new-decks.ts @@ -0,0 +1,95 @@ +import seedrandom from 'seedrandom'; +import { deckBox, deckNote, NewDeck } from './deck-notes'; +import { hasPlug, PowerPlant, PowerPlantType } 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[]; + // 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; +} + +// 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], + belowStepThree: 0, +}; + +const mapPreparations: Record> = { + // 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[]; +} + +// Prepares the opening market and stack for Set 1 or Set 2 under Recharged: 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, + rng: seedrandom.prng, + catalogue: PowerPlant[] +): PreparedDeck { + const note = deckNote(deck, map.name); + const prep = mapPreparation(map.name); + const boxed = new Set([...deckBox[deck], ...(note.box ?? [])]); + 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. + if (map.name == 'Europe' && map.setupDeck) { + return map.setupDeck(numPlayers, 'recharged', rng, cards); + } + + const step3 = cards.find((p) => p.type == PowerPlantType.Step3)!; + cards = cards.filter((p) => p != step3); + + let plugs = shuffle( + cards.filter((p) => hasPlug(p)), + rng() + '' + ); + let sockets = shuffle( + cards.filter((p) => !hasPlug(p)), + rng() + '' + ); + + const market = plugs.splice(0, prep.market[0] + prep.market[1]).sort((a, b) => a.number - b.number); + const onTop = prep.plugsOnTop == 'one' ? plugs.splice(0, 1) : prep.plugsOnTop == 'all' ? plugs.splice(0) : []; + + const isProtected = (p: PowerPlant) => !!prep.protect?.(p); + 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 = [...onTop, ...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, + }; +} diff --git a/engine/src/powerPlants.ts b/engine/src/powerPlants.ts index 01293a04..c0b6b0be 100644 --- a/engine/src/powerPlants.ts +++ b/engine/src/powerPlants.ts @@ -1,5 +1,5 @@ import { cloneDeep } from 'lodash'; -import { PowerPlant, PowerPlantType } from './gamestate'; +import { PlantDeck, PowerPlant, PowerPlantType } from './gamestate'; const powerPlants: PowerPlant[] = [ { number: 3, type: PowerPlantType.Oil, cost: 2, citiesPowered: 1 }, @@ -174,8 +174,25 @@ 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); + +// The full card list a game on this map draws from. Set 1 and Set 2 (plantDeck) are +// the whole cards of their sets; the map notes decide which of them are used. +function plantCatalogue(mapName: string, alternate?: boolean, plantDeck?: PlantDeck): PowerPlant[] { + if (plantDeck == 'set1') { + return mapName == 'India' ? indiaSet1 : set1PowerPlants; + } + if (plantDeck == 'set2') { + return mapName == 'India' ? indiaSet2 : alternatePowerPlants; + } if (mapName == 'India') { return indiaPowerPlants; } From 322d915249d14478e40bb496ad97dcb4670247e7 Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:23:48 -0600 Subject: [PATCH 04/19] Prepare the new decks on France, Brazil, Spain, Russia, Quebec and more Adds the map notes that box plants, place plants in the opening market, protect plants from the player-count removal, or set plants aside for the top of the stack (Set 1: plants-new-power-plants-set1-map-notes.pdf pp.1-3; Set 2: plants-new-power-plants-set2-map-notes.md): - France: box 10 / 11; 08 / 13 in the market. - Brazil: 06 / 05 and 14 in the market; the other biogas plants protected (Set 1: 21, 27, 33, 42; Set 2: 16, 19, 26, 39). - Spain & Portugal: 15, 16, 26 / 17, 24, 28 put on top of the stack at the start of Step 2 (stored as stepTwoTopPlants). - Russia: box 06 and 21 / 05 and 14. - Quebec: 11, 15, 16 / 11 in the market; Set 2 puts 17 and 24 on top. - Australia, India, South Africa: box 19, 08, 07 / 21, 13, 10. And the maps' own preparation with any deck: France's plug removals (maps-france-italy.pdf p.2), Russia's 3 + 3 market with the plug cards on top and socket-only removals (maps-russia-japan.pdf p.2), and Quebec's ecological plants kept out of the removal (maps-quebec-baden-wurttemberg.pdf p.2). India's garbage plants need one more garbage with any deck. --- engine/src/deck-notes.ts | 24 ++++++ engine/src/engine.ts | 18 +++-- engine/src/gamestate.ts | 3 + engine/src/new-decks.spec.ts | 142 ++++++++++++++++++++++++++++++++++- engine/src/new-decks.ts | 28 ++++++- 5 files changed, 204 insertions(+), 11 deletions(-) diff --git a/engine/src/deck-notes.ts b/engine/src/deck-notes.ts index 75c84884..dd027eb3 100644 --- a/engine/src/deck-notes.ts +++ b/engine/src/deck-notes.ts @@ -8,6 +8,14 @@ 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 on top of the stack at the start of Step 2, top first. + stepTwoTop?: number[]; } // Boxed for every map. Set 1: 52, 54, 57 and 60 (plants-new-power-plants-set1.pdf p.2). @@ -22,6 +30,14 @@ const noChanges: DeckNote = {}; export const deckNotes: Record> = { set1: { USA: noChanges, + 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] }, Benelux: noChanges, 'Central Europe': noChanges, Korea: noChanges, @@ -33,6 +49,14 @@ export const deckNotes: Record> = { }, set2: { USA: noChanges, + 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] }, Benelux: noChanges, 'Central Europe': noChanges, Korea: noChanges, diff --git a/engine/src/engine.ts b/engine/src/engine.ts index bab043ba..152ed2da 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -37,7 +37,7 @@ import { } from './gamestate'; import { GameMap, maps, mapsRecharged } from './maps'; import { Move, MoveName, Moves } from './move'; -import { prepareNewDeck } from './new-decks'; +import { PreparedDeck, prepareNewDeck } from './new-decks'; import { plantCatalogue, powerPlants, usesAlternateDeck } from './powerPlants'; import prices from './prices'; import { createRandomizedMap } from './randomizeMap'; @@ -175,13 +175,14 @@ export function setup( 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, @@ -359,7 +360,7 @@ 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, @@ -524,6 +525,7 @@ export function setup( card39Bought: false, knownPowerPlantDeck: actualMarket.concat(futureMarket), knownPowerPlantDeckStep3: [], + ...(stepTwoTopPlants ? { stepTwoTopPlants } : {}), powerPlantDeckAfterStep3: undefined, } as GameState; @@ -596,7 +598,7 @@ function prepareDeck( rng: seedrandom.prng, options: GameOptions, catalogue?: PowerPlant[] -) { +): PreparedDeck { if (options.plantDeck == 'set1' || options.plantDeck == 'set2') { return prepareNewDeck(map, numPlayers, options.plantDeck, rng, catalogue!); } @@ -2684,8 +2686,12 @@ 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') { + // 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)); diff --git a/engine/src/gamestate.ts b/engine/src/gamestate.ts index c18051c0..6d621aa2 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -265,6 +265,9 @@ 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. + // 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). diff --git a/engine/src/new-decks.spec.ts b/engine/src/new-decks.spec.ts index d57bb78d..61447647 100644 --- a/engine/src/new-decks.spec.ts +++ b/engine/src/new-decks.spec.ts @@ -1,7 +1,9 @@ import { expect } from 'chai'; import 'mocha'; -import { setup } from './engine'; -import { GameState, hasPlug, MapName, PowerPlant, PowerPlantType } from './gamestate'; +import { availableMoves } from './available-moves'; +import { move, setup } from './engine'; +import { GameState, hasPlug, MapName, Phase, PowerPlant, PowerPlantType } from './gamestate'; +import { MoveName } from './move'; import { alternatePowerPlants, powerPlants, set1PowerPlants } from './powerPlants'; const { Coal, Oil, Garbage, Uranium, Hybrid, Wind, Nuclear, GarbageOilHybrid } = PowerPlantType; @@ -13,6 +15,17 @@ function card(number: number, type: PowerPlantType, cost: number, 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), @@ -146,4 +159,129 @@ describe('New power plant decks', () => { } } }); + + 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]); + }); }); diff --git a/engine/src/new-decks.ts b/engine/src/new-decks.ts index d8d53ed7..2ecdbf38 100644 --- a/engine/src/new-decks.ts +++ b/engine/src/new-decks.ts @@ -31,6 +31,15 @@ const standard: MapPreparation = { }; const mapPreparations: Record> = { + // 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 }, + // 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 }, }; @@ -43,6 +52,8 @@ 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[]; } // Prepares the opening market and stack for Set 1 or Set 2 under Recharged: the @@ -64,6 +75,14 @@ export function prepareNewDeck( return map.setupDeck(numPlayers, 'recharged', rng, 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 fixedMarket = take(note.market); const step3 = cards.find((p) => p.type == PowerPlantType.Step3)!; cards = cards.filter((p) => p != step3); @@ -76,20 +95,23 @@ export function prepareNewDeck( rng() + '' ); - const market = plugs.splice(0, prep.market[0] + prep.market[1]).sort((a, b) => a.number - b.number); + 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 isProtected = (p: PowerPlant) => !!prep.protect?.(p); + const isProtected = (p: PowerPlant) => !!prep.protect?.(p) || !!note.protect?.includes(p.number); 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 = [...onTop, ...shuffle([...plugs, ...sockets, ...protectedPlants], rng() + '')]; + const stack = [...topOfStack, ...onTop, ...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 } : {}), }; } From 9f00ac0e31575b4b5830e561985a00e52d828212 Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:25:17 -0600 Subject: [PATCH 05/19] Stop Germany and Italy uranium on the plant the new decks' notes name With Set 1 the uranium resupply stops once a player buys 38, with Set 2 once a player buys 37 (plants-new-power-plants-set1-map-notes.pdf p.1; plants-new-power-plants-set2-map-notes.md). It never triggers if that plant was removed in setup, since nobody can buy it. The trigger reads the plant's face value through uraniumStopPlant; the standard deck keeps 39. The help texts mention the new decks' plants. --- engine/src/deck-notes.ts | 15 +++++++++++++++ engine/src/engine.ts | 6 +++++- engine/src/gamestate.ts | 1 + engine/src/maps/germany.ts | 3 ++- engine/src/maps/italy.ts | 3 ++- engine/src/new-decks.spec.ts | 31 ++++++++++++++++++++++++++++--- 6 files changed, 53 insertions(+), 6 deletions(-) diff --git a/engine/src/deck-notes.ts b/engine/src/deck-notes.ts index dd027eb3..65a5c229 100644 --- a/engine/src/deck-notes.ts +++ b/engine/src/deck-notes.ts @@ -3,6 +3,8 @@ // 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 { GameOptions } from './gamestate'; + export type NewDeck = 'set1' | 'set2'; export interface DeckNote { @@ -16,6 +18,8 @@ export interface DeckNote { topOfStack?: 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; } // Boxed for every map. Set 1: 52, 54, 57 and 60 (plants-new-power-plants-set1.pdf p.2). @@ -30,6 +34,8 @@ 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] }, @@ -49,6 +55,8 @@ export const deckNotes: Record> = { }, 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] }, @@ -68,6 +76,13 @@ export const deckNotes: Record> = { }, }; +// 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; +} + export function deckNote(deck: NewDeck, mapName: string): DeckNote { const note = deckNotes[deck][mapName]; if (!note) { diff --git a/engine/src/engine.ts b/engine/src/engine.ts index 152ed2da..f472333a 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -17,8 +17,10 @@ import { replacePoweringLog, restorePowering, } from './choice-revisions'; +import { uraniumStopPlant } from './deck-notes'; import { countHeldPowerPlants, + faceValue, GameOptions, GameState, hasPlug, @@ -779,7 +781,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 (faceValue(G.chosenPowerPlant) === uraniumStopPlant(G.options, G.map.name)) { G.card39Bought = true; } diff --git a/engine/src/gamestate.ts b/engine/src/gamestate.ts index 6d621aa2..898164a2 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -274,6 +274,7 @@ export interface GameState { 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[]; 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 index 61447647..e6114aff 100644 --- a/engine/src/new-decks.spec.ts +++ b/engine/src/new-decks.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import 'mocha'; import { availableMoves } from './available-moves'; +import { deckNote } from './deck-notes'; import { move, setup } from './engine'; import { GameState, hasPlug, MapName, Phase, PowerPlant, PowerPlantType } from './gamestate'; import { MoveName } from './move'; @@ -91,9 +92,8 @@ describe('New power plant decks', () => { }); it('throws a clear error for a deck and map without map notes', () => { - expect(() => setup(3, { map: 'Germany', variant: 'recharged', plantDeck: 'set1' }, 'n')).to.throw( - 'There are no Set 1 map notes for Germany.' - ); + 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', () => { @@ -284,4 +284,29 @@ describe('New power plant decks', () => { 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; + } + } + }); }); From 1d5c677002b8021ca608b8570899cba0cfdde518 Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:27:19 -0600 Subject: [PATCH 06/19] Middle East with the new decks: Step 1 removals and Set 2's garbage ban The notes box the garbage and nuclear plants with a plug on the back if they are in the opening current market (Set 1: 06 and 08; Set 2: 05, 13 and 14). These are exactly the plants Middle East's own Step 1 rule boxes now that it reads the plug flag, so the notes need no data of their own; a test checks the lists. With Set 2, players may not buy garbage during Step 1, only oil for the garbage/oil hybrids (plants-new-power-plants-set2-map-notes.md). --- engine/src/available-moves.ts | 6 ++++- engine/src/deck-notes.ts | 12 ++++++++++ engine/src/new-decks.spec.ts | 43 +++++++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/engine/src/available-moves.ts b/engine/src/available-moves.ts index f3e4bf68..888424da 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); + if (allowSouth && G.garbageMarket > 0 && !garbageClosed) { const garbagePrices = G.garbagePrices ?? prices[ResourceType.Garbage]; let price = garbagePrices[garbagePrices.length - G.garbageMarket]; diff --git a/engine/src/deck-notes.ts b/engine/src/deck-notes.ts index 65a5c229..32ee23e9 100644 --- a/engine/src/deck-notes.ts +++ b/engine/src/deck-notes.ts @@ -20,6 +20,8 @@ export interface DeckNote { 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 for every map. Set 1: 52, 54, 57 and 60 (plants-new-power-plants-set1.pdf p.2). @@ -44,6 +46,9 @@ export const deckNotes: Record> = { India: { box: [8] }, 'South Africa': { box: [7] }, Quebec: { market: [11, 15, 16] }, + // 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, @@ -65,6 +70,8 @@ export const deckNotes: Record> = { India: { box: [13] }, 'South Africa': { box: [10] }, Quebec: { market: [11], topOfStack: [17, 24] }, + // 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, @@ -83,6 +90,11 @@ export function uraniumStopPlant(options: GameOptions, mapName: string): number return deck == 'set1' || deck == 'set2' ? deckNotes[deck][mapName]?.uraniumStop : 39; } +export function noGarbageInStepOne(options: GameOptions, mapName: string): boolean { + const deck = options.plantDeck; + return (deck == 'set1' || deck == 'set2') && !!deckNotes[deck][mapName]?.noGarbageInStepOne; +} + export function deckNote(deck: NewDeck, mapName: string): DeckNote { const note = deckNotes[deck][mapName]; if (!note) { diff --git a/engine/src/new-decks.spec.ts b/engine/src/new-decks.spec.ts index e6114aff..ae27719b 100644 --- a/engine/src/new-decks.spec.ts +++ b/engine/src/new-decks.spec.ts @@ -2,8 +2,8 @@ import { expect } from 'chai'; import 'mocha'; import { availableMoves } from './available-moves'; import { deckNote } from './deck-notes'; -import { move, setup } from './engine'; -import { GameState, hasPlug, MapName, Phase, PowerPlant, PowerPlantType } from './gamestate'; +import { move, setup, updatePlayerCapacity } from './engine'; +import { GameState, hasPlug, MapName, Phase, PowerPlant, PowerPlantType, ResourceType } from './gamestate'; import { MoveName } from './move'; import { alternatePowerPlants, powerPlants, set1PowerPlants } from './powerPlants'; @@ -309,4 +309,43 @@ describe('New power plant decks', () => { } } }); + + 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; + }); }); From 75aa4ba72fe9826c46e5c04ffda1c28124d8e64c Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:29:21 -0600 Subject: [PATCH 07/19] Prepare the new decks on China, Bremen and Manhattan Their notes box plants by player count and their maps build their own stacks (Set 1: plants-new-power-plants-set1-map-notes.pdf pp.1, 3; Set 2: plants-new-power-plants-set2-map-notes.md): - China: plants up to 30 in ascending order on top, the remaining 31-35 shuffled with the Step 3 card, then the rest shuffled; the round 1 market is the lowest plant for each player (maps-china-korea.pdf p.2). - Bremen: the normal preparation without further removals (maps-bremen-manhattan.pdf p.2). - Manhattan: the Step 3 card boxed; 7 (Set 1) or 4 (Set 2) plug cards boxed at random, 8 of the rest to the market and any left on top of the socket cards. With Set 1 at 2-3 players, 16 is boxed first, so all 8 remaining plug cards go to the market and none goes on top (owner's ruling). --- engine/src/deck-notes.ts | 44 +++++++++++++++++++++++ engine/src/new-decks.spec.ts | 68 ++++++++++++++++++++++++++++++++++++ engine/src/new-decks.ts | 44 +++++++++++++++++++++-- 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/engine/src/deck-notes.ts b/engine/src/deck-notes.ts index 32ee23e9..17e7a634 100644 --- a/engine/src/deck-notes.ts +++ b/engine/src/deck-notes.ts @@ -22,6 +22,10 @@ export interface DeckNote { 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; } // Boxed for every map. Set 1: 52, 54, 57 and 60 (plants-new-power-plants-set1.pdf p.2). @@ -46,6 +50,27 @@ export const deckNotes: Record> = { India: { box: [8] }, 'South Africa': { box: [7] }, Quebec: { market: [11, 15, 16] }, + 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, @@ -70,6 +95,25 @@ export const deckNotes: Record> = { India: { box: [13] }, 'South Africa': { box: [10] }, Quebec: { market: [11], topOfStack: [17, 24] }, + 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, diff --git a/engine/src/new-decks.spec.ts b/engine/src/new-decks.spec.ts index ae27719b..b9b86aa3 100644 --- a/engine/src/new-decks.spec.ts +++ b/engine/src/new-decks.spec.ts @@ -348,4 +348,72 @@ describe('New power plant decks', () => { 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; + } + } + }); }); diff --git a/engine/src/new-decks.ts b/engine/src/new-decks.ts index 2ecdbf38..f6a54daf 100644 --- a/engine/src/new-decks.ts +++ b/engine/src/new-decks.ts @@ -1,6 +1,6 @@ import seedrandom from 'seedrandom'; import { deckBox, deckNote, NewDeck } from './deck-notes'; -import { hasPlug, PowerPlant, PowerPlantType } from './gamestate'; +import { faceValue, hasPlug, PowerPlant, PowerPlantType } from './gamestate'; import { GameMap } from './maps'; import { shuffle } from './utils'; @@ -18,6 +18,8 @@ interface MapPreparation { 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 @@ -31,12 +33,23 @@ const standard: MapPreparation = { }; 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] }, + // 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] }, @@ -67,7 +80,7 @@ export function prepareNewDeck( ): PreparedDeck { const note = deckNote(deck, map.name); const prep = mapPreparation(map.name); - const boxed = new Set([...deckBox[deck], ...(note.box ?? [])]); + const boxed = new Set([...deckBox[deck], ...(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. @@ -86,6 +99,33 @@ export function prepareNewDeck( 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') { + const plugCards = 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], + }; + } + let plugs = shuffle( cards.filter((p) => hasPlug(p)), rng() + '' From 14df9ea594001ad23c479960fc0ffeb7f1b5f8a4 Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:32:23 -0600 Subject: [PATCH 08/19] Stress script: play the new power plant decks --deck standard,set1,set2 picks the decks to play (default standard). Standard-deck seeds are unchanged; other decks get the deck in their seed. A game whose setup throws is now reported as an error instead of stopping its worker. --- engine/scripts/stress.ts | 43 +++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/engine/scripts/stress.ts b/engine/scripts/stress.ts index 93e76668..3959e916 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] [--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. @@ -13,12 +13,13 @@ import { writeFileSync } from 'fs'; import seedrandom from 'seedrandom'; import { availableMoves } from '../src/available-moves'; 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 +80,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 +122,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 +149,16 @@ 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) { + for (const n of players) jobs.push({ variant, deck, map, players: n, games, cap }); + } } } @@ -166,7 +178,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 +190,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, From ffd2742783589758c300949bf643e8af4d8890e3 Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:37:02 -0600 Subject: [PATCH 09/19] Northern Europe with the new decks, and fractional plant numbers Northern Europe (plants-new-power-plants-set1-map-notes.pdf p.2; plants-new-power-plants-set2-map-notes.md): each region in play adds its two plants before preparation. With Set 2 they box the plants with the same numbers. With Set 1, Sweden North boxes Set 1's 21 and 26 and the Baltic States Set 1's 19 and 22; these boxes only touch the deck's own cards, so Denmark's 19 and 26 stay when they are in play too. Regional plants get their deck's plug flag, which puts Norway's 07 and Sweden South's 10 among the plug cards. Sweden North's 25 joins Set 1's 25 as 25.1 (ruling R11): a regional plant whose number is still in the deck gets number + 0.1, so it sorts just above 25 and moves still name plants by number. Rules that read a plant's number as a value now use its face value: the minimum bid, the city-count removal when building and drawing, and the discount replacement check (base-recharged.pdf p.4, "printed number"). Ordering, identity and largest-plant tiebreaks keep the full number. Log text shows plants by plantLabel (face value, "+" where printed), which is unchanged for every existing card. --- engine/src/auction-pricing.ts | 4 +- engine/src/deck-notes.ts | 8 ++ engine/src/engine.ts | 139 +++++++++++++++++++++------------- engine/src/gamestate.ts | 5 ++ engine/src/new-decks.spec.ts | 73 ++++++++++++++++++ engine/src/rule-fixes.spec.ts | 1 + 6 files changed, 176 insertions(+), 54 deletions(-) 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/deck-notes.ts b/engine/src/deck-notes.ts index 17e7a634..7ccdc4d8 100644 --- a/engine/src/deck-notes.ts +++ b/engine/src/deck-notes.ts @@ -26,6 +26,9 @@ export interface DeckNote { 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). @@ -50,6 +53,10 @@ export const deckNotes: Record> = { 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], @@ -95,6 +102,7 @@ export const deckNotes: Record> = { 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], diff --git a/engine/src/engine.ts b/engine/src/engine.ts index f472333a..1defa51b 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -17,7 +17,7 @@ import { replacePoweringLog, restorePowering, } from './choice-revisions'; -import { uraniumStopPlant } from './deck-notes'; +import { deckNote, uraniumStopPlant } from './deck-notes'; import { countHeldPowerPlants, faceValue, @@ -28,6 +28,8 @@ import { isUraniumMine, MapName, Phase, + PlantDeck, + plantLabel, Player, playerColors, PowerPlant, @@ -368,7 +370,7 @@ export function setup( variant, seedrandom(seed), { useNewRechargedSetup, version, plantDeck: newDeck }, - withRegionalPlants(deck, chosenMap, playRegions) + withRegionalPlants(deck, chosenMap, playRegions, newDeck) )); } } @@ -609,19 +611,34 @@ function prepareDeck( : 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). +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 @@ -648,7 +665,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. @@ -796,7 +813,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); @@ -805,8 +822,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]; @@ -840,8 +857,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) { @@ -955,7 +976,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(); @@ -968,9 +991,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); } @@ -988,7 +1011,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(); @@ -1383,7 +1408,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); @@ -1392,7 +1417,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); @@ -1404,7 +1429,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); @@ -1440,13 +1465,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); } @@ -1494,14 +1524,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)}.`, }); } @@ -1572,10 +1603,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); @@ -1852,7 +1885,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' @@ -2318,10 +2351,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) { @@ -2403,7 +2436,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]; @@ -2412,16 +2445,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.` }); } } @@ -2516,7 +2551,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); @@ -2533,7 +2568,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); @@ -2554,7 +2589,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); @@ -2581,7 +2616,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.`, }); } } @@ -2648,13 +2683,13 @@ function removePlantsForMiddleEastStep1(G: GameState) { 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.`, }); } @@ -2688,7 +2723,7 @@ 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.`); + 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. @@ -2744,7 +2779,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); } @@ -2767,7 +2802,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); } @@ -3081,7 +3116,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); } @@ -3104,7 +3139,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 898164a2..586fae30 100644 --- a/engine/src/gamestate.ts +++ b/engine/src/gamestate.ts @@ -317,6 +317,11 @@ 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 { diff --git a/engine/src/new-decks.spec.ts b/engine/src/new-decks.spec.ts index b9b86aa3..4b82bbc0 100644 --- a/engine/src/new-decks.spec.ts +++ b/engine/src/new-decks.spec.ts @@ -416,4 +416,77 @@ describe('New power plant decks', () => { } } }); + + // 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'); + }); }); 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)]; From 7966e90807ca328c344a49c294a78da35644b23d Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:38:53 -0600 Subject: [PATCH 10/19] India: garbage/oil hybrids need one more token only when burning garbage Ruling R10 for India with Set 2: a garbage/oil hybrid needs one extra fuel unit (either fuel) when its run includes at least one garbage; on oil alone it burns the printed amount, and it stores twice the printed amount as before. The powering options and India's must-power target check follow this; the target check tries each hybrid both ways. Other maps are unchanged. (India's garbage plants already need one more garbage with any deck.) --- engine/src/available-moves.ts | 9 +++++--- engine/src/engine.ts | 40 ++++++++++++++++++++++------------- engine/src/new-decks.spec.ts | 35 ++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/engine/src/available-moves.ts b/engine/src/available-moves.ts index 888424da..397f38cd 100644 --- a/engine/src/available-moves.ts +++ b/engine/src/available-moves.ts @@ -807,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/engine.ts b/engine/src/engine.ts index 1defa51b..c532c27e 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -3021,7 +3021,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); } @@ -3030,7 +3030,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) @@ -3068,23 +3068,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) { diff --git a/engine/src/new-decks.spec.ts b/engine/src/new-decks.spec.ts index 4b82bbc0..e857fa81 100644 --- a/engine/src/new-decks.spec.ts +++ b/engine/src/new-decks.spec.ts @@ -2,10 +2,10 @@ import { expect } from 'chai'; import 'mocha'; import { availableMoves } from './available-moves'; import { deckNote } from './deck-notes'; -import { move, setup, updatePlayerCapacity } from './engine'; +import { calculateMaxCitiesPowered, move, setup, updatePlayerCapacity } from './engine'; import { GameState, hasPlug, MapName, Phase, PowerPlant, PowerPlantType, ResourceType } from './gamestate'; import { MoveName } from './move'; -import { alternatePowerPlants, powerPlants, set1PowerPlants } from './powerPlants'; +import { alternatePowerPlants, plantCatalogue, powerPlants, set1PowerPlants } from './powerPlants'; const { Coal, Oil, Garbage, Uranium, Hybrid, Wind, Nuclear, GarbageOilHybrid } = PowerPlantType; @@ -489,4 +489,35 @@ describe('New power plant decks', () => { 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); + }); }); From ea38435b2c4e9b1c091829cb02e2d968a9631e3e Mon Sep 17 00:00:00 2001 From: Mike Isom <103458985+MikeIsom@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:45:50 -0600 Subject: [PATCH 11/19] Viewer: show plants by their printed number and plus Cards, the plant tooltip in the log and the sole-buyer dialog show a plant's face value with its printed "+" (plantLabel): Sweden North's 25.1 shows as 25, and Set 1's 46+ and 50+ keep their plus. The sole buyer's minimum bid uses the face value. The journal's lookup of cards no longer on the board now builds the game's own card list (deck and regional plants, through the engine's withRegionalPlants), so it finds Set 1 and Set 2 cards and tells 25 from 25.1. --- engine/src/engine.ts | 2 +- engine/src/new-decks.spec.ts | 8 +++++++- viewer/src/components/Game.vue | 13 ++++++++++--- viewer/src/components/InlineLog.vue | 4 ++-- viewer/src/components/pieces/Card.vue | 11 ++++++++--- viewer/src/util/journal-plant.ts | 13 +++++++------ viewer/tests/unit/journal-plant.spec.ts | 13 ++++++++++++- 7 files changed, 47 insertions(+), 17 deletions(-) diff --git a/engine/src/engine.ts b/engine/src/engine.ts index c532c27e..e05ddf55 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -617,7 +617,7 @@ function prepareDeck( // 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). -function withRegionalPlants( +export function withRegionalPlants( catalogue: PowerPlant[], map: GameMap, regions: Set, diff --git a/engine/src/new-decks.spec.ts b/engine/src/new-decks.spec.ts index e857fa81..aee9581f 100644 --- a/engine/src/new-decks.spec.ts +++ b/engine/src/new-decks.spec.ts @@ -3,7 +3,7 @@ import 'mocha'; import { availableMoves } from './available-moves'; import { deckNote } from './deck-notes'; import { calculateMaxCitiesPowered, move, setup, updatePlayerCapacity } from './engine'; -import { GameState, hasPlug, MapName, Phase, PowerPlant, PowerPlantType, ResourceType } from './gamestate'; +import { GameState, hasPlug, MapName, Phase, plantLabel, PowerPlant, PowerPlantType, ResourceType } from './gamestate'; import { MoveName } from './move'; import { alternatePowerPlants, plantCatalogue, powerPlants, set1PowerPlants } from './powerPlants'; @@ -520,4 +520,10 @@ describe('New power plant decks', () => { 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'); + }); }); 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 @@