@driftengine/tools 4.0.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.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Which of two sets of component hashes disagree.
3
+ *
4
+ * **Here rather than beside the divergence search, because a shipped game gets this half.** The
5
+ * search walks a timeline, which only the editor has; comparing what two peers hashed is what an
6
+ * in-game network panel does the moment a rollback session desynchronises, and that is a panel a
7
+ * game carries. `editor/src/pie/divergence.ts` holds the search and imports these.
8
+ */
9
+ /**
10
+ * Which components two sides hash differently, into `out`. Returns how many.
11
+ *
12
+ * A component only one side has is a divergence too, and the loudest kind: it means one peer
13
+ * created or destroyed something the other did not.
14
+ */
15
+ export function divergentComponents(ours, theirs, out) {
16
+ out.length = 0;
17
+ for (const [name, hash] of ours) {
18
+ if (theirs.get(name) !== hash)
19
+ out.push(name);
20
+ }
21
+ for (const name of theirs.keys()) {
22
+ if (!ours.has(name))
23
+ out.push(name);
24
+ }
25
+ return out.length;
26
+ }
27
+ /**
28
+ * Whether the difference is in something nobody hashed.
29
+ *
30
+ * The honest answer when the whole-world fingerprints disagree and every recorded component
31
+ * agrees: the state that differs was not in the breakdown. Reporting agreement there would be a
32
+ * panel saying everything is fine on a frame that is provably not.
33
+ */
34
+ export function divergenceIsUnaccounted(differing, worldsDiffer) {
35
+ return worldsDiffer && differing === 0;
36
+ }
@@ -0,0 +1,34 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * The editor panels a shipped game can carry.
4
+ *
5
+ * **The argument is the split, not the panels.** An inspector, a console, a profiler and a network
6
+ * panel are what somebody wants while a game is running — on a tester's machine, on a console
7
+ * devkit, in a build nobody can attach a debugger to. A scene tree and an asset browser are not:
8
+ * they need a project, and a shipped game has none. So those two stayed in the editor and these
9
+ * four became a package, and **a game that never imports this package pays nothing at all** — which
10
+ * is a claim the size gate makes rather than a claim this paragraph makes.
11
+ *
12
+ * **The command stack came with them, because the alternative is an in-game inspector that cannot
13
+ * undo.** `Panel.route` returns a `Command` or nothing, which is what makes "a panel mutates only
14
+ * through commands" a signature rather than a convention; a game carrying the panels carries that
15
+ * rule too, and gets undo for free.
16
+ *
17
+ * **`Selection` came with them for the same reason**: the inspector's whole subject is what is
18
+ * selected, and three panels sharing one selection by reference is how the editor already works.
19
+ */
20
+ export type { Command, UndoStack } from './command.ts';
21
+ export { createUndoStack } from './command.ts';
22
+ export type { Panel, UiEvent } from './panel.ts';
23
+ export { PANEL_PREFIX, createPanelRoot, emptyPanel, keyEvent, panelIdOf, pointerEvent, treeShape, wheelEvent, } from './panel.ts';
24
+ export type { Selection } from './selection.ts';
25
+ export { addToSelection, clearSelection, createSelection, isSelected, primarySelection, selectOnly, selectRange, selectedEntities, toggleSelection, } from './selection.ts';
26
+ export { divergenceIsUnaccounted, divergentComponents } from './divergence.ts';
27
+ export type { InspectableWorld, InspectorRow, InspectorView, InspectorWorld, RowKind, } from './inspector.ts';
28
+ export { MIXED, addComponentCommand, createInspectorView, fieldEditable, inspectorPanel, inspectorRows, removeComponentCommand, rowText, setFieldCommand, } from './inspector.ts';
29
+ export type { ConsoleView, ConsoleWorld, LogEntry, LogFilter, LogRing, Severity, SourceCursor, } from './console.ts';
30
+ export { DISPLAY_LIMIT, appendLog, consolePanel, createConsoleView, createLogFilter, createLogRing, displayText, filteredEntries, jumpToSourceCommand, logEntries, } from './console.ts';
31
+ export type { FrameHistory, ProfilerRow, ProfilerView, ProfilerWorld } from './profiler.ts';
32
+ export { createFrameHistory, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, } from './profiler.ts';
33
+ export type { NetworkReadout, NetworkView, NetworkWorld, RollbackHistory } from './network.ts';
34
+ export { componentDivergence, createNetworkView, createRollbackHistory, networkPanel, pushRollback, rollbackValues, } from './network.ts';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ export { createUndoStack } from './command.js';
3
+ export { PANEL_PREFIX, createPanelRoot, emptyPanel, keyEvent, panelIdOf, pointerEvent, treeShape, wheelEvent, } from './panel.js';
4
+ export { addToSelection, clearSelection, createSelection, isSelected, primarySelection, selectOnly, selectRange, selectedEntities, toggleSelection, } from './selection.js';
5
+ export { divergenceIsUnaccounted, divergentComponents } from './divergence.js';
6
+ export { MIXED, addComponentCommand, createInspectorView, fieldEditable, inspectorPanel, inspectorRows, removeComponentCommand, rowText, setFieldCommand, } from './inspector.js';
7
+ export { DISPLAY_LIMIT, appendLog, consolePanel, createConsoleView, createLogFilter, createLogRing, displayText, filteredEntries, jumpToSourceCommand, logEntries, } from './console.js';
8
+ export { createFrameHistory, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, } from './profiler.js';
9
+ export { componentDivergence, createNetworkView, createRollbackHistory, networkPanel, pushRollback, rollbackValues, } from './network.js';
@@ -0,0 +1,86 @@
1
+ import type { UiNode } from '@driftengine/ui2d';
2
+ import { type FieldKind } from '@driftengine/editor';
3
+ import type { Command } from './command.ts';
4
+ import { type Selection } from './selection.ts';
5
+ import { type Panel } from './panel.ts';
6
+ /**
7
+ * What the inspector needs of a world.
8
+ *
9
+ * **Four reads and three writes**, so a panel can be tested against plain maps — this plan's second
10
+ * constraint — and so the same panel serves `@driftengine/entities`, a `.drs` file's declarations
11
+ * and anything else that can answer these. A caller holding a `World` wraps it in seven lines.
12
+ */
13
+ export interface InspectableWorld {
14
+ componentsOf(entity: number): readonly string[];
15
+ schemaOf(component: string): readonly {
16
+ readonly name: string;
17
+ readonly type: string;
18
+ }[];
19
+ read(entity: number, component: string, field: string): unknown;
20
+ write(entity: number, component: string, field: string, value: unknown): void;
21
+ hasComponent(entity: number, component: string): boolean;
22
+ addComponent(entity: number, component: string): void;
23
+ removeComponent(entity: number, component: string): void;
24
+ }
25
+ /** What a row's value is when the selected entities disagree about it. */
26
+ export declare const MIXED: unique symbol;
27
+ /** A vector row is three number fields; anything else is one field. */
28
+ export type RowKind = FieldKind | 'vector';
29
+ export interface InspectorRow {
30
+ readonly component: string;
31
+ /** What the row is called: a field's name, or the prefix the vector's three share. */
32
+ readonly label: string;
33
+ /** The declared type, verbatim. Empty for a vector, which is three of them. */
34
+ readonly type: string;
35
+ readonly kind: RowKind;
36
+ readonly enumName: string;
37
+ /** The schema fields this row writes, in order. One, or three for a vector. */
38
+ readonly fields: readonly string[];
39
+ /** The value, `MIXED` where the selection disagrees. An array of three for a vector. */
40
+ readonly value: unknown;
41
+ readonly editable: boolean;
42
+ }
43
+ export declare function fieldEditable(type: string): boolean;
44
+ /**
45
+ * The rows for a selection: the components every selected entity has, field by field.
46
+ *
47
+ * Components are taken from the first entity and then filtered by the rest, so the order is stable
48
+ * — a row that moved between frames because a set iterated differently is a field somebody clicks
49
+ * and misses.
50
+ */
51
+ export declare function inspectorRows(world: InspectableWorld, entities: readonly number[]): InspectorRow[];
52
+ /**
53
+ * Write one row's fields across the whole selection, as one entry.
54
+ *
55
+ * **One entry for the whole selection and for all three of a vector's components.** Editing with
56
+ * ten entities selected is one act, and undo that took it back one entity at a time would make the
57
+ * person count. Each entity's own previous value is recorded, so undo does not flatten them onto
58
+ * whatever the first one held.
59
+ */
60
+ export declare function setFieldCommand(world: InspectableWorld, entities: readonly number[], component: string, fields: readonly string[], values: readonly unknown[]): Command | null;
61
+ export declare function addComponentCommand(world: InspectableWorld, entities: readonly number[], component: string): Command | null;
62
+ /**
63
+ * Remove a component, keeping its values so undo can put them back.
64
+ *
65
+ * **The values are read before the removal and written after the restore.** A remove that only
66
+ * remembers "it was there" gives back a component full of zeroes, which reads as undo having
67
+ * worked and is the worse failure: nothing looks broken.
68
+ */
69
+ export declare function removeComponentCommand(world: InspectableWorld, entities: readonly number[], component: string): Command | null;
70
+ export interface InspectorWorld {
71
+ readonly world: InspectableWorld;
72
+ }
73
+ export interface InspectorView {
74
+ readonly root: UiNode;
75
+ readonly selection: Selection;
76
+ /** The rows the last build produced, so routing can find a field by index. */
77
+ rows: InspectorRow[];
78
+ rowHeight: number;
79
+ }
80
+ export declare function createInspectorView(options: {
81
+ selection: Selection;
82
+ rowHeight?: number;
83
+ }): InspectorView;
84
+ /** What a row shows to the right of its label. `—` is the mark for a disagreement. */
85
+ export declare function rowText(row: InspectorRow): string;
86
+ export declare const inspectorPanel: Panel<InspectorWorld, InspectorView>;
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The components of whatever is selected, generated from the schema.
3
+ *
4
+ * **Schema-driven and not a drawer per type**, which is the decision that decides whether an editor
5
+ * stays maintainable. A drawer per component type means every new component needs editor work, so
6
+ * components stop being added — and it is the one thing reflection makes unnecessary, because a
7
+ * component already describes itself. `fieldKindOf` in `@driftengine/editor` already turns a
8
+ * declared type into what to draw, and this uses it rather than repeating it.
9
+ *
10
+ * **A value several entities disagree about is marked, never guessed at.** Showing the first
11
+ * entity's is how somebody types into a field, sees nothing change, and silently overwrites the
12
+ * other nine.
13
+ *
14
+ * **A type this cannot edit is shown read-only rather than dropped.** A field that is silently not
15
+ * there is a field somebody spends an afternoon looking for.
16
+ */
17
+ import { addUiChild, createUiNode } from '@driftengine/ui2d';
18
+ import { fieldKindOf } from '@driftengine/editor';
19
+ import { selectedEntities } from './selection.js';
20
+ import { createPanelRoot, emptyPanel } from './panel.js';
21
+ /** What a row's value is when the selected entities disagree about it. */
22
+ export const MIXED = Symbol('mixed');
23
+ /**
24
+ * The declared types this can edit.
25
+ *
26
+ * **A list rather than `fieldKindOf`'s answer**, because that function's fallback is `number` — it
27
+ * is asked what *kind* a known type is, not whether a type is known, and `MeshHandle` comes back as
28
+ * a number that would then be typed into. The two questions are different and the second one is
29
+ * this file's.
30
+ */
31
+ const KNOWN = new Set([
32
+ 'f32',
33
+ 'f64',
34
+ 'i8',
35
+ 'i16',
36
+ 'i32',
37
+ 'i64',
38
+ 'u8',
39
+ 'u16',
40
+ 'u32',
41
+ 'u64',
42
+ 'bool',
43
+ 'String',
44
+ 'Entity',
45
+ ]);
46
+ export function fieldEditable(type) {
47
+ const inner = type.startsWith('option:') ? type.slice('option:'.length) : type;
48
+ return KNOWN.has(inner) || inner.startsWith('enum:');
49
+ }
50
+ /** `position.x` is the `x` of a vector called `position`. Anything else is its own row. */
51
+ function vectorPartOf(name) {
52
+ const dot = name.lastIndexOf('.');
53
+ if (dot <= 0)
54
+ return null;
55
+ const axis = 'xyz'.indexOf(name.slice(dot + 1));
56
+ return axis < 0 ? null : { prefix: name.slice(0, dot), axis };
57
+ }
58
+ /** One value across the selection, or `MIXED`. */
59
+ function agreed(world, entities, component, field) {
60
+ const first = world.read(entities[0], component, field);
61
+ for (let at = 1; at < entities.length; at += 1) {
62
+ if (world.read(entities[at], component, field) !== first)
63
+ return MIXED;
64
+ }
65
+ return first;
66
+ }
67
+ /**
68
+ * The rows for a selection: the components every selected entity has, field by field.
69
+ *
70
+ * Components are taken from the first entity and then filtered by the rest, so the order is stable
71
+ * — a row that moved between frames because a set iterated differently is a field somebody clicks
72
+ * and misses.
73
+ */
74
+ export function inspectorRows(world, entities) {
75
+ const first = entities[0];
76
+ if (first === undefined)
77
+ return [];
78
+ const shared = world
79
+ .componentsOf(first)
80
+ .filter((component) => entities.every((entity) => world.hasComponent(entity, component)));
81
+ const rows = [];
82
+ for (const component of shared) {
83
+ const fields = world.schemaOf(component);
84
+ const vectors = new Map();
85
+ for (const field of fields) {
86
+ const part = vectorPartOf(field.name);
87
+ if (part === null)
88
+ continue;
89
+ const slots = vectors.get(part.prefix) ?? ['', '', ''];
90
+ slots[part.axis] = field.name;
91
+ vectors.set(part.prefix, slots);
92
+ }
93
+ /* Two of three is not a vector; it is two fields that happen to be named that way. */
94
+ for (const [prefix, slots] of vectors) {
95
+ if (slots.some((slot) => slot === ''))
96
+ vectors.delete(prefix);
97
+ }
98
+ const claimed = new Set([...vectors.values()].flat());
99
+ const emitted = new Set();
100
+ for (const field of fields) {
101
+ const part = vectorPartOf(field.name);
102
+ if (part !== null && claimed.has(field.name)) {
103
+ if (emitted.has(part.prefix))
104
+ continue;
105
+ emitted.add(part.prefix);
106
+ const slots = vectors.get(part.prefix);
107
+ rows.push({
108
+ component,
109
+ label: part.prefix,
110
+ type: '',
111
+ kind: 'vector',
112
+ enumName: '',
113
+ fields: slots,
114
+ value: slots.map((slot) => agreed(world, entities, component, slot)),
115
+ editable: fieldEditable(field.type),
116
+ });
117
+ continue;
118
+ }
119
+ const { kind, enumName } = fieldKindOf(field.type);
120
+ rows.push({
121
+ component,
122
+ label: field.name,
123
+ type: field.type,
124
+ kind,
125
+ enumName,
126
+ fields: [field.name],
127
+ value: agreed(world, entities, component, field.name),
128
+ editable: fieldEditable(field.type),
129
+ });
130
+ }
131
+ }
132
+ return rows;
133
+ }
134
+ /**
135
+ * Write one row's fields across the whole selection, as one entry.
136
+ *
137
+ * **One entry for the whole selection and for all three of a vector's components.** Editing with
138
+ * ten entities selected is one act, and undo that took it back one entity at a time would make the
139
+ * person count. Each entity's own previous value is recorded, so undo does not flatten them onto
140
+ * whatever the first one held.
141
+ */
142
+ export function setFieldCommand(world, entities, component, fields, values) {
143
+ if (entities.length === 0 || fields.length === 0)
144
+ return null;
145
+ if (!entities.every((entity) => world.hasComponent(entity, component)))
146
+ return null;
147
+ const before = entities.map((entity) => fields.map((field) => world.read(entity, component, field)));
148
+ return {
149
+ label: `${component}.${stripAxis(fields)}`,
150
+ apply: () => {
151
+ for (const entity of entities) {
152
+ fields.forEach((field, at) => world.write(entity, component, field, values[at]));
153
+ }
154
+ },
155
+ revert: () => {
156
+ entities.forEach((entity, row) => {
157
+ fields.forEach((field, at) => {
158
+ world.write(entity, component, field, before[row][at]);
159
+ });
160
+ });
161
+ },
162
+ };
163
+ }
164
+ /** A vector's label is what its three fields share, so the undo menu says `position` not `position.x`. */
165
+ function stripAxis(fields) {
166
+ const first = fields[0];
167
+ if (fields.length === 1)
168
+ return first;
169
+ const part = vectorPartOf(first);
170
+ return part === null ? first : part.prefix;
171
+ }
172
+ export function addComponentCommand(world, entities, component) {
173
+ const missing = entities.filter((entity) => !world.hasComponent(entity, component));
174
+ if (missing.length === 0)
175
+ return null;
176
+ return {
177
+ label: `Add ${component}`,
178
+ apply: () => {
179
+ for (const entity of missing)
180
+ world.addComponent(entity, component);
181
+ },
182
+ revert: () => {
183
+ for (const entity of missing)
184
+ world.removeComponent(entity, component);
185
+ },
186
+ };
187
+ }
188
+ /**
189
+ * Remove a component, keeping its values so undo can put them back.
190
+ *
191
+ * **The values are read before the removal and written after the restore.** A remove that only
192
+ * remembers "it was there" gives back a component full of zeroes, which reads as undo having
193
+ * worked and is the worse failure: nothing looks broken.
194
+ */
195
+ export function removeComponentCommand(world, entities, component) {
196
+ const present = entities.filter((entity) => world.hasComponent(entity, component));
197
+ if (present.length === 0)
198
+ return null;
199
+ const fields = world.schemaOf(component).map((field) => field.name);
200
+ const kept = present.map((entity) => fields.map((field) => world.read(entity, component, field)));
201
+ return {
202
+ label: `Remove ${component}`,
203
+ apply: () => {
204
+ for (const entity of present)
205
+ world.removeComponent(entity, component);
206
+ },
207
+ revert: () => {
208
+ present.forEach((entity, row) => {
209
+ world.addComponent(entity, component);
210
+ fields.forEach((field, at) => {
211
+ world.write(entity, component, field, kept[row][at]);
212
+ });
213
+ });
214
+ },
215
+ };
216
+ }
217
+ export function createInspectorView(options) {
218
+ return {
219
+ root: createPanelRoot(inspectorPanel),
220
+ selection: options.selection,
221
+ rows: [],
222
+ rowHeight: options.rowHeight ?? 18,
223
+ };
224
+ }
225
+ /** What a row shows to the right of its label. `—` is the mark for a disagreement. */
226
+ export function rowText(row) {
227
+ if (row.value === MIXED)
228
+ return `${row.label} —`;
229
+ if (row.kind === 'vector') {
230
+ const parts = row.value.map((part) => (part === MIXED ? '—' : String(part)));
231
+ return `${row.label} ${parts.join(', ')}`;
232
+ }
233
+ if (row.kind === 'boolean')
234
+ return `${row.label} ${row.value ? 'true' : 'false'}`;
235
+ return `${row.label} ${String(row.value)}`;
236
+ }
237
+ export const inspectorPanel = {
238
+ id: 'inspector',
239
+ title: 'Inspector',
240
+ build(world, view, root) {
241
+ const entities = selectedEntities(view.selection);
242
+ view.rows = inspectorRows(world.world, entities);
243
+ if (view.rows.length === 0) {
244
+ emptyPanel(root, entities.length === 0 ? 'Nothing selected' : 'No fields in common');
245
+ return;
246
+ }
247
+ root.children.length = 0;
248
+ for (let at = 0; at < view.rows.length; at += 1) {
249
+ const row = view.rows[at];
250
+ addUiChild(root, createUiNode({
251
+ width: 'grow',
252
+ height: view.rowHeight,
253
+ text: rowText(row),
254
+ interactive: row.editable,
255
+ focusable: row.editable,
256
+ name: `field:${at}`,
257
+ }));
258
+ }
259
+ },
260
+ route() {
261
+ /* Task 5 wires the field widgets in. Until then the inspector shows and does not edit, which
262
+ is a state worth having rather than a half-built router that swallows keys. */
263
+ return null;
264
+ },
265
+ };
@@ -0,0 +1,58 @@
1
+ import type { UiNode } from '@driftengine/ui2d';
2
+ import type { Desync } from '@driftengine/network';
3
+ import { type Panel } from './panel.ts';
4
+ /**
5
+ * What the panel needs of a session.
6
+ *
7
+ * Supplied rather than read off a `LockstepSession`, for the reason the asset index is supplied:
8
+ * an editor attached to a session, a recording or a replay wants the same panel, and only two of
9
+ * those have a session to read.
10
+ */
11
+ export interface NetworkReadout {
12
+ readonly inputDelay: number;
13
+ readonly participants: number;
14
+ /** Bytes in one snapshot slot. */
15
+ readonly snapshotBytes: number;
16
+ /** How many slots the rewind window holds. */
17
+ readonly snapshotCount: number;
18
+ /** Everything the session has seen disagree, oldest first. */
19
+ readonly desyncs: readonly Desync[];
20
+ /** A per-component breakdown, where the caller has one. See the header. */
21
+ readonly componentHashes: {
22
+ readonly ours: ReadonlyMap<string, string>;
23
+ readonly theirs: ReadonlyMap<string, string>;
24
+ } | null;
25
+ }
26
+ /**
27
+ * Which components two peers hash differently, in the order they are first seen.
28
+ *
29
+ * **The comparison itself lives in `pie/divergence.ts`**, which is where the divergence search
30
+ * needs it too. Two copies of "which of these hashes disagree" is two places for the rule about a
31
+ * component only one side has to be got wrong, and a panel is the wrong owner of a rule — it uses
32
+ * one. This keeps its own shape, returning a fresh array, because a panel builds a line of text
33
+ * from it once per rebuild and an out-parameter would be ceremony for nothing.
34
+ */
35
+ export declare function componentDivergence(ours: ReadonlyMap<string, string>, theirs: ReadonlyMap<string, string>): string[];
36
+ /** A fixed window of how deep each recent frame rewound. */
37
+ export interface RollbackHistory {
38
+ readonly depth: Int32Array;
39
+ head: number;
40
+ filled: number;
41
+ }
42
+ export declare function createRollbackHistory(capacity: number): RollbackHistory;
43
+ export declare function pushRollback(history: RollbackHistory, depth: number): void;
44
+ export declare function rollbackValues(history: RollbackHistory): number[];
45
+ export interface NetworkWorld {
46
+ /** Null where the editor is not attached to anything. */
47
+ readonly session: NetworkReadout | null;
48
+ }
49
+ export interface NetworkView {
50
+ readonly root: UiNode;
51
+ readonly rollback: RollbackHistory;
52
+ rowHeight: number;
53
+ }
54
+ export declare function createNetworkView(options: {
55
+ rowHeight?: number;
56
+ historyLength?: number;
57
+ }): NetworkView;
58
+ export declare const networkPanel: Panel<NetworkWorld, NetworkView>;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Snapshots, rollback depth, and the frame two peers stopped agreeing on.
3
+ *
4
+ * **Nobody else has this panel, because nobody else has the state to put in it.** The one that
5
+ * matters is divergence: `@driftengine/network` already fingerprints every snapshot and reports a
6
+ * `Desync` carrying the tick, the peer and both digests, and nothing has ever shown it to anybody.
7
+ *
8
+ * **An empty divergence list and a missing one must not look the same.** One says the peers agree;
9
+ * the other says nobody checked. Those are the two answers somebody most needs to tell apart, and
10
+ * a panel that renders both as blank space is worse than no panel — it reads as agreement. So no
11
+ * session says "No session", and no divergence says "peers agree" in as many words.
12
+ *
13
+ * **Which component differs is answerable only where somebody hashed per component.** `Fingerprint`
14
+ * digests a whole snapshot, so `Desync` is two sixteen-character strings and nothing finer. Where a
15
+ * caller supplies a per-component breakdown this names the component; where it does not, it says
16
+ * the breakdown is absent rather than showing an empty line that reads as "no component differs".
17
+ */
18
+ import { addUiChild, createUiNode } from '@driftengine/ui2d';
19
+ import { divergentComponents } from './divergence.js';
20
+ import { createPanelRoot, emptyPanel } from './panel.js';
21
+ /**
22
+ * Which components two peers hash differently, in the order they are first seen.
23
+ *
24
+ * **The comparison itself lives in `pie/divergence.ts`**, which is where the divergence search
25
+ * needs it too. Two copies of "which of these hashes disagree" is two places for the rule about a
26
+ * component only one side has to be got wrong, and a panel is the wrong owner of a rule — it uses
27
+ * one. This keeps its own shape, returning a fresh array, because a panel builds a line of text
28
+ * from it once per rebuild and an out-parameter would be ceremony for nothing.
29
+ */
30
+ export function componentDivergence(ours, theirs) {
31
+ const out = [];
32
+ divergentComponents(ours, theirs, out);
33
+ return out;
34
+ }
35
+ export function createRollbackHistory(capacity) {
36
+ return { depth: new Int32Array(Math.max(1, capacity)), head: 0, filled: 0 };
37
+ }
38
+ export function pushRollback(history, depth) {
39
+ const capacity = history.depth.length;
40
+ history.depth[history.head] = depth;
41
+ history.head = (history.head + 1) % capacity;
42
+ if (history.filled < capacity)
43
+ history.filled += 1;
44
+ }
45
+ export function rollbackValues(history) {
46
+ const capacity = history.depth.length;
47
+ const first = history.filled < capacity ? 0 : history.head;
48
+ const out = [];
49
+ for (let at = 0; at < history.filled; at += 1) {
50
+ out.push(history.depth[(first + at) % capacity]);
51
+ }
52
+ return out;
53
+ }
54
+ export function createNetworkView(options) {
55
+ return {
56
+ root: createPanelRoot(networkPanel),
57
+ rollback: createRollbackHistory(options.historyLength ?? 60),
58
+ rowHeight: options.rowHeight ?? 18,
59
+ };
60
+ }
61
+ /** Kilobytes to one decimal, which is the resolution a snapshot budget is argued at. */
62
+ function kilobytes(bytes) {
63
+ return `${(bytes / 1024).toFixed(1)} KB`;
64
+ }
65
+ function lineAt(root, index, text, height) {
66
+ const existing = root.children[index];
67
+ if (existing !== undefined) {
68
+ existing.hidden = false;
69
+ existing.text = text;
70
+ return;
71
+ }
72
+ addUiChild(root, createUiNode({ width: 'grow', height, text, interactive: false, name: `line:${index}` }));
73
+ }
74
+ export const networkPanel = {
75
+ id: 'network',
76
+ title: 'Network',
77
+ build(world, view, root) {
78
+ const session = world.session;
79
+ if (session === null) {
80
+ emptyPanel(root, 'No session');
81
+ return;
82
+ }
83
+ const lines = [
84
+ `delay ${session.inputDelay} ticks`,
85
+ `peers ${session.participants}`,
86
+ `snapshots ${session.snapshotCount} of ${kilobytes(session.snapshotBytes)} — ${kilobytes(session.snapshotBytes * session.snapshotCount)}`,
87
+ `rollback ${rollbackValues(view.rollback).join(' ')}`,
88
+ ];
89
+ const first = session.desyncs[0];
90
+ if (first === undefined) {
91
+ lines.push('peers agree on every fingerprint so far');
92
+ }
93
+ else {
94
+ lines.push(`diverged at tick ${first.tick} against ${first.peer}`);
95
+ lines.push(` ours ${first.ours}`);
96
+ lines.push(` theirs ${first.theirs}`);
97
+ const hashes = session.componentHashes;
98
+ if (hashes === null) {
99
+ lines.push(' no per-component hashes were supplied');
100
+ }
101
+ else {
102
+ const differing = componentDivergence(hashes.ours, hashes.theirs);
103
+ lines.push(differing.length === 0
104
+ ? ' every component agrees, so the difference is outside them'
105
+ : ` differing: ${differing.join(', ')}`);
106
+ }
107
+ }
108
+ for (let at = 0; at < lines.length; at += 1) {
109
+ lineAt(root, at, lines[at], view.rowHeight);
110
+ }
111
+ for (let at = lines.length; at < root.children.length; at += 1) {
112
+ root.children[at].hidden = true;
113
+ }
114
+ },
115
+ route() {
116
+ /* Read-only, and deliberately: there is nothing in a session's diagnostics a person should be
117
+ able to edit, and a panel that let them would be editing the evidence. */
118
+ return null;
119
+ },
120
+ };