@civ-clone/base-city-lifecycle 0.0.0-stage → 0.1.0

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.
Files changed (44) hide show
  1. package/.gitattributes +2 -0
  2. package/.github/workflows/build-on-push.yml +39 -0
  3. package/.prettierrc +4 -0
  4. package/LICENSE +21 -0
  5. package/README.md +31 -2
  6. package/lib/canBeWorked.d.ts +28 -0
  7. package/lib/canBeWorked.js +27 -0
  8. package/lib/canBeWorked.js.map +1 -0
  9. package/lib/canBeWorked.ts +43 -0
  10. package/lib/claimName.d.ts +12 -0
  11. package/lib/claimName.js +9 -0
  12. package/lib/claimName.js.map +1 -0
  13. package/lib/claimName.ts +13 -0
  14. package/lib/events.d.ts +31 -0
  15. package/lib/events.js +26 -0
  16. package/lib/events.js.map +1 -0
  17. package/lib/events.ts +40 -0
  18. package/lib/famine.d.ts +41 -0
  19. package/lib/famine.js +44 -0
  20. package/lib/famine.js.map +1 -0
  21. package/lib/famine.ts +68 -0
  22. package/lib/foodStorage.d.ts +21 -0
  23. package/lib/foodStorage.js +27 -0
  24. package/lib/foodStorage.js.map +1 -0
  25. package/lib/foodStorage.ts +30 -0
  26. package/lib/occupiedTiles.d.ts +25 -0
  27. package/lib/occupiedTiles.js +35 -0
  28. package/lib/occupiedTiles.js.map +1 -0
  29. package/lib/occupiedTiles.ts +52 -0
  30. package/lib/playerActions.d.ts +72 -0
  31. package/lib/playerActions.js +68 -0
  32. package/lib/playerActions.js.map +1 -0
  33. package/lib/playerActions.ts +114 -0
  34. package/lib/processYield.d.ts +30 -0
  35. package/lib/processYield.js +54 -0
  36. package/lib/processYield.js.map +1 -0
  37. package/lib/processYield.ts +82 -0
  38. package/lib/unsupported.d.ts +6 -0
  39. package/lib/unsupported.js +9 -0
  40. package/lib/unsupported.js.map +1 -0
  41. package/lib/unsupported.ts +8 -0
  42. package/package.json +40 -4
  43. package/tests/base-city-lifecycle.test.ts +536 -0
  44. package/tsconfig.json +16 -0
