@civ-clone/base-unit-lifecycle 0.0.0-stage → 0.1.1

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 (79) hide show
  1. package/.gitattributes +4 -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 +61 -2
  6. package/lib/activate.d.ts +15 -0
  7. package/lib/activate.js +27 -0
  8. package/lib/activate.js.map +1 -0
  9. package/lib/activate.ts +47 -0
  10. package/lib/canStow.d.ts +8 -0
  11. package/lib/canStow.js +17 -0
  12. package/lib/canStow.js.map +1 -0
  13. package/lib/canStow.ts +25 -0
  14. package/lib/created.d.ts +9 -0
  15. package/lib/created.js +19 -0
  16. package/lib/created.js.map +1 -0
  17. package/lib/created.ts +24 -0
  18. package/lib/defeated.d.ts +25 -0
  19. package/lib/defeated.js +33 -0
  20. package/lib/defeated.js.map +1 -0
  21. package/lib/defeated.ts +55 -0
  22. package/lib/destroyed.d.ts +37 -0
  23. package/lib/destroyed.js +74 -0
  24. package/lib/destroyed.js.map +1 -0
  25. package/lib/destroyed.ts +106 -0
  26. package/lib/detonatedRules.d.ts +56 -0
  27. package/lib/detonatedRules.js +95 -0
  28. package/lib/detonatedRules.js.map +1 -0
  29. package/lib/detonatedRules.ts +196 -0
  30. package/lib/diplomatRules.d.ts +27 -0
  31. package/lib/diplomatRules.js +41 -0
  32. package/lib/diplomatRules.js.map +1 -0
  33. package/lib/diplomatRules.ts +83 -0
  34. package/lib/halted.d.ts +6 -0
  35. package/lib/halted.js +10 -0
  36. package/lib/halted.js.map +1 -0
  37. package/lib/halted.ts +11 -0
  38. package/lib/impassable.d.ts +8 -0
  39. package/lib/impassable.js +15 -0
  40. package/lib/impassable.js.map +1 -0
  41. package/lib/impassable.ts +19 -0
  42. package/lib/lifecycleRules.d.ts +118 -0
  43. package/lib/lifecycleRules.js +126 -0
  44. package/lib/lifecycleRules.js.map +1 -0
  45. package/lib/lifecycleRules.ts +378 -0
  46. package/lib/lostAtSea.d.ts +12 -0
  47. package/lib/lostAtSea.js +26 -0
  48. package/lib/lostAtSea.js.map +1 -0
  49. package/lib/lostAtSea.ts +37 -0
  50. package/lib/movedRules.d.ts +44 -0
  51. package/lib/movedRules.js +126 -0
  52. package/lib/movedRules.js.map +1 -0
  53. package/lib/movedRules.ts +300 -0
  54. package/lib/playerAction.d.ts +22 -0
  55. package/lib/playerAction.js +28 -0
  56. package/lib/playerAction.js.map +1 -0
  57. package/lib/playerAction.ts +64 -0
  58. package/lib/stowed.d.ts +2 -0
  59. package/lib/stowed.js +8 -0
  60. package/lib/stowed.js.map +1 -0
  61. package/lib/stowed.ts +6 -0
  62. package/lib/treatyPartner.d.ts +26 -0
  63. package/lib/treatyPartner.js +38 -0
  64. package/lib/treatyPartner.js.map +1 -0
  65. package/lib/treatyPartner.ts +78 -0
  66. package/lib/unsupported.d.ts +6 -0
  67. package/lib/unsupported.js +9 -0
  68. package/lib/unsupported.js.map +1 -0
  69. package/lib/unsupported.ts +10 -0
  70. package/lib/validateMove.d.ts +11 -0
  71. package/lib/validateMove.js +26 -0
  72. package/lib/validateMove.js.map +1 -0
  73. package/lib/validateMove.ts +36 -0
  74. package/package.json +61 -4
  75. package/tests/fixtures/civ1-unit-rules.json +89 -0
  76. package/tests/lifecycle.test.ts +657 -0
  77. package/tests/movedRules.test.ts +256 -0
  78. package/tests/rules.test.ts +434 -0
  79. package/tsconfig.json +16 -0
