@v12-engine/create 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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -2
  3. package/base/_gitignore +10 -0
  4. package/base/index.html +20 -0
  5. package/base/package.json +23 -0
  6. package/base/src/ground.worker.ts +8 -0
  7. package/base/src/main.ts +61 -0
  8. package/base/tsconfig.json +14 -0
  9. package/base/vite.config.ts +25 -0
  10. package/bin.mjs +10 -0
  11. package/cli.mjs +381 -0
  12. package/index.mjs +286 -0
  13. package/layers/assets/package.json +5 -0
  14. package/layers/editor/package.json +8 -0
  15. package/layers/editor/src/editor.ts +13 -0
  16. package/layers/rooms/server.ts +69 -0
  17. package/layers/server/package.json +8 -0
  18. package/layers/server/server.ts +34 -0
  19. package/layers/source/package.json +12 -0
  20. package/layers/source/tsconfig.json +5 -0
  21. package/layers/source/vite.config.ts +45 -0
  22. package/layers/tests/package.json +8 -0
  23. package/package.json +46 -5
  24. package/starters/campaign/README.md +61 -0
  25. package/starters/campaign/files.txt +42 -0
  26. package/starters/campaign/layers/editor/src/editor.ts +18 -0
  27. package/starters/campaign/layers/rooms/server.ts +39 -0
  28. package/starters/campaign/layers/server/server.ts +33 -0
  29. package/starters/campaign/layers/tests/tests/rules.test.ts +320 -0
  30. package/starters/campaign/public/games/campaign/assets.json +1 -0
  31. package/starters/campaign/src/campaign/campaign.json +52 -0
  32. package/starters/campaign/src/campaign/carry.ts +47 -0
  33. package/starters/campaign/src/campaign/cinematics/intro.json +148 -0
  34. package/starters/campaign/src/campaign/cinematics/outro.json +92 -0
  35. package/starters/campaign/src/campaign/data.json +1 -0
  36. package/starters/campaign/src/campaign/fx.json +1 -0
  37. package/starters/campaign/src/campaign/game.ts +6 -0
  38. package/starters/campaign/src/campaign/ids.ts +77 -0
  39. package/starters/campaign/src/campaign/index.ts +121 -0
  40. package/starters/campaign/src/campaign/kills.ts +16 -0
  41. package/starters/campaign/src/campaign/looks.json +1 -0
  42. package/starters/campaign/src/campaign/maps.ts +34 -0
  43. package/starters/campaign/src/campaign/missions/homecoming.ts +160 -0
  44. package/starters/campaign/src/campaign/missions/siege.ts +91 -0
  45. package/starters/campaign/src/campaign/missions/warlord.ts +177 -0
  46. package/starters/campaign/src/campaign/prefabs/watchpost.prefab.json +18 -0
  47. package/starters/campaign/src/campaign/scenes.ts +46 -0
  48. package/starters/campaign/src/campaign/tiles.ts +8 -0
  49. package/starters/campaign/src/campaign/units.ts +390 -0
  50. package/starters/campaign/src/campaign/view/cinematics.ts +59 -0
  51. package/starters/campaign/src/campaign/view/fire.ts +115 -0
  52. package/starters/campaign/src/campaign/view/index.ts +334 -0
  53. package/starters/campaign/src/campaign/view/labels.ts +105 -0
  54. package/starters/campaign/src/campaign/view/rubble.ts +40 -0
  55. package/starters/campaign/src/campaign/view/waveLane.ts +88 -0
  56. package/starters/campaign/src/campaign/waves.json +36 -0
  57. package/starters/campaign/src/campaign/waves.ts +186 -0
  58. package/starters/campaign/src/campaign-editor.ts +43 -0
  59. package/starters/campaign/src/game.ts +2 -0
  60. package/starters/campaign/src/main.ts +70 -0
  61. package/starters/campaign/src/maps/fort.names.ts +31 -0
  62. package/starters/campaign/src/maps/fort.vgmap.json +1162 -0
  63. package/starters/campaign/src/maps/homestead.names.ts +36 -0
  64. package/starters/campaign/src/maps/homestead.vgmap.json +916 -0
  65. package/starters/campaign/src/maps/pass.names.ts +40 -0
  66. package/starters/campaign/src/maps/pass.vgmap.json +522 -0
  67. package/starters/campaign/src/maps.ts +2 -0
  68. package/starters/campaign/src/rules.ts +20 -0
  69. package/starters/campaign/src/waves-panel.ts +355 -0
  70. package/starters/empty/README.md +71 -0
  71. package/starters/empty/layers/tests/tests/rules.test.ts +31 -0
  72. package/starters/empty/src/game.ts +79 -0
  73. package/starters/empty/src/generate.worker.ts +9 -0
  74. package/starters/empty/src/maps.ts +44 -0
  75. package/starters/empty/src/rules.ts +67 -0
  76. package/starters/empty/src/tiles.ts +12 -0
  77. package/starters/melee/README.md +61 -0
  78. package/starters/melee/files.txt +60 -0
  79. package/starters/melee/layers/editor/src/editor.ts +13 -0
  80. package/starters/melee/layers/tests/tests/rules.test.ts +58 -0
  81. package/starters/melee/src/game.ts +2 -0
  82. package/starters/melee/src/maps/bay.names.ts +28 -0
  83. package/starters/melee/src/maps/bay.vgmap.json +3123 -0
  84. package/starters/melee/src/maps.ts +20 -0
  85. package/starters/melee/src/melee/abilities.ts +376 -0
  86. package/starters/melee/src/melee/bay.ts +319 -0
  87. package/starters/melee/src/melee/blight.ts +102 -0
  88. package/starters/melee/src/melee/cellLayers.json +11 -0
  89. package/starters/melee/src/melee/data.json +18 -0
  90. package/starters/melee/src/melee/floats.json +199 -0
  91. package/starters/melee/src/melee/formations.ts +46 -0
  92. package/starters/melee/src/melee/fx.json +1 -0
  93. package/starters/melee/src/melee/game.ts +6 -0
  94. package/starters/melee/src/melee/generator.ts +116 -0
  95. package/starters/melee/src/melee/heroSpells.ts +38 -0
  96. package/starters/melee/src/melee/hud.json +269 -0
  97. package/starters/melee/src/melee/index.ts +182 -0
  98. package/starters/melee/src/melee/items.ts +362 -0
  99. package/starters/melee/src/melee/languages/he.json +96 -0
  100. package/starters/melee/src/melee/looks.json +1 -0
  101. package/starters/melee/src/melee/maps.ts +53 -0
  102. package/starters/melee/src/melee/pings.json +16 -0
  103. package/starters/melee/src/melee/pings.ts +34 -0
  104. package/starters/melee/src/melee/quality.json +1 -0
  105. package/starters/melee/src/melee/saves.ts +12 -0
  106. package/starters/melee/src/melee/sounds.json +1 -0
  107. package/starters/melee/src/melee/undead.ts +683 -0
  108. package/starters/melee/src/melee/units.ts +785 -0
  109. package/starters/melee/src/melee/view/buildings.ts +33 -0
  110. package/starters/melee/src/melee/view/cheerLane.ts +42 -0
  111. package/starters/melee/src/melee/view/heroSpellLooks.ts +21 -0
  112. package/starters/melee/src/melee/view/index.ts +268 -0
  113. package/starters/melee/src/melee/view/languages.ts +10 -0
  114. package/starters/melee/src/melee/view/looks.ts +659 -0
  115. package/starters/melee/src/melee/view/undeadLooks.ts +220 -0
  116. package/starters/melee/src/melee/view/walls.ts +59 -0
  117. package/starters/melee/src/rules.ts +24 -0
  118. package/starters.mjs +35 -0
  119. package/v12.mjs +111 -0
