Builds the domain body structure, assembling the three flat persisted arrays into the Zone → Part → Location hierarchy and allocating each zone its run of zone numbers.
Persisted body-structure data
Persisted data shape for a complete body structure — three flat arrays, assembled into a hierarchy by the BodyStructure constructor.
Optional__kind?: stringDiscriminator kind, written on serialization (SohlEntity.toJSON) and read back on revival. Optional as constructor input — it is derived from the concrete class, not supplied by callers.
Persisted hit locations, in order; each names its part by shortcode.
Persisted body parts, in order; each names its zone by shortcode.
Persisted body zones, in order; their order sets zone-number runs.
Construction options
Construction options for a BodyStructure instance; inherits all SohlEntity.Options.
The Logic that owns the entity. Required (the constructor throws without it) and transient — held in memory, never written to JSON.
ReadonlylocationsEvery hit location reachable in the hierarchy, ordered by its index into
the flat locations array.
ReadonlyorphanedPersisted locations whose bodyPartCode names no reachable part. As with
orphanedParts, they stay in storage and out of the hierarchy.
ReadonlyorphanedPersisted parts whose bodyZoneCode names no existing zone. They are
absent from the hierarchy but preserved in storage, so a mis-typed zone
code loses nothing — fix the code and the part reappears.
ReadonlypartsEvery body part reachable in the hierarchy, ordered by its index into the
flat parts array (so parts[i].index === i for a body with no
orphanedParts).
ReadonlyzonesAll body zones, in persisted order — the root of the derived hierarchy.
The struck-creature injury-level thresholds, scaled by the owning being's
bodyScale. Falls back to the human BASE_INJURY_THRESHOLDS when the
being has not derived a table yet (e.g. before its evaluate phase). Used
by injuryLevelFromImpact so an absolute impact reads size-correct.
The serialization discriminator for this instance — the concrete class's static Kind. Written into the JSON by toJSON under the kind key and read back by sohl.utils.defaultFromJSON to select the constructor. Derived from the class, never stored per-instance.
The highest zone number on this body — the N of the 1..N run.
Zone numbers are handed out in persisted zone order, each zone taking a
contiguous block sized by its probWeight, so across the whole body they
are contiguous, unique, gap-free, and monotonically increasing by 1
from 1. That makes this both the sum of every zone's weight and the
upper bound of the zone roll — they are the same number by construction.
A zero-weight zone claims no numbers and does not interrupt the run.
0 for a body with no weighted zones (an incorporeal being), which makes
getRandomZone return undefined.
Build an update() payload that appends a new hit location to the
persisted body structure. The location's own bodyPartCode decides which
part it joins; prefer sohl.entity.body.BodyPart.addLocationUpdate
to stamp it automatically.
The persisted data for the location to append.
A complete-array update() payload appending the location.
Build an update() payload that appends a new part to the persisted
body structure. The part's own bodyZoneCode decides which zone it joins;
prefer sohl.entity.body.BodyZone.addPartUpdate to stamp it
automatically.
A complete-array update() payload appending the part.
Build an update() payload that appends a new zone to the persisted
structure. Sources the current array from the canonical DataModel data,
not from the (possibly mutated) domain objects.
A complete-array update() payload appending the zone.
Resolve a hit location by Zone-Number aiming with a Zone Die.
The first stage of an aimed strike: roll the zone die (uniform in
1..zoneDie), offset the aimed zone number by it —
Hit ZN = (targetZoneNumber - 1) + result — and look up the owning zone
(getZoneByNumber). When the hit zone number exceeds
maxZoneNumber (or lands in a zone that holds no hittable part) the
blow misses: zone and location are undefined and isMiss is
true. Otherwise a weighted body part is drawn within that zone, then a
weighted location within that part.
The aim.
The zone number aimed at (clamped up to 1).
The zone die to roll (clamped up to 1).
The random source; defaults to the shared sohl.random singleton. Inject a seeded generator to make the draw reproducible.
The full aim trace (see ZoneAimResult).
Deep-copy this entity, re-parenting the copy under parent with no other
changes. Shorthand for clone({}, { parent }).
The Logic to own the cloned entity.
The cloned entity.
Deep-copy this entity, optionally overriding fields and clone options.
Field overrides applied to the clone.
Clone options (e.g. a new parent).
The cloned entity.
Get all locations from all parts as a flat array.
Every location across all parts, in persisted order.
Get all parts from all zones as a flat array — the parent tier of
getAllLocations. Used to source the shortcode-reference dropdown
for a hit location's bodyPartCode (#982), mapping each to
{value: shortcode, label: name}.
Every part across all zones, in persisted order.
Get all zones as a flat array — the roots of the Zone → Part → Location
hierarchy. The parent tier of getAllParts; used to source the
shortcode-reference dropdown for a body part's bodyZoneCode (#982),
mapping each to {value: shortcode, label: name}.
Every zone, in persisted order.
Find a hit location by shortcode. Location shortcodes are unique across the whole body, not merely within their part.
The location shortcode to look up.
The matching location, or undefined if none exists.
Find a hit location by its index into the flat locations array.
The zero-based index into the locations array.
The location at that index, or undefined if out of range.
The parts a strike drifts to when it misses partCode — the nearest ring
of anatomy, now that adjacency is implied by the zone tree rather than a
hand-authored part graph (#780).
The nearest ring is the part's own zone siblings; when those are exhausted the search widens one zone at a time by index distance (both directions at once, so a zone's two neighbours rank equally). Only the closest non-empty ring is returned, mirroring the old behavior of drifting exactly one adjacency step.
The shortcode of the part being drifted away from.
Shortcodes already visited, skipped at every ring.
The nearest ring of candidate parts, or empty when none remain.
Every body part carrying the given sohl.utils.BODY_ROLE — e.g.
all VITAL parts. A part can hold several roles, so a part is included
when its BodyPart.roles contains role.
The body-role to filter by (a BODY_ROLE value).
The parts with that role, in persisted order (empty if none).
Select a random location using the full top-down draw: pick a part (optionally with aimed targeting), then pick a location within that part (weighted).
Optionaltarget: { spread: number; targetPart: sohl.entity.body.BodyPart }Optional aimed-strike parameters; omit for pure weighted selection.
The random source; defaults to the shared sohl.random singleton. Inject a seeded generator to force a hit location deterministically end to end (zone, part, and location all draw from it).
The selected body location.
Roll a zone the way getRandomZone does, but over only those zones
that actually hold a part — each drawn with probability
weight / (sum of the occupied zones' weights).
A weighted zone with no parts is unhittable, so including it would leak
its share of the roll into a body-wide fallback and skew every other
zone's true frequency. Restricting the draw keeps
P(part) = P(zone) x P(part | zone) exact for any authored body.
The random source; defaults to the shared sohl.random singleton.
The selected zone, or undefined when no weighted zone holds a
part.
Select a random body part.
Without parameters, hit determination runs top down and is weighted at
every tier: roll a zone weighted by its probWeight
(getRandomOccupiedZone), then draw a part inside it weighted by
BodyPart.probWeight. So
P(part) = P(zone) x P(part | zone), and with the location draw that
follows in getRandomLocation, each tier's odds are that entry's
weight over the sum of its siblings' weights.
With a target parameter, simulates aimed strikes with spread drift:
spread.probWeight, that part is hit.spread by the part's probWeight, pick a
random part from the nearest ring of getNeighborParts, and
repeat from step 2.This models the idea that a more accurate attack is more likely to hit the intended target, while a less accurate one drifts outward through the zone tree to neighbouring anatomy.
The selected body part.
Select a random body part with the given sohl.utils.BODY_ROLE,
weighted by each candidate's probWeight (via weightedRandom).
Used to pick an unaimed target — e.g. a random VITAL part for the
Resolve Injury flow when no body part is specified.
The body-role to draw from (a BODY_ROLE value).
The random source; defaults to the shared sohl.random singleton. Inject a seeded generator for a deterministic draw.
A random part with that role, or undefined when none exists.
Roll a zone number in 1..maxZoneNumber and return the zone
owning it. Zones with no weight own no numbers and can never be rolled.
The random source; defaults to the shared sohl.random singleton. Inject a seeded generator for a deterministic draw.
The selected zone, or undefined when the body has no weighted
zones.
Find the zone owning a given zone number (as rolled against maxZoneNumber).
Because the runs are contiguous and gap-free, every integer in
1..maxZoneNumber resolves to exactly one zone. Anything else — 0 or
below, above maxZoneNumber, or a non-integer — is not a zone
number on this body and yields undefined.
The rolled zone number, 1-based.
The zone owning that number, or undefined when the body has no
such zone number.
The number of item-holding limbs currently gripping the item with the
given id. A limb counts only if it both can and holds that specific item. Used to decide whether a
held weapon's strike modes are available (a mode needs at least its
minParts limbs).
The id of the held item to count grips for.
The number of limbs currently gripping that item.
Build an update() payload that moves a hit location — either reordering
it within its part or relocating it to another part — by rewriting the
whole flat locations array.
The location's current index into the flat locations
array.
Shortcode of the destination part (its current part for a plain reorder).
Target position among the destination part's locations; past the end appends.
A complete-array update() payload with the location moved.
Build an update() payload that moves a part — either reordering it
within its zone or re-parenting it to another zone — by rewriting the
whole flat parts array. Parts have no sort field; their position
within a zone is their relative order among that zone's members.
The part's current index into the flat parts array.
Shortcode of the destination zone (its current zone for a plain reorder).
Target position among the destination zone's parts; past the end appends.
A complete-array update() payload with the part relocated.
Build an update() payload that relocates a zone within the persisted
zones array (drag-to-sort). Zones have no sort field — their order
is their array order, and it determines zone-number allocation — so
reordering rewrites the whole array.
The zone's current index.
The destination index.
A complete-array update() payload with the zone relocated.
Build an update() payload that removes a hit location by shortcode.
Location shortcodes are unique body-wide, so no part context is needed.
The shortcode of the location to remove.
A complete-array update() payload with the location removed.
Build an update() payload that removes a part by shortcode, cascading
to every hit location on that part.
The shortcode of the part to remove.
A complete-array update() payload spanning parts and locations.
Build an update() payload that removes a zone by shortcode, cascading
to every part in that zone and every location on those parts — a zone
is never removed out from under its descendants.
The shortcode of the zone to remove.
A complete-array update() payload spanning all three arrays.
Build an update() payload that re-points every location naming
oldCode as its part to newCode. Merge this with the part's own field
update when a part's shortcode changes, for the same reason as
repointPartsUpdate.
The part shortcode the locations currently name.
The part's new shortcode.
A complete-array update() payload, or {} when the code is
unchanged or no location references it.
Build an update() payload that re-points every part naming oldCode as
its zone to newCode. Merge this with the zone's own field update when a
zone's shortcode changes — otherwise its parts are orphaned, since a
child's link is the parent's shortcode, not its index.
The two payloads touch different arrays, so they merge by spread:
{ ...setZoneFieldsUpdate(…), ...repointPartsUpdate(old, new) }.
The zone shortcode the parts currently name.
The zone's new shortcode.
A complete-array update() payload, or {} when the code is
unchanged or no part references it.
Build an update() payload that sets fields on one or more hit
locations, addressed by flat index, by rewriting the entire
locations array. See setPartFieldsUpdate for why the write is
never a by-index one (#247).
One { index, changes } per location to modify;
changes is a partial of that location's persisted fields.
Out-of-range indices are ignored.
A complete-array update() payload, or {} if nothing applies.
Build an update() payload that sets fields on one or more parts,
addressed by flat index, by rewriting the entire parts array.
Never write a single array element by index (parts.${i}.field):
Foundry expands the dotted key to { parts: { i: {…} } } and rebuilds
the array field from that sparse map, truncating it and default-filling
every other element — silently destroying every part but the one
touched (issue #247). Sourcing the full canonical array and replacing the
target element(s) makes the write a complete-array replacement, which
Foundry applies faithfully.
A complete-array update() payload, or {} if nothing applies.
Build an update() payload that sets fields on one or more zones,
addressed by flat index, by rewriting the entire zones array.
A complete-array update() payload, or {} if nothing applies.
Serialize this instance to a plain object suitable for JSON serialization.
A plain object representing this instance, consistent with the
Data interface of the subclass.
The base emits only the kind tag. A subclass that adds state
overrides this, chaining ...super.toJSON(), and emits keys matching its
own Data interface in persisted form (a uuid/shortcode where the live
object holds a resolved reference). The governing rule: toJSON() output
must be valid data for the constructor. The transient parent is
deliberately not emitted — it is re-supplied on revival.
The complete anatomical structure of a Being — its zones, body parts, and hit locations.
Storage is flat; the hierarchy is derived. The persisted data holds three sibling arrays — zones, parts, and locations — where each child names its parent by shortcode (
bodyZoneCode/bodyPartCode). The constructor assembles them into the Zone → Part → Location tree exposed by BodyStructure.zones. Flat storage keeps everyupdate()a single whole-array write (see setPartFieldsUpdate and #247) instead of the nested rewrite a tree would force.Every entity's
indexis its position in its flat array, sostructure.parts[i].index === iand eachupdatePathis a plain two-segment path. Position within a parent is the child's relative order in the flat array.Hit determination runs top down: each zone owns a contiguous run of zone numbers sized by its weight — contiguous and gap-free across the body, so they run
1..maxZoneNumber — and one roll against that range picks a zone (getRandomZone), then a weighted part inside it, then a weighted location.Lifecycle: This object is rebuilt from persisted schema data on every preparation cycle. It may be mutated during the lifecycle (e.g., active effects adding ValueModifier deltas to protection values), but those changes are not persisted — they are recomputed on the next cycle. To persist changes, modify the underlying DataModel fields directly (via form submission or
document.update()).