@its-not-rocket-science/ananke 0.1.69 → 0.5.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.
Files changed (165) hide show
  1. package/CHANGELOG.md +254 -0
  2. package/README.md +150 -432
  3. package/STABLE_API.md +113 -490
  4. package/conformance/README.md +46 -44
  5. package/conformance/lockstep-sequence.json +395 -395
  6. package/conformance/phase-order.json +21 -0
  7. package/conformance/replay-parity.json +65 -65
  8. package/conformance/state-hash-regression.json +39 -0
  9. package/conformance/state-hash.json +23 -22
  10. package/dist/src/conformance.d.ts +8 -2
  11. package/dist/src/content/index.d.ts +3 -0
  12. package/dist/src/content/index.js +2 -0
  13. package/dist/src/content/injector.d.ts +19 -0
  14. package/dist/src/content/injector.js +61 -0
  15. package/dist/src/content/loader.d.ts +3 -0
  16. package/dist/src/content/loader.js +170 -0
  17. package/dist/src/content/types.d.ts +56 -0
  18. package/dist/src/content/types.js +1 -0
  19. package/dist/src/content/validator.d.ts +6 -0
  20. package/dist/src/content/validator.js +14 -0
  21. package/dist/src/content-pack.d.ts +3 -2
  22. package/dist/src/content-pack.js +205 -10
  23. package/dist/src/data-governance.d.ts +73 -0
  24. package/dist/src/data-governance.js +188 -0
  25. package/dist/src/debug.d.ts +6 -0
  26. package/dist/src/debug.js +9 -0
  27. package/dist/src/determinism.d.ts +12 -0
  28. package/dist/src/determinism.js +93 -0
  29. package/dist/src/dialogue.d.ts +41 -0
  30. package/dist/src/dialogue.js +113 -2
  31. package/dist/src/economy.d.ts +97 -3
  32. package/dist/src/economy.js +191 -37
  33. package/dist/src/history/autosave.d.ts +19 -0
  34. package/dist/src/history/autosave.js +98 -0
  35. package/dist/src/history/timetravel.d.ts +28 -0
  36. package/dist/src/history/timetravel.js +61 -0
  37. package/dist/src/index.d.ts +19 -31
  38. package/dist/src/index.js +20 -38
  39. package/dist/src/model3d.js +63 -2
  40. package/dist/src/narrative/combat-logger.d.ts +25 -0
  41. package/dist/src/narrative/combat-logger.js +97 -0
  42. package/dist/src/narrative/plausibility.d.ts +30 -0
  43. package/dist/src/narrative/plausibility.js +70 -0
  44. package/dist/src/narrative-layer.d.ts +2 -0
  45. package/dist/src/narrative-layer.js +2 -0
  46. package/dist/src/navigation/causal-chain.d.ts +26 -0
  47. package/dist/src/navigation/causal-chain.js +74 -0
  48. package/dist/src/netcode.d.ts +1 -1
  49. package/dist/src/netcode.js +1 -1
  50. package/dist/src/performance/adaptive-tick.d.ts +23 -0
  51. package/dist/src/performance/adaptive-tick.js +50 -0
  52. package/dist/src/plugins/loader.d.ts +23 -0
  53. package/dist/src/plugins/loader.js +86 -0
  54. package/dist/src/plugins/registry.d.ts +11 -0
  55. package/dist/src/plugins/registry.js +28 -0
  56. package/dist/src/plugins/types.d.ts +31 -0
  57. package/dist/src/plugins/types.js +7 -0
  58. package/dist/src/replay.js +5 -2
  59. package/dist/src/scenario.js +2 -1
  60. package/dist/src/serialization/binary.d.ts +5 -0
  61. package/dist/src/serialization/binary.js +193 -0
  62. package/dist/src/session.d.ts +115 -0
  63. package/dist/src/session.js +228 -0
  64. package/dist/src/sim/ai/decide.js +1 -1
  65. package/dist/src/sim/ai/personality.js +2 -2
  66. package/dist/src/sim/bodyplan.d.ts +30 -0
  67. package/dist/src/sim/context.d.ts +2 -0
  68. package/dist/src/sim/kernel.js +80 -1294
  69. package/dist/src/sim/normalization.d.ts +12 -0
  70. package/dist/src/sim/normalization.js +92 -0
  71. package/dist/src/sim/resolvers/attack-resolver.d.ts +30 -0
  72. package/dist/src/sim/resolvers/attack-resolver.js +310 -0
  73. package/dist/src/sim/resolvers/capability-resolver.d.ts +15 -0
  74. package/dist/src/sim/resolvers/capability-resolver.js +270 -0
  75. package/dist/src/sim/resolvers/grapple-resolver.d.ts +28 -0
  76. package/dist/src/sim/resolvers/grapple-resolver.js +70 -0
  77. package/dist/src/sim/resolvers/shoot-resolver.d.ts +27 -0
  78. package/dist/src/sim/resolvers/shoot-resolver.js +212 -0
  79. package/dist/src/sim/resolvers/treat-resolver.d.ts +16 -0
  80. package/dist/src/sim/resolvers/treat-resolver.js +77 -0
  81. package/dist/src/sim/step/apply/intents.d.ts +6 -0
  82. package/dist/src/sim/step/apply/intents.js +83 -0
  83. package/dist/src/sim/step/kernel-pipeline.d.ts +9 -0
  84. package/dist/src/sim/step/kernel-pipeline.js +15 -0
  85. package/dist/src/sim/step/phases/capability-phase.d.ts +8 -0
  86. package/dist/src/sim/step/phases/capability-phase.js +65 -0
  87. package/dist/src/sim/step/phases/cooldowns-phase.d.ts +3 -0
  88. package/dist/src/sim/step/phases/cooldowns-phase.js +37 -0
  89. package/dist/src/sim/step/phases/prepare-phase.d.ts +10 -0
  90. package/dist/src/sim/step/phases/prepare-phase.js +33 -0
  91. package/dist/src/sim/step/pipeline.d.ts +2 -0
  92. package/dist/src/sim/step/pipeline.js +15 -0
  93. package/dist/src/sim/step/resolvers/impact-resolver.d.ts +9 -0
  94. package/dist/src/sim/step/resolvers/impact-resolver.js +55 -0
  95. package/dist/src/sim/step/world-phases.d.ts +7 -0
  96. package/dist/src/sim/step/world-phases.js +15 -0
  97. package/dist/src/sim/step/world-step-context.d.ts +23 -0
  98. package/dist/src/sim/step/world-step-context.js +1 -0
  99. package/dist/src/sim/systemic-toxicology.js +1 -1
  100. package/dist/src/sim/team.js +2 -2
  101. package/dist/src/sim/testing.js +4 -3
  102. package/dist/src/sim/toxicology.d.ts +1 -1
  103. package/dist/src/sim/toxicology.js +1 -1
  104. package/dist/src/sim/world.d.ts +17 -24
  105. package/dist/src/snapshot.js +3 -6
  106. package/dist/src/tier2.d.ts +19 -0
  107. package/dist/src/tier2.js +21 -0
  108. package/dist/src/tier3.d.ts +10 -0
  109. package/dist/src/tier3.js +12 -0
  110. package/dist/src/version.d.ts +6 -0
  111. package/dist/src/version.js +8 -0
  112. package/dist/src/wasm/bridge.d.ts +14 -0
  113. package/dist/src/wasm/bridge.js +84 -0
  114. package/dist/src/wasm-kernel.d.ts +2 -0
  115. package/dist/src/wasm-kernel.js +1 -0
  116. package/dist/src/world-evolution-backend/engine.d.ts +4 -0
  117. package/dist/src/world-evolution-backend/engine.js +347 -0
  118. package/dist/src/world-evolution-backend/host-schema.d.ts +145 -0
  119. package/dist/src/world-evolution-backend/host-schema.js +375 -0
  120. package/dist/src/world-evolution-backend/index.d.ts +1 -0
  121. package/dist/src/world-evolution-backend/index.js +1 -0
  122. package/dist/src/world-evolution-backend/open-world-host-adapter.d.ts +96 -0
  123. package/dist/src/world-evolution-backend/open-world-host-adapter.js +208 -0
  124. package/dist/src/world-evolution-backend/profiles.d.ts +4 -0
  125. package/dist/src/world-evolution-backend/profiles.js +136 -0
  126. package/dist/src/world-evolution-backend/public.d.ts +11 -0
  127. package/dist/src/world-evolution-backend/public.js +7 -0
  128. package/dist/src/world-evolution-backend/reproducibility.d.ts +8 -0
  129. package/dist/src/world-evolution-backend/reproducibility.js +35 -0
  130. package/dist/src/world-evolution-backend/timeline.d.ts +6 -0
  131. package/dist/src/world-evolution-backend/timeline.js +337 -0
  132. package/dist/src/world-evolution-backend/types.d.ts +138 -0
  133. package/dist/src/world-evolution-backend/types.js +1 -0
  134. package/dist/src/world-evolution-host-backend.d.ts +51 -0
  135. package/dist/src/world-evolution-host-backend.js +97 -0
  136. package/dist/src/world-evolution.d.ts +201 -0
  137. package/dist/src/world-evolution.js +543 -0
  138. package/dist/src/world-factory.js +2 -1
  139. package/dist/tools/pack-cli.js +55 -2
  140. package/docs/bridge-contract.md +140 -332
  141. package/docs/completion-evidence-dashboard.md +63 -0
  142. package/docs/host-contract.md +86 -310
  143. package/docs/host-world-evolution-schema.md +155 -0
  144. package/docs/integration-primer.md +29 -22
  145. package/docs/migration-monolith-to-modular.md +102 -120
  146. package/docs/package-architecture.md +116 -323
  147. package/docs/plugins/README.md +42 -0
  148. package/docs/plugins/hooks.md +22 -0
  149. package/docs/plugins/publishing.md +29 -0
  150. package/docs/plugins/security-model.md +273 -0
  151. package/docs/project-overview.md +30 -30
  152. package/docs/release-dashboard.md +47 -0
  153. package/docs/session-api.md +127 -0
  154. package/docs/stable-api-manifest.json +52 -0
  155. package/docs/trust-dashboard.md +37 -0
  156. package/docs/versioning.md +256 -245
  157. package/docs/wire-protocol.md +140 -209
  158. package/docs/world-evolution-backend.md +200 -0
  159. package/docs/world-evolution-engine.md +150 -0
  160. package/docs/world-evolution-host-backend.md +70 -0
  161. package/docs/world-evolution-orchestration.md +248 -0
  162. package/package.json +125 -6
  163. package/schema/content-pack.schema.json +85 -0
  164. package/schema/plugin.schema.json +57 -0
  165. package/schema/world-evolution-input.schema.json +196 -0
@@ -1,60 +1,52 @@
1
1
  import { ensureAnatomyRuntime } from "./entity.js";
2
2
  import { SCALE, q, clampQ, qMul, mulDiv, to } from "../units.js";
3
3
  import { DamageChannel } from "../channels.js";
4
- import { deriveArmourProfile, findWeapon, findShield, findRangedWeapon, findExoskeleton, findSensor } from "../equipment.js";
5
- import { isCapabilityAvailable } from "./tech.js";
6
- import { deriveFunctionalState, hasAllDisabledFunctions } from "./impairment.js";
4
+ import { findWeapon } from "../equipment.js";
7
5
  import { TUNING } from "./tuning.js";
8
6
  import { vSub, vAdd } from "./vec3.js";
9
- import { defaultIntent } from "./intent.js";
10
- import { defaultAction } from "./action.js";
11
- import { resolveHit, shieldCovers, chooseArea } from "./combat.js";
7
+ import { shieldCovers, chooseArea } from "./combat.js";
12
8
  import { normaliseDirCheapQ, dotDirQ } from "./vec3.js";
13
9
  import { eventSeed } from "./seeds.js";
14
10
  import { regionFromHit } from "./body.js";
15
11
  import { resolveHitSegment } from "./bodyplan.js";
16
12
  import { FRACTURE_THRESHOLD } from "./injury.js";
17
- import { TIER_RANK, TIER_MUL, ACTION_MIN_TIER, TIER_TECH_REQ } from "./medical.js";
18
13
  import { blastEnergyFracQ, fragmentsExpected, fragmentKineticEnergy } from "./explosion.js";
19
14
  import { makeRng } from "../rng.js";
20
- import { buildWorldIndex } from "./indexing.js";
21
15
  import { buildSpatialIndex } from "./spatial.js";
22
16
  import { sortEventsDeterministic } from "./events.js";
23
- import { parryLeverageQ } from "./combat.js";
24
17
  import { pickNearestEnemyInReach } from "./formation.js";