@@ -0,0 +1,42 @@
1
+ # The files the campaign plays with: what its page asks for. In the starter's public/, but for those @v12-engine/assets
2
+ # has: the project takes those from it (its groups' CREDITS.md say whose they are). The package's tests check that a
3
+ # project's build has every one of the shared ones
4
+ # the client fetches a game's own folder's list as it starts
5
+ games/campaign/assets.json
6
+ # the player's: peasants, footmen, archers, the knight
7
+ models/characters/barbarian.glb
8
+ models/characters/knight.glb
9
+ models/characters/ranger.glb
10
+ models/gear/axe_1handed.glb
11
+ models/gear/sword_1handed.glb
12
+ models/gear/shield_round.glb
13
+ models/gear/bow.glb
14
+ models/gear/sword_2handed.glb
15
+ # the Red Hand and the woods: raiders, warlocks, brutes, Varga, gnolls, prisoners
16
+ models/characters/mage.glb
17
+ models/characters/rogue_hooded.glb
18
+ models/characters/rogue.glb
19
+ models/gear/shield_round_barbarian.glb
20
+ models/gear/staff.glb
21
+ models/gear/axe_2handed.glb
22
+ models/gear/dagger.glb
23
+ # the buildings
24
+ models/rts/castle.glb
25
+ models/rts/small_farm.glb
26
+ models/rts/barracks.glb
27
+ models/rts/watch_tower.glb
28
+ models/rts/stone_wall.glb
29
+ models/rts/mine.glb
30
+ models/rts/castle_gate.glb
31
+ models/village/market_stand.glb
32
+ models/village/cart.glb
33
+ models/village/crate.glb
34
+ # the trees
35
+ models/forest/common_tree_1.glb
36
+ models/forest/common_tree_2.glb
37
+ models/forest/common_tree_3.glb
38
+ models/forest/common_tree_5.glb
39
+ models/forest/pine_1.glb
40
+ models/forest/pine_2.glb
41
+ models/forest/pine_3.glb
42
+ models/forest/pine_5.glb
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The map editor of the game, a scene of the app (Map editor on the start screen, or `/?editor`) that `appScenes` makes
3
+ * (`src/main.ts`) with the editor's `editorScene`: it opens on the first mission's map, as a file of the project that
4
+ * Save writes back, and Play here tries a map with its mission's rules in the page's own game. What is set here is the
5
+ * editor's own: what the campaign adds to it (`src/campaign-editor.ts`), as one extension
6
+ */
7
+ import type { AppEditorScene } from '@v12-engine/client/app'
8
+ import '@v12-engine/editor/styles.css'
9
+
10
+ // fetched as it first opens, with what the campaign adds to it: the start screen carries none of it
11
+ export const editor = async (scene: AppEditorScene) => {
12
+ const [{ editorScene }, { campaignEditor }] = await Promise.all([import('@v12-engine/editor'), import('./campaign-editor')])
13
+ return editorScene({
14
+ ...scene,
15
+ // what the campaign adds to its editor (src/campaign-editor.ts): the Waves panel, its icon, the search's entries
16
+ extensions: [campaignEditor]
17
+ })
18
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A server of many games of the game (`npm run server`), in rooms that share its port. The page finds them at
3
+ * `/?find-game` (`npm run dev` beside it): the games to join or watch, a New game on any mission's map, and Play now.
4
+ * `PORT` is the port, 8787 when not said
5
+ */
6
+ import { GameRooms, mapMode, type GameMode } from '@v12-engine/engine/server'
7
+ // @if ratings
8
+ import { createRatings } from '@v12-engine/engine/server'
9
+ // @end
10
+ import { mapNamed, maps } from './src/maps'
11
+ import { PLAYER, rules, start } from './src/rules'
12
+
13
+ /** A game on one of the missions' maps: the one seat is Aldric's, the Red Hand the mission's own computer, which `start` seats */
14
+ const onMap = (name: string): GameMode => mapMode(rules, mapNamed(name), { start, seats: [{ player: PLAYER, name: 'Aldric' }], ai: null })
15
+
16
+ // @if ratings
17
+ // a rating for each player, moved by every game that ends with a winner, kept in a file (`ratings.json`)
18
+ const ratings = createRatings({ file: 'ratings.json' })
19
+ // a file that cannot be read stops the server here, rather than being written over
20
+ await ratings.ready
21
+ // @end
22
+ const rooms = await GameRooms.start({
23
+ port: Number(process.env.PORT ?? 8787),
24
+ // a room for each map: ws://host:8787/<map>. Its game starts once Aldric's seat is taken
25
+ rooms: Object.fromEntries(Object.keys(maps).map((name) => [name, { mode: onMap(name) }])),
26
+ // what players make games of (New game), each closed once nobody has been in it for a minute; Play now is matched
27
+ // at once, a game being for one
28
+ kinds: Object.fromEntries(Object.entries(maps).map(([name, map]) => [name, { name: map.name, room: { mode: onMap(name) } }])),
29
+ // @if ratings
30
+ ratings,
31
+ // @end
32
+ log: (line) => console.log(line)
33
+ })
34
+ console.log(`The game's server is at ws://localhost:${rooms.port}: open the page at /?find-game to find a game on it`)
35
+
36
+ // stopped (Ctrl+C, or SIGTERM where it is hosted): no new games, and those running have 30 seconds to end
37
+ const stop = () => void rooms.close({ drain: 30 }).then(() => process.exit(0))
38
+ process.once('SIGINT', stop)
39
+ process.once('SIGTERM', stop)
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A server of the game (`npm run server`): each game on the first mission's map, Aldric's seat for whoever joins. The
3
+ * page joins it at `/?server` (`npm run dev` beside it). `PORT` is the port, 8787 when not said
4
+ */
5
+ import { startServer } from '@v12-engine/engine/server'
6
+ // @if ratings
7
+ import { createRatings } from '@v12-engine/engine/server'
8
+ // @end
9
+ import { mapNamed } from './src/maps'
10
+ import { PLAYER, rules, start } from './src/rules'
11
+
12
+ // @if ratings
13
+ // a rating for each player, moved by every game that ends with a winner, kept in a file (`ratings.json`)
14
+ const ratings = createRatings({ file: 'ratings.json' })
15
+ // a file that cannot be read stops the server here, rather than being written over
16
+ await ratings.ready
17
+ // @end
18
+ const server = await startServer({
19
+ game: rules,
20
+ map: mapNamed(),
21
+ start,
22
+ // the one seat is Aldric's: the Red Hand is the mission's own computer, which `start` seats
23
+ seats: [{ player: PLAYER, name: 'Aldric' }],
24
+ computer: null,
25
+ // @if ratings
26
+ ratings,
27
+ // @end
28
+ port: Number(process.env.PORT ?? 8787)
29
+ })
30
+ console.log(`The game's server is at ws://localhost:${server.port}: open the page at /?server to play on it`)
31
+
32
+ // stopped (Ctrl+C), it closes its connections first
33
+ process.once('SIGINT', () => void server.close().then(() => process.exit(0)))
@@ -0,0 +1,320 @@
1
+ /** The rules, tested with no page: a world steps here as it does in a page (`@v12-engine/engine/testing`) */
2
+ import {
3
+ carryIn,
4
+ carryOut,
5
+ checkMap,
6
+ checkRules,
7
+ createRegistry,
8
+ createWorldFromMap,
9
+ type HeroRecord,
10
+ isGateOpen,
11
+ learn,
12
+ MAP_NEUTRAL,
13
+ missionWon,
14
+ newCampaignProgress,
15
+ readCampaignProgress,
16
+ type World,
17
+ heroOf
18
+ } from '@v12-engine/engine'
19
+ import { checkDeterminism, order, run, simulate, testWorld } from '@v12-engine/engine/testing'
20
+ import { describe, expect, it } from 'vitest'
21
+ import { homecomingCarry, type RoadData, siegeCarriedIn, warlordCarry } from '../src/campaign/carry'
22
+ import { kills } from '../src/campaign/kills'
23
+ import { builtWaves, FIRST_WAIT, readWaves, siegeWaves, wavesOf } from '../src/campaign/waves'
24
+ import { warlord } from '../src/campaign/missions/warlord'
25
+ import { mapNamed, maps } from '../src/maps'
26
+ import { ENEMY, PLAYER, rules, start, UNIT } from '../src/rules'
27
+ import wavesFile from '../src/campaign/waves.json'
28
+
29
+ const registry = createRegistry(rules)
30
+
31
+ /** A mission's world, started as the page starts it */
32
+ async function mission(name: string): Promise<World> {
33
+ const map = mapNamed(name)
34
+ const world = await createWorldFromMap({ map, game: rules, seed: 1 })
35
+ start(world, { map })
36
+ return world
37
+ }
38
+
39
+ /** Steps `seconds` of the game */
40
+ function play(world: World, seconds: number): void {
41
+ for (let i = 0; i < seconds * world.tickRate; i++) world.step()
42
+ }
43
+
44
+ const standing = (world: World, owner: number, type: string) =>
45
+ [...world.index.ownedBy(owner)].filter((entity) => entity.alive && entity.def.id === type)
46
+
47
+ describe('the maps', () => {
48
+ it.each(Object.keys(maps))('%s has nothing wrong with it', (name) => {
49
+ const problems = checkMap(mapNamed(name), registry).filter((problem) => problem.error)
50
+ expect(problems).toEqual([])
51
+ })
52
+ })
53
+
54
+ describe('mission one, Homecoming', () => {
55
+ it('gives the objectives, puts up the outpost from its prefab, and seats the Red Hand', async () => {
56
+ const world = await mission('homestead')
57
+ expect(world.objectives.get(PLAYER, 'campaign.raze').progress?.max).toBeGreaterThan(2)
58
+ expect(world.objectives.get(PLAYER, 'campaign.caravan').status).toBe('open')
59
+ // the outpost: a tower and two walls where the map's point is, besides what the map places
60
+ const outpost = world.map.point('outpost')
61
+ const towers = standing(world, PLAYER, UNIT.tower).filter((tower) => Math.hypot(tower.pos.x - outpost.x, tower.pos.z - outpost.z) < 2)
62
+ expect(towers).toHaveLength(1)
63
+ expect(world.computers.get(ENEMY)).toBeDefined()
64
+ world.dispose()
65
+ })
66
+
67
+ it('is won by burning every building of the camp, and lost when the hall falls', async () => {
68
+ const won = await mission('homestead')
69
+ play(won, 1)
70
+ for (const entity of [...won.index.ownedBy(ENEMY)]) if (entity.def.kind === 'building') won.kill(entity)
71
+ play(won, 1)
72
+ expect(won.objectives.get(PLAYER, 'campaign.raze').status).toBe('done')
73
+ expect(won.winner).toBe(won.player(PLAYER).team)
74
+ won.dispose()
75
+
76
+ const lost = await mission('homestead')
77
+ for (const hall of standing(lost, PLAYER, UNIT.hall)) lost.kill(hall)
78
+ play(lost, 1)
79
+ expect(lost.objectives.get(PLAYER, 'campaign.hall').status).toBe('failed')
80
+ expect(lost.winner).not.toBe(lost.player(PLAYER).team)
81
+ lost.dispose()
82
+ })
83
+
84
+ it('pays for the caravan when one of the player’s walks into its region', async () => {
85
+ const world = await mission('homestead')
86
+ const caravan = world.map.regions.find((region) => region.name === 'caravan')!.shape
87
+ const gold = world.player(PLAYER).resources.gold ?? 0
88
+ world.spawn(UNIT.footman, PLAYER, { x: caravan.x, z: caravan.z })
89
+ play(world, 1)
90
+ expect(world.objectives.get(PLAYER, 'campaign.caravan').status).toBe('done')
91
+ expect(world.player(PLAYER).resources.gold).toBe(gold + 200)
92
+ world.dispose()
93
+ })
94
+
95
+ it('plays, the Red Hand’s computer thinking, with no error for a minute (simulate)', async () => {
96
+ const map = mapNamed('homestead')
97
+ // the mission seats the Red Hand's computer itself (`start`): simulate is told of none
98
+ const [game] = await simulate({ game: rules, map, computers: [], seconds: 60, start: (world) => start(world, { map }) }, { seeds: [1] })
99
+ expect(game.failed).toBeUndefined()
100
+ expect(game.ticks).toBeGreaterThan(0)
101
+ })
102
+ })
103
+
104
+ describe('mission two, The Siege', () => {
105
+ it('plays the waves of waves.json, what the editor’s Waves panel writes', () => {
106
+ expect(builtWaves()).toEqual(readWaves(wavesFile))
107
+ expect(builtWaves().length).toBeGreaterThanOrEqual(3)
108
+ expect(readWaves([{ units: [{ type: 'raider', count: 0 }] }, { units: [{ type: 'brute', count: 2 }], interval: -1 }, 'nothing'])).toEqual([
109
+ { units: [{ type: 'brute', count: 2 }], interval: 1, bonus: 0 }
110
+ ])
111
+ })
112
+
113
+ it('names its waves’ unit types to the editor, where Used by and Find references list them (ED2)', () => {
114
+ const registry = createRegistry(rules)
115
+ const asked = builtWaves()[0].units[0].type
116
+ const uses = rules.editor!.references!({ type: asked }, Object.values(maps)[0], registry)
117
+ expect(uses.length).toBeGreaterThan(0)
118
+ expect(uses.every((use) => use.where === 'waves.json' && /^wave \d+$/.test(use.what))).toBe(true)
119
+ expect(rules.editor!.references!({ type: 'nobody sends this' }, Object.values(maps)[0], registry)).toEqual([])
120
+ })
121
+
122
+ it('sends the first wave out of the war camp after a while, and shuts the gate as it comes', async () => {
123
+ const world = await mission('fort')
124
+ expect(isGateOpen(world, 'gate')).toBe(true)
125
+ expect(wavesOf(world).phase).toBe('waiting')
126
+ play(world, FIRST_WAIT + 2)
127
+ expect(wavesOf(world).phase).not.toBe('waiting')
128
+ expect(standing(world, ENEMY, UNIT.raider).length).toBeGreaterThan(0)
129
+ expect(isGateOpen(world, 'gate')).toBe(false)
130
+ world.dispose()
131
+ })
132
+
133
+ it('counts what each tower kills, for the view to pin over it (kills, sent to everyone)', async () => {
134
+ const world = await mission('fort')
135
+ play(world, FIRST_WAIT + 4)
136
+ const [tower] = standing(world, PLAYER, UNIT.tower)
137
+ const [first, second] = standing(world, ENEMY, UNIT.raider)
138
+ world.kill(first, tower)
139
+ expect(tower.get(kills)).toBe(1)
140
+ // one that falls to something else is not the tower's
141
+ world.kill(second)
142
+ expect(tower.get(kills)).toBe(1)
143
+ world.dispose()
144
+ })
145
+
146
+ it('is won once every wave is beaten', async () => {
147
+ const world = await mission('fort')
148
+ play(world, FIRST_WAIT + 2)
149
+ for (let wave = 0; wave < siegeWaves(world).length; wave++) {
150
+ // every raider of the wave down
151
+ play(world, 20)
152
+ for (const raider of [...world.index.ownedBy(ENEMY)]) if (raider.def.kind === 'unit') world.kill(raider)
153
+ play(world, 1)
154
+ if (wave < siegeWaves(world).length - 1) {
155
+ expect(isGateOpen(world, 'gate')).toBe(true)
156
+ play(world, 30)
157
+ }
158
+ }
159
+ expect(world.objectives.get(PLAYER, 'campaign.hold').status).toBe('done')
160
+ expect(world.winner).toBe(world.player(PLAYER).team)
161
+ world.dispose()
162
+ })
163
+ })
164
+
165
+ describe('what goes on to the next mission', () => {
166
+ it('takes the footmen who came through Homecoming, three at most, and the caravan, into the Siege', async () => {
167
+ const home = await mission('homestead')
168
+ // four footmen at the end, one of them hurt: the three healthiest go on
169
+ const footmen = standing(home, PLAYER, UNIT.footman)
170
+ const more = [home.spawn(UNIT.footman, PLAYER, { x: 20, z: 20 }), home.spawn(UNIT.footman, PLAYER, { x: 22, z: 20 })]
171
+ const hurt = more[0]
172
+ hurt.hp = 10
173
+ home.objectives.complete(PLAYER, 'campaign.caravan')
174
+ const carried = homecomingCarry(home)
175
+ expect(footmen.length + more.length).toBe(4)
176
+ expect(carried.footmen).toHaveLength(3)
177
+ expect(carried.footmen.every((record) => record.type === UNIT.footman && record.health === 1)).toBe(true)
178
+ expect(carried.caravan).toBe(true)
179
+ // kept as the campaign keeps it, and read back
180
+ const won = missionWon(newCampaignProgress<RoadData>('campaign', 'homecoming'), 'homecoming', 600, carryOut(home, PLAYER), 'siege', carried)
181
+ const kept = readCampaignProgress(JSON.parse(JSON.stringify(won)))
182
+ home.dispose()
183
+
184
+ const map = mapNamed('fort')
185
+ const fort = await createWorldFromMap({ map, game: rules, seed: 1 })
186
+ start(fort, { map })
187
+ const before = { footmen: standing(fort, PLAYER, UNIT.footman).length, gold: fort.player(PLAYER).resources.gold ?? 0 }
188
+ siegeCarriedIn(fort, map, kept.data as Partial<RoadData>)
189
+ expect(standing(fort, PLAYER, UNIT.footman)).toHaveLength(before.footmen + 3)
190
+ expect(fort.player(PLAYER).resources.gold).toBe(before.gold + 100)
191
+ fort.dispose()
192
+ })
193
+ })
194
+
195
+ describe('mission three, The Warlord', () => {
196
+ /** Aldric as he might come out of the siege: level 3, Holy Light learned twice, a sword in his bag */
197
+ const seasoned: HeroRecord = {
198
+ type: UNIT.knight,
199
+ name: 'hero',
200
+ level: 3,
201
+ xp: 0,
202
+ points: 0,
203
+ abilities: { holyLight: 2 },
204
+ items: [{ item: 'steelSword', charges: 0 }, null, null, null, null, null]
205
+ }
206
+
207
+ it('brings the carried hero in where the map has one, with what he earned', async () => {
208
+ const map = mapNamed('pass')
209
+ const world = await createWorldFromMap({ map, game: rules, seed: 1 })
210
+ const [hero] = carryIn(world, [seasoned], PLAYER, map.players[0].start!)
211
+ start(world, { map })
212
+ expect(heroOf(hero)?.level).toBe(3)
213
+ expect(hero.abilities.find((ability) => ability.id === 'holyLight')?.level).toBe(2)
214
+ expect(standing(world, PLAYER, UNIT.knight)).toHaveLength(1)
215
+ world.dispose()
216
+ })
217
+
218
+ it('is harder on hard and easier on easy, its computer seated on the campaign’s difficulty', async () => {
219
+ const on = async (difficulty: string) => {
220
+ const map = mapNamed('pass')
221
+ const world = await createWorldFromMap({ map, game: rules, seed: 1 })
222
+ // what the campaign's scene does before a mission seats its computers
223
+ world.computers.defaults = { difficulty }
224
+ warlord(world, true, difficulty)
225
+ return world
226
+ }
227
+ const hard = await on('hard')
228
+ expect(standing(hard, ENEMY, UNIT.brute).some((brute) => hard.nameOf(brute) === 'guard4')).toBe(true)
229
+ expect((hard.computers.get(ENEMY)?.computer as { profile?: { difficulty?: string } }).profile?.difficulty).toBe('hard')
230
+ const easy = await on('easy')
231
+ const varga = easy.named('warlord')!
232
+ expect(varga.hp).toBe(Math.round(varga.maxHp * 0.75))
233
+ expect(easy.named('guard4')).toBeUndefined()
234
+ hard.dispose()
235
+ easy.dispose()
236
+ })
237
+
238
+ it('lets the player choose a duel: the guards stand down, Varga is rallied, and the campaign keeps the answer', async () => {
239
+ const world = await mission('pass')
240
+ play(world, 0.5)
241
+ expect(warlordCarry(world)).toEqual({})
242
+ world.issue({ type: 'cinematicChoice', player: PLAYER, cinematic: 'campaign.parley', question: 'How does this end?', choice: 'duel' })
243
+ play(world, 0.5)
244
+ for (const name of ['guard1', 'guard2', 'guard3']) expect(world.named(name)?.owner).toBe(MAP_NEUTRAL)
245
+ expect(world.named('warlord')?.buffs.some((buff) => buff.id === 'rallied')).toBe(true)
246
+ expect(warlordCarry(world)).toEqual({ parley: 'duel' })
247
+ world.dispose()
248
+ })
249
+
250
+ it('frees the prisoners when the hero reaches the cells, and is won when Varga falls, lost when Aldric does', async () => {
251
+ const freed = await mission('pass')
252
+ const cells = freed.map.regions.find((region) => region.name === 'cells')!.shape
253
+ freed.spawn(UNIT.footman, PLAYER, { x: cells.x, z: cells.z })
254
+ play(freed, 1)
255
+ expect(freed.objectives.get(PLAYER, 'campaign.prisoners').status).toBe('done')
256
+ expect(standing(freed, PLAYER, UNIT.prisoner).length).toBeGreaterThan(0)
257
+ freed.kill(freed.named('warlord')!)
258
+ // the outro plays for the player first; nobody watches here, so it ends at once
259
+ play(freed, 3)
260
+ expect(freed.objectives.get(PLAYER, 'campaign.warlord').status).toBe('done')
261
+ expect(freed.winner).toBe(freed.player(PLAYER).team)
262
+ freed.dispose()
263
+
264
+ const lost = await mission('pass')
265
+ lost.kill(lost.named('hero')!)
266
+ play(lost, 1)
267
+ expect(lost.objectives.get(PLAYER, 'campaign.hero').status).toBe('failed')
268
+ expect(lost.winner).not.toBe(lost.player(PLAYER).team)
269
+ lost.dispose()
270
+ })
271
+ })
272
+
273
+ describe('the hero', () => {
274
+ it('heals a friend with Holy Light once he has learned it', async () => {
275
+ const world = await testWorld(rules)
276
+ const hero = world.spawn(UNIT.knight, PLAYER, { x: 20, z: 20 })
277
+ const footman = world.spawn(UNIT.footman, PLAYER, { x: 22, z: 20 })
278
+ learn(world, hero, 'holyLight')
279
+ footman.hp = 100
280
+ order(world, [hero], { type: 'cast', ability: 'holyLight', target: footman.id })
281
+ run(world, 3, () => footman.hp > 200)
282
+ expect(footman.hp).toBeGreaterThan(200)
283
+ world.dispose()
284
+ })
285
+
286
+ it('strikes with Judgement, an effect of the game’s own, and heals for what it took', async () => {
287
+ const world = await testWorld(rules)
288
+ const hero = world.spawn(UNIT.knight, PLAYER, { x: 20, z: 20 })
289
+ // something of the enemy's that does not hit back: what the strike took is then the strike's alone
290
+ const captive = world.spawn(UNIT.prisoner, ENEMY, { x: 23, z: 20 })
291
+ learn(world, hero, 'judgement')
292
+ hero.hp = 300
293
+ // a tick first: an enemy is a target once it is seen, and the fog is worked out as the game steps
294
+ run(world, 0.1)
295
+ order(world, [hero], { type: 'cast', ability: 'judgement', target: captive.id })
296
+ run(world, 3, () => captive.hp <= captive.maxHp - 120)
297
+ expect(captive.maxHp - captive.hp).toBeGreaterThanOrEqual(120)
298
+ expect(hero.hp).toBeGreaterThanOrEqual(300 + 120 - 1)
299
+ world.dispose()
300
+ })
301
+ })
302
+
303
+ describe('the rules', () => {
304
+ it('fit together: every id they name is theirs, every effect has a handler (checkRules)', () => {
305
+ expect(checkRules(rules).filter((problem) => problem.level === 'error')).toEqual([])
306
+ })
307
+ })
308
+
309
+ describe('each mission', () => {
310
+ // the same game every time it is played from its start, and from a save of it: what a replay, a save that goes on
311
+ // and a game over the wire stand on. A rule that reads Math.random or the clock is found here, with its tick
312
+ it.each(Object.keys(maps))(
313
+ '%s comes out the same, played twice and on from a save',
314
+ async (name) => {
315
+ const report = await checkDeterminism(() => mission(name), { ticks: 600, from: 150, game: rules })
316
+ expect(report).toEqual({ twice: null, fromSave: null })
317
+ },
318
+ 120_000
319
+ )
320
+ })
@@ -0,0 +1,52 @@
1
+ {
2
+ "id": "campaign",
3
+ "name": "The Long Road",
4
+ "menu": {
5
+ "title": "The Long Road",
6
+ "subtitle": "A campaign in three missions",
7
+ "background": "radial-gradient(ellipse at 50% 30%, #2b3346 0%, #14161c 70%)"
8
+ },
9
+ "chapters": [
10
+ {
11
+ "id": "road",
12
+ "name": "I. The Way Home",
13
+ "missions": ["homecoming"]
14
+ },
15
+ {
16
+ "id": "fort",
17
+ "name": "II. The Fort",
18
+ "missions": ["siege", "warlord"]
19
+ }
20
+ ],
21
+ "missions": [
22
+ {
23
+ "id": "homecoming",
24
+ "name": "Homecoming",
25
+ "map": "homestead",
26
+ "description": "Through the woods, and burn the camp of the Red Hand beyond them",
27
+ "briefing": {
28
+ "text": "Aldric is home from the war, and the Red Hand burns the farms of the valley. Take what men are left at the homestead through the woods, find the caravan the raiders took, and put their camp to the torch.",
29
+ "objectives": ["Burn the camp of the Red Hand", "Find the caravan (if you can)", "Aldric must live"]
30
+ },
31
+ "debrief": {
32
+ "text": "The camp burns. The valley breathes again, for a night: the Red Hand has a fort in mind."
33
+ }
34
+ },
35
+ {
36
+ "id": "siege",
37
+ "name": "The Siege",
38
+ "map": "fort",
39
+ "description": "The Red Hand comes for the fort at night. Hold for every wave",
40
+ "briefing": {
41
+ "text": "Scouts saw torches on the ridge. The Red Hand comes for the fort at nightfall, wave on wave. Man the walls.",
42
+ "objectives": ["Hold the fort until the last wave breaks"]
43
+ }
44
+ },
45
+ {
46
+ "id": "warlord",
47
+ "name": "The Warlord",
48
+ "map": "pass",
49
+ "description": "Up the pass to Varga’s hold. Do not lose Aldric"
50
+ }
51
+ ]
52
+ }
@@ -0,0 +1,47 @@
1
+ import { carryUnits, giveResources, type GameMap, placeUnits, type UnitRecord, type World } from '@v12-engine/engine'
2
+ import { PLAYER, UNIT } from './ids'
3
+ import { findCaravan } from './missions/homecoming'
4
+ import { parley } from './missions/warlord'
5
+
6
+ /**
7
+ * What the campaign carries from one mission to the next besides Aldric (the heroes are carried as they are): its own
8
+ * data, of this type (`CampaignDef<RoadData>`), each mission won giving its part (`MissionDef.carry`) and the next
9
+ * reading it as it starts (`context.progress.data`)
10
+ */
11
+ export interface RoadData {
12
+ /** The footmen who came through Homecoming, three at the most: they man the fort in the Siege */
13
+ footmen: UnitRecord[]
14
+ /** Whether the lost caravan was found in Homecoming: the fort's quartermaster remembers it */
15
+ caravan: boolean
16
+ /** What Aldric answered at Varga's gate: a duel, or a charge */
17
+ parley: 'duel' | 'charge'
18
+ }
19
+
20
+ /** How many of the footmen go on to the fort */
21
+ const FOOTMEN_CARRIED = 3
22
+ /** What the quartermaster gives at the fort for the caravan found */
23
+ const CARAVAN_THANKS = 100
24
+
25
+ /** What Homecoming carries on: the footmen who lived through it (the healthiest first, three), and the caravan found or not */
26
+ export function homecomingCarry(world: World): Pick<RoadData, 'footmen' | 'caravan'> {
27
+ const footmen = carryUnits(world, PLAYER, (unit) => unit.def.id === UNIT.footman)
28
+ .sort((a, b) => b.health - a.health)
29
+ .slice(0, FOOTMEN_CARRIED)
30
+ return { footmen, caravan: world.objectives.has(PLAYER, findCaravan.id) && world.objectives.get(PLAYER, findCaravan.id).status === 'done' }
31
+ }
32
+
33
+ /**
34
+ * The Siege, given what came before: the footmen of Homecoming where the player starts, and the quartermaster's
35
+ * thanks for the caravan. For a fresh game of it: a game loaded from a save has them already
36
+ */
37
+ export function siegeCarriedIn(world: World, map: GameMap, data: Partial<RoadData>): void {
38
+ const start = map.players.find((player) => player.id === PLAYER)?.start ?? { x: map.width / 2, z: map.depth / 2 }
39
+ if (data.footmen?.length) placeUnits(world, PLAYER, data.footmen, start)
40
+ if (data.caravan) giveResources(world, PLAYER, { gold: CARAVAN_THANKS })
41
+ }
42
+
43
+ /** What The Warlord carries on: the answer at the gate, where one was given (the last mission: the campaign keeps it) */
44
+ export function warlordCarry(world: World): Partial<Pick<RoadData, 'parley'>> {
45
+ const { choice } = world.state(parley)
46
+ return choice ? { parley: choice } : {}
47
+ }