powergrid-viewer 2.1.5 → 2.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/powergrid-viewer.css +1 -1
- package/dist/powergrid-viewer.umd.min.js +6 -6
- package/package.json +10 -3
- package/src/components/Game.vue +59 -16
- package/src/components/boards/CityCount.vue +89 -23
- package/src/components/boards/Map.vue +19 -0
- package/src/components/pieces/House.vue +7 -1
- package/src/game-chat.ts +32 -1
- package/src/launch.ts +5 -0
- package/src/sounds.ts +1 -1
- package/src/tutorial/lessons.ts +646 -0
- package/src/tutorial/mount.ts +158 -0
- package/src/tutorial/tutorial.css +135 -0
- package/src/wrapper.ts +14 -3
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import type { TutorialOptions, TutorialStep } from '@boardgamers/protocol/tutorial';
|
|
2
|
+
import type { GameState, Move } from 'powergrid-engine';
|
|
3
|
+
import { availableMoves, move as engineMove, setup } from 'powergrid-engine';
|
|
4
|
+
import { Phase, PowerPlantType, ResourceType } from 'powergrid-engine/src/gamestate';
|
|
5
|
+
import { map as germany } from 'powergrid-engine/src/maps/germany';
|
|
6
|
+
import { MoveName } from 'powergrid-engine/src/move';
|
|
7
|
+
import { powerPlants as plants } from 'powergrid-engine/src/powerPlants';
|
|
8
|
+
|
|
9
|
+
export type Action = { kind: 'move'; move: Move } | { kind: 'watch' } | { kind: 'answer'; answer: string };
|
|
10
|
+
export interface LessonState {
|
|
11
|
+
game: GameState;
|
|
12
|
+
answer?: string;
|
|
13
|
+
watched: number;
|
|
14
|
+
}
|
|
15
|
+
export interface Choice {
|
|
16
|
+
label: string;
|
|
17
|
+
action: Action;
|
|
18
|
+
}
|
|
19
|
+
export interface Lesson extends Omit<TutorialOptions<LessonState, Action>, 'move'> {
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
move(state: LessonState, action: Action, frame?: (game: GameState) => void): LessonState;
|
|
23
|
+
choices(state: LessonState, step: string): Choice[];
|
|
24
|
+
}
|
|
25
|
+
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
|
26
|
+
const pass: Move = { name: MoveName.Pass, data: true };
|
|
27
|
+
const plant = (number: number) => copy(plants.find((p) => p.number === number)!);
|
|
28
|
+
const bid = (data: number): Move => ({ name: MoveName.Bid, data });
|
|
29
|
+
const choose = (data: number): Move => ({ name: MoveName.ChoosePowerPlant, data });
|
|
30
|
+
const you = (s: LessonState) => s.game.players[0];
|
|
31
|
+
const has = (s: LessonState, number: number) => you(s).powerPlants.some((p) => p.number === number);
|
|
32
|
+
const inPhase = (phase: Phase) => (s: LessonState) => s.game.phase === phase;
|
|
33
|
+
|
|
34
|
+
function activate(game: GameState, phase: Phase, seats: number[]) {
|
|
35
|
+
game.phase = phase;
|
|
36
|
+
game.currentPlayers = seats;
|
|
37
|
+
game.players.forEach((p) => {
|
|
38
|
+
p.availableMoves = seats.includes(p.id) ? availableMoves(game, p) : null;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function base(): GameState {
|
|
42
|
+
// Three adjacent regions, including the Ruhr's inexpensive starting connections.
|
|
43
|
+
const map = copy(germany);
|
|
44
|
+
map.cities = map.cities.filter((c) => ['green', 'red', 'brown'].includes(c.region));
|
|
45
|
+
const names = new Set(map.cities.map((c) => c.name));
|
|
46
|
+
map.connections = map.connections.filter((c) => c.nodes.every((n) => names.has(n)));
|
|
47
|
+
const game = setup(3, { map: 'Germany', variant: 'original', showMoney: true }, 'powergrid-tutorial-1');
|
|
48
|
+
// Keep the engine's computed layout while fixing the regions used by the lessons.
|
|
49
|
+
game.map.cities = map.cities;
|
|
50
|
+
game.map.connections = map.connections;
|
|
51
|
+
game.players.forEach((p, i) => {
|
|
52
|
+
p.name = ['You', 'Ada', 'Leo'][i];
|
|
53
|
+
});
|
|
54
|
+
return game;
|
|
55
|
+
}
|
|
56
|
+
function own(game: GameState, seat: number, numbers: number[]) {
|
|
57
|
+
const p = game.players[seat];
|
|
58
|
+
p.powerPlants = numbers.map(plant);
|
|
59
|
+
p.powerPlantsNotUsed = [...numbers];
|
|
60
|
+
for (const type of ['coal', 'oil', 'garbage', 'uranium', 'hybrid'] as const) p[`${type}Capacity`] = 0;
|
|
61
|
+
for (const card of p.powerPlants) {
|
|
62
|
+
const key = (
|
|
63
|
+
{
|
|
64
|
+
[PowerPlantType.Coal]: 'coal',
|
|
65
|
+
[PowerPlantType.Oil]: 'oil',
|
|
66
|
+
[PowerPlantType.Garbage]: 'garbage',
|
|
67
|
+
[PowerPlantType.Uranium]: 'uranium',
|
|
68
|
+
[PowerPlantType.Hybrid]: 'hybrid',
|
|
69
|
+
} as const
|
|
70
|
+
)[card.type];
|
|
71
|
+
if (key) p[`${key}Capacity`] += card.cost * 2;
|
|
72
|
+
}
|
|
73
|
+
game.actualMarket = game.actualMarket.filter((p) => !numbers.includes(p.number));
|
|
74
|
+
game.futureMarket = game.futureMarket.filter((p) => !numbers.includes(p.number));
|
|
75
|
+
game.powerPlantsDeck = game.powerPlantsDeck.filter((p) => !numbers.includes(p.number));
|
|
76
|
+
}
|
|
77
|
+
function market(game: GameState, numbers: number[]) {
|
|
78
|
+
game.actualMarket = numbers.slice(0, game.step === 3 ? 6 : 4).map(plant);
|
|
79
|
+
game.futureMarket = game.step === 3 ? [] : numbers.slice(4).map(plant);
|
|
80
|
+
const held = game.players.flatMap((p) => p.powerPlants.map((p) => p.number));
|
|
81
|
+
game.powerPlantsDeck = plants.filter((p) => !numbers.includes(p.number) && !held.includes(p.number)).map(copy);
|
|
82
|
+
game.cardsLeft = game.powerPlantsDeck.length;
|
|
83
|
+
}
|
|
84
|
+
function cities(game: GameState, seat: number, names: string[]) {
|
|
85
|
+
game.players[seat].cities = names.map((name) => ({
|
|
86
|
+
name,
|
|
87
|
+
position: game.players.filter((p) => p.id !== seat && p.cities.some((c) => c.name === name)).length,
|
|
88
|
+
}));
|
|
89
|
+
game.players[seat].housesLeft = 22 - names.length;
|
|
90
|
+
}
|
|
91
|
+
function fuel(game: GameState, seat: number, resource: ResourceType, count: number) {
|
|
92
|
+
const key = `${resource}Left` as const;
|
|
93
|
+
const needed = count - game.players[seat][key];
|
|
94
|
+
const fromSupply = Math.min(game[`${resource}Supply`], needed);
|
|
95
|
+
game[`${resource}Supply`] -= fromSupply;
|
|
96
|
+
game[`${resource}Market`] -= needed - fromSupply;
|
|
97
|
+
game.players[seat][key] = count;
|
|
98
|
+
}
|
|
99
|
+
function state(game: GameState): LessonState {
|
|
100
|
+
return { game, watched: 0 };
|
|
101
|
+
}
|
|
102
|
+
function midgame(phase: Phase): GameState {
|
|
103
|
+
const game = base();
|
|
104
|
+
game.round = 4;
|
|
105
|
+
game.playerOrder = [2, 1, 0];
|
|
106
|
+
own(game, 0, [4]);
|
|
107
|
+
own(game, 1, [3]);
|
|
108
|
+
own(game, 2, [5]);
|
|
109
|
+
market(game, [6, 7, 8, 9, 10, 11, 12, 13]);
|
|
110
|
+
activate(game, phase, [0]);
|
|
111
|
+
return game;
|
|
112
|
+
}
|
|
113
|
+
function run(game: GameState, move: Move, seat: number, frame?: (game: GameState) => void) {
|
|
114
|
+
engineMove(game, copy(move), seat);
|
|
115
|
+
frame?.(copy(game));
|
|
116
|
+
}
|
|
117
|
+
function finishOtherAuctions(game: GameState, frame?: (game: GameState) => void) {
|
|
118
|
+
for (let guard = 0; game.phase === Phase.Auction && guard < 30; guard++) {
|
|
119
|
+
const seat = game.currentPlayers[0];
|
|
120
|
+
if (seat === 0) throw Error('The learner still has an auction decision.');
|
|
121
|
+
const moves = game.players[seat].availableMoves!;
|
|
122
|
+
const next = moves.ChoosePowerPlant
|
|
123
|
+
? choose(moves.ChoosePowerPlant[0])
|
|
124
|
+
: moves.Bid && !game.currentBid
|
|
125
|
+
? bid(moves.Bid[0])
|
|
126
|
+
: pass;
|
|
127
|
+
run(game, next, seat, frame);
|
|
128
|
+
}
|
|
129
|
+
if (game.phase === Phase.Auction) throw Error('Auction script did not finish.');
|
|
130
|
+
}
|
|
131
|
+
function finishIncome(game: GameState, frame?: (game: GameState) => void) {
|
|
132
|
+
for (let guard = 0; game.phase === Phase.Bureaucracy && guard < 20; guard++) {
|
|
133
|
+
const seat = game.currentPlayers[0];
|
|
134
|
+
const usable = game.players[seat].availableMoves?.UsePowerPlant?.[0];
|
|
135
|
+
run(game, usable ? { name: MoveName.UsePowerPlant, data: usable } : pass, seat, frame);
|
|
136
|
+
}
|
|
137
|
+
if (game.phase === Phase.Bureaucracy) throw Error('Income script did not finish.');
|
|
138
|
+
}
|
|
139
|
+
function apply(id: string, original: LessonState, action: Action, frame?: (game: GameState) => void): LessonState {
|
|
140
|
+
const next = copy(original);
|
|
141
|
+
next.answer = undefined;
|
|
142
|
+
const game = next.game;
|
|
143
|
+
if (action.kind === 'answer') next.answer = action.answer;
|
|
144
|
+
else if (action.kind === 'watch') {
|
|
145
|
+
next.watched++;
|
|
146
|
+
if (id === 'auctions') finishOtherAuctions(game, frame);
|
|
147
|
+
else if (id === 'income') finishIncome(game, frame);
|
|
148
|
+
else if (id === 'steps') {
|
|
149
|
+
finishIncome(game, frame);
|
|
150
|
+
// The engine handles the Step 3 card drawn during market upkeep.
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
run(game, action.move, 0, frame);
|
|
154
|
+
if (id === 'auctions' && action.move.name === MoveName.Bid) {
|
|
155
|
+
if (action.move.data === 4) {
|
|
156
|
+
run(game, bid(5), 1, frame);
|
|
157
|
+
run(game, pass, 2, frame);
|
|
158
|
+
} else if (action.move.data === 6) run(game, pass, 1, frame);
|
|
159
|
+
}
|
|
160
|
+
if (['network', 'steps', 'final-round'].includes(id) && action.move.name === MoveName.Pass) {
|
|
161
|
+
while (game.phase === Phase.Building && !game.currentPlayers.includes(0))
|
|
162
|
+
run(game, pass, game.currentPlayers[0], frame);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return next;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type Step = TutorialStep<LessonState, Action>;
|
|
169
|
+
function actionStep(
|
|
170
|
+
id: string,
|
|
171
|
+
title: string,
|
|
172
|
+
text: string | ((s: LessonState) => string),
|
|
173
|
+
accepts: (a: Action, s: LessonState) => boolean,
|
|
174
|
+
complete: (s: LessonState) => boolean,
|
|
175
|
+
target?: string
|
|
176
|
+
): Step {
|
|
177
|
+
return {
|
|
178
|
+
id,
|
|
179
|
+
title,
|
|
180
|
+
text,
|
|
181
|
+
target,
|
|
182
|
+
complete,
|
|
183
|
+
validateMove: (s, a) =>
|
|
184
|
+
accepts(a, s) ? undefined : 'Follow this step first. ' + (typeof text === 'string' ? text : text(s)),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const moveIs = (name: MoveName, data?: unknown) => (a: Action) =>
|
|
188
|
+
a.kind === 'move' &&
|
|
189
|
+
a.move.name === name &&
|
|
190
|
+
(data === undefined || JSON.stringify(a.move.data) === JSON.stringify(data));
|
|
191
|
+
function quiz(id: string, title: string, text: string, answer: string, success: string): Step {
|
|
192
|
+
return {
|
|
193
|
+
id,
|
|
194
|
+
title,
|
|
195
|
+
text,
|
|
196
|
+
complete: (s) => s.answer === answer,
|
|
197
|
+
validateMove: (_s, a) => (a.kind === 'answer' && a.answer === answer ? undefined : 'Not quite. ' + text),
|
|
198
|
+
success,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function answers(...labels: string[]): Choice[] {
|
|
202
|
+
return labels.map((label) => ({ label, action: { kind: 'answer', answer: label } }));
|
|
203
|
+
}
|
|
204
|
+
const watch = (label: string): Choice[] => [{ label, action: { kind: 'watch' } }];
|
|
205
|
+
const watchIs = (a: Action) => a.kind === 'watch';
|
|
206
|
+
function chapter(
|
|
207
|
+
id: string,
|
|
208
|
+
title: string,
|
|
209
|
+
description: string,
|
|
210
|
+
initialState: () => LessonState,
|
|
211
|
+
steps: Step[],
|
|
212
|
+
completion: { title: string; text: string },
|
|
213
|
+
choices: Lesson['choices'] = () => []
|
|
214
|
+
): Lesson {
|
|
215
|
+
return {
|
|
216
|
+
game: 'powergrid',
|
|
217
|
+
id,
|
|
218
|
+
title,
|
|
219
|
+
description,
|
|
220
|
+
version: 1,
|
|
221
|
+
initialState,
|
|
222
|
+
steps,
|
|
223
|
+
completion,
|
|
224
|
+
move: (s, a, frame) => apply(id, s, a, frame),
|
|
225
|
+
choices,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const lessons: Lesson[] = [
|
|
230
|
+
chapter(
|
|
231
|
+
'auctions',
|
|
232
|
+
'Your first power plant',
|
|
233
|
+
'Read a plant, start an auction and decide how far to bid.',
|
|
234
|
+
() => state(base()),
|
|
235
|
+
[
|
|
236
|
+
{
|
|
237
|
+
id: 'welcome',
|
|
238
|
+
title: 'Build a network that you can power',
|
|
239
|
+
text: 'Buy power plants, buy their fuel, connect cities, then earn income by supplying electricity. At the end, the player who can power the most cities wins. These lessons use Germany and the classic rules with two scripted opponents.',
|
|
240
|
+
},
|
|
241
|
+
actionStep(
|
|
242
|
+
'choose',
|
|
243
|
+
'Choose plant 4',
|
|
244
|
+
'Only the four plants in the current market are for sale. Plant 4 burns 2 coal to power 1 city. Its large number is its minimum opening price, not its output. Click plant 4.',
|
|
245
|
+
moveIs(MoveName.ChoosePowerPlant, 4),
|
|
246
|
+
(s) => s.game.chosenPowerPlant?.number === 4,
|
|
247
|
+
'plants'
|
|
248
|
+
),
|
|
249
|
+
actionStep(
|
|
250
|
+
'open',
|
|
251
|
+
'Open at $4',
|
|
252
|
+
'Confirm an opening bid of $4 using the auction calculator. Keep some cash for fuel and connections.',
|
|
253
|
+
moveIs(MoveName.Bid, 4),
|
|
254
|
+
(s) => s.game.currentBid === 5,
|
|
255
|
+
'plants'
|
|
256
|
+
),
|
|
257
|
+
actionStep(
|
|
258
|
+
'raise',
|
|
259
|
+
'Ada offers $5',
|
|
260
|
+
'Leo has passed. Raise to $6. Once you pass in an auction, you cannot rejoin that auction, but you may compete for a later plant.',
|
|
261
|
+
moveIs(MoveName.Bid, 6),
|
|
262
|
+
(s) => has(s, 4),
|
|
263
|
+
'plants'
|
|
264
|
+
),
|
|
265
|
+
actionStep(
|
|
266
|
+
'others',
|
|
267
|
+
'One plant per round',
|
|
268
|
+
'You paid $6 and have $44 left. You cannot buy another plant this round. Watch Ada and Leo buy theirs. Plants are drawn and sorted again after each purchase.',
|
|
269
|
+
watchIs,
|
|
270
|
+
inPhase(Phase.Resources)
|
|
271
|
+
),
|
|
272
|
+
],
|
|
273
|
+
{
|
|
274
|
+
title: 'Ready to buy fuel',
|
|
275
|
+
text: 'A higher plant number does not necessarily mean better value. Compare its fuel cost and output, then leave money for the rest of the round. In Recharged games, the discount token sets the cheapest market plant’s opening bid to $1, regardless of its printed number.',
|
|
276
|
+
},
|
|
277
|
+
(_s, step) => (step === 'others' ? watch('Watch the remaining auctions') : [])
|
|
278
|
+
),
|
|
279
|
+
|
|
280
|
+
chapter(
|
|
281
|
+
'resources',
|
|
282
|
+
'Fuel and rising prices',
|
|
283
|
+
'Buy coal, see prices rise and learn your storage limit.',
|
|
284
|
+
() => {
|
|
285
|
+
const game = midgame(Phase.Resources);
|
|
286
|
+
game.coalSupply += game.coalMarket - 20;
|
|
287
|
+
game.coalMarket = 20;
|
|
288
|
+
activate(game, Phase.Resources, [0]);
|
|
289
|
+
return state(game);
|
|
290
|
+
},
|
|
291
|
+
[
|
|
292
|
+
{
|
|
293
|
+
id: 'order',
|
|
294
|
+
title: 'Last in player order buys first',
|
|
295
|
+
text: 'Fuel is bought in reverse player order. You are last on the order track, so you buy first. Plant 4 needs 2 coal each time it runs and can store 4, enough for two rounds.',
|
|
296
|
+
},
|
|
297
|
+
actionStep(
|
|
298
|
+
'coal-one',
|
|
299
|
+
'Buy one coal',
|
|
300
|
+
'Click one coal in the resource market. Each token has its own price, and the cheapest available token is bought first.',
|
|
301
|
+
(a) =>
|
|
302
|
+
a.kind === 'move' &&
|
|
303
|
+
a.move.name === MoveName.BuyResource &&
|
|
304
|
+
a.move.data.resource === ResourceType.Coal,
|
|
305
|
+
(s) => you(s).coalLeft === 1,
|
|
306
|
+
'resources'
|
|
307
|
+
),
|
|
308
|
+
actionStep(
|
|
309
|
+
'coal-two',
|
|
310
|
+
'Enough for this round',
|
|
311
|
+
'Buy a second coal. You now have the 2 coal needed to run plant 4 once.',
|
|
312
|
+
(a) =>
|
|
313
|
+
a.kind === 'move' &&
|
|
314
|
+
a.move.name === MoveName.BuyResource &&
|
|
315
|
+
a.move.data.resource === ResourceType.Coal,
|
|
316
|
+
(s) => you(s).coalLeft === 2,
|
|
317
|
+
'resources'
|
|
318
|
+
),
|
|
319
|
+
quiz(
|
|
320
|
+
'price',
|
|
321
|
+
'What does the next coal cost?',
|
|
322
|
+
'The two $2 coal are gone. Look at the next occupied market space. What will one more coal cost?',
|
|
323
|
+
'$3',
|
|
324
|
+
'Correct! As cheap tokens disappear, later buyers pay more.'
|
|
325
|
+
),
|
|
326
|
+
actionStep(
|
|
327
|
+
'reserve',
|
|
328
|
+
'Store fuel for next round',
|
|
329
|
+
'Buy 2 more coal, one at a time. They cost $3 each. This fills your plant’s storage: 4 coal in total.',
|
|
330
|
+
(a) =>
|
|
331
|
+
a.kind === 'move' &&
|
|
332
|
+
a.move.name === MoveName.BuyResource &&
|
|
333
|
+
a.move.data.resource === ResourceType.Coal,
|
|
334
|
+
(s) => you(s).coalLeft === 4,
|
|
335
|
+
'resources'
|
|
336
|
+
),
|
|
337
|
+
actionStep(
|
|
338
|
+
'done',
|
|
339
|
+
'Finish buying',
|
|
340
|
+
'You spent $10 and have $40 left. You cannot store a fifth coal or buy fuel that none of your plants can use. Click Done to let Ada buy.',
|
|
341
|
+
moveIs(MoveName.Pass),
|
|
342
|
+
(s) => !s.game.currentPlayers.includes(0),
|
|
343
|
+
'turn'
|
|
344
|
+
),
|
|
345
|
+
],
|
|
346
|
+
{
|
|
347
|
+
title: 'Fuel is a shared market',
|
|
348
|
+
text: 'Stored fuel carries over. Used fuel returns to the supply, and the market is refilled after income. Hybrid plants can burn a mix of coal and oil, with one shared storage limit. Wind plants need no fuel.',
|
|
349
|
+
},
|
|
350
|
+
(_s, step) => (step === 'price' ? answers('$1', '$2', '$3') : [])
|
|
351
|
+
),
|
|
352
|
+
|
|
353
|
+
chapter(
|
|
354
|
+
'network',
|
|
355
|
+
'Connect your cities',
|
|
356
|
+
'Pay for a city and the cheapest route from your network.',
|
|
357
|
+
() => {
|
|
358
|
+
const game = midgame(Phase.Building);
|
|
359
|
+
own(game, 0, [8]);
|
|
360
|
+
market(game, [6, 7, 9, 10, 11, 12, 13, 14]);
|
|
361
|
+
activate(game, Phase.Building, [0]);
|
|
362
|
+
return state(game);
|
|
363
|
+
},
|
|
364
|
+
[
|
|
365
|
+
actionStep(
|
|
366
|
+
'first',
|
|
367
|
+
'Start in Essen',
|
|
368
|
+
'Your first city costs $10, with no connection fee. Click Essen on the map. In Step 1, each city has room for only one player.',
|
|
369
|
+
(a) => a.kind === 'move' && a.move.name === MoveName.Build && a.move.data.name === 'Essen',
|
|
370
|
+
(s) => you(s).cities.length === 1,
|
|
371
|
+
'destination'
|
|
372
|
+
),
|
|
373
|
+
actionStep(
|
|
374
|
+
'second',
|
|
375
|
+
'A free connection',
|
|
376
|
+
'Connect Duisburg. The route from Essen costs $0, so you pay only the $10 city fee. You may connect several cities in one turn.',
|
|
377
|
+
(a) => a.kind === 'move' && a.move.name === MoveName.Build && a.move.data.name === 'Duisburg',
|
|
378
|
+
(s) => you(s).cities.length === 2,
|
|
379
|
+
'destination'
|
|
380
|
+
),
|
|
381
|
+
quiz(
|
|
382
|
+
'route',
|
|
383
|
+
'What would Düsseldorf cost?',
|
|
384
|
+
'Düsseldorf has an empty $10 space. The cheapest connection from your network costs $2. What is the total?',
|
|
385
|
+
'$12',
|
|
386
|
+
'Correct! $10 for the city plus $2 for the connection.'
|
|
387
|
+
),
|
|
388
|
+
actionStep(
|
|
389
|
+
'third',
|
|
390
|
+
'Connect Düsseldorf',
|
|
391
|
+
'Click Düsseldorf and pay $12. The game finds the cheapest route from any city you already own. Routes may pass through other cities without buying a house there.',
|
|
392
|
+
(a) => a.kind === 'move' && a.move.name === MoveName.Build && a.move.data.name === 'Dusseldorf',
|
|
393
|
+
(s) => you(s).cities.length === 3,
|
|
394
|
+
'destination'
|
|
395
|
+
),
|
|
396
|
+
actionStep(
|
|
397
|
+
'done',
|
|
398
|
+
'Keep some cash',
|
|
399
|
+
'You have $18 left. Plant 8 can power only 2 of your 3 cities, so expansion alone will not increase this round’s income. Click Done to finish building.',
|
|
400
|
+
moveIs(MoveName.Pass),
|
|
401
|
+
inPhase(Phase.Bureaucracy),
|
|
402
|
+
'turn'
|
|
403
|
+
),
|
|
404
|
+
],
|
|
405
|
+
{
|
|
406
|
+
title: 'Your network is growing',
|
|
407
|
+
text: 'Building also follows reverse player order. Plan both your routes and your generating capacity. You can leave cities unpowered; they remain in your network.',
|
|
408
|
+
},
|
|
409
|
+
(_s, step) => (step === 'route' ? answers('$2', '$10', '$12') : [])
|
|
410
|
+
),
|
|
411
|
+
|
|
412
|
+
chapter(
|
|
413
|
+
'income',
|
|
414
|
+
'Power cities and earn income',
|
|
415
|
+
'Run your plants, collect income and understand the next turn order.',
|
|
416
|
+
() => {
|
|
417
|
+
const game = midgame(Phase.Bureaucracy);
|
|
418
|
+
own(game, 0, [4, 13]);
|
|
419
|
+
market(game, [6, 7, 8, 9, 10, 11, 12, 14]);
|
|
420
|
+
cities(game, 0, ['Essen', 'Duisburg', 'Dusseldorf']);
|
|
421
|
+
cities(game, 1, ['Hamburg', 'Kiel']);
|
|
422
|
+
cities(game, 2, ['Hannover']);
|
|
423
|
+
fuel(game, 0, ResourceType.Coal, 2);
|
|
424
|
+
fuel(game, 1, ResourceType.Oil, 2);
|
|
425
|
+
fuel(game, 2, ResourceType.Coal, 2);
|
|
426
|
+
game.players[0].money = 18;
|
|
427
|
+
activate(game, Phase.Bureaucracy, [0, 1, 2]);
|
|
428
|
+
return state(game);
|
|
429
|
+
},
|
|
430
|
+
[
|
|
431
|
+
actionStep(
|
|
432
|
+
'coal',
|
|
433
|
+
'Run your coal plant',
|
|
434
|
+
'Click your plant 4. It consumes 2 coal and supplies 1 city. Each plant can run once per round; you cannot pay extra fuel to run it twice.',
|
|
435
|
+
(a) => a.kind === 'move' && a.move.name === MoveName.UsePowerPlant && a.move.data.powerPlant === 4,
|
|
436
|
+
(s) => !you(s).powerPlantsNotUsed.includes(4),
|
|
437
|
+
'players'
|
|
438
|
+
),
|
|
439
|
+
actionStep(
|
|
440
|
+
'wind',
|
|
441
|
+
'Free wind power',
|
|
442
|
+
'Click plant 13. It needs no fuel and supplies 1 more city, bringing your total to 2.',
|
|
443
|
+
(a) => a.kind === 'move' && a.move.name === MoveName.UsePowerPlant && a.move.data.powerPlant === 13,
|
|
444
|
+
(s) => you(s).citiesPowered === 2,
|
|
445
|
+
'players'
|
|
446
|
+
),
|
|
447
|
+
actionStep(
|
|
448
|
+
'pay',
|
|
449
|
+
'Collect $33',
|
|
450
|
+
'The $ amounts on the city track show total income: 0 powered cities earns $10, 1 earns $22, 2 earn $33, and 3 earn $44. Your houses mark 3 cities built, but you powered only 2, so collect $33. Click Done.',
|
|
451
|
+
moveIs(MoveName.Pass),
|
|
452
|
+
(s) => you(s).money === 51,
|
|
453
|
+
'turn'
|
|
454
|
+
),
|
|
455
|
+
actionStep(
|
|
456
|
+
'others',
|
|
457
|
+
'Finish the round',
|
|
458
|
+
'Watch Ada and Leo run their plants and collect income. The resource market then refills and the plant market changes.',
|
|
459
|
+
watchIs,
|
|
460
|
+
(s) => s.game.round === 5
|
|
461
|
+
),
|
|
462
|
+
quiz(
|
|
463
|
+
'order',
|
|
464
|
+
'Who chooses an auction first?',
|
|
465
|
+
'You have 3 cities, Ada has 2 and Leo has 1. Most cities goes first; a tie is broken by the highest-numbered plant. Who goes first next round?',
|
|
466
|
+
'You',
|
|
467
|
+
'Correct! You choose an auction first, but buy fuel and build last.'
|
|
468
|
+
),
|
|
469
|
+
],
|
|
470
|
+
{
|
|
471
|
+
title: 'Keep the whole round in mind',
|
|
472
|
+
text: 'A large network puts you early in the auction order and late in resource buying and building. Sometimes waiting to expand saves money. Even powering no cities normally earns $10.',
|
|
473
|
+
},
|
|
474
|
+
(_s, step) =>
|
|
475
|
+
step === 'others'
|
|
476
|
+
? watch('Watch income and market upkeep')
|
|
477
|
+
: step === 'order'
|
|
478
|
+
? answers('You', 'Ada', 'Leo')
|
|
479
|
+
: []
|
|
480
|
+
),
|
|
481
|
+
|
|
482
|
+
chapter(
|
|
483
|
+
'upgrades',
|
|
484
|
+
'Replace an old power plant',
|
|
485
|
+
'Upgrade your output while respecting the three-plant limit.',
|
|
486
|
+
() => {
|
|
487
|
+
const game = midgame(Phase.Auction);
|
|
488
|
+
own(game, 0, [4, 8, 13]);
|
|
489
|
+
market(game, [25, 26, 27, 28, 29, 30, 31, 32]);
|
|
490
|
+
game.playerOrder = [0, 1, 2];
|
|
491
|
+
game.players[1].skipAuction = game.players[2].skipAuction = true;
|
|
492
|
+
activate(game, Phase.Auction, [0]);
|
|
493
|
+
return state(game);
|
|
494
|
+
},
|
|
495
|
+
[
|
|
496
|
+
actionStep(
|
|
497
|
+
'buy',
|
|
498
|
+
'Buy plant 25',
|
|
499
|
+
'Ada and Leo have already bought this round. Choose plant 25 and confirm the $25 purchase. It uses 2 coal to power 5 cities, compared with just 1 city for plant 4.',
|
|
500
|
+
moveIs(MoveName.ChoosePowerPlant, 25),
|
|
501
|
+
(s) => has(s, 25),
|
|
502
|
+
'plants'
|
|
503
|
+
),
|
|
504
|
+
actionStep(
|
|
505
|
+
'discard',
|
|
506
|
+
'Keep your best three',
|
|
507
|
+
'In a three-player game you may keep 3 plants. Click your old plant 4 to discard it. You get no refund. Stored fuel can move to compatible plants, but excess fuel must be discarded.',
|
|
508
|
+
moveIs(MoveName.DiscardPowerPlant, 4),
|
|
509
|
+
(s) => !has(s, 4),
|
|
510
|
+
'players'
|
|
511
|
+
),
|
|
512
|
+
],
|
|
513
|
+
{
|
|
514
|
+
title: 'More output, less fuel',
|
|
515
|
+
text: 'Your remaining plants can supply up to 8 cities: 2 + 1 + 5. You still need enough cities and fuel to use that output. The two-player classic game allows 4 plants instead of 3.',
|
|
516
|
+
}
|
|
517
|
+
),
|
|
518
|
+
|
|
519
|
+
chapter(
|
|
520
|
+
'steps',
|
|
521
|
+
'When the game opens up',
|
|
522
|
+
'Trigger Step 2 and see how Step 3 changes cities and the plant market.',
|
|
523
|
+
() => {
|
|
524
|
+
const game = midgame(Phase.Building);
|
|
525
|
+
cities(game, 0, ['Essen', 'Duisburg', 'Dusseldorf', 'Dortmund', 'Münster', 'Osnabrück']);
|
|
526
|
+
cities(game, 1, ['Hamburg', 'Kiel']);
|
|
527
|
+
cities(game, 2, ['Hannover']);
|
|
528
|
+
market(game, [18, 19, 20, 21, 22, 23, 24, 25]);
|
|
529
|
+
game.powerPlantsDeck = [plant(26), plant(99)];
|
|
530
|
+
game.cardsLeft = game.powerPlantsDeck.length;
|
|
531
|
+
activate(game, Phase.Building, [0]);
|
|
532
|
+
return state(game);
|
|
533
|
+
},
|
|
534
|
+
[
|
|
535
|
+
actionStep(
|
|
536
|
+
'seventh',
|
|
537
|
+
'Connect your seventh city',
|
|
538
|
+
'Rounds repeat; Steps are larger changes during the game. Here Step 2 will start once someone has 7 cities, after everyone finishes building. Connect Kassel.',
|
|
539
|
+
(a) => a.kind === 'move' && a.move.name === MoveName.Build && a.move.data.name === 'Kassel',
|
|
540
|
+
(s) => you(s).cities.length === 7,
|
|
541
|
+
'destination'
|
|
542
|
+
),
|
|
543
|
+
actionStep(
|
|
544
|
+
'finish',
|
|
545
|
+
'Finish the building phase',
|
|
546
|
+
'Click Done. Ada and Leo will also finish, and the game will enter Step 2.',
|
|
547
|
+
moveIs(MoveName.Pass),
|
|
548
|
+
(s) => s.game.step === 2,
|
|
549
|
+
'turn'
|
|
550
|
+
),
|
|
551
|
+
quiz(
|
|
552
|
+
'second-space',
|
|
553
|
+
'Now two players fit in a city',
|
|
554
|
+
'In Step 2, a second player may build in an occupied city. That second space costs $15, plus the connection. An empty city still costs $10. Can you build a second house of your own in Essen?',
|
|
555
|
+
'No',
|
|
556
|
+
'Correct! Each player may have only one house in each city.'
|
|
557
|
+
),
|
|
558
|
+
actionStep(
|
|
559
|
+
'step-three',
|
|
560
|
+
'The Step 3 card',
|
|
561
|
+
'This example is near the end of the plant deck. Finish income and watch the Step 3 card appear during market upkeep.',
|
|
562
|
+
watchIs,
|
|
563
|
+
(s) => s.game.step === 3
|
|
564
|
+
),
|
|
565
|
+
quiz(
|
|
566
|
+
'market',
|
|
567
|
+
'All six are available',
|
|
568
|
+
'Step 3 allows a third player in each city for $20 plus connections. The plant market has 6 cards and no future market. How many of those plants can be chosen for auction?',
|
|
569
|
+
'All 6',
|
|
570
|
+
'Correct! All six market plants are available in Step 3.'
|
|
571
|
+
),
|
|
572
|
+
],
|
|
573
|
+
{
|
|
574
|
+
title: 'Watch the Step, not just the round',
|
|
575
|
+
text: 'Step 2 opens second city spaces; Step 3 opens third spaces and the whole plant market. Resource refill rates also change. Other maps and player counts may use different thresholds.',
|
|
576
|
+
},
|
|
577
|
+
(_s, step) =>
|
|
578
|
+
step === 'second-space'
|
|
579
|
+
? answers('Yes', 'No')
|
|
580
|
+
: step === 'step-three'
|
|
581
|
+
? watch('Finish the round')
|
|
582
|
+
: step === 'market'
|
|
583
|
+
? answers('Only 4', 'All 6')
|
|
584
|
+
: []
|
|
585
|
+
),
|
|
586
|
+
|
|
587
|
+
chapter(
|
|
588
|
+
'final-round',
|
|
589
|
+
'Who actually wins?',
|
|
590
|
+
'Trigger the end and compare cities built with cities powered.',
|
|
591
|
+
() => {
|
|
592
|
+
const game = midgame(Phase.Building);
|
|
593
|
+
game.step = 3;
|
|
594
|
+
game.round = 10;
|
|
595
|
+
own(game, 0, [20, 29, 32]);
|
|
596
|
+
own(game, 1, [25, 36, 44]);
|
|
597
|
+
own(game, 2, [13, 18, 22]);
|
|
598
|
+
market(game, [30, 31, 33, 34, 35, 37]);
|
|
599
|
+
const names = game.map.cities.map((c) => c.name);
|
|
600
|
+
cities(game, 0, names.slice(0, 16));
|
|
601
|
+
cities(game, 1, names.slice(0, 16));
|
|
602
|
+
cities(game, 2, names.slice(0, 15));
|
|
603
|
+
fuel(game, 0, ResourceType.Coal, 4);
|
|
604
|
+
fuel(game, 0, ResourceType.Oil, 3);
|
|
605
|
+
fuel(game, 1, ResourceType.Coal, 5);
|
|
606
|
+
game.players[0].money = 100;
|
|
607
|
+
activate(game, Phase.Building, [0]);
|
|
608
|
+
return state(game);
|
|
609
|
+
},
|
|
610
|
+
[
|
|
611
|
+
{
|
|
612
|
+
id: 'ending',
|
|
613
|
+
title: 'One last building phase',
|
|
614
|
+
text: 'On this three-player map, reaching 17 cities triggers the end after everyone finishes building. You and Ada each have 16 cities. You can power 15; Ada can power 16. Building another house will not improve your plants.',
|
|
615
|
+
},
|
|
616
|
+
actionStep(
|
|
617
|
+
'trigger',
|
|
618
|
+
'Connect a seventeenth city',
|
|
619
|
+
'Connect any affordable city you do not own. The other players still get to finish this building phase.',
|
|
620
|
+
(a) => a.kind === 'move' && a.move.name === MoveName.Build,
|
|
621
|
+
(s) => you(s).cities.length === 17,
|
|
622
|
+
'map'
|
|
623
|
+
),
|
|
624
|
+
quiz(
|
|
625
|
+
'predict',
|
|
626
|
+
'Who is ahead?',
|
|
627
|
+
'You now have 17 cities but can power only 15. Ada has 16 cities and enough plants and fuel to power all 16. Who will win if nobody builds further?',
|
|
628
|
+
'Ada',
|
|
629
|
+
'Correct! Powered cities decide the winner, not houses placed.'
|
|
630
|
+
),
|
|
631
|
+
actionStep(
|
|
632
|
+
'finish',
|
|
633
|
+
'See the result',
|
|
634
|
+
'Click Done. Ada and Leo will finish without building. The game checks everyone’s plants and stored fuel automatically.',
|
|
635
|
+
moveIs(MoveName.Pass),
|
|
636
|
+
inPhase(Phase.GameEnd),
|
|
637
|
+
'turn'
|
|
638
|
+
),
|
|
639
|
+
],
|
|
640
|
+
{
|
|
641
|
+
title: 'Power wins the game',
|
|
642
|
+
text: 'Ada wins with 16 powered cities, ahead of your 15. Remaining cash breaks a tie in powered cities. There is no ordinary income payment at the end, so buy the fuel you need before the final building phase.',
|
|
643
|
+
},
|
|
644
|
+
(_s, step) => (step === 'predict' ? answers('You', 'Ada', 'Leo') : [])
|
|
645
|
+
),
|
|
646
|
+
];
|