Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 74 additions & 38 deletions engine/src/analysis.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import seedrandom from 'seedrandom';
import { availableMoves } from './available-moves';
import {
originalTopPlants,
QUEBEC_RECHARGED_TOP_PLANTS,
removedPlants,
RUSSIA_ORIGINAL_NEXT_PLANTS,
SPAIN_STEP_2_PLANTS,
} from './deckChanges';
import { defaultSetupDeck, reconstructState, setup, stripSecret } from './engine';
import { GameState, PowerPlant, PowerPlantType } from './gamestate';
import { plantCatalogue } from './powerPlants';
import {
gameDeck,
isEcological,
isPlugPlant,
labelledPlant,
lastPlugPlant,
plantCatalogue,
PLANT_LABEL_PATTERN,
} from './powerPlants';
import { shuffle } from './utils';

/** Unknown cards come from the published catalogue, never the live draw pile. */
Expand All @@ -15,18 +30,12 @@ export function createAnalysisScenario(
const rng = seedrandom(seed);
const name = s.map.name;
const n = s.players.length;
let excluded: number[] = [];
if (name === 'France') excluded = [13];
if (name === 'Russia') excluded = [6, 14];
if (name === 'Australia') excluded = [17];
if (name === 'South Africa') excluded = [7];
if (name === 'Bremen') excluded = [11, 17, 23, 28, 34, 36, 38, 39, 46, ...(n <= 4 ? [31, 50] : [])];
if (name === 'Manhattan') excluded = [99, ...(n <= 3 ? [20, 22, 37] : [])];
if (name === 'China')
excluded =
n <= 3 ? [3, 4, 9, 11, 16, 18, 20, 24, 30, 33, 46] : n === 4 ? [3, 4, 11, 18, 24, 33, 46] : [3, 4, 33];
if (name === 'Spain & Portugal' && s.step === 1) excluded = [18, 22, 27];
const catalogue = JSON.parse(JSON.stringify(plantCatalogue(name, s.options.alternatePowerPlants))) as PowerPlant[];
const powerPlantDeck = gameDeck(s.options);
// Spain & Portugal's held-back plants return at Step 2; Manhattan boxes its Step 3 card.
let excluded = removedPlants(name, powerPlantDeck, n, s.options.variant!);
if (name === 'Spain & Portugal' && s.step !== 1) excluded = [];
if (name === 'Manhattan') excluded = [99, ...excluded];
const catalogue = JSON.parse(JSON.stringify(plantCatalogue(name, powerPlantDeck))) as PowerPlant[];
for (const region of new Set(s.regionDraft ? [] : s.map.cities.map((c) => c.region))) {
for (const replacement of s.map.regionalPowerPlants?.[region] || []) {
const i = catalogue.findIndex((p) => p.number === replacement.number);
Expand All @@ -42,8 +51,10 @@ export function createAnalysisScenario(
const recyclePile: number[] = [];
for (const entry of s.log) {
if (entry.type !== 'event') continue;
const setAside = /Setting Power Plant (\d+) aside on the recycle pile/.exec(entry.event);
if (setAside) recyclePile.push(Number(setAside[1]));
const setAside = new RegExp(`Setting Power Plant ${PLANT_LABEL_PATTERN} aside on the recycle pile`).exec(
entry.event
);
if (setAside) recyclePile.push(labelledPlant(setAside[1], setAside[2]));
if (/reshuffling the recycle pile/.test(entry.event)) {
recyclePile.forEach((number) => recycled.add(number));
recyclePile.length = 0;
Expand All @@ -56,17 +67,21 @@ export function createAnalysisScenario(
bottom.forEach((number) => recycled.add(number));
bottom.length = 0;
}
const rotation = /(?:Putting|Sending) Power Plant (\d+) (?:on|to) the bottom of the deck/.exec(entry.event);
const rotation = new RegExp(
`(?:Putting|Sending) Power Plant ${PLANT_LABEL_PATTERN} (?:on|to) the bottom of the deck`
).exec(entry.event);
if (rotation) {
const number = Number(rotation[1]);
const number = labelledPlant(rotation[1], rotation[2]);
const i = bottom.indexOf(number);
if (i >= 0) bottom.splice(i, 1);
bottom.push(number);
recycled.delete(number);
}
const drawn = /Power Plant (\d+) (?:drawn from the deck|discarded\.)/i.exec(entry.event);
const drawn = new RegExp(`Power Plant ${PLANT_LABEL_PATTERN} (?:drawn from the deck|discarded\\.)`, 'i').exec(
entry.event
);
if (drawn) {
const number = Number(drawn[1]);
const number = labelledPlant(drawn[1], drawn[2]);
const i = bottom.indexOf(number);
if (i >= 0) bottom.splice(i, 1);
recycled.delete(number);
Expand All @@ -81,13 +96,28 @@ export function createAnalysisScenario(
].map((p) => p.number)
);
const fixedBottom = bottom.filter((number) => !unavailable.has(number));
const pool = catalogue.filter(
let pool = catalogue.filter(
(p) =>
!excluded.includes(p.number) &&
!unavailable.has(p.number) &&
((!seen.has(p.number) && !(name === 'Manhattan' && s.manhattanDepletion)) || recycled.has(p.number)) &&
!fixedBottom.includes(p.number)
);
// One per number: a copy already known rules out the number's other copies, and
// each still-unknown number is in the game exactly once.
if (powerPlantDeck === 'shuffledUnique') {
const known = new Set([...seen, ...unavailable, ...fixedBottom]);
const knownPrinted = new Set([...known].map((number) => Math.floor(number)));
const unknown = (p: PowerPlant) => p.number !== 99 && !known.has(p.number);
pool = pool.filter((p) => !unknown(p) || !knownPrinted.has(Math.floor(p.number)));
const copies = new Map<number, PowerPlant[]>();
for (const p of pool.filter(unknown)) {
copies.set(Math.floor(p.number), [...(copies.get(Math.floor(p.number)) ?? []), p]);
}
const pick = seedrandom(seed + ':unique');
const chosen = new Set([...copies.values()].map((options) => options[Math.floor(pick() * options.length)]));
pool = pool.filter((p) => !unknown(p) || chosen.has(p));
}
// Step 3 can be returned underneath the deck for the Middle East's second pass.
if (name === 'Middle East' && s.step === 2 && !unavailable.has(99) && !pool.some((p) => p.number === 99))
pool.push(byNumber(99));
Expand All @@ -100,23 +130,22 @@ export function createAnalysisScenario(
const fixedTop: number[] = [];
const original = s.options.variant === 'original';
if (original && name !== 'China' && name !== 'Manhattan') {
const opening =
name === 'France' ? [11] : name === 'Quebec' ? [13, 18, 22] : name === 'Brazil' ? [13, 14] : [13];
for (const number of opening) if (!seen.has(number)) fixedTop.push(number);
for (const number of originalTopPlants(name, powerPlantDeck)) if (!seen.has(number)) fixedTop.push(number);
}
if (!original && name === 'Quebec') {
// The first low plant is followed by the two guaranteed ecological plants.
for (const number of [18, 22]) if (!seen.has(number)) fixedTop.push(number);
for (const number of QUEBEC_RECHARGED_TOP_PLANTS[powerPlantDeck]) if (!seen.has(number)) fixedTop.push(number);
}
if (name === 'Spain & Portugal' && s.step === 2) {
for (const number of [18, 22, 27]) if (!seen.has(number)) fixedTop.push(number);
for (const number of SPAIN_STEP_2_PLANTS[powerPlantDeck]) if (!seen.has(number)) fixedTop.push(number);
}
const top = fixedTop.map(byNumber).filter((p) => candidates.some((c) => c.number === p.number));
candidates = candidates.filter((p) => !top.some((t) => t.number === p.number));
const mandatory = (p: PowerPlant) =>
(name === 'Brazil' && p.type === PowerPlantType.Garbage && p.number > 14) ||
(name === 'Quebec' && [PowerPlantType.Wind, PowerPlantType.Nuclear].includes(p.type)) ||
(name === 'Russia' && original && [10, 11].includes(p.number));
(name === 'Brazil' && p.type === PowerPlantType.Garbage && p.number > lastPlugPlant(powerPlantDeck)) ||
// Games without the stored ruling built their Québec deck protecting fusion too.
(name === 'Quebec' && isEcological(p, s.options.fusionEcological ?? true)) ||
(name === 'Russia' && original && RUSSIA_ORIGINAL_NEXT_PLANTS[powerPlantDeck].includes(p.number));
// A plug-back top card is public information in the recharged edition.
if (
(!original || name === 'Manhattan') &&
Expand All @@ -127,7 +156,8 @@ export function createAnalysisScenario(
candidates = candidates.filter(mandatory).concat(candidates.filter((p) => !mandatory(p)));
const index = candidates.findIndex(
(p) =>
p.number <= 15 === (name === 'Manhattan' && s.knownPowerPlantDeck.length === 8 ? true : s.nextCardWeak)
isPlugPlant(p, powerPlantDeck) ===
(name === 'Manhattan' && s.knownPowerPlantDeck.length === 8 ? true : s.nextCardWeak)
);
if (index >= 0) top.unshift(...candidates.splice(index, 1));
}
Expand All @@ -148,16 +178,17 @@ export function createAnalysisScenario(
n,
s.options.variant!,
seedrandom(seed + ':catalogue-counts'),
s.options.useNewRechargedSetup ?? true
s.options.useNewRechargedSetup ?? true,
powerPlantDeck,
name
)
: setup(n, s.options, seed + ':catalogue-counts');
const threshold = name === 'India' ? 16 : 15;
const low = (p: PowerPlant) => p.number <= threshold;
const low = (p: { number: number }) => (name === 'India' ? p.number <= 16 : isPlugPlant(p, powerPlantDeck));
const initialLow = [...initial.actualMarket, ...initial.futureMarket, ...initial.powerPlantsDeck].filter(
low
).length;
const seenLow = [...seen].filter((number) => number <= threshold).length;
const recycledLow = [...recycled].filter((number) => number <= threshold && !unavailable.has(number)).length;
const seenLow = [...seen].filter((number) => low({ number })).length;
const recycledLow = [...recycled].filter((number) => low({ number }) && !unavailable.has(number)).length;
const lowCount = initialLow - seenLow + recycledLow - top.filter(low).length;
rest = select(candidates.filter(low), Math.max(0, lowCount)).concat(
select(
Expand All @@ -171,9 +202,11 @@ export function createAnalysisScenario(
if (name === 'Russia' && s.step < 3) {
if (original) {
const remainingWindow = Math.max(0, 6 - Math.max(0, s.knownPowerPlantDeck.length - 6));
const first = deck.filter((p) => p.number === 13);
const guaranteed = deck.filter((p) => [10, 11].includes(p.number));
const others = deck.filter((p) => ![10, 11, 13].includes(p.number));
const topNumber = originalTopPlants(name, powerPlantDeck)[0];
const nextNumbers = RUSSIA_ORIGINAL_NEXT_PLANTS[powerPlantDeck];
const first = deck.filter((p) => p.number === topNumber);
const guaranteed = deck.filter((p) => nextNumbers.includes(p.number));
const others = deck.filter((p) => p.number !== topNumber && !nextNumbers.includes(p.number));
deck = first.concat(
shuffle(
guaranteed.concat(
Expand All @@ -183,7 +216,10 @@ export function createAnalysisScenario(
),
others
);
} else deck = deck.filter((p) => p.number <= 15).concat(deck.filter((p) => p.number > 15));
} else {
const lastPlug = lastPlugPlant(powerPlantDeck);
deck = deck.filter((p) => p.number <= lastPlug).concat(deck.filter((p) => p.number > lastPlug));
}
}
if (name === 'China') {
const low = deck.filter((p) => p.number <= 30).sort((a, b) => a.number - b.number);
Expand Down
3 changes: 2 additions & 1 deletion engine/src/auction-pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ import type { GameState, PowerPlant } from './gamestate';
export function minimumPlantBid(G: GameState, plant: PowerPlant): number {
return G.options.variant === 'recharged' && G.plantDiscountActive && plant.number === G.actualMarket[0]?.number
? 1
: plant.number;
: // The shuffled decks number extra copies 46.1, 46.2: bids start at the printed number.
Math.floor(plant.number);
}
5 changes: 4 additions & 1 deletion engine/src/available-moves.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,10 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves {
}
}

if (allowSouth && G.garbageMarket > 0) {
// Middle East: no garbage in Step 1. Only Deck 2's garbage/oil hybrids could
// burn it then, since garbage plants leave the Step 1 market.
const garbageClosed = G.map.name == 'Middle East' && G.step == 1;
if (allowSouth && G.garbageMarket > 0 && !garbageClosed) {
const garbagePrices = G.garbagePrices ?? prices[ResourceType.Garbage];
const price = garbagePrice(G, player, garbagePrices[garbagePrices.length - G.garbageMarket]);

Expand Down
116 changes: 116 additions & 0 deletions engine/src/deckChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { PowerPlantDeck, SingleDeck } from './gamestate';

// Map rules that name plant numbers, per deck: the standard deck's from each map's
// rules, Deck 1's and Deck 2's from their map-changes sheets (2F-Spiele, 2024). The
// sheets are written for Recharged; Original games use the same numbers, except
// where The New Power Plants rulebook covers the Original edition itself.

type PerDeck<T> = Record<SingleDeck, T>;

// The shuffled decks are only offered on maps without these rules (see availableDecks).
const isShuffled = (deck: PowerPlantDeck): deck is 'shuffled' | 'shuffledUnique' =>
deck == 'shuffled' || deck == 'shuffledUnique';

// Plants out of the game from the start. Manhattan's boxed Step 3 card and India's
// plant (see plantCatalogue) are handled where those decks are built.
export function removedPlants(mapName: string, deck: PowerPlantDeck, numPlayers: number, variant: string): number[] {
if (isShuffled(deck)) return [];
const fewPlayers = numPlayers <= 3;
switch (mapName) {
case 'France':
// The New Power Plants rulebook: on France, simply remove plant 11.
return { standard: [13], deck1: variant == 'original' ? [11] : [10], deck2: [11] }[deck];
case 'Russia':
return { standard: [6, 14], deck1: [6, 21], deck2: [5, 14] }[deck];
case 'Australia':
return { standard: [17], deck1: [19], deck2: [21] }[deck];
case 'South Africa':
return { standard: [7], deck1: [7], deck2: [10] }[deck];
case 'Spain & Portugal':
// Held back until Step 2, then put on top of the deck.
return SPAIN_STEP_2_PLANTS[deck];
case 'Bremen': {
const table: PerDeck<number[]> = {
standard: [11, 17, 23, 28, 34, 36, 38, 39, 46, ...(numPlayers <= 4 ? [31, 50] : [])],
deck1: [8, 19, 24, 31, 37, 38, 42, 46, 50, ...(numPlayers <= 4 ? [34, 44] : [])],
deck2: [13, 21, 32, 33, 34, 37, 38, 46, 50, ...(numPlayers <= 4 ? [22, 44] : [])],
};
return table[deck];
}
case 'Manhattan':
return fewPlayers ? { standard: [20, 22, 37], deck1: [16, 28, 38], deck2: [22, 28, 36] }[deck] : [];
case 'China': {
const table: PerDeck<number[][]> = {
standard: [
[3, 4, 9, 11, 16, 18, 20, 24, 30, 33, 46],
[3, 4, 11, 18, 24, 33, 46],
[3, 4, 33],
],
deck1: [
[1, 2, 8, 12, 15, 22, 27, 28, 32, 36, 42],
[1, 2, 8, 15, 27, 32, 36],
[1, 2, 32],
],
deck2: [
[3, 6, 10, 13, 17, 18, 25, 26, 31, 34, 35],
[3, 6, 13, 17, 26, 31, 35],
[3, 6, 31],
],
};
return table[deck][fewPlayers ? 0 : numPlayers == 4 ? 1 : 2];
}
default:
return [];
}
}

// Original edition: the plants placed on top of the deck, topmost first, once the
// eight smallest plants have opened the market. Plants not in the game are skipped.
export function originalTopPlants(mapName: string, deck: PowerPlantDeck): number[] {
// The shuffled decks put the standard deck's 13 on top.
if (isShuffled(deck)) return [13];
switch (mapName) {
case 'France':
// The nuclear plant comes up first; Deck 1's (08) already opens the market.
return { standard: [11], deck1: [], deck2: [13] }[deck];
case 'Brazil':
// The low biogas plants that don't open the market follow the usual top plant.
return { standard: [13, 14], deck1: [11], deck2: [13, 14] }[deck];
case 'Quebec':
return { standard: [13, 18, 22], deck1: [11, 15, 16], deck2: [11, 17, 24] }[deck];
default:
return { standard: [13], deck1: [11], deck2: [13] }[deck];
}
}

// Original Russia: the two plants shuffled into the top five cards, beneath the top
// plant; they are the two plants after the six-plant opening market.
export const RUSSIA_ORIGINAL_NEXT_PLANTS: PerDeck<number[]> = { standard: [10, 11], deck1: [8, 9], deck2: [10, 11] };

// Recharged edition: plants that always open the market; the rest of its eight are
// drawn from the remaining plug cards.
export function rechargedMarketPlants(mapName: string, deck: PowerPlantDeck): number[] {
if (isShuffled(deck)) return [];
switch (mapName) {
case 'France':
return { standard: [11], deck1: [8], deck2: [13] }[deck];
case 'Brazil':
return { standard: [6, 14], deck1: [6], deck2: [5, 14] }[deck];
case 'Quebec':
// The standard deck's Québec market is drawn from 03–10 plus 13 instead.
return { standard: [], deck1: [11, 15, 16], deck2: [11] }[deck];
default:
return [];
}
}

// Recharged Québec: plants set aside and placed on top of the finished deck, topmost
// first. The standard deck puts them beneath one plug card; Deck 2 on the very top.
export const QUEBEC_RECHARGED_TOP_PLANTS: PerDeck<number[]> = { standard: [18, 22], deck1: [], deck2: [17, 24] };

// Spain & Portugal: the ecological plants held back until Step 2, then drawn first.
export const SPAIN_STEP_2_PLANTS: PerDeck<number[]> = {
standard: [18, 22, 27],
deck1: [15, 16, 26],
deck2: [17, 24, 28],
};
Loading
Loading