@its-not-rocket-science/ananke 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -0
- package/LICENSE +21 -0
- package/README.md +2199 -0
- package/STABLE_API.md +266 -0
- package/dist/src/anatomy/anatomy-compiler.d.ts +14 -0
- package/dist/src/anatomy/anatomy-compiler.js +277 -0
- package/dist/src/anatomy/anatomy-contracts.d.ts +94 -0
- package/dist/src/anatomy/anatomy-contracts.js +1 -0
- package/dist/src/anatomy/anatomy-helpers.d.ts +82 -0
- package/dist/src/anatomy/anatomy-helpers.js +233 -0
- package/dist/src/anatomy/anatomy-schema.d.ts +28 -0
- package/dist/src/anatomy/anatomy-schema.js +388 -0
- package/dist/src/anatomy/index.d.ts +4 -0
- package/dist/src/anatomy/index.js +4 -0
- package/dist/src/archetypes.d.ts +87 -0
- package/dist/src/archetypes.js +285 -0
- package/dist/src/arena.d.ts +173 -0
- package/dist/src/arena.js +695 -0
- package/dist/src/bridge/bridge-engine.d.ts +46 -0
- package/dist/src/bridge/bridge-engine.js +252 -0
- package/dist/src/bridge/index.d.ts +4 -0
- package/dist/src/bridge/index.js +5 -0
- package/dist/src/bridge/interpolation.d.ts +64 -0
- package/dist/src/bridge/interpolation.js +130 -0
- package/dist/src/bridge/mapping.d.ts +33 -0
- package/dist/src/bridge/mapping.js +54 -0
- package/dist/src/bridge/types.d.ts +94 -0
- package/dist/src/bridge/types.js +2 -0
- package/dist/src/campaign.d.ts +141 -0
- package/dist/src/campaign.js +235 -0
- package/dist/src/channels.d.ts +15 -0
- package/dist/src/channels.js +20 -0
- package/dist/src/chronicle.d.ts +124 -0
- package/dist/src/chronicle.js +232 -0
- package/dist/src/collective-activities.d.ts +154 -0
- package/dist/src/collective-activities.js +247 -0
- package/dist/src/competence/acoustic.d.ts +101 -0
- package/dist/src/competence/acoustic.js +242 -0
- package/dist/src/competence/catalogue.d.ts +30 -0
- package/dist/src/competence/catalogue.js +241 -0
- package/dist/src/competence/crafting.d.ts +35 -0
- package/dist/src/competence/crafting.js +88 -0
- package/dist/src/competence/engineering.d.ts +53 -0
- package/dist/src/competence/engineering.js +108 -0
- package/dist/src/competence/framework.d.ts +68 -0
- package/dist/src/competence/framework.js +694 -0
- package/dist/src/competence/index.d.ts +12 -0
- package/dist/src/competence/index.js +13 -0
- package/dist/src/competence/interspecies.d.ts +81 -0
- package/dist/src/competence/interspecies.js +108 -0
- package/dist/src/competence/language.d.ts +79 -0
- package/dist/src/competence/language.js +115 -0
- package/dist/src/competence/naturalist.d.ts +97 -0
- package/dist/src/competence/naturalist.js +187 -0
- package/dist/src/competence/navigation.d.ts +24 -0
- package/dist/src/competence/navigation.js +48 -0
- package/dist/src/competence/performance.d.ts +125 -0
- package/dist/src/competence/performance.js +210 -0
- package/dist/src/competence/teaching.d.ts +64 -0
- package/dist/src/competence/teaching.js +121 -0
- package/dist/src/competence/willpower.d.ts +74 -0
- package/dist/src/competence/willpower.js +114 -0
- package/dist/src/crafting/index.d.ts +55 -0
- package/dist/src/crafting/index.js +229 -0
- package/dist/src/crafting/manufacturing.d.ts +83 -0
- package/dist/src/crafting/manufacturing.js +165 -0
- package/dist/src/crafting/materials.d.ts +53 -0
- package/dist/src/crafting/materials.js +120 -0
- package/dist/src/crafting/recipes.d.ts +75 -0
- package/dist/src/crafting/recipes.js +233 -0
- package/dist/src/crafting/workshops.d.ts +61 -0
- package/dist/src/crafting/workshops.js +170 -0
- package/dist/src/debug.d.ts +86 -0
- package/dist/src/debug.js +76 -0
- package/dist/src/derive.d.ts +21 -0
- package/dist/src/derive.js +88 -0
- package/dist/src/describe.d.ts +29 -0
- package/dist/src/describe.js +276 -0
- package/dist/src/dialogue.d.ts +122 -0
- package/dist/src/dialogue.js +266 -0
- package/dist/src/dist.d.ts +20 -0
- package/dist/src/dist.js +39 -0
- package/dist/src/downtime.d.ts +89 -0
- package/dist/src/downtime.js +391 -0
- package/dist/src/economy.d.ts +116 -0
- package/dist/src/economy.js +182 -0
- package/dist/src/emotional-contagion.d.ts +142 -0
- package/dist/src/emotional-contagion.js +274 -0
- package/dist/src/equipment.d.ts +206 -0
- package/dist/src/equipment.js +598 -0
- package/dist/src/faction.d.ts +102 -0
- package/dist/src/faction.js +237 -0
- package/dist/src/generate.d.ts +35 -0
- package/dist/src/generate.js +166 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/index.js +54 -0
- package/dist/src/inheritance.d.ts +69 -0
- package/dist/src/inheritance.js +136 -0
- package/dist/src/inventory.d.ts +194 -0
- package/dist/src/inventory.js +637 -0
- package/dist/src/item-durability.d.ts +69 -0
- package/dist/src/item-durability.js +308 -0
- package/dist/src/legend.d.ts +97 -0
- package/dist/src/legend.js +269 -0
- package/dist/src/lod.d.ts +9 -0
- package/dist/src/lod.js +84 -0
- package/dist/src/metrics.d.ts +51 -0
- package/dist/src/metrics.js +91 -0
- package/dist/src/model3d.d.ts +138 -0
- package/dist/src/model3d.js +214 -0
- package/dist/src/mythology.d.ts +101 -0
- package/dist/src/mythology.js +308 -0
- package/dist/src/narrative-render.d.ts +42 -0
- package/dist/src/narrative-render.js +194 -0
- package/dist/src/narrative-stress.d.ts +123 -0
- package/dist/src/narrative-stress.js +183 -0
- package/dist/src/narrative.d.ts +44 -0
- package/dist/src/narrative.js +257 -0
- package/dist/src/party.d.ts +70 -0
- package/dist/src/party.js +226 -0
- package/dist/src/polity.d.ts +262 -0
- package/dist/src/polity.js +398 -0
- package/dist/src/presets.d.ts +42 -0
- package/dist/src/presets.js +170 -0
- package/dist/src/progression.d.ts +170 -0
- package/dist/src/progression.js +256 -0
- package/dist/src/quest-generators.d.ts +76 -0
- package/dist/src/quest-generators.js +534 -0
- package/dist/src/quest.d.ts +239 -0
- package/dist/src/quest.js +520 -0
- package/dist/src/relationships-effects.d.ts +75 -0
- package/dist/src/relationships-effects.js +219 -0
- package/dist/src/relationships.d.ts +104 -0
- package/dist/src/relationships.js +347 -0
- package/dist/src/replay.d.ts +47 -0
- package/dist/src/replay.js +82 -0
- package/dist/src/rng.d.ts +9 -0
- package/dist/src/rng.js +37 -0
- package/dist/src/settlement-services.d.ts +67 -0
- package/dist/src/settlement-services.js +267 -0
- package/dist/src/settlement.d.ts +143 -0
- package/dist/src/settlement.js +419 -0
- package/dist/src/sim/action.d.ts +28 -0
- package/dist/src/sim/action.js +12 -0
- package/dist/src/sim/aging.d.ts +95 -0
- package/dist/src/sim/aging.js +243 -0
- package/dist/src/sim/ai/decide.d.ts +10 -0
- package/dist/src/sim/ai/decide.js +267 -0
- package/dist/src/sim/ai/perception.d.ts +12 -0
- package/dist/src/sim/ai/perception.js +54 -0
- package/dist/src/sim/ai/personality.d.ts +54 -0
- package/dist/src/sim/ai/personality.js +202 -0
- package/dist/src/sim/ai/presets.d.ts +2 -0
- package/dist/src/sim/ai/presets.js +28 -0
- package/dist/src/sim/ai/system.d.ts +6 -0
- package/dist/src/sim/ai/system.js +13 -0
- package/dist/src/sim/ai/targeting.d.ts +8 -0
- package/dist/src/sim/ai/targeting.js +42 -0
- package/dist/src/sim/ai/types.d.ts +14 -0
- package/dist/src/sim/ai/types.js +1 -0
- package/dist/src/sim/body.d.ts +9 -0
- package/dist/src/sim/body.js +32 -0
- package/dist/src/sim/bodyplan.d.ts +161 -0
- package/dist/src/sim/bodyplan.js +677 -0
- package/dist/src/sim/capability.d.ts +135 -0
- package/dist/src/sim/capability.js +8 -0
- package/dist/src/sim/combat.d.ts +21 -0
- package/dist/src/sim/combat.js +77 -0
- package/dist/src/sim/commandBuilders.d.ts +11 -0
- package/dist/src/sim/commandBuilders.js +39 -0
- package/dist/src/sim/commands.d.ts +71 -0
- package/dist/src/sim/commands.js +8 -0
- package/dist/src/sim/condition.d.ts +35 -0
- package/dist/src/sim/condition.js +21 -0
- package/dist/src/sim/cone.d.ts +40 -0
- package/dist/src/sim/cone.js +44 -0
- package/dist/src/sim/context.d.ts +68 -0
- package/dist/src/sim/context.js +1 -0
- package/dist/src/sim/density.d.ts +14 -0
- package/dist/src/sim/density.js +33 -0
- package/dist/src/sim/disease.d.ts +141 -0
- package/dist/src/sim/disease.js +353 -0
- package/dist/src/sim/entity.d.ts +251 -0
- package/dist/src/sim/entity.js +19 -0
- package/dist/src/sim/events.d.ts +25 -0
- package/dist/src/sim/events.js +5 -0
- package/dist/src/sim/explosion.d.ts +40 -0
- package/dist/src/sim/explosion.js +40 -0
- package/dist/src/sim/formation-unit.d.ts +138 -0
- package/dist/src/sim/formation-unit.js +197 -0
- package/dist/src/sim/formation.d.ts +12 -0
- package/dist/src/sim/formation.js +54 -0
- package/dist/src/sim/frontage.d.ts +30 -0
- package/dist/src/sim/frontage.js +84 -0
- package/dist/src/sim/grapple.d.ts +100 -0
- package/dist/src/sim/grapple.js +480 -0
- package/dist/src/sim/hazard.d.ts +104 -0
- package/dist/src/sim/hazard.js +201 -0
- package/dist/src/sim/hydrostatic.d.ts +58 -0
- package/dist/src/sim/hydrostatic.js +117 -0
- package/dist/src/sim/impairment.d.ts +20 -0
- package/dist/src/sim/impairment.js +162 -0
- package/dist/src/sim/indexing.d.ts +7 -0
- package/dist/src/sim/indexing.js +7 -0
- package/dist/src/sim/injury.d.ts +54 -0
- package/dist/src/sim/injury.js +66 -0
- package/dist/src/sim/intent.d.ts +26 -0
- package/dist/src/sim/intent.js +7 -0
- package/dist/src/sim/kernel.d.ts +45 -0
- package/dist/src/sim/kernel.js +1992 -0
- package/dist/src/sim/kinds.d.ts +64 -0
- package/dist/src/sim/kinds.js +56 -0
- package/dist/src/sim/knockback.d.ts +50 -0
- package/dist/src/sim/knockback.js +82 -0
- package/dist/src/sim/limb.d.ts +48 -0
- package/dist/src/sim/limb.js +78 -0
- package/dist/src/sim/medical.d.ts +32 -0
- package/dist/src/sim/medical.js +33 -0
- package/dist/src/sim/morale.d.ts +69 -0
- package/dist/src/sim/morale.js +92 -0
- package/dist/src/sim/mount.d.ts +150 -0
- package/dist/src/sim/mount.js +225 -0
- package/dist/src/sim/nutrition.d.ts +74 -0
- package/dist/src/sim/nutrition.js +168 -0
- package/dist/src/sim/occlusion.d.ts +8 -0
- package/dist/src/sim/occlusion.js +71 -0
- package/dist/src/sim/push.d.ts +11 -0
- package/dist/src/sim/push.js +79 -0
- package/dist/src/sim/ranged.d.ts +44 -0
- package/dist/src/sim/ranged.js +69 -0
- package/dist/src/sim/seeds.d.ts +3 -0
- package/dist/src/sim/seeds.js +16 -0
- package/dist/src/sim/sensory-extended.d.ts +103 -0
- package/dist/src/sim/sensory-extended.js +181 -0
- package/dist/src/sim/sensory.d.ts +38 -0
- package/dist/src/sim/sensory.js +109 -0
- package/dist/src/sim/skills.d.ts +70 -0
- package/dist/src/sim/skills.js +69 -0
- package/dist/src/sim/sleep.d.ts +107 -0
- package/dist/src/sim/sleep.js +215 -0
- package/dist/src/sim/spatial.d.ts +8 -0
- package/dist/src/sim/spatial.js +59 -0
- package/dist/src/sim/step/capability.d.ts +8 -0
- package/dist/src/sim/step/capability.js +77 -0
- package/dist/src/sim/step/concentration.d.ts +9 -0
- package/dist/src/sim/step/concentration.js +25 -0
- package/dist/src/sim/step/effects.d.ts +17 -0
- package/dist/src/sim/step/effects.js +96 -0
- package/dist/src/sim/step/energy.d.ts +3 -0
- package/dist/src/sim/step/energy.js +31 -0
- package/dist/src/sim/step/hazards.d.ts +4 -0
- package/dist/src/sim/step/hazards.js +19 -0
- package/dist/src/sim/step/injury.d.ts +10 -0
- package/dist/src/sim/step/injury.js +353 -0
- package/dist/src/sim/step/morale.d.ts +11 -0
- package/dist/src/sim/step/morale.js +130 -0
- package/dist/src/sim/step/movement.d.ts +5 -0
- package/dist/src/sim/step/movement.js +172 -0
- package/dist/src/sim/step/push.d.ts +11 -0
- package/dist/src/sim/step/push.js +79 -0
- package/dist/src/sim/step/substances.d.ts +3 -0
- package/dist/src/sim/step/substances.js +75 -0
- package/dist/src/sim/substance.d.ts +38 -0
- package/dist/src/sim/substance.js +57 -0
- package/dist/src/sim/systemic-toxicology.d.ts +109 -0
- package/dist/src/sim/systemic-toxicology.js +263 -0
- package/dist/src/sim/team.d.ts +9 -0
- package/dist/src/sim/team.js +37 -0
- package/dist/src/sim/tech.d.ts +36 -0
- package/dist/src/sim/tech.js +46 -0
- package/dist/src/sim/terrain.d.ts +121 -0
- package/dist/src/sim/terrain.js +141 -0
- package/dist/src/sim/testing.d.ts +13 -0
- package/dist/src/sim/testing.js +100 -0
- package/dist/src/sim/thermoregulation.d.ts +77 -0
- package/dist/src/sim/thermoregulation.js +161 -0
- package/dist/src/sim/tick.d.ts +3 -0
- package/dist/src/sim/tick.js +3 -0
- package/dist/src/sim/toxicology.d.ts +52 -0
- package/dist/src/sim/toxicology.js +104 -0
- package/dist/src/sim/trace.d.ts +141 -0
- package/dist/src/sim/trace.js +1 -0
- package/dist/src/sim/tuning.d.ts +16 -0
- package/dist/src/sim/tuning.js +42 -0
- package/dist/src/sim/vec3.d.ts +14 -0
- package/dist/src/sim/vec3.js +31 -0
- package/dist/src/sim/weapon_dynamics.d.ts +102 -0
- package/dist/src/sim/weapon_dynamics.js +142 -0
- package/dist/src/sim/weather.d.ts +95 -0
- package/dist/src/sim/weather.js +105 -0
- package/dist/src/sim/world.d.ts +52 -0
- package/dist/src/sim/world.js +1 -0
- package/dist/src/sim/wound-aging.d.ts +120 -0
- package/dist/src/sim/wound-aging.js +223 -0
- package/dist/src/species.d.ts +106 -0
- package/dist/src/species.js +664 -0
- package/dist/src/story-arcs.d.ts +17 -0
- package/dist/src/story-arcs.js +276 -0
- package/dist/src/tech-diffusion.d.ts +80 -0
- package/dist/src/tech-diffusion.js +185 -0
- package/dist/src/traits.d.ts +25 -0
- package/dist/src/traits.js +178 -0
- package/dist/src/types.d.ts +117 -0
- package/dist/src/types.js +1 -0
- package/dist/src/units.d.ts +41 -0
- package/dist/src/units.js +64 -0
- package/dist/src/weapons.d.ts +20 -0
- package/dist/src/weapons.js +824 -0
- package/dist/src/world-generation.d.ts +52 -0
- package/dist/src/world-generation.js +301 -0
- package/package.json +74 -0
|
@@ -0,0 +1,1992 @@
|
|
|
1
|
+
import { ensureAnatomyRuntime } from "./entity.js";
|
|
2
|
+
import { SCALE, q, clampQ, qMul, mulDiv, to } from "../units.js";
|
|
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";
|
|
7
|
+
import { TUNING } from "./tuning.js";
|
|
8
|
+
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";
|
|
12
|
+
import { normaliseDirCheapQ, dotDirQ } from "./vec3.js";
|
|
13
|
+
import { eventSeed } from "./seeds.js";
|
|
14
|
+
import { regionFromHit } from "./body.js";
|
|
15
|
+
import { resolveHitSegment } from "./bodyplan.js";
|
|
16
|
+
import { FRACTURE_THRESHOLD } from "./injury.js";
|
|
17
|
+
import { TIER_RANK, TIER_MUL, ACTION_MIN_TIER, TIER_TECH_REQ } from "./medical.js";
|
|
18
|
+
import { blastEnergyFracQ, fragmentsExpected, fragmentKineticEnergy } from "./explosion.js";
|
|
19
|
+
import { makeRng } from "../rng.js";
|
|
20
|
+
import { buildWorldIndex } from "./indexing.js";
|
|
21
|
+
import { buildSpatialIndex } from "./spatial.js";
|
|
22
|
+
import { sortEventsDeterministic } from "./events.js";
|
|
23
|
+
import { parryLeverageQ } from "./combat.js";
|
|
24
|
+
import { pickNearestEnemyInReach } from "./formation.js";
|
|
25
|
+
import { isMeleeLaneOccludedByFriendly } from "./occlusion.js";
|
|
26
|
+
import { applyFrontageCap } from "./frontage.js";
|
|
27
|
+
import { computeDensityField } from "./density.js";
|
|
28
|
+
import { coverFractionAtPosition, elevationAtPosition } from "./terrain.js";
|
|
29
|
+
import { nullTrace } from "./trace.js";
|
|
30
|
+
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";
|
|
33
|
+
import { stepPushAndRepulsion } from "./step/push.js";
|
|
34
|
+
import { stepMoraleForEntity } from "./step/morale.js";
|
|
35
|
+
import { stepSubstances } from "./step/substances.js";
|
|
36
|
+
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
|
+
import { stepConditionsToInjury, stepInjuryProgression } from "./step/injury.js";
|
|
42
|
+
import { stepCoreTemp, deriveTempModifiers, CORE_TEMP_NORMAL_Q } from "./thermoregulation.js";
|
|
43
|
+
import { stepNutrition } from "./nutrition.js";
|
|
44
|
+
import { stepToxicology } from "./toxicology.js";
|
|
45
|
+
import { stepIngestedToxicology } from "./systemic-toxicology.js";
|
|
46
|
+
import { buildLimbStates, stepLimbFatigue } from "./limb.js";
|
|
47
|
+
import { stepCapabilitySources } from "./step/capability.js";
|
|
48
|
+
import { stepMovement } from "./step/movement.js";
|
|
49
|
+
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";
|
|
55
|
+
import { TICK_HZ } from "./tick.js";
|
|
56
|
+
// Phase 2 extension: swing momentum carry
|
|
57
|
+
const SWING_MOMENTUM_DECAY = q(0.95); // 5% decay per tick
|
|
58
|
+
const SWING_MOMENTUM_MAX = q(0.12); // max +12% energy bonus at full momentum
|
|
59
|
+
// Phase 3 extension: aiming time
|
|
60
|
+
const AIM_MAX_TICKS = 20; // 1 second at 20 ticks/s
|
|
61
|
+
const AIM_MIN_MUL = q(0.50); // half dispersion at full aim
|
|
62
|
+
const AIM_STILL_THRESHOLD = 5_000; // 0.5 m/s in SCALE.mps units
|
|
63
|
+
function resolveTargetHitSegment(target, roll01, sideBit, fallbackArea) {
|
|
64
|
+
if (target.bodyPlan) {
|
|
65
|
+
return resolveHitSegment(target.bodyPlan, roll01);
|
|
66
|
+
}
|
|
67
|
+
const area = fallbackArea ?? chooseArea(roll01);
|
|
68
|
+
return regionFromHit(area, sideBit);
|
|
69
|
+
}
|
|
70
|
+
function regionCoverageQ(coverageByRegion, segmentId) {
|
|
71
|
+
return coverageByRegion[segmentId] ?? q(0);
|
|
72
|
+
}
|
|
73
|
+
function inferHitAreaForSegment(target, segmentId) {
|
|
74
|
+
const seg = target.bodyPlan?.segments.find((s) => s.id === segmentId);
|
|
75
|
+
if (!seg) {
|
|
76
|
+
// Legacy humanoid region ids
|
|
77
|
+
if (segmentId === "head")
|
|
78
|
+
return "head";
|
|
79
|
+
if (segmentId === "torso")
|
|
80
|
+
return "torso";
|
|
81
|
+
if (segmentId === "leftArm" || segmentId === "rightArm")
|
|
82
|
+
return "arm";
|
|
83
|
+
if (segmentId === "leftLeg" || segmentId === "rightLeg")
|
|
84
|
+
return "leg";
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (seg.cnsRole === "central")
|
|
88
|
+
return "head";
|
|
89
|
+
if (seg.manipulationRole === "primary" || seg.manipulationRole === "secondary") {
|
|
90
|
+
return "arm";
|
|
91
|
+
}
|
|
92
|
+
if (seg.locomotionRole === "primary" || seg.locomotionRole === "secondary") {
|
|
93
|
+
return "leg";
|
|
94
|
+
}
|
|
95
|
+
return "torso";
|
|
96
|
+
}
|
|
97
|
+
function shieldBlocksSegment(shield, target, segmentId, area) {
|
|
98
|
+
if (!shield)
|
|
99
|
+
return false;
|
|
100
|
+
const { helpers } = ensureAnatomyRuntime(target);
|
|
101
|
+
if (shield.coverageProfileId && helpers?.coverage) {
|
|
102
|
+
return helpers.coverage.coversSegmentId(shield.coverageProfileId, segmentId);
|
|
103
|
+
}
|
|
104
|
+
const effectiveArea = area ?? inferHitAreaForSegment(target, segmentId);
|
|
105
|
+
if (effectiveArea === undefined)
|
|
106
|
+
return false;
|
|
107
|
+
return shieldCovers(shield, effectiveArea);
|
|
108
|
+
}
|
|
109
|
+
export function stepWorld(world, cmds, ctx) {
|
|
110
|
+
const tuning = ctx.tuning ?? TUNING.tactical;
|
|
111
|
+
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;
|
|
142
|
+
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
|
+
}
|
|
329
|
+
for (const e of world.entities) {
|
|
330
|
+
if (e.injury.dead)
|
|
331
|
+
continue;
|
|
332
|
+
applyCommands(e, cmds.get(e.id) ?? []);
|
|
333
|
+
applyFunctionalGating(e, tuning);
|
|
334
|
+
applyStandAndKO(e, tuning);
|
|
335
|
+
trace.onEvent({ kind: TraceKinds.Intent, tick: world.tick, entityId: e.id, intent: e.intent });
|
|
336
|
+
}
|
|
337
|
+
for (const e of world.entities) {
|
|
338
|
+
const d = e.intent.move.dir;
|
|
339
|
+
if (d.x !== 0 || d.y !== 0 || d.z !== 0)
|
|
340
|
+
e.action.facingDirQ = normaliseDirCheapQ(d);
|
|
341
|
+
}
|
|
342
|
+
for (const e of world.entities) {
|
|
343
|
+
if (e.injury.dead)
|
|
344
|
+
continue;
|
|
345
|
+
stepMovement(e, world, ctx, tuning);
|
|
346
|
+
trace.onEvent({ kind: TraceKinds.Move, tick: world.tick, entityId: e.id, pos: e.position_m, vel: e.velocity_mps });
|
|
347
|
+
}
|
|
348
|
+
const spatialAfterMove = buildSpatialIndex(world, cellSize_m);
|
|
349
|
+
// Phase 6: hazard damage — applied after movement so entities in hazard cells take damage each tick.
|
|
350
|
+
if (ctx.hazardGrid) {
|
|
351
|
+
stepHazardEffects(world.entities, ctx.hazardGrid, cellSize_m);
|
|
352
|
+
}
|
|
353
|
+
stepPushAndRepulsion(world, index, spatialAfterMove, {
|
|
354
|
+
personalRadius_m: Math.trunc(0.45 * SCALE.m),
|
|
355
|
+
repelAccel_mps2: Math.trunc(1.5 * SCALE.mps2),
|
|
356
|
+
pushTransfer: q(0.35),
|
|
357
|
+
maxNeighbours: 10,
|
|
358
|
+
});
|
|
359
|
+
for (const e of world.entities) {
|
|
360
|
+
if (e.injury.dead)
|
|
361
|
+
continue;
|
|
362
|
+
const commands = cmds.get(e.id) ?? [];
|
|
363
|
+
for (const c of commands) {
|
|
364
|
+
if (c.kind === "attack") {
|
|
365
|
+
resolveAttack(world, e, c, tuning, index, impacts, spatial, trace, ctx);
|
|
366
|
+
}
|
|
367
|
+
else if (c.kind === "attackNearest") {
|
|
368
|
+
const wpn = findWeapon(e.loadout, c.weaponId);
|
|
369
|
+
if (!wpn)
|
|
370
|
+
continue;
|
|
371
|
+
const reach_m = wpn.reach_m ?? Math.trunc(e.attributes.morphology.stature_m * 0.45);
|
|
372
|
+
const target = pickNearestEnemyInReach(world, e, index, spatial, {
|
|
373
|
+
reach_m,
|
|
374
|
+
buffer_m: Math.trunc(0.8 * SCALE.m),
|
|
375
|
+
maxTargets: 12,
|
|
376
|
+
requireFrontArc: tuning.realism !== "arcade",
|
|
377
|
+
minDotQ: tuning.realism === "sim" ? q(0.20) : q(0.0),
|
|
378
|
+
});
|
|
379
|
+
if (!target)
|
|
380
|
+
continue;
|
|
381
|
+
// Convert to ordinary attack command
|
|
382
|
+
const attackCmd = {
|
|
383
|
+
kind: "attack",
|
|
384
|
+
targetId: target.id,
|
|
385
|
+
mode: c.mode,
|
|
386
|
+
...(c.weaponId !== undefined ? { weaponId: c.weaponId } : {}),
|
|
387
|
+
...(c.intensity !== undefined ? { intensity: c.intensity } : {}),
|
|
388
|
+
};
|
|
389
|
+
resolveAttack(world, e, attackCmd, tuning, index, impacts, spatialAfterMove, trace, ctx);
|
|
390
|
+
}
|
|
391
|
+
else if (c.kind === "grapple") {
|
|
392
|
+
resolveGrappleCommand(world, e, c, tuning, index, impacts, trace);
|
|
393
|
+
}
|
|
394
|
+
else if (c.kind === "breakGrapple") {
|
|
395
|
+
resolveBreakGrapple(world, e, c.intensity, tuning, index, trace);
|
|
396
|
+
}
|
|
397
|
+
else if (c.kind === "breakBind") {
|
|
398
|
+
resolveBreakBind(world, e, c.intensity, index, trace);
|
|
399
|
+
}
|
|
400
|
+
else if (c.kind === "shoot") {
|
|
401
|
+
resolveShoot(world, e, c, tuning, index, impacts, trace, ctx);
|
|
402
|
+
}
|
|
403
|
+
else if (c.kind === "treat") {
|
|
404
|
+
resolveTreat(world, e, c, index, trace, ctx);
|
|
405
|
+
}
|
|
406
|
+
else if (c.kind === "activate") {
|
|
407
|
+
// Don't queue a new activation if one is already pending (cast in progress)
|
|
408
|
+
if (!e.pendingActivation) {
|
|
409
|
+
resolveActivation(world, e, c, ctx, trace, world.tick);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Phase 2A: per-tick grapple maintenance (stamina drain, grip decay, auto-release)
|
|
415
|
+
for (const e of world.entities) {
|
|
416
|
+
if (e.injury.dead)
|
|
417
|
+
continue;
|
|
418
|
+
stepGrappleTick(world, e, index);
|
|
419
|
+
}
|
|
420
|
+
let finalImpacts = impacts;
|
|
421
|
+
if (tuning.realism !== "arcade") {
|
|
422
|
+
finalImpacts = applyFrontageCap(impacts, index, { maxEngagersPerTarget: tuning.realism === "sim" ? 2 : 3 });
|
|
423
|
+
}
|
|
424
|
+
sortEventsDeterministic(finalImpacts);
|
|
425
|
+
// Phase 5: snapshot alive set before impacts are applied (used by morale step to detect ally deaths)
|
|
426
|
+
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
|
+
}
|
|
481
|
+
// Phase 12B: apply chain payloads from active field effects, then expire timed ones
|
|
482
|
+
stepChainEffects(world, trace, world.tick);
|
|
483
|
+
stepFieldEffects(world);
|
|
484
|
+
// Phase 5: precompute routing fraction per team for routing cascade check
|
|
485
|
+
const teamAliveCount = new Map();
|
|
486
|
+
const teamRoutingCount = new Map();
|
|
487
|
+
for (const e of world.entities) {
|
|
488
|
+
if (e.injury.dead)
|
|
489
|
+
continue;
|
|
490
|
+
teamAliveCount.set(e.teamId, (teamAliveCount.get(e.teamId) ?? 0) + 1);
|
|
491
|
+
if (isRouting(e.condition.fearQ ?? 0, e.attributes.resilience.distressTolerance)) {
|
|
492
|
+
teamRoutingCount.set(e.teamId, (teamRoutingCount.get(e.teamId) ?? 0) + 1);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const teamRoutingFrac = new Map();
|
|
496
|
+
for (const [teamId, alive] of teamAliveCount) {
|
|
497
|
+
teamRoutingFrac.set(teamId, alive > 0 ? (teamRoutingCount.get(teamId) ?? 0) / alive : 0);
|
|
498
|
+
}
|
|
499
|
+
// Injury progression and energy — must complete for ALL entities before morale runs
|
|
500
|
+
for (const e of world.entities) {
|
|
501
|
+
if (e.injury.dead)
|
|
502
|
+
continue;
|
|
503
|
+
const wasAboveKOThreshold = e.injury.consciousness > tuning.unconsciousThreshold;
|
|
504
|
+
stepConditionsToInjury(e, world, ctx.ambientTemperature_Q);
|
|
505
|
+
stepInjuryProgression(e, world.tick);
|
|
506
|
+
stepSubstances(e, ctx.ambientTemperature_Q);
|
|
507
|
+
stepEnergy(e, ctx);
|
|
508
|
+
// Phase 29: advance core temperature once per tick (Phase 31: skip ectotherms)
|
|
509
|
+
if (ctx.thermalAmbient_Q !== undefined && !e.physiology?.coldBlooded) {
|
|
510
|
+
stepCoreTemp(e, ctx.thermalAmbient_Q, 1 / TICK_HZ);
|
|
511
|
+
}
|
|
512
|
+
stepCapabilitySources(e, world, ctx); // Phase 12
|
|
513
|
+
// Phase 13: emit KO and Death events so metrics/replay consumers can track incapacitation
|
|
514
|
+
if (e.injury.dead) {
|
|
515
|
+
trace.onEvent({ kind: TraceKinds.Death, tick: world.tick, entityId: e.id });
|
|
516
|
+
// Phase 12B: kill-triggered regen — credit all living entities with kill triggers
|
|
517
|
+
for (const observer of world.entities) {
|
|
518
|
+
if (observer.id === e.id || observer.injury.dead)
|
|
519
|
+
continue;
|
|
520
|
+
for (const src of (observer.capabilitySources ?? [])) {
|
|
521
|
+
if (src.regenModel.type !== "event")
|
|
522
|
+
continue;
|
|
523
|
+
for (const trig of src.regenModel.triggers) {
|
|
524
|
+
if (trig.on === "kill") {
|
|
525
|
+
src.reserve_J = Math.min(src.maxReserve_J, src.reserve_J + trig.amount_J);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else if (wasAboveKOThreshold && e.injury.consciousness <= tuning.unconsciousThreshold) {
|
|
532
|
+
trace.onEvent({ kind: TraceKinds.KO, tick: world.tick, entityId: e.id });
|
|
533
|
+
}
|
|
534
|
+
trace.onEvent({
|
|
535
|
+
kind: TraceKinds.Injury,
|
|
536
|
+
tick: world.tick,
|
|
537
|
+
entityId: e.id,
|
|
538
|
+
dead: e.injury.dead,
|
|
539
|
+
shockQ: e.injury.shock,
|
|
540
|
+
fluidLossQ: e.injury.fluidLoss,
|
|
541
|
+
consciousnessQ: e.injury.consciousness,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
// Phase 30: nutrition at 1 Hz (world-level accumulator avoids per-tick BMR calls)
|
|
545
|
+
// Phase 32C: toxicology ticked at same 1 Hz cadence
|
|
546
|
+
{
|
|
547
|
+
if ((world).__nutritionAccum === undefined)
|
|
548
|
+
(world).__nutritionAccum = 0;
|
|
549
|
+
(world).__nutritionAccum = (world).__nutritionAccum + (1 / TICK_HZ);
|
|
550
|
+
if ((world).__nutritionAccum >= 1.0) {
|
|
551
|
+
(world).__nutritionAccum -= 1.0;
|
|
552
|
+
for (const e of world.entities) {
|
|
553
|
+
if (!e.injury.dead) {
|
|
554
|
+
const nVMag = Math.sqrt(e.velocity_mps.x ** 2 + e.velocity_mps.y ** 2);
|
|
555
|
+
const nAct = nVMag >= Math.trunc(SCALE.mps) ? q(0.50) : q(0);
|
|
556
|
+
stepNutrition(e, 1.0, nAct);
|
|
557
|
+
if (e.activeVenoms?.length)
|
|
558
|
+
stepToxicology(e, 1.0);
|
|
559
|
+
if (e.activeIngestedToxins?.length || e.withdrawal?.length)
|
|
560
|
+
stepIngestedToxicology(e, 1.0);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Phase 32B: limb fatigue tick (per-tick, for entities with limbStates)
|
|
566
|
+
for (const e of world.entities) {
|
|
567
|
+
if (!e.injury.dead && e.limbStates) {
|
|
568
|
+
stepLimbFatigue(e.limbStates, e.attributes.performance.peakForce_N, 1 / TICK_HZ);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Phase 5: morale step — runs after all deaths from this tick are determined
|
|
572
|
+
for (const e of world.entities) {
|
|
573
|
+
if (e.injury.dead)
|
|
574
|
+
continue;
|
|
575
|
+
stepMoraleForEntity(world, e, index, spatialAfterMove, aliveBeforeTick, teamRoutingFrac, trace, ctx);
|
|
576
|
+
}
|
|
577
|
+
trace.onEvent({ kind: TraceKinds.TickEnd, tick: world.tick });
|
|
578
|
+
world.tick += 1;
|
|
579
|
+
}
|
|
580
|
+
function resolveCapabilityHitSegment(world, tick, actor, target, salt) {
|
|
581
|
+
const seed = eventSeed(world.seed, tick, actor.id, target.id, salt);
|
|
582
|
+
const rng = makeRng(seed, SCALE.Q);
|
|
583
|
+
const sideBit = (seed & 1);
|
|
584
|
+
return resolveTargetHitSegment(target, rng.q01(), sideBit);
|
|
585
|
+
}
|
|
586
|
+
function applyFunctionalGating(e, tuning) {
|
|
587
|
+
const func = deriveFunctionalState(e, tuning);
|
|
588
|
+
// incapacity gates voluntary actions
|
|
589
|
+
if (!func.canAct) {
|
|
590
|
+
e.intent.defence = { mode: "none", intensity: q(0) };
|
|
591
|
+
e.intent.move = { dir: { x: 0, y: 0, z: 0 }, intensity: q(0), mode: "walk" };
|
|
592
|
+
// keep prone if already, and prefer prone for non-acting entities in tactical/sim
|
|
593
|
+
if (tuning.realism !== "arcade")
|
|
594
|
+
e.condition.prone = true;
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// Phase 2A: pinned entities cannot use normal defence (only breakGrapple applies)
|
|
598
|
+
if (e.condition.pinned && tuning.realism !== "arcade") {
|
|
599
|
+
e.intent.defence = { mode: "none", intensity: q(0) };
|
|
600
|
+
e.condition.prone = true;
|
|
601
|
+
}
|
|
602
|
+
// Phase 2B: exhaustion collapse — when reserve is fully depleted, entity
|
|
603
|
+
// cannot maintain posture or active defence (tactical/sim only).
|
|
604
|
+
if (e.energy.reserveEnergy_J <= 0 && tuning.realism !== "arcade") {
|
|
605
|
+
e.condition.prone = true;
|
|
606
|
+
e.intent.defence = { mode: "none", intensity: q(0) };
|
|
607
|
+
}
|
|
608
|
+
// forced prone if cannot stand (tactical/sim)
|
|
609
|
+
if (!func.canStand && tuning.realism !== "arcade")
|
|
610
|
+
e.condition.prone = true;
|
|
611
|
+
// hard limb disable hooks (tactical/sim)
|
|
612
|
+
if (tuning.realism !== "arcade") {
|
|
613
|
+
const armsOut = hasAllDisabledFunctions(func, "leftManipulation", "rightManipulation");
|
|
614
|
+
if (armsOut && (e.intent.defence.mode === "block" || e.intent.defence.mode === "parry")) {
|
|
615
|
+
e.intent.defence = { mode: "none", intensity: q(0) };
|
|
616
|
+
}
|
|
617
|
+
const legsOut = hasAllDisabledFunctions(func, "leftLocomotion", "rightLocomotion");
|
|
618
|
+
if (legsOut && e.intent.move.mode === "sprint") {
|
|
619
|
+
e.intent.move = { ...e.intent.move, mode: "walk" };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function applyCommands(e, commands) {
|
|
624
|
+
e.intent.defence = { mode: "none", intensity: q(0) };
|
|
625
|
+
for (const c of commands) {
|
|
626
|
+
if (c.kind === "setProne")
|
|
627
|
+
e.condition.prone = c.prone;
|
|
628
|
+
else if (c.kind === "move")
|
|
629
|
+
e.intent.move = { dir: c.dir, intensity: c.intensity, mode: c.mode };
|
|
630
|
+
else if (c.kind === "defend")
|
|
631
|
+
e.intent.defence = { mode: c.mode, intensity: clampQ(c.intensity, 0, SCALE.Q) };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function applyStandAndKO(e, tuning) {
|
|
635
|
+
// KO: if below threshold, go unconscious (but do NOT mark dead)
|
|
636
|
+
const wasUnconscious = e.condition.unconsciousTicks > 0;
|
|
637
|
+
if (e.injury.consciousness <= tuning.unconsciousThreshold) {
|
|
638
|
+
if (!wasUnconscious) {
|
|
639
|
+
e.condition.unconsciousTicks = tuning.unconsciousBaseTicks;
|
|
640
|
+
e.condition.prone = true;
|
|
641
|
+
e.intent.defence = { mode: "none", intensity: q(0) };
|
|
642
|
+
e.intent.move = { dir: { x: 0, y: 0, z: 0 }, intensity: q(0), mode: "walk" };
|
|
643
|
+
// SIM: drop weapons
|
|
644
|
+
if (tuning.dropWeaponsOnUnconscious) {
|
|
645
|
+
e.loadout.items = e.loadout.items.filter(it => it.kind !== "weapon");
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
// keep them down
|
|
650
|
+
e.condition.prone = true;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// If unconscious, cannot act/stand
|
|
654
|
+
if (e.condition.unconsciousTicks > 0) {
|
|
655
|
+
e.intent.defence = { mode: "none", intensity: q(0) };
|
|
656
|
+
e.intent.move = { dir: { x: 0, y: 0, z: 0 }, intensity: q(0), mode: "walk" };
|
|
657
|
+
e.condition.prone = true;
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Standing rules: if player wants to stand but is blocked, force prone
|
|
661
|
+
if (!e.intent.prone && e.condition.prone) {
|
|
662
|
+
if (tuning.realism === "arcade") {
|
|
663
|
+
e.condition.prone = false;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (e.condition.standBlockedTicks > 0) {
|
|
667
|
+
e.condition.prone = true;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// Compute stand-up time based on leg damage + shock + fatigue + encumbrance
|
|
671
|
+
const func = deriveFunctionalState(e, tuning);
|
|
672
|
+
const slow = (SCALE.Q - func.mobilityMul); // 0..1
|
|
673
|
+
const extra = Math.trunc((slow * tuning.standUpMaxExtraTicks) / SCALE.Q);
|
|
674
|
+
const ticks = tuning.standUpBaseTicks + extra;
|
|
675
|
+
e.condition.standBlockedTicks = Math.max(1, ticks);
|
|
676
|
+
e.condition.prone = true;
|
|
677
|
+
e.intent.prone = true; // reflect forced state
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/* ------------------ Combat ------------------ */
|
|
681
|
+
function resolveAttack(world, attacker, cmd, tuning, index, impacts, spatial, trace, ctx) {
|
|
682
|
+
if (attacker.action.attackCooldownTicks > 0)
|
|
683
|
+
return;
|
|
684
|
+
// Phase 2C: weapon bind gate — cannot attack while weapons are locked
|
|
685
|
+
if (attacker.action.weaponBindPartnerId !== 0)
|
|
686
|
+
return;
|
|
687
|
+
const target = index.byId.get(cmd.targetId);
|
|
688
|
+
if (!target || target.injury.dead)
|
|
689
|
+
return;
|
|
690
|
+
const funcA = deriveFunctionalState(attacker, tuning);
|
|
691
|
+
const funcB = deriveFunctionalState(target, tuning);
|
|
692
|
+
if (!funcA.canAct)
|
|
693
|
+
return;
|
|
694
|
+
// Phase 5: pain-induced action suppression (tactical/sim only)
|
|
695
|
+
if (tuning.realism !== "arcade") {
|
|
696
|
+
const painSeed = eventSeed(world.seed, world.tick, attacker.id, target.id, 0xA77A2);
|
|
697
|
+
if (painBlocksAction(painSeed, attacker.injury.shock, attacker.attributes.resilience.distressTolerance))
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const wpn = findWeapon(attacker.loadout, cmd.weaponId);
|
|
701
|
+
if (!wpn)
|
|
702
|
+
return;
|
|
703
|
+
const reach_m = wpn.reach_m ?? Math.trunc(attacker.attributes.morphology.stature_m * 0.45);
|
|
704
|
+
const dx = target.position_m.x - attacker.position_m.x;
|
|
705
|
+
const dy = target.position_m.y - attacker.position_m.y;
|
|
706
|
+
const dz = target.position_m.z - attacker.position_m.z;
|
|
707
|
+
// Phase 6: elevation differential adds to vertical separation in the reach check.
|
|
708
|
+
const cellSizeA = ctx.cellSize_m ?? Math.trunc(4 * SCALE.m);
|
|
709
|
+
const elevA = elevationAtPosition(ctx.elevationGrid, cellSizeA, attacker.position_m.x, attacker.position_m.y);
|
|
710
|
+
const elevT = elevationAtPosition(ctx.elevationGrid, cellSizeA, target.position_m.x, target.position_m.y);
|
|
711
|
+
const dzWithElev = dz + (elevT - elevA);
|
|
712
|
+
const dist2 = BigInt(dx) * BigInt(dx) + BigInt(dy) * BigInt(dy) + BigInt(dzWithElev) * BigInt(dzWithElev);
|
|
713
|
+
const reach2 = BigInt(reach_m) * BigInt(reach_m);
|
|
714
|
+
if (dist2 > reach2)
|
|
715
|
+
return;
|
|
716
|
+
if (tuning.realism !== "arcade") {
|
|
717
|
+
const blocked = isMeleeLaneOccludedByFriendly(world, attacker, target, index, spatial, { laneRadius_m: Math.trunc(0.35 * SCALE.m) });
|
|
718
|
+
if (blocked)
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const dirToTarget = normaliseDirCheapQ({ x: dx, y: dy, z: dz });
|
|
722
|
+
const readyTime_s = wpn.readyTime_s ?? to.s(0.6);
|
|
723
|
+
// Phase 7: meleeCombat.hitTimingOffset_s shortens attack recovery (max 67% reduction)
|
|
724
|
+
const attackerMeleeSkill = getSkill(attacker.skills, "meleeCombat");
|
|
725
|
+
const effectiveReadyTime = Math.max(Math.trunc(readyTime_s / 3), readyTime_s + attackerMeleeSkill.hitTimingOffset_s);
|
|
726
|
+
attacker.action.attackCooldownTicks = Math.max(1, Math.trunc((effectiveReadyTime * TICK_HZ) / SCALE.s));
|
|
727
|
+
// Phase 2B: deduct strike stamina cost (always — attacker expends effort whether hit or miss)
|
|
728
|
+
const clampedIntensity = clampQ(cmd.intensity ?? q(1.0), q(0.1), q(1.0));
|
|
729
|
+
attacker.energy.reserveEnergy_J = Math.max(0, attacker.energy.reserveEnergy_J - strikeCost_J(attacker, clampedIntensity));
|
|
730
|
+
const attackSkillBase = clampQ(qMul(attacker.attributes.control.controlQuality, attacker.attributes.control.fineControl), q(0.05), q(0.99));
|
|
731
|
+
let attackSkill = clampQ(qMul(attackSkillBase, qMul(funcA.coordinationMul, funcA.manipulationMul)), q(0.01), q(0.99));
|
|
732
|
+
const defenceSkillBase = clampQ(qMul(target.attributes.control.controlQuality, target.attributes.control.stability), q(0.05), q(0.99));
|
|
733
|
+
let defenceSkill = clampQ(qMul(defenceSkillBase, qMul(funcB.coordinationMul, funcB.mobilityMul)), q(0.01), q(0.99));
|
|
734
|
+
const geomDot = dotDirQ(attacker.action.facingDirQ, dirToTarget);
|
|
735
|
+
// Phase 2C: reach dominance — short weapon penalised vs longer weapon in open combat.
|
|
736
|
+
// Does not apply when attacker is grappling target (close contact negates reach), or target is prone.
|
|
737
|
+
const grappling = attacker.grapple.holdingTargetId === target.id;
|
|
738
|
+
if (tuning.realism !== "arcade" && !target.condition.prone && !grappling) {
|
|
739
|
+
const tgtWpn = findWeapon(target.loadout);
|
|
740
|
+
if (tgtWpn) {
|
|
741
|
+
const tgtReach_m = tgtWpn.reach_m ?? Math.trunc(target.attributes.morphology.stature_m * 0.45);
|
|
742
|
+
const penalty = reachDomPenaltyQ(reach_m, tgtReach_m);
|
|
743
|
+
attackSkill = clampQ(qMul(attackSkill, penalty), q(0.01), q(0.99));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// Phase 6: elevation advantage — higher ground boosts attack skill (tactical/sim only).
|
|
747
|
+
// Threshold is 0.5 m so the effect is achievable at practical melee ranges.
|
|
748
|
+
if (tuning.realism !== "arcade") {
|
|
749
|
+
const elevDiff = elevA - elevT; // positive = attacker is higher
|
|
750
|
+
if (elevDiff > to.m(0.5)) {
|
|
751
|
+
// +5% per metre above 0.5 m threshold, capped at +10%
|
|
752
|
+
const bonus = clampQ(mulDiv(elevDiff - to.m(0.5), q(0.05), to.m(1)), q(0), q(0.10));
|
|
753
|
+
attackSkill = clampQ(qMul(attackSkill, (SCALE.Q + bonus)), q(0.01), q(0.99));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// Phase 7: meleeDefence skill boosts effective defence quality (parry/block technique)
|
|
757
|
+
const defMeleeSkill = getSkill(target.skills, "meleeDefence");
|
|
758
|
+
defenceSkill = clampQ(qMul(defenceSkill, defMeleeSkill.energyTransferMul), q(0.01), q(0.99));
|
|
759
|
+
// Phase 2C: weapon bind also prevents the defender from parrying/blocking with their weapon
|
|
760
|
+
const defenceModeEffective = target.action.weaponBindPartnerId !== 0
|
|
761
|
+
? "none"
|
|
762
|
+
: target.intent.defence.mode;
|
|
763
|
+
let defenceIntensityEffective = target.action.weaponBindPartnerId !== 0
|
|
764
|
+
? q(0)
|
|
765
|
+
: target.intent.defence.intensity;
|
|
766
|
+
// Phase 7: shieldCraft boosts effective defence skill when actively blocking with a shield
|
|
767
|
+
if (defenceModeEffective === "block") {
|
|
768
|
+
const tgtShield = findShield(target.loadout);
|
|
769
|
+
if (tgtShield) {
|
|
770
|
+
const shieldSkill = getSkill(target.skills, "shieldCraft");
|
|
771
|
+
defenceSkill = clampQ(qMul(defenceSkill, shieldSkill.energyTransferMul), q(0.01), q(0.99));
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
// Phase 4: surprise mechanics — if the defender cannot perceive the attacker,
|
|
775
|
+
// their defensive response is reduced or eliminated.
|
|
776
|
+
if (tuning.realism !== "arcade") {
|
|
777
|
+
const sEnv = (world).__sensoryEnv ?? DEFAULT_SENSORY_ENV;
|
|
778
|
+
// Phase 11C: sensor boost from loadout
|
|
779
|
+
const tgtSensor = findSensor(target.loadout);
|
|
780
|
+
const tgtSensorBoost = tgtSensor
|
|
781
|
+
? { visionRangeMul: tgtSensor.visionRangeMul, hearingRangeMul: tgtSensor.hearingRangeMul }
|
|
782
|
+
: undefined;
|
|
783
|
+
const detectionQ = canDetect(target, attacker, sEnv, tgtSensorBoost);
|
|
784
|
+
if (detectionQ <= 0) {
|
|
785
|
+
// Full surprise: defender has no defence
|
|
786
|
+
defenceIntensityEffective = q(0);
|
|
787
|
+
// Phase 5: fear spike from being attacked without warning
|
|
788
|
+
target.condition.fearQ = clampQ((target.condition.fearQ ?? 0) + FEAR_SURPRISE, 0, SCALE.Q);
|
|
789
|
+
}
|
|
790
|
+
else if (detectionQ < q(0.8)) {
|
|
791
|
+
// Partial surprise: scale defence intensity by detection quality
|
|
792
|
+
defenceIntensityEffective = qMul(defenceIntensityEffective, detectionQ);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// Phase 2C: reach dominance on defence — parrying with a shorter weapon is harder.
|
|
796
|
+
if (tuning.realism !== "arcade" && defenceModeEffective === "parry" && !grappling) {
|
|
797
|
+
const defWpnReach = findWeapon(target.loadout);
|
|
798
|
+
if (defWpnReach) {
|
|
799
|
+
const defReach = defWpnReach.reach_m ?? Math.trunc(target.attributes.morphology.stature_m * 0.45);
|
|
800
|
+
defenceSkill = clampQ(qMul(defenceSkill, reachDomPenaltyQ(defReach, reach_m)), q(0.01), q(0.99));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Phase 17: flexible weapons (flail, morning star) partially bypass shield blocks
|
|
804
|
+
const meleeBypassQ = (wpn).shieldBypassQ ?? 0;
|
|
805
|
+
const defenceIntensityForHit = (meleeBypassQ > 0 && defenceModeEffective === "block")
|
|
806
|
+
? qMul(defenceIntensityEffective, SCALE.Q - meleeBypassQ)
|
|
807
|
+
: defenceIntensityEffective;
|
|
808
|
+
const seed = eventSeed(world.seed, world.tick, attacker.id, target.id, 0xA11AC);
|
|
809
|
+
const res = resolveHit(seed, attackSkill, defenceSkill, geomDot, defenceModeEffective, defenceIntensityForHit);
|
|
810
|
+
trace.onEvent({
|
|
811
|
+
kind: TraceKinds.AttackAttempt,
|
|
812
|
+
tick: world.tick,
|
|
813
|
+
attackerId: attacker.id,
|
|
814
|
+
targetId: target.id,
|
|
815
|
+
hit: res.hit,
|
|
816
|
+
blocked: res.blocked,
|
|
817
|
+
parried: res.parried,
|
|
818
|
+
hitQuality: res.hitQuality,
|
|
819
|
+
area: res.area,
|
|
820
|
+
});
|
|
821
|
+
if (!res.hit) {
|
|
822
|
+
attacker.action.attackCooldownTicks += Math.trunc(mulDiv(missRecoveryTicks(wpn), clampedIntensity, SCALE.Q));
|
|
823
|
+
attacker.action.swingMomentumQ = q(0);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const hitSeed = eventSeed(world.seed, world.tick, attacker.id, target.id, 0x51DE);
|
|
827
|
+
const sideBit = (hitSeed & 1);
|
|
828
|
+
const region = resolveTargetHitSegment(target, ((hitSeed >>> 8) % SCALE.Q), sideBit, res.area);
|
|
829
|
+
const defenderBlocking = (target.intent.defence.mode === "block"); // or cmd-derived if you do that elsewhere
|
|
830
|
+
const shield = findShield(target.loadout);
|
|
831
|
+
const shieldBlocked = res.hit &&
|
|
832
|
+
res.blocked &&
|
|
833
|
+
defenderBlocking &&
|
|
834
|
+
!!shield &&
|
|
835
|
+
shieldBlocksSegment(shield, target, region, res.area);
|
|
836
|
+
const baseIntensity = clampQ(cmd.intensity ?? q(1.0), q(0.1), q(1.0));
|
|
837
|
+
const handling = wpn.handlingMul ?? q(1.0);
|
|
838
|
+
const handlingPenalty = clampQ(q(1.0) - qMul(q(0.18), (handling - SCALE.Q)), q(0.70), q(1.0));
|
|
839
|
+
const intensity = clampQ(qMul(baseIntensity, qMul(funcA.manipulationMul, handlingPenalty)), q(0.1), q(1.0));
|
|
840
|
+
// Phase 29: apply core-temperature power modifier
|
|
841
|
+
const coreTempQ = (attacker.condition).coreTemp_Q ?? CORE_TEMP_NORMAL_Q;
|
|
842
|
+
const tempMods = deriveTempModifiers(coreTempQ);
|
|
843
|
+
const P = Math.trunc(qMul(attacker.attributes.performance.peakPower_W, tempMods.powerMul));
|
|
844
|
+
const base = clampI32(Math.trunc((P * SCALE.mps) / 200), Math.trunc(2 * SCALE.mps), Math.trunc(12 * SCALE.mps));
|
|
845
|
+
const wMul = wpn.strikeSpeedMul ?? q(1.0);
|
|
846
|
+
const cMul = attacker.attributes.control.controlQuality;
|
|
847
|
+
const qualMul = q(0.70) + qMul(res.hitQuality, q(0.30));
|
|
848
|
+
const vStrike = mulDiv(mulDiv(mulDiv(mulDiv(base, wMul, SCALE.Q), cMul, SCALE.Q), intensity, SCALE.Q), qualMul, SCALE.Q);
|
|
849
|
+
const vStrikeVec = scaleDirToSpeed(dirToTarget, vStrike);
|
|
850
|
+
// Clamp body-movement contribution to strike energy. Combatants decelerate before a controlled
|
|
851
|
+
// swing; pure sprint-on-sprint kinetic energy should not dominate. Cap at 2 m/s relative.
|
|
852
|
+
const APPROACH_CAP = Math.trunc(2.0 * SCALE.mps);
|
|
853
|
+
const bodyRelX = clampI32(attacker.velocity_mps.x - target.velocity_mps.x, -APPROACH_CAP, APPROACH_CAP);
|
|
854
|
+
const bodyRelY = clampI32(attacker.velocity_mps.y - target.velocity_mps.y, -APPROACH_CAP, APPROACH_CAP);
|
|
855
|
+
const bodyRelZ = clampI32(attacker.velocity_mps.z - target.velocity_mps.z, -APPROACH_CAP, APPROACH_CAP);
|
|
856
|
+
const rel = {
|
|
857
|
+
x: bodyRelX + vStrikeVec.x,
|
|
858
|
+
y: bodyRelY + vStrikeVec.y,
|
|
859
|
+
z: bodyRelZ + vStrikeVec.z,
|
|
860
|
+
};
|
|
861
|
+
// Phase 2C: two-handed leverage bonus — only when both arms are functional and no off-hand item
|
|
862
|
+
const hasOffHand = attacker.loadout.items.some(it => it.kind === "shield") ||
|
|
863
|
+
attacker.loadout.items.filter(it => it.kind === "weapon").length > 1;
|
|
864
|
+
const twoHandBonus = twoHandedAttackBonusQ(wpn, funcA.leftArmDisabled, funcA.rightArmDisabled, hasOffHand);
|
|
865
|
+
const baseEnergy_J = mulDiv(mulDiv((impactEnergy_J(attacker, wpn, rel)), funcA.manipulationMul, SCALE.Q), twoHandBonus, SCALE.Q);
|
|
866
|
+
// Phase 7: meleeCombat.energyTransferMul boosts strike energy delivery (technique bonus)
|
|
867
|
+
// Phase 11: exoskeleton force multiplier amplifies strike energy
|
|
868
|
+
const attackerExo = findExoskeleton(attacker.loadout);
|
|
869
|
+
const exoForceMul = attackerExo ? attackerExo.forceMultiplier : SCALE.Q;
|
|
870
|
+
let energy_J = mulDiv(mulDiv(baseEnergy_J, attackerMeleeSkill.energyTransferMul, SCALE.Q), exoForceMul, SCALE.Q);
|
|
871
|
+
// Phase 2 extension: swing momentum carry — bonus energy from rhythmic consecutive strikes
|
|
872
|
+
const momentumBonus_J = Math.trunc(qMul(energy_J, qMul(attacker.action.swingMomentumQ, SWING_MOMENTUM_MAX)));
|
|
873
|
+
energy_J += momentumBonus_J;
|
|
874
|
+
let mitigated = energy_J;
|
|
875
|
+
if (res.blocked || res.parried) {
|
|
876
|
+
// Phase 2B: deduct defence stamina cost from the defender
|
|
877
|
+
target.energy.reserveEnergy_J = Math.max(0, target.energy.reserveEnergy_J - defenceCost_J(target));
|
|
878
|
+
const leverage = parryLeverageQ(wpn, attacker);
|
|
879
|
+
const handed = (wpn.handedness ?? "oneHand") === "twoHand" ? q(1.10) : q(1.0);
|
|
880
|
+
const defenceMul = qMul(leverage, handed);
|
|
881
|
+
if (res.blocked) {
|
|
882
|
+
const m = clampQ(q(0.40) - qMul(q(0.12), (defenceMul - SCALE.Q)), q(0.25), q(0.60));
|
|
883
|
+
mitigated = mulDiv(mitigated, m, SCALE.Q);
|
|
884
|
+
}
|
|
885
|
+
if (res.parried) {
|
|
886
|
+
const m = clampQ(q(0.25) - qMul(q(0.15), (defenceMul - SCALE.Q)), q(0.10), q(0.45));
|
|
887
|
+
mitigated = mulDiv(mitigated, m, SCALE.Q);
|
|
888
|
+
// Phase 2C: weapon bind on parry — weapons may lock, requiring both to disengage
|
|
889
|
+
if (tuning.realism !== "arcade"
|
|
890
|
+
&& attacker.action.weaponBindPartnerId === 0
|
|
891
|
+
&& target.action.weaponBindPartnerId === 0) {
|
|
892
|
+
const defWpn = findWeapon(target.loadout);
|
|
893
|
+
if (defWpn) {
|
|
894
|
+
const bindSeed = eventSeed(world.seed, world.tick, attacker.id, target.id, 0xB1DE);
|
|
895
|
+
const bindRoll = (bindSeed % SCALE.Q);
|
|
896
|
+
const bChanceBase = bindChanceQ(wpn, defWpn);
|
|
897
|
+
// Phase 2C improvement #4: fatigue increases bind chance — tired fighters lose weapon control
|
|
898
|
+
const avgFatigue = ((attacker.energy.fatigue + target.energy.fatigue) >>> 1);
|
|
899
|
+
const fatigueMod = (SCALE.Q + qMul(avgFatigue, q(0.20))); // 1.0..1.20
|
|
900
|
+
const bChance = clampQ(qMul(bChanceBase, fatigueMod), q(0), q(0.45));
|
|
901
|
+
if (bindRoll < bChance) {
|
|
902
|
+
const dur = bindDurationTicks(wpn, defWpn);
|
|
903
|
+
attacker.action.weaponBindPartnerId = target.id;
|
|
904
|
+
attacker.action.weaponBindTicks = dur;
|
|
905
|
+
target.action.weaponBindPartnerId = attacker.id;
|
|
906
|
+
target.action.weaponBindTicks = dur;
|
|
907
|
+
trace.onEvent({
|
|
908
|
+
kind: TraceKinds.WeaponBind,
|
|
909
|
+
tick: world.tick,
|
|
910
|
+
attackerId: attacker.id,
|
|
911
|
+
targetId: target.id,
|
|
912
|
+
durationTicks: dur,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (res.shieldBlocked) {
|
|
919
|
+
const m = clampQ(q(0.35) - qMul(q(0.10), (defenceMul - SCALE.Q)), q(0.20), q(0.55));
|
|
920
|
+
mitigated = mulDiv(mitigated, m, SCALE.Q);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const armour = deriveArmourProfile(target.loadout, target.armourState);
|
|
924
|
+
const isEnergyWeapon = !!(wpn).energyType;
|
|
925
|
+
const CHANNEL_MASK = isEnergyWeapon ? (1 << DamageChannel.Energy) : (1 << DamageChannel.Kinetic);
|
|
926
|
+
const armourHit = armourCoversHit(world, regionCoverageQ(armour.coverageByRegion, region), attacker.id, target.id);
|
|
927
|
+
const protectedByArmour = armourHit && ((armour.protects & CHANNEL_MASK) !== 0);
|
|
928
|
+
let finalEnergy = mitigated;
|
|
929
|
+
if (protectedByArmour) {
|
|
930
|
+
if (isEnergyWeapon && armour.reflectivity > q(0)) {
|
|
931
|
+
// Phase 11C: reflective armour reduces energy weapon damage
|
|
932
|
+
finalEnergy = mulDiv(finalEnergy, SCALE.Q - armour.reflectivity, SCALE.Q);
|
|
933
|
+
}
|
|
934
|
+
else if (!isEnergyWeapon) {
|
|
935
|
+
finalEnergy = applyKineticArmourPenetration(mitigated, armour.resist_J, armour.protectedDamageMul);
|
|
936
|
+
}
|
|
937
|
+
// Phase 11C: decrement ablative armour resist
|
|
938
|
+
if (target.armourState) {
|
|
939
|
+
const armourItems = target.loadout.items.filter(it => it.kind === "armour");
|
|
940
|
+
for (const it of armourItems) {
|
|
941
|
+
if ((it).ablative && target.armourState.has(it.id)) {
|
|
942
|
+
const st = target.armourState.get(it.id);
|
|
943
|
+
st.resistRemaining_J = Math.max(0, st.resistRemaining_J - mitigated);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// Phase 26: effective mass for knockback — same formula used in impactEnergy_J
|
|
949
|
+
const kbBodyMass = mulDiv(attacker.attributes.morphology.mass_kg, wpn.strikeEffectiveMassFrac ?? q(0.10), SCALE.Q);
|
|
950
|
+
const kbMassEff = wpn.mass_kg + kbBodyMass;
|
|
951
|
+
impacts.push({
|
|
952
|
+
kind: "impact",
|
|
953
|
+
attackerId: attacker.id,
|
|
954
|
+
targetId: target.id,
|
|
955
|
+
region,
|
|
956
|
+
energy_J: finalEnergy,
|
|
957
|
+
protectedByArmour,
|
|
958
|
+
weaponId: wpn.id,
|
|
959
|
+
wpn,
|
|
960
|
+
blocked: res.blocked,
|
|
961
|
+
parried: res.parried,
|
|
962
|
+
hitQuality: clampQ(res.hitQuality, q(0.05), q(1.0)),
|
|
963
|
+
shieldBlocked,
|
|
964
|
+
massEff_kg: kbMassEff, // Phase 26
|
|
965
|
+
});
|
|
966
|
+
// Phase 2 extension: update swing momentum based on outcome
|
|
967
|
+
if (res.blocked || res.parried) {
|
|
968
|
+
// Blocked/parried — defender broke the rhythm
|
|
969
|
+
attacker.action.swingMomentumQ = q(0);
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
// Clean hit — rhythm maintained; capture intensity for next strike bonus
|
|
973
|
+
attacker.action.swingMomentumQ = clampQ(qMul(clampedIntensity, q(0.80)), q(0), SCALE.Q);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
export function clampSpeed(v, vmax_mps) {
|
|
977
|
+
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) };
|
|
978
|
+
}
|
|
979
|
+
export function scaleDirToSpeed(dirQ, speed_mps) {
|
|
980
|
+
return {
|
|
981
|
+
x: mulDiv(speed_mps, dirQ.x, SCALE.Q),
|
|
982
|
+
y: mulDiv(speed_mps, dirQ.y, SCALE.Q),
|
|
983
|
+
z: mulDiv(speed_mps, dirQ.z, SCALE.Q),
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
export function clampI32(x, lo, hi) {
|
|
987
|
+
return Math.max(lo, Math.min(hi, x));
|
|
988
|
+
}
|
|
989
|
+
/* ------------------ Grapple command dispatch (Phase 2A) ------------------ */
|
|
990
|
+
function resolveGrappleCommand(world, e, c, tuning, index, impacts, trace) {
|
|
991
|
+
const target = index.byId.get(c.targetId);
|
|
992
|
+
if (!target || target.injury.dead)
|
|
993
|
+
return;
|
|
994
|
+
const mode = c.mode ?? "grapple";
|
|
995
|
+
if (mode === "grapple") {
|
|
996
|
+
if (e.grapple.holdingTargetId === 0 || e.grapple.holdingTargetId !== c.targetId) {
|
|
997
|
+
// Attempt new grapple
|
|
998
|
+
resolveGrappleAttempt(world, e, target, c.intensity, tuning, impacts, trace);
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
// Already holding — tick trace (stepGrappleTick handles maintenance)
|
|
1002
|
+
trace.onEvent({
|
|
1003
|
+
kind: TraceKinds.Grapple,
|
|
1004
|
+
tick: world.tick, attackerId: e.id, targetId: target.id,
|
|
1005
|
+
phase: "tick", strengthQ: e.grapple.gripQ,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
else if (mode === "throw") {
|
|
1010
|
+
resolveGrappleThrow(world, e, target, c.intensity, tuning, impacts, trace);
|
|
1011
|
+
}
|
|
1012
|
+
else if (mode === "choke") {
|
|
1013
|
+
resolveGrappleChoke(e, target, c.intensity, tuning);
|
|
1014
|
+
}
|
|
1015
|
+
else if (mode === "jointLock") {
|
|
1016
|
+
resolveGrappleJointLock(world, e, target, c.intensity, tuning, impacts);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/* ------------------ Weapon bind breaking (Phase 2C) ------------------ */
|
|
1020
|
+
/**
|
|
1021
|
+
* Resolve an active breakBind command.
|
|
1022
|
+
*
|
|
1023
|
+
* The entity attempts to disengage their weapon from the bind.
|
|
1024
|
+
* A seeded torque contest (peakForce × momentArm) determines the outcome.
|
|
1025
|
+
* On success: bind clears for both; the loser takes a brief stun (q(0.05)).
|
|
1026
|
+
* On failure: bind persists; no effect.
|
|
1027
|
+
*
|
|
1028
|
+
* The bind always clears if the partner entity is dead or no longer present.
|
|
1029
|
+
*/
|
|
1030
|
+
function resolveBreakBind(world, e, intensity, index, trace) {
|
|
1031
|
+
if (e.action.weaponBindPartnerId === 0)
|
|
1032
|
+
return; // not bound
|
|
1033
|
+
const partner = index.byId.get(e.action.weaponBindPartnerId);
|
|
1034
|
+
if (!partner || partner.injury.dead) {
|
|
1035
|
+
// Partner gone — trivially clear
|
|
1036
|
+
e.action.weaponBindPartnerId = 0;
|
|
1037
|
+
e.action.weaponBindTicks = 0;
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const breakerWpn = findWeapon(e.loadout);
|
|
1041
|
+
const holderWpn = findWeapon(partner.loadout);
|
|
1042
|
+
const breakerArm = breakerWpn?.momentArm_m ?? Math.trunc(0.55 * SCALE.m);
|
|
1043
|
+
const holderArm = holderWpn?.momentArm_m ?? Math.trunc(0.55 * SCALE.m);
|
|
1044
|
+
// Win probability, scaled by command intensity (half-hearted attempt is less likely to succeed)
|
|
1045
|
+
const baseWinQ = breakBindContestQ(e.attributes.performance.peakForce_N, partner.attributes.performance.peakForce_N, breakerArm, holderArm);
|
|
1046
|
+
const winQ = clampQ(qMul(baseWinQ, intensity), q(0.05), q(0.95));
|
|
1047
|
+
const breakSeed = eventSeed(world.seed, world.tick, e.id, partner.id, 0xBB1D);
|
|
1048
|
+
const breakRoll = (breakSeed % SCALE.Q);
|
|
1049
|
+
if (breakRoll < winQ) {
|
|
1050
|
+
// Success: clear bind for both; loser takes a brief stun
|
|
1051
|
+
partner.condition.stunned = clampQ(partner.condition.stunned + q(0.05), 0, SCALE.Q);
|
|
1052
|
+
e.action.weaponBindPartnerId = 0;
|
|
1053
|
+
e.action.weaponBindTicks = 0;
|
|
1054
|
+
partner.action.weaponBindPartnerId = 0;
|
|
1055
|
+
partner.action.weaponBindTicks = 0;
|
|
1056
|
+
trace.onEvent({
|
|
1057
|
+
kind: TraceKinds.WeaponBindBreak,
|
|
1058
|
+
tick: world.tick,
|
|
1059
|
+
entityId: e.id,
|
|
1060
|
+
partnerId: partner.id,
|
|
1061
|
+
reason: "forced",
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
// On failure: bind persists; no trace (silence is the signal)
|
|
1065
|
+
}
|
|
1066
|
+
/* ------------------ Ranged combat (Phase 3) ------------------ */
|
|
1067
|
+
/** Integer square root of a BigInt (floor). Newton-Raphson. */
|
|
1068
|
+
function isqrtBig(n) {
|
|
1069
|
+
if (n <= 0n)
|
|
1070
|
+
return 0n;
|
|
1071
|
+
let r = n;
|
|
1072
|
+
let r1 = (r + 1n) >> 1n;
|
|
1073
|
+
while (r1 < r) {
|
|
1074
|
+
r = r1;
|
|
1075
|
+
r1 = (r + n / r) >> 1n;
|
|
1076
|
+
}
|
|
1077
|
+
return r;
|
|
1078
|
+
}
|
|
1079
|
+
function resolveShoot(world, shooter, cmd, tuning, index, impacts, trace, ctx) {
|
|
1080
|
+
// Phase 3 extension: aiming time accumulation — runs even during reload cooldown
|
|
1081
|
+
{
|
|
1082
|
+
const svx = shooter.velocity_mps.x;
|
|
1083
|
+
const svy = shooter.velocity_mps.y;
|
|
1084
|
+
const shooterVelMag = Math.trunc(Math.sqrt(svx * svx + svy * svy));
|
|
1085
|
+
if (cmd.targetId !== shooter.action.aimTargetId || shooterVelMag > AIM_STILL_THRESHOLD) {
|
|
1086
|
+
shooter.action.aimTicks = 0;
|
|
1087
|
+
shooter.action.aimTargetId = cmd.targetId;
|
|
1088
|
+
}
|
|
1089
|
+
else if (shooter.action.shootCooldownTicks > 0 && shooterVelMag <= AIM_STILL_THRESHOLD) {
|
|
1090
|
+
shooter.action.aimTicks = Math.min(shooter.action.aimTicks + 1, AIM_MAX_TICKS);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (shooter.action.shootCooldownTicks > 0)
|
|
1094
|
+
return;
|
|
1095
|
+
const wpn = findRangedWeapon(shooter.loadout, cmd.weaponId);
|
|
1096
|
+
if (!wpn)
|
|
1097
|
+
return;
|
|
1098
|
+
const target = index.byId.get(cmd.targetId);
|
|
1099
|
+
if (!target || target.injury.dead)
|
|
1100
|
+
return;
|
|
1101
|
+
// Phase 3 extension: ammo type override
|
|
1102
|
+
const ammo = cmd.ammoId ? wpn.ammo?.find(a => a.id === cmd.ammoId) : undefined;
|
|
1103
|
+
const projMass_kg = ammo?.projectileMass_kg ?? wpn.projectileMass_kg;
|
|
1104
|
+
const dragCoeff_perM = ammo?.dragCoeff_perM ?? wpn.dragCoeff_perM;
|
|
1105
|
+
const ammoDamage = ammo?.damage ?? wpn.damage;
|
|
1106
|
+
const launchMul = ammo?.launchEnergyMul ?? SCALE.Q;
|
|
1107
|
+
const funcA = deriveFunctionalState(shooter, tuning);
|
|
1108
|
+
if (!funcA.canAct)
|
|
1109
|
+
return;
|
|
1110
|
+
// 3D range (SCALE.m units); Phase 6: elevation differential lengthens the flight path.
|
|
1111
|
+
const dx = BigInt(target.position_m.x - shooter.position_m.x);
|
|
1112
|
+
const dy = BigInt(target.position_m.y - shooter.position_m.y);
|
|
1113
|
+
const cellSizeRS = ctx.cellSize_m ?? Math.trunc(4 * SCALE.m);
|
|
1114
|
+
const elevSh = elevationAtPosition(ctx.elevationGrid, cellSizeRS, shooter.position_m.x, shooter.position_m.y);
|
|
1115
|
+
const elevTg = elevationAtPosition(ctx.elevationGrid, cellSizeRS, target.position_m.x, target.position_m.y);
|
|
1116
|
+
const dz = BigInt(target.position_m.z - shooter.position_m.z + (elevTg - elevSh));
|
|
1117
|
+
const dist_m = Number(isqrtBig(dx * dx + dy * dy + dz * dz));
|
|
1118
|
+
const intensity = clampQ(cmd.intensity ?? q(1.0), q(0.1), q(1.0));
|
|
1119
|
+
// Determine launch energy
|
|
1120
|
+
// Phase 7: throwingWeapons.energyTransferMul boosts thrown weapon launch energy
|
|
1121
|
+
// Phase 3 extension: ammo launchEnergyMul applied for non-thrown weapons
|
|
1122
|
+
const launchEnergy = wpn.category === "thrown"
|
|
1123
|
+
? mulDiv(thrownLaunchEnergy_J(shooter.attributes.performance.peakPower_W), getSkill(shooter.skills, "throwingWeapons").energyTransferMul, SCALE.Q)
|
|
1124
|
+
: Math.trunc(qMul(wpn.launchEnergy_J, launchMul));
|
|
1125
|
+
// Energy at impact after drag (Phase 3 extension: use ammo drag coefficient)
|
|
1126
|
+
const energy_J = energyAtRange_J(launchEnergy, dragCoeff_perM, dist_m);
|
|
1127
|
+
// Phase 27: impact velocity from pre-armour energy (v = sqrt(2E/m))
|
|
1128
|
+
const v_impact_mps = projMass_kg > 0
|
|
1129
|
+
? Math.trunc(Math.sqrt(2 * energy_J * SCALE.kg / projMass_kg) * SCALE.mps)
|
|
1130
|
+
: 0;
|
|
1131
|
+
// Shooter's aim quality
|
|
1132
|
+
const ctrl = shooter.attributes.control;
|
|
1133
|
+
const adjDisp = adjustedDispersionQ(wpn.dispersionQ, ctrl.controlQuality, ctrl.fineControl, shooter.energy.fatigue, intensity);
|
|
1134
|
+
// Phase 7: rangedCombat.dispersionMul reduces effective dispersion (tighter grouping)
|
|
1135
|
+
const rangedSkill = getSkill(shooter.skills, "rangedCombat");
|
|
1136
|
+
const skillAdjDisp = qMul(adjDisp, rangedSkill.dispersionMul);
|
|
1137
|
+
let gRadius_m = groupingRadius_m(skillAdjDisp, dist_m);
|
|
1138
|
+
// Phase 3 extension: aiming time — reduce dispersion up to 50% at full aim
|
|
1139
|
+
const aimReduction = mulDiv(SCALE.Q - AIM_MIN_MUL, Math.min(shooter.action.aimTicks, AIM_MAX_TICKS), AIM_MAX_TICKS);
|
|
1140
|
+
const aimMul = (SCALE.Q - aimReduction);
|
|
1141
|
+
gRadius_m = Math.trunc(qMul(gRadius_m, aimMul));
|
|
1142
|
+
// Phase 3 extension: moving target penalty — lead error based on target velocity
|
|
1143
|
+
const tvx = target.velocity_mps.x;
|
|
1144
|
+
const tvy = target.velocity_mps.y;
|
|
1145
|
+
const targetVelMag = Math.trunc(Math.sqrt(tvx * tvx + tvy * tvy));
|
|
1146
|
+
const leadError_m = mulDiv(targetVelMag, 2_000, SCALE.mps); // 0.2s reaction × SCALE.m
|
|
1147
|
+
gRadius_m += leadError_m;
|
|
1148
|
+
// Phase 51: wind drift — crosswind component adds to grouping radius.
|
|
1149
|
+
if (ctx.weather?.wind && v_impact_mps > 0 && dist_m > 0) {
|
|
1150
|
+
gRadius_m += computeWindAimError(ctx.weather.wind, Number(dx), Number(dy), dist_m, v_impact_mps);
|
|
1151
|
+
}
|
|
1152
|
+
// Body half-width: ~20% of stature (≈0.35m for 1.75m human).
|
|
1153
|
+
// Phase 6: cover fraction reduces effective target width → harder to hit.
|
|
1154
|
+
const rawHalfWidth_m = mulDiv(shooter.attributes.morphology.stature_m, 2000, SCALE.Q);
|
|
1155
|
+
const cover = ctx.obstacleGrid
|
|
1156
|
+
? coverFractionAtPosition(ctx.obstacleGrid, cellSizeRS, target.position_m.x, target.position_m.y)
|
|
1157
|
+
: 0;
|
|
1158
|
+
const bodyHalfWidth_m = cover > 0
|
|
1159
|
+
? mulDiv(rawHalfWidth_m, Math.max(0, SCALE.Q - cover), SCALE.Q)
|
|
1160
|
+
: rawHalfWidth_m;
|
|
1161
|
+
// Deterministic hit roll (salt 0xD15A)
|
|
1162
|
+
const dispSeed = eventSeed(world.seed, world.tick, shooter.id, target.id, 0xD15A);
|
|
1163
|
+
const errorMag_m = gRadius_m > 0
|
|
1164
|
+
? mulDiv(dispSeed % SCALE.Q, gRadius_m, SCALE.Q)
|
|
1165
|
+
: 0;
|
|
1166
|
+
const hit = errorMag_m <= bodyHalfWidth_m;
|
|
1167
|
+
const suppressed = !hit && errorMag_m <= bodyHalfWidth_m * 3;
|
|
1168
|
+
// Deduct stamina and set reload cooldown regardless of hit
|
|
1169
|
+
shooter.energy.reserveEnergy_J = Math.max(0, shooter.energy.reserveEnergy_J - shootCost_J(wpn, intensity, shooter.attributes.performance.peakPower_W));
|
|
1170
|
+
shooter.action.aimTicks = 0; // Phase 3 extension: reset aim after each shot
|
|
1171
|
+
// Phase 17: magazine-based cooldown (modern/revolver firearms)
|
|
1172
|
+
if (wpn.magCapacity !== undefined) {
|
|
1173
|
+
if (shooter.action.roundsInMag === undefined) {
|
|
1174
|
+
shooter.action.roundsInMag = wpn.magCapacity; // first draw: full mag
|
|
1175
|
+
}
|
|
1176
|
+
shooter.action.roundsInMag -= 1;
|
|
1177
|
+
if (shooter.action.roundsInMag <= 0) {
|
|
1178
|
+
shooter.action.roundsInMag = wpn.magCapacity; // magazine empty — reload
|
|
1179
|
+
shooter.action.shootCooldownTicks = recycleTicks(wpn, TICK_HZ);
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
shooter.action.shootCooldownTicks =
|
|
1183
|
+
wpn.shotInterval_s !== undefined
|
|
1184
|
+
? Math.ceil((wpn.shotInterval_s * TICK_HZ) / SCALE.s)
|
|
1185
|
+
: recycleTicks(wpn, TICK_HZ);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
shooter.action.shootCooldownTicks = recycleTicks(wpn, TICK_HZ);
|
|
1190
|
+
}
|
|
1191
|
+
if (suppressed) {
|
|
1192
|
+
target.condition.suppressedTicks = Math.max(target.condition.suppressedTicks, 4);
|
|
1193
|
+
target.condition.suppressionFearMul = wpn.suppressionFearMul ?? SCALE.Q;
|
|
1194
|
+
}
|
|
1195
|
+
let hitRegion;
|
|
1196
|
+
if (hit && energy_J > 0) {
|
|
1197
|
+
const sideSeed = eventSeed(world.seed, world.tick, shooter.id, target.id, 0xD15B);
|
|
1198
|
+
const areaSeed = eventSeed(world.seed, world.tick, shooter.id, target.id, 0xD15C);
|
|
1199
|
+
const hitArea = target.bodyPlan
|
|
1200
|
+
? undefined
|
|
1201
|
+
: chooseArea((areaSeed % SCALE.Q));
|
|
1202
|
+
const sideBit = (sideSeed & 1);
|
|
1203
|
+
hitRegion = resolveTargetHitSegment(target, ((areaSeed >>> 8) % SCALE.Q), sideBit, hitArea);
|
|
1204
|
+
// Shield interposition — Phase 17: projectile bypass for flexible weapons
|
|
1205
|
+
const shield = findShield(target.loadout);
|
|
1206
|
+
const shieldSeed = eventSeed(world.seed, world.tick, shooter.id, target.id, 0xD15D);
|
|
1207
|
+
const projBypassQ = ("shieldBypassQ" in wpn) ? wpn.shieldBypassQ : 0;
|
|
1208
|
+
const effectiveCoverageQ = projBypassQ > 0
|
|
1209
|
+
? Math.max(0, qMul((shield)?.coverageQ ?? 0, SCALE.Q - projBypassQ))
|
|
1210
|
+
: ((shield)?.coverageQ ?? 0);
|
|
1211
|
+
const shieldHit = shield !== undefined &&
|
|
1212
|
+
((shieldSeed % SCALE.Q) < effectiveCoverageQ) &&
|
|
1213
|
+
shieldBlocksSegment(shield, target, hitRegion, hitArea);
|
|
1214
|
+
// Armour
|
|
1215
|
+
const armour = deriveArmourProfile(target.loadout, target.armourState);
|
|
1216
|
+
const isEnergyProjectile = !!(wpn).energyType;
|
|
1217
|
+
const PROJ_CHANNEL_MASK = isEnergyProjectile ? (1 << DamageChannel.Energy) : (1 << DamageChannel.Kinetic);
|
|
1218
|
+
const armourHit = armourCoversHit(world, regionCoverageQ(armour.coverageByRegion, hitRegion), shooter.id, target.id);
|
|
1219
|
+
const protectedByArmour = armourHit && ((armour.protects & PROJ_CHANNEL_MASK) !== 0);
|
|
1220
|
+
let finalEnergy = energy_J;
|
|
1221
|
+
if (shield && shieldHit) {
|
|
1222
|
+
const shieldResidual = Math.max(0, energy_J - (shield).blockResist_J);
|
|
1223
|
+
finalEnergy = mulDiv(shieldResidual, (shield).deflectQ ?? q(0.30), SCALE.Q);
|
|
1224
|
+
}
|
|
1225
|
+
if (protectedByArmour) {
|
|
1226
|
+
if (isEnergyProjectile && armour.reflectivity > q(0)) {
|
|
1227
|
+
finalEnergy = mulDiv(finalEnergy, SCALE.Q - armour.reflectivity, SCALE.Q);
|
|
1228
|
+
}
|
|
1229
|
+
else if (!isEnergyProjectile) {
|
|
1230
|
+
finalEnergy = applyKineticArmourPenetration(finalEnergy, armour.resist_J, armour.protectedDamageMul);
|
|
1231
|
+
}
|
|
1232
|
+
// Phase 11C: decrement ablative armour resist
|
|
1233
|
+
if (target.armourState) {
|
|
1234
|
+
const armourItems = target.loadout.items.filter(it => it.kind === "armour");
|
|
1235
|
+
for (const it of armourItems) {
|
|
1236
|
+
if ((it).ablative && target.armourState.has(it.id)) {
|
|
1237
|
+
const st = target.armourState.get(it.id);
|
|
1238
|
+
st.resistRemaining_J = Math.max(0, st.resistRemaining_J - energy_J);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
// Build a minimal Weapon proxy for applyImpactToInjury
|
|
1244
|
+
// Phase 3 extension: use ammo-overridden damage and projectile mass
|
|
1245
|
+
const wpnProxy = {
|
|
1246
|
+
id: wpn.id,
|
|
1247
|
+
kind: "weapon",
|
|
1248
|
+
name: wpn.name,
|
|
1249
|
+
mass_kg: projMass_kg,
|
|
1250
|
+
bulk: q(0),
|
|
1251
|
+
damage: ammoDamage,
|
|
1252
|
+
};
|
|
1253
|
+
impacts.push({
|
|
1254
|
+
kind: "impact",
|
|
1255
|
+
attackerId: shooter.id,
|
|
1256
|
+
targetId: target.id,
|
|
1257
|
+
region: hitRegion,
|
|
1258
|
+
energy_J: finalEnergy,
|
|
1259
|
+
protectedByArmour,
|
|
1260
|
+
weaponId: wpn.id,
|
|
1261
|
+
wpn: wpnProxy,
|
|
1262
|
+
blocked: shieldHit ?? false,
|
|
1263
|
+
parried: false,
|
|
1264
|
+
hitQuality: q(0.75),
|
|
1265
|
+
shieldBlocked: shieldHit ?? false,
|
|
1266
|
+
massEff_kg: projMass_kg, // Phase 26: projectile mass drives knockback
|
|
1267
|
+
v_impact_mps, // Phase 27: hydrostatic shock velocity
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
trace.onEvent({
|
|
1271
|
+
kind: TraceKinds.ProjectileHit,
|
|
1272
|
+
tick: world.tick,
|
|
1273
|
+
shooterId: shooter.id,
|
|
1274
|
+
targetId: target.id,
|
|
1275
|
+
weaponId: wpn.id, // Phase 18
|
|
1276
|
+
hit,
|
|
1277
|
+
...(hitRegion !== undefined ? { region: hitRegion } : {}),
|
|
1278
|
+
distance_m: dist_m,
|
|
1279
|
+
energyAtImpact_J: energy_J,
|
|
1280
|
+
suppressed,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
// ---- Phase 2B: per-action stamina cost helpers ----
|
|
1284
|
+
/**
|
|
1285
|
+
* Energy cost (J) for a melee strike at a given intensity.
|
|
1286
|
+
* Modelled as a ~40 ms burst at peak power:
|
|
1287
|
+
* cost = peakPower_W × 0.04 × intensity
|
|
1288
|
+
* Calibration: 1200 W × 0.04 = 48 J ≈ 50 J reference.
|
|
1289
|
+
* Minimum 5 J to avoid zero cost on very weak entities.
|
|
1290
|
+
*/
|
|
1291
|
+
function strikeCost_J(attacker, intensity) {
|
|
1292
|
+
const base = Math.max(20, mulDiv(attacker.attributes.performance.peakPower_W, 4, 100));
|
|
1293
|
+
return Math.max(5, mulDiv(base, intensity, SCALE.Q));
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Energy cost (J) of an active melee defence (block or parry).
|
|
1297
|
+
* Modelled as a ~25 ms burst at peak power:
|
|
1298
|
+
* cost = peakPower_W × 0.025
|
|
1299
|
+
* Calibration: 1200 W × 0.025 = 30 J reference.
|
|
1300
|
+
* Minimum 5 J.
|
|
1301
|
+
*/
|
|
1302
|
+
function defenceCost_J(defender) {
|
|
1303
|
+
return Math.max(5, mulDiv(defender.attributes.performance.peakPower_W, 25, 1000));
|
|
1304
|
+
}
|
|
1305
|
+
export function armourCoversHit(world, coverage, aId, bId) {
|
|
1306
|
+
if (coverage <= 0)
|
|
1307
|
+
return false;
|
|
1308
|
+
if (coverage >= SCALE.Q)
|
|
1309
|
+
return true;
|
|
1310
|
+
const seed = eventSeed(world.seed, world.tick, aId, bId, 0xC0DE);
|
|
1311
|
+
const roll = (seed % SCALE.Q);
|
|
1312
|
+
return roll < coverage;
|
|
1313
|
+
}
|
|
1314
|
+
function applyKineticArmourPenetration(energy_J, resist_J, postMul) {
|
|
1315
|
+
const remaining = Math.max(0, energy_J - Math.max(0, resist_J));
|
|
1316
|
+
return mulDiv(remaining, postMul, SCALE.Q);
|
|
1317
|
+
}
|
|
1318
|
+
function impactEnergy_J(attacker, wpn, relVel_mps) {
|
|
1319
|
+
const frac = wpn.strikeEffectiveMassFrac ?? q(0.10);
|
|
1320
|
+
const bodyEffMass = mulDiv(attacker.attributes.morphology.mass_kg, frac, SCALE.Q);
|
|
1321
|
+
const mEff = wpn.mass_kg + bodyEffMass;
|
|
1322
|
+
const vx = BigInt(relVel_mps.x);
|
|
1323
|
+
const vy = BigInt(relVel_mps.y);
|
|
1324
|
+
const vz = BigInt(relVel_mps.z);
|
|
1325
|
+
const v2 = vx * vx + vy * vy + vz * vz;
|
|
1326
|
+
const denom = 2n * BigInt(SCALE.kg) * BigInt(SCALE.mps) * BigInt(SCALE.mps);
|
|
1327
|
+
const num = BigInt(mEff) * v2;
|
|
1328
|
+
return Math.max(0, Number(num / denom));
|
|
1329
|
+
}
|
|
1330
|
+
export function applyImpactToInjury(target, wpn, energy_J, region, armoured, trace, tick, tempCavityMul_Q) {
|
|
1331
|
+
if (energy_J <= 0)
|
|
1332
|
+
return;
|
|
1333
|
+
// Determine region role: head → CNS-critical; limb → structural-priority; torso → default
|
|
1334
|
+
const areaSurf = q(1.0);
|
|
1335
|
+
let areaInt = q(1.0), areaStr = q(1.0);
|
|
1336
|
+
const seg = target.bodyPlan?.segments.find(s => s.id === region);
|
|
1337
|
+
if (seg) {
|
|
1338
|
+
if (seg.cnsRole === "central") {
|
|
1339
|
+
areaInt = q(1.25);
|
|
1340
|
+
areaStr = q(0.85);
|
|
1341
|
+
}
|
|
1342
|
+
else if (seg.locomotionRole === "primary" || seg.manipulationRole === "primary") {
|
|
1343
|
+
areaStr = q(1.20);
|
|
1344
|
+
areaInt = q(0.80);
|
|
1345
|
+
}
|
|
1346
|
+
else {
|
|
1347
|
+
areaInt = q(1.05);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
else {
|
|
1351
|
+
// Backward compat: humanoid string literals
|
|
1352
|
+
if (region === "head") {
|
|
1353
|
+
areaInt = q(1.25);
|
|
1354
|
+
areaStr = q(0.85);
|
|
1355
|
+
}
|
|
1356
|
+
else if (region === "leftArm" || region === "rightArm" || region === "leftLeg" || region === "rightLeg") {
|
|
1357
|
+
areaStr = q(1.20);
|
|
1358
|
+
areaInt = q(0.80);
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
areaInt = q(1.05);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
const armourShift = armoured ? q(0.75) : q(1.0);
|
|
1365
|
+
// Phase 8C: intrinsic exoskeleton armor — absorbed before damage channels are allocated
|
|
1366
|
+
if (seg?.intrinsicArmor_J !== undefined && seg.intrinsicArmor_J > 0) {
|
|
1367
|
+
energy_J = Math.max(0, energy_J - seg.intrinsicArmor_J);
|
|
1368
|
+
if (energy_J === 0)
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const SURF_J = 6930;
|
|
1372
|
+
const INT_J = 1000;
|
|
1373
|
+
const STR_J = 220;
|
|
1374
|
+
const energyQ = energy_J * SCALE.Q;
|
|
1375
|
+
const bias = wpn.damage.penetrationBias;
|
|
1376
|
+
const surfFrac = clampQ(wpn.damage.surfaceFrac - qMul(bias, q(0.12)), q(0.05), q(0.95));
|
|
1377
|
+
const intFrac = clampQ(wpn.damage.internalFrac + qMul(bias, q(0.12)), q(0.05), q(0.95));
|
|
1378
|
+
const surfInc = Math.min(SCALE.Q, mulDiv(Math.trunc(energyQ), qMul(qMul(surfFrac, areaSurf), armourShift), SURF_J * SCALE.Q));
|
|
1379
|
+
let intInc = Math.min(SCALE.Q, mulDiv(Math.trunc(energyQ), qMul(intFrac, areaInt), INT_J * SCALE.Q));
|
|
1380
|
+
// Phase 27: temporary cavity amplifies internal damage for high-velocity projectiles
|
|
1381
|
+
if (tempCavityMul_Q && tempCavityMul_Q > SCALE.Q) {
|
|
1382
|
+
intInc = Math.min(SCALE.Q, mulDiv(intInc, tempCavityMul_Q, SCALE.Q));
|
|
1383
|
+
}
|
|
1384
|
+
let strInc = Math.min(SCALE.Q, mulDiv(Math.trunc(energyQ), qMul(wpn.damage.structuralFrac, areaStr), STR_J * SCALE.Q));
|
|
1385
|
+
// Phase 8B: joint vulnerability — joints take extra structural damage from kinetic impacts
|
|
1386
|
+
if (seg?.isJoint && seg.jointDamageMultiplier) {
|
|
1387
|
+
strInc = Math.trunc(strInc * seg.jointDamageMultiplier / SCALE.Q);
|
|
1388
|
+
}
|
|
1389
|
+
// Phase 8B: molting softening — segments currently softening take reduced structural damage (×q(0.70))
|
|
1390
|
+
if (target.molting?.active && target.molting.softeningSegments.includes(region)) {
|
|
1391
|
+
strInc = qMul(strInc, q(0.70));
|
|
1392
|
+
}
|
|
1393
|
+
const regionState = target.injury.byRegion[region];
|
|
1394
|
+
if (!regionState)
|
|
1395
|
+
return;
|
|
1396
|
+
// Phase 8B: exoskeleton shell breach — below threshold all damage routes to structural only
|
|
1397
|
+
if (seg?.structureType === "exoskeleton" && seg.breachThreshold !== undefined) {
|
|
1398
|
+
if (regionState.structuralDamage < seg.breachThreshold) {
|
|
1399
|
+
const totalInc = surfInc + intInc + strInc;
|
|
1400
|
+
regionState.structuralDamage = clampQ(regionState.structuralDamage + totalInc, 0, SCALE.Q);
|
|
1401
|
+
// Phase 9: fracture detection still applies during shell build-up
|
|
1402
|
+
if (!regionState.fractured && regionState.structuralDamage >= FRACTURE_THRESHOLD) {
|
|
1403
|
+
regionState.fractured = true;
|
|
1404
|
+
trace.onEvent({ kind: TraceKinds.Fracture, tick, entityId: target.id, region });
|
|
1405
|
+
}
|
|
1406
|
+
return; // no bleed / shock until shell is breached
|
|
1407
|
+
}
|
|
1408
|
+
// shell already breached — fall through to normal three-channel split
|
|
1409
|
+
}
|
|
1410
|
+
regionState.surfaceDamage = clampQ(regionState.surfaceDamage + surfInc, 0, SCALE.Q);
|
|
1411
|
+
regionState.internalDamage = clampQ(regionState.internalDamage + intInc, 0, SCALE.Q);
|
|
1412
|
+
regionState.structuralDamage = clampQ(regionState.structuralDamage + strInc, 0, SCALE.Q);
|
|
1413
|
+
const bleedBase = clampQ(((surfInc + intInc) >>> 1), 0, SCALE.Q);
|
|
1414
|
+
const bleedDelta = qMul(bleedBase, wpn.damage.bleedFactor);
|
|
1415
|
+
const BLEED_SCALE = q(0.004);
|
|
1416
|
+
regionState.bleedingRate = clampQ(regionState.bleedingRate + qMul(bleedDelta, BLEED_SCALE), 0, q(1.0));
|
|
1417
|
+
const SHOCK_SPIKE = q(0.010);
|
|
1418
|
+
target.injury.shock = clampQ(target.injury.shock + qMul(bleedBase, SHOCK_SPIKE), 0, SCALE.Q);
|
|
1419
|
+
// Phase 9: fracture detection
|
|
1420
|
+
if (!regionState.fractured && regionState.structuralDamage >= FRACTURE_THRESHOLD) {
|
|
1421
|
+
regionState.fractured = true;
|
|
1422
|
+
trace.onEvent({ kind: TraceKinds.Fracture, tick, entityId: target.id, region });
|
|
1423
|
+
}
|
|
1424
|
+
// Phase 9: permanent damage floor — once structural is very high, a floor is set
|
|
1425
|
+
const PERMANENT_THRESHOLD = q(0.90);
|
|
1426
|
+
const PERMANENT_FLOOR_MUL = q(0.75);
|
|
1427
|
+
if (regionState.structuralDamage >= PERMANENT_THRESHOLD) {
|
|
1428
|
+
const newFloor = qMul(regionState.structuralDamage, PERMANENT_FLOOR_MUL);
|
|
1429
|
+
if (newFloor > regionState.permanentDamage)
|
|
1430
|
+
regionState.permanentDamage = newFloor;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
// 15% of kinetic energy transmitted after muscle absorption (85% absorbed)
|
|
1434
|
+
const FALL_MUSCLE_ABSORB = q(0.85);
|
|
1435
|
+
// Weapon-like damage profiles reused by the injury pipeline
|
|
1436
|
+
const FALL_WEAPON = {
|
|
1437
|
+
id: "fall", kind: "weapon", name: "Fall", mass_kg: 0, bulk: q(0),
|
|
1438
|
+
damage: { penetrationBias: q(0), surfaceFrac: q(0.10), internalFrac: q(0.20), structuralFrac: q(0.70), bleedFactor: q(0.05) },
|
|
1439
|
+
};
|
|
1440
|
+
const BLAST_WEAPON = {
|
|
1441
|
+
id: "blast", kind: "weapon", name: "Blast Wave", mass_kg: 0, bulk: q(0),
|
|
1442
|
+
damage: { penetrationBias: q(0), surfaceFrac: q(0.15), internalFrac: q(0.55), structuralFrac: q(0.30), bleedFactor: q(0.25) },
|
|
1443
|
+
};
|
|
1444
|
+
const FRAG_WEAPON = {
|
|
1445
|
+
id: "frag", kind: "weapon", name: "Fragment", mass_kg: 0, bulk: q(0),
|
|
1446
|
+
damage: { penetrationBias: q(0.60), surfaceFrac: q(0.25), internalFrac: q(0.40), structuralFrac: q(0.35), bleedFactor: q(0.60) },
|
|
1447
|
+
};
|
|
1448
|
+
/**
|
|
1449
|
+
* Apply fall damage to a single entity (Phase 10).
|
|
1450
|
+
* KE = mass × g × height; 85% absorbed by controlled landing.
|
|
1451
|
+
* Remaining 15% distributed: locomotion-primary regions × 70%, others × 30%.
|
|
1452
|
+
* Any fall ≥ 1 m forces prone.
|
|
1453
|
+
*/
|
|
1454
|
+
export function applyFallDamage(world, entityId, height_m, tick, trace) {
|
|
1455
|
+
const e = world.entities.find(x => x.id === entityId);
|
|
1456
|
+
if (!e || e.injury.dead)
|
|
1457
|
+
return;
|
|
1458
|
+
// KE_J = (mass_kg / SCALE.kg) × 9.81 × (height_m / SCALE.m)
|
|
1459
|
+
// = mass_kg × 981 × height_m / (SCALE.kg × 100 × SCALE.m)
|
|
1460
|
+
const G_X100 = 981;
|
|
1461
|
+
const keFull = Number((BigInt(e.attributes.morphology.mass_kg) * BigInt(G_X100) * BigInt(height_m)) /
|
|
1462
|
+
BigInt(SCALE.kg * 100 * SCALE.m));
|
|
1463
|
+
if (keFull <= 0)
|
|
1464
|
+
return;
|
|
1465
|
+
// 15% transmitted after muscle absorption
|
|
1466
|
+
const keEffective = mulDiv(keFull, SCALE.Q - FALL_MUSCLE_ABSORB, SCALE.Q);
|
|
1467
|
+
if (keEffective <= 0)
|
|
1468
|
+
return;
|
|
1469
|
+
// Any fall ≥ 1 m forces prone
|
|
1470
|
+
if (height_m >= SCALE.m)
|
|
1471
|
+
e.condition.prone = true;
|
|
1472
|
+
const plan = e.bodyPlan;
|
|
1473
|
+
if (plan) {
|
|
1474
|
+
// Body-plan-aware: locomotion-primary 70%, remainder 30%
|
|
1475
|
+
const primIds = plan.segments.filter(s => s.locomotionRole === "primary").map(s => s.id);
|
|
1476
|
+
const otherIds = plan.segments.filter(s => s.locomotionRole !== "primary").map(s => s.id);
|
|
1477
|
+
const primShare = primIds.length > 0 ? Math.trunc((keEffective * 7) / 10) : 0;
|
|
1478
|
+
const otherShare = otherIds.length > 0 ? keEffective - primShare : 0;
|
|
1479
|
+
const perPrim = primIds.length > 0 ? Math.trunc(primShare / primIds.length) : 0;
|
|
1480
|
+
const perOther = otherIds.length > 0 ? Math.trunc(otherShare / otherIds.length) : 0;
|
|
1481
|
+
for (const rid of primIds)
|
|
1482
|
+
if (perPrim > 0)
|
|
1483
|
+
applyImpactToInjury(e, FALL_WEAPON, perPrim, rid, false, trace, tick);
|
|
1484
|
+
for (const rid of otherIds)
|
|
1485
|
+
if (perOther > 0)
|
|
1486
|
+
applyImpactToInjury(e, FALL_WEAPON, perOther, rid, false, trace, tick);
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
// Humanoid fallback: legs 35% each, arms 10% each, torso 5%, head 5%
|
|
1490
|
+
const regions = [
|
|
1491
|
+
["leftLeg", 35], ["rightLeg", 35],
|
|
1492
|
+
["leftArm", 10], ["rightArm", 10],
|
|
1493
|
+
["torso", 5], ["head", 5],
|
|
1494
|
+
];
|
|
1495
|
+
for (const [region, pct] of regions) {
|
|
1496
|
+
const energy = Math.trunc((keEffective * pct) / 100);
|
|
1497
|
+
if (energy > 0)
|
|
1498
|
+
applyImpactToInjury(e, FALL_WEAPON, energy, region, false, trace, tick);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
// Multiplier for blast throw velocity: SCALE.mps × SCALE.kg ÷ 10 (empirical damping).
|
|
1503
|
+
// Produces ~0.67 m/s per 500 J on a 75 kg entity; capped at 10 m/s.
|
|
1504
|
+
const BLAST_THROW_MUL = Math.trunc(SCALE.mps * SCALE.kg / 10); // 1_000_000
|
|
1505
|
+
/**
|
|
1506
|
+
* Apply a point-source explosion to all living entities within the blast radius (Phase 10).
|
|
1507
|
+
*
|
|
1508
|
+
* Features:
|
|
1509
|
+
* - Blast wave delivered to torso; entities facing away take −30% blast damage.
|
|
1510
|
+
* - Stochastic fragment hits to random regions.
|
|
1511
|
+
* - Blast throw: entities are pushed outward; velocity proportional to blast energy / mass.
|
|
1512
|
+
* - Emits a BlastHit trace event for each affected entity.
|
|
1513
|
+
*/
|
|
1514
|
+
export function applyExplosion(world, origin, spec, tick, trace) {
|
|
1515
|
+
for (const e of world.entities) {
|
|
1516
|
+
if (e.injury.dead)
|
|
1517
|
+
continue;
|
|
1518
|
+
const dx = e.position_m.x - origin.x;
|
|
1519
|
+
const dy = e.position_m.y - origin.y;
|
|
1520
|
+
const distSq = dx * dx + dy * dy;
|
|
1521
|
+
const blastFracQ = blastEnergyFracQ(spec, distSq);
|
|
1522
|
+
const fragExpected = fragmentsExpected(spec, distSq);
|
|
1523
|
+
if (blastFracQ <= 0 && fragExpected <= 0)
|
|
1524
|
+
continue;
|
|
1525
|
+
// Direction from epicentre to entity (used for facing check and throw).
|
|
1526
|
+
// normaliseDirCheapQ handles zero-vector gracefully (returns all-zero).
|
|
1527
|
+
const blastDir = normaliseDirCheapQ(vSub(e.position_m, origin));
|
|
1528
|
+
// Blast wave — delivered to torso (or best equivalent region)
|
|
1529
|
+
let blastDelivered = 0;
|
|
1530
|
+
if (blastFracQ > 0) {
|
|
1531
|
+
blastDelivered = mulDiv(spec.blastEnergy_J, blastFracQ, SCALE.Q);
|
|
1532
|
+
// Facing check: entity facing away from blast has less frontal exposure → −30%.
|
|
1533
|
+
// dot > 0 means facingDir and blastDir roughly align (entity turned away from blast).
|
|
1534
|
+
if (blastDelivered > 0) {
|
|
1535
|
+
const facingDot = dotDirQ(e.action.facingDirQ, blastDir);
|
|
1536
|
+
if (facingDot > 0) {
|
|
1537
|
+
blastDelivered = mulDiv(blastDelivered, q(0.70), SCALE.Q);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
const blastRegion = e.bodyPlan
|
|
1541
|
+
? (e.bodyPlan.segments.find(s => s.locomotionRole === "none" && s.cnsRole !== "central")?.id
|
|
1542
|
+
?? e.bodyPlan.segments[0]?.id ?? "torso")
|
|
1543
|
+
: "torso";
|
|
1544
|
+
if (blastDelivered > 0 && e.injury.byRegion[blastRegion]) {
|
|
1545
|
+
applyImpactToInjury(e, BLAST_WEAPON, blastDelivered, blastRegion, false, trace, tick);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
// Fragment hits — stochastic count, fractional part resolved by RNG
|
|
1549
|
+
let fragHits = 0;
|
|
1550
|
+
if (fragExpected > 0) {
|
|
1551
|
+
const countSeed = eventSeed(world.seed, tick, e.id, 0, 0xBEA5);
|
|
1552
|
+
const rng = makeRng(countSeed, SCALE.Q);
|
|
1553
|
+
const fragFrac = fragExpected - Math.trunc(fragExpected);
|
|
1554
|
+
const fragCount = Math.trunc(fragExpected) + (rng.q01() < Math.trunc(fragFrac * SCALE.Q) ? 1 : 0);
|
|
1555
|
+
const fragKeJ = fragmentKineticEnergy(spec, distSq);
|
|
1556
|
+
for (let f = 0; f < fragCount; f++) {
|
|
1557
|
+
if (fragKeJ <= 0)
|
|
1558
|
+
break;
|
|
1559
|
+
const fragRegSeed = eventSeed(world.seed, tick, e.id, f, 0xF4A6);
|
|
1560
|
+
const fragRng = makeRng(fragRegSeed, SCALE.Q);
|
|
1561
|
+
let fragRegion;
|
|
1562
|
+
if (e.bodyPlan) {
|
|
1563
|
+
fragRegion = resolveHitSegment(e.bodyPlan, fragRng.q01());
|
|
1564
|
+
}
|
|
1565
|
+
else {
|
|
1566
|
+
const area = chooseArea(fragRng.q01());
|
|
1567
|
+
const sideBit = (fragRegSeed & 1);
|
|
1568
|
+
fragRegion = regionFromHit(area, sideBit);
|
|
1569
|
+
}
|
|
1570
|
+
if (fragRegion && e.injury.byRegion[fragRegion]) {
|
|
1571
|
+
applyImpactToInjury(e, FRAG_WEAPON, fragKeJ, fragRegion, false, trace, tick);
|
|
1572
|
+
fragHits++;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
// Phase 10C: flash blindness — entities within inner 40% of blast radius are temporarily blinded
|
|
1577
|
+
if (blastFracQ > 0) {
|
|
1578
|
+
const FLASH_RADIUS_FRAC = q(0.40);
|
|
1579
|
+
const flashRadiusSq = mulDiv(spec.radius_m * spec.radius_m, FLASH_RADIUS_FRAC, SCALE.Q);
|
|
1580
|
+
if (distSq < flashRadiusSq) {
|
|
1581
|
+
const blindDuration = Math.max(10, Math.trunc(20 * (1 - distSq / flashRadiusSq)));
|
|
1582
|
+
e.condition.blindTicks = Math.max(e.condition.blindTicks, blindDuration);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
// Blast throw: push entity outward from epicentre.
|
|
1586
|
+
// throwVel_units = clamp(blastDelivered × BLAST_THROW_MUL / mass_kg, 0, 10 m/s)
|
|
1587
|
+
if (blastDelivered > 0 && distSq > 0) {
|
|
1588
|
+
const mass_kg = e.attributes.morphology.mass_kg;
|
|
1589
|
+
const throwVel = Math.min(to.mps(10), Number(BigInt(blastDelivered) * BigInt(BLAST_THROW_MUL) / BigInt(Math.max(1, mass_kg))));
|
|
1590
|
+
if (throwVel > 0) {
|
|
1591
|
+
const throwVec = {
|
|
1592
|
+
x: Math.trunc(blastDir.x * throwVel / SCALE.Q),
|
|
1593
|
+
y: Math.trunc(blastDir.y * throwVel / SCALE.Q),
|
|
1594
|
+
z: 0,
|
|
1595
|
+
};
|
|
1596
|
+
e.velocity_mps = vAdd(e.velocity_mps, throwVec);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
trace.onEvent({
|
|
1600
|
+
kind: TraceKinds.BlastHit,
|
|
1601
|
+
tick,
|
|
1602
|
+
entityId: e.id,
|
|
1603
|
+
blastEnergy_J: blastDelivered,
|
|
1604
|
+
fragHits,
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
/* ── Phase 9: medical treatment ──────────────────────────────────────────── */
|
|
1609
|
+
function resolveTreat(world, treater, cmd, index, trace, ctx) {
|
|
1610
|
+
if (treater.injury.dead)
|
|
1611
|
+
return;
|
|
1612
|
+
const target = index.byId.get(cmd.targetId);
|
|
1613
|
+
if (!target || target.injury.dead)
|
|
1614
|
+
return;
|
|
1615
|
+
// Treater must be within 2 m of target (physical contact required)
|
|
1616
|
+
const dx = target.position_m.x - treater.position_m.x;
|
|
1617
|
+
const dy = target.position_m.y - treater.position_m.y;
|
|
1618
|
+
const dist2 = dx * dx + dy * dy;
|
|
1619
|
+
const MAX_TREAT_DIST_m = Math.trunc(2 * SCALE.m);
|
|
1620
|
+
if (dist2 > MAX_TREAT_DIST_m * MAX_TREAT_DIST_m)
|
|
1621
|
+
return;
|
|
1622
|
+
// Check equipment tier meets minimum requirement
|
|
1623
|
+
const tierRank = TIER_RANK[cmd.tier];
|
|
1624
|
+
const actionMinRank = TIER_RANK[ACTION_MIN_TIER[cmd.action]];
|
|
1625
|
+
if (tierRank < actionMinRank)
|
|
1626
|
+
return;
|
|
1627
|
+
// Phase 11: technology gate — check if the tier's required capability is available
|
|
1628
|
+
const techReq = TIER_TECH_REQ[cmd.tier];
|
|
1629
|
+
if (techReq && ctx.techCtx && !ctx.techCtx.available.has(techReq))
|
|
1630
|
+
return;
|
|
1631
|
+
const tierMul = TIER_MUL[cmd.tier];
|
|
1632
|
+
const medSkill = getSkill(treater.skills, "medical");
|
|
1633
|
+
// effectMul = tierMul × (treatmentRateMul / SCALE.Q)
|
|
1634
|
+
// treatmentRateMul at q(1.0) = SCALE.Q baseline gives exactly tierMul
|
|
1635
|
+
const effectMul = mulDiv(tierMul, medSkill.treatmentRateMul, SCALE.Q);
|
|
1636
|
+
if (cmd.action === "tourniquet") {
|
|
1637
|
+
const reg = cmd.regionId ? target.injury.byRegion[cmd.regionId] : undefined;
|
|
1638
|
+
if (!reg)
|
|
1639
|
+
return;
|
|
1640
|
+
reg.bleedingRate = q(0);
|
|
1641
|
+
reg.bleedDuration_ticks = 0;
|
|
1642
|
+
// Slight shock from painful application
|
|
1643
|
+
target.injury.shock = clampQ(target.injury.shock + q(0.005), 0, SCALE.Q);
|
|
1644
|
+
}
|
|
1645
|
+
else if (cmd.action === "bandage") {
|
|
1646
|
+
const reg = cmd.regionId ? target.injury.byRegion[cmd.regionId] : undefined;
|
|
1647
|
+
if (!reg)
|
|
1648
|
+
return;
|
|
1649
|
+
const BASE_BANDAGE_RATE = q(0.0050);
|
|
1650
|
+
const reduction = mulDiv(BASE_BANDAGE_RATE, effectMul, SCALE.Q);
|
|
1651
|
+
reg.bleedingRate = clampQ((reg.bleedingRate - reduction), q(0), q(1.0));
|
|
1652
|
+
}
|
|
1653
|
+
else if (cmd.action === "surgery") {
|
|
1654
|
+
const reg = cmd.regionId ? target.injury.byRegion[cmd.regionId] : undefined;
|
|
1655
|
+
if (!reg)
|
|
1656
|
+
return;
|
|
1657
|
+
const BASE_SURGERY_RATE = q(0.0020);
|
|
1658
|
+
const BASE_BANDAGE_RATE = q(0.0050);
|
|
1659
|
+
const strReduction = mulDiv(BASE_SURGERY_RATE, effectMul, SCALE.Q);
|
|
1660
|
+
const newStr = clampQ((reg.structuralDamage - strReduction), reg.permanentDamage, // cannot heal below permanent floor
|
|
1661
|
+
SCALE.Q);
|
|
1662
|
+
reg.structuralDamage = newStr;
|
|
1663
|
+
// Surgery also stops active bleeding
|
|
1664
|
+
const bleedReduction = mulDiv(BASE_BANDAGE_RATE, effectMul, SCALE.Q);
|
|
1665
|
+
reg.bleedingRate = clampQ((reg.bleedingRate - bleedReduction), q(0), q(1.0));
|
|
1666
|
+
// Clear fracture if structural drops below threshold
|
|
1667
|
+
if (reg.fractured && reg.structuralDamage < FRACTURE_THRESHOLD) {
|
|
1668
|
+
reg.fractured = false;
|
|
1669
|
+
}
|
|
1670
|
+
// Clear infection at surgicalKit tier or above
|
|
1671
|
+
if (reg.infectedTick >= 0 && tierRank >= TIER_RANK["surgicalKit"]) {
|
|
1672
|
+
reg.infectedTick = -1;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
else if (cmd.action === "fluidReplacement") {
|
|
1676
|
+
const BASE_FLUID_RATE = q(0.0050);
|
|
1677
|
+
const recovery = mulDiv(BASE_FLUID_RATE, effectMul, SCALE.Q);
|
|
1678
|
+
target.injury.fluidLoss = clampQ((target.injury.fluidLoss - recovery), q(0), SCALE.Q);
|
|
1679
|
+
// Fluid restoration also reduces shock slightly
|
|
1680
|
+
target.injury.shock = clampQ((target.injury.shock - q(0.002)), q(0), SCALE.Q);
|
|
1681
|
+
}
|
|
1682
|
+
trace.onEvent({
|
|
1683
|
+
kind: TraceKinds.TreatmentApplied,
|
|
1684
|
+
tick: world.tick,
|
|
1685
|
+
treaterId: treater.id,
|
|
1686
|
+
targetId: target.id,
|
|
1687
|
+
action: cmd.action,
|
|
1688
|
+
...(cmd.regionId !== undefined ? { regionId: cmd.regionId } : {}),
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
/* ── Phase 12: capability sources and effects ─────────────────────────────── */
|
|
1692
|
+
/**
|
|
1693
|
+
* Synthetic Weapon objects for capability impact payloads, keyed by DamageChannel.
|
|
1694
|
+
* The engine cannot distinguish these from weapon impacts — same applyImpactToInjury path.
|
|
1695
|
+
*/
|
|
1696
|
+
const CAPABILITY_CHANNEL_WEAPONS = {
|
|
1697
|
+
[DamageChannel.Kinetic]: { id: "cap_kinetic", kind: "weapon", name: "Kinetic Force", mass_kg: 0, bulk: q(0), damage: { penetrationBias: q(0.30), surfaceFrac: q(0.30), internalFrac: q(0.30), structuralFrac: q(0.40), bleedFactor: q(0.30) } },
|
|
1698
|
+
[DamageChannel.Thermal]: { id: "cap_thermal", kind: "weapon", name: "Thermal", mass_kg: 0, bulk: q(0), damage: { penetrationBias: q(0), surfaceFrac: q(0.40), internalFrac: q(0.50), structuralFrac: q(0.10), bleedFactor: q(0.10) } },
|
|
1699
|
+
[DamageChannel.Electrical]: { id: "cap_elec", kind: "weapon", name: "Electrical", mass_kg: 0, bulk: q(0), damage: { penetrationBias: q(0.20), surfaceFrac: q(0.20), internalFrac: q(0.60), structuralFrac: q(0.20), bleedFactor: q(0.05) } },
|
|
1700
|
+
[DamageChannel.Chemical]: { id: "cap_chem", kind: "weapon", name: "Chemical", mass_kg: 0, bulk: q(0), damage: { penetrationBias: q(0), surfaceFrac: q(0.45), internalFrac: q(0.45), structuralFrac: q(0.10), bleedFactor: q(0.20) } },
|
|
1701
|
+
[DamageChannel.Radiation]: { id: "cap_rad", kind: "weapon", name: "Radiation", mass_kg: 0, bulk: q(0), damage: { penetrationBias: q(0), surfaceFrac: q(0.05), internalFrac: q(0.90), structuralFrac: q(0.05), bleedFactor: q(0.05) } },
|
|
1702
|
+
};
|
|
1703
|
+
const CAPABILITY_WEAPON_DEFAULT = {
|
|
1704
|
+
id: "cap_generic", kind: "weapon", name: "Capability", mass_kg: 0, bulk: q(0),
|
|
1705
|
+
damage: { penetrationBias: q(0.10), surfaceFrac: q(0.30), internalFrac: q(0.40), structuralFrac: q(0.30), bleedFactor: q(0.20) },
|
|
1706
|
+
};
|
|
1707
|
+
/**
|
|
1708
|
+
* Apply a single EffectPayload to target on behalf of actor.
|
|
1709
|
+
* All payloads route to existing engine primitives — the engine does not distinguish
|
|
1710
|
+
* magical from technological effects at this level.
|
|
1711
|
+
*/
|
|
1712
|
+
export function applyPayload(world, actor, target, payload, trace, tick, effectId) {
|
|
1713
|
+
switch (payload.kind) {
|
|
1714
|
+
case "impact": {
|
|
1715
|
+
const hitRegion = resolveCapabilityHitSegment(world, tick, actor, target, 0xCAB1);
|
|
1716
|
+
if (!target.injury.byRegion[hitRegion])
|
|
1717
|
+
break;
|
|
1718
|
+
let effectiveEnergy = payload.spec.energy_J;
|
|
1719
|
+
if ((target.condition.shieldReserve_J ?? 0) > 0 &&
|
|
1720
|
+
target.condition.shieldExpiry_tick !== undefined &&
|
|
1721
|
+
tick <= target.condition.shieldExpiry_tick) {
|
|
1722
|
+
const absorbed = Math.min(target.condition.shieldReserve_J, effectiveEnergy);
|
|
1723
|
+
target.condition.shieldReserve_J -= absorbed;
|
|
1724
|
+
effectiveEnergy -= absorbed;
|
|
1725
|
+
}
|
|
1726
|
+
if (effectiveEnergy > 0) {
|
|
1727
|
+
const wpn = CAPABILITY_CHANNEL_WEAPONS[payload.spec.channel] ?? CAPABILITY_WEAPON_DEFAULT;
|
|
1728
|
+
applyImpactToInjury(target, wpn, effectiveEnergy, hitRegion, false, trace, tick);
|
|
1729
|
+
}
|
|
1730
|
+
break;
|
|
1731
|
+
}
|
|
1732
|
+
case "treatment": {
|
|
1733
|
+
// Direct healing — bypasses range/equipment checks; capability source IS the tool.
|
|
1734
|
+
const BASE_CAP_HEAL = q(0.0050);
|
|
1735
|
+
const healRate = qMul(BASE_CAP_HEAL, payload.rateMul);
|
|
1736
|
+
for (const reg of Object.values(target.injury.byRegion)) {
|
|
1737
|
+
if (reg.bleedingRate > 0) {
|
|
1738
|
+
reg.bleedingRate = clampQ((reg.bleedingRate - healRate), 0, SCALE.Q);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
target.injury.shock = clampQ((target.injury.shock - qMul(q(0.01), payload.rateMul)), 0, SCALE.Q);
|
|
1742
|
+
break;
|
|
1743
|
+
}
|
|
1744
|
+
case "armourLayer": {
|
|
1745
|
+
// Accumulate into shield reserve; extend or set expiry.
|
|
1746
|
+
target.condition.shieldReserve_J = (target.condition.shieldReserve_J ?? 0) + payload.resist_J;
|
|
1747
|
+
const newExpiry = tick + payload.duration_ticks;
|
|
1748
|
+
target.condition.shieldExpiry_tick = Math.max(target.condition.shieldExpiry_tick ?? 0, newExpiry);
|
|
1749
|
+
break;
|
|
1750
|
+
}
|
|
1751
|
+
case "velocity": {
|
|
1752
|
+
target.velocity_mps = vAdd(target.velocity_mps, payload.delta_mps);
|
|
1753
|
+
break;
|
|
1754
|
+
}
|
|
1755
|
+
case "substance": {
|
|
1756
|
+
if (!target.substances)
|
|
1757
|
+
target.substances = [];
|
|
1758
|
+
target.substances.push({ ...payload.substance });
|
|
1759
|
+
break;
|
|
1760
|
+
}
|
|
1761
|
+
case "structuralRepair": {
|
|
1762
|
+
const seg = target.injury.byRegion[payload.region];
|
|
1763
|
+
if (seg) {
|
|
1764
|
+
const floor = seg.permanentDamage ?? 0;
|
|
1765
|
+
const repaired = Math.max(floor, seg.structuralDamage - payload.amount);
|
|
1766
|
+
seg.structuralDamage = clampQ(repaired, 0, SCALE.Q);
|
|
1767
|
+
}
|
|
1768
|
+
break;
|
|
1769
|
+
}
|
|
1770
|
+
case "fieldEffect": {
|
|
1771
|
+
if (!world.activeFieldEffects)
|
|
1772
|
+
world.activeFieldEffects = [];
|
|
1773
|
+
const fe = {
|
|
1774
|
+
...payload.spec,
|
|
1775
|
+
id: `${actor.id}_${effectId}_${tick}`,
|
|
1776
|
+
origin: { x: actor.position_m.x, y: actor.position_m.y, z: actor.position_m.z },
|
|
1777
|
+
placedByEntityId: actor.id,
|
|
1778
|
+
};
|
|
1779
|
+
world.activeFieldEffects.push(fe);
|
|
1780
|
+
break;
|
|
1781
|
+
}
|
|
1782
|
+
case "weaponImpact": {
|
|
1783
|
+
// Phase 28: custom damage profile (fire breath, napalm, chemical burn, etc.)
|
|
1784
|
+
// Bypasses armour like other capability impacts; damage mix is determined by profile.
|
|
1785
|
+
const wpn = {
|
|
1786
|
+
id: "cap_weaponimpact",
|
|
1787
|
+
kind: "weapon",
|
|
1788
|
+
name: "WeaponImpact",
|
|
1789
|
+
mass_kg: 0,
|
|
1790
|
+
bulk: q(0),
|
|
1791
|
+
damage: payload.profile,
|
|
1792
|
+
};
|
|
1793
|
+
const hitRegion = resolveCapabilityHitSegment(world, tick, actor, target, 0xCAB2);
|
|
1794
|
+
if (!target.injury.byRegion[hitRegion])
|
|
1795
|
+
break;
|
|
1796
|
+
let effectiveEnergy = payload.energy_J;
|
|
1797
|
+
if ((target.condition.shieldReserve_J ?? 0) > 0 &&
|
|
1798
|
+
target.condition.shieldExpiry_tick !== undefined &&
|
|
1799
|
+
tick <= target.condition.shieldExpiry_tick) {
|
|
1800
|
+
const absorbed = Math.min(target.condition.shieldReserve_J, effectiveEnergy);
|
|
1801
|
+
target.condition.shieldReserve_J -= absorbed;
|
|
1802
|
+
effectiveEnergy -= absorbed;
|
|
1803
|
+
}
|
|
1804
|
+
if (effectiveEnergy > 0) {
|
|
1805
|
+
applyImpactToInjury(target, wpn, effectiveEnergy, hitRegion, false, trace, tick);
|
|
1806
|
+
}
|
|
1807
|
+
break;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Resolve all payloads of a capability effect for the appropriate target set.
|
|
1813
|
+
* AoE: all living entities within aoeRadius_m of target/actor position.
|
|
1814
|
+
* Single-target: targetId entity, or self if absent.
|
|
1815
|
+
*/
|
|
1816
|
+
export function applyCapabilityEffect(world, actor, targetId, effect, trace, tick) {
|
|
1817
|
+
const payloads = Array.isArray(effect.payload)
|
|
1818
|
+
? effect.payload
|
|
1819
|
+
: [effect.payload];
|
|
1820
|
+
// Determine target entities
|
|
1821
|
+
let targets;
|
|
1822
|
+
if (effect.coneHalfAngle_rad !== undefined) {
|
|
1823
|
+
// Phase 28: directional cone AoE — apply to all living entities within the cone
|
|
1824
|
+
const range_m = effect.range_m ?? 0;
|
|
1825
|
+
let dir;
|
|
1826
|
+
if (effect.coneDir === "fixed" && effect.coneDirFixed) {
|
|
1827
|
+
dir = effect.coneDirFixed;
|
|
1828
|
+
}
|
|
1829
|
+
else {
|
|
1830
|
+
// "facing" (default) — use actor's current facingDirQ
|
|
1831
|
+
const fx = actor.action.facingDirQ.x;
|
|
1832
|
+
const fy = actor.action.facingDirQ.y;
|
|
1833
|
+
const mag = Math.sqrt(fx * fx + fy * fy);
|
|
1834
|
+
const sc = mag > 0 ? SCALE.m / mag : 1;
|
|
1835
|
+
dir = { dx: Math.round(fx * sc), dy: Math.round(fy * sc) };
|
|
1836
|
+
}
|
|
1837
|
+
const cone = {
|
|
1838
|
+
origin: { x: actor.position_m.x, y: actor.position_m.y },
|
|
1839
|
+
dir,
|
|
1840
|
+
halfAngle_rad: effect.coneHalfAngle_rad,
|
|
1841
|
+
range_m,
|
|
1842
|
+
};
|
|
1843
|
+
targets = world.entities.filter(e => !e.injury.dead && entityInCone(e, cone));
|
|
1844
|
+
}
|
|
1845
|
+
else if (effect.aoeRadius_m !== undefined) {
|
|
1846
|
+
const origin = targetId !== undefined
|
|
1847
|
+
? (world.entities.find(e => e.id === targetId)?.position_m ?? actor.position_m)
|
|
1848
|
+
: actor.position_m;
|
|
1849
|
+
const radSq = effect.aoeRadius_m * effect.aoeRadius_m;
|
|
1850
|
+
targets = world.entities.filter(e => {
|
|
1851
|
+
if (e.injury.dead)
|
|
1852
|
+
return false;
|
|
1853
|
+
const dx = e.position_m.x - origin.x;
|
|
1854
|
+
const dy = e.position_m.y - origin.y;
|
|
1855
|
+
return dx * dx + dy * dy <= radSq;
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
else if (targetId !== undefined) {
|
|
1859
|
+
const t = world.entities.find(e => e.id === targetId);
|
|
1860
|
+
targets = t && !t.injury.dead ? [t] : [];
|
|
1861
|
+
}
|
|
1862
|
+
else {
|
|
1863
|
+
targets = [actor];
|
|
1864
|
+
}
|
|
1865
|
+
for (const target of targets) {
|
|
1866
|
+
// Phase 12B: magic resistance — non-self targets may resist the effect
|
|
1867
|
+
if (target.id !== actor.id) {
|
|
1868
|
+
const mr = target.attributes.resilience.magicResist ?? 0;
|
|
1869
|
+
if (mr > 0) {
|
|
1870
|
+
const resistSeed = eventSeed(world.seed, tick, actor.id, target.id, 0x5E515);
|
|
1871
|
+
if ((resistSeed % SCALE.Q) < mr)
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
for (const p of payloads) {
|
|
1876
|
+
applyPayload(world, actor, target, p, trace, tick, effect.id);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Resolve a capability activation command for actor.
|
|
1882
|
+
* Validates suppression, range, and cost; handles cast time via pendingActivation.
|
|
1883
|
+
*/
|
|
1884
|
+
function resolveActivation(world, actor, cmd, ctx, trace, tick) {
|
|
1885
|
+
if (!actor.capabilitySources)
|
|
1886
|
+
return;
|
|
1887
|
+
const source = actor.capabilitySources.find(s => s.id === cmd.sourceId);
|
|
1888
|
+
if (!source)
|
|
1889
|
+
return;
|
|
1890
|
+
const effect = source.effects.find(ef => ef.id === cmd.effectId);
|
|
1891
|
+
if (!effect)
|
|
1892
|
+
return;
|
|
1893
|
+
// Phase 12B: cooldown gate — can't re-activate until cooldown expires
|
|
1894
|
+
const cooldownKey = `${source.id}:${effect.id}`;
|
|
1895
|
+
if ((actor.action.capabilityCooldowns?.get(cooldownKey) ?? 0) > 0)
|
|
1896
|
+
return;
|
|
1897
|
+
// Phase 12B: tech-context gate — requiredCapability must be available if techCtx is present
|
|
1898
|
+
if (effect.requiredCapability !== undefined && ctx.techCtx !== undefined) {
|
|
1899
|
+
if (!isCapabilityAvailable(ctx.techCtx, effect.requiredCapability))
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
// Suppression check — any covering field whose tags overlap source tags blocks activation
|
|
1903
|
+
const ax = actor.position_m.x;
|
|
1904
|
+
const ay = actor.position_m.y;
|
|
1905
|
+
const suppressed = (world.activeFieldEffects ?? []).some(fe => {
|
|
1906
|
+
const dx = ax - fe.origin.x;
|
|
1907
|
+
const dy = ay - fe.origin.y;
|
|
1908
|
+
const distSq = dx * dx + dy * dy;
|
|
1909
|
+
const radSq = fe.radius_m * fe.radius_m;
|
|
1910
|
+
return distSq <= radSq && source.tags.some(t => fe.suppressesTags.includes(t));
|
|
1911
|
+
});
|
|
1912
|
+
if (suppressed) {
|
|
1913
|
+
trace.onEvent({ kind: TraceKinds.CapabilitySuppressed, tick, entityId: actor.id, sourceId: cmd.sourceId, effectId: cmd.effectId });
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
// Range check
|
|
1917
|
+
if (effect.range_m !== undefined && cmd.targetId !== undefined) {
|
|
1918
|
+
const tgt = world.entities.find(e => e.id === cmd.targetId);
|
|
1919
|
+
if (tgt) {
|
|
1920
|
+
const dx = tgt.position_m.x - ax;
|
|
1921
|
+
const dy = tgt.position_m.y - ay;
|
|
1922
|
+
if (dx * dx + dy * dy > effect.range_m * effect.range_m)
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
// Phase 12B: concentration aura — castTime_ticks < 0 means ongoing per-tick effect.
|
|
1927
|
+
// No upfront cost; stepConcentration deducts cost_J each tick.
|
|
1928
|
+
if (effect.castTime_ticks < 0) {
|
|
1929
|
+
actor.activeConcentration = {
|
|
1930
|
+
sourceId: source.id,
|
|
1931
|
+
effectId: effect.id,
|
|
1932
|
+
...(cmd.targetId !== undefined ? { targetId: cmd.targetId } : {}),
|
|
1933
|
+
};
|
|
1934
|
+
trace.onEvent({ kind: TraceKinds.CapabilityActivated, tick, entityId: actor.id, sourceId: source.id, effectId: effect.id });
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
// Cost check (boundless sources always have enough)
|
|
1938
|
+
const isBoundless = source.regenModel.type === "boundless";
|
|
1939
|
+
let sourceToDraw = source;
|
|
1940
|
+
if (!isBoundless && source.reserve_J < effect.cost_J) {
|
|
1941
|
+
// Phase 12B: try linked fallback source
|
|
1942
|
+
if (source.linkedFallbackId) {
|
|
1943
|
+
const fallback = actor.capabilitySources.find(s => s.id === source.linkedFallbackId);
|
|
1944
|
+
if (fallback && (fallback.regenModel.type === "boundless" || fallback.reserve_J >= effect.cost_J)) {
|
|
1945
|
+
sourceToDraw = fallback;
|
|
1946
|
+
}
|
|
1947
|
+
else {
|
|
1948
|
+
return; // both depleted
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
else {
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
const drawIsBoundless = sourceToDraw.regenModel.type === "boundless";
|
|
1956
|
+
// Cast time — queue pending activation and deduct cost at cast-start
|
|
1957
|
+
if (effect.castTime_ticks > 0) {
|
|
1958
|
+
if (!actor.pendingActivation) {
|
|
1959
|
+
if (!drawIsBoundless)
|
|
1960
|
+
sourceToDraw.reserve_J -= effect.cost_J;
|
|
1961
|
+
actor.pendingActivation = cmd.targetId !== undefined
|
|
1962
|
+
? { sourceId: cmd.sourceId, effectId: cmd.effectId, targetId: cmd.targetId, resolveAtTick: tick + effect.castTime_ticks }
|
|
1963
|
+
: { sourceId: cmd.sourceId, effectId: cmd.effectId, resolveAtTick: tick + effect.castTime_ticks };
|
|
1964
|
+
// Phase 12B: set cooldown at cast-start so the cast itself is gated
|
|
1965
|
+
if (effect.cooldown_ticks && effect.cooldown_ticks > 0) {
|
|
1966
|
+
if (!actor.action.capabilityCooldowns)
|
|
1967
|
+
actor.action.capabilityCooldowns = new Map();
|
|
1968
|
+
actor.action.capabilityCooldowns.set(cooldownKey, effect.cooldown_ticks);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
// Instant — deduct, resolve, and set cooldown
|
|
1974
|
+
if (!drawIsBoundless)
|
|
1975
|
+
sourceToDraw.reserve_J -= effect.cost_J;
|
|
1976
|
+
applyCapabilityEffect(world, actor, cmd.targetId, effect, trace, tick);
|
|
1977
|
+
trace.onEvent({ kind: TraceKinds.CapabilityActivated, tick, entityId: actor.id, sourceId: cmd.sourceId, effectId: cmd.effectId });
|
|
1978
|
+
if (effect.cooldown_ticks && effect.cooldown_ticks > 0) {
|
|
1979
|
+
if (!actor.action.capabilityCooldowns)
|
|
1980
|
+
actor.action.capabilityCooldowns = new Map();
|
|
1981
|
+
actor.action.capabilityCooldowns.set(cooldownKey, effect.cooldown_ticks);
|
|
1982
|
+
}
|
|
1983
|
+
// Phase 28: if sustainedTicks, set up emission for the remaining ticks (first tick fired above)
|
|
1984
|
+
if (effect.sustainedTicks && effect.sustainedTicks > 1) {
|
|
1985
|
+
actor.action.sustainedEmission = {
|
|
1986
|
+
sourceId: cmd.sourceId,
|
|
1987
|
+
effectId: cmd.effectId,
|
|
1988
|
+
...(cmd.targetId !== undefined ? { targetId: cmd.targetId } : {}),
|
|
1989
|
+
remainingTicks: effect.sustainedTicks - 1,
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
}
|