@netforge/engine 0.0.0-stage → 0.2.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/GAME-DESIGN.md +195 -0
- package/LICENSE +21 -0
- package/NOTICE +30 -0
- package/README.md +100 -2
- package/dist/api/appearance.d.ts +37 -0
- package/dist/api/appearance.js +41 -0
- package/dist/api/cards.d.ts +105 -0
- package/dist/api/cards.js +133 -0
- package/dist/api/collector.d.ts +114 -0
- package/dist/api/collector.js +204 -0
- package/dist/api/dungeon.d.ts +58 -0
- package/dist/api/dungeon.js +216 -0
- package/dist/api/harness.d.ts +149 -0
- package/dist/api/harness.js +549 -0
- package/dist/api/idle.d.ts +71 -0
- package/dist/api/idle.js +104 -0
- package/dist/api/race-harness.d.ts +3 -0
- package/dist/api/race-harness.js +148 -0
- package/dist/api/registry.d.ts +241 -0
- package/dist/api/registry.js +394 -0
- package/dist/api/scheduler.d.ts +56 -0
- package/dist/api/scheduler.js +160 -0
- package/dist/api/schemas.d.ts +958 -0
- package/dist/api/schemas.js +859 -0
- package/dist/api/tower.d.ts +127 -0
- package/dist/api/tower.js +183 -0
- package/dist/game/adapter.d.ts +69 -0
- package/dist/game/adapter.js +341 -0
- package/dist/game/arena-bot.d.ts +66 -0
- package/dist/game/arena-bot.js +188 -0
- package/dist/game/bot.d.ts +95 -0
- package/dist/game/bot.js +285 -0
- package/dist/game/compile.d.ts +97 -0
- package/dist/game/compile.js +476 -0
- package/dist/game/director.d.ts +42 -0
- package/dist/game/director.js +179 -0
- package/dist/game/earn-source.d.ts +122 -0
- package/dist/game/earn-source.js +386 -0
- package/dist/game/earn.d.ts +49 -0
- package/dist/game/earn.js +55 -0
- package/dist/game/index.d.ts +37 -0
- package/dist/game/index.js +25 -0
- package/dist/game/platformer.d.ts +156 -0
- package/dist/game/platformer.js +619 -0
- package/dist/game/profile.d.ts +97 -0
- package/dist/game/profile.js +140 -0
- package/dist/game/race.d.ts +177 -0
- package/dist/game/race.js +386 -0
- package/dist/game/racer.d.ts +98 -0
- package/dist/game/racer.js +379 -0
- package/dist/game/runner.d.ts +286 -0
- package/dist/game/runner.js +824 -0
- package/dist/game/threats.d.ts +23 -0
- package/dist/game/threats.js +49 -0
- package/dist/game/view.d.ts +47 -0
- package/dist/game/view.js +123 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +24 -0
- package/dist/net/bits.d.ts +33 -0
- package/dist/net/bits.js +135 -0
- package/dist/net/client.d.ts +75 -0
- package/dist/net/client.js +151 -0
- package/dist/net/index.d.ts +23 -0
- package/dist/net/index.js +23 -0
- package/dist/net/input.d.ts +49 -0
- package/dist/net/input.js +112 -0
- package/dist/net/interpolation.d.ts +37 -0
- package/dist/net/interpolation.js +97 -0
- package/dist/net/netsim.d.ts +22 -0
- package/dist/net/netsim.js +52 -0
- package/dist/net/prediction.d.ts +31 -0
- package/dist/net/prediction.js +101 -0
- package/dist/net/profile-client.d.ts +75 -0
- package/dist/net/profile-client.js +173 -0
- package/dist/net/protocol.d.ts +215 -0
- package/dist/net/protocol.js +10 -0
- package/dist/net/room-client.d.ts +65 -0
- package/dist/net/room-client.js +156 -0
- package/dist/net/schema.d.ts +78 -0
- package/dist/net/schema.js +121 -0
- package/dist/net/sim-adapter.d.ts +134 -0
- package/dist/net/sim-adapter.js +22 -0
- package/dist/net/snapshot.d.ts +61 -0
- package/dist/net/snapshot.js +301 -0
- package/dist/rules/adapter.d.ts +53 -0
- package/dist/rules/adapter.js +261 -0
- package/dist/rules/cards.d.ts +115 -0
- package/dist/rules/cards.js +547 -0
- package/dist/rules/collector.d.ts +161 -0
- package/dist/rules/collector.js +732 -0
- package/dist/rules/genres.d.ts +13 -0
- package/dist/rules/genres.js +38 -0
- package/dist/rules/harness.d.ts +16 -0
- package/dist/rules/harness.js +127 -0
- package/dist/rules/idle.d.ts +115 -0
- package/dist/rules/idle.js +370 -0
- package/dist/rules/index.d.ts +16 -0
- package/dist/rules/index.js +14 -0
- package/dist/rules/kit.d.ts +171 -0
- package/dist/rules/kit.js +88 -0
- package/dist/rules/tower.d.ts +214 -0
- package/dist/rules/tower.js +655 -0
- package/dist/runtime/arena.d.ts +86 -0
- package/dist/runtime/arena.js +269 -0
- package/dist/runtime/assets.d.ts +26 -0
- package/dist/runtime/assets.js +35 -0
- package/dist/runtime/camera.d.ts +113 -0
- package/dist/runtime/camera.js +247 -0
- package/dist/runtime/crowd.d.ts +18 -0
- package/dist/runtime/crowd.js +62 -0
- package/dist/runtime/feel/audio.d.ts +150 -0
- package/dist/runtime/feel/audio.js +327 -0
- package/dist/runtime/feel/clock.d.ts +52 -0
- package/dist/runtime/feel/clock.js +80 -0
- package/dist/runtime/feel/flash.d.ts +28 -0
- package/dist/runtime/feel/flash.js +60 -0
- package/dist/runtime/feel/index.d.ts +27 -0
- package/dist/runtime/feel/index.js +16 -0
- package/dist/runtime/feel/kit.d.ts +159 -0
- package/dist/runtime/feel/kit.js +226 -0
- package/dist/runtime/feel/knockback.d.ts +41 -0
- package/dist/runtime/feel/knockback.js +70 -0
- package/dist/runtime/feel/noise.d.ts +6 -0
- package/dist/runtime/feel/noise.js +30 -0
- package/dist/runtime/feel/numbers.d.ts +91 -0
- package/dist/runtime/feel/numbers.js +172 -0
- package/dist/runtime/feel/rumble.d.ts +40 -0
- package/dist/runtime/feel/rumble.js +58 -0
- package/dist/runtime/feel/sound.d.ts +96 -0
- package/dist/runtime/feel/sound.js +176 -0
- package/dist/runtime/feel/spring.d.ts +42 -0
- package/dist/runtime/feel/spring.js +95 -0
- package/dist/runtime/feel/trauma.d.ts +48 -0
- package/dist/runtime/feel/trauma.js +76 -0
- package/dist/runtime/feel/tween.d.ts +66 -0
- package/dist/runtime/feel/tween.js +180 -0
- package/dist/runtime/feel/types.d.ts +30 -0
- package/dist/runtime/feel/types.js +5 -0
- package/dist/runtime/index.d.ts +28 -0
- package/dist/runtime/index.js +26 -0
- package/dist/runtime/input.d.ts +38 -0
- package/dist/runtime/input.js +159 -0
- package/dist/runtime/interpolate.d.ts +6 -0
- package/dist/runtime/interpolate.js +13 -0
- package/dist/runtime/render/animation.d.ts +63 -0
- package/dist/runtime/render/animation.js +128 -0
- package/dist/runtime/render/atmosphere.d.ts +32 -0
- package/dist/runtime/render/atmosphere.js +59 -0
- package/dist/runtime/render/crowd-motion.d.ts +68 -0
- package/dist/runtime/render/crowd-motion.js +163 -0
- package/dist/runtime/render/decals.d.ts +64 -0
- package/dist/runtime/render/decals.js +222 -0
- package/dist/runtime/render/highlight.d.ts +29 -0
- package/dist/runtime/render/highlight.js +110 -0
- package/dist/runtime/render/index.d.ts +35 -0
- package/dist/runtime/render/index.js +22 -0
- package/dist/runtime/render/lighting.d.ts +50 -0
- package/dist/runtime/render/lighting.js +142 -0
- package/dist/runtime/render/particles.d.ts +117 -0
- package/dist/runtime/render/particles.js +289 -0
- package/dist/runtime/render/post.d.ts +92 -0
- package/dist/runtime/render/post.js +191 -0
- package/dist/runtime/render/presets.d.ts +57 -0
- package/dist/runtime/render/presets.js +116 -0
- package/dist/runtime/render/quality.d.ts +113 -0
- package/dist/runtime/render/quality.js +180 -0
- package/dist/runtime/render/rig.d.ts +29 -0
- package/dist/runtime/render/rig.js +107 -0
- package/dist/runtime/render/sprite.d.ts +34 -0
- package/dist/runtime/render/sprite.js +58 -0
- package/dist/runtime/render/stack.d.ts +74 -0
- package/dist/runtime/render/stack.js +132 -0
- package/dist/runtime/render/vat.d.ts +78 -0
- package/dist/runtime/render/vat.js +219 -0
- package/dist/runtime/renderer.d.ts +19 -0
- package/dist/runtime/renderer.js +18 -0
- package/dist/runtime/stats.d.ts +11 -0
- package/dist/runtime/stats.js +29 -0
- package/dist/runtime/styles/handdrawn/ground.d.ts +11 -0
- package/dist/runtime/styles/handdrawn/ground.js +282 -0
- package/dist/runtime/styles/handdrawn/index.d.ts +4 -0
- package/dist/runtime/styles/handdrawn/index.js +94 -0
- package/dist/runtime/styles/handdrawn/ink.d.ts +31 -0
- package/dist/runtime/styles/handdrawn/ink.js +68 -0
- package/dist/runtime/styles/handdrawn/looks.d.ts +18 -0
- package/dist/runtime/styles/handdrawn/looks.js +42 -0
- package/dist/runtime/styles/handdrawn/paint.d.ts +59 -0
- package/dist/runtime/styles/handdrawn/paint.js +129 -0
- package/dist/runtime/styles/handdrawn/palette.d.ts +49 -0
- package/dist/runtime/styles/handdrawn/palette.js +70 -0
- package/dist/runtime/styles/handdrawn/textures.d.ts +27 -0
- package/dist/runtime/styles/handdrawn/textures.js +150 -0
- package/dist/runtime/styles/handdrawn/vfx.d.ts +14 -0
- package/dist/runtime/styles/handdrawn/vfx.js +73 -0
- package/dist/runtime/styles/index.d.ts +9 -0
- package/dist/runtime/styles/index.js +32 -0
- package/dist/runtime/styles/kit.d.ts +193 -0
- package/dist/runtime/styles/kit.js +1 -0
- package/dist/runtime/styles/pixel/index.d.ts +46 -0
- package/dist/runtime/styles/pixel/index.js +167 -0
- package/dist/runtime/styles/ps1/index.d.ts +29 -0
- package/dist/runtime/styles/ps1/index.js +41 -0
- package/dist/runtime/styles/ps1/lights.d.ts +47 -0
- package/dist/runtime/styles/ps1/lights.js +146 -0
- package/dist/runtime/styles/ps1/materials.d.ts +62 -0
- package/dist/runtime/styles/ps1/materials.js +285 -0
- package/dist/runtime/styles/ps1/pipeline.d.ts +52 -0
- package/dist/runtime/styles/ps1/pipeline.js +111 -0
- package/dist/runtime/styles/ps1/surfaces.d.ts +44 -0
- package/dist/runtime/styles/ps1/surfaces.js +228 -0
- package/dist/runtime/styles/toon/index.d.ts +5 -0
- package/dist/runtime/styles/toon/index.js +178 -0
- package/dist/runtime/styles/toon/ink.d.ts +39 -0
- package/dist/runtime/styles/toon/ink.js +73 -0
- package/dist/runtime/styles/toon/look.d.ts +10 -0
- package/dist/runtime/styles/toon/look.js +112 -0
- package/dist/runtime/styles/toon/material.d.ts +74 -0
- package/dist/runtime/styles/toon/material.js +208 -0
- package/dist/runtime/styles/toon/normals.d.ts +26 -0
- package/dist/runtime/styles/toon/normals.js +127 -0
- package/dist/runtime/styles/toon/shadows.d.ts +24 -0
- package/dist/runtime/styles/toon/shadows.js +42 -0
- package/dist/runtime/styles/toon/vfx.d.ts +15 -0
- package/dist/runtime/styles/toon/vfx.js +47 -0
- package/dist/runtime/styles/view.d.ts +35 -0
- package/dist/runtime/styles/view.js +34 -0
- package/dist/runtime/styles/voxel/core.d.ts +114 -0
- package/dist/runtime/styles/voxel/core.js +577 -0
- package/dist/runtime/styles/voxel/geometry.d.ts +27 -0
- package/dist/runtime/styles/voxel/geometry.js +40 -0
- package/dist/runtime/styles/voxel/index.d.ts +21 -0
- package/dist/runtime/styles/voxel/index.js +23 -0
- package/dist/runtime/styles/voxel/kit.d.ts +6 -0
- package/dist/runtime/styles/voxel/kit.js +11 -0
- package/dist/runtime/styles/voxel/look.d.ts +10 -0
- package/dist/runtime/styles/voxel/look.js +16 -0
- package/dist/runtime/styles/voxel/props.d.ts +9 -0
- package/dist/runtime/styles/voxel/props.js +52 -0
- package/dist/runtime/styles/voxel/shapes.d.ts +41 -0
- package/dist/runtime/styles/voxel/shapes.js +83 -0
- package/dist/runtime/styles/voxel/world.d.ts +49 -0
- package/dist/runtime/styles/voxel/world.js +422 -0
- package/llms-full.txt +460 -0
- package/llms.txt +183 -0
- package/package.json +104 -3
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a compiled game on an engine-sim world on the authoritative side
|
|
3
|
+
* (room server, headless harness): the declared systems on a run clock,
|
|
4
|
+
* sim events turned into named game events, earn awards, and the run's
|
|
5
|
+
* phases (playing, then won or lost, then a fresh run).
|
|
6
|
+
*
|
|
7
|
+
* Arena games add the shooter machinery here: the round clock and frag
|
|
8
|
+
* limit (a round is a run), kills and deaths per player, a kill feed, and
|
|
9
|
+
* server bots that fill empty seats (they play, they never earn, and
|
|
10
|
+
* killing one, or topping a board of them, pays no earn rule).
|
|
11
|
+
*
|
|
12
|
+
* Clients never run this: they predict only their own character, and the
|
|
13
|
+
* world's enemies, waves, bosses and bots arrive in snapshots.
|
|
14
|
+
*/
|
|
15
|
+
import { addPlayer, arenaSpawn, BOT_PLAYER_BASE, Button, enemyTypeOf, grantLevels, playerBuild, edgeSpawnPoint, enemyCount, enemyMaxHealth, entityHealth, entityHeadroom, listPickups, listPlayers, removePlayer, resetRun, respawnDead, revivePlayer, entityPosition, isOnScreen, removeEnemies, ringSpawnPoint, scatterEnemies, setXpBoost, simPaused, spawnPickupAt, simRandom, spawnEnemy, spawnEnemyScaled, spawnRingPoint, } from "@netforge/engine-sim";
|
|
16
|
+
import { SystemScheduler } from "../api/scheduler.js";
|
|
17
|
+
import { Director } from "./director.js";
|
|
18
|
+
import { GameDefinitionError } from "../api/schemas.js";
|
|
19
|
+
import { arenaBotInput, arenaBotMemory } from "./arena-bot.js";
|
|
20
|
+
import { EarnTracker } from "./earn.js";
|
|
21
|
+
const FEED_LENGTH = 6;
|
|
22
|
+
/** The end screen shows at least this long before a unanimous "Play again" starts the next run. */
|
|
23
|
+
const MIN_END_SECONDS = 2;
|
|
24
|
+
/** A player who joins after this many seconds catches up to the team (players.lateJoinBehind). */
|
|
25
|
+
const LATE_JOIN_SECONDS = 10;
|
|
26
|
+
const bump = (m, k, by = 1) => m.set(k, (m.get(k) ?? 0) + by);
|
|
27
|
+
/** How sharp seat bots play against each other (1 is the playtest players' level). */
|
|
28
|
+
const SEAT_BOT_SKILL = 0.6;
|
|
29
|
+
/** Per kill a seat bot is ahead of the best person, its skill against people drops by this much (down to the floor). */
|
|
30
|
+
const LEADER_EASE_STEP = 0.25;
|
|
31
|
+
const LEADER_EASE_FLOOR = 0.3;
|
|
32
|
+
/**
|
|
33
|
+
* How sharp seat bots play against a person, from that person's record this
|
|
34
|
+
* round: a newcomer (no kills yet) faces loose bots, and they sharpen as
|
|
35
|
+
* the person wins (GAME-DESIGN.md "Bots that ramp").
|
|
36
|
+
*/
|
|
37
|
+
export function seatBotSkillVs(kills, deaths) {
|
|
38
|
+
const kd = (kills + 1) / (deaths + 1);
|
|
39
|
+
return Math.max(0.15, Math.min(0.8, 0.15 + 0.22 * (kd - 0.5) + 0.01 * kills));
|
|
40
|
+
}
|
|
41
|
+
const isBot = (id) => id >= BOT_PLAYER_BASE;
|
|
42
|
+
/** A prefab's `boss` declares a boss when it is true or a pattern. */
|
|
43
|
+
const isBoss = (b) => b !== undefined && b !== false;
|
|
44
|
+
export class GameRunner {
|
|
45
|
+
compiled;
|
|
46
|
+
world;
|
|
47
|
+
opts;
|
|
48
|
+
scheduler;
|
|
49
|
+
runTick = 0;
|
|
50
|
+
runN = 0;
|
|
51
|
+
phase = "playing";
|
|
52
|
+
phaseTicks = 0;
|
|
53
|
+
allDownTicks = 0;
|
|
54
|
+
pending = [];
|
|
55
|
+
/** Players whose referee run started after this world (round) began: world and fight progress never pays them (record). */
|
|
56
|
+
lateStart = new Set();
|
|
57
|
+
/** Systems are handling an unpaid event: what they emit now is unpaid too. */
|
|
58
|
+
dispatchingUnpaid = false;
|
|
59
|
+
earn;
|
|
60
|
+
kills = new Map();
|
|
61
|
+
deaths = new Map();
|
|
62
|
+
emitted = new Map();
|
|
63
|
+
teamKills = 0;
|
|
64
|
+
bosses = new Map();
|
|
65
|
+
feed = [];
|
|
66
|
+
endTicks;
|
|
67
|
+
graceTicks;
|
|
68
|
+
arena;
|
|
69
|
+
roundTicks;
|
|
70
|
+
botMemory = new Map();
|
|
71
|
+
loadout;
|
|
72
|
+
clock = 0;
|
|
73
|
+
director = null;
|
|
74
|
+
holdNext = 0;
|
|
75
|
+
announcement = null;
|
|
76
|
+
announceN = 0;
|
|
77
|
+
/** Run tick when an XP boost ends (0: none). */
|
|
78
|
+
boostEnds = 0;
|
|
79
|
+
/** The sim was paused for a solo pick at the start of this step: the run clock stands still. */
|
|
80
|
+
pausedStep = false;
|
|
81
|
+
ready = new Set();
|
|
82
|
+
upcomingAt = null;
|
|
83
|
+
achievements;
|
|
84
|
+
missions;
|
|
85
|
+
/** Event names the missions and achievements count (their per-player counts go to the HUD). */
|
|
86
|
+
tracked;
|
|
87
|
+
/** Per enemy type: the "<prefab>.killed" event to raise, when something counts it. */
|
|
88
|
+
killNames;
|
|
89
|
+
unlocked = new Map();
|
|
90
|
+
/** Achievements, goals and titles reached since the room last took them (it writes them to profiles). */
|
|
91
|
+
reached = [];
|
|
92
|
+
revives = new Map();
|
|
93
|
+
downs = new Map();
|
|
94
|
+
pickups = new Map();
|
|
95
|
+
lastHit = new Map();
|
|
96
|
+
downedBy = new Map();
|
|
97
|
+
coins = new Map();
|
|
98
|
+
peak = 0;
|
|
99
|
+
viewTick = -1;
|
|
100
|
+
view = { players: [], pickups: [] };
|
|
101
|
+
constructor(compiled, world, opts = {}) {
|
|
102
|
+
this.compiled = compiled;
|
|
103
|
+
this.world = world;
|
|
104
|
+
this.opts = opts;
|
|
105
|
+
this.arena = compiled.mode === "arena";
|
|
106
|
+
this.earn = new EarnTracker(compiled.manifest.earnRules);
|
|
107
|
+
// Achievements and missions count like earn rules but pay nothing.
|
|
108
|
+
const asRules = (m) => new Map([...m].map(([id, d]) => [id, { when: d.when, n: d.n, pay: 0, oncePer: "run" }]));
|
|
109
|
+
this.achievements = new EarnTracker(asRules(compiled.manifest.achievements));
|
|
110
|
+
this.missions = new EarnTracker(asRules(compiled.manifest.missions));
|
|
111
|
+
this.tracked = [...new Set([...compiled.manifest.achievements.values(), ...compiled.manifest.missions.values()].map((d) => d.when))];
|
|
112
|
+
const counted = new Set([...this.tracked, ...[...compiled.manifest.earnRules.values()].map((r) => r.when)]);
|
|
113
|
+
this.killNames = compiled.enemyPrefabs.map((p) => (counted.has(`${p}.killed`) ? `${p}.killed` : null));
|
|
114
|
+
this.endTicks = Math.round((opts.endSeconds ?? (this.arena ? 8 : 10)) * world.tickRate);
|
|
115
|
+
this.graceTicks = Math.round((opts.loseGraceSeconds ?? 5) * world.tickRate);
|
|
116
|
+
this.roundTicks = compiled.round ? Math.round(compiled.round.seconds * world.tickRate) : 0;
|
|
117
|
+
const weapons = world.config.content.weapons;
|
|
118
|
+
this.loadout = compiled.loadout.map((wi) => {
|
|
119
|
+
const w = weapons[wi];
|
|
120
|
+
return { kind: w?.kind ?? "blaster", range: w?.range ?? 10, count: w?.count ?? 1 };
|
|
121
|
+
});
|
|
122
|
+
this.newRun();
|
|
123
|
+
}
|
|
124
|
+
newRun() {
|
|
125
|
+
this.runN++;
|
|
126
|
+
this.runTick = 0;
|
|
127
|
+
this.phase = "playing";
|
|
128
|
+
this.phaseTicks = 0;
|
|
129
|
+
this.allDownTicks = 0;
|
|
130
|
+
this.kills.clear();
|
|
131
|
+
this.deaths.clear();
|
|
132
|
+
this.emitted.clear();
|
|
133
|
+
this.teamKills = 0;
|
|
134
|
+
this.bosses.clear();
|
|
135
|
+
this.feed = [];
|
|
136
|
+
this.announcement = null;
|
|
137
|
+
this.boostEnds = 0;
|
|
138
|
+
this.ready.clear();
|
|
139
|
+
this.upcomingAt = null;
|
|
140
|
+
this.achievements.resetRun();
|
|
141
|
+
this.missions.resetRun();
|
|
142
|
+
this.unlocked.clear();
|
|
143
|
+
// Nobody took them (a harness, not a room): do not let them pile up.
|
|
144
|
+
if (this.reached.length > 1024)
|
|
145
|
+
this.reached = [];
|
|
146
|
+
this.revives.clear();
|
|
147
|
+
this.downs.clear();
|
|
148
|
+
this.pickups.clear();
|
|
149
|
+
this.lastHit.clear();
|
|
150
|
+
this.downedBy.clear();
|
|
151
|
+
this.coins.clear();
|
|
152
|
+
this.peak = 0;
|
|
153
|
+
setXpBoost(this.world, 1);
|
|
154
|
+
this.earn.resetRun();
|
|
155
|
+
this.lateStart.clear();
|
|
156
|
+
this.scheduler = new SystemScheduler(this.compiled.manifest, {
|
|
157
|
+
tickRate: this.world.tickRate,
|
|
158
|
+
tick: () => this.runTick,
|
|
159
|
+
players: () => this.playerViews(),
|
|
160
|
+
spawn: (prefab, at, opts) => this.spawn(prefab, at, opts?.elite === true),
|
|
161
|
+
random: () => simRandom(this.world),
|
|
162
|
+
emit: (event, players) => {
|
|
163
|
+
const ev = { name: event, players: players ? [...players] : this.presentPlayers() };
|
|
164
|
+
this.pending.push(this.dispatchingUnpaid ? { ...ev, unpaid: true } : ev);
|
|
165
|
+
},
|
|
166
|
+
alive: (prefab) => {
|
|
167
|
+
if (prefab === undefined)
|
|
168
|
+
return enemyCount(this.world);
|
|
169
|
+
const t = this.compiled.enemyType.get(prefab);
|
|
170
|
+
return t === undefined ? 0 : enemyCount(this.world, t);
|
|
171
|
+
},
|
|
172
|
+
respawnDead: () => {
|
|
173
|
+
respawnDead(this.world);
|
|
174
|
+
},
|
|
175
|
+
end: (outcome) => this.finish(outcome),
|
|
176
|
+
ringPoint: (cx, cz, radius, prefab) => ringSpawnPoint(this.world, cx, cz, radius, this.radiusOf(prefab)),
|
|
177
|
+
spawnEdge: (prefab, opts) => this.spawnEdge(prefab, opts),
|
|
178
|
+
spawnPickup: (prefab, at) => {
|
|
179
|
+
const i = this.compiled.specialPickups.indexOf(prefab);
|
|
180
|
+
if (i >= 0)
|
|
181
|
+
spawnPickupAt(this.world, i, at[0], at[2]);
|
|
182
|
+
},
|
|
183
|
+
announce: (text, tone) => {
|
|
184
|
+
this.announcement = { id: ++this.announceN, text, tone };
|
|
185
|
+
},
|
|
186
|
+
upcoming: (label, seconds) => {
|
|
187
|
+
this.upcomingAt = { label, tick: this.runTick + Math.round(seconds * this.world.tickRate) };
|
|
188
|
+
},
|
|
189
|
+
bossAlive: () => this.bosses.size > 0,
|
|
190
|
+
pauseDirector: (seconds) => this.director?.pause(this.runTick, Math.round(seconds * this.world.tickRate)),
|
|
191
|
+
surge: (k, seconds) => this.director?.surge(this.runTick, k, Math.round(seconds * this.world.tickRate)),
|
|
192
|
+
xpBoost: (k, seconds) => {
|
|
193
|
+
setXpBoost(this.world, k);
|
|
194
|
+
this.boostEnds = this.runTick + Math.round(seconds * this.world.tickRate);
|
|
195
|
+
},
|
|
196
|
+
scatter: (prefab, count) => {
|
|
197
|
+
const t = this.compiled.enemyType.get(prefab);
|
|
198
|
+
return t === undefined ? 0 : scatterEnemies(this.world, t, count);
|
|
199
|
+
},
|
|
200
|
+
clear: (prefab) => {
|
|
201
|
+
const t = this.compiled.enemyType.get(prefab);
|
|
202
|
+
return t === undefined ? 0 : removeEnemies(this.world, t);
|
|
203
|
+
},
|
|
204
|
+
onScreen: (x, z) => isOnScreen(this.world, x, z),
|
|
205
|
+
});
|
|
206
|
+
const d = this.compiled.manifest.director;
|
|
207
|
+
this.director = d
|
|
208
|
+
? new Director(d, this.world, {
|
|
209
|
+
typeOf: (p) => this.compiled.enemyType.get(p),
|
|
210
|
+
radiusOf: (p) => this.radiusOf(p),
|
|
211
|
+
spawned: (p, id) => {
|
|
212
|
+
const t = this.compiled.enemyType.get(p);
|
|
213
|
+
if (t !== undefined && this.compiled.manifest.prefabs.get(p)?.boss)
|
|
214
|
+
this.bosses.set(id, t);
|
|
215
|
+
},
|
|
216
|
+
bossAlive: () => this.bosses.size > 0,
|
|
217
|
+
})
|
|
218
|
+
: null;
|
|
219
|
+
this.scheduler.start();
|
|
220
|
+
}
|
|
221
|
+
radiusOf(prefab) {
|
|
222
|
+
return this.compiled.manifest.prefabs.get(prefab)?.radius ?? 0.35;
|
|
223
|
+
}
|
|
224
|
+
/** Bosses are tougher with more players: health x (1 - k + k x players), k = players.bossHealthPerPlayer (0.55). */
|
|
225
|
+
bossScale() {
|
|
226
|
+
const k = this.compiled.manifest.game.players.bossHealthPerPlayer ?? 0.55;
|
|
227
|
+
const n = Math.max(1, listPlayers(this.world).length);
|
|
228
|
+
return 1 - k + k * n;
|
|
229
|
+
}
|
|
230
|
+
spawnEdge(prefab, opts) {
|
|
231
|
+
const t = this.compiled.enemyType.get(prefab);
|
|
232
|
+
if (t === undefined) {
|
|
233
|
+
throw new GameDefinitionError(`spawnEdge("${prefab}")`, [`"${prefab}" is a pickup; spawnEdge spawns enemies`]);
|
|
234
|
+
}
|
|
235
|
+
const boss = isBoss(this.compiled.manifest.prefabs.get(prefab)?.boss);
|
|
236
|
+
const radius = this.radiusOf(prefab);
|
|
237
|
+
const side = opts.side ?? "any";
|
|
238
|
+
const spread = side === "any" ? Math.PI * 2 : (Math.max(1, Math.min(360, opts.spread ?? 50)) * Math.PI) / 180;
|
|
239
|
+
const compass = { east: 0, south: Math.PI / 2, west: Math.PI, north: -Math.PI / 2 };
|
|
240
|
+
// One heading for the whole call when a side is named: a tide comes from one direction.
|
|
241
|
+
const fixed = compass[side];
|
|
242
|
+
let n = 0;
|
|
243
|
+
for (let i = 0; i < opts.count; i++) {
|
|
244
|
+
if (entityHeadroom(this.world) <= 64)
|
|
245
|
+
break;
|
|
246
|
+
const at = edgeSpawnPoint(this.world, opts.around, {
|
|
247
|
+
radius,
|
|
248
|
+
spread,
|
|
249
|
+
...(fixed !== undefined ? { heading: fixed } : {}),
|
|
250
|
+
...(side === "ahead" || side === "behind" ? { relative: side } : {}),
|
|
251
|
+
});
|
|
252
|
+
if (!at)
|
|
253
|
+
continue;
|
|
254
|
+
const s = opts.scale ?? {};
|
|
255
|
+
const id = spawnEnemyScaled(this.world, t, at[0], at[1], { health: (s.health ?? 1) * (boss ? this.bossScale() : 1), damage: s.damage ?? 1, xp: s.xp ?? 1, elite: opts.elite === true });
|
|
256
|
+
if (boss)
|
|
257
|
+
this.bosses.set(id, t);
|
|
258
|
+
n++;
|
|
259
|
+
}
|
|
260
|
+
return n;
|
|
261
|
+
}
|
|
262
|
+
playerViews() {
|
|
263
|
+
return listPlayers(this.world).map((p) => ({
|
|
264
|
+
id: p.playerId,
|
|
265
|
+
position: [p.x, p.y, p.z],
|
|
266
|
+
health: p.health,
|
|
267
|
+
alive: p.up,
|
|
268
|
+
kills: this.kills.get(p.playerId) ?? 0,
|
|
269
|
+
deaths: this.deaths.get(p.playerId) ?? 0,
|
|
270
|
+
bot: isBot(p.playerId),
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
presentPlayers() {
|
|
274
|
+
return listPlayers(this.world).map((p) => p.playerId);
|
|
275
|
+
}
|
|
276
|
+
spawn(prefab, at, elite = false) {
|
|
277
|
+
const t = this.compiled.enemyType.get(prefab);
|
|
278
|
+
if (t === undefined) {
|
|
279
|
+
throw new GameDefinitionError(`spawn("${prefab}")`, [`"${prefab}" is a pickup; pickups come from enemy drops, spawn enemies only`]);
|
|
280
|
+
}
|
|
281
|
+
if (entityHeadroom(this.world) <= 64)
|
|
282
|
+
return; // keep room for gems and projectiles
|
|
283
|
+
const boss = isBoss(this.compiled.manifest.prefabs.get(prefab)?.boss);
|
|
284
|
+
const id = boss || elite ? spawnEnemyScaled(this.world, t, at[0], at[2], { health: boss ? this.bossScale() : 1, elite }) : spawnEnemy(this.world, t, at[0], at[2]);
|
|
285
|
+
if (boss)
|
|
286
|
+
this.bosses.set(id, t);
|
|
287
|
+
}
|
|
288
|
+
finish(outcome) {
|
|
289
|
+
if (this.phase !== "playing")
|
|
290
|
+
return;
|
|
291
|
+
this.phase = outcome;
|
|
292
|
+
this.phaseTicks = 0;
|
|
293
|
+
this.pending.push({ name: outcome === "won" ? "run.won" : "run.lost", players: this.presentPlayers() });
|
|
294
|
+
// The end screen's titles go to the takers' profiles.
|
|
295
|
+
for (const [player, ids] of this.titleIds(this.summaryRows()))
|
|
296
|
+
for (const id of ids)
|
|
297
|
+
this.reached.push({ player, kind: "title", id });
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Profile progress since the last call: achievements and mission goals
|
|
301
|
+
* reached (humans only) and titles taken at the end of a run. The room
|
|
302
|
+
* server writes these to the players' profiles; the browser never does.
|
|
303
|
+
*/
|
|
304
|
+
takeProgress() {
|
|
305
|
+
if (this.reached.length === 0)
|
|
306
|
+
return [];
|
|
307
|
+
const out = this.reached;
|
|
308
|
+
this.reached = [];
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Arena: the round is over. "round.top" credits the top fragger(s) (with at
|
|
313
|
+
* least one kill), "round.finished" everyone present. Topping a board of
|
|
314
|
+
* seat bots pays nothing: "round.top" is unpaid unless another person was
|
|
315
|
+
* in the round at the end.
|
|
316
|
+
*/
|
|
317
|
+
finishRound() {
|
|
318
|
+
if (this.phase !== "playing")
|
|
319
|
+
return;
|
|
320
|
+
const present = this.presentPlayers();
|
|
321
|
+
let best = 0;
|
|
322
|
+
for (const id of present)
|
|
323
|
+
best = Math.max(best, this.kills.get(id) ?? 0);
|
|
324
|
+
if (best > 0) {
|
|
325
|
+
const top = present.filter((id) => (this.kills.get(id) ?? 0) === best);
|
|
326
|
+
const humans = present.filter((id) => !isBot(id)).length;
|
|
327
|
+
// (A player whose run started mid-round is not paid it: record() drops late starters from world events.)
|
|
328
|
+
this.pending.push(humans >= 2 ? { name: "round.top", players: top } : { name: "round.top", players: top, unpaid: true });
|
|
329
|
+
}
|
|
330
|
+
this.pending.push({ name: "round.finished", players: present });
|
|
331
|
+
this.finish("won");
|
|
332
|
+
}
|
|
333
|
+
get phaseNow() {
|
|
334
|
+
return this.phase;
|
|
335
|
+
}
|
|
336
|
+
get runNumber() {
|
|
337
|
+
return this.runN;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Call once before each sim step: fills empty arena seats with bots,
|
|
341
|
+
* runs the systems' timers (waves) and the round clock while playing, and,
|
|
342
|
+
* given the step's input map, adds every bot's input to it.
|
|
343
|
+
*/
|
|
344
|
+
beforeStep(inputs) {
|
|
345
|
+
this.clock++;
|
|
346
|
+
if (this.arena)
|
|
347
|
+
this.fillSeats();
|
|
348
|
+
// Solo upgrade cards pause the sim; the run clock, systems and director wait too.
|
|
349
|
+
this.pausedStep = !this.arena && simPaused(this.world);
|
|
350
|
+
if (this.phase === "playing" && !this.pausedStep) {
|
|
351
|
+
this.scheduler.tick();
|
|
352
|
+
this.director?.tick(this.runTick);
|
|
353
|
+
if (this.boostEnds > 0 && this.runTick >= this.boostEnds) {
|
|
354
|
+
setXpBoost(this.world, 1);
|
|
355
|
+
this.boostEnds = 0;
|
|
356
|
+
}
|
|
357
|
+
this.hold();
|
|
358
|
+
if (this.arena && this.roundTicks > 0 && this.runTick + 1 >= this.roundTicks)
|
|
359
|
+
this.finishRound();
|
|
360
|
+
}
|
|
361
|
+
if (inputs && this.arena) {
|
|
362
|
+
for (const p of listPlayers(this.world))
|
|
363
|
+
if (isBot(p.playerId))
|
|
364
|
+
inputs.set(p.playerId, this.autopilot(p.playerId));
|
|
365
|
+
}
|
|
366
|
+
// The end screen's vote: Button.Ready from anyone present.
|
|
367
|
+
if (inputs && this.phase !== "playing") {
|
|
368
|
+
for (const [pid, cmd] of inputs)
|
|
369
|
+
if ((cmd.buttons & Button.Ready) !== 0)
|
|
370
|
+
this.ready.add(pid);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Every human present voted to play again (bots always agree), after the end screen had a moment. */
|
|
374
|
+
allReady() {
|
|
375
|
+
if (this.phaseTicks < MIN_END_SECONDS * this.world.tickRate)
|
|
376
|
+
return false;
|
|
377
|
+
const humans = this.presentPlayers().filter((id) => !isBot(id));
|
|
378
|
+
return humans.length > 0 && humans.every((id) => this.ready.has(id));
|
|
379
|
+
}
|
|
380
|
+
/** A player joining a run in progress starts players.lateJoinBehind levels below the team's average, cards waiting. */
|
|
381
|
+
catchUp(playerId) {
|
|
382
|
+
if (this.arena || this.phase !== "playing" || this.runTick < LATE_JOIN_SECONDS * this.world.tickRate)
|
|
383
|
+
return;
|
|
384
|
+
const others = listPlayers(this.world).filter((p) => p.playerId !== playerId);
|
|
385
|
+
if (others.length === 0)
|
|
386
|
+
return;
|
|
387
|
+
const avg = others.reduce((s, p) => s + p.level, 0) / others.length;
|
|
388
|
+
const behind = this.compiled.manifest.game.players.lateJoinBehind ?? 2;
|
|
389
|
+
const target = Math.max(1, Math.round(avg) - behind);
|
|
390
|
+
grantLevels(this.world, playerId, target - 1);
|
|
391
|
+
}
|
|
392
|
+
prefabOfEnemy(netId) {
|
|
393
|
+
const t = enemyTypeOf(this.world, netId);
|
|
394
|
+
return t >= 0 ? (this.compiled.enemyPrefabs[t] ?? "") : "";
|
|
395
|
+
}
|
|
396
|
+
/** Seats bots fill up to, with at least one human present; bots leave as humans join. One change a tick. */
|
|
397
|
+
fillSeats() {
|
|
398
|
+
const players = listPlayers(this.world);
|
|
399
|
+
const humans = players.filter((p) => !isBot(p.playerId)).length;
|
|
400
|
+
const bots = players.filter((p) => isBot(p.playerId)).map((p) => p.playerId);
|
|
401
|
+
const want = humans > 0 ? Math.max(0, this.compiled.bots - humans) : 0;
|
|
402
|
+
if (bots.length < want) {
|
|
403
|
+
let id = BOT_PLAYER_BASE;
|
|
404
|
+
while (bots.includes(id))
|
|
405
|
+
id++;
|
|
406
|
+
const [x, z] = arenaSpawn(this.world, id);
|
|
407
|
+
addPlayer(this.world, id, [x, this.world.config.arena.spawn[1], z]);
|
|
408
|
+
this.botMemory.set(id, arenaBotMemory());
|
|
409
|
+
}
|
|
410
|
+
else if (bots.length > want) {
|
|
411
|
+
const id = Math.max(...bots);
|
|
412
|
+
removePlayer(this.world, id);
|
|
413
|
+
this.botMemory.delete(id);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Arena: what a bot (or a playtest player) does this tick, from the
|
|
418
|
+
* present state. Deterministic, so a room's run replays exactly.
|
|
419
|
+
*/
|
|
420
|
+
autopilot(id) {
|
|
421
|
+
if (this.viewTick !== this.clock) {
|
|
422
|
+
this.viewTick = this.clock;
|
|
423
|
+
this.view = { players: listPlayers(this.world), pickups: listPickups(this.world) };
|
|
424
|
+
}
|
|
425
|
+
let mem = this.botMemory.get(id);
|
|
426
|
+
if (!mem) {
|
|
427
|
+
mem = arenaBotMemory();
|
|
428
|
+
this.botMemory.set(id, mem);
|
|
429
|
+
}
|
|
430
|
+
const me = this.view.players.find((p) => p.playerId === id);
|
|
431
|
+
if (!me)
|
|
432
|
+
return { seq: 0, tick: this.clock, moveX: 0, moveZ: 0, yaw: 0, buttons: 0 };
|
|
433
|
+
const seat = isBot(id);
|
|
434
|
+
// A seat bot ahead of the best person in the room eases off people: looser against them, and it
|
|
435
|
+
// would rather fight another bot. It never stops shooting.
|
|
436
|
+
let topHuman = 0;
|
|
437
|
+
for (const p of this.view.players)
|
|
438
|
+
if (!isBot(p.playerId))
|
|
439
|
+
topHuman = Math.max(topHuman, this.kills.get(p.playerId) ?? 0);
|
|
440
|
+
const ahead = Math.max(0, (this.kills.get(id) ?? 0) - topHuman + 1);
|
|
441
|
+
const ease = seat ? Math.max(LEADER_EASE_FLOOR, 1 - LEADER_EASE_STEP * ahead) : 1;
|
|
442
|
+
// A person another seat bot is already fighting counts as crowded, so bots do not gang up on one person.
|
|
443
|
+
const chasing = new Map();
|
|
444
|
+
if (seat)
|
|
445
|
+
for (const [bid, m] of this.botMemory)
|
|
446
|
+
if (bid !== id && isBot(bid) && m.target > 0)
|
|
447
|
+
chasing.set(m.target, (chasing.get(m.target) ?? 0) + 1);
|
|
448
|
+
return arenaBotInput({
|
|
449
|
+
id,
|
|
450
|
+
x: me.x,
|
|
451
|
+
z: me.z,
|
|
452
|
+
health: me.health,
|
|
453
|
+
maxHealth: me.maxHealth,
|
|
454
|
+
up: me.up && this.phase === "playing",
|
|
455
|
+
slot: me.slot,
|
|
456
|
+
loadout: this.loadout,
|
|
457
|
+
opponents: this.view.players
|
|
458
|
+
.filter((p) => p.playerId !== id && p.up)
|
|
459
|
+
.map((p) => seat && !isBot(p.playerId)
|
|
460
|
+
? {
|
|
461
|
+
id: p.playerId,
|
|
462
|
+
x: p.x,
|
|
463
|
+
z: p.z,
|
|
464
|
+
skill: seatBotSkillVs(this.kills.get(p.playerId) ?? 0, this.deaths.get(p.playerId) ?? 0) * ease,
|
|
465
|
+
crowded: (chasing.get(p.playerId) ?? 0) > 0 || ahead > 0,
|
|
466
|
+
}
|
|
467
|
+
: { id: p.playerId, x: p.x, z: p.z }),
|
|
468
|
+
pickups: this.view.pickups,
|
|
469
|
+
pillars: this.world.config.arena.pillars,
|
|
470
|
+
half: this.world.config.arena.halfExtent,
|
|
471
|
+
seed: this.world.config.seed,
|
|
472
|
+
// Seat bots play loose against each other, and against people by their record (seatBotSkillVs).
|
|
473
|
+
skill: seat ? SEAT_BOT_SKILL * Math.max(0.4, 1 - 0.2 * Math.max(0, ahead - 1)) : 1,
|
|
474
|
+
}, mem, this.clock, this.world.tickRate);
|
|
475
|
+
}
|
|
476
|
+
hold() {
|
|
477
|
+
const n = this.opts.holdEnemies ?? 0;
|
|
478
|
+
if (n <= 0)
|
|
479
|
+
return;
|
|
480
|
+
// holdMix: the director pool by weight (the game's own late-run mix), else every non-boss type once.
|
|
481
|
+
const pool = this.compiled.manifest.director?.pool ?? [];
|
|
482
|
+
const weighted = pool.flatMap((e) => Array.from({ length: Math.max(1, Math.round(e.weight)) }, () => this.compiled.enemyType.get(e.prefab) ?? 0));
|
|
483
|
+
const mix = !this.opts.holdMix
|
|
484
|
+
? [0]
|
|
485
|
+
: weighted.length > 0
|
|
486
|
+
? weighted
|
|
487
|
+
: this.compiled.enemyPrefabs.map((p, i) => [p, i]).filter(([p]) => !isBoss(this.compiled.manifest.prefabs.get(p)?.boss)).map(([, i]) => i);
|
|
488
|
+
let budget = 64;
|
|
489
|
+
while (budget-- > 0 && enemyCount(this.world) < n && entityHeadroom(this.world) > 64) {
|
|
490
|
+
const [x, z] = spawnRingPoint(this.world, 16 + simRandom(this.world) * 14);
|
|
491
|
+
spawnEnemy(this.world, mix[this.holdNext++ % mix.length] ?? 0, x, z);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/** Earn credits humans only: bots fill seats, they never earn, and an unpaid event (a bot's death, a board of bots topped) pays no one. */
|
|
495
|
+
record(ev) {
|
|
496
|
+
if (ev.unpaid)
|
|
497
|
+
return [];
|
|
498
|
+
// World or fight progress (anything not the player's own act) never pays a run that started after this world began.
|
|
499
|
+
const late = ev.own === true || this.lateStart.size === 0 ? null : this.lateStart;
|
|
500
|
+
const paid = ev.players.filter((p) => !isBot(p) && !(late?.has(p) ?? false));
|
|
501
|
+
return paid.length === ev.players.length ? this.earn.record(ev) : this.earn.record({ name: ev.name, players: paid });
|
|
502
|
+
}
|
|
503
|
+
/** Call once after each sim step with its events. */
|
|
504
|
+
afterStep(simEvents) {
|
|
505
|
+
const events = this.pending;
|
|
506
|
+
this.pending = [];
|
|
507
|
+
// Arena: the round is over once it ends; nothing scores on the end screen.
|
|
508
|
+
const scoring = !this.arena || this.phase === "playing";
|
|
509
|
+
for (const e of simEvents) {
|
|
510
|
+
if (e.type === "enemy.killed") {
|
|
511
|
+
this.teamKills++;
|
|
512
|
+
if (e.by > 0)
|
|
513
|
+
this.kills.set(e.by, (this.kills.get(e.by) ?? 0) + 1);
|
|
514
|
+
const credit = e.by > 0 ? [e.by] : [];
|
|
515
|
+
events.push({ name: "enemy.killed", players: credit, own: true });
|
|
516
|
+
if (e.elite)
|
|
517
|
+
events.push({ name: "elite.killed", players: this.presentPlayers() });
|
|
518
|
+
// "<prefab>.killed" ("lantern_thief.killed"), only for names a rule, achievement or mission counts.
|
|
519
|
+
const named = this.killNames[e.enemyType];
|
|
520
|
+
if (named)
|
|
521
|
+
events.push({ name: named, players: this.presentPlayers() });
|
|
522
|
+
if (e.boss) {
|
|
523
|
+
this.bosses.delete(e.netId);
|
|
524
|
+
// A mini-boss raises "miniboss.killed"; only a final boss raises "boss.killed" (earn rules and the win key on it).
|
|
525
|
+
const b = this.compiled.manifest.prefabs.get(this.compiled.enemyPrefabs[e.enemyType] ?? "")?.boss;
|
|
526
|
+
const mini = typeof b === "object" && b.rank === "mini";
|
|
527
|
+
events.push({ name: mini ? "miniboss.killed" : "boss.killed", players: this.presentPlayers() });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
else if (e.type === "player.killed") {
|
|
531
|
+
if (!scoring)
|
|
532
|
+
continue;
|
|
533
|
+
this.deaths.set(e.playerId, (this.deaths.get(e.playerId) ?? 0) + 1);
|
|
534
|
+
if (e.by > 0 && e.by !== e.playerId) {
|
|
535
|
+
this.teamKills++;
|
|
536
|
+
this.kills.set(e.by, (this.kills.get(e.by) ?? 0) + 1);
|
|
537
|
+
// A seat bot's death scores and feeds like any kill, but earn rules never count it.
|
|
538
|
+
events.push(isBot(e.playerId) ? { name: "opponent.killed", players: [e.by], unpaid: true } : { name: "opponent.killed", players: [e.by], own: true });
|
|
539
|
+
this.feed.push({ by: e.by, victim: e.playerId, tick: this.runTick });
|
|
540
|
+
if (this.feed.length > FEED_LENGTH)
|
|
541
|
+
this.feed.shift();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
else if (e.type === "player.died") {
|
|
545
|
+
if (!scoring)
|
|
546
|
+
continue;
|
|
547
|
+
if (!this.arena)
|
|
548
|
+
this.deaths.set(e.playerId, (this.deaths.get(e.playerId) ?? 0) + 1);
|
|
549
|
+
events.push({ name: "player.died", players: [e.playerId], own: true });
|
|
550
|
+
}
|
|
551
|
+
else if (e.type === "player.joined") {
|
|
552
|
+
this.catchUp(e.playerId);
|
|
553
|
+
}
|
|
554
|
+
else if (e.type === "player.hit") {
|
|
555
|
+
const prefab = e.enemyType !== undefined ? (this.compiled.enemyPrefabs[e.enemyType] ?? "") : this.prefabOfEnemy(e.by);
|
|
556
|
+
this.lastHit.set(e.playerId, { prefab, source: e.source ?? "contact" });
|
|
557
|
+
}
|
|
558
|
+
else if (e.type === "player.downed") {
|
|
559
|
+
bump(this.downs, e.playerId);
|
|
560
|
+
const hit = this.lastHit.get(e.playerId);
|
|
561
|
+
if (hit)
|
|
562
|
+
this.downedBy.set(e.playerId, hit);
|
|
563
|
+
events.push({ name: "player.downed", players: [e.playerId], own: true });
|
|
564
|
+
if (this.opts.godMode)
|
|
565
|
+
revivePlayer(this.world, e.playerId);
|
|
566
|
+
}
|
|
567
|
+
else if (e.type === "player.revived") {
|
|
568
|
+
if (e.by !== e.playerId)
|
|
569
|
+
bump(this.revives, e.by);
|
|
570
|
+
events.push({ name: "player.revived", players: e.by !== e.playerId ? [e.by] : [], own: true });
|
|
571
|
+
}
|
|
572
|
+
else if (e.type === "player.levelup") {
|
|
573
|
+
events.push({ name: "player.levelup", players: [e.playerId] });
|
|
574
|
+
}
|
|
575
|
+
else if (e.type === "player.upgraded" && e.evolved) {
|
|
576
|
+
events.push({ name: "player.evolved", players: [e.playerId] });
|
|
577
|
+
}
|
|
578
|
+
else if (e.type === "player.collected") {
|
|
579
|
+
bump(this.pickups, e.playerId);
|
|
580
|
+
// "chest.collected", "heal.collected", ...: systems and earn rules can key on them.
|
|
581
|
+
events.push({ name: `${e.effect}.collected`, players: [e.playerId], own: true });
|
|
582
|
+
}
|
|
583
|
+
else if (e.type === "player.left") {
|
|
584
|
+
this.kills.delete(e.playerId);
|
|
585
|
+
this.deaths.delete(e.playerId);
|
|
586
|
+
this.botMemory.delete(e.playerId);
|
|
587
|
+
this.earn.forget(e.playerId);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const awards = [];
|
|
591
|
+
for (const ev of events) {
|
|
592
|
+
this.emitted.set(ev.name, (this.emitted.get(ev.name) ?? 0) + 1);
|
|
593
|
+
this.dispatchingUnpaid = ev.unpaid === true;
|
|
594
|
+
try {
|
|
595
|
+
this.scheduler.dispatch(ev.name, ev.players);
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
this.dispatchingUnpaid = false;
|
|
599
|
+
}
|
|
600
|
+
awards.push(...this.record(ev));
|
|
601
|
+
}
|
|
602
|
+
// Arena: the frag limit ends the round, reached by a person (a seat bot never takes the round).
|
|
603
|
+
const limit = this.compiled.round?.frags ?? null;
|
|
604
|
+
if (this.arena && limit !== null && this.phase === "playing") {
|
|
605
|
+
for (const [id, n] of this.kills) {
|
|
606
|
+
if (n >= limit && !isBot(id)) {
|
|
607
|
+
this.finishRound();
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Systems may have emitted or ended the run from their handlers.
|
|
613
|
+
for (const ev of this.pending) {
|
|
614
|
+
this.emitted.set(ev.name, (this.emitted.get(ev.name) ?? 0) + 1);
|
|
615
|
+
awards.push(...this.record(ev));
|
|
616
|
+
events.push(ev);
|
|
617
|
+
}
|
|
618
|
+
this.pending = [];
|
|
619
|
+
for (const ev of events)
|
|
620
|
+
this.progress(ev);
|
|
621
|
+
for (const a of awards) {
|
|
622
|
+
let row = this.coins.get(a.player);
|
|
623
|
+
if (!row) {
|
|
624
|
+
row = new Map();
|
|
625
|
+
this.coins.set(a.player, row);
|
|
626
|
+
}
|
|
627
|
+
row.set(a.rule, (row.get(a.rule) ?? 0) + a.units);
|
|
628
|
+
}
|
|
629
|
+
if (this.phase === "playing")
|
|
630
|
+
this.peak = Math.max(this.peak, enemyCount(this.world));
|
|
631
|
+
if (this.upcomingAt && this.runTick >= this.upcomingAt.tick)
|
|
632
|
+
this.upcomingAt = null;
|
|
633
|
+
let reset = false;
|
|
634
|
+
const players = listPlayers(this.world);
|
|
635
|
+
if (this.phase === "playing") {
|
|
636
|
+
if (!this.pausedStep)
|
|
637
|
+
this.runTick++;
|
|
638
|
+
// A keeper with a free self-revive left can still stand up: the run waits for it.
|
|
639
|
+
const anyUp = players.some((p) => p.up || (p.downed && p.selfRevives > 0));
|
|
640
|
+
this.allDownTicks = !this.arena && players.length > 0 && !anyUp ? this.allDownTicks + 1 : 0;
|
|
641
|
+
if (this.allDownTicks >= this.graceTicks) {
|
|
642
|
+
this.finish("lost");
|
|
643
|
+
const last = this.pending.splice(0);
|
|
644
|
+
for (const ev of last) {
|
|
645
|
+
awards.push(...this.record(ev));
|
|
646
|
+
events.push(ev);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (players.length === 0 && this.runTick > 1)
|
|
650
|
+
reset = true;
|
|
651
|
+
}
|
|
652
|
+
else if (++this.phaseTicks >= this.endTicks || this.allReady()) {
|
|
653
|
+
reset = true;
|
|
654
|
+
}
|
|
655
|
+
if (reset) {
|
|
656
|
+
resetRun(this.world);
|
|
657
|
+
this.newRun();
|
|
658
|
+
}
|
|
659
|
+
return { events, awards, reset };
|
|
660
|
+
}
|
|
661
|
+
/** Achievements and missions count the run's events (humans only, like earn). */
|
|
662
|
+
progress(ev) {
|
|
663
|
+
if (this.tracked.length === 0 || !this.tracked.includes(ev.name))
|
|
664
|
+
return;
|
|
665
|
+
const humans = ev.players.filter((p) => !isBot(p));
|
|
666
|
+
const e = { name: ev.name, players: humans };
|
|
667
|
+
for (const a of this.achievements.record(e)) {
|
|
668
|
+
const list = this.unlocked.get(a.player) ?? [];
|
|
669
|
+
list.push(a.rule);
|
|
670
|
+
this.unlocked.set(a.player, list);
|
|
671
|
+
this.reached.push({ player: a.player, kind: "achievement", id: a.rule });
|
|
672
|
+
}
|
|
673
|
+
for (const g of this.missions.record(e))
|
|
674
|
+
this.reached.push({ player: g.player, kind: "goal", id: g.rule });
|
|
675
|
+
}
|
|
676
|
+
countsOf(playerId) {
|
|
677
|
+
const out = {};
|
|
678
|
+
for (const name of this.tracked) {
|
|
679
|
+
const n = Math.max(this.achievements.count(playerId, name), this.missions.count(playerId, name));
|
|
680
|
+
if (n > 0)
|
|
681
|
+
out[name] = n;
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
685
|
+
keeperId(index) {
|
|
686
|
+
return [...this.compiled.manifest.keepers.keys()][index] ?? null;
|
|
687
|
+
}
|
|
688
|
+
/** The end screen: each player's build, damage per weapon, what downed them, coins and titles. */
|
|
689
|
+
summary() {
|
|
690
|
+
const rows = this.summaryRows();
|
|
691
|
+
const titles = this.titleIds(rows);
|
|
692
|
+
const text = (id) => this.compiled.manifest.titles.get(id)?.text ?? id;
|
|
693
|
+
return {
|
|
694
|
+
peak: this.peak,
|
|
695
|
+
seconds: Math.floor(this.runTick / this.world.tickRate),
|
|
696
|
+
players: rows.map((r) => ({ ...r, titles: (titles.get(r.id) ?? []).map(text) })),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
/** Each human's end-screen row, without titles. */
|
|
700
|
+
summaryRows() {
|
|
701
|
+
const c = this.compiled;
|
|
702
|
+
return listPlayers(this.world)
|
|
703
|
+
.filter((p) => !isBot(p.playerId))
|
|
704
|
+
.map((p) => {
|
|
705
|
+
const b = playerBuild(this.world, p.playerId);
|
|
706
|
+
const weapons = (b?.weapons ?? []).map((w) => ({ id: c.weaponIds[w.index] ?? "", level: w.level, damage: Math.round(b?.damage[w.index] ?? 0) }));
|
|
707
|
+
const damage = Math.round((b?.damage ?? []).reduce((s, d) => s + (d ?? 0), 0));
|
|
708
|
+
return {
|
|
709
|
+
id: p.playerId,
|
|
710
|
+
keeper: c.manifest.keepers.size > 0 ? this.keeperId(b?.keeper ?? 0) : null,
|
|
711
|
+
weapons,
|
|
712
|
+
passives: (b?.stats ?? []).map((s) => ({ id: c.upgradeIds[s.index] ?? "", level: s.level })),
|
|
713
|
+
level: p.level,
|
|
714
|
+
kills: this.kills.get(p.playerId) ?? 0,
|
|
715
|
+
damage,
|
|
716
|
+
revives: this.revives.get(p.playerId) ?? 0,
|
|
717
|
+
downs: this.downs.get(p.playerId) ?? 0,
|
|
718
|
+
pickups: this.pickups.get(p.playerId) ?? 0,
|
|
719
|
+
downedBy: this.downedBy.get(p.playerId) ?? null,
|
|
720
|
+
coins: [...(this.coins.get(p.playerId) ?? new Map())].map(([rule, units]) => ({ rule, units })),
|
|
721
|
+
achievements: this.unlocked.get(p.playerId) ?? [],
|
|
722
|
+
};
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
/** Who took each declared title (by id): the most (or, with 2+ players, the least) of its stat. */
|
|
726
|
+
titleIds(rows) {
|
|
727
|
+
const titles = new Map();
|
|
728
|
+
for (const [id, t] of this.compiled.manifest.titles) {
|
|
729
|
+
const stat = t.most ?? t.least;
|
|
730
|
+
if (!stat || rows.length === 0 || (t.least && rows.length < 2))
|
|
731
|
+
continue;
|
|
732
|
+
const vals = rows.map((r) => [r.id, r[stat]]);
|
|
733
|
+
const best = t.most ? Math.max(...vals.map(([, v]) => v)) : Math.min(...vals.map(([, v]) => v));
|
|
734
|
+
if (t.most && best <= 0)
|
|
735
|
+
continue;
|
|
736
|
+
const [winner] = vals.find(([, v]) => v === best) ?? [];
|
|
737
|
+
if (winner === undefined)
|
|
738
|
+
continue;
|
|
739
|
+
const list = titles.get(winner) ?? [];
|
|
740
|
+
list.push(id);
|
|
741
|
+
titles.set(winner, list);
|
|
742
|
+
}
|
|
743
|
+
return titles;
|
|
744
|
+
}
|
|
745
|
+
info() {
|
|
746
|
+
const players = listPlayers(this.world);
|
|
747
|
+
const bosses = [...this.bosses.entries()].map(([netId, type]) => {
|
|
748
|
+
const prefab = this.compiled.enemyPrefabs[type] ?? "";
|
|
749
|
+
const max = enemyMaxHealth(this.world, netId) ?? this.compiled.manifest.prefabs.get(prefab)?.health ?? 1;
|
|
750
|
+
const [x, z] = entityPosition(this.world, netId) ?? [0, 0];
|
|
751
|
+
return { prefab, health: this.bossHealth(netId), max, x: Math.round(x * 10) / 10, z: Math.round(z * 10) / 10 };
|
|
752
|
+
});
|
|
753
|
+
const first = bosses[0];
|
|
754
|
+
const boss = first ? { prefab: first.prefab, health: first.health, max: first.max } : null;
|
|
755
|
+
const rate = this.world.tickRate;
|
|
756
|
+
const round = this.compiled.round;
|
|
757
|
+
return {
|
|
758
|
+
run: this.runN,
|
|
759
|
+
phase: this.phase,
|
|
760
|
+
seconds: Math.floor(this.runTick / rate),
|
|
761
|
+
// A dungeon's cleared rooms are its waves: the run's progress either way.
|
|
762
|
+
wave: (this.emitted.get("wave.cleared") ?? 0) + (this.emitted.get("room.cleared") ?? 0) + 1,
|
|
763
|
+
kills: this.teamKills,
|
|
764
|
+
enemies: enemyCount(this.world),
|
|
765
|
+
boss,
|
|
766
|
+
bosses,
|
|
767
|
+
paused: this.pausedStep,
|
|
768
|
+
announce: this.announcement,
|
|
769
|
+
players: players.map((p) => ({
|
|
770
|
+
id: p.playerId,
|
|
771
|
+
level: p.level,
|
|
772
|
+
kills: this.kills.get(p.playerId) ?? 0,
|
|
773
|
+
downed: p.downed,
|
|
774
|
+
dead: p.dead,
|
|
775
|
+
deaths: this.deaths.get(p.playerId) ?? 0,
|
|
776
|
+
bot: isBot(p.playerId),
|
|
777
|
+
keeper: this.compiled.manifest.keepers.size > 0 ? this.keeperId(p.keeper) : null,
|
|
778
|
+
buffed: p.buffed,
|
|
779
|
+
counts: this.countsOf(p.playerId),
|
|
780
|
+
achievements: this.unlocked.get(p.playerId) ?? [],
|
|
781
|
+
ready: this.ready.has(p.playerId),
|
|
782
|
+
})),
|
|
783
|
+
next: this.upcomingAt ? { label: this.upcomingAt.label, in: Math.max(0, Math.ceil((this.upcomingAt.tick - this.runTick) / rate)) } : null,
|
|
784
|
+
summary: this.phase === "playing" ? null : this.summary(),
|
|
785
|
+
nextRunIn: this.phase === "playing" ? null : Math.max(0, Math.ceil((this.endTicks - this.phaseTicks) / rate)),
|
|
786
|
+
round: round ? { seconds: round.seconds, left: Math.max(0, Math.ceil((this.roundTicks - this.runTick) / rate)), frags: round.frags } : null,
|
|
787
|
+
feed: this.feed.map((f) => ({ by: f.by, victim: f.victim, ago: Math.floor((this.runTick - f.tick) / rate) })),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
bossHealth(netId) {
|
|
791
|
+
return entityHealth(this.world, netId) ?? 0;
|
|
792
|
+
}
|
|
793
|
+
/** Boss NetIds alive now. */
|
|
794
|
+
bossIds() {
|
|
795
|
+
return [...this.bosses.keys()];
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* A referee run starts for this player (the room's sign-in or round start):
|
|
799
|
+
* their earn counts and once-per-run payouts start over, and systems hear
|
|
800
|
+
* "run.started" (unpaid) so their own counters can start over too (a kill
|
|
801
|
+
* streak). A run started after this world began is never paid world or
|
|
802
|
+
* fight progress (boss.killed, wave.cleared, round.top, run.won, system
|
|
803
|
+
* emits: every event that is not the player's own act) until the next
|
|
804
|
+
* world, since that progress predates the run.
|
|
805
|
+
*/
|
|
806
|
+
startRun(player) {
|
|
807
|
+
this.earn.forget(player);
|
|
808
|
+
if (this.runTick > 0)
|
|
809
|
+
this.lateStart.add(player);
|
|
810
|
+
this.pending.push({ name: "run.started", players: [player], unpaid: true });
|
|
811
|
+
}
|
|
812
|
+
/** Earn counts for one player this run (HUD and tests). */
|
|
813
|
+
count(player, event) {
|
|
814
|
+
return this.earn.count(player, event);
|
|
815
|
+
}
|
|
816
|
+
/** Total number of times an event has fired this run. */
|
|
817
|
+
emittedCount(event) {
|
|
818
|
+
return this.emitted.get(event) ?? 0;
|
|
819
|
+
}
|
|
820
|
+
/** Kills by one player this run (arena: opponents). */
|
|
821
|
+
killsOf(player) {
|
|
822
|
+
return this.kills.get(player) ?? 0;
|
|
823
|
+
}
|
|
824
|
+
}
|