25
- import { isMeleeLaneOccludedByFriendly } from "./occlusion.js";
26
18
  import { applyFrontageCap } from "./frontage.js";
27
- import { computeDensityField } from "./density.js";
28
- import { coverFractionAtPosition, elevationAtPosition } from "./terrain.js";
29
19
  import { nullTrace } from "./trace.js";
30
20
  import { TraceKinds } from "./kinds.js";
31
- import { DEFAULT_SENSORY_ENV, DEFAULT_PERCEPTION, canDetect } from "./sensory.js";
32
- import { FEAR_SURPRISE, isRouting, painBlocksAction } from "./morale.js";
21
+ import { runPreparePhase } from "./step/phases/prepare-phase.js";
22
+ import { runCooldownsPhase } from "./step/phases/cooldowns-phase.js";
23
+ import { runCapabilityPhase } from "./step/phases/capability-phase.js";
24
+ import { isRouting } from "./morale.js";
25
+ import { applyCommands, applyFunctionalGating, applyStandAndKO } from "./step/apply/intents.js";
26
+ import { applyResolvedImpacts } from "./step/resolvers/impact-resolver.js";
27
+ import { STEP_PHASE_ORDER } from "./step/pipeline.js";
33
28
  import { stepPushAndRepulsion } from "./step/push.js";
34
29
  import { stepMoraleForEntity } from "./step/morale.js";
35
30
  import { stepSubstances } from "./step/substances.js";
36
31
  import { stepEnergy } from "./step/energy.js";
37
- import { stepConcentration } from "./step/concentration.js";
38
- import { computeKnockback, applyKnockback } from "./knockback.js";
39
- import { computeTemporaryCavityMul, computeCavitationBleed } from "./hydrostatic.js";
40
- import { entityInCone } from "./cone.js";
41
32
  import { stepConditionsToInjury, stepInjuryProgression } from "./step/injury.js";
42
- import { stepCoreTemp, deriveTempModifiers, CORE_TEMP_NORMAL_Q } from "./thermoregulation.js";
33
+ import { stepCoreTemp } from "./thermoregulation.js";
43
34
  import { stepNutrition } from "./nutrition.js";
44
35
  import { stepToxicology } from "./toxicology.js";
45
36
  import { stepIngestedToxicology } from "./systemic-toxicology.js";
46
- import { buildLimbStates, stepLimbFatigue } from "./limb.js";
37
+ import { stepLimbFatigue } from "./limb.js";
47
38
  import { stepCapabilitySources } from "./step/capability.js";
48
39
  import { stepMovement } from "./step/movement.js";
49
40
  import { stepChainEffects, stepFieldEffects, stepHazardEffects } from "./step/effects.js";
50
- import { deriveWeatherModifiers, computeWindAimError } from "./weather.js";
51
- import { resolveGrappleAttempt, resolveGrappleThrow, resolveGrappleChoke, resolveGrappleJointLock, resolveBreakGrapple, stepGrappleTick, } from "./grapple.js";
52
- import { reachDomPenaltyQ, twoHandedAttackBonusQ, missRecoveryTicks, bindChanceQ, bindDurationTicks, breakBindContestQ, } from "./weapon_dynamics.js";
53
- import { energyAtRange_J, adjustedDispersionQ, groupingRadius_m, thrownLaunchEnergy_J, recycleTicks, shootCost_J, } from "./ranged.js";
54
- import { getSkill } from "./skills.js";
41
+ import { resolveBreakGrapple, stepGrappleTick, } from "./grapple.js";
55
42
  import { TICK_HZ } from "./tick.js";
43
+ import { assertDeterministicWorldLike, assertNoFloatUsageInProduction } from "../determinism.js";
44
+ import { resolveAttack as resolveAttackFromResolver } from "./resolvers/attack-resolver.js";
45
+ import { resolveShoot as resolveShootFromResolver } from "./resolvers/shoot-resolver.js";
46
+ import { resolveGrappleCommand as resolveGrappleCommandFromResolver, resolveBreakBind as resolveBreakBindFromResolver } from "./resolvers/grapple-resolver.js";
47
+ import { resolveTreat as resolveTreatFromResolver } from "./resolvers/treat-resolver.js";
48
+ import { resolveActivation as resolveActivationFromResolver, applyPayload as applyPayloadFromResolver, applyCapabilityEffect as applyCapabilityEffectFromResolver } from "./resolvers/capability-resolver.js";
56
49
  // Phase 2 extension: swing momentum carry
57
- const SWING_MOMENTUM_DECAY = q(0.95); // 5% decay per tick
58
50
  const SWING_MOMENTUM_MAX = q(0.12); // max +12% energy bonus at full momentum
59
51
  // Phase 3 extension: aiming time
60
52
  const AIM_MAX_TICKS = 20; // 1 second at 20 ticks/s
@@ -107,225 +99,19 @@ function shieldBlocksSegment(shield, target, segmentId, area) {
107
99
  return shieldCovers(shield, effectiveArea);
108
100
  }