@@ -0,0 +1,536 @@
1
+ import { describe, it } from 'mocha';
2
+ import { buildWithProduction, storeFood } from '../lib/processYield';
3
+ import {
4
+ changeProductionActions,
5
+ changeSpecialistActions,
6
+ changeWorkedTileActions,
7
+ chooseProductionActions,
8
+ hasCity,
9
+ hasCityToChangeProduction,
10
+ hasCityToChooseProduction,
11
+ hasSpecialist,
12
+ } from '../lib/playerActions';
13
+ import {
14
+ emitBuildingHeld,
15
+ emitTileReleased,
16
+ emitTileWorked,
17
+ } from '../lib/events';
18
+ import {
19
+ emitFoodStorageExhausted,
20
+ emptyFoodStore,
21
+ foodSupportedUnit,
22
+ loseFoodSupportedUnit,
23
+ shrinkFromFamine,
24
+ } from '../lib/famine';
25
+ import { enemyWorkedTile, releaseEnemyWorkedTile } from '../lib/occupiedTiles';
26
+ import {
27
+ exhaustFood,
28
+ grow,
29
+ hasFoodToGrow,
30
+ isFoodExhausted,
31
+ } from '../lib/foodStorage';
32
+ import {
33
+ isNotAnotherCity,
34
+ isNotOccupiedByEnemy,
35
+ isNotWorked,
36
+ } from '../lib/canBeWorked';
37
+ import {
38
+ ChangeProduction,
39
+ CityBuild as CityBuildAction,
40
+ } from '@civ-clone/core-city-build/PlayerActions';
41
+ import ChangeSpecialist from '@civ-clone/core-city/PlayerActions/ChangeSpecialist';
42
+ import ChangeWorkedTile from '@civ-clone/core-city/PlayerActions/ChangeWorkedTile';
43
+ import CivilDisorder from '@civ-clone/core-city-happiness/Rules/CivilDisorder';
44
+ import Effect from '@civ-clone/core-rule/Effect';
45
+ import FoodExhausted from '@civ-clone/core-city-growth/Rules/FoodExhausted';
46
+ import FoodStorage from '@civ-clone/core-city-growth/Yields/FoodStorage';
47
+ import RuleRegistry from '@civ-clone/core-rule/RuleRegistry';
48
+ import TileReassigned from '@civ-clone/core-city/Rules/TileReassigned';
49
+ import Unsupported from '@civ-clone/core-unit/Rules/Unsupported';
50
+ import Yield from '@civ-clone/core-yield/Yield';
51
+ import { claimName } from '../lib/claimName';
52
+ import { disbandUnsupported } from '../lib/unsupported';
53
+ import { expect } from 'chai';
54
+
55
+ class Grain extends Yield {}
56
+ class Ore extends Yield {}
57
+
58
+ // A yield paid for a unit, as a city's unit support is.
59
+ class Fed extends Yield {
60
+ private _unit: any;
61
+
62
+ constructor(value: number, unit: any) {
63
+ super(value);
64
+
65
+ this._unit = unit;
66
+ }
67
+
68
+ unit(): any {
69
+ return this._unit;
70
+ }
71
+ }
72
+ class Kept extends Fed {}
73
+
74
+ const recordingEngine = () => {
75
+ const emitted: unknown[][] = [];
76
+
77
+ return {
78
+ emitted,
79
+ engine: {
80
+ emit: (...args: unknown[]): void => {
81
+ emitted.push(args);
82
+ },
83
+ } as any,
84
+ };
85
+ },
86
+ named = (name: string, extra: object = {}) => ({ name, ...extra } as any),
87
+ standInGrowth = (
88
+ progress: number,
89
+ cost: number,
90
+ yields: Yield[] = [],
91
+ log: unknown[][] = []
92
+ ) =>
93
+ ({
94
+ city: () => ({ yields: () => yields }),
95
+ progress: () => new Yield(progress),
96
+ cost: () => new Yield(cost),
97
+ grow: (...args: unknown[]) => log.push(['grow', ...args]),
98
+ shrink: (...args: unknown[]) => log.push(['shrink', ...args]),
99
+ empty: (...args: unknown[]) => log.push(['empty', ...args]),
100
+ } as any);
101
+
102
+ describe('events', (): void => {
103
+ it('tells clients of a worked or released tile and a held build, with just those arguments', (): void => {
104
+ const { emitted, engine } = recordingEngine(),
105
+ city = named('city'),
106
+ tile = named('tile'),
107
+ cityBuild = named('cityBuild'),
108
+ item = named('item');
109
+
110
+ emitTileWorked(city, tile, engine);
111
+ emitTileReleased(city, tile, engine);
112
+ emitBuildingHeld(cityBuild, item, ['a', null], engine);
113
+
114
+ expect(emitted).to.eql([
115
+ ['city:tile-worked', city, tile],
116
+ ['city:tile-released', city, tile],
117
+ ['city:building-held', cityBuild, item, ['a', null]],
118
+ ]);
119
+ });
120
+ });
121
+
122
+ describe('foodStorage', (): void => {
123
+ it('grows once the store holds the cost, and runs out below 0', (): void => {
124
+ expect(hasFoodToGrow(standInGrowth(19, 20))).to.false;
125
+ expect(hasFoodToGrow(standInGrowth(20, 20))).to.true;
126
+ expect(hasFoodToGrow(standInGrowth(21, 20))).to.true;
127
+ expect(isFoodExhausted(standInGrowth(0, 20))).to.false;
128
+ expect(isFoodExhausted(standInGrowth(-1, 20))).to.true;
129
+ });
130
+
131
+ it('grows the city with no cause, and hands a city out of food to the FoodExhausted rules', (): void => {
132
+ const log: unknown[][] = [],
133
+ cityGrowth = standInGrowth(-1, 20, [], log),
134
+ ruleRegistry = new RuleRegistry(),
135
+ exhausted: unknown[][] = [];
136
+
137
+ ruleRegistry.register(
138
+ new FoodExhausted(
139
+ new Effect((...args: unknown[]) => exhausted.push(args))
140
+ )
141
+ );
142
+
143
+ grow(cityGrowth);
144
+ exhaustFood(cityGrowth, ruleRegistry);
145
+
146
+ expect(log).to.eql([['grow']]);
147
+ expect(exhausted).to.eql([[cityGrowth]]);
148
+ });
149
+ });
150
+
151
+ describe('famine', (): void => {
152
+ const founder = named('founder', { destroy: () => {} }),
153
+ other = named('other');
154
+
155
+ it('finds the first unit fed by a yield of the given type, skipping yields for no unit', (): void => {
156
+ expect(
157
+ foodSupportedUnit(
158
+ standInGrowth(0, 0, [
159
+ new Grain(2),
160
+ new Fed(1, null),
161
+ new Kept(1, other),
162
+ new Fed(1, founder),
163
+ ]),
164
+ Fed
165
+ )
166
+ ).to.equal(other);
167
+ expect(
168
+ foodSupportedUnit(
169
+ standInGrowth(0, 0, [new Kept(1, other), new Fed(1, founder)]),
170
+ Kept
171
+ )
172
+ ).to.equal(other);
173
+ expect(
174
+ foodSupportedUnit(
175
+ standInGrowth(0, 0, [new Grain(1), new Fed(1, null)]),
176
+ Fed
177
+ )
178
+ ).to.null;
179
+ });
180
+
181
+ it('tells clients of the famine, with the unit lost to it only when there is one', (): void => {
182
+ const { emitted, engine } = recordingEngine(),
183
+ city = named('city'),
184
+ cityGrowth = { city: () => city } as any;
185
+
186
+ emitFoodStorageExhausted(cityGrowth, null, engine);
187
+ emitFoodStorageExhausted(cityGrowth, founder, engine);
188
+
189
+ expect(emitted).to.eql([
190
+ ['city:food-storage-exhausted', city],
191
+ ['city:food-storage-exhausted', city, founder],
192
+ ]);
193
+ expect(emitted[0]).to.length(2);
194
+ });
195
+
196
+ it('loses the first unit fed, or shrinks the city for its famine, and empties the store', (): void => {
197
+ const destroyed: unknown[][] = [],
198
+ unit = named('unit', {
199
+ destroy: (...args: unknown[]) => destroyed.push(args),
200
+ }),
201
+ log: unknown[][] = [],
202
+ cityGrowth = standInGrowth(-1, 20, [new Fed(1, unit)], log);
203
+
204
+ loseFoodSupportedUnit(cityGrowth, Fed);
205
+ shrinkFromFamine(cityGrowth);
206
+ emptyFoodStore(cityGrowth);
207
+
208
+ expect(destroyed).to.eql([[]]);
209
+ expect(log).to.eql([['shrink', FoodExhausted], ['empty']]);
210
+ });
211
+ });
212
+
213
+ describe('processYield', (): void => {
214
+ it("adds the city's food to its store as FoodStorage, then checks it", (): void => {
215
+ const log: unknown[][] = [],
216
+ city = named('city'),
217
+ cityGrowth = {
218
+ add: (food: Yield) =>
219
+ log.push(['add', food instanceof FoodStorage, food.value()]),
220
+ check: () => log.push(['check']),
221
+ };
222
+
223
+ storeFood(new Grain(3), city, {
224
+ getByCity: (forCity: unknown) => {
225
+ expect(forCity).to.equal(city);
226
+
227
+ return cityGrowth;
228
+ },
229
+ } as any);
230
+
231
+ expect(log).to.eql([['add', true, 3], ['check']]);
232
+ });
233
+
234
+ const unitAt = (name: string, distance: number) =>
235
+ named(name, {
236
+ tile: () => ({ distanceFrom: () => distance }),
237
+ }),
238
+ setUp = (disorder: boolean) => {
239
+ const log: unknown[][] = [],
240
+ ruleRegistry = new RuleRegistry(),
241
+ city = named('city', {
242
+ tile: () => ({}),
243
+ yields: () => {
244
+ log.push(['yields']);
245
+
246
+ return [];
247
+ },
248
+ }),
249
+ cityBuild = {
250
+ add: (production: Yield) => log.push(['add', production.value()]),
251
+ check: () => log.push(['check']),
252
+ };
253
+
254
+ ruleRegistry.register(
255
+ new Unsupported(
256
+ new Effect((forCity: unknown, unit: any, cityYield: Yield) =>
257
+ log.push(['unsupported', unit.name, cityYield.value()])
258
+ )
259
+ ),
260
+ new CivilDisorder(
261
+ new Effect(() => {
262
+ log.push(['disorder?']);
263
+
264
+ return disorder;
265
+ })
266
+ )
267
+ );
268
+
269
+ return {
270
+ log,
271
+ ruleRegistry,
272
+ city,
273
+ cityBuildRegistry: { getByCity: () => cityBuild } as any,
274
+ };
275
+ };
276
+
277
+ it('loses the furthest units it can no longer support, until production is back to 0, then builds with what is left', (): void => {
278
+ const { log, ruleRegistry, city, cityBuildRegistry } = setUp(false),
279
+ cityYields: Yield[] = [
280
+ new Kept(-1, unitAt('near', 1)),
281
+ new Kept(-1, null),
282
+ new Kept(-1, unitAt('far', 5)),
283
+ new Ore(-1),
284
+ new Kept(-1, unitAt('middle', 3)),
285
+ ],
286
+ production = new Grain(-2);
287
+
288
+ buildWithProduction(
289
+ production,
290
+ city,
291
+ cityYields,
292
+ Kept,
293
+ cityBuildRegistry,
294
+ ruleRegistry
295
+ );
296
+
297
+ expect(log).to.eql([
298
+ ['unsupported', 'far', -1],
299
+ ['unsupported', 'middle', -1],
300
+ ['yields'],
301
+ ['disorder?'],
302
+ ['add', 0],
303
+ ['check'],
304
+ ]);
305
+ expect(production.value()).to.equal(-2);
306
+ });
307
+
308
+ it('builds nothing while there is civil disorder, but still checks the build', (): void => {
309
+ const { log, ruleRegistry, city, cityBuildRegistry } = setUp(true);
310
+
311
+ buildWithProduction(
312
+ new Grain(4),
313
+ city,
314
+ [new Kept(-1, unitAt('near', 1))],
315
+ Kept,
316
+ cityBuildRegistry,
317
+ ruleRegistry
318
+ );
319
+
320
+ expect(log).to.eql([['yields'], ['disorder?'], ['check']]);
321
+ });
322
+ });
323
+
324
+ describe('canBeWorked', (): void => {
325
+ const player = named('player'),
326
+ enemy = named('enemy'),
327
+ city = named('city', { player: () => player }),
328
+ tile = named('tile');
329
+
330
+ it('is not worked yet', (): void => {
331
+ expect(isNotWorked(tile, { tileIsWorked: () => false } as any)).to.true;
332
+ expect(isNotWorked(tile, { tileIsWorked: () => true } as any)).to.false;
333
+ });
334
+
335
+ it("has no other player's unit on it", (): void => {
336
+ const unitsOf = (...players: unknown[]) =>
337
+ ({
338
+ getByTile: () => players.map((owner) => ({ player: () => owner })),
339
+ } as any);
340
+
341
+ expect(isNotOccupiedByEnemy(tile, city, unitsOf())).to.true;
342
+ expect(isNotOccupiedByEnemy(tile, city, unitsOf(player))).to.true;
343
+ expect(isNotOccupiedByEnemy(tile, city, unitsOf(player, enemy))).to.false;
344
+ });
345
+
346
+ it("isn't another city's centre", (): void => {
347
+ const cityOn = (other: unknown) => ({ getByTile: () => other } as any);
348
+
349
+ expect(isNotAnotherCity(tile, city, cityOn(null))).to.true;
350
+ expect(isNotAnotherCity(tile, city, cityOn(city))).to.true;
351
+ expect(isNotAnotherCity(tile, city, cityOn(named('other')))).to.false;
352
+ });
353
+ });
354
+
355
+ describe('claimName and disbandUnsupported', (): void => {
356
+ it("claims the city's name", (): void => {
357
+ const claimed: string[] = [];
358
+
359
+ claimName(named('city', { name: () => 'Ur' }), {
360
+ claim: (name: string) => claimed.push(name),
361
+ });
362
+
363
+ expect(claimed).to.eql(['Ur']);
364
+ });
365
+
366
+ it('destroys the unit, by no one', (): void => {
367
+ const destroyed: unknown[][] = [];
368
+
369
+ disbandUnsupported(
370
+ named('city'),
371
+ named('unit', { destroy: (...args: unknown[]) => destroyed.push(args) })
372
+ );
373
+
374
+ expect(destroyed).to.eql([[]]);
375
+ });
376
+ });
377
+
378
+ describe('playerActions', (): void => {
379
+ const player = named('player'),
380
+ building = { building: () => named('item') },
381
+ idle = { building: () => null },
382
+ cities = [named('a'), named('b'), named('c')],
383
+ builds = new Map<unknown, unknown>([
384
+ [cities[0], building],
385
+ [cities[1], idle],
386
+ [cities[2], building],
387
+ ]),
388
+ cityRegistry = { getByPlayer: () => cities } as any,
389
+ cityBuildRegistry = {
390
+ getByCity: (city: unknown) => builds.get(city),
391
+ } as any;
392
+
393
+ it('offers a choice of production for each city building nothing, and a change for each building something', (): void => {
394
+ expect(hasCityToChooseProduction(player, cityRegistry, cityBuildRegistry))
395
+ .to.true;
396
+ expect(hasCityToChangeProduction(player, cityRegistry, cityBuildRegistry))
397
+ .to.true;
398
+
399
+ const choose = chooseProductionActions(
400
+ player,
401
+ cityRegistry,
402
+ cityBuildRegistry
403
+ ),
404
+ change = changeProductionActions(player, cityRegistry, cityBuildRegistry);
405
+
406
+ expect(choose.map((action) => action.constructor)).to.eql([
407
+ CityBuildAction,
408
+ ]);
409
+ expect(choose.map((action) => action.value())).to.eql([idle]);
410
+ expect(change.map((action) => action.constructor)).to.eql([
411
+ ChangeProduction,
412
+ ChangeProduction,
413
+ ]);
414
+ expect(change.map((action) => action.value())).to.eql([building, building]);
415
+
416
+ const allBuilding = { getByCity: () => building } as any;
417
+
418
+ expect(hasCityToChooseProduction(player, cityRegistry, allBuilding)).to
419
+ .false;
420
+ expect(chooseProductionActions(player, cityRegistry, allBuilding)).to.eql(
421
+ []
422
+ );
423
+ expect(
424
+ hasCityToChangeProduction(player, cityRegistry, {
425
+ getByCity: () => idle,
426
+ } as any)
427
+ ).to.false;
428
+ });
429
+
430
+ it('offers a worked-tile change for each city, and a specialist change for each specialist', (): void => {
431
+ const noCities = { getByPlayer: () => [] } as any,
432
+ specialists = new Map<unknown, unknown[]>([
433
+ [cities[0], []],
434
+ [cities[1], ['s1', 's2']],
435
+ [cities[2], ['s3']],
436
+ ]),
437
+ specialistRegistry = {
438
+ getByCity: (city: unknown) => specialists.get(city),
439
+ } as any;
440
+
441
+ expect(hasCity(player, cityRegistry)).to.true;
442
+ expect(hasCity(player, noCities)).to.false;
443
+
444
+ const worked = changeWorkedTileActions(player, cityRegistry);
445
+
446
+ expect(worked.map((action) => action.constructor)).to.eql([
447
+ ChangeWorkedTile,
448
+ ChangeWorkedTile,
449
+ ChangeWorkedTile,
450
+ ]);
451
+ expect(worked.map((action) => action.value())).to.eql(cities);
452
+
453
+ expect(hasSpecialist(player, cityRegistry, specialistRegistry)).to.true;
454
+ expect(hasSpecialist(player, cityRegistry, { getByCity: () => [] } as any))
455
+ .to.false;
456
+
457
+ const changes = changeSpecialistActions(
458
+ player,
459
+ cityRegistry,
460
+ specialistRegistry
461
+ );
462
+
463
+ expect(changes.map((action) => action.constructor)).to.eql([
464
+ ChangeSpecialist,
465
+ ChangeSpecialist,
466
+ ChangeSpecialist,
467
+ ]);
468
+ expect(changes.map((action) => action.value())).to.eql(['s1', 's2', 's3']);
469
+ });
470
+ });
471
+
472
+ describe('occupiedTiles', (): void => {
473
+ const player = named('player'),
474
+ enemy = named('enemy'),
475
+ centre = named('centre'),
476
+ tile = named('tile'),
477
+ enemyCity = named('enemyCity', {
478
+ tile: () => centre,
479
+ player: () => enemy,
480
+ }),
481
+ unit = named('unit', { player: () => player }),
482
+ workedBy = (city: unknown, on: unknown) => ({
483
+ city: () => city,
484
+ tile: () => on,
485
+ }),
486
+ registryOf = (workedTile: unknown, log: unknown[][] = []) =>
487
+ ({
488
+ getByTile: () => workedTile,
489
+ unregister: (...args: unknown[]) => log.push(['unregister', ...args]),
490
+ } as any);
491
+
492
+ it("finds a tile another player's city works, other than its centre", (): void => {
493
+ const worked = workedBy(enemyCity, tile);
494
+
495
+ expect(enemyWorkedTile(unit, tile, registryOf(null))).to.null;
496
+ expect(enemyWorkedTile(unit, tile, registryOf(worked))).to.equal(worked);
497
+ expect(
498
+ enemyWorkedTile(unit, centre, registryOf(workedBy(enemyCity, centre)))
499
+ ).to.null;
500
+ expect(
501
+ enemyWorkedTile(
502
+ unit,
503
+ tile,
504
+ registryOf(
505
+ workedBy(
506
+ named('own', { tile: () => centre, player: () => player }),
507
+ tile
508
+ )
509
+ )
510
+ )
511
+ ).to.null;
512
+ });
513
+
514
+ it("releases it from the city and hands the city to its TileReassigned rules, only when it's still worked", (): void => {
515
+ const log: unknown[][] = [],
516
+ ruleRegistry = new RuleRegistry(),
517
+ worked = workedBy(enemyCity, tile);
518
+
519
+ ruleRegistry.register(
520
+ new TileReassigned(
521
+ new Effect((...args: unknown[]) => log.push(['reassigned', ...args]))
522
+ )
523
+ );
524
+
525
+ releaseEnemyWorkedTile(unit, tile, registryOf(null, log), ruleRegistry);
526
+
527
+ expect(log).to.eql([]);
528
+
529
+ releaseEnemyWorkedTile(unit, tile, registryOf(worked, log), ruleRegistry);
530
+
531
+ expect(log).to.eql([
532
+ ['unregister', worked],
533
+ ['reassigned', enemyCity, tile],
534
+ ]);
535
+ });
536
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "declaration": true,
4
+ "lib": [
5
+ "es2020"
6
+ ],
7
+ "module": "commonjs",
8
+ "sourceMap": true,
9
+ "strict": true,
10
+ "target": "ES2018"
11
+ },
12
+ "exclude": [
13
+ "node_modules",
14
+ "tests"
15
+ ]
16
+ }