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