109
101
  export function stepWorld(world, cmds, ctx) {
102
+ assertNoFloatUsageInProduction(world);
103
+ const strictDeterminism = ctx.strictDeterminism ??
104
+ (typeof process !== "undefined" && process.env.ANANKE_STRICT_DETERMINISM === "1");
105
+ if (strictDeterminism)
106
+ assertDeterministicWorldLike(world, "step:start");
110
107
  const tuning = ctx.tuning ?? TUNING.tactical;
111
108
  const trace = ctx.trace ?? nullTrace;
112
- // Phase 4: attach sensory environment to world for use in resolveAttack / resolveShoot.
113
- // WorldState is a plain data object; we use a type-cast side-channel to avoid widening the type.
114
- (world).__sensoryEnv = ctx.sensoryEnv ?? DEFAULT_SENSORY_ENV;
115
- // Phase 51: apply weather modifiers to traction, sensory environment, and thermal ambient.
116
- if (ctx.weather) {
117
- const wMod = deriveWeatherModifiers(ctx.weather);
118
- // Traction: rain/snow/ice reduce friction.
119
- ctx.tractionCoeff = Math.trunc((ctx.tractionCoeff * wMod.tractionMul_Q) / SCALE.Q);
120
- // Sensory: fog and precipitation reduce vision range.
121
- const baseEnv = world.__sensoryEnv;
122
- world.__sensoryEnv = {
123
- ...baseEnv,
124
- lightMul: Math.trunc((baseEnv.lightMul * wMod.lightMul_Q) / SCALE.Q),
125
- smokeMul: Math.trunc((baseEnv.smokeMul * wMod.precipVisionMul_Q) / SCALE.Q),
126
- };
127
- // Thermal: precipitation cools the ambient temperature (Phase 29 encoding).
128
- if (ctx.thermalAmbient_Q !== undefined && wMod.thermalOffset_Q !== 0) {
129
- ctx.thermalAmbient_Q = (ctx.thermalAmbient_Q + wMod.thermalOffset_Q);
130
- }
131
- }
132
- world.entities.sort((a, b) => a.id - b.id);
133
- const index = buildWorldIndex(world);
134
- const cellSize_m = ctx.cellSize_m ?? Math.trunc(4 * SCALE.m);
135
- const spatial = buildSpatialIndex(world, cellSize_m);
136
- const density = computeDensityField(world, index, spatial, {
137
- personalRadius_m: Math.trunc(0.45 * SCALE.m),
138
- maxNeighbours: 12,
139
- crowdingAt: 6,
140
- });
141
- ctx.density = density;
109
+ void STEP_PHASE_ORDER;
110
+ const { cellSize_m, index, spatial } = runPreparePhase(world, ctx);
111
+ const runtimeState = world.runtimeState;
142
112
  const impacts = [];
143
- for (const e of world.entities) {
144
- if (!(e).intent)
145
- (e).intent = defaultIntent();
146
- if (!(e).action)
147
- (e).action = defaultAction();
148
- // Phase 2A: default new fields on entities created before this phase
149
- if (!(e).grapple) {
150
- (e).grapple = { holdingTargetId: 0, heldByIds: [], gripQ: q(0), position: "standing" };
151
- }
152
- else if ((e).grapple.position === undefined) {
153
- (e).grapple.position = "standing";
154
- }
155
- if ((e).action.grappleCooldownTicks === undefined)
156
- (e).action.grappleCooldownTicks = 0;
157
- if ((e).condition?.pinned === undefined)
158
- (e).condition.pinned = false;
159
- // Phase 2C: default weapon bind fields
160
- if ((e).action.weaponBindPartnerId === undefined)
161
- (e).action.weaponBindPartnerId = 0;
162
- if ((e).action.weaponBindTicks === undefined)
163
- (e).action.weaponBindTicks = 0;
164
- // Phase 3: ranged combat fields
165
- if ((e).action.shootCooldownTicks === undefined)
166
- (e).action.shootCooldownTicks = 0;
167
- if ((e).condition.suppressedTicks === undefined)
168
- (e).condition.suppressedTicks = 0;
169
- // Phase 2 extension: swing momentum
170
- if ((e).action.swingMomentumQ === undefined)
171
- (e).action.swingMomentumQ = 0;
172
- // Phase 3 extension: aiming time
173
- if ((e).action.aimTicks === undefined)
174
- (e).action.aimTicks = 0;
175
- if ((e).action.aimTargetId === undefined)
176
- (e).action.aimTargetId = 0;
177
- // Phase 4: perception defaults and decision latency
178
- if (!(e.attributes).perception)
179
- (e.attributes).perception = DEFAULT_PERCEPTION;
180
- if (!e.ai)
181
- e.ai = { focusTargetId: 0, retargetCooldownTicks: 0, decisionCooldownTicks: 0 };
182
- else if ((e.ai).decisionCooldownTicks === undefined)
183
- (e.ai).decisionCooldownTicks = 0;
184
- // Phase 5: fear / morale
185
- if ((e.condition).fearQ === undefined)
186
- (e.condition).fearQ = q(0);
187
- // Phase 5 extensions: morale features
188
- if ((e.condition).suppressionFearMul === undefined)
189
- (e.condition).suppressionFearMul = SCALE.Q;
190
- if ((e.condition).recentAllyDeaths === undefined)
191
- (e.condition).recentAllyDeaths = 0;
192
- if ((e.condition).lastAllyDeathTick === undefined)
193
- (e.condition).lastAllyDeathTick = -1;
194
- if ((e.condition).surrendered === undefined)
195
- (e.condition).surrendered = false;
196
- if ((e.condition).rallyCooldownTicks === undefined)
197
- (e.condition).rallyCooldownTicks = 0;
198
- // Phase 10C: flash blindness
199
- if ((e.condition).blindTicks === undefined)
200
- (e.condition).blindTicks = 0;
201
- // Phase 9: new RegionInjury fields (default for entities created pre-Phase-9)
202
- if ((e.injury).hemolymphLoss === undefined)
203
- (e.injury).hemolymphLoss = q(0);
204
- for (const reg of Object.values(e.injury.byRegion)) {
205
- if ((reg).fractured === undefined)
206
- (reg).fractured = false;
207
- if ((reg).infectedTick === undefined)
208
- (reg).infectedTick = -1;
209
- if ((reg).bleedDuration_ticks === undefined)
210
- (reg).bleedDuration_ticks = 0;
211
- if ((reg).permanentDamage === undefined)
212
- (reg).permanentDamage = q(0);
213
- }
214
- // Phase 11C: initialize ablative armour state for entities that don't have it yet
215
- if (!e.armourState) {
216
- const armourTiems = e.loadout.items.filter(it => it.kind === "armour");
217
- const ablativeItems = armourTiems.filter(it => it.ablative);
218
- if (ablativeItems.length > 0) {
219
- e.armourState = new Map(ablativeItems.map(it => [it.id, { resistRemaining_J: (it).resist_J }]));
220
- }
221
- }
222
- // Phase 32B: initialize per-limb state for multi-limb body plans (once only)
223
- if (!e.limbStates && e.bodyPlan) {
224
- const limbs = buildLimbStates(e.bodyPlan);
225
- if (limbs.length > 0)
226
- e.limbStates = limbs;
227
- }
228
- }
229
- for (const e of world.entities) {
230
- e.action.attackCooldownTicks = Math.max(0, e.action.attackCooldownTicks - 1);
231
- e.action.defenceCooldownTicks = Math.max(0, e.action.defenceCooldownTicks - 1);
232
- e.action.grappleCooldownTicks = Math.max(0, e.action.grappleCooldownTicks - 1);
233
- e.action.shootCooldownTicks = Math.max(0, e.action.shootCooldownTicks - 1); // Phase 3
234
- e.action.swingMomentumQ = qMul(e.action.swingMomentumQ, SWING_MOMENTUM_DECAY); // Phase 2 ext
235
- // Phase 12B: per-capability cooldown decay
236
- if (e.action.capabilityCooldowns) {
237
- for (const [key, ticks] of e.action.capabilityCooldowns) {
238
- if (ticks <= 1)
239
- e.action.capabilityCooldowns.delete(key);
240
- else
241
- e.action.capabilityCooldowns.set(key, ticks - 1);
242
- }
243
- }
244
- e.condition.standBlockedTicks = Math.max(0, e.condition.standBlockedTicks - 1);
245
- e.condition.unconsciousTicks = Math.max(0, e.condition.unconsciousTicks - 1);
246
- e.condition.suppressedTicks = Math.max(0, e.condition.suppressedTicks - 1); // Phase 3
247
- e.condition.blindTicks = Math.max(0, e.condition.blindTicks - 1); // Phase 10C
248
- if (e.action.staggerTicks)
249
- e.action.staggerTicks = Math.max(0, e.action.staggerTicks - 1); // Phase 26
250
- e.condition.rallyCooldownTicks = Math.max(0, e.condition.rallyCooldownTicks - 1); // Phase 5 ext
251
- // Phase 2C: weapon bind decay — emit trace only from the smaller-ID entity to avoid duplicates
252
- if (e.action.weaponBindTicks > 0) {
253
- e.action.weaponBindTicks = Math.max(0, e.action.weaponBindTicks - 1);
254
- if (e.action.weaponBindTicks === 0) {
255
- const partnerId = e.action.weaponBindPartnerId;
256
- e.action.weaponBindPartnerId = 0;
257
- if (partnerId !== 0 && e.id < partnerId) {
258
- trace.onEvent({ kind: TraceKinds.WeaponBindBreak, tick: world.tick, entityId: e.id, partnerId, reason: "timeout" });
259
- }
260
- }
261
- }
262
- }
263
- // Phase 12: resolve or interrupt pending capability activations (cast-time completion)
264
- const CAST_INTERRUPT_SHOCK = q(0.30);
265
- for (const e of world.entities) {
266
- if (!e.pendingActivation || e.injury.dead)
267
- continue;
268
- if (e.injury.shock >= CAST_INTERRUPT_SHOCK) {
269
- trace.onEvent({ kind: TraceKinds.CastInterrupted, tick: world.tick, entityId: e.id });
270
- delete e.pendingActivation;
271
- }
272
- else if (world.tick >= e.pendingActivation.resolveAtTick) {
273
- const src = e.capabilitySources?.find(s => s.id === e.pendingActivation.sourceId);
274
- const eff = src?.effects.find(ef => ef.id === e.pendingActivation.effectId);
275
- if (src && eff) {
276
- applyCapabilityEffect(world, e, e.pendingActivation.targetId, eff, trace, world.tick);
277
- trace.onEvent({ kind: TraceKinds.CapabilityActivated, tick: world.tick, entityId: e.id, sourceId: src.id, effectId: eff.id });
278
- // Phase 28: set up sustained emission for remaining ticks after cast completion
279
- if (eff.sustainedTicks && eff.sustainedTicks > 1) {
280
- e.action.sustainedEmission = {
281
- sourceId: src.id,
282
- effectId: eff.id,
283
- ...(e.pendingActivation.targetId !== undefined ? { targetId: e.pendingActivation.targetId } : {}),
284
- remainingTicks: eff.sustainedTicks - 1,
285
- };
286
- }
287
- }
288
- delete e.pendingActivation;
289
- }
290
- }
291
- // Phase 12B: step active concentration auras (castTime_ticks = -1 ongoing effects)
292
- for (const e of world.entities) {
293
- if (!e.activeConcentration || e.injury.dead)
294
- continue;
295
- stepConcentration(e, world, trace, world.tick);
296
- }
297
- // Phase 28: step sustained emission (breath weapons, flamethrowers, etc.)
298
- for (const e of world.entities) {
299
- if (!e.action.sustainedEmission || e.injury.dead)
300
- continue;
301
- const em = e.action.sustainedEmission;
302
- // Concentration break: shock interrupts sustained emission (same threshold as cast)
303
- if (e.injury.shock >= CAST_INTERRUPT_SHOCK) {
304
- trace.onEvent({ kind: TraceKinds.CastInterrupted, tick: world.tick, entityId: e.id });
305
- delete e.action.sustainedEmission;
306
- continue;
307
- }
308
- const src = e.capabilitySources?.find(s => s.id === em.sourceId);
309
- const eff = src?.effects.find(ef => ef.id === em.effectId);
310
- if (!src || !eff) {
311
- delete e.action.sustainedEmission;
312
- continue;
313
- }
314
- // Deduct cost per tick; stop if reserve exhausted
315
- const isBoundless = src.regenModel.type === "boundless";
316
- if (!isBoundless) {
317
- if (src.reserve_J < eff.cost_J) {
318
- delete e.action.sustainedEmission;
319
- continue;
320
- }
321
- src.reserve_J -= eff.cost_J;
322
- }
323
- applyCapabilityEffect(world, e, em.targetId, eff, trace, world.tick);
324
- em.remainingTicks -= 1;
325
- if (em.remainingTicks <= 0) {
326
- delete e.action.sustainedEmission;
327
- }
328
- }
113
+ runCooldownsPhase(world, trace);
114
+ runCapabilityPhase(world, trace, { applyCapabilityEffect });
329
115
  for (const e of world.entities) {
330
116
  if (e.injury.dead)
331
117
  continue;
@@ -346,6 +132,8 @@ export function stepWorld(world, cmds, ctx) {
346
132
  trace.onEvent({ kind: TraceKinds.Move, tick: world.tick, entityId: e.id, pos: e.position_m, vel: e.velocity_mps });
347
133
  }
348
134
  const spatialAfterMove = buildSpatialIndex(world, cellSize_m);
135
+ if (strictDeterminism)
136
+ assertDeterministicWorldLike(world, "step:post-movement");
349
137
  // Phase 6: hazard damage — applied after movement so entities in hazard cells take damage each tick.
350
138
  if (ctx.hazardGrid) {
351
139
  stepHazardEffects(world.entities, ctx.hazardGrid, cellSize_m);
@@ -424,60 +212,9 @@ export function stepWorld(world, cmds, ctx) {
424
212
  sortEventsDeterministic(finalImpacts);
425
213
  // Phase 5: snapshot alive set before impacts are applied (used by morale step to detect ally deaths)
426
214
  const aliveBeforeTick = new Set(world.entities.filter(e => !e.injury.dead).map(e => e.id));
427
- for (const ev of finalImpacts) {
428
- const target = index.byId.get(ev.targetId);
429
- if (!target || target.injury.dead)
430
- continue;
431
- // Phase 12: temporary shield absorption from armourLayer capability effects
432
- let effectiveEnergy = ev.energy_J;
433
- if ((target.condition.shieldReserve_J ?? 0) > 0 &&
434
- target.condition.shieldExpiry_tick !== undefined &&
435
- world.tick <= target.condition.shieldExpiry_tick) {
436
- const absorbed = Math.min(target.condition.shieldReserve_J, effectiveEnergy);
437
- target.condition.shieldReserve_J -= absorbed;
438
- effectiveEnergy -= absorbed;
439
- }
440
- if (effectiveEnergy > 0) {
441
- // Phase 27: compute temporary-cavity multiplier from impact velocity
442
- const region = ev.region;
443
- const tempCavMul = ev.v_impact_mps
444
- ? computeTemporaryCavityMul(ev.v_impact_mps, region)
445
- : undefined;
446
- applyImpactToInjury(target, ev.wpn, effectiveEnergy, region, ev.protectedByArmour, trace, world.tick, tempCavMul);
447
- // Phase 27: cavitation bleed boost for fluid-saturated tissue
448
- if (ev.v_impact_mps) {
449
- const rs = target.injury.byRegion[region];
450
- if (rs) {
451
- rs.bleedingRate = computeCavitationBleed(ev.v_impact_mps, rs.bleedingRate, region);
452
- }
453
- }
454
- }
455
- // Phase 26: apply knockback impulse to the target
456
- if (effectiveEnergy > 0 && (ev.massEff_kg ?? 0) > 0) {
457
- const attacker = index.byId.get(ev.attackerId);
458
- if (attacker) {
459
- const kbResult = computeKnockback(effectiveEnergy, ev.massEff_kg, target);
460
- applyKnockback(target, kbResult, {
461
- dx: target.position_m.x - attacker.position_m.x,
462
- dy: target.position_m.y - attacker.position_m.y,
463
- });
464
- }
465
- }
466
- trace.onEvent({
467
- kind: TraceKinds.Attack,
468
- tick: world.tick,
469
- attackerId: ev.attackerId,
470
- targetId: ev.targetId,
471
- weaponId: ev.weaponId, // Phase 18
472
- region: ev.region,
473
- energy_J: ev.energy_J,
474
- blocked: ev.blocked,
475
- parried: ev.parried,
476
- shieldBlocked: ev.shieldBlocked,
477
- armoured: ev.protectedByArmour,
478
- hitQuality: ev.hitQuality,
479
- });
480
- }
215
+ applyResolvedImpacts(world, index, finalImpacts, trace, { applyImpactToInjury });
216
+ if (strictDeterminism)
217
+ assertDeterministicWorldLike(world, "step:post-impacts");
481
218
  // Phase 12B: apply chain payloads from active field effects, then expire timed ones
482
219
  stepChainEffects(world, trace, world.tick);
483
220
  stepFieldEffects(world);
@@ -550,11 +287,11 @@ export function stepWorld(world, cmds, ctx) {
550
287
  // Phase 30: nutrition at 1 Hz (world-level accumulator avoids per-tick BMR calls)
551
288
  // Phase 32C: toxicology ticked at same 1 Hz cadence
552
289
  {
553
- if ((world).__nutritionAccum === undefined)
554
- (world).__nutritionAccum = 0;
555
- (world).__nutritionAccum = (world).__nutritionAccum + (1 / TICK_HZ);
556
- if ((world).__nutritionAccum >= 1.0) {
557
- (world).__nutritionAccum -= 1.0;
290
+ if (runtimeState.nutritionAccum === undefined)
291
+ runtimeState.nutritionAccum = 0;
292
+ runtimeState.nutritionAccum += (1 / TICK_HZ);
293
+ if (runtimeState.nutritionAccum >= 1.0) {
294
+ runtimeState.nutritionAccum -= 1.0;
558
295
  for (const e of world.entities) {
559
296
  if (!e.injury.dead) {
560
297
  const nVMag = Math.sqrt(e.velocity_mps.x ** 2 + e.velocity_mps.y ** 2);
@@ -582,6 +319,8 @@ export function stepWorld(world, cmds, ctx) {
582
319
  }
583
320
  trace.onEvent({ kind: TraceKinds.TickEnd, tick: world.tick });
584
321
  world.tick += 1;
322
+ if (strictDeterminism)
323
+ assertDeterministicWorldLike(world, "step:end");
585
324
  }
586
325
  function resolveCapabilityHitSegment(world, tick, actor, target, salt) {
587
326
  const seed = eventSeed(world.seed, tick, actor.id, target.id, salt);
@@ -589,395 +328,23 @@ function resolveCapabilityHitSegment(world, tick, actor, target, salt) {
589
328
  const sideBit = (seed & 1);
590
329
  return resolveTargetHitSegment(target, rng.q01(), sideBit);
591
330
  }
592
- function applyFunctionalGating(e, tuning) {
593
- const func = deriveFunctionalState(e, tuning);
594
- // incapacity gates voluntary actions
595
- if (!func.canAct) {
596
- e.intent.defence = { mode: "none", intensity: q(0) };
597
- e.intent.move = { dir: { x: 0, y: 0, z: 0 }, intensity: q(0), mode: "walk" };
598
- // keep prone if already, and prefer prone for non-acting entities in tactical/sim
599
- if (tuning.realism !== "arcade")
600
- e.condition.prone = true;
601
- return;
602
- }
603
- // Phase 2A: pinned entities cannot use normal defence (only breakGrapple applies)
604
- if (e.condition.pinned && tuning.realism !== "arcade") {
605
- e.intent.defence = { mode: "none", intensity: q(0) };
606
- e.condition.prone = true;
607
- }
608
- // Phase 2B: exhaustion collapse — when reserve is fully depleted, entity
609
- // cannot maintain posture or active defence (tactical/sim only).
610
- if (e.energy.reserveEnergy_J <= 0 && tuning.realism !== "arcade") {
611
- e.condition.prone = true;
612
- e.intent.defence = { mode: "none", intensity: q(0) };
613
- }
614
- // forced prone if cannot stand (tactical/sim)
615
- if (!func.canStand && tuning.realism !== "arcade")
616
- e.condition.prone = true;
617
- // hard limb disable hooks (tactical/sim)
618
- if (tuning.realism !== "arcade") {
619
- const armsOut = hasAllDisabledFunctions(func, "leftManipulation", "rightManipulation");
620
- if (armsOut && (e.intent.defence.mode === "block" || e.intent.defence.mode === "parry")) {
621
- e.intent.defence = { mode: "none", intensity: q(0) };
622
- }
623
- const legsOut = hasAllDisabledFunctions(func, "leftLocomotion", "rightLocomotion");
624
- if (legsOut && e.intent.move.mode === "sprint") {
625
- e.intent.move = { ...e.intent.move, mode: "walk" };
626
- }
627
- }
628
- }
629
- function applyCommands(e, commands) {
630
- e.intent.defence = { mode: "none", intensity: q(0) };
631
- for (const c of commands) {
632
- if (c.kind === "setProne")
633
- e.condition.prone = c.prone;
634
- else if (c.kind === "move")
635
- e.intent.move = { dir: c.dir, intensity: c.intensity, mode: c.mode };
636
- else if (c.kind === "defend")
637
- e.intent.defence = { mode: c.mode, intensity: clampQ(c.intensity, 0, SCALE.Q) };
638
- }
639
- }
640
- function applyStandAndKO(e, tuning) {
641
- // KO: if below threshold, go unconscious (but do NOT mark dead)
642
- const wasUnconscious = e.condition.unconsciousTicks > 0;
643
- if (e.injury.consciousness <= tuning.unconsciousThreshold) {
644
- if (!wasUnconscious) {
645
- e.condition.unconsciousTicks = tuning.unconsciousBaseTicks;
646
- e.condition.prone = true;
647
- e.intent.defence = { mode: "none", intensity: q(0) };
648
- e.intent.move = { dir: { x: 0, y: 0, z: 0 }, intensity: q(0), mode: "walk" };
649
- // SIM: drop weapons
650
- if (tuning.dropWeaponsOnUnconscious) {
651
- e.loadout.items = e.loadout.items.filter(it => it.kind !== "weapon");
652
- }
653
- }
654
- else {
655
- // keep them down
656
- e.condition.prone = true;
657
- }
658
- }
659
- // If unconscious, cannot act/stand
660
- if (e.condition.unconsciousTicks > 0) {
661
- e.intent.defence = { mode: "none", intensity: q(0) };
662
- e.intent.move = { dir: { x: 0, y: 0, z: 0 }, intensity: q(0), mode: "walk" };
663
- e.condition.prone = true;
664
- return;
665
- }
666
- // Standing rules: if player wants to stand but is blocked, force prone
667
- if (!e.intent.prone && e.condition.prone) {
668
- if (tuning.realism === "arcade") {
669
- e.condition.prone = false;
670
- return;
671
- }
672
- if (e.condition.standBlockedTicks > 0) {
673
- e.condition.prone = true;
674
- return;
675
- }
676
- // Compute stand-up time based on leg damage + shock + fatigue + encumbrance
677
- const func = deriveFunctionalState(e, tuning);
678
- const slow = (SCALE.Q - func.mobilityMul); // 0..1
679
- const extra = Math.trunc((slow * tuning.standUpMaxExtraTicks) / SCALE.Q);
680
- const ticks = tuning.standUpBaseTicks + extra;
681
- e.condition.standBlockedTicks = Math.max(1, ticks);
682
- e.condition.prone = true;
683
- e.intent.prone = true; // reflect forced state
684
- }
685
- }
686
331
  /* ------------------ Combat ------------------ */
687
332
  function resolveAttack(world, attacker, cmd, tuning, index, impacts, spatial, trace, ctx) {
688
- if (attacker.action.attackCooldownTicks > 0)
689
- return;
690
- // Phase 2C: weapon bind gate — cannot attack while weapons are locked
691
- if (attacker.action.weaponBindPartnerId !== 0)
692
- return;
693
- const target = index.byId.get(cmd.targetId);
694
- if (!target || target.injury.dead)
695
- return;
696
- const funcA = deriveFunctionalState(attacker, tuning);
697
- const funcB = deriveFunctionalState(target, tuning);
698
- if (!funcA.canAct)
699
- return;
700
- // Phase 5: pain-induced action suppression (tactical/sim only)
701
- if (tuning.realism !== "arcade") {
702
- const painSeed = eventSeed(world.seed, world.tick, attacker.id, target.id, 0xA77A2);
703
- if (painBlocksAction(painSeed, attacker.injury.shock, attacker.attributes.resilience.distressTolerance))
704
- return;
705
- }
706
- const wpn = findWeapon(attacker.loadout, cmd.weaponId);
707
- if (!wpn)
708
- return;
709
- const reach_m = wpn.reach_m ?? Math.trunc(attacker.attributes.morphology.stature_m * 0.45);
710
- const dx = target.position_m.x - attacker.position_m.x;
711
- const dy = target.position_m.y - attacker.position_m.y;
712
- const dz = target.position_m.z - attacker.position_m.z;
713
- // Phase 6: elevation differential adds to vertical separation in the reach check.
714
- const cellSizeA = ctx.cellSize_m ?? Math.trunc(4 * SCALE.m);
715
- const elevA = elevationAtPosition(ctx.elevationGrid, cellSizeA, attacker.position_m.x, attacker.position_m.y);
716
- const elevT = elevationAtPosition(ctx.elevationGrid, cellSizeA, target.position_m.x, target.position_m.y);
717
- const dzWithElev = dz + (elevT - elevA);
718
- const dist2 = BigInt(dx) * BigInt(dx) + BigInt(dy) * BigInt(dy) + BigInt(dzWithElev) * BigInt(dzWithElev);
719
- const reach2 = BigInt(reach_m) * BigInt(reach_m);
720
- if (dist2 > reach2)
721
- return;
722
- if (tuning.realism !== "arcade") {
723
- const blocked = isMeleeLaneOccludedByFriendly(world, attacker, target, index, spatial, { laneRadius_m: Math.trunc(0.35 * SCALE.m) });
724
- if (blocked)
725
- return;
726
- }
727
- const dirToTarget = normaliseDirCheapQ({ x: dx, y: dy, z: dz });
728
- const readyTime_s = wpn.readyTime_s ?? to.s(0.6);
729
- // Phase 7: meleeCombat.hitTimingOffset_s shortens attack recovery (max 67% reduction)
730
- const attackerMeleeSkill = getSkill(attacker.skills, "meleeCombat");
731
- const effectiveReadyTime = Math.max(Math.trunc(readyTime_s / 3), readyTime_s + attackerMeleeSkill.hitTimingOffset_s);
732
- attacker.action.attackCooldownTicks = Math.max(1, Math.trunc((effectiveReadyTime * TICK_HZ) / SCALE.s));
733
- // Phase 2B: deduct strike stamina cost (always — attacker expends effort whether hit or miss)
734
- const clampedIntensity = clampQ(cmd.intensity ?? q(1.0), q(0.1), q(1.0));
735
- attacker.energy.reserveEnergy_J = Math.max(0, attacker.energy.reserveEnergy_J - strikeCost_J(attacker, clampedIntensity));
736
- const attackSkillBase = clampQ(qMul(attacker.attributes.control.controlQuality, attacker.attributes.control.fineControl), q(0.05), q(0.99));
737
- let attackSkill = clampQ(qMul(attackSkillBase, qMul(funcA.coordinationMul, funcA.manipulationMul)), q(0.01), q(0.99));
738
- const defenceSkillBase = clampQ(qMul(target.attributes.control.controlQuality, target.attributes.control.stability), q(0.05), q(0.99));
739
- let defenceSkill = clampQ(qMul(defenceSkillBase, qMul(funcB.coordinationMul, funcB.mobilityMul)), q(0.01), q(0.99));
740
- const geomDot = dotDirQ(attacker.action.facingDirQ, dirToTarget);
741
- // Phase 2C: reach dominance — short weapon penalised vs longer weapon in open combat.
742
- // Does not apply when attacker is grappling target (close contact negates reach), or target is prone.
743
- const grappling = attacker.grapple.holdingTargetId === target.id;
744
- if (tuning.realism !== "arcade" && !target.condition.prone && !grappling) {
745
- const tgtWpn = findWeapon(target.loadout);
746
- if (tgtWpn) {
747
- const tgtReach_m = tgtWpn.reach_m ?? Math.trunc(target.attributes.morphology.stature_m * 0.45);
748
- const penalty = reachDomPenaltyQ(reach_m, tgtReach_m);
749
- attackSkill = clampQ(qMul(attackSkill, penalty), q(0.01), q(0.99));
750
- }
751
- }
752
- // Phase 6: elevation advantage — higher ground boosts attack skill (tactical/sim only).
753
- // Threshold is 0.5 m so the effect is achievable at practical melee ranges.
754
- if (tuning.realism !== "arcade") {
755
- const elevDiff = elevA - elevT; // positive = attacker is higher
756
- if (elevDiff > to.m(0.5)) {
757
- // +5% per metre above 0.5 m threshold, capped at +10%
758
- const bonus = clampQ(mulDiv(elevDiff - to.m(0.5), q(0.05), to.m(1)), q(0), q(0.10));
759
- attackSkill = clampQ(qMul(attackSkill, (SCALE.Q + bonus)), q(0.01), q(0.99));
760
- }
761
- }
762
- // Phase 7: meleeDefence skill boosts effective defence quality (parry/block technique)
763
- const defMeleeSkill = getSkill(target.skills, "meleeDefence");
764
- defenceSkill = clampQ(qMul(defenceSkill, defMeleeSkill.energyTransferMul), q(0.01), q(0.99));
765
- // Phase 2C: weapon bind also prevents the defender from parrying/blocking with their weapon
766
- const defenceModeEffective = target.action.weaponBindPartnerId !== 0
767
- ? "none"
768
- : target.intent.defence.mode;
769
- let defenceIntensityEffective = target.action.weaponBindPartnerId !== 0
770
- ? q(0)
771
- : target.intent.defence.intensity;
772
- // Phase 7: shieldCraft boosts effective defence skill when actively blocking with a shield
773
- if (defenceModeEffective === "block") {
774
- const tgtShield = findShield(target.loadout);
775
- if (tgtShield) {
776
- const shieldSkill = getSkill(target.skills, "shieldCraft");
777
- defenceSkill = clampQ(qMul(defenceSkill, shieldSkill.energyTransferMul), q(0.01), q(0.99));
778
- }
779
- }
780
- // Phase 4: surprise mechanics — if the defender cannot perceive the attacker,
781
- // their defensive response is reduced or eliminated.
782
- if (tuning.realism !== "arcade") {
783
- const sEnv = (world).__sensoryEnv ?? DEFAULT_SENSORY_ENV;
784
- // Phase 11C: sensor boost from loadout
785
- const tgtSensor = findSensor(target.loadout);
786
- const tgtSensorBoost = tgtSensor
787
- ? { visionRangeMul: tgtSensor.visionRangeMul, hearingRangeMul: tgtSensor.hearingRangeMul }
788
- : undefined;
789
- const detectionQ = canDetect(target, attacker, sEnv, tgtSensorBoost);
790
- if (detectionQ <= 0) {
791
- // Full surprise: defender has no defence
792
- defenceIntensityEffective = q(0);
793
- // Phase 5: fear spike from being attacked without warning
794
- target.condition.fearQ = clampQ((target.condition.fearQ ?? 0) + FEAR_SURPRISE, 0, SCALE.Q);
795
- }
796
- else if (detectionQ < q(0.8)) {
797
- // Partial surprise: scale defence intensity by detection quality
798
- defenceIntensityEffective = qMul(defenceIntensityEffective, detectionQ);
799
- }
800
- }
801
- // Phase 2C: reach dominance on defence — parrying with a shorter weapon is harder.
802
- if (tuning.realism !== "arcade" && defenceModeEffective === "parry" && !grappling) {
803
- const defWpnReach = findWeapon(target.loadout);
804
- if (defWpnReach) {
805
- const defReach = defWpnReach.reach_m ?? Math.trunc(target.attributes.morphology.stature_m * 0.45);
806
- defenceSkill = clampQ(qMul(defenceSkill, reachDomPenaltyQ(defReach, reach_m)), q(0.01), q(0.99));
807
- }
808
- }
809
- // Phase 17: flexible weapons (flail, morning star) partially bypass shield blocks
810
- const meleeBypassQ = (wpn).shieldBypassQ ?? 0;
811
- const defenceIntensityForHit = (meleeBypassQ > 0 && defenceModeEffective === "block")
812
- ? qMul(defenceIntensityEffective, SCALE.Q - meleeBypassQ)
813
- : defenceIntensityEffective;
814
- const seed = eventSeed(world.seed, world.tick, attacker.id, target.id, 0xA11AC);
815
- const res = resolveHit(seed, attackSkill, defenceSkill, geomDot, defenceModeEffective, defenceIntensityForHit);
816
- trace.onEvent({
817
- kind: TraceKinds.AttackAttempt,
818
- tick: world.tick,
819
- attackerId: attacker.id,
820
- targetId: target.id,
821
- hit: res.hit,
822
- blocked: res.blocked,
823
- parried: res.parried,
824
- hitQuality: res.hitQuality,
825
- area: res.area,
333
+ resolveAttackFromResolver({
334
+ world,
335
+ attacker,
336
+ cmd,
337
+ tuning,
338
+ index,
339
+ impacts,
340
+ spatial,
341
+ trace,
342
+ ctx,
343
+ resolveTargetHitSegment,
344
+ regionCoverageQ,
345
+ shieldBlocksSegment,
346
+ armourCoversHit,
826
347
  });
827
- if (!res.hit) {
828
- attacker.action.attackCooldownTicks += Math.trunc(mulDiv(missRecoveryTicks(wpn), clampedIntensity, SCALE.Q));
829
- attacker.action.swingMomentumQ = q(0);
830
- return;
831
- }
832
- const hitSeed = eventSeed(world.seed, world.tick, attacker.id, target.id, 0x51DE);
833
- const sideBit = (hitSeed & 1);
834
- const region = resolveTargetHitSegment(target, ((hitSeed >>> 8) % SCALE.Q), sideBit, res.area);
835
- const defenderBlocking = (target.intent.defence.mode === "block"); // or cmd-derived if you do that elsewhere
836
- const shield = findShield(target.loadout);
837
- const shieldBlocked = res.hit &&
838
- res.blocked &&
839
- defenderBlocking &&
840
- !!shield &&
841
- shieldBlocksSegment(shield, target, region, res.area);
842
- const baseIntensity = clampQ(cmd.intensity ?? q(1.0), q(0.1), q(1.0));
843
- const handling = wpn.handlingMul ?? q(1.0);
844
- const handlingPenalty = clampQ(q(1.0) - qMul(q(0.18), (handling - SCALE.Q)), q(0.70), q(1.0));
845
- const intensity = clampQ(qMul(baseIntensity, qMul(funcA.manipulationMul, handlingPenalty)), q(0.1), q(1.0));
846
- // Phase 29: apply core-temperature power modifier
847
- const coreTempQ = (attacker.condition).coreTemp_Q ?? CORE_TEMP_NORMAL_Q;
848
- const tempMods = deriveTempModifiers(coreTempQ);
849
- const P = Math.trunc(qMul(attacker.attributes.performance.peakPower_W, tempMods.powerMul));
850
- const base = clampI32(Math.trunc((P * SCALE.mps) / 200), Math.trunc(2 * SCALE.mps), Math.trunc(12 * SCALE.mps));
851
- const wMul = wpn.strikeSpeedMul ?? q(1.0);
852
- const cMul = attacker.attributes.control.controlQuality;
853
- const qualMul = q(0.70) + qMul(res.hitQuality, q(0.30));
854
- const vStrike = mulDiv(mulDiv(mulDiv(mulDiv(base, wMul, SCALE.Q), cMul, SCALE.Q), intensity, SCALE.Q), qualMul, SCALE.Q);
855
- const vStrikeVec = scaleDirToSpeed(dirToTarget, vStrike);
856
- // Clamp body-movement contribution to strike energy. Combatants decelerate before a controlled
857
- // swing; pure sprint-on-sprint kinetic energy should not dominate. Cap at 2 m/s relative.
858
- const APPROACH_CAP = Math.trunc(2.0 * SCALE.mps);
859
- const bodyRelX = clampI32(attacker.velocity_mps.x - target.velocity_mps.x, -APPROACH_CAP, APPROACH_CAP);
860
- const bodyRelY = clampI32(attacker.velocity_mps.y - target.velocity_mps.y, -APPROACH_CAP, APPROACH_CAP);
861
- const bodyRelZ = clampI32(attacker.velocity_mps.z - target.velocity_mps.z, -APPROACH_CAP, APPROACH_CAP);
862
- const rel = {
863
- x: bodyRelX + vStrikeVec.x,
864
- y: bodyRelY + vStrikeVec.y,
865
- z: bodyRelZ + vStrikeVec.z,
866
- };
867
- // Phase 2C: two-handed leverage bonus — only when both arms are functional and no off-hand item
868
- const hasOffHand = attacker.loadout.items.some(it => it.kind === "shield") ||
869
- attacker.loadout.items.filter(it => it.kind === "weapon").length > 1;
870
- const twoHandBonus = twoHandedAttackBonusQ(wpn, funcA.leftArmDisabled, funcA.rightArmDisabled, hasOffHand);
871
- const baseEnergy_J = mulDiv(mulDiv((impactEnergy_J(attacker, wpn, rel)), funcA.manipulationMul, SCALE.Q), twoHandBonus, SCALE.Q);
872
- // Phase 7: meleeCombat.energyTransferMul boosts strike energy delivery (technique bonus)
873
- // Phase 11: exoskeleton force multiplier amplifies strike energy
874
- const attackerExo = findExoskeleton(attacker.loadout);
875
- const exoForceMul = attackerExo ? attackerExo.forceMultiplier : SCALE.Q;
876
- let energy_J = mulDiv(mulDiv(baseEnergy_J, attackerMeleeSkill.energyTransferMul, SCALE.Q), exoForceMul, SCALE.Q);
877
- // Phase 2 extension: swing momentum carry — bonus energy from rhythmic consecutive strikes
878
- const momentumBonus_J = Math.trunc(qMul(energy_J, qMul(attacker.action.swingMomentumQ, SWING_MOMENTUM_MAX)));
879
- energy_J += momentumBonus_J;
880
- let mitigated = energy_J;
881
- if (res.blocked || res.parried) {
882
- // Phase 2B: deduct defence stamina cost from the defender
883
- target.energy.reserveEnergy_J = Math.max(0, target.energy.reserveEnergy_J - defenceCost_J(target));
884
- const leverage = parryLeverageQ(wpn, attacker);
885
- const handed = (wpn.handedness ?? "oneHand") === "twoHand" ? q(1.10) : q(1.0);
886
- const defenceMul = qMul(leverage, handed);
887
- if (res.blocked) {
888
- const m = clampQ(q(0.40) - qMul(q(0.12), (defenceMul - SCALE.Q)), q(0.25), q(0.60));
889
- mitigated = mulDiv(mitigated, m, SCALE.Q);
890
- }
891
- if (res.parried) {
892
- const m = clampQ(q(0.25) - qMul(q(0.15), (defenceMul - SCALE.Q)), q(0.10), q(0.45));
893
- mitigated = mulDiv(mitigated, m, SCALE.Q);
894
- // Phase 2C: weapon bind on parry — weapons may lock, requiring both to disengage
895
- if (tuning.realism !== "arcade"
896
- && attacker.action.weaponBindPartnerId === 0
897
- && target.action.weaponBindPartnerId === 0) {
898
- const defWpn = findWeapon(target.loadout);
899
- if (defWpn) {
900
- const bindSeed = eventSeed(world.seed, world.tick, attacker.id, target.id, 0xB1DE);
901
- const bindRoll = (bindSeed % SCALE.Q);
902
- const bChanceBase = bindChanceQ(wpn, defWpn);
903
- // Phase 2C improvement #4: fatigue increases bind chance — tired fighters lose weapon control
904
- const avgFatigue = ((attacker.energy.fatigue + target.energy.fatigue) >>> 1);
905
- const fatigueMod = (SCALE.Q + qMul(avgFatigue, q(0.20))); // 1.0..1.20
906
- const bChance = clampQ(qMul(bChanceBase, fatigueMod), q(0), q(0.45));
907
- if (bindRoll < bChance) {
908
- const dur = bindDurationTicks(wpn, defWpn);
909
- attacker.action.weaponBindPartnerId = target.id;
910
- attacker.action.weaponBindTicks = dur;
911
- target.action.weaponBindPartnerId = attacker.id;
912
- target.action.weaponBindTicks = dur;
913
- trace.onEvent({
914
- kind: TraceKinds.WeaponBind,
915
- tick: world.tick,
916
- attackerId: attacker.id,
917
- targetId: target.id,
918
- durationTicks: dur,
919
- });
920
- }
921
- }
922
- }
923
- }
924
- if (res.shieldBlocked) {
925
- const m = clampQ(q(0.35) - qMul(q(0.10), (defenceMul - SCALE.Q)), q(0.20), q(0.55));
926
- mitigated = mulDiv(mitigated, m, SCALE.Q);
927
- }
928
- }
929
- const armour = deriveArmourProfile(target.loadout, target.armourState);
930
- const isEnergyWeapon = !!(wpn).energyType;
931
- const CHANNEL_MASK = isEnergyWeapon ? (1 << DamageChannel.Energy) : (1 << DamageChannel.Kinetic);
932
- const armourHit = armourCoversHit(world, regionCoverageQ(armour.coverageByRegion, region), attacker.id, target.id);
933
- const protectedByArmour = armourHit && ((armour.protects & CHANNEL_MASK) !== 0);
934
- let finalEnergy = mitigated;
935
- if (protectedByArmour) {
936
- if (isEnergyWeapon && armour.reflectivity > q(0)) {
937
- // Phase 11C: reflective armour reduces energy weapon damage
938
- finalEnergy = mulDiv(finalEnergy, SCALE.Q - armour.reflectivity, SCALE.Q);
939
- }
940
- else if (!isEnergyWeapon) {
941
- finalEnergy = applyKineticArmourPenetration(mitigated, armour.resist_J, armour.protectedDamageMul);
942
- }
943
- // Phase 11C: decrement ablative armour resist
944
- if (target.armourState) {
945
- const armourItems = target.loadout.items.filter(it => it.kind === "armour");
946
- for (const it of armourItems) {
947
- if ((it).ablative && target.armourState.has(it.id)) {
948
- const st = target.armourState.get(it.id);
949
- st.resistRemaining_J = Math.max(0, st.resistRemaining_J - mitigated);
950
- }
951
- }
952
- }
953
- }
954
- // Phase 26: effective mass for knockback — same formula used in impactEnergy_J
955
- const kbBodyMass = mulDiv(attacker.attributes.morphology.mass_kg, wpn.strikeEffectiveMassFrac ?? q(0.10), SCALE.Q);
956
- const kbMassEff = wpn.mass_kg + kbBodyMass;
957
- impacts.push({
958
- kind: "impact",
959
- attackerId: attacker.id,
960
- targetId: target.id,
961
- region,
962
- energy_J: finalEnergy,
963
- protectedByArmour,
964
- weaponId: wpn.id,
965
- wpn,
966
- blocked: res.blocked,
967
- parried: res.parried,
968
- hitQuality: clampQ(res.hitQuality, q(0.05), q(1.0)),
969
- shieldBlocked,
970
- massEff_kg: kbMassEff, // Phase 26
971
- });
972
- // Phase 2 extension: update swing momentum based on outcome
973
- if (res.blocked || res.parried) {
974
- // Blocked/parried — defender broke the rhythm
975
- attacker.action.swingMomentumQ = q(0);
976
- }
977
- else {
978
- // Clean hit — rhythm maintained; capture intensity for next strike bonus
979
- attacker.action.swingMomentumQ = clampQ(qMul(clampedIntensity, q(0.80)), q(0), SCALE.Q);
980
- }
981
348
  }
982
349
  export function clampSpeed(v, vmax_mps) {
983
350
  return { x: clampI32(v.x, -vmax_mps, vmax_mps), y: clampI32(v.y, -vmax_mps, vmax_mps), z: clampI32(v.z, -vmax_mps, vmax_mps) };
@@ -994,33 +361,7 @@ export function clampI32(x, lo, hi) {
994
361
  }
995
362
  /* ------------------ Grapple command dispatch (Phase 2A) ------------------ */
996
363
  function resolveGrappleCommand(world, e, c, tuning, index, impacts, trace) {
997
- const target = index.byId.get(c.targetId);
998
- if (!target || target.injury.dead)
999
- return;
1000
- const mode = c.mode ?? "grapple";
1001
- if (mode === "grapple") {
1002
- if (e.grapple.holdingTargetId === 0 || e.grapple.holdingTargetId !== c.targetId) {
1003
- // Attempt new grapple
1004
- resolveGrappleAttempt(world, e, target, c.intensity, tuning, impacts, trace);
1005
- }
1006
- else {
1007
- // Already holding — tick trace (stepGrappleTick handles maintenance)
1008
- trace.onEvent({
1009
- kind: TraceKinds.Grapple,
1010
- tick: world.tick, attackerId: e.id, targetId: target.id,
1011
- phase: "tick", strengthQ: e.grapple.gripQ,
1012
- });
1013
- }
1014
- }
1015
- else if (mode === "throw") {
1016
- resolveGrappleThrow(world, e, target, c.intensity, tuning, impacts, trace);
1017
- }
1018
- else if (mode === "choke") {
1019
- resolveGrappleChoke(e, target, c.intensity, tuning);
1020
- }
1021
- else if (mode === "jointLock") {
1022
- resolveGrappleJointLock(world, e, target, c.intensity, tuning, impacts);
1023
- }
364
+ resolveGrappleCommandFromResolver({ world, entity: e, command: c, tuning, index, impacts, trace });
1024
365
  }
1025
366
  /* ------------------ Weapon bind breaking (Phase 2C) ------------------ */
1026
367
  /**
@@ -1034,40 +375,7 @@ function resolveGrappleCommand(world, e, c, tuning, index, impacts, trace) {
1034
375
  * The bind always clears if the partner entity is dead or no longer present.
1035
376
  */
1036
377
  function resolveBreakBind(world, e, intensity, index, trace) {
1037
- if (e.action.weaponBindPartnerId === 0)
1038
- return; // not bound
1039
- const partner = index.byId.get(e.action.weaponBindPartnerId);
1040
- if (!partner || partner.injury.dead) {
1041
- // Partner gone — trivially clear
1042
- e.action.weaponBindPartnerId = 0;
1043
- e.action.weaponBindTicks = 0;
1044
- return;
1045
- }
1046
- const breakerWpn = findWeapon(e.loadout);
1047
- const holderWpn = findWeapon(partner.loadout);
1048
- const breakerArm = breakerWpn?.momentArm_m ?? Math.trunc(0.55 * SCALE.m);
1049
- const holderArm = holderWpn?.momentArm_m ?? Math.trunc(0.55 * SCALE.m);
1050
- // Win probability, scaled by command intensity (half-hearted attempt is less likely to succeed)
1051
- const baseWinQ = breakBindContestQ(e.attributes.performance.peakForce_N, partner.attributes.performance.peakForce_N, breakerArm, holderArm);
1052
- const winQ = clampQ(qMul(baseWinQ, intensity), q(0.05), q(0.95));
1053
- const breakSeed = eventSeed(world.seed, world.tick, e.id, partner.id, 0xBB1D);
1054
- const breakRoll = (breakSeed % SCALE.Q);
1055
- if (breakRoll < winQ) {
1056
- // Success: clear bind for both; loser takes a brief stun
1057
- partner.condition.stunned = clampQ(partner.condition.stunned + q(0.05), 0, SCALE.Q);
1058
- e.action.weaponBindPartnerId = 0;
1059
- e.action.weaponBindTicks = 0;
1060
- partner.action.weaponBindPartnerId = 0;
1061
- partner.action.weaponBindTicks = 0;
1062
- trace.onEvent({
1063
- kind: TraceKinds.WeaponBindBreak,
1064
- tick: world.tick,
1065
- entityId: e.id,
1066
- partnerId: partner.id,
1067
- reason: "forced",
1068
- });
1069
- }
1070
- // On failure: bind persists; no trace (silence is the signal)
378
+ resolveBreakBindFromResolver({ world, entity: e, intensity, index, trace });
1071
379
  }
1072
380
  /* ------------------ Ranged combat (Phase 3) ------------------ */
1073
381
  /** Integer square root of a BigInt (floor). Newton-Raphson. */
@@ -1083,207 +391,19 @@ function isqrtBig(n) {
1083
391
  return r;
1084
392
  }
1085
393
  function resolveShoot(world, shooter, cmd, tuning, index, impacts, trace, ctx) {
1086
- // Phase 3 extension: aiming time accumulation — runs even during reload cooldown
1087
- {
1088
- const svx = shooter.velocity_mps.x;
1089
- const svy = shooter.velocity_mps.y;
1090
- const shooterVelMag = Math.trunc(Math.sqrt(svx * svx + svy * svy));
1091
- if (cmd.targetId !== shooter.action.aimTargetId || shooterVelMag > AIM_STILL_THRESHOLD) {
1092
- shooter.action.aimTicks = 0;
1093
- shooter.action.aimTargetId = cmd.targetId;
1094
- }
1095
- else if (shooter.action.shootCooldownTicks > 0 && shooterVelMag <= AIM_STILL_THRESHOLD) {
1096
- shooter.action.aimTicks = Math.min(shooter.action.aimTicks + 1, AIM_MAX_TICKS);
1097
- }
1098
- }
1099
- if (shooter.action.shootCooldownTicks > 0)
1100
- return;
1101
- const wpn = findRangedWeapon(shooter.loadout, cmd.weaponId);
1102
- if (!wpn)
1103
- return;
1104
- const target = index.byId.get(cmd.targetId);
1105
- if (!target || target.injury.dead)
1106
- return;
1107
- // Phase 3 extension: ammo type override
1108
- const ammo = cmd.ammoId ? wpn.ammo?.find(a => a.id === cmd.ammoId) : undefined;
1109
- const projMass_kg = ammo?.projectileMass_kg ?? wpn.projectileMass_kg;
1110
- const dragCoeff_perM = ammo?.dragCoeff_perM ?? wpn.dragCoeff_perM;
1111
- const ammoDamage = ammo?.damage ?? wpn.damage;
1112
- const launchMul = ammo?.launchEnergyMul ?? SCALE.Q;
1113
- const funcA = deriveFunctionalState(shooter, tuning);
1114
- if (!funcA.canAct)
1115
- return;
1116
- // 3D range (SCALE.m units); Phase 6: elevation differential lengthens the flight path.
1117
- const dx = BigInt(target.position_m.x - shooter.position_m.x);
1118
- const dy = BigInt(target.position_m.y - shooter.position_m.y);
1119
- const cellSizeRS = ctx.cellSize_m ?? Math.trunc(4 * SCALE.m);
1120
- const elevSh = elevationAtPosition(ctx.elevationGrid, cellSizeRS, shooter.position_m.x, shooter.position_m.y);
1121
- const elevTg = elevationAtPosition(ctx.elevationGrid, cellSizeRS, target.position_m.x, target.position_m.y);
1122
- const dz = BigInt(target.position_m.z - shooter.position_m.z + (elevTg - elevSh));
1123
- const dist_m = Number(isqrtBig(dx * dx + dy * dy + dz * dz));
1124
- const intensity = clampQ(cmd.intensity ?? q(1.0), q(0.1), q(1.0));
1125
- // Determine launch energy
1126
- // Phase 7: throwingWeapons.energyTransferMul boosts thrown weapon launch energy
1127
- // Phase 3 extension: ammo launchEnergyMul applied for non-thrown weapons
1128
- const launchEnergy = wpn.category === "thrown"
1129
- ? mulDiv(thrownLaunchEnergy_J(shooter.attributes.performance.peakPower_W), getSkill(shooter.skills, "throwingWeapons").energyTransferMul, SCALE.Q)
1130
- : Math.trunc(qMul(wpn.launchEnergy_J, launchMul));
1131
- // Energy at impact after drag (Phase 3 extension: use ammo drag coefficient)
1132
- const energy_J = energyAtRange_J(launchEnergy, dragCoeff_perM, dist_m);
1133
- // Phase 27: impact velocity from pre-armour energy (v = sqrt(2E/m))
1134
- const v_impact_mps = projMass_kg > 0
1135
- ? Math.trunc(Math.sqrt(2 * energy_J * SCALE.kg / projMass_kg) * SCALE.mps)
1136
- : 0;
1137
- // Shooter's aim quality
1138
- const ctrl = shooter.attributes.control;
1139
- const adjDisp = adjustedDispersionQ(wpn.dispersionQ, ctrl.controlQuality, ctrl.fineControl, shooter.energy.fatigue, intensity);
1140
- // Phase 7: rangedCombat.dispersionMul reduces effective dispersion (tighter grouping)
1141
- const rangedSkill = getSkill(shooter.skills, "rangedCombat");
1142
- const skillAdjDisp = qMul(adjDisp, rangedSkill.dispersionMul);
1143
- let gRadius_m = groupingRadius_m(skillAdjDisp, dist_m);
1144
- // Phase 3 extension: aiming time — reduce dispersion up to 50% at full aim
1145
- const aimReduction = mulDiv(SCALE.Q - AIM_MIN_MUL, Math.min(shooter.action.aimTicks, AIM_MAX_TICKS), AIM_MAX_TICKS);
1146
- const aimMul = (SCALE.Q - aimReduction);
1147
- gRadius_m = Math.trunc(qMul(gRadius_m, aimMul));
1148
- // Phase 3 extension: moving target penalty — lead error based on target velocity
1149
- const tvx = target.velocity_mps.x;
1150
- const tvy = target.velocity_mps.y;
1151
- const targetVelMag = Math.trunc(Math.sqrt(tvx * tvx + tvy * tvy));
1152
- const leadError_m = mulDiv(targetVelMag, 2_000, SCALE.mps); // 0.2s reaction × SCALE.m
1153
- gRadius_m += leadError_m;
1154
- // Phase 51: wind drift — crosswind component adds to grouping radius.
1155
- if (ctx.weather?.wind && v_impact_mps > 0 && dist_m > 0) {
1156
- gRadius_m += computeWindAimError(ctx.weather.wind, Number(dx), Number(dy), dist_m, v_impact_mps);
1157
- }
1158
- // Body half-width: ~20% of stature (≈0.35m for 1.75m human).
1159
- // Phase 6: cover fraction reduces effective target width → harder to hit.
1160
- const rawHalfWidth_m = mulDiv(shooter.attributes.morphology.stature_m, 2000, SCALE.Q);
1161
- const cover = ctx.obstacleGrid
1162
- ? coverFractionAtPosition(ctx.obstacleGrid, cellSizeRS, target.position_m.x, target.position_m.y)
1163
- : 0;
1164
- const bodyHalfWidth_m = cover > 0
1165
- ? mulDiv(rawHalfWidth_m, Math.max(0, SCALE.Q - cover), SCALE.Q)
1166
- : rawHalfWidth_m;
1167
- // Deterministic hit roll (salt 0xD15A)
1168
- const dispSeed = eventSeed(world.seed, world.tick, shooter.id, target.id, 0xD15A);
1169
- const errorMag_m = gRadius_m > 0
1170
- ? mulDiv(dispSeed % SCALE.Q, gRadius_m, SCALE.Q)
1171
- : 0;
1172
- const hit = errorMag_m <= bodyHalfWidth_m;
1173
- const suppressed = !hit && errorMag_m <= bodyHalfWidth_m * 3;
1174
- // Deduct stamina and set reload cooldown regardless of hit
1175
- shooter.energy.reserveEnergy_J = Math.max(0, shooter.energy.reserveEnergy_J - shootCost_J(wpn, intensity, shooter.attributes.performance.peakPower_W));
1176
- shooter.action.aimTicks = 0; // Phase 3 extension: reset aim after each shot
1177
- // Phase 17: magazine-based cooldown (modern/revolver firearms)
1178
- if (wpn.magCapacity !== undefined) {
1179
- if (shooter.action.roundsInMag === undefined) {
1180
- shooter.action.roundsInMag = wpn.magCapacity; // first draw: full mag
1181
- }
1182
- shooter.action.roundsInMag -= 1;
1183
- if (shooter.action.roundsInMag <= 0) {
1184
- shooter.action.roundsInMag = wpn.magCapacity; // magazine empty — reload
1185
- shooter.action.shootCooldownTicks = recycleTicks(wpn, TICK_HZ);
1186
- }
1187
- else {
1188
- shooter.action.shootCooldownTicks =
1189
- wpn.shotInterval_s !== undefined
1190
- ? Math.ceil((wpn.shotInterval_s * TICK_HZ) / SCALE.s)
1191
- : recycleTicks(wpn, TICK_HZ);
1192
- }
1193
- }
1194
- else {
1195
- shooter.action.shootCooldownTicks = recycleTicks(wpn, TICK_HZ);
1196
- }
1197
- if (suppressed) {
1198
- target.condition.suppressedTicks = Math.max(target.condition.suppressedTicks, 4);
1199
- target.condition.suppressionFearMul = wpn.suppressionFearMul ?? SCALE.Q;
1200
- }
1201
- let hitRegion;
1202
- if (hit && energy_J > 0) {
1203
- const sideSeed = eventSeed(world.seed, world.tick, shooter.id, target.id, 0xD15B);
1204
- const areaSeed = eventSeed(world.seed, world.tick, shooter.id, target.id, 0xD15C);
1205
- const hitArea = target.bodyPlan
1206
- ? undefined
1207
- : chooseArea((areaSeed % SCALE.Q));
1208
- const sideBit = (sideSeed & 1);
1209
- hitRegion = resolveTargetHitSegment(target, ((areaSeed >>> 8) % SCALE.Q), sideBit, hitArea);
1210
- // Shield interposition — Phase 17: projectile bypass for flexible weapons
1211
- const shield = findShield(target.loadout);
1212
- const shieldSeed = eventSeed(world.seed, world.tick, shooter.id, target.id, 0xD15D);
1213
- const projBypassQ = ("shieldBypassQ" in wpn) ? wpn.shieldBypassQ : 0;
1214
- const effectiveCoverageQ = projBypassQ > 0
1215
- ? Math.max(0, qMul((shield)?.coverageQ ?? 0, SCALE.Q - projBypassQ))
1216
- : ((shield)?.coverageQ ?? 0);
1217
- const shieldHit = shield !== undefined &&
1218
- ((shieldSeed % SCALE.Q) < effectiveCoverageQ) &&
1219
- shieldBlocksSegment(shield, target, hitRegion, hitArea);
1220
- // Armour
1221
- const armour = deriveArmourProfile(target.loadout, target.armourState);
1222
- const isEnergyProjectile = !!(wpn).energyType;
1223
- const PROJ_CHANNEL_MASK = isEnergyProjectile ? (1 << DamageChannel.Energy) : (1 << DamageChannel.Kinetic);
1224
- const armourHit = armourCoversHit(world, regionCoverageQ(armour.coverageByRegion, hitRegion), shooter.id, target.id);
1225
- const protectedByArmour = armourHit && ((armour.protects & PROJ_CHANNEL_MASK) !== 0);
1226
- let finalEnergy = energy_J;
1227
- if (shield && shieldHit) {
1228
- const shieldResidual = Math.max(0, energy_J - (shield).blockResist_J);
1229
- finalEnergy = mulDiv(shieldResidual, (shield).deflectQ ?? q(0.30), SCALE.Q);
1230
- }
1231
- if (protectedByArmour) {
1232
- if (isEnergyProjectile && armour.reflectivity > q(0)) {
1233
- finalEnergy = mulDiv(finalEnergy, SCALE.Q - armour.reflectivity, SCALE.Q);
1234
- }
1235
- else if (!isEnergyProjectile) {
1236
- finalEnergy = applyKineticArmourPenetration(finalEnergy, armour.resist_J, armour.protectedDamageMul);
1237
- }
1238
- // Phase 11C: decrement ablative armour resist
1239
- if (target.armourState) {
1240
- const armourItems = target.loadout.items.filter(it => it.kind === "armour");
1241
- for (const it of armourItems) {
1242
- if ((it).ablative && target.armourState.has(it.id)) {
1243
- const st = target.armourState.get(it.id);
1244
- st.resistRemaining_J = Math.max(0, st.resistRemaining_J - energy_J);
1245
- }
1246
- }
1247
- }
1248
- }
1249
- // Build a minimal Weapon proxy for applyImpactToInjury
1250
- // Phase 3 extension: use ammo-overridden damage and projectile mass
1251
- const wpnProxy = {
1252
- id: wpn.id,
1253
- kind: "weapon",
1254
- name: wpn.name,
1255
- mass_kg: projMass_kg,
1256
- bulk: q(0),
1257
- damage: ammoDamage,
1258
- };
1259
- impacts.push({
1260
- kind: "impact",
1261
- attackerId: shooter.id,
1262
- targetId: target.id,
1263
- region: hitRegion,
1264
- energy_J: finalEnergy,
1265
- protectedByArmour,
1266
- weaponId: wpn.id,
1267
- wpn: wpnProxy,
1268
- blocked: shieldHit ?? false,
1269
- parried: false,
1270
- hitQuality: q(0.75),
1271
- shieldBlocked: shieldHit ?? false,
1272
- massEff_kg: projMass_kg, // Phase 26: projectile mass drives knockback
1273
- v_impact_mps, // Phase 27: hydrostatic shock velocity
1274
- });
1275
- }
1276
- trace.onEvent({
1277
- kind: TraceKinds.ProjectileHit,
1278
- tick: world.tick,
1279
- shooterId: shooter.id,
1280
- targetId: target.id,
1281
- weaponId: wpn.id, // Phase 18
1282
- hit,
1283
- ...(hitRegion !== undefined ? { region: hitRegion } : {}),
1284
- distance_m: dist_m,
1285
- energyAtImpact_J: energy_J,
1286
- suppressed,
394
+ resolveShootFromResolver({
395
+ world,
396
+ shooter,
397
+ cmd,
398
+ tuning,
399
+ impacts,
400
+ trace,
401
+ ctx,
402
+ target: index.byId.get(cmd.targetId),
403
+ resolveTargetHitSegment,
404
+ shieldBlocksSegment,
405
+ regionCoverageQ,
406
+ armourCoversHit,
1287
407
  });
1288
408
  }
1289
409
  // ---- Phase 2B: per-action stamina cost helpers ----
@@ -1613,86 +733,7 @@ export function applyExplosion(world, origin, spec, tick, trace) {
1613
733
  }
1614
734
  /* ── Phase 9: medical treatment ──────────────────────────────────────────── */
1615
735
  function resolveTreat(world, treater, cmd, index, trace, ctx) {
1616
- if (treater.injury.dead)
1617
- return;
1618
- const target = index.byId.get(cmd.targetId);
1619
- if (!target || target.injury.dead)
1620
- return;
1621
- // Treater must be within 2 m of target (physical contact required)
1622
- const dx = target.position_m.x - treater.position_m.x;
1623
- const dy = target.position_m.y - treater.position_m.y;
1624
- const dist2 = dx * dx + dy * dy;
1625
- const MAX_TREAT_DIST_m = Math.trunc(2 * SCALE.m);
1626
- if (dist2 > MAX_TREAT_DIST_m * MAX_TREAT_DIST_m)
1627
- return;
1628
- // Check equipment tier meets minimum requirement
1629
- const tierRank = TIER_RANK[cmd.tier];
1630
- const actionMinRank = TIER_RANK[ACTION_MIN_TIER[cmd.action]];
1631
- if (tierRank < actionMinRank)
1632
- return;
1633
- // Phase 11: technology gate — check if the tier's required capability is available
1634
- const techReq = TIER_TECH_REQ[cmd.tier];
1635
- if (techReq && ctx.techCtx && !ctx.techCtx.available.has(techReq))
1636
- return;
1637
- const tierMul = TIER_MUL[cmd.tier];
1638
- const medSkill = getSkill(treater.skills, "medical");
1639
- // effectMul = tierMul × (treatmentRateMul / SCALE.Q)
1640
- // treatmentRateMul at q(1.0) = SCALE.Q baseline gives exactly tierMul
1641
- const effectMul = mulDiv(tierMul, medSkill.treatmentRateMul, SCALE.Q);
1642
- if (cmd.action === "tourniquet") {
1643
- const reg = cmd.regionId ? target.injury.byRegion[cmd.regionId] : undefined;
1644
- if (!reg)
1645
- return;
1646
- reg.bleedingRate = q(0);
1647
- reg.bleedDuration_ticks = 0;
1648
- // Slight shock from painful application
1649
- target.injury.shock = clampQ(target.injury.shock + q(0.005), 0, SCALE.Q);
1650
- }
1651
- else if (cmd.action === "bandage") {
1652
- const reg = cmd.regionId ? target.injury.byRegion[cmd.regionId] : undefined;
1653
- if (!reg)
1654
- return;
1655
- const BASE_BANDAGE_RATE = q(0.0050);
1656
- const reduction = mulDiv(BASE_BANDAGE_RATE, effectMul, SCALE.Q);
1657
- reg.bleedingRate = clampQ((reg.bleedingRate - reduction), q(0), q(1.0));
1658
- }
1659
- else if (cmd.action === "surgery") {
1660
- const reg = cmd.regionId ? target.injury.byRegion[cmd.regionId] : undefined;
1661
- if (!reg)
1662
- return;
1663
- const BASE_SURGERY_RATE = q(0.0020);
1664
- const BASE_BANDAGE_RATE = q(0.0050);
1665
- const strReduction = mulDiv(BASE_SURGERY_RATE, effectMul, SCALE.Q);
1666
- const newStr = clampQ((reg.structuralDamage - strReduction), reg.permanentDamage, // cannot heal below permanent floor
1667
- SCALE.Q);
1668
- reg.structuralDamage = newStr;
1669
- // Surgery also stops active bleeding
1670
- const bleedReduction = mulDiv(BASE_BANDAGE_RATE, effectMul, SCALE.Q);
1671
- reg.bleedingRate = clampQ((reg.bleedingRate - bleedReduction), q(0), q(1.0));
1672
- // Clear fracture if structural drops below threshold
1673
- if (reg.fractured && reg.structuralDamage < FRACTURE_THRESHOLD) {
1674
- reg.fractured = false;
1675
- }
1676
- // Clear infection at surgicalKit tier or above
1677
- if (reg.infectedTick >= 0 && tierRank >= TIER_RANK["surgicalKit"]) {
1678
- reg.infectedTick = -1;
1679
- }
1680
- }
1681
- else if (cmd.action === "fluidReplacement") {
1682
- const BASE_FLUID_RATE = q(0.0050);
1683
- const recovery = mulDiv(BASE_FLUID_RATE, effectMul, SCALE.Q);
1684
- target.injury.fluidLoss = clampQ((target.injury.fluidLoss - recovery), q(0), SCALE.Q);
1685
- // Fluid restoration also reduces shock slightly
1686
- target.injury.shock = clampQ((target.injury.shock - q(0.002)), q(0), SCALE.Q);
1687
- }
1688
- trace.onEvent({
1689
- kind: TraceKinds.TreatmentApplied,
1690
- tick: world.tick,
1691
- treaterId: treater.id,
1692
- targetId: target.id,
1693
- action: cmd.action,
1694
- ...(cmd.regionId !== undefined ? { regionId: cmd.regionId } : {}),
1695
- });
736
+ resolveTreatFromResolver({ world, treater, cmd, index, trace, ctx });
1696
737
  }
1697
738
  /* ── Phase 12: capability sources and effects ─────────────────────────────── */
1698
739
  /**
@@ -1716,103 +757,10 @@ const CAPABILITY_WEAPON_DEFAULT = {
1716
757
  * magical from technological effects at this level.
1717
758
  */
1718
759
  export function applyPayload(world, actor, target, payload, trace, tick, effectId) {
1719
- switch (payload.kind) {
1720
- case "impact": {
1721
- const hitRegion = resolveCapabilityHitSegment(world, tick, actor, target, 0xCAB1);
1722
- if (!target.injury.byRegion[hitRegion])
1723
- break;
1724
- let effectiveEnergy = payload.spec.energy_J;
1725
- if ((target.condition.shieldReserve_J ?? 0) > 0 &&
1726
- target.condition.shieldExpiry_tick !== undefined &&
1727
- tick <= target.condition.shieldExpiry_tick) {
1728
- const absorbed = Math.min(target.condition.shieldReserve_J, effectiveEnergy);
1729
- target.condition.shieldReserve_J -= absorbed;
1730
- effectiveEnergy -= absorbed;
1731
- }
1732
- if (effectiveEnergy > 0) {
1733
- const wpn = CAPABILITY_CHANNEL_WEAPONS[payload.spec.channel] ?? CAPABILITY_WEAPON_DEFAULT;
1734
- applyImpactToInjury(target, wpn, effectiveEnergy, hitRegion, false, trace, tick);
1735
- }
1736
- break;
1737
- }
1738
- case "treatment": {
1739
- // Direct healing — bypasses range/equipment checks; capability source IS the tool.
1740
- const BASE_CAP_HEAL = q(0.0050);
1741
- const healRate = qMul(BASE_CAP_HEAL, payload.rateMul);
1742
- for (const reg of Object.values(target.injury.byRegion)) {
1743
- if (reg.bleedingRate > 0) {
1744
- reg.bleedingRate = clampQ((reg.bleedingRate - healRate), 0, SCALE.Q);
1745
- }
1746
- }
1747
- target.injury.shock = clampQ((target.injury.shock - qMul(q(0.01), payload.rateMul)), 0, SCALE.Q);
1748
- break;
1749
- }
1750
- case "armourLayer": {
1751
- // Accumulate into shield reserve; extend or set expiry.
1752
- target.condition.shieldReserve_J = (target.condition.shieldReserve_J ?? 0) + payload.resist_J;
1753
- const newExpiry = tick + payload.duration_ticks;
1754
- target.condition.shieldExpiry_tick = Math.max(target.condition.shieldExpiry_tick ?? 0, newExpiry);
1755
- break;
1756
- }
1757
- case "velocity": {
1758
- target.velocity_mps = vAdd(target.velocity_mps, payload.delta_mps);
1759
- break;
1760
- }
1761
- case "substance": {
1762
- if (!target.substances)
1763
- target.substances = [];
1764
- target.substances.push({ ...payload.substance });
1765
- break;
1766
- }
1767
- case "structuralRepair": {
1768
- const seg = target.injury.byRegion[payload.region];
1769
- if (seg) {
1770
- const floor = seg.permanentDamage ?? 0;
1771
- const repaired = Math.max(floor, seg.structuralDamage - payload.amount);
1772
- seg.structuralDamage = clampQ(repaired, 0, SCALE.Q);
1773
- }
1774
- break;
1775
- }
1776
- case "fieldEffect": {
1777
- if (!world.activeFieldEffects)
1778
- world.activeFieldEffects = [];
1779
- const fe = {
1780
- ...payload.spec,
1781
- id: `${actor.id}_${effectId}_${tick}`,
1782
- origin: { x: actor.position_m.x, y: actor.position_m.y, z: actor.position_m.z },
1783
- placedByEntityId: actor.id,
1784
- };
1785
- world.activeFieldEffects.push(fe);
1786
- break;
1787
- }
1788
- case "weaponImpact": {
1789
- // Phase 28: custom damage profile (fire breath, napalm, chemical burn, etc.)
1790
- // Bypasses armour like other capability impacts; damage mix is determined by profile.
1791
- const wpn = {
1792
- id: "cap_weaponimpact",
1793
- kind: "weapon",
1794
- name: "WeaponImpact",
1795
- mass_kg: 0,
1796
- bulk: q(0),
1797
- damage: payload.profile,
1798
- };
1799
- const hitRegion = resolveCapabilityHitSegment(world, tick, actor, target, 0xCAB2);
1800
- if (!target.injury.byRegion[hitRegion])
1801
- break;
1802
- let effectiveEnergy = payload.energy_J;
1803
- if ((target.condition.shieldReserve_J ?? 0) > 0 &&
1804
- target.condition.shieldExpiry_tick !== undefined &&
1805
- tick <= target.condition.shieldExpiry_tick) {
1806
- const absorbed = Math.min(target.condition.shieldReserve_J, effectiveEnergy);
1807
- target.condition.shieldReserve_J -= absorbed;
1808
- effectiveEnergy -= absorbed;
1809
- }
1810
- if (effectiveEnergy > 0) {
1811
- applyImpactToInjury(target, wpn, effectiveEnergy, hitRegion, false, trace, tick);
1812
- }
1813
- break;
1814
- }
1815
- }
760
+ applyPayloadFromResolver(world, actor, target, payload, trace, tick, effectId, {
761
+ resolveCapabilityHitSegment,
762
+ applyImpactToInjury,
763
+ });
1816
764
  }
1817
765
  /**
1818
766
  * Resolve all payloads of a capability effect for the appropriate target set.
@@ -1820,179 +768,17 @@ export function applyPayload(world, actor, target, payload, trace, tick, effectI
1820
768
  * Single-target: targetId entity, or self if absent.
1821
769
  */
1822
770
  export function applyCapabilityEffect(world, actor, targetId, effect, trace, tick) {
1823
- const payloads = Array.isArray(effect.payload)
1824
- ? effect.payload
1825
- : [effect.payload];
1826
- // Determine target entities
1827
- let targets;
1828
- if (effect.coneHalfAngle_rad !== undefined) {
1829
- // Phase 28: directional cone AoE — apply to all living entities within the cone
1830
- const range_m = effect.range_m ?? 0;
1831
- let dir;
1832
- if (effect.coneDir === "fixed" && effect.coneDirFixed) {
1833
- dir = effect.coneDirFixed;
1834
- }
1835
- else {
1836
- // "facing" (default) — use actor's current facingDirQ
1837
- const fx = actor.action.facingDirQ.x;
1838
- const fy = actor.action.facingDirQ.y;
1839
- const mag = Math.sqrt(fx * fx + fy * fy);
1840
- const sc = mag > 0 ? SCALE.m / mag : 1;
1841
- dir = { dx: Math.round(fx * sc), dy: Math.round(fy * sc) };
1842
- }
1843
- const cone = {
1844
- origin: { x: actor.position_m.x, y: actor.position_m.y },
1845
- dir,
1846
- halfAngle_rad: effect.coneHalfAngle_rad,
1847
- range_m,
1848
- };
1849
- targets = world.entities.filter(e => !e.injury.dead && entityInCone(e, cone));
1850
- }
1851
- else if (effect.aoeRadius_m !== undefined) {
1852
- const origin = targetId !== undefined
1853
- ? (world.entities.find(e => e.id === targetId)?.position_m ?? actor.position_m)
1854
- : actor.position_m;
1855
- const radSq = effect.aoeRadius_m * effect.aoeRadius_m;
1856
- targets = world.entities.filter(e => {
1857
- if (e.injury.dead)
1858
- return false;
1859
- const dx = e.position_m.x - origin.x;
1860
- const dy = e.position_m.y - origin.y;
1861
- return dx * dx + dy * dy <= radSq;
1862
- });
1863
- }
1864
- else if (targetId !== undefined) {
1865
- const t = world.entities.find(e => e.id === targetId);
1866
- targets = t && !t.injury.dead ? [t] : [];
1867
- }
1868
- else {
1869
- targets = [actor];
1870
- }
1871
- for (const target of targets) {
1872
- // Phase 12B: magic resistance — non-self targets may resist the effect
1873
- if (target.id !== actor.id) {
1874
- const mr = target.attributes.resilience.magicResist ?? 0;
1875
- if (mr > 0) {
1876
- const resistSeed = eventSeed(world.seed, tick, actor.id, target.id, 0x5E515);
1877
- if ((resistSeed % SCALE.Q) < mr)
1878
- continue;
1879
- }
1880
- }
1881
- for (const p of payloads) {
1882
- applyPayload(world, actor, target, p, trace, tick, effect.id);
1883
- }
1884
- }
771
+ applyCapabilityEffectFromResolver(world, actor, targetId, effect, trace, tick, {
772
+ resolveCapabilityHitSegment,
773
+ applyImpactToInjury,
774
+ });
1885
775
  }
1886
776
  /**
1887
777
  * Resolve a capability activation command for actor.
1888
778
  * Validates suppression, range, and cost; handles cast time via pendingActivation.
1889
779
  */
1890
780
  function resolveActivation(world, actor, cmd, ctx, trace, tick) {
1891
- if (!actor.capabilitySources)
1892
- return;
1893
- const source = actor.capabilitySources.find(s => s.id === cmd.sourceId);
1894
- if (!source)
1895
- return;
1896
- const effect = source.effects.find(ef => ef.id === cmd.effectId);
1897
- if (!effect)
1898
- return;
1899
- // Phase 12B: cooldown gate — can't re-activate until cooldown expires
1900
- const cooldownKey = `${source.id}:${effect.id}`;
1901
- if ((actor.action.capabilityCooldowns?.get(cooldownKey) ?? 0) > 0)
1902
- return;
1903
- // Phase 12B: tech-context gate — requiredCapability must be available if techCtx is present
1904
- if (effect.requiredCapability !== undefined && ctx.techCtx !== undefined) {
1905
- if (!isCapabilityAvailable(ctx.techCtx, effect.requiredCapability))
1906
- return;
1907
- }
1908
- // Suppression check — any covering field whose tags overlap source tags blocks activation
1909
- const ax = actor.position_m.x;
1910
- const ay = actor.position_m.y;
1911
- const suppressed = (world.activeFieldEffects ?? []).some(fe => {
1912
- const dx = ax - fe.origin.x;
1913
- const dy = ay - fe.origin.y;
1914
- const distSq = dx * dx + dy * dy;
1915
- const radSq = fe.radius_m * fe.radius_m;
1916
- return distSq <= radSq && source.tags.some(t => fe.suppressesTags.includes(t));
781
+ resolveActivationFromResolver(world, actor, cmd, ctx, trace, tick, (effect) => {
782
+ applyCapabilityEffect(world, actor, cmd.targetId, effect, trace, tick);
1917
783
  });
1918
- if (suppressed) {
1919
- trace.onEvent({ kind: TraceKinds.CapabilitySuppressed, tick, entityId: actor.id, sourceId: cmd.sourceId, effectId: cmd.effectId });
1920
- return;
1921
- }
1922
- // Range check
1923
- if (effect.range_m !== undefined && cmd.targetId !== undefined) {
1924
- const tgt = world.entities.find(e => e.id === cmd.targetId);
1925
- if (tgt) {
1926
- const dx = tgt.position_m.x - ax;
1927
- const dy = tgt.position_m.y - ay;
1928
- if (dx * dx + dy * dy > effect.range_m * effect.range_m)
1929
- return;
1930
- }
1931
- }
1932
- // Phase 12B: concentration aura — castTime_ticks < 0 means ongoing per-tick effect.
1933
- // No upfront cost; stepConcentration deducts cost_J each tick.
1934
- if (effect.castTime_ticks < 0) {
1935
- actor.activeConcentration = {
1936
- sourceId: source.id,
1937
- effectId: effect.id,
1938
- ...(cmd.targetId !== undefined ? { targetId: cmd.targetId } : {}),
1939
- };
1940
- trace.onEvent({ kind: TraceKinds.CapabilityActivated, tick, entityId: actor.id, sourceId: source.id, effectId: effect.id });
1941
- return;
1942
- }
1943
- // Cost check (boundless sources always have enough)
1944
- const isBoundless = source.regenModel.type === "boundless";
1945
- let sourceToDraw = source;
1946
- if (!isBoundless && source.reserve_J < effect.cost_J) {
1947
- // Phase 12B: try linked fallback source
1948
- if (source.linkedFallbackId) {
1949
- const fallback = actor.capabilitySources.find(s => s.id === source.linkedFallbackId);
1950
- if (fallback && (fallback.regenModel.type === "boundless" || fallback.reserve_J >= effect.cost_J)) {
1951
- sourceToDraw = fallback;
1952
- }
1953
- else {
1954
- return; // both depleted
1955
- }
1956
- }
1957
- else {
1958
- return;
1959
- }
1960
- }
1961
- const drawIsBoundless = sourceToDraw.regenModel.type === "boundless";
1962
- // Cast time — queue pending activation and deduct cost at cast-start
1963
- if (effect.castTime_ticks > 0) {
1964
- if (!actor.pendingActivation) {
1965
- if (!drawIsBoundless)
1966
- sourceToDraw.reserve_J -= effect.cost_J;
1967
- actor.pendingActivation = cmd.targetId !== undefined
1968
- ? { sourceId: cmd.sourceId, effectId: cmd.effectId, targetId: cmd.targetId, resolveAtTick: tick + effect.castTime_ticks }
1969
- : { sourceId: cmd.sourceId, effectId: cmd.effectId, resolveAtTick: tick + effect.castTime_ticks };
1970
- // Phase 12B: set cooldown at cast-start so the cast itself is gated
1971
- if (effect.cooldown_ticks && effect.cooldown_ticks > 0) {
1972
- if (!actor.action.capabilityCooldowns)
1973
- actor.action.capabilityCooldowns = new Map();
1974
- actor.action.capabilityCooldowns.set(cooldownKey, effect.cooldown_ticks);
1975
- }
1976
- }
1977
- return;
1978
- }
1979
- // Instant — deduct, resolve, and set cooldown
1980
- if (!drawIsBoundless)
1981
- sourceToDraw.reserve_J -= effect.cost_J;
1982
- applyCapabilityEffect(world, actor, cmd.targetId, effect, trace, tick);
1983
- trace.onEvent({ kind: TraceKinds.CapabilityActivated, tick, entityId: actor.id, sourceId: cmd.sourceId, effectId: cmd.effectId });
1984
- if (effect.cooldown_ticks && effect.cooldown_ticks > 0) {
1985
- if (!actor.action.capabilityCooldowns)
1986
- actor.action.capabilityCooldowns = new Map();
1987
- actor.action.capabilityCooldowns.set(cooldownKey, effect.cooldown_ticks);
1988
- }
1989
- // Phase 28: if sustainedTicks, set up emission for the remaining ticks (first tick fired above)
1990
- if (effect.sustainedTicks && effect.sustainedTicks > 1) {
1991
- actor.action.sustainedEmission = {
1992
- sourceId: cmd.sourceId,
1993
- effectId: cmd.effectId,
1994
- ...(cmd.targetId !== undefined ? { targetId: cmd.targetId } : {}),
1995
- remainingTicks: effect.sustainedTicks - 1,
1996
- };
1997
- }
1998
784
  }