dredless 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0",
3
+ "version": "1.1.0",
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 {
@@ -141,6 +141,10 @@ export const MODEL_TABLE_SPECS = new Map([
141
141
  [11, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
142
142
  [12, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
143
143
  [14, {name: "size_state", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44})}],
144
+ [15, {
145
+ name: "combat_beams",
146
+ fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44, 128: 48, 256: 52})
147
+ }],
144
148
  [16, {
145
149
  name: "label_numeric_state",
146
150
  read(reader, record, mask) {
@@ -158,29 +162,25 @@ export const MODEL_TABLE_SPECS = new Map([
158
162
  if (mask & 16) record.q36 = (record.q36 || 0) + reader.readFieldDelta();
159
163
  }
160
164
  }],
161
- [17, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
165
+ [17, {
166
+ name: "ball",
167
+ packedBeforeValues: true,
168
+ packedBits: [{bit: 1, offset: 21}, {bit: 2, offset: 22}]
169
+ }],
162
170
  [18, {
163
171
  name: "mob_combat_state",
172
+ // Official reader packs flags 64/2048 before numeric deltas. Raw offsets
173
+ // follow that reader's layout, rather than treating flag bits as values.
174
+ packedBeforeValues: true,
175
+ packedBits: [{bit: 64, offset: 77}, {bit: 2048, offset: 78}],
176
+ defaults: {
177
+ q20: -1, q24: 0, q28: 0, q32: 0, q36: 0, q40: -9417217,
178
+ q44: 0, q48: -100, q52: 0, q56: 10, q60: 10, q64: 1, q68: 1, q72: 100
179
+ },
164
180
  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
181
+ 2: 20, 4: 24, 8: 28, 16: 32, 32: 36, 128: 40,
182
+ 256: 44, 512: 48, 1024: 52, 4096: 56, 8192: 60,
183
+ 16384: 64, 32768: 68, 65536: 72
184
184
  })
185
185
  }],
186
186
  [19, {name: "motion_state", packedBits: [{bit: 4, offset: 29}], ...TWO_FIELD_SPEC}],
@@ -214,7 +214,13 @@ export const MODEL_TABLE_SPECS = new Map([
214
214
  fields: numericFields({1: 20, 2: 24}),
215
215
  blobs: [{bit: 4, offset: 28}, {bit: 8, offset: 40}]
216
216
  }],
217
- [24, {name: "projectile_state", packedBits: [{bit: 8, offset: 33}], fields: numericFields({1: 20, 2: 24, 4: 28})}],
217
+ [24, {
218
+ name: "projectile_state",
219
+ defaults: {q20: 16777215, q24: 1, q28: 1},
220
+ packedBeforeValues: true,
221
+ packedBits: [{bit: 2, offset: 33}],
222
+ fields: numericFields({1: 20, 4: 24, 8: 28})
223
+ }],
218
224
  [25, {
219
225
  name: "zone_label",
220
226
  orderedValues: true,
@@ -279,7 +285,7 @@ export const MODEL_TABLE_SPECS = new Map([
279
285
  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
286
  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
287
  }],
282
- [59, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
288
+ [59, {name: "rcd", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
283
289
  [60, {name: "fluid_tank", packedBits: [{bit: 4, offset: 29}], ...TWO_FIELD_SPEC}],
284
290
  [61, {name: "shield_charge", ...TWO_FIELD_SPEC}],
285
291
  [62, {name: "flag_state", packedBits: [{bit: 1, offset: 21}], fields: []}],
@@ -358,7 +364,7 @@ export const WIRE_TAG_TABLES = new Map([
358
364
  [48, 12],
359
365
  [49, 13],
360
366
  [50, 14],
361
- [51, 51],
367
+ [51, 15],
362
368
  [52, 16],
363
369
  [74, 17],
364
370
  [80, 17],
@@ -790,45 +796,120 @@ export function summarizeBot(entity, {
790
796
  combatRecord,
791
797
  motionRecord,
792
798
  table10Record,
793
- table51Record,
799
+ beamRecord,
794
800
  itemCrate,
795
801
  player,
796
802
  shipControl
797
803
  }) {
798
- if (!health || itemCrate || player || shipControl || isBoulderBotExclusion(health, table2Record, combatRecord)) return null;
804
+ if (!health || itemCrate || player || shipControl) return null;
799
805
  const className = botClassFromState({
800
806
  health,
801
807
  table2Record,
802
808
  smallRecord,
803
809
  combatRecord,
804
810
  table10Record,
805
- table51Record
811
+ beamRecord
806
812
  });
813
+ // Health alone is not enough to identify an arbitrary entity as a bot.
814
+ if (className === "bot") return null;
807
815
  return {
808
816
  entity,
809
817
  className,
818
+ classificationSource: "heuristic",
819
+ health,
820
+ beams: summarizeCombatBeams(entity, beamRecord),
821
+ combat: combatRecord ? {
822
+ bodyColorRgba: (combatRecord.q20 ?? -1) >>> 0,
823
+ weaponColorRgba: (combatRecord.q40 ?? -9417217) >>> 0,
824
+ aimAngle: combatRecord.q44 === -70 ? null : numberOrNull(combatRecord.q44, 10),
825
+ thrusterMask: combatRecord.q60 ?? 10,
826
+ alternateSprite: Boolean(combatRecord.q77),
827
+ beamEffectEnabled: Boolean(combatRecord.q78),
828
+ state: cloneRecord(combatRecord)
829
+ } : null,
810
830
  identifier: botIdentifierFromState({combatRecord, motionRecord, smallRecord}),
811
831
  typeA: health.state?.q28 ?? null,
812
832
  typeB: health.state?.q32 ?? null
813
833
  };
814
834
  }
815
835
 
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
- );
836
+ export function summarizeProjectile(entity, record) {
837
+ if (!record) return null;
838
+ const color = record.q20 ?? 16777215;
839
+ const itemId = record.q28 ?? 1;
840
+ return {
841
+ entity,
842
+ color,
843
+ colorCss: colorToCss(color),
844
+ botStyle: Boolean(record.q33),
845
+ indicatorCount: record.q24 ?? 1,
846
+ spriteItemId: itemId === 0 ? null : itemId,
847
+ spriteItemName: itemId === 0 ? null : entityNameFromType(itemId),
848
+ state: cloneRecord(record)
849
+ };
850
+ }
851
+
852
+ export function summarizeCombatBeams(entity, record) {
853
+ if (!record) return null;
854
+ return {
855
+ entity,
856
+ beams: [0, 1, 2].map((index) => {
857
+ const length = (record[qKey(32 + index * 4)] ?? 0) / 10;
858
+ return {
859
+ angle: (record[qKey(20 + index * 4)] ?? 0) / 1000,
860
+ length,
861
+ active: length > 0,
862
+ colorRgba: (record[qKey(44 + index * 4)] ?? 0) >>> 0
863
+ };
864
+ }),
865
+ state: cloneRecord(record)
866
+ };
867
+ }
868
+
869
+ export function summarizeBall(entity, record, health = null, sizeRecord = null) {
870
+ if (!record) return null;
871
+ return {
872
+ entity,
873
+ health,
874
+ radius: numberOrNull(sizeRecord?.q20, 20),
875
+ rubber: Boolean(record.q21),
876
+ isEgg: Boolean(record.q22),
877
+ state: cloneRecord(record)
878
+ };
879
+ }
880
+
881
+ export function summarizeRcd(entity, record, itemHolderRecord) {
882
+ if (!record) return null;
883
+ const variant = (record.q20 ?? 0) === 0 ? "sandbox" : "flux";
884
+ const item = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24);
885
+ return {
886
+ entity,
887
+ variant,
888
+ variantCode: record.q20 ?? 0,
889
+ itemId: variant === "sandbox" ? 121 : 122,
890
+ storedItemId: item.itemId,
891
+ storedItemName: item.itemName,
892
+ storedItemCount: item.count,
893
+ beam: {
894
+ offsetX: (record.q28 ?? 0) / 5,
895
+ offsetY: (record.q32 ?? 0) / 5,
896
+ active: Boolean(record.q28 || record.q32)
897
+ },
898
+ state: cloneRecord(record)
899
+ };
900
+ }
901
+
902
+ export function summarizeRecycler(entity, marker, itemHolderRecord) {
903
+ if (!marker) return null;
904
+ const item = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24);
905
+ return {entity, storedItemId: item.itemId, storedItemName: item.itemName, storedItemCount: item.count};
825
906
  }
826
907
 
827
- export function botClassFromState({health, table2Record, smallRecord, combatRecord, table10Record, table51Record}) {
908
+ export function botClassFromState({health, table2Record, smallRecord, combatRecord, table10Record, beamRecord}) {
828
909
  if (isYellowMineGuardBotState({health, smallRecord, combatRecord, table2Record})) return "yellow-mine-guard";
829
910
  if (isShieldHelperBotState({health, table2Record})) return "shield-helper";
830
911
  if (isShieldMasterBotState({health, table2Record})) return "shield-master";
831
- if (isLazerEnthusiastBotState({health, table2Record, table10Record, table51Record})) return "lazer-enthusiast";
912
+ if (isLazerEnthusiastBotState({health, table2Record, table10Record, beamRecord})) return "lazer-enthusiast";
832
913
  if (isCowardBossState({health})) return "the-coward";
833
914
  if (isOrangeFoolBotState({combatRecord})) return "orange-fool";
834
915
  if (isZombieBossBotState({health, table2Record, combatRecord})) return "zombie-boss";
@@ -842,6 +923,7 @@ export function botClassFromState({health, table2Record, smallRecord, combatReco
842
923
  if (isAquaShielderBotState({health, table2Record, combatRecord})) return "aqua-shielder";
843
924
  if (table2Record) return "table2-bot";
844
925
  if (combatRecord) return "combat-bot";
926
+ if (beamRecord) return "beam-bot";
845
927
  return "bot";
846
928
  }
847
929
 
@@ -895,14 +977,14 @@ export function isAquaShielderBotState({health, table2Record, combatRecord}) {
895
977
  );
896
978
  }
897
979
 
898
- export function isLazerEnthusiastBotState({health, table2Record, table10Record, table51Record}) {
980
+ export function isLazerEnthusiastBotState({health, table2Record, table10Record, beamRecord}) {
899
981
  return (
900
982
  health?.state?.q28 === 140 &&
901
983
  health?.state?.q32 === 155 &&
902
984
  table2Record?.q20 === 300 &&
903
985
  table2Record?.q24 === -900 &&
904
986
  Boolean(table10Record) &&
905
- Boolean(table51Record)
987
+ Boolean(beamRecord)
906
988
  );
907
989
  }
908
990
 
@@ -1515,6 +1597,10 @@ export function entityFootprint(entity) {
1515
1597
  }
1516
1598
 
1517
1599
  export function entityLabel(entity) {
1600
+ if (entity?.projectile) return "Projectile";
1601
+ if (entity?.ball) return entity.ball.isEgg ? "Egg" : "Ball";
1602
+ if (entity?.rcd) return entity.rcd.variant === "sandbox" ? "Sandbox RCD" : "Flux RCD";
1603
+ if (entity?.recycler) return "Recycler";
1518
1604
  if (entity?.category === "metadata" && entity?.typeName) return `Metadata ${entity.typeName}`;
1519
1605
  if (entity?.blueprintPreview) return `Blueprint Preview (${entity.blueprintPreview.itemName ?? entity.blueprintPreview.itemId ?? "item"})`;
1520
1606
  if (entity?.category === "loose_item" && entity?.itemHolder?.itemName) return `Loose ${entity.itemHolder.itemName}`;
@@ -1546,6 +1632,10 @@ export function entityLabel(entity) {
1546
1632
  }
1547
1633
 
1548
1634
  export function entityCategory(entity) {
1635
+ if (entity?.projectile) return "projectile";
1636
+ if (entity?.ball) return "ball";
1637
+ if (entity?.rcd) return "rcd";
1638
+ if (entity?.recycler) return "placed_entity";
1549
1639
  if (entity?.player) return "player";
1550
1640
  if (entity?.blueprintPreview) return "blueprint_preview";
1551
1641
  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,