dredless 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/PROTOCOL.md CHANGED
@@ -211,6 +211,11 @@ worldState.tileDefinition(material: number): TileDefinition | null;
211
211
  modelState.itemHolders(): ItemHolderSummary[];
212
212
  modelState.fabricators(): FabricatorSummary[];
213
213
  modelState.players(): PlayerSummary[];
214
+ modelState.projectiles(): ProjectileSummary[];
215
+ modelState.bots(): BotSummary[];
216
+ modelState.rcds(): RcdSummary[];
217
+ modelState.balls(): BallSummary[];
218
+ modelState.recyclers(): RecyclerSummary[];
214
219
  modelState.setLocalPlayerEntity(entity: number | null): void;
215
220
  modelState.shipControls(): ShipControlSummary[];
216
221
  modelState.shipMetadata(): ShipWorldMetadataSummary | null;
package/docs/README.md CHANGED
@@ -19,6 +19,7 @@ In a browser, `BrowserSession` uses the ambient browser cookie jar.
19
19
  | Handle disconnects and retain useful state | [Client lifecycle](CLIENT.md) |
20
20
  | Subscribe to model, inventory, or management updates | [Event catalog](EVENTS.md) |
21
21
  | Find an entity, machine, or nearby ship | [World and entity reads](WORLD_STATE.md) |
