@v12-engine/create 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.
- package/LICENSE +21 -0
- package/README.md +26 -2
- package/base/_gitignore +10 -0
- package/base/index.html +20 -0
- package/base/package.json +23 -0
- package/base/src/ground.worker.ts +8 -0
- package/base/src/main.ts +61 -0
- package/base/tsconfig.json +14 -0
- package/base/vite.config.ts +25 -0
- package/bin.mjs +10 -0
- package/cli.mjs +381 -0
- package/index.mjs +286 -0
- package/layers/assets/package.json +5 -0
- package/layers/editor/package.json +8 -0
- package/layers/editor/src/editor.ts +13 -0
- package/layers/rooms/server.ts +69 -0
- package/layers/server/package.json +8 -0
- package/layers/server/server.ts +34 -0
- package/layers/source/package.json +12 -0
- package/layers/source/tsconfig.json +5 -0
- package/layers/source/vite.config.ts +45 -0
- package/layers/tests/package.json +8 -0
- package/package.json +45 -5
- package/starters/campaign/README.md +61 -0
- package/starters/campaign/files.txt +42 -0
- package/starters/campaign/layers/editor/src/editor.ts +18 -0
- package/starters/campaign/layers/rooms/server.ts +39 -0
- package/starters/campaign/layers/server/server.ts +33 -0
- package/starters/campaign/layers/tests/tests/rules.test.ts +320 -0
- package/starters/campaign/public/games/campaign/assets.json +1 -0
- package/starters/campaign/src/campaign/campaign.json +52 -0
- package/starters/campaign/src/campaign/carry.ts +47 -0
- package/starters/campaign/src/campaign/cinematics/intro.json +148 -0
- package/starters/campaign/src/campaign/cinematics/outro.json +92 -0
- package/starters/campaign/src/campaign/data.json +1 -0
- package/starters/campaign/src/campaign/fx.json +1 -0
- package/starters/campaign/src/campaign/game.ts +6 -0
- package/starters/campaign/src/campaign/ids.ts +77 -0
- package/starters/campaign/src/campaign/index.ts +121 -0
- package/starters/campaign/src/campaign/kills.ts +16 -0
- package/starters/campaign/src/campaign/looks.json +1 -0
- package/starters/campaign/src/campaign/maps.ts +34 -0
- package/starters/campaign/src/campaign/missions/homecoming.ts +160 -0
- package/starters/campaign/src/campaign/missions/siege.ts +91 -0
- package/starters/campaign/src/campaign/missions/warlord.ts +177 -0
- package/starters/campaign/src/campaign/prefabs/watchpost.prefab.json +18 -0
- package/starters/campaign/src/campaign/scenes.ts +46 -0
- package/starters/campaign/src/campaign/tiles.ts +8 -0
- package/starters/campaign/src/campaign/units.ts +390 -0
- package/starters/campaign/src/campaign/view/cinematics.ts +59 -0
- package/starters/campaign/src/campaign/view/fire.ts +115 -0
- package/starters/campaign/src/campaign/view/index.ts +334 -0
- package/starters/campaign/src/campaign/view/labels.ts +105 -0
- package/starters/campaign/src/campaign/view/rubble.ts +40 -0
- package/starters/campaign/src/campaign/view/waveLane.ts +88 -0
- package/starters/campaign/src/campaign/waves.json +36 -0
- package/starters/campaign/src/campaign/waves.ts +186 -0
- package/starters/campaign/src/campaign-editor.ts +43 -0
- package/starters/campaign/src/game.ts +2 -0
- package/starters/campaign/src/main.ts +70 -0
- package/starters/campaign/src/maps/fort.names.ts +31 -0
- package/starters/campaign/src/maps/fort.vgmap.json +1162 -0
- package/starters/campaign/src/maps/homestead.names.ts +36 -0
- package/starters/campaign/src/maps/homestead.vgmap.json +916 -0
- package/starters/campaign/src/maps/pass.names.ts +40 -0
- package/starters/campaign/src/maps/pass.vgmap.json +522 -0
- package/starters/campaign/src/maps.ts +2 -0
- package/starters/campaign/src/rules.ts +20 -0
- package/starters/campaign/src/waves-panel.ts +355 -0
- package/starters/empty/README.md +71 -0
- package/starters/empty/layers/tests/tests/rules.test.ts +31 -0
- package/starters/empty/src/game.ts +79 -0
- package/starters/empty/src/generate.worker.ts +9 -0
- package/starters/empty/src/maps.ts +44 -0
- package/starters/empty/src/rules.ts +67 -0
- package/starters/empty/src/tiles.ts +12 -0
- package/starters/melee/README.md +61 -0
- package/starters/melee/files.txt +60 -0
- package/starters/melee/layers/editor/src/editor.ts +13 -0
- package/starters/melee/layers/tests/tests/rules.test.ts +58 -0
- package/starters/melee/src/game.ts +2 -0
- package/starters/melee/src/maps/bay.names.ts +28 -0
- package/starters/melee/src/maps/bay.vgmap.json +3123 -0
- package/starters/melee/src/maps.ts +20 -0
- package/starters/melee/src/melee/abilities.ts +376 -0
- package/starters/melee/src/melee/bay.ts +319 -0
- package/starters/melee/src/melee/blight.ts +102 -0
- package/starters/melee/src/melee/cellLayers.json +11 -0
- package/starters/melee/src/melee/data.json +18 -0
- package/starters/melee/src/melee/floats.json +199 -0
- package/starters/melee/src/melee/formations.ts +46 -0
- package/starters/melee/src/melee/fx.json +1 -0
- package/starters/melee/src/melee/game.ts +6 -0
- package/starters/melee/src/melee/generator.ts +116 -0
- package/starters/melee/src/melee/heroSpells.ts +38 -0
- package/starters/melee/src/melee/hud.json +269 -0
- package/starters/melee/src/melee/index.ts +182 -0
- package/starters/melee/src/melee/items.ts +362 -0
- package/starters/melee/src/melee/languages/he.json +96 -0
- package/starters/melee/src/melee/looks.json +1 -0
- package/starters/melee/src/melee/maps.ts +53 -0
- package/starters/melee/src/melee/pings.json +16 -0
- package/starters/melee/src/melee/pings.ts +34 -0
- package/starters/melee/src/melee/quality.json +1 -0
- package/starters/melee/src/melee/saves.ts +12 -0
- package/starters/melee/src/melee/sounds.json +1 -0
- package/starters/melee/src/melee/undead.ts +683 -0
- package/starters/melee/src/melee/units.ts +785 -0
- package/starters/melee/src/melee/view/buildings.ts +33 -0
- package/starters/melee/src/melee/view/cheerLane.ts +42 -0
- package/starters/melee/src/melee/view/heroSpellLooks.ts +21 -0
- package/starters/melee/src/melee/view/index.ts +268 -0
- package/starters/melee/src/melee/view/languages.ts +10 -0
- package/starters/melee/src/melee/view/looks.ts +659 -0
- package/starters/melee/src/melee/view/undeadLooks.ts +220 -0
- package/starters/melee/src/melee/view/walls.ts +59 -0
- package/starters/melee/src/rules.ts +24 -0
- package/starters.mjs +35 -0
- 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 @@
|
|
|
1
|
+
{}
|
|
@@ -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
|
+
}
|