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 every update() a single whole-array write (see setPartFieldsUpdate and #247) instead of the nested rewrite a tree would force.

Every entity's index is its position in its flat array, so structure.parts[i].index === i and each updatePath is 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()).

Hierarchy (View Summary)

Constructors

Properties

Every hit location reachable in the hierarchy, ordered by its index into the flat locations array.

orphanedLocations: readonly sohl.entity.body.BodyLocation.Data[]

Persisted locations whose bodyPartCode names no reachable part. As with orphanedParts, they stay in storage and out of the hierarchy.

orphanedParts: readonly sohl.entity.body.BodyPart.Data[]

Persisted 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.

Every 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).

All body zones, in persisted order — the root of the derived hierarchy.

Accessors

  • get injuryTable(): readonly number[]
  • 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.

    Returns readonly number[]

  • get kind(): string
  • 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.

    Returns string

  • get maxZoneNumber(): number
  • 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.

    Returns number

  • get parent(): SohlLogic<any>
  • The Logic that owns this entity. Always present (the constructor rejects a missing parent) and transient — it is not serialized and is re-supplied when the entity is revived or cloned.

    Returns SohlLogic<any>

Methods

  • 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.

    Parameters

    Returns PlainObject

    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.

    Parameters

    • target: { targetZoneNumber: number; zoneDie: number }

      The aim.

      • targetZoneNumber: number

        The zone number aimed at (clamped up to 1).

      • zoneDie: number

        The zone die to roll (clamped up to 1).

    • rng: Rng = ...

      The random source; defaults to the shared sohl.random singleton. Inject a seeded generator to make the draw reproducible.

    Returns ZoneAimResult

    The full aim trace (see ZoneAimResult).

  • 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.

    Parameters

    • partCode: string

      The shortcode of the part being drifted away from.

    • exclude: ReadonlySet<string> = ...

      Shortcodes already visited, skipped at every ring.

    Returns sohl.entity.body.BodyPart[]

    The nearest ring of candidate parts, or empty when none remain.

  • 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).

    Parameters

    • Optionaltarget: { spread: number; targetPart: sohl.entity.body.BodyPart }

      Optional aimed-strike parameters; omit for pure weighted selection.

      • spread: number

        The accuracy spread driving drift. Unaimed, the composite odds of any one location are (zone weight / total zone weight) x (part weight / its zone's part weights) x (location weight / its part's location weights).

      • targetPart: sohl.entity.body.BodyPart

        The intended part to aim at.

    • rng: Rng = ...

      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).

    Returns sohl.entity.body.BodyLocation

    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.

    Parameters

    • rng: Rng = ...

      The random source; defaults to the shared sohl.random singleton.

    Returns undefined | sohl.entity.body.BodyZone

    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:

    1. Roll a random number from 1 to spread.
    2. If the roll ≤ the current part's probWeight, that part is hit.
    3. Otherwise, reduce spread by the part's probWeight, pick a random part from the nearest ring of getNeighborParts, and repeat from step 2.
    4. If no unvisited neighbour remains, the current part is hit.

    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.

    Parameters

    • Optionaltarget: { spread: number; targetPart: sohl.entity.body.BodyPart }

      Optional aimed-strike parameters; omit for pure weighted selection.

      • spread: number

        The accuracy spread driving drift.

      • targetPart: sohl.entity.body.BodyPart

        The intended part to aim at.

    • rng: Rng = ...

      The random source; defaults to the shared sohl.random singleton. Inject a seeded generator to force a hit location deterministically end to end.

    Returns sohl.entity.body.BodyPart

    The selected body part.

    If the body has no parts at all.

  • 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.

    Parameters

    • role: string

      The body-role to draw from (a BODY_ROLE value).

    • rng: Rng = ...

      The random source; defaults to the shared sohl.random singleton. Inject a seeded generator for a deterministic draw.

    Returns undefined | sohl.entity.body.BodyPart

    A random part with that role, or undefined when none exists.

  • 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.

    Parameters

    • zoneNumber: number

      The rolled zone number, 1-based.

    Returns undefined | sohl.entity.body.BodyZone

    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).

    Parameters

    • itemId: string

      The id of the held item to count grips for.

    Returns number

    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.

    Parameters

    • fromIndex: number

      The location's current index into the flat locations array.

    • toPartCode: string

      Shortcode of the destination part (its current part for a plain reorder).

    • toPosition: number

      Target position among the destination part's locations; past the end appends.

    Returns PlainObject

    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.

    Parameters

    • fromIndex: number

      The part's current index into the flat parts array.

    • toZoneCode: string

      Shortcode of the destination zone (its current zone for a plain reorder).

    • toPosition: number

      Target position among the destination zone's parts; past the end appends.

    Returns PlainObject

    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.

    Parameters

    • fromIndex: number

      The zone's current index.

    • toIndex: number

      The destination index.

    Returns PlainObject

    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.

    Parameters

    • shortcode: string

      The shortcode of the location to remove.

    Returns PlainObject

    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.

    Parameters

    • shortcode: string

      The shortcode of the part to remove.

    Returns PlainObject

    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.

    Parameters

    • shortcode: string

      The shortcode of the zone to remove.

    Returns PlainObject

    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.

    Parameters

    • oldCode: string

      The part shortcode the locations currently name.

    • newCode: string

      The part's new shortcode.

    Returns PlainObject

    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) }.

    Parameters

    • oldCode: string

      The zone shortcode the parts currently name.

    • newCode: string

      The zone's new shortcode.

    Returns PlainObject

    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).

    Parameters

    • updates: { changes: Partial<sohl.entity.body.BodyLocation.Data>; index: number }[]

      One { index, changes } per location to modify; changes is a partial of that location's persisted fields. Out-of-range indices are ignored.

    Returns PlainObject

    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.

    Parameters

    • updates: { changes: Partial<sohl.entity.body.BodyPart.Data>; index: number }[]

      One { index, changes } per part to modify; changes is a partial of that part's persisted fields. Out-of-range indices are ignored.

    Returns PlainObject

    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.

    Parameters

    • updates: { changes: Partial<sohl.entity.body.BodyZone.Data>; index: number }[]

      One { index, changes } per zone to modify; changes is a partial of that zone's persisted fields. Out-of-range indices are ignored.

    Returns PlainObject

    A complete-array update() payload, or {} if nothing applies.

  • Serialize this instance to a plain object suitable for JSON serialization.

    Returns PlainObject

    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.