22
+ | Read projectiles, bots, balls/eggs, RCDs, or recyclers | [Special entity reads](WORLD_STATE.md#projectiles-bots-and-special-entities) |
22
23
  | Move, aim, equip, or place a blueprint | [Player and inventory](PLAYER_INVENTORY.md) |
23
24
  | Use a context menu or set door access | [Machines and context menus](MACHINES.md#context-menus-and-access) |
24
25
  | Configure a loader, pusher, launcher, or navigation unit | [Machine reference](MACHINES.md) |
@@ -7,6 +7,7 @@
7
7
  - [Choose a world](#choose-a-world)
8
8
  - [Entities and features](#entities-and-features)
9
9
  - [Machine and other collections](#machine-and-other-collections)
10
+ - [Projectiles, bots, and special entities](#projectiles-bots-and-special-entities)
10
11
  - [Nearby ships](#nearby-ships)
11
12
  - [Live reads and snapshots](#live-reads-and-snapshots)
12
13
  - [Coordinates and incomplete data](#coordinates-and-incomplete-data)
@@ -105,6 +106,76 @@ not freeze them.
105
106
  Materials summarize decoded blocks; they are not an authoritative ship inventory. A missing collection or component can
106
107
  reflect incomplete decoding, not an absent server object.
107
108
 
109
+ ## Projectiles, bots, and special entities
110
+
111
+ Every world domain exposes these collections. They return current component summaries; `get()` returns `null` when
112
+ the entity or requested component is absent. Use `world.entities.get(summary.entity)` for position, health, snapshots,
113
+ and generic interactions.
114
+
115
+ ```ts
116
+ world.projectiles.all(): ProjectileSummary[];
117
+ world.projectiles.get(entity: EntityRef): ProjectileSummary | null;
118
+ world.bots.all(): BotSummary[];
119
+ world.bots.get(entity: EntityRef): BotSummary | null;
120
+ world.rcds.all(): RcdSummary[];
121
+ world.rcds.get(entity: EntityRef): RcdSummary | null;
122
+ world.balls.all(): BallSummary[];
123
+ world.balls.get(entity: EntityRef): BallSummary | null;
124
+ world.recyclers.all(): RecyclerSummary[];
125
+ world.recyclers.get(entity: EntityRef): RecyclerSummary | null;
126
+ ```
127
+
128
+ These arrays also appear in world/model snapshots. Standalone models expose `projectiles()`, `bots()`, `rcds()`,
129
+ `balls()`, and `recyclers()`. Entity handles recognize the `projectile`, `bot`, `rcd`, `ball`, and `recycler` features.
130
+ An egg has public type `"egg"` and a `ball` component with `isEgg: true`; the balls collection includes both kinds.
131
+
132
+ | Component | Exposed state |
133
+ | --- | --- |
134
+ | `projectile` | RGB `color`/`colorCss`, `botStyle`, `indicatorCount`, `spriteItemId`/`spriteItemName`, raw `state`. |
135
+ | `bot` | Existing class/identifier fields, `classificationSource: "heuristic"`, health, nullable combat display state, and nullable `beams`. |
136
+ | `rcd` | Sandbox/flux variant and variant code, corresponding item ID, stored item identity/count, beam vector, raw `state`. |
137
+ | `ball` | Nullable `health`, radius in world units, `rubber`, `isEgg`, raw `state`. |
138
+ | `recycler` | Stored item identity/count. Identified from the recycler marker in a ship interior. |
139
+
140
+ Projectile `botStyle` selects the official bot-bullet sprite; it does not prove ownership or identify the shooter.
141
+ `indicatorCount` is the number of orbiting visual markers. `spriteItemId` is the item image used by the official
142
+ renderer when present, not a reliable identification of the ammunition or its damage. Projectile lifetimes follow
143
+ normal entity updates/removals; no local projectile simulation or trajectory prediction is performed.
144
+
145
+ `bot.combat` exposes packed RGBA `bodyColorRgba` and `weaponColorRgba`, `aimAngle` in radians, `thrusterMask`,
146
+ `alternateSprite`, `beamEffectEnabled`, and raw combat `state`. The official disabled-angle sentinel becomes `null`.
147
+ RGBA uses `0xRRGGBBAA`; projectile and outfit colors use RGB. Thruster mask bits select visual thrusters and do not
148
+ provide thrust force. Beam enablement describes an effect, not whether a target is taking damage.
149
+
150
+ `bot.beams` and the entity's `combatBeams` feature expose the same replicated three-beam component:
151
+ `{entity, beams: [{angle, length, active, colorRgba}, ...], state}`. Angles are radians relative to the entity's
152
+ orientation; lengths are in world units, and `active` means the length is positive. The renderer begins these beams
153
+ at the object's radius, so `length` is the extension beyond that radius. Wire angles are divided by `1000`, lengths
154
+ by `10`. The beam component is decoded separately from expando-box sizing.
155
+
156
+ Bot class names remain inferred from component combinations and values, rather than an authoritative server type.
157
+ Entities with health alone are no longer automatically treated as bots. Projectiles, balls, and RCDs are excluded
158
+ from bot/crate inference. Server AI decisions, target identity, and weapon damage are not inferred from display fields.
159
+
160
+ Ball identification uses the decoded ball component, including objects described in-game as boulders. It does not
161
+ depend on a particular HP value or the previous boulder exclusion heuristic. Balls may have health, including rubber
162
+ balls; `health` is `null` only when that component is absent. `radius` comes from the size component divided by `20`.
163
+ `rubber` is `true` for rubber and `false` for rock. The official renderer tints rubber dark gray (`#222222`) and
164
+ rock white. This flag is independent of health/destructibility.
165
+
166
+ An RCD's `beam` is `{offsetX, offsetY, active}`. The vector uses world axes and world units, scaled from the wire by
167
+ `1/5`; its endpoint is the RCD's position plus that vector. The official renderer chooses sandbox for variant code
168
+ zero and flux otherwise. Stored items are reported without assuming that every item is valid fuel or assigning a
169
+ fuel capacity. Generic `entity.use()`/tip interactions remain available; there is no dedicated RCD programming API.
170
+
171
+ Recyclers expose the replicated held item without inventing a processing timer. Body/appearance-only entities and
172
+ other special objects may still have generic handles and raw component tables without a dedicated summary. An
173
+ annihilator is a block material in the official client, so it is represented through tiles rather than this collection.
174
+
175
+ Footprints and the block index are coarse occupancy aids, not collision polygons or navigation meshes. Check
176
+ `footprint.source`: `"default"` and `"heuristic"` dimensions are estimates, and hover outlines describe highlights.
177
+ Helm/comms occupancy retains its existing inference rules; these collections do not establish player-to-machine links.
178
+
108
179
  ## Nearby ships
109
180
 
110
181
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dredless",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "A fully-featured and from-scratch headless drednot.io client implementation written in pure js.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/certainhuman/dredless#readme",
@@ -427,6 +427,11 @@ export class WorldDomain {
427
427
  this.entities = new EntityCollection(client, scope);
428
428
  this.machines = new MachineCollection(client, scope);
429
429
  this.players = new PlayerCollection(client, scope);
430
+ this.projectiles = new EntitySummaryCollection(client, scope, "projectiles");
431
+ this.bots = new EntitySummaryCollection(client, scope, "bots");
432
+ this.rcds = new EntitySummaryCollection(client, scope, "rcds");
433
+ this.balls = new EntitySummaryCollection(client, scope, "balls");
434
+ this.recyclers = new EntitySummaryCollection(client, scope, "recyclers");
430
435
  this.blocks = new BlockCollection(client, scope);
431
436
  this.materials = new MaterialCollection(client, scope);
432
437
  }
@@ -569,6 +574,24 @@ export class PlayerCollection {
569
574
  }
570
575
  }
571
576
 
577
+ class EntitySummaryCollection {
578
+ constructor(client, scope, method) {
579
+ this.client = client;
580
+ this.scope = scope;
581
+ this.method = method;
582
+ this.component = {projectiles: "projectile", bots: "bot", rcds: "rcd", balls: "ball", recyclers: "recycler"}[method];
583
+ }
584
+
585
+ all() {
586
+ return worldStateFor(this.client, this.scope)?.model[this.method]() || [];
587
+ }
588
+
589
+ get(entity) {
590
+ const id = entityIdOf(entity);
591
+ return worldStateFor(this.client, this.scope)?.model.entity(id)?.contents?.[this.component] || null;
592
+ }
593
+ }
594
+
572
595
  export class BlockCollection {
573
596
  constructor(client, scope) {
574
597
  this.client = client;
@@ -1413,6 +1436,7 @@ export class ShipHandle {
1413
1436
  }
1414
1437
 
1415
1438
  const ENTITY_COMPONENT_ALIASES = new Map([
1439
+ ["combatbeams", "combatBeams"],
1416
1440
  ["hoveroutline", "hoverOutline"],
1417
1441
  ["outline", "hoverOutline"],
1418
1442
  ["itemholder", "itemHolder"],
@@ -1465,6 +1489,12 @@ const MACHINE_COMPONENTS = [
1465
1489
  ];
1466
1490
 
1467
1491
  const ENTITY_FEATURES = [
1492
+ "combatBeams",
1493
+ "projectile",
1494
+ "bot",
1495
+ "rcd",
1496
+ "ball",
1497
+ "recycler",
1468
1498
  "health",
1469
1499
  "position",
1470
1500
  "outline",
@@ -1531,6 +1561,10 @@ function sourceWithProperty(contents, names, property) {
1531
1561
  }
1532
1562
 
1533
1563
  function entityPublicType(summary, contents) {
1564
+ if (contents?.projectile) return "projectile";
1565
+ if (contents?.rcd) return "rcd";
1566
+ if (contents?.ball) return contents.ball.isEgg ? "egg" : "ball";
1567
+ if (contents?.recycler) return "recycler";
1534
1568
  const machine = entityMachineType(contents);
1535
1569
  if (machine) return machine;
1536
1570
  if (contents?.player || summary?.category === "player") return "player";
package/src/index.d.ts CHANGED
@@ -932,6 +932,11 @@ export interface WorldDomain {
932
932
  entities: EntityCollection;
933
933
  machines: MachineCollection;
934
934
  players: PlayerCollection;
935
+ projectiles: EntitySummaryCollection<ProjectileSummary>;
936
+ bots: EntitySummaryCollection<BotSummary>;
937
+ rcds: EntitySummaryCollection<RcdSummary>;
938
+ balls: EntitySummaryCollection<BallSummary>;
939
+ recyclers: EntitySummaryCollection<RecyclerSummary>;
935
940
  blocks: BlockCollection;
936
941
  materials: MaterialCollection;
937
942
 
@@ -976,6 +981,12 @@ export interface PlayerCollection {
976
981
  current(): PlayerSummary | null;
977
982
  }
978
983
 
984
+ /** Current component summaries; get() returns null for a missing entity or component. */
985
+ export interface EntitySummaryCollection<T> {
986
+ all(): T[];
987
+ get(entity: EntityRef): T | null;
988
+ }
989
+
979
990
  export interface BlockCollection {
980
991
  all(): BlockSummary[];
981
992
 
@@ -1065,6 +1076,11 @@ export interface MachineCollection {
1065
1076
  }
1066
1077
 
1067
1078
  export type EntityPublicType =
1079
+ | "projectile"
1080
+ | "rcd"
1081
+ | "ball"
1082
+ | "egg"
1083
+ | "recycler"
1068
1084
  | "loader"
1069
1085
  | "pusher"
1070
1086
  | "launcher"
@@ -1556,6 +1572,11 @@ export interface CargoEjectorSummary {
1556
1572
  }
1557
1573
 
1558
1574
  export interface EntityContentsSummary {
1575
+ combatBeams?: CombatBeamsSummary;
1576
+ projectile?: ProjectileSummary;
1577
+ rcd?: RcdSummary;
1578
+ ball?: BallSummary;
1579
+ recycler?: RecyclerSummary;
1559
1580
  itemHolder?: ItemHolderSummary;
1560
1581
  expandoBox?: ExpandoBoxSummary;
1561
1582
  hoverOutline?: HoverOutlineSummary;
@@ -1591,7 +1612,7 @@ export interface EntityContentsSummary {
1591
1612
 
1592
1613
  export interface EntitySummary {
1593
1614
  entity: number;
1594
- category: "placed_entity" | "loose_item" | "untyped_holder" | "metadata" | "player" | "ship_control" | "blueprint_preview" | "entity";
1615
+ category: "placed_entity" | "loose_item" | "untyped_holder" | "metadata" | "player" | "ship_control" | "blueprint_preview" | "projectile" | "ball" | "rcd" | "entity";
1595
1616
  typeId: number | null;
1596
1617
  typeName: string | null;
1597
1618
  markerTypeId: number | null;
@@ -1789,6 +1810,11 @@ export interface WorldSnapshot {
1789
1810
  transforms: TransformSummary[];
1790
1811
  machines: MachineSummary;
1791
1812
  players: PlayerSummary[];
1813
+ projectiles: ProjectileSummary[];
1814
+ bots: BotSummary[];
1815
+ rcds: RcdSummary[];
1816
+ balls: BallSummary[];
1817
+ recyclers: RecyclerSummary[];
1792
1818
  shipControls: ShipControlSummary[];
1793
1819
  commsBubbles: CommsBubbleSummary[];
1794
1820
  tiles?: Tile[];
@@ -1883,6 +1909,12 @@ export class ModelState {
1883
1909
 
1884
1910
  players(): PlayerSummary[];
1885
1911
 
1912
+ projectiles(): ProjectileSummary[];
1913
+ bots(): BotSummary[];
1914
+ rcds(): RcdSummary[];
1915
+ balls(): BallSummary[];
1916
+ recyclers(): RecyclerSummary[];
1917
+
1886
1918
  shipControls(): ShipControlSummary[];
1887
1919
 
1888
1920
  shipMetadata(): ShipWorldMetadataSummary | null;
@@ -1904,6 +1936,11 @@ export interface ModelSnapshot {
1904
1936
  blocks?: BlockSummary[];
1905
1937
  transforms: TransformSummary[];
1906
1938
  players: PlayerSummary[];
1939
+ projectiles: ProjectileSummary[];
1940
+ bots: BotSummary[];
1941
+ rcds: RcdSummary[];
1942
+ balls: BallSummary[];
1943
+ recyclers: RecyclerSummary[];
1907
1944
  shipControls: ShipControlSummary[];
1908
1945
  machines: MachineSummary;
1909
1946
  tables: unknown[];
@@ -2017,6 +2054,75 @@ export interface BotSummary {
2017
2054
  identifier: string;
2018
2055
  typeA: number | null;
2019
2056
  typeB: number | null;
2057
+ classificationSource: "heuristic";
2058
+ health: HealthSummary;
2059
+ beams: CombatBeamsSummary | null;
2060
+ combat: BotCombatSummary | null;
2061
+ }
2062
+
2063
+ export interface CombatBeamsSummary {
2064
+ entity: number;
2065
+ /** Three replicated beams; angles are radians, lengths are world units. */
2066
+ beams: { angle: number; length: number; active: boolean; colorRgba: number }[];
2067
+ state: ModelRecord;
2068
+ }
2069
+
2070
+ export interface BotCombatSummary {
2071
+ /** Packed RGBA, unlike the RGB projectile and outfit colors. */
2072
+ bodyColorRgba: number;
2073
+ weaponColorRgba: number;
2074
+ /** Radians; the official -7 sentinel becomes null. */
2075
+ aimAngle: number | null;
2076
+ thrusterMask: number;
2077
+ alternateSprite: boolean;
2078
+ beamEffectEnabled: boolean;
2079
+ state: ModelRecord;
2080
+ }
2081
+
2082
+ export interface ProjectileSummary {
2083
+ entity: number;
2084
+ /** Packed RGB trail/tint color. */
2085
+ color: number;
2086
+ colorCss: string;
2087
+ /** Selects the official bot-bullet sprite; not an ownership guarantee. */
2088
+ botStyle: boolean;
2089
+ /** Count of orbiting visual markers. No inferred damage or ammo semantics. */
2090
+ indicatorCount: number;
2091
+ spriteItemId: number | null;
2092
+ spriteItemName: string | null;
2093
+ state: ModelRecord;
2094
+ }
2095
+
2096
+ export interface BallSummary {
2097
+ entity: number;
2098
+ health: HealthSummary | null;
2099
+ /** World units, when the size component is available. */
2100
+ radius: number | null;
2101
+ /** True for rubber, false for rock; independent of health/destructibility. */
2102
+ rubber: boolean;
2103
+ isEgg: boolean;
2104
+ state: ModelRecord;
2105
+ }
2106
+
2107
+ export interface RcdSummary {
2108
+ entity: number;
2109
+ variant: "sandbox" | "flux";
2110
+ /** Official renderer selects sandbox for zero and flux otherwise. */
2111
+ variantCode: number;
2112
+ itemId: 121 | 122;
2113
+ storedItemId: number | null;
2114
+ storedItemName: string | null;
2115
+ storedItemCount: number | null;
2116
+ /** Vector from the RCD in world axes, in world units. */
2117
+ beam: { offsetX: number; offsetY: number; active: boolean };
2118
+ state: ModelRecord;
2119
+ }
2120
+
2121
+ export interface RecyclerSummary {
2122
+ entity: number;
2123
+ storedItemId: number | null;
2124
+ storedItemName: string | null;
2125
+ storedItemCount: number | null;
2020
2126
  }
2021
2127
 
2022
2128
  export interface FabricatorSummary {
@@ -45,19 +45,13 @@ export const TWO_FIELD_SPEC = {
45
45
 
46
46
  export const LABEL_STATE_SPEC = {
47
47
  name: "label_state",
48
- read(reader, record, mask) {
49
- if (mask & 1) record.blob20 = reader.readBlob();
50
- if (mask & 2) {
51
- if (mask & 1) record.blob24 = reader.readBlob();
52
- else record.q20 = (record.q20 || 0) + reader.readFieldDelta();
53
- }
54
- if (mask & 4) record.q28 = (record.q28 || 0) + reader.readFieldDelta();
55
- if (mask & 8) {
56
- if (mask & 1) record.q32 = (record.q32 || 0) + reader.readFieldDelta();
57
- else record.blob28 = reader.readBlob();
58
- }
59
- if (mask & 16) record.q36 = (record.q36 || 0) + reader.readFieldDelta();
60
- }
48
+ // Packed flag first, two independent text blobs,
49
+ // then two numeric deltas. Field types never depend on another mask bit.
50
+ // Keep the existing summary keys for the strings and colors.
51
+ packedBeforeValues: true,
52
+ packedBits: [{bit: 16, offset: 36}],
53
+ blobs: [{bit: 1, offset: 20}, {bit: 2, offset: 24}],
54
+ fields: numericFields({4: 28, 8: 32})
61
55
  };
62
56
 
63
57
  export const SIMPLE_LABEL_STATE_SPEC = {
@@ -141,6 +135,10 @@ export const MODEL_TABLE_SPECS = new Map([
141
135
  [11, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
142
136
  [12, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
143
137
  [14, {name: "size_state", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44})}],
138
+ [15, {
139
+ name: "combat_beams",
140
+ fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44, 128: 48, 256: 52})
141
+ }],
144
142
  [16, {
145
143
  name: "label_numeric_state",
146
144
  read(reader, record, mask) {
@@ -158,29 +156,25 @@ export const MODEL_TABLE_SPECS = new Map([
158
156
  if (mask & 16) record.q36 = (record.q36 || 0) + reader.readFieldDelta();
159
157
  }
160
158
  }],
161
- [17, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
159
+ [17, {
160
+ name: "ball",
161
+ packedBeforeValues: true,
162
+ packedBits: [{bit: 1, offset: 21}, {bit: 2, offset: 22}]
163
+ }],
162
164
  [18, {
163
165
  name: "mob_combat_state",
166
+ // Official reader packs flags 64/2048 before numeric deltas. Raw offsets
167
+ // follow that reader's layout, rather than treating flag bits as values.
168
+ packedBeforeValues: true,
169
+ packedBits: [{bit: 64, offset: 77}, {bit: 2048, offset: 78}],
170
+ defaults: {
171
+ q20: -1, q24: 0, q28: 0, q32: 0, q36: 0, q40: -9417217,
172
+ q44: 0, q48: -100, q52: 0, q56: 10, q60: 10, q64: 1, q68: 1, q72: 100
173
+ },
164
174
  fields: numericFields({
165
- 2: 20,
166
- 4: 24,
167
- 8: 28,
168
- 16: 32,
169
- 32: 36,
170
- 64: 40,
171
- 128: 44,
172
- 256: 48,
173
- 512: 52,
174
- 1024: 56,
175
- 2048: 60,
176
- 4096: 64,
177
- 8192: 68,
178
- 16384: 72,
179
- 32768: 76,
180
- 65536: 80,
181
- 131072: 84,
182
- 524288: 88,
183
- 4194304: 92
175
+ 2: 20, 4: 24, 8: 28, 16: 32, 32: 36, 128: 40,
176
+ 256: 44, 512: 48, 1024: 52, 4096: 56, 8192: 60,
177
+ 16384: 64, 32768: 68, 65536: 72
184
178
  })
185
179
  }],
186
180
  [19, {name: "motion_state", packedBits: [{bit: 4, offset: 29}], ...TWO_FIELD_SPEC}],
@@ -214,7 +208,13 @@ export const MODEL_TABLE_SPECS = new Map([
214
208
  fields: numericFields({1: 20, 2: 24}),
215
209
  blobs: [{bit: 4, offset: 28}, {bit: 8, offset: 40}]
216
210
  }],
217
- [24, {name: "projectile_state", packedBits: [{bit: 8, offset: 33}], fields: numericFields({1: 20, 2: 24, 4: 28})}],
211
+ [24, {
212
+ name: "projectile_state",
213
+ defaults: {q20: 16777215, q24: 1, q28: 1},
214
+ packedBeforeValues: true,
215
+ packedBits: [{bit: 2, offset: 33}],
216
+ fields: numericFields({1: 20, 4: 24, 8: 28})
217
+ }],
218
218
  [25, {
219
219
  name: "zone_label",
220
220
  orderedValues: true,
@@ -279,7 +279,7 @@ export const MODEL_TABLE_SPECS = new Map([
279
279
  defaults: {q20: 0, q24: 0, q28: 0, q32: 0, q36: 0, q40: 0, q44: 0, q48: 0, q52: 0, q56: 0, q60: 0, q64: 0},
280
280
  fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44, 128: 48, 256: 52, 512: 56, 1024: 60, 2048: 64})
281
281
  }],
282
- [59, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
282
+ [59, {name: "rcd", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
283
283
  [60, {name: "fluid_tank", packedBits: [{bit: 4, offset: 29}], ...TWO_FIELD_SPEC}],
284
284
  [61, {name: "shield_charge", ...TWO_FIELD_SPEC}],
285
285
  [62, {name: "flag_state", packedBits: [{bit: 1, offset: 21}], fields: []}],
@@ -358,7 +358,7 @@ export const WIRE_TAG_TABLES = new Map([
358
358
  [48, 12],
359
359
  [49, 13],
360
360
  [50, 14],
361
- [51, 51],
361
+ [51, 15],
362
362
  [52, 16],
363
363
  [74, 17],
364
364
  [80, 17],
@@ -790,45 +790,120 @@ export function summarizeBot(entity, {
790
790
  combatRecord,
791
791
  motionRecord,
792
792
  table10Record,
793
- table51Record,
793
+ beamRecord,
794
794
  itemCrate,
795
795
  player,
796
796
  shipControl
797
797
  }) {
798
- if (!health || itemCrate || player || shipControl || isBoulderBotExclusion(health, table2Record, combatRecord)) return null;
798
+ if (!health || itemCrate || player || shipControl) return null;
799
799
  const className = botClassFromState({
800
800
  health,
801
801
  table2Record,
802
802
  smallRecord,
803
803
  combatRecord,
804
804
  table10Record,
805
- table51Record
805
+ beamRecord
806
806
  });
807
+ // Health alone is not enough to identify an arbitrary entity as a bot.
808
+ if (className === "bot") return null;
807
809
  return {
808
810
  entity,
809
811
  className,
812
+ classificationSource: "heuristic",
813
+ health,
814
+ beams: summarizeCombatBeams(entity, beamRecord),
815
+ combat: combatRecord ? {
816
+ bodyColorRgba: (combatRecord.q20 ?? -1) >>> 0,
817
+ weaponColorRgba: (combatRecord.q40 ?? -9417217) >>> 0,
818
+ aimAngle: combatRecord.q44 === -70 ? null : numberOrNull(combatRecord.q44, 10),
819
+ thrusterMask: combatRecord.q60 ?? 10,
820
+ alternateSprite: Boolean(combatRecord.q77),
821
+ beamEffectEnabled: Boolean(combatRecord.q78),
822
+ state: cloneRecord(combatRecord)
823
+ } : null,
810
824
  identifier: botIdentifierFromState({combatRecord, motionRecord, smallRecord}),
811
825
  typeA: health.state?.q28 ?? null,
812
826
  typeB: health.state?.q32 ?? null
813
827
  };
814
828
  }
815
829
 
816
- export function isBoulderBotExclusion(health, table2Record, combatRecord) {
817
- return Boolean(
818
- health?.maxHp === 2000 &&
819
- health?.state?.q28 === 30 &&
820
- health?.state?.q32 != null &&
821
- table2Record?.q20 != null &&
822
- table2Record?.q24 == null &&
823
- !combatRecord
824
- );
830
+ export function summarizeProjectile(entity, record) {
831
+ if (!record) return null;
832
+ const color = record.q20 ?? 16777215;
833
+ const itemId = record.q28 ?? 1;
834
+ return {
835
+ entity,
836
+ color,
837
+ colorCss: colorToCss(color),
838
+ botStyle: Boolean(record.q33),
839
+ indicatorCount: record.q24 ?? 1,
840
+ spriteItemId: itemId === 0 ? null : itemId,
841
+ spriteItemName: itemId === 0 ? null : entityNameFromType(itemId),
842
+ state: cloneRecord(record)
843
+ };
844
+ }
845
+
846
+ export function summarizeCombatBeams(entity, record) {
847
+ if (!record) return null;
848
+ return {
849
+ entity,
850
+ beams: [0, 1, 2].map((index) => {
851
+ const length = (record[qKey(32 + index * 4)] ?? 0) / 10;
852
+ return {
853
+ angle: (record[qKey(20 + index * 4)] ?? 0) / 1000,
854
+ length,
855
+ active: length > 0,
856
+ colorRgba: (record[qKey(44 + index * 4)] ?? 0) >>> 0
857
+ };
858
+ }),
859
+ state: cloneRecord(record)
860
+ };
861
+ }
862
+
863
+ export function summarizeBall(entity, record, health = null, sizeRecord = null) {
864
+ if (!record) return null;
865
+ return {
866
+ entity,
867
+ health,
868
+ radius: numberOrNull(sizeRecord?.q20, 20),
869
+ rubber: Boolean(record.q21),
870
+ isEgg: Boolean(record.q22),
871
+ state: cloneRecord(record)
872
+ };
873
+ }
874
+
875
+ export function summarizeRcd(entity, record, itemHolderRecord) {
876
+ if (!record) return null;
877
+ const variant = (record.q20 ?? 0) === 0 ? "sandbox" : "flux";
878
+ const item = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24);
879
+ return {
880
+ entity,
881
+ variant,
882
+ variantCode: record.q20 ?? 0,
883
+ itemId: variant === "sandbox" ? 121 : 122,
884
+ storedItemId: item.itemId,
885
+ storedItemName: item.itemName,
886
+ storedItemCount: item.count,
887
+ beam: {
888
+ offsetX: (record.q28 ?? 0) / 5,
889
+ offsetY: (record.q32 ?? 0) / 5,
890
+ active: Boolean(record.q28 || record.q32)
891
+ },
892
+ state: cloneRecord(record)
893
+ };
894
+ }
895
+
896
+ export function summarizeRecycler(entity, marker, itemHolderRecord) {
897
+ if (!marker) return null;
898
+ const item = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24);
899
+ return {entity, storedItemId: item.itemId, storedItemName: item.itemName, storedItemCount: item.count};
825
900
  }
826
901
 
827
- export function botClassFromState({health, table2Record, smallRecord, combatRecord, table10Record, table51Record}) {
902
+ export function botClassFromState({health, table2Record, smallRecord, combatRecord, table10Record, beamRecord}) {
828
903
  if (isYellowMineGuardBotState({health, smallRecord, combatRecord, table2Record})) return "yellow-mine-guard";
829
904
  if (isShieldHelperBotState({health, table2Record})) return "shield-helper";
830
905
  if (isShieldMasterBotState({health, table2Record})) return "shield-master";
831
- if (isLazerEnthusiastBotState({health, table2Record, table10Record, table51Record})) return "lazer-enthusiast";
906
+ if (isLazerEnthusiastBotState({health, table2Record, table10Record, beamRecord})) return "lazer-enthusiast";
832
907
  if (isCowardBossState({health})) return "the-coward";
833
908
  if (isOrangeFoolBotState({combatRecord})) return "orange-fool";
834
909
  if (isZombieBossBotState({health, table2Record, combatRecord})) return "zombie-boss";
@@ -842,6 +917,7 @@ export function botClassFromState({health, table2Record, smallRecord, combatReco
842
917
  if (isAquaShielderBotState({health, table2Record, combatRecord})) return "aqua-shielder";
843
918
  if (table2Record) return "table2-bot";
844
919
  if (combatRecord) return "combat-bot";
920
+ if (beamRecord) return "beam-bot";
845
921
  return "bot";
846
922
  }
847
923
 
@@ -895,14 +971,14 @@ export function isAquaShielderBotState({health, table2Record, combatRecord}) {
895
971
  );
896
972
  }
897
973
 
898
- export function isLazerEnthusiastBotState({health, table2Record, table10Record, table51Record}) {
974
+ export function isLazerEnthusiastBotState({health, table2Record, table10Record, beamRecord}) {
899
975
  return (
900
976
  health?.state?.q28 === 140 &&
901
977
  health?.state?.q32 === 155 &&
902
978
  table2Record?.q20 === 300 &&
903
979
  table2Record?.q24 === -900 &&
904
980
  Boolean(table10Record) &&
905
- Boolean(table51Record)
981
+ Boolean(beamRecord)
906
982
  );
907
983
  }
908
984
 
@@ -1515,6 +1591,10 @@ export function entityFootprint(entity) {
1515
1591
  }
1516
1592
 
1517
1593
  export function entityLabel(entity) {
1594
+ if (entity?.projectile) return "Projectile";
1595
+ if (entity?.ball) return entity.ball.isEgg ? "Egg" : "Ball";
1596
+ if (entity?.rcd) return entity.rcd.variant === "sandbox" ? "Sandbox RCD" : "Flux RCD";
1597
+ if (entity?.recycler) return "Recycler";
1518
1598
  if (entity?.category === "metadata" && entity?.typeName) return `Metadata ${entity.typeName}`;
1519
1599
  if (entity?.blueprintPreview) return `Blueprint Preview (${entity.blueprintPreview.itemName ?? entity.blueprintPreview.itemId ?? "item"})`;
1520
1600
  if (entity?.category === "loose_item" && entity?.itemHolder?.itemName) return `Loose ${entity.itemHolder.itemName}`;
@@ -1546,6 +1626,10 @@ export function entityLabel(entity) {
1546
1626
  }
1547
1627
 
1548
1628
  export function entityCategory(entity) {
1629
+ if (entity?.projectile) return "projectile";
1630
+ if (entity?.ball) return "ball";
1631
+ if (entity?.rcd) return "rcd";
1632
+ if (entity?.recycler) return "placed_entity";
1549
1633
  if (entity?.player) return "player";
1550
1634
  if (entity?.blueprintPreview) return "blueprint_preview";
1551
1635
  if (entity?.shipControl) return "ship_control";
@@ -2,6 +2,13 @@ export {
2
2
  FabricatorType,
3
3
  ModelState,
4
4
  decodeModelData,
5
+ type ProjectileSummary,
6
+ type BotCombatSummary,
7
+ type CombatBeamsSummary,
8
+ type BotSummary,
9
+ type RcdSummary,
10
+ type BallSummary,
11
+ type RecyclerSummary,
5
12
  type ModelRecord,
6
13
  type ModelSnapshot,
7
14
  type EntitySummary,