@@ -0,0 +1,256 @@
1
+ import { Peace, Vendetta } from '@civ-clone/library-diplomacy/Declarations';
2
+ import Air from '@civ-clone/base-unit-type-air/Air';
3
+ import InteractionRegistry from '@civ-clone/core-diplomacy/InteractionRegistry';
4
+ import Land from '@civ-clone/base-unit-type-land/Land';
5
+ import Moved from '@civ-clone/core-unit/Rules/Moved';
6
+ import NavalTransport from '@civ-clone/base-unit-type-naval-transport/NavalTransport';
7
+ import Player from '@civ-clone/core-player/Player';
8
+ import RuleRegistry from '@civ-clone/core-rule/RuleRegistry';
9
+ import Sleeping from '@civ-clone/base-unit-action-sleep/Rules/Sleeping';
10
+ import Stowed from '@civ-clone/base-unit-action-embark/Busy/Stowed';
11
+ import { expect } from 'chai';
12
+ import movedRules from '../lib/movedRules';
13
+
14
+ // The bodies of `movedRules`, with stand-in units, actions and registries: each rule processed through a registry, as
15
+ // `Moved` is in a game (every rule's criteria checked before any effect runs).
16
+
17
+ class Move {
18
+ fromTile: unknown;
19
+ toTile: unknown;
20
+ forUnits: unknown[] = [];
21
+
22
+ constructor(fromTile: unknown, toTile: unknown) {
23
+ this.fromTile = fromTile;
24
+ this.toTile = toTile;
25
+ }
26
+
27
+ from() {
28
+ return this.fromTile;
29
+ }
30
+
31
+ to() {
32
+ return this.toTile;
33
+ }
34
+
35
+ forUnit(unit: unknown) {
36
+ this.forUnits.push(unit);
37
+
38
+ return this;
39
+ }
40
+ }
41
+ class Disembark extends Move {}
42
+ class Attack {}
43
+ class SneakMove extends Move {
44
+ _enemy: Player;
45
+
46
+ constructor(enemy: Player) {
47
+ super(null, null);
48
+
49
+ this._enemy = enemy;
50
+ }
51
+
52
+ enemy() {
53
+ return this._enemy;
54
+ }
55
+ }
56
+ class SneakAttack extends Attack {
57
+ _enemy: Player;
58
+
59
+ constructor(enemy: Player) {
60
+ super();
61
+
62
+ this._enemy = enemy;
63
+ }
64
+
65
+ enemy() {
66
+ return this._enemy;
67
+ }
68
+ }
69
+
70
+ const me = new Player(new RuleRegistry()),
71
+ them = new Player(new RuleRegistry());
72
+
73
+ type Stub = Record<string, unknown>;
74
+
75
+ const stub = (Type: Function, props: Stub): any =>
76
+ Object.assign(Object.create(Type.prototype), {
77
+ player: () => me,
78
+ busy: () => null,
79
+ setBusy() {},
80
+ hasTrait: () => false,
81
+ moves: () => ({ value: () => 1, set() {} }),
82
+ setActive() {},
83
+ applyVisibility() {},
84
+ action() {},
85
+ ...props,
86
+ });
87
+
88
+ const setUp = ({
89
+ cityOwner = null as Player | null,
90
+ unitsOn = [] as unknown[],
91
+ aboard = new Map<unknown, unknown>(),
92
+ interactionRegistry = new InteractionRegistry(),
93
+ } = {}) => {
94
+ const ruleRegistry = new RuleRegistry(),
95
+ unregistered: unknown[] = [];
96
+
97
+ ruleRegistry.register(
98
+ ...movedRules(
99
+ 'game',
100
+ {
101
+ cityRegistry: {
102
+ getByTile: () => (cityOwner ? { player: () => cityOwner } : null),
103
+ } as never,
104
+ engine: { emit() {} } as never,
105
+ interactionRegistry,
106
+ randomNumberGenerator: () => 1,
107
+ ruleRegistry,
108
+ transportRegistry: {
109
+ hasUnit: (unit: unknown) => aboard.has(unit),
110
+ getByUnit: (unit: unknown) => ({
111
+ transport: () => aboard.get(unit),
112
+ }),
113
+ unregister: (manifest: unknown) => unregistered.push(manifest),
114
+ } as never,
115
+ unitRegistry: { getByTile: () => unitsOn } as never,
116
+ },
117
+ {
118
+ Move: Move as never,
119
+ Disembark: Disembark as never,
120
+ attacks: [Attack, SneakAttack] as never[],
121
+ noVendetta: [SneakMove] as never[],
122
+ coastBoundLoss: 0.5,
123
+ }
124
+ )
125
+ );
126
+
127
+ return {
128
+ moved: (unit: unknown, action: unknown) =>
129
+ ruleRegistry.process(Moved, unit as never, action as never),
130
+ unregistered,
131
+ interactionRegistry,
132
+ };
133
+ };
134
+
135
+ // A transport with `cargo` aboard, recording what it unloads and stows.
136
+ const transport = (cargo: unknown[], tile: unknown = 'sea') => {
137
+ const unloaded: unknown[] = [],
138
+ stowed: unknown[] = [];
139
+
140
+ return {
141
+ unloaded,
142
+ stowed,
143
+ ship: stub(NavalTransport, {
144
+ tile: () => tile,
145
+ hasCargo: () => cargo.length > 0,
146
+ cargo: () => cargo,
147
+ unload: (unit: unknown) => unloaded.push(unit),
148
+ hasCapacity: () => true,
149
+ canStow: () => true,
150
+ stow: (unit: unknown) => stowed.push(unit),
151
+ }),
152
+ };
153
+ };
154
+
155
+ describe('movedRules', (): void => {
156
+ it('takes only sentried cargo and aircraft out of its own port, and all of it at sea', (): void => {
157
+ const awake = stub(Land, {}),
158
+ sentried = stub(Land, { busy: () => new Stowed() }),
159
+ asleep = stub(Land, { busy: () => new Sleeping() }),
160
+ aircraft = stub(Air, {}),
161
+ cargo = [awake, sentried, asleep, aircraft],
162
+ inPort = transport(cargo),
163
+ atSea = transport(cargo);
164
+
165
+ const leaving = new Move('port', 'sea');
166
+
167
+ setUp({ cityOwner: me }).moved(inPort.ship, leaving);
168
+ expect(inPort.unloaded).to.deep.equal([awake]);
169
+ expect(leaving.forUnits).to.deep.equal([sentried, asleep, aircraft]);
170
+
171
+ const sailing = new Move('sea', 'sea');
172
+
173
+ setUp().moved(atSea.ship, sailing);
174
+ expect(atSea.unloaded).to.deep.equal([]);
175
+ expect(sailing.forUnits).to.deep.equal(cargo);
176
+ });
177
+
178
+ it('collects its aircraft outside its cities, and its sleeping units in them', (): void => {
179
+ const aircraft = stub(Air, {}),
180
+ sleeper = stub(Land, { busy: () => new Sleeping() }),
181
+ awake = stub(Land, {}),
182
+ foreign = stub(Air, { player: () => them });
183
+
184
+ const atSea = transport([], 'here');
185
+
186
+ setUp({ unitsOn: [aircraft, sleeper, awake, foreign] }).moved(
187
+ atSea.ship,
188
+ new Move('there', 'here')
189
+ );
190
+ expect(atSea.stowed).to.deep.equal([aircraft]);
191
+
192
+ const inPort = transport([], 'here');
193
+
194
+ setUp({ cityOwner: me, unitsOn: [aircraft, sleeper, awake] }).moved(
195
+ inPort.ship,
196
+ new Move('there', 'here')
197
+ );
198
+ expect(inPort.stowed).to.deep.equal([sleeper]);
199
+ });
200
+
201
+ it('unloads a disembarking unit and forgets its manifest, and a unit that moves off its transport', (): void => {
202
+ const unloaded: unknown[] = [],
203
+ ship = {
204
+ unload: (unit: unknown) => unloaded.push(unit),
205
+ tile: () => 'sea',
206
+ },
207
+ leaving = stub(Land, { tile: () => 'shore' }),
208
+ takingOff = stub(Air, { tile: () => 'sky', busy: () => new Stowed() }),
209
+ aboard = new Map<unknown, unknown>([
210
+ [leaving, ship],
211
+ [takingOff, ship],
212
+ ]),
213
+ { moved, unregistered } = setUp({ aboard });
214
+
215
+ moved(leaving, new Disembark('sea', 'shore'));
216
+ expect(unloaded).to.deep.equal([leaving]);
217
+ expect(unregistered).to.length(1);
218
+
219
+ let busy: unknown = 'stowed';
220
+
221
+ takingOff.setBusy = (value?: unknown) => (busy = value ?? null);
222
+ moved(takingOff, new Move('sea', 'sky'));
223
+ expect(unloaded).to.deep.equal([leaving, takingOff]);
224
+ expect(busy).to.null;
225
+ });
226
+
227
+ it('ends a treaty broken by a sneak attack, with a grudge, and one broken by a sneak move, without', (): void => {
228
+ const attacker = stub(Land, {}),
229
+ attacked = setUp(),
230
+ walked = setUp();
231
+
232
+ attacked.interactionRegistry.register(
233
+ new Peace(me, them, new RuleRegistry())
234
+ );
235
+ walked.interactionRegistry.register(
236
+ new Peace(me, them, new RuleRegistry())
237
+ );
238
+
239
+ attacked.moved(attacker, new SneakAttack(them));
240
+ walked.moved(attacker, new SneakMove(them));
241
+
242
+ const active = (registry: InteractionRegistry, Type: Function) =>
243
+ registry
244
+ .entries()
245
+ .filter(
246
+ (interaction) =>
247
+ interaction instanceof Type &&
248
+ (interaction as unknown as { active(): boolean }).active()
249
+ ).length;
250
+
251
+ expect(active(attacked.interactionRegistry, Peace)).to.equal(0);
252
+ expect(active(attacked.interactionRegistry, Vendetta)).to.equal(1);
253
+ expect(active(walked.interactionRegistry, Peace)).to.equal(0);
254
+ expect(active(walked.interactionRegistry, Vendetta)).to.equal(0);
255
+ });
256
+ });
@@ -0,0 +1,434 @@
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('prices a long step with the given shortfall factor', (): void => {
119
+ const [rule] = lifecycleRules(
120
+ 'game',
121
+ {},
122
+ {
123
+ ActiveUnit: ActiveUnit as never,
124
+ InactiveUnit: InactiveUnit as never,
125
+ removedOnActivate: [],
126
+ stowable: [],
127
+ shortfallFactor: 0.25,
128
+ }
129
+ ).expectedMovementCost;
130
+
131
+ // max(m, c × factor): 3 moves, a step costing 20.
132
+ expect(rule.process(null, 20, 3)).to.equal(5);
133
+ expect(rule.validate(null, 20, 0)).to.false;
134
+ });
135
+ });
136
+
137
+ describe('movedRules', (): void => {
138
+ const options = {
139
+ Move: Move as never,
140
+ Disembark: Disembark as never,
141
+ attacks: [Attack, SneakAttack] as never[],
142
+ noVendetta: [SneakMove, SneakPillage] as never[],
143
+ coastBoundLoss: 0.5,
144
+ };
145
+
146
+ it("gives civ1-unit's Rules/Unit/moved rules, in order", (): void => {
147
+ expect(fingerprint(movedRules('civ1-unit', {}, options))).to.deep.equal(
148
+ civ1['Rules/Unit/moved']
149
+ );
150
+ });
151
+
152
+ it('ends the turn of a unit that AttackEndsTurn after any of the given attacks only', (): void => {
153
+ const rule = movedRules('game', {}, options).find((rule) =>
154
+ rule.id()!.endsWith('/bomber/end-turn-after-attack')
155
+ )!,
156
+ bomber = {
157
+ hasTrait: (Trait: unknown): boolean => Trait === AttackEndsTurn,
158
+ },
159
+ fighter = { hasTrait: (): boolean => false };
160
+
161
+ expect(rule.validate(bomber, new Attack())).to.true;
162
+ expect(rule.validate(bomber, new SneakAttack())).to.true;
163
+ expect(rule.validate(bomber, new Move())).to.false;
164
+ expect(rule.validate(fighter, new Attack())).to.false;
165
+ });
166
+
167
+ it('loses a CoastBound unit out of sight of land at the given odds', (): void => {
168
+ const rules = (draw: number) =>
169
+ movedRules(
170
+ 'game',
171
+ { randomNumberGenerator: (): number => draw },
172
+ { ...options, coastBoundLoss: 0.25 }
173
+ ).find((rule) => rule.id()!.endsWith('/trireme-lost-at-sea'))!,
174
+ unit = (coast: boolean) => ({
175
+ hasTrait: (Trait: unknown): boolean => Trait === CoastBound,
176
+ moves: () => ({ value: (): number => 0 }),
177
+ tile: () => ({ isCoast: (): boolean => coast }),
178
+ });
179
+
180
+ expect(rules(0.25).validate(unit(false))).to.true;
181
+ expect(rules(0.26).validate(unit(false))).to.false;
182
+ expect(rules(0).validate(unit(true))).to.false;
183
+ });
184
+ });
185
+
186
+ describe('detonatedRules', (): void => {
187
+ it("gives civ1-unit's Rules/Unit/detonated rules, in order", (): void => {
188
+ expect(
189
+ fingerprint(
190
+ detonatedRules(
191
+ 'civ1-unit',
192
+ {},
193
+ {
194
+ sparesUnits: (): boolean => false,
195
+ Pollution: Pollution as never,
196
+ falloutChance: 2 / 3,
197
+ }
198
+ )
199
+ )
200
+ ).to.deep.equal(civ1['Rules/Unit/detonated']);
201
+ });
202
+
203
+ it('pollutes land at the given odds, and spares the units sparesUnits keeps', (): void => {
204
+ const built: Tile[] = [],
205
+ destroyed: unknown[] = [],
206
+ spared: unknown[] = [],
207
+ land = (name: string) =>
208
+ ({ name, isLand: (): boolean => true } as unknown as Tile),
209
+ centre = Object.assign(land('centre'), {
210
+ getNeighbours: (): Tile[] => [land('a'), land('b')],
211
+ }),
212
+ draws = [0.1, 0.9, 0.2],
213
+ city = { name: 'city' },
214
+ rules = detonatedRules(
215
+ 'game',
216
+ {
217
+ cityRegistry: {
218
+ getByTile: (tile: { name: string }) =>
219
+ tile.name === 'a' ? city : null,
220
+ } as never,
221
+ engine: {
222
+ emit: (event: string, ...args: unknown[]): void => {
223
+ if (event === 'city:sdi-protected') {
224
+ spared.push(args[0]);
225
+ }
226
+ },
227
+ } as never,
228
+ randomNumberGenerator: (): number => draws.shift()!,
229
+ ruleRegistry: {
230
+ process: (Type: unknown, unit: unknown): void => {
231
+ destroyed.push(unit);
232
+ },
233
+ } as never,
234
+ tileImprovementRegistry: { getByTile: () => [] } as never,
235
+ unitRegistry: {
236
+ getByTile: (tile: { name: string }) => [
237
+ { tile, destroyed: (): boolean => false },
238
+ ],
239
+ } as never,
240
+ },
241
+ {
242
+ sparesUnits: (candidate): boolean => candidate === (city as never),
243
+ Pollution: class {
244
+ constructor(tile: Tile) {
245
+ built.push(tile);
246
+ }
247
+ } as never,
248
+ falloutChance: 0.5,
249
+ }
250
+ ),
251
+ attacker = { player: (): null => null } as unknown as Unit;
252
+
253
+ rules
254
+ .find((rule) => rule.id() === 'game:unit/detonated/destroy-units')!
255
+ .process(attacker, centre);
256
+ rules
257
+ .find((rule) => rule.id() === 'game:unit/detonated/fallout')!
258
+ .process(attacker, centre);
259
+
260
+ expect(spared).to.deep.equal([city]);
261
+ expect(destroyed).to.length(2);
262
+ // Square a has the city; of centre and b, centre draws 0.1 (polluted) and b draws 0.9 (not).
263
+ expect(
264
+ built.map((tile) => (tile as unknown as { name: string }).name)
265
+ ).to.deep.equal(['centre']);
266
+ });
267
+ });
268
+
269
+ describe('treatyPartner', (): void => {
270
+ class Road extends TileImprovement {}
271
+ class Farm extends TileImprovement {}
272
+
273
+ it('is the partner working an improved tile, for a land unit that fights', (): void => {
274
+ const me = { name: 'me' },
275
+ them = { name: 'them' },
276
+ unit = (fights: boolean, domain: typeof Unit = Land) =>
277
+ Object.assign(Object.create(domain.prototype), {
278
+ hasTrait: (): boolean => !fights,
279
+ player: () => me,
280
+ }),
281
+ partnerOf = (improvement: TileImprovement | null, atPeace = true) =>
282
+ treatyPartner([Road], {
283
+ interactionRegistry: {
284
+ getByPlayers: () => [
285
+ Object.assign(Object.create(Peace.prototype), {
286
+ active: (): boolean => atPeace,
287
+ }),
288
+ ],
289
+ } as never,
290
+ tileImprovementRegistry: {
291
+ getByTile: () => (improvement ? [improvement] : []),
292
+ } as never,
293
+ workedTileRegistry: {
294
+ getByTile: () => ({ city: () => ({ player: () => them }) }),
295
+ } as never,
296
+ });
297
+
298
+ const road = Object.create(Road.prototype),
299
+ farm = Object.create(Farm.prototype),
300
+ tile = {} as Tile;
301
+
302
+ expect(partnerOf(road)(unit(true), tile)).to.equal(them);
303
+ expect(partnerOf(farm)(unit(true), tile)).to.null;
304
+ expect(partnerOf(null)(unit(true), tile)).to.null;
305
+ expect(partnerOf(road, false)(unit(true), tile)).to.null;
306
+ expect(partnerOf(road)(unit(false), tile)).to.null;
307
+ expect(partnerOf(road)(unit(true, Air), tile)).to.null;
308
+ });
309
+ });
310
+
311
+ describe('diplomatRules', (): void => {
312
+ it("gives civ1-unit's embassy, investigation and meeting rules, which come first in its Rules/Unit/diplomat", (): void => {
313
+ const rules = diplomatRules('civ1-unit', {});
314
+
315
+ expect(
316
+ fingerprint([
317
+ ...rules.embassyEstablished,
318
+ ...rules.cityInvestigated,
319
+ ...rules.leaderMet,
320
+ ])
321
+ ).to.deep.equal(civ1['Rules/Unit/diplomat'].slice(0, 3));
322
+ });
323
+
324
+ it("registers an embassy for the Diplomat's player, and a meeting ends its turn", (): void => {
325
+ const registered: unknown[] = [],
326
+ events: string[] = [],
327
+ me = {},
328
+ them = {},
329
+ rules = diplomatRules('game', {
330
+ engine: { emit: (event: string) => events.push(event) } as never,
331
+ interactionRegistry: {
332
+ register: (interaction: unknown) => registered.push(interaction),
333
+ } as never,
334
+ });
335
+ let moves = 1;
336
+ const unit = {
337
+ player: () => me,
338
+ moves: () => ({ set: (value: number) => (moves = value) }),
339
+ };
340
+
341
+ rules.embassyEstablished[0].process(unit, { player: () => them });
342
+ rules.leaderMet[0].process(unit, them);
343
+
344
+ expect(registered).to.length(1);
345
+ const [embassy] = registered as { holder(): unknown; host(): unknown }[];
346
+
347
+ expect([embassy.holder(), embassy.host()]).to.deep.equal([me, them]);
348
+ expect(moves).to.equal(0);
349
+ expect(events).to.deep.equal([
350
+ 'player:embassy-established',
351
+ 'player:meet-with-leader',
352
+ ]);
353
+ });
354
+ });
355
+
356
+ describe('lifecycleRules, a short step of the ruleset’s own', (): void => {
357
+ it('decides a short step and prices it with `shortStep`, spending the moves', (): void => {
358
+ const calls: unknown[][] = [],
359
+ rules = lifecycleRules(
360
+ 'game',
361
+ { randomNumberGenerator: (): number => 0.25 },
362
+ {
363
+ ActiveUnit: ActiveUnit as never,
364
+ InactiveUnit: InactiveUnit as never,
365
+ removedOnActivate: [],
366
+ stowable: [],
367
+ shortStep: {
368
+ succeeds: (unit, remaining, movement, cost, rng) => {
369
+ calls.push([remaining, movement, cost, rng()]);
370
+
371
+ return remaining === movement;
372
+ },
373
+ expectedCost: (movement: number) => movement,
374
+ },
375
+ }
376
+ ),
377
+ [, short] = rules.validateMove,
378
+ [expected] = rules.expectedMovementCost;
379
+ let moves = 1;
380
+ const unit = {
381
+ moves: () => ({
382
+ value: () => moves,
383
+ set: (value: number) => (moves = value),
384
+ }),
385
+ movement: () => ({ value: () => 1 }),
386
+ };
387
+
388
+ expect(short.process(unit, 3)).to.true;
389
+ expect(moves).to.equal(0);
390
+ expect(calls).to.deep.equal([[1, 1, 3, 0.25]]);
391
+ expect(expected.process(null, 20, 3)).to.equal(3);
392
+ });
393
+ });
394
+
395
+ describe('detonatedRules, vetoed', (): void => {
396
+ it('does nothing over a square the ruleset vetoes, but still tells the client', (): void => {
397
+ let asked = 0;
398
+ const events: string[] = [],
399
+ touched: string[] = [],
400
+ rules = detonatedRules(
401
+ 'game',
402
+ {
403
+ cityRegistry: {
404
+ getByTile: () => {
405
+ touched.push('city');
406
+
407
+ return null;
408
+ },
409
+ } as never,
410
+ engine: { emit: (event: string) => events.push(event) } as never,
411
+ unitRegistry: { getByTile: () => [] } as never,
412
+ tileImprovementRegistry: { getByTile: () => [] } as never,
413
+ },
414
+ {
415
+ sparesUnits: () => false,
416
+ Pollution: Pollution as never,
417
+ falloutChance: 1,
418
+ vetoes: () => {
419
+ asked++;
420
+
421
+ return true;
422
+ },
423
+ }
424
+ ),
425
+ tile = { getNeighbours: () => [], isLand: () => true },
426
+ attacker = { player: () => null };
427
+
428
+ rules.forEach((rule) => rule.process(attacker, tile));
429
+
430
+ expect(events).to.deep.equal(['unit:nuclear-detonation']);
431
+ expect(touched).to.deep.equal([]);
432
+ expect(asked).to.equal(1);
433
+ });
434
+ });
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
+ }