@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,301 @@
|
|
|
1
|
+
// Delta-compressed, bit-packed snapshots.
|
|
2
|
+
//
|
|
3
|
+
// The server keeps, per client, the quantized entity sets it sent for the
|
|
4
|
+
// last N snapshot ticks. Each new snapshot is encoded against the most recent
|
|
5
|
+
// one the client acknowledged (its "baseline"):
|
|
6
|
+
//
|
|
7
|
+
// - entities in the baseline but not in this snapshot are listed as removed
|
|
8
|
+
// (destroyed, or left the client's area of interest),
|
|
9
|
+
// - every other baseline entity costs one "changed?" bit, in id order (no
|
|
10
|
+
// ids on the wire for a crowd); changed ones send a per-field changed
|
|
11
|
+
// mask (or one bit when it repeats the previous changed entity's mask),
|
|
12
|
+
// and each changed field goes either as a small signed delta or in full,
|
|
13
|
+
// - entities absent from the baseline are sent in full ("new") by id gap.
|
|
14
|
+
//
|
|
15
|
+
// Both sides compare QUANTIZED integers, so the client's reconstruction is
|
|
16
|
+
// bit-identical to what the server believes it sent. With no acked baseline
|
|
17
|
+
// (first snapshot, or the ack fell out of history) the snapshot is full.
|
|
18
|
+
import { BitReader, BitWriter } from './bits.js';
|
|
19
|
+
import { fieldBits, fieldDeltaBits } from './schema.js';
|
|
20
|
+
export class BaselineMissingError extends Error {
|
|
21
|
+
baselineTick;
|
|
22
|
+
constructor(baselineTick) {
|
|
23
|
+
super(`snapshot baseline ${baselineTick} not in history`);
|
|
24
|
+
this.baselineTick = baselineTick;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const ANGLE_MOD = 65536;
|
|
28
|
+
/**
|
|
29
|
+
* Encodes `current` (any order) against `baseline` (null for a full snapshot).
|
|
30
|
+
*/
|
|
31
|
+
export function encodeSnapshot(schema, header, current, baseline) {
|
|
32
|
+
const w = new BitWriter(512);
|
|
33
|
+
w.writeBits(header.tick, 32);
|
|
34
|
+
w.writeBits(baseline ? header.baselineTick : 0, 32);
|
|
35
|
+
w.writeBits(header.ackSeq, 32);
|
|
36
|
+
w.writeBits(header.self, 16);
|
|
37
|
+
w.writeBool(header.inputBuffer !== undefined);
|
|
38
|
+
if (header.inputBuffer !== undefined)
|
|
39
|
+
w.writeBits(Math.max(0, Math.min(255, Math.round(header.inputBuffer))), 8);
|
|
40
|
+
const sorted = [...current].sort((a, b) => a.id - b.id);
|
|
41
|
+
const fields = schema.fields;
|
|
42
|
+
// Removed: in the baseline and gone now, or replaced by a different
|
|
43
|
+
// entity under the same id (kind or owner changed: removed, then new).
|
|
44
|
+
const removed = [];
|
|
45
|
+
const fresh = [];
|
|
46
|
+
const kept = new Map();
|
|
47
|
+
if (baseline) {
|
|
48
|
+
const live = new Map();
|
|
49
|
+
for (const e of sorted)
|
|
50
|
+
live.set(e.id, e);
|
|
51
|
+
for (const [id, b] of baseline) {
|
|
52
|
+
const e = live.get(id);
|
|
53
|
+
if (!e || e.kind !== b.kind || e.owner !== b.owner)
|
|
54
|
+
removed.push(id);
|
|
55
|
+
else
|
|
56
|
+
kept.set(id, e);
|
|
57
|
+
}
|
|
58
|
+
removed.sort((x, y) => x - y);
|
|
59
|
+
for (const e of sorted)
|
|
60
|
+
if (!kept.has(e.id))
|
|
61
|
+
fresh.push(e);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
fresh.push(...sorted);
|
|
65
|
+
}
|
|
66
|
+
w.writeVarUint(removed.length);
|
|
67
|
+
let prev = 0;
|
|
68
|
+
for (const id of removed) {
|
|
69
|
+
w.writeVarUint(id - prev);
|
|
70
|
+
prev = id;
|
|
71
|
+
}
|
|
72
|
+
// Kept entities: one bit each, in baseline id order (changed or not), so a
|
|
73
|
+
// crowd costs a bit per entity for "which ones", never an id. Changed ones
|
|
74
|
+
// follow with a field mask (or one bit when it repeats the previous mask).
|
|
75
|
+
if (baseline) {
|
|
76
|
+
const keptIds = [...kept.keys()].sort((x, y) => x - y);
|
|
77
|
+
let lastMask = -1;
|
|
78
|
+
let lastMaskLen = -1;
|
|
79
|
+
for (const id of keptIds) {
|
|
80
|
+
const e = kept.get(id);
|
|
81
|
+
const base = baseline.get(id);
|
|
82
|
+
const idx = kindFieldIndices(schema, e.kind);
|
|
83
|
+
let mask = 0;
|
|
84
|
+
for (let j = 0; j < idx.length; j++) {
|
|
85
|
+
const i = idx[j];
|
|
86
|
+
if (e.q[i] !== base.q[i])
|
|
87
|
+
mask |= 1 << j;
|
|
88
|
+
}
|
|
89
|
+
w.writeBool(mask !== 0);
|
|
90
|
+
if (mask === 0)
|
|
91
|
+
continue;
|
|
92
|
+
if (mask === lastMask && idx.length === lastMaskLen) {
|
|
93
|
+
w.writeBool(true);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
w.writeBool(false);
|
|
97
|
+
w.writeBits(mask, idx.length);
|
|
98
|
+
lastMask = mask;
|
|
99
|
+
lastMaskLen = idx.length;
|
|
100
|
+
}
|
|
101
|
+
for (let j = 0; j < idx.length; j++) {
|
|
102
|
+
if ((mask & (1 << j)) === 0)
|
|
103
|
+
continue;
|
|
104
|
+
const i = idx[j];
|
|
105
|
+
const f = fieldAt(fields, i);
|
|
106
|
+
const v = e.q[i] ?? 0;
|
|
107
|
+
const db = fieldDeltaBits(f);
|
|
108
|
+
if (db > 0) {
|
|
109
|
+
const d = fieldDelta(f, base.q[i] ?? 0, v);
|
|
110
|
+
if (fitsSigned(d, db)) {
|
|
111
|
+
w.writeBool(true);
|
|
112
|
+
w.writeSigned(d, db);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
w.writeBool(false);
|
|
116
|
+
}
|
|
117
|
+
writeFull(w, f, v);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// New entities, in full, by id gap.
|
|
122
|
+
w.writeVarUint(fresh.length);
|
|
123
|
+
prev = 0;
|
|
124
|
+
for (const e of fresh) {
|
|
125
|
+
w.writeVarUint(e.id - prev);
|
|
126
|
+
prev = e.id;
|
|
127
|
+
w.writeBits(e.kind, 8);
|
|
128
|
+
w.writeBits(e.owner, 16);
|
|
129
|
+
for (const i of kindFieldIndices(schema, e.kind))
|
|
130
|
+
writeFull(w, fieldAt(fields, i), e.q[i] ?? 0);
|
|
131
|
+
}
|
|
132
|
+
return w.finish();
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Decodes a snapshot. `baselineFor` returns the client's stored entity set
|
|
136
|
+
* for a tick it previously decoded.
|
|
137
|
+
*/
|
|
138
|
+
export function decodeSnapshot(schema, bytes, baselineFor) {
|
|
139
|
+
const r = new BitReader(bytes);
|
|
140
|
+
const tick = r.readBits(32);
|
|
141
|
+
const baselineTick = r.readBits(32);
|
|
142
|
+
const ackSeq = r.readBits(32);
|
|
143
|
+
const self = r.readBits(16);
|
|
144
|
+
const header = r.readBool() ? { tick, baselineTick, ackSeq, self, inputBuffer: r.readBits(8) } : { tick, baselineTick, ackSeq, self };
|
|
145
|
+
let base;
|
|
146
|
+
if (baselineTick !== 0) {
|
|
147
|
+
base = baselineFor(baselineTick);
|
|
148
|
+
if (!base)
|
|
149
|
+
throw new BaselineMissingError(baselineTick);
|
|
150
|
+
}
|
|
151
|
+
const out = new Map(base ?? []);
|
|
152
|
+
const removedCount = r.readVarUint();
|
|
153
|
+
const removed = [];
|
|
154
|
+
let prev = 0;
|
|
155
|
+
for (let i = 0; i < removedCount; i++) {
|
|
156
|
+
const id = prev + r.readVarUint();
|
|
157
|
+
prev = id;
|
|
158
|
+
removed.push(id);
|
|
159
|
+
out.delete(id);
|
|
160
|
+
}
|
|
161
|
+
const fields = schema.fields;
|
|
162
|
+
const nf = fields.length;
|
|
163
|
+
if (base) {
|
|
164
|
+
const keptIds = [...out.keys()].sort((x, y) => x - y);
|
|
165
|
+
let lastMask = -1;
|
|
166
|
+
let lastMaskLen = -1;
|
|
167
|
+
for (const id of keptIds) {
|
|
168
|
+
if (!r.readBool())
|
|
169
|
+
continue;
|
|
170
|
+
const prevE = out.get(id);
|
|
171
|
+
const idx = kindFieldIndices(schema, prevE.kind);
|
|
172
|
+
let mask;
|
|
173
|
+
if (r.readBool()) {
|
|
174
|
+
if (idx.length !== lastMaskLen)
|
|
175
|
+
throw new Error('snapshot: repeated mask for a different field set');
|
|
176
|
+
mask = lastMask;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
mask = r.readBits(idx.length);
|
|
180
|
+
lastMask = mask;
|
|
181
|
+
lastMaskLen = idx.length;
|
|
182
|
+
}
|
|
183
|
+
const q = [...prevE.q];
|
|
184
|
+
for (let j = 0; j < idx.length; j++) {
|
|
185
|
+
if ((mask & (1 << j)) === 0)
|
|
186
|
+
continue;
|
|
187
|
+
const i = idx[j];
|
|
188
|
+
const f = fieldAt(fields, i);
|
|
189
|
+
const db = fieldDeltaBits(f);
|
|
190
|
+
if (db > 0 && r.readBool()) {
|
|
191
|
+
q[i] = applyDelta(f, prevE.q[i] ?? 0, r.readSigned(db));
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
q[i] = readFull(r, f);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
out.set(id, { id, kind: prevE.kind, owner: prevE.owner, q });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const count = r.readVarUint();
|
|
201
|
+
prev = 0;
|
|
202
|
+
for (let n = 0; n < count; n++) {
|
|
203
|
+
const id = prev + r.readVarUint();
|
|
204
|
+
prev = id;
|
|
205
|
+
const kind = r.readBits(8);
|
|
206
|
+
const owner = r.readBits(16);
|
|
207
|
+
const q = new Array(nf).fill(0);
|
|
208
|
+
for (const i of kindFieldIndices(schema, kind))
|
|
209
|
+
q[i] = readFull(r, fieldAt(fields, i));
|
|
210
|
+
out.set(id, { id, kind, owner, q });
|
|
211
|
+
}
|
|
212
|
+
return { header, entities: out, removed };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Per-client history of what was sent, keyed by tick, so the next snapshot
|
|
216
|
+
* can be encoded against whatever the client last acked.
|
|
217
|
+
*/
|
|
218
|
+
export class SnapshotHistory {
|
|
219
|
+
capacity;
|
|
220
|
+
byTick = new Map();
|
|
221
|
+
order = [];
|
|
222
|
+
constructor(capacity = 64) {
|
|
223
|
+
this.capacity = capacity;
|
|
224
|
+
}
|
|
225
|
+
put(tick, entities) {
|
|
226
|
+
const m = entities instanceof Map ? entities : new Map(entities.map((e) => [e.id, e]));
|
|
227
|
+
if (!this.byTick.has(tick))
|
|
228
|
+
this.order.push(tick);
|
|
229
|
+
this.byTick.set(tick, m);
|
|
230
|
+
while (this.order.length > this.capacity) {
|
|
231
|
+
const old = this.order.shift();
|
|
232
|
+
if (old !== undefined)
|
|
233
|
+
this.byTick.delete(old);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
get(tick) {
|
|
237
|
+
return this.byTick.get(tick);
|
|
238
|
+
}
|
|
239
|
+
/** Drop entries older than `tick` (they can never be a baseline again). */
|
|
240
|
+
pruneBefore(tick) {
|
|
241
|
+
while (this.order.length > 0 && (this.order[0] ?? Infinity) < tick) {
|
|
242
|
+
const old = this.order.shift();
|
|
243
|
+
if (old !== undefined)
|
|
244
|
+
this.byTick.delete(old);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
get size() {
|
|
248
|
+
return this.order.length;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const allIndices = new WeakMap();
|
|
252
|
+
/** Field indices carried on the wire for an entity kind (all fields unless the schema narrows that kind). */
|
|
253
|
+
export function kindFieldIndices(schema, kind) {
|
|
254
|
+
const narrowed = schema.kindFields?.[kind];
|
|
255
|
+
if (narrowed)
|
|
256
|
+
return narrowed;
|
|
257
|
+
let all = allIndices.get(schema);
|
|
258
|
+
if (!all) {
|
|
259
|
+
all = schema.fields.map((_, i) => i);
|
|
260
|
+
allIndices.set(schema, all);
|
|
261
|
+
}
|
|
262
|
+
return all;
|
|
263
|
+
}
|
|
264
|
+
function fieldAt(fields, i) {
|
|
265
|
+
const f = fields[i];
|
|
266
|
+
if (f === undefined)
|
|
267
|
+
throw new Error(`schema has no field ${i}`);
|
|
268
|
+
return f;
|
|
269
|
+
}
|
|
270
|
+
function writeFull(w, f, v) {
|
|
271
|
+
if (f.kind === 'int')
|
|
272
|
+
w.writeSigned(v, f.bits);
|
|
273
|
+
else
|
|
274
|
+
w.writeBits(v, fieldBits(f));
|
|
275
|
+
}
|
|
276
|
+
function readFull(r, f) {
|
|
277
|
+
if (f.kind === 'int')
|
|
278
|
+
return r.readSigned(f.bits);
|
|
279
|
+
return r.readBits(fieldBits(f));
|
|
280
|
+
}
|
|
281
|
+
function fieldDelta(f, from, to) {
|
|
282
|
+
if (f.kind === 'angle16') {
|
|
283
|
+
let d = (to - from) % ANGLE_MOD;
|
|
284
|
+
if (d >= ANGLE_MOD / 2)
|
|
285
|
+
d -= ANGLE_MOD;
|
|
286
|
+
if (d < -ANGLE_MOD / 2)
|
|
287
|
+
d += ANGLE_MOD;
|
|
288
|
+
return d;
|
|
289
|
+
}
|
|
290
|
+
return to - from;
|
|
291
|
+
}
|
|
292
|
+
function applyDelta(f, from, d) {
|
|
293
|
+
if (f.kind === 'angle16')
|
|
294
|
+
return (((from + d) % ANGLE_MOD) + ANGLE_MOD) % ANGLE_MOD;
|
|
295
|
+
return from + d;
|
|
296
|
+
}
|
|
297
|
+
function fitsSigned(d, bits) {
|
|
298
|
+
// zigzag(d) must fit in `bits` bits
|
|
299
|
+
const z = d >= 0 ? d * 2 : -d * 2 - 1;
|
|
300
|
+
return z < 2 ** bits;
|
|
301
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SimAdapter for board genres (rules/kit.ts): the room server steps a
|
|
3
|
+
* genre's rules on a fixed tick; the client predicts only its own avatar.
|
|
4
|
+
*
|
|
5
|
+
* The adapter, not the genre, owns what makes a game pay fairly, the same
|
|
6
|
+
* way GameRunner does for the action templates:
|
|
7
|
+
* - earn rules counted per player per run (EarnTracker), seat bots never
|
|
8
|
+
* earn, unpaid events pay nobody, and world progress never pays a run
|
|
9
|
+
* that started after this world began;
|
|
10
|
+
* - store upgrades and losable gear as recorded room commands (a replay
|
|
11
|
+
* applies them at the same tick), capped at each upgrade's maxLevel;
|
|
12
|
+
* - cosmetics on the avatar;
|
|
13
|
+
* - the end screen, then `run.reset` so the room rebuilds the world and
|
|
14
|
+
* starts every seat's next referee run.
|
|
15
|
+
*/
|
|
16
|
+
import { EarnTracker } from "../game/earn.js";
|
|
17
|
+
import type { GameManifest } from "../api/registry.js";
|
|
18
|
+
import type { PlayerId, SimAdapter } from "../net/sim-adapter.js";
|
|
19
|
+
import type { Avatar, RulesGenre, RulesInput, RunOutcome } from "./kit.js";
|
|
20
|
+
export interface RulesWorld<S = unknown> {
|
|
21
|
+
readonly role: "server" | "client";
|
|
22
|
+
/** The genre's state (server only). */
|
|
23
|
+
state: S | null;
|
|
24
|
+
readonly avatars: Map<PlayerId, Avatar>;
|
|
25
|
+
tick: number;
|
|
26
|
+
/** Ticks into the run (0 until the first step). */
|
|
27
|
+
runTick: number;
|
|
28
|
+
phase: "playing" | "ended";
|
|
29
|
+
outcome: RunOutcome | null;
|
|
30
|
+
endTick: number;
|
|
31
|
+
readonly earn: EarnTracker | null;
|
|
32
|
+
/** Players whose referee run started after this world made progress. */
|
|
33
|
+
readonly late: Set<PlayerId>;
|
|
34
|
+
/** World or fight progress (any event that is not a player's own act) has happened in this world. */
|
|
35
|
+
progressed: boolean;
|
|
36
|
+
/** Store upgrade levels per player: upgrade id -> level. */
|
|
37
|
+
readonly upgrades: Map<PlayerId, Map<string, number>>;
|
|
38
|
+
/** Earn rules paid this run, per player (HUD). */
|
|
39
|
+
readonly paid: Map<PlayerId, Set<string>>;
|
|
40
|
+
/** Seat bots present. */
|
|
41
|
+
readonly bots: Set<PlayerId>;
|
|
42
|
+
/** Total earn awards this world (harness). */
|
|
43
|
+
awards: number;
|
|
44
|
+
}
|
|
45
|
+
export interface RulesAdapterOptions {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly version: string;
|
|
48
|
+
}
|
|
49
|
+
/** The SimAdapter for a board genre built from a game manifest. */
|
|
50
|
+
export declare function rulesAdapter<S>(genre: RulesGenre<S>, manifest: GameManifest, opts: RulesAdapterOptions): SimAdapter<RulesWorld<S>, RulesInput> & {
|
|
51
|
+
readonly genre: RulesGenre<S>;
|
|
52
|
+
readonly manifest: GameManifest;
|
|
53
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SimAdapter for board genres (rules/kit.ts): the room server steps a
|
|
3
|
+
* genre's rules on a fixed tick; the client predicts only its own avatar.
|
|
4
|
+
*
|
|
5
|
+
* The adapter, not the genre, owns what makes a game pay fairly, the same
|
|
6
|
+
* way GameRunner does for the action templates:
|
|
7
|
+
* - earn rules counted per player per run (EarnTracker), seat bots never
|
|
8
|
+
* earn, unpaid events pay nobody, and world progress never pays a run
|
|
9
|
+
* that started after this world began;
|
|
10
|
+
* - store upgrades and losable gear as recorded room commands (a replay
|
|
11
|
+
* applies them at the same tick), capped at each upgrade's maxLevel;
|
|
12
|
+
* - cosmetics on the avatar;
|
|
13
|
+
* - the end screen, then `run.reset` so the room rebuilds the world and
|
|
14
|
+
* starts every seat's next referee run.
|
|
15
|
+
*/
|
|
16
|
+
import { EarnTracker } from "../game/earn.js";
|
|
17
|
+
import { avatarEntity, IDLE_INPUT, isRulesBot, RF, RULES_BOT_BASE, RULES_SCHEMA, rulesInputCodec, RulesKind } from "./kit.js";
|
|
18
|
+
/** The SimAdapter for a board genre built from a game manifest. */
|
|
19
|
+
export function rulesAdapter(genre, manifest, opts) {
|
|
20
|
+
const upgrades = manifest.upgrades;
|
|
21
|
+
const skus = manifest.skus;
|
|
22
|
+
const gear = new Set([...skus.values()].flatMap((s) => (s.grants && "gear" in s.grants ? [s.grants.gear] : [])));
|
|
23
|
+
const endTicks = Math.max(1, Math.round(genre.endSeconds * genre.tickRate));
|
|
24
|
+
const ctxOf = (w) => ({
|
|
25
|
+
bonus(player, stat) {
|
|
26
|
+
const mine = w.upgrades.get(player);
|
|
27
|
+
if (!mine)
|
|
28
|
+
return 0;
|
|
29
|
+
let b = 0;
|
|
30
|
+
for (const [id, lvl] of mine) {
|
|
31
|
+
const u = upgrades.get(id);
|
|
32
|
+
if (u && u.stat === stat)
|
|
33
|
+
b += u.step * lvl;
|
|
34
|
+
}
|
|
35
|
+
return b;
|
|
36
|
+
},
|
|
37
|
+
isBot: (p) => isRulesBot(p),
|
|
38
|
+
});
|
|
39
|
+
/** Earn credits people only; unpaid events pay nobody; world progress skips late runs. */
|
|
40
|
+
const record = (w, ev, out) => {
|
|
41
|
+
if (!w.earn)
|
|
42
|
+
return;
|
|
43
|
+
if (ev.own !== true && !ev.unpaid)
|
|
44
|
+
w.progressed = true;
|
|
45
|
+
if (ev.unpaid)
|
|
46
|
+
return;
|
|
47
|
+
const late = ev.own === true || w.late.size === 0 ? null : w.late;
|
|
48
|
+
const players = ev.players.filter((p) => !isRulesBot(p) && w.avatars.has(p) && !(late?.has(p) ?? false));
|
|
49
|
+
if (players.length === 0)
|
|
50
|
+
return;
|
|
51
|
+
for (const a of w.earn.record({ name: ev.name, players })) {
|
|
52
|
+
out.push({ type: "earn", player: a.player, rule: a.rule, units: a.units });
|
|
53
|
+
let set = w.paid.get(a.player);
|
|
54
|
+
if (!set)
|
|
55
|
+
w.paid.set(a.player, (set = new Set()));
|
|
56
|
+
set.add(a.rule);
|
|
57
|
+
w.awards++;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const addAvatar = (w, player) => {
|
|
61
|
+
const at = genre.spawn(w.state, player);
|
|
62
|
+
w.avatars.set(player, { x: at.x, z: at.z, yaw: 0, cosmetic: 0, flags: 0 });
|
|
63
|
+
if (w.state)
|
|
64
|
+
genre.join(w.state, player);
|
|
65
|
+
return player;
|
|
66
|
+
};
|
|
67
|
+
const removeAvatar = (w, player) => {
|
|
68
|
+
if (!w.avatars.delete(player))
|
|
69
|
+
return;
|
|
70
|
+
if (w.state)
|
|
71
|
+
genre.leave(w.state, player);
|
|
72
|
+
w.earn?.forget(player);
|
|
73
|
+
w.upgrades.delete(player);
|
|
74
|
+
w.paid.delete(player);
|
|
75
|
+
w.late.delete(player);
|
|
76
|
+
w.bots.delete(player);
|
|
77
|
+
};
|
|
78
|
+
/** Seat bots: as many as the genre wants with the people present, one change a tick, only while a person is here. */
|
|
79
|
+
const fillSeats = (w) => {
|
|
80
|
+
if (!genre.seats || !w.state)
|
|
81
|
+
return;
|
|
82
|
+
let humans = 0;
|
|
83
|
+
for (const p of w.avatars.keys())
|
|
84
|
+
if (!isRulesBot(p))
|
|
85
|
+
humans++;
|
|
86
|
+
const want = humans > 0 ? Math.max(0, genre.seats(w.state, humans)) : 0;
|
|
87
|
+
if (w.bots.size < want) {
|
|
88
|
+
let id = RULES_BOT_BASE;
|
|
89
|
+
while (w.bots.has(id))
|
|
90
|
+
id++;
|
|
91
|
+
w.bots.add(id);
|
|
92
|
+
addAvatar(w, id);
|
|
93
|
+
}
|
|
94
|
+
else if (w.bots.size > want) {
|
|
95
|
+
const id = Math.max(...w.bots);
|
|
96
|
+
removeAvatar(w, id);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const level = (w, player, id) => {
|
|
100
|
+
const u = upgrades.get(id);
|
|
101
|
+
if (!u || !w.avatars.has(player))
|
|
102
|
+
return false;
|
|
103
|
+
let mine = w.upgrades.get(player);
|
|
104
|
+
if (!mine)
|
|
105
|
+
w.upgrades.set(player, (mine = new Map()));
|
|
106
|
+
const now = mine.get(id) ?? 0;
|
|
107
|
+
if (now >= u.maxLevel)
|
|
108
|
+
return false;
|
|
109
|
+
mine.set(id, now + 1);
|
|
110
|
+
return true;
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
name: opts.name,
|
|
114
|
+
version: opts.version,
|
|
115
|
+
tickHz: genre.tickRate,
|
|
116
|
+
schema: RULES_SCHEMA,
|
|
117
|
+
input: rulesInputCodec,
|
|
118
|
+
maxInputsPerTick: 1,
|
|
119
|
+
infoEveryTicks: genre.infoEveryTicks,
|
|
120
|
+
genre,
|
|
121
|
+
manifest,
|
|
122
|
+
createWorld(seed, role) {
|
|
123
|
+
const w = {
|
|
124
|
+
role,
|
|
125
|
+
state: null,
|
|
126
|
+
avatars: new Map(),
|
|
127
|
+
tick: 0,
|
|
128
|
+
runTick: 0,
|
|
129
|
+
phase: "playing",
|
|
130
|
+
outcome: null,
|
|
131
|
+
endTick: 0,
|
|
132
|
+
earn: role === "server" ? new EarnTracker(manifest.earnRules) : null,
|
|
133
|
+
late: new Set(),
|
|
134
|
+
progressed: false,
|
|
135
|
+
upgrades: new Map(),
|
|
136
|
+
paid: new Map(),
|
|
137
|
+
bots: new Set(),
|
|
138
|
+
awards: 0,
|
|
139
|
+
};
|
|
140
|
+
if (role === "server")
|
|
141
|
+
w.state = genre.create(seed >>> 0, ctxOf(w));
|
|
142
|
+
return w;
|
|
143
|
+
},
|
|
144
|
+
addPlayer(w, player) {
|
|
145
|
+
return addAvatar(w, player);
|
|
146
|
+
},
|
|
147
|
+
removePlayer(w, player) {
|
|
148
|
+
removeAvatar(w, player);
|
|
149
|
+
},
|
|
150
|
+
step(w, inputsByPlayer) {
|
|
151
|
+
w.tick++;
|
|
152
|
+
const out = [];
|
|
153
|
+
const s = w.state;
|
|
154
|
+
// The client predicts its own avatar only.
|
|
155
|
+
if (!s) {
|
|
156
|
+
for (const [p, list] of inputsByPlayer) {
|
|
157
|
+
const a = w.avatars.get(p);
|
|
158
|
+
const i = list[0];
|
|
159
|
+
if (a && i)
|
|
160
|
+
genre.moveAvatar(a, i);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
fillSeats(w);
|
|
165
|
+
const emit = (e) => record(w, e, out);
|
|
166
|
+
for (const [p, a] of w.avatars) {
|
|
167
|
+
const i = isRulesBot(p) ? genre.bot(s, p, a, w.tick, 0.8) : (inputsByPlayer.get(p)?.[0] ?? IDLE_INPUT);
|
|
168
|
+
genre.moveAvatar(a, i);
|
|
169
|
+
if (w.phase === "playing" && i.act !== 0)
|
|
170
|
+
genre.act(s, p, i, a, emit);
|
|
171
|
+
if (w.phase === "playing")
|
|
172
|
+
genre.tend?.(s, p, a, emit);
|
|
173
|
+
}
|
|
174
|
+
if (w.phase === "playing") {
|
|
175
|
+
genre.step(s, emit);
|
|
176
|
+
w.runTick++;
|
|
177
|
+
const o = genre.outcome(s);
|
|
178
|
+
if (o) {
|
|
179
|
+
w.phase = "ended";
|
|
180
|
+
w.outcome = o;
|
|
181
|
+
w.endTick = w.tick + endTicks;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (w.tick >= w.endTick) {
|
|
185
|
+
out.push({ type: "run.reset" });
|
|
186
|
+
// The room rebuilds the world at its safe point; until then nothing moves.
|
|
187
|
+
w.endTick = Number.MAX_SAFE_INTEGER;
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
},
|
|
191
|
+
snapshot(w) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const [p, a] of w.avatars)
|
|
194
|
+
out.push(avatarEntity(p, a));
|
|
195
|
+
if (w.state)
|
|
196
|
+
genre.entities(w.state, out);
|
|
197
|
+
return out;
|
|
198
|
+
},
|
|
199
|
+
applySnapshot(w, entities) {
|
|
200
|
+
for (const e of entities) {
|
|
201
|
+
if (e.kind !== RulesKind.Avatar)
|
|
202
|
+
continue;
|
|
203
|
+
const v = e.values;
|
|
204
|
+
const yaw = Math.round(((v[RF.yaw] ?? 0) / (Math.PI * 2)) * 65536) & 0xffff;
|
|
205
|
+
const a = w.avatars.get(e.owner);
|
|
206
|
+
if (a) {
|
|
207
|
+
a.x = Math.round(v[RF.x] ?? 0);
|
|
208
|
+
a.z = Math.round(v[RF.z] ?? 0);
|
|
209
|
+
a.yaw = yaw;
|
|
210
|
+
a.flags = Math.round(v[RF.flags] ?? 0);
|
|
211
|
+
a.cosmetic = Math.round(v[RF.value] ?? 0);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
w.avatars.set(e.owner, { x: Math.round(v[RF.x] ?? 0), z: Math.round(v[RF.z] ?? 0), yaw, cosmetic: Math.round(v[RF.value] ?? 0), flags: Math.round(v[RF.flags] ?? 0) });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
info(w) {
|
|
219
|
+
if (!w.state)
|
|
220
|
+
return null;
|
|
221
|
+
return { ...genre.view(w.state, null), phase: w.phase, outcome: w.outcome, endsIn: w.phase === "ended" ? Math.max(0, (w.endTick - w.tick) / genre.tickRate) : null };
|
|
222
|
+
},
|
|
223
|
+
infoFor(w, player) {
|
|
224
|
+
if (!w.state)
|
|
225
|
+
return null;
|
|
226
|
+
const progress = [...manifest.earnRules].map(([id, r]) => ({ id, when: r.when, n: r.n, have: Math.min(r.n, w.earn?.count(player, r.when) ?? 0), paid: w.paid.get(player)?.has(id) ?? false }));
|
|
227
|
+
const ups = Object.fromEntries(w.upgrades.get(player) ?? []);
|
|
228
|
+
return {
|
|
229
|
+
...genre.view(w.state, player),
|
|
230
|
+
you: player,
|
|
231
|
+
phase: w.phase,
|
|
232
|
+
outcome: w.outcome,
|
|
233
|
+
endsIn: w.phase === "ended" ? Math.max(0, Math.round(((w.endTick - w.tick) / genre.tickRate) * 10) / 10) : null,
|
|
234
|
+
earn: progress,
|
|
235
|
+
upgrades: ups,
|
|
236
|
+
late: w.late.has(player),
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
startRun(w, player) {
|
|
240
|
+
w.earn?.forget(player);
|
|
241
|
+
w.paid.delete(player);
|
|
242
|
+
// A run that starts after the world made progress is never paid for that progress. A run that starts
|
|
243
|
+
// before any (the first player's sign-in lands a few ticks after the world begins) is not late.
|
|
244
|
+
if (w.progressed)
|
|
245
|
+
w.late.add(player);
|
|
246
|
+
},
|
|
247
|
+
command(w, player, cmd) {
|
|
248
|
+
const a = w.avatars.get(player);
|
|
249
|
+
if (!a)
|
|
250
|
+
return false;
|
|
251
|
+
if (cmd.name === "cosmetic") {
|
|
252
|
+
a.cosmetic = Math.max(0, Math.min(255, Math.round(cmd.value ?? 0)));
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
// A store upgrade (a SKU named after a declared upgrade) or gear the room issues at a staked run's start: one level.
|
|
256
|
+
if (upgrades.has(cmd.name) && (skus.has(cmd.name) || gear.has(cmd.name)))
|
|
257
|
+
return level(w, player, cmd.name);
|
|
258
|
+
return false;
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|