@civ-clone/base-unit-lifecycle 0.1.0 → 0.1.2

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.
@@ -0,0 +1,484 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import AttackEndsTurn from '@civ-clone/base-unit-trait-attack-ends-turn/AttackEndsTurn';
4
+ import CoastBound from '@civ-clone/base-unit-trait-coast-bound/CoastBound';
5
+ import Air from '@civ-clone/base-unit-type-air/Air';
6
+ import Land from '@civ-clone/base-unit-type-land/Land';
7
+ import { Peace } from '@civ-clone/library-diplomacy/Declarations';
8
+ import Player from '@civ-clone/core-player/Player';
9
+ import PlayerAction from '@civ-clone/core-player/PlayerAction';
10
+ import Rule from '@civ-clone/core-rule/Rule';
11
+ import Tile from '@civ-clone/core-world/Tile';
12
+ import TileImprovement from '@civ-clone/core-tile-improvement/TileImprovement';
13
+ import Unit from '@civ-clone/core-unit/Unit';
14
+ import UnitImprovement from '@civ-clone/core-unit-improvement/UnitImprovement';
15
+ import detonatedRules from '../lib/detonatedRules';
16
+ import diplomatRules from '../lib/diplomatRules';
17
+ import { expect } from 'chai';
18
+ import lifecycleRules from '../lib/lifecycleRules';
19
+ import movedRules from '../lib/movedRules';
20
+ import treatyPartner from '../lib/treatyPartner';
21
+
22
+ // civ1-unit 0.1.40's own rules (835709d), dumped from its compiled `getRules`: id, rule type, priority and how many
23
+ // criteria each has, in order. The assemblers with civ1-unit's prefix and options must give exactly these, so that
24
+ // civ1-unit can adopt them later without a single rule moving (R2b).
25
+ const civ1: Record<string, [string, string, number, number][]> = JSON.parse(
26
+ fs.readFileSync(
27
+ path.join(process.cwd(), 'tests/fixtures/civ1-unit-rules.json'),
28
+ 'utf8'
29
+ )
30
+ );
31
+
32
+ const fingerprint = (
33
+ rules: Rule<any, any>[]
34
+ ): [string, string, number, number][] =>
35
+ rules.map((rule: Rule<any, any>) => [
36
+ rule.id() as string,
37
+ rule.constructor.name,
38
+ rule.priority().value(),
39
+ (
40
+ (rule as unknown as { _criteria?: { _criteria: unknown[] } })._criteria
41
+ ?._criteria ?? []
42
+ ).length,
43
+ ]);
44
+
45
+ // Stand-ins named as civ1-unit's classes are, since a rule id carries the class's name.
46
+ class Fortified extends UnitImprovement {}
47
+ class ActiveUnit extends PlayerAction {}
48
+ class InactiveUnit extends PlayerAction {}
49
+ class Move {}
50
+ class Disembark extends Move {}
51
+ class Attack {}
52
+ class SneakAttack extends Attack {}
53
+ class SneakMove extends Move {}
54
+ class SneakPillage {}
55
+ class Pollution extends TileImprovement {}
56
+
57
+ const lifecycle = lifecycleRules(
58
+ 'civ1-unit',
59
+ {},
60
+ {
61
+ ActiveUnit: ActiveUnit as never,
62
+ InactiveUnit: InactiveUnit as never,
63
+ removedOnActivate: [Fortified],
64
+ stowable: [Land, Air],
65
+ partnerOf: (): Player | null => null,
66
+ shortfallFactor: 0.5,
67
+ }
68
+ );
69
+
70
+ describe('lifecycleRules', (): void => {
71
+ (
72
+ [
73
+ ['Rules/Unit/activate', lifecycle.activate],
74
+ ['Rules/Unit/canStow', lifecycle.canStow],
75
+ ['Rules/Unit/created', lifecycle.created],
76
+ ['Rules/Unit/defeated', lifecycle.defeated],
77
+ ['Rules/Unit/destroyed', lifecycle.destroyed],
78
+ ['Rules/Unit/expectedMovementCost', lifecycle.expectedMovementCost],
79
+ ['Rules/Unit/halted', lifecycle.halted],
80
+ ['Rules/Unit/impassable', lifecycle.impassable],
81
+ ['Rules/Unit/lostAtSea', lifecycle.lostAtSea],
82
+ ['Rules/Player/action', lifecycle.playerAction],
83
+ ['Rules/Unit/stowed', lifecycle.stowed],
84
+ ['Rules/Unit/unsupported', lifecycle.unsupported],
85
+ ['Rules/Unit/validateMove', lifecycle.validateMove],
86
+ ] as [string, Rule<any, any>[]][]
87
+ ).forEach(([file, rules]): void => {
88
+ it(`gives civ1-unit's ${file} rules, in order`, (): void => {
89
+ expect(fingerprint(rules)).to.deep.equal(civ1[file]);
90
+ });
91
+ });
92
+
93
+ it("gives civ1-unit's transferred/emit, after which civ1-unit keeps its own start-afresh rule", (): void => {
94
+ expect(fingerprint(lifecycle.transferred)).to.deep.equal(
95
+ civ1['Rules/Unit/transferred'].filter(([id]) => id.endsWith('/emit'))
96
+ );
97
+ });
98
+
99
+ it('makes no impassable rule without a partnerOf', (): void => {
100
+ const rules = lifecycleRules(
101
+ 'game',
102
+ {},
103
+ {
104
+ ActiveUnit: ActiveUnit as never,
105
+ InactiveUnit: InactiveUnit as never,
106
+ removedOnActivate: [],
107
+ stowable: [],
108
+ shortfallFactor: 0.5,
109
+ }
110
+ );
111
+
112
+ expect(rules.impassable).to.deep.equal([]);
113
+ expect(rules.created[0].id()).to.equal('game:unit/created/register');
114
+ expect(rules.canStow).to.deep.equal([]);
115
+ expect(rules.activate).to.length(2);
116
+ });
117
+
118
+ it('spares the stack where sparesStack says so, with one criterion more, and only then', (): void => {
119
+ const city = {},
120
+ fort = {},
121
+ open = {},
122
+ cityRegistry = {
123
+ getByTile: (tile: any) => (tile === city ? {} : null),
124
+ } as any,
125
+ units = new Map<any, Unit[]>(),
126
+ unitRegistry = {
127
+ getByTile: (tile: any): Unit[] => units.get(tile) ?? [],
128
+ } as any,
129
+ player = {},
130
+ by = { player: () => ({}) } as unknown as Unit,
131
+ defender = (tile: any): Unit => {
132
+ const unit = { tile: () => tile, player: () => player } as any,
133
+ friend = { tile: () => tile, player: () => player } as any;
134
+
135
+ units.set(tile, [unit, friend]);
136
+
137
+ return unit;
138
+ },
139
+ destroyStackRule = (sparesStack?: (tile: Tile, unit: Unit) => boolean) =>
140
+ lifecycleRules(
141
+ 'game',
142
+ { cityRegistry, unitRegistry },
143
+ {
144
+ ActiveUnit: ActiveUnit as never,
145
+ InactiveUnit: InactiveUnit as never,
146
+ removedOnActivate: [],
147
+ stowable: [],
148
+ shortfallFactor: 0.5,
149
+ sparesStack,
150
+ }
151
+ ).defeated[1],
152
+ plain = destroyStackRule(),
153
+ sparing = destroyStackRule((tile: Tile): boolean => tile === fort);
154
+
155
+ expect(fingerprint([plain])[0][3]).to.equal(3);
156
+ expect(fingerprint([sparing])[0][3]).to.equal(4);
157
+ expect(sparing.id()).to.equal(plain.id());
158
+
159
+ expect(plain.validate(defender(open), by)).to.true;
160
+ expect(plain.validate(defender(fort), by)).to.true;
161
+ expect(plain.validate(defender(city), by)).to.false;
162
+
163
+ expect(sparing.validate(defender(open), by)).to.true;
164
+ expect(sparing.validate(defender(fort), by)).to.false;
165
+ expect(sparing.validate(defender(city), by)).to.false;
166
+ });
167
+
168
+ it('prices a long step with the given shortfall factor', (): void => {
169
+ const [rule] = lifecycleRules(
170
+ 'game',
171
+ {},
172
+ {
173
+ ActiveUnit: ActiveUnit as never,
174
+ InactiveUnit: InactiveUnit as never,
175
+ removedOnActivate: [],
176
+ stowable: [],
177
+ shortfallFactor: 0.25,
178
+ }
179
+ ).expectedMovementCost;
180
+
181
+ // max(m, c × factor): 3 moves, a step costing 20.
182
+ expect(rule.process(null, 20, 3)).to.equal(5);
183
+ expect(rule.validate(null, 20, 0)).to.false;
184
+ });
185
+ });
186
+
187
+ describe('movedRules', (): void => {
188
+ const options = {
189
+ Move: Move as never,
190
+ Disembark: Disembark as never,
191
+ attacks: [Attack, SneakAttack] as never[],
192
+ noVendetta: [SneakMove, SneakPillage] as never[],
193
+ coastBoundLoss: 0.5,
194
+ };
195
+
196
+ it("gives civ1-unit's Rules/Unit/moved rules, in order", (): void => {
197
+ expect(fingerprint(movedRules('civ1-unit', {}, options))).to.deep.equal(
198
+ civ1['Rules/Unit/moved']
199
+ );
200
+ });
201
+
202
+ it('ends the turn of a unit that AttackEndsTurn after any of the given attacks only', (): void => {
203
+ const rule = movedRules('game', {}, options).find((rule) =>
204
+ rule.id()!.endsWith('/bomber/end-turn-after-attack')
205
+ )!,
206
+ bomber = {
207
+ hasTrait: (Trait: unknown): boolean => Trait === AttackEndsTurn,
208
+ },
209
+ fighter = { hasTrait: (): boolean => false };
210
+
211
+ expect(rule.validate(bomber, new Attack())).to.true;
212
+ expect(rule.validate(bomber, new SneakAttack())).to.true;
213
+ expect(rule.validate(bomber, new Move())).to.false;
214
+ expect(rule.validate(fighter, new Attack())).to.false;
215
+ });
216
+
217
+ it('loses a CoastBound unit out of sight of land at the given odds', (): void => {
218
+ const rules = (draw: number) =>
219
+ movedRules(
220
+ 'game',
221
+ { randomNumberGenerator: (): number => draw },
222
+ { ...options, coastBoundLoss: 0.25 }
223
+ ).find((rule) => rule.id()!.endsWith('/trireme-lost-at-sea'))!,
224
+ unit = (coast: boolean) => ({
225
+ hasTrait: (Trait: unknown): boolean => Trait === CoastBound,
226
+ moves: () => ({ value: (): number => 0 }),
227
+ tile: () => ({ isCoast: (): boolean => coast }),
228
+ });
229
+
230
+ expect(rules(0.25).validate(unit(false))).to.true;
231
+ expect(rules(0.26).validate(unit(false))).to.false;
232
+ expect(rules(0).validate(unit(true))).to.false;
233
+ });
234
+ });
235
+
236
+ describe('detonatedRules', (): void => {
237
+ it("gives civ1-unit's Rules/Unit/detonated rules, in order", (): void => {
238
+ expect(
239
+ fingerprint(
240
+ detonatedRules(
241
+ 'civ1-unit',
242
+ {},
243
+ {
244
+ sparesUnits: (): boolean => false,
245
+ Pollution: Pollution as never,
246
+ falloutChance: 2 / 3,
247
+ }
248
+ )
249
+ )
250
+ ).to.deep.equal(civ1['Rules/Unit/detonated']);
251
+ });
252
+
253
+ it('pollutes land at the given odds, and spares the units sparesUnits keeps', (): void => {
254
+ const built: Tile[] = [],
255
+ destroyed: unknown[] = [],
256
+ spared: unknown[] = [],
257
+ land = (name: string) =>
258
+ ({ name, isLand: (): boolean => true } as unknown as Tile),
259
+ centre = Object.assign(land('centre'), {
260
+ getNeighbours: (): Tile[] => [land('a'), land('b')],
261
+ }),
262
+ draws = [0.1, 0.9, 0.2],
263
+ city = { name: 'city' },
264
+ rules = detonatedRules(
265
+ 'game',
266
+ {
267
+ cityRegistry: {
268
+ getByTile: (tile: { name: string }) =>
269
+ tile.name === 'a' ? city : null,
270
+ } as never,
271
+ engine: {
272
+ emit: (event: string, ...args: unknown[]): void => {
273
+ if (event === 'city:sdi-protected') {
274
+ spared.push(args[0]);
275
+ }
276
+ },
277
+ } as never,
278
+ randomNumberGenerator: (): number => draws.shift()!,
279
+ ruleRegistry: {
280
+ process: (Type: unknown, unit: unknown): void => {
281
+ destroyed.push(unit);
282
+ },
283
+ } as never,
284
+ tileImprovementRegistry: { getByTile: () => [] } as never,
285
+ unitRegistry: {
286
+ getByTile: (tile: { name: string }) => [
287
+ { tile, destroyed: (): boolean => false },
288
+ ],
289
+ } as never,
290
+ },
291
+ {
292
+ sparesUnits: (candidate): boolean => candidate === (city as never),
293
+ Pollution: class {
294
+ constructor(tile: Tile) {
295
+ built.push(tile);
296
+ }
297
+ } as never,
298
+ falloutChance: 0.5,
299
+ }
300
+ ),
301
+ attacker = { player: (): null => null } as unknown as Unit;
302
+
303
+ rules
304
+ .find((rule) => rule.id() === 'game:unit/detonated/destroy-units')!
305
+ .process(attacker, centre);
306
+ rules
307
+ .find((rule) => rule.id() === 'game:unit/detonated/fallout')!
308
+ .process(attacker, centre);
309
+
310
+ expect(spared).to.deep.equal([city]);
311
+ expect(destroyed).to.length(2);
312
+ // Square a has the city; of centre and b, centre draws 0.1 (polluted) and b draws 0.9 (not).
313
+ expect(
314
+ built.map((tile) => (tile as unknown as { name: string }).name)
315
+ ).to.deep.equal(['centre']);
316
+ });
317
+ });
318
+
319
+ describe('treatyPartner', (): void => {
320
+ class Road extends TileImprovement {}
321
+ class Farm extends TileImprovement {}
322
+
323
+ it('is the partner working an improved tile, for a land unit that fights', (): void => {
324
+ const me = { name: 'me' },
325
+ them = { name: 'them' },
326
+ unit = (fights: boolean, domain: typeof Unit = Land) =>
327
+ Object.assign(Object.create(domain.prototype), {
328
+ hasTrait: (): boolean => !fights,
329
+ player: () => me,
330
+ }),
331
+ partnerOf = (improvement: TileImprovement | null, atPeace = true) =>
332
+ treatyPartner([Road], {
333
+ interactionRegistry: {
334
+ getByPlayers: () => [
335
+ Object.assign(Object.create(Peace.prototype), {
336
+ active: (): boolean => atPeace,
337
+ }),
338
+ ],
339
+ } as never,
340
+ tileImprovementRegistry: {
341
+ getByTile: () => (improvement ? [improvement] : []),
342
+ } as never,
343
+ workedTileRegistry: {
344
+ getByTile: () => ({ city: () => ({ player: () => them }) }),
345
+ } as never,
346
+ });
347
+
348
+ const road = Object.create(Road.prototype),
349
+ farm = Object.create(Farm.prototype),
350
+ tile = {} as Tile;
351
+
352
+ expect(partnerOf(road)(unit(true), tile)).to.equal(them);
353
+ expect(partnerOf(farm)(unit(true), tile)).to.null;
354
+ expect(partnerOf(null)(unit(true), tile)).to.null;
355
+ expect(partnerOf(road, false)(unit(true), tile)).to.null;
356
+ expect(partnerOf(road)(unit(false), tile)).to.null;
357
+ expect(partnerOf(road)(unit(true, Air), tile)).to.null;
358
+ });
359
+ });
360
+
361
+ describe('diplomatRules', (): void => {
362
+ it("gives civ1-unit's embassy, investigation and meeting rules, which come first in its Rules/Unit/diplomat", (): void => {
363
+ const rules = diplomatRules('civ1-unit', {});
364
+
365
+ expect(
366
+ fingerprint([
367
+ ...rules.embassyEstablished,
368
+ ...rules.cityInvestigated,
369
+ ...rules.leaderMet,
370
+ ])
371
+ ).to.deep.equal(civ1['Rules/Unit/diplomat'].slice(0, 3));
372
+ });
373
+
374
+ it("registers an embassy for the Diplomat's player, and a meeting ends its turn", (): void => {
375
+ const registered: unknown[] = [],
376
+ events: string[] = [],
377
+ me = {},
378
+ them = {},
379
+ rules = diplomatRules('game', {
380
+ engine: { emit: (event: string) => events.push(event) } as never,
381
+ interactionRegistry: {
382
+ register: (interaction: unknown) => registered.push(interaction),
383
+ } as never,
384
+ });
385
+ let moves = 1;
386
+ const unit = {
387
+ player: () => me,
388
+ moves: () => ({ set: (value: number) => (moves = value) }),
389
+ };
390
+
391
+ rules.embassyEstablished[0].process(unit, { player: () => them });
392
+ rules.leaderMet[0].process(unit, them);
393
+
394
+ expect(registered).to.length(1);
395
+ const [embassy] = registered as { holder(): unknown; host(): unknown }[];
396
+
397
+ expect([embassy.holder(), embassy.host()]).to.deep.equal([me, them]);
398
+ expect(moves).to.equal(0);
399
+ expect(events).to.deep.equal([
400
+ 'player:embassy-established',
401
+ 'player:meet-with-leader',
402
+ ]);
403
+ });
404
+ });
405
+
406
+ describe('lifecycleRules, a short step of the ruleset’s own', (): void => {
407
+ it('decides a short step and prices it with `shortStep`, spending the moves', (): void => {
408
+ const calls: unknown[][] = [],
409
+ rules = lifecycleRules(
410
+ 'game',
411
+ { randomNumberGenerator: (): number => 0.25 },
412
+ {
413
+ ActiveUnit: ActiveUnit as never,
414
+ InactiveUnit: InactiveUnit as never,
415
+ removedOnActivate: [],
416
+ stowable: [],
417
+ shortStep: {
418
+ succeeds: (unit, remaining, movement, cost, rng) => {
419
+ calls.push([remaining, movement, cost, rng()]);
420
+
421
+ return remaining === movement;
422
+ },
423
+ expectedCost: (movement: number) => movement,
424
+ },
425
+ }
426
+ ),
427
+ [, short] = rules.validateMove,
428
+ [expected] = rules.expectedMovementCost;
429
+ let moves = 1;
430
+ const unit = {
431
+ moves: () => ({
432
+ value: () => moves,
433
+ set: (value: number) => (moves = value),
434
+ }),
435
+ movement: () => ({ value: () => 1 }),
436
+ };
437
+
438
+ expect(short.process(unit, 3)).to.true;
439
+ expect(moves).to.equal(0);
440
+ expect(calls).to.deep.equal([[1, 1, 3, 0.25]]);
441
+ expect(expected.process(null, 20, 3)).to.equal(3);
442
+ });
443
+ });
444
+
445
+ describe('detonatedRules, vetoed', (): void => {
446
+ it('does nothing over a square the ruleset vetoes, but still tells the client', (): void => {
447
+ let asked = 0;
448
+ const events: string[] = [],
449
+ touched: string[] = [],
450
+ rules = detonatedRules(
451
+ 'game',
452
+ {
453
+ cityRegistry: {
454
+ getByTile: () => {
455
+ touched.push('city');
456
+
457
+ return null;
458
+ },
459
+ } as never,
460
+ engine: { emit: (event: string) => events.push(event) } as never,
461
+ unitRegistry: { getByTile: () => [] } as never,
462
+ tileImprovementRegistry: { getByTile: () => [] } as never,
463
+ },
464
+ {
465
+ sparesUnits: () => false,
466
+ Pollution: Pollution as never,
467
+ falloutChance: 1,
468
+ vetoes: () => {
469
+ asked++;
470
+
471
+ return true;
472
+ },
473
+ }
474
+ ),
475
+ tile = { getNeighbours: () => [], isLand: () => true },
476
+ attacker = { player: () => null };
477
+
478
+ rules.forEach((rule) => rule.process(attacker, tile));
479
+
480
+ expect(events).to.deep.equal(['unit:nuclear-detonation']);
481
+ expect(touched).to.deep.equal([]);
482
+ expect(asked).to.equal(1);
483
+ });
484
+ });