@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,182 @@
|
|
|
1
|
+
// src/economy.ts — Phase 25: Loot & Economy
|
|
2
|
+
//
|
|
3
|
+
// Item value, equipment degradation, drop resolution, and trade evaluation.
|
|
4
|
+
// Composes with the equipment, medical, and arena systems without modifying them.
|
|
5
|
+
//
|
|
6
|
+
// No kernel import — pure data-management module.
|
|
7
|
+
import { SCALE, q, clampQ, qMul } from "./units.js";
|
|
8
|
+
import { eventSeed } from "./sim/seeds.js";
|
|
9
|
+
import { makeRng } from "./rng.js";
|
|
10
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
11
|
+
/** Base wear increment at full intensity (q(1.0) per strike). */
|
|
12
|
+
export const WEAR_BASE = q(0.001);
|
|
13
|
+
/** Wear threshold at which a −5% effective-mass penalty begins. */
|
|
14
|
+
export const WEAR_PENALTY_THRESHOLD = q(0.30);
|
|
15
|
+
/** Wear threshold at which a 20% fumble chance triggers. */
|
|
16
|
+
export const WEAR_FUMBLE_THRESHOLD = q(0.70);
|
|
17
|
+
const FUMBLE_CHANCE_Q = q(0.20); // 20%
|
|
18
|
+
const DEFAULT_SELL_FRACTION = 0.40; // weapons/armour
|
|
19
|
+
const MEDICAL_SELL_FRACTION = 0.50;
|
|
20
|
+
// ── computeItemValue ──────────────────────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Derive economic metadata for any item or medical resource.
|
|
23
|
+
*
|
|
24
|
+
* `wear_Q` is the caller's current wear value for the item.
|
|
25
|
+
* - For melee weapons: use `weapon.wear_Q ?? q(0)`.
|
|
26
|
+
* - For armour: derive via `armourConditionQ(resist_J, resistRemaining_J)`.
|
|
27
|
+
* - Omit (defaults to `q(0)`) for consumables or new items.
|
|
28
|
+
*
|
|
29
|
+
* `condition_Q = q(1.0) − wear_Q`, clamped to [0, SCALE.Q].
|
|
30
|
+
*/
|
|
31
|
+
export function computeItemValue(item, wear_Q = q(0)) {
|
|
32
|
+
// MedicalResource: identified by `costUnits` presence
|
|
33
|
+
if ("costUnits" in item) {
|
|
34
|
+
const med = item;
|
|
35
|
+
return {
|
|
36
|
+
itemId: med.id,
|
|
37
|
+
baseValue: med.costUnits,
|
|
38
|
+
condition_Q: q(1.0), // consumables don't degrade
|
|
39
|
+
sellFraction: MEDICAL_SELL_FRACTION,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const it = item;
|
|
43
|
+
const condition_Q = clampQ(SCALE.Q - wear_Q, 0, SCALE.Q);
|
|
44
|
+
if (it.kind === "weapon") {
|
|
45
|
+
const w = it;
|
|
46
|
+
// ~10 cost units per kg, plus reach bonus
|
|
47
|
+
const massUnits = Math.floor((w.mass_kg / SCALE.kg) * 10);
|
|
48
|
+
const reachBonus = w.reach_m ? Math.floor((w.reach_m / SCALE.m) * 5) : 0;
|
|
49
|
+
return { itemId: w.id, baseValue: massUnits + reachBonus, condition_Q, sellFraction: DEFAULT_SELL_FRACTION };
|
|
50
|
+
}
|
|
51
|
+
if (it.kind === "armour") {
|
|
52
|
+
const a = it;
|
|
53
|
+
// ~0.4 cost units per joule of base resist
|
|
54
|
+
const base = Math.floor(a.resist_J * 0.4);
|
|
55
|
+
return { itemId: a.id, baseValue: base, condition_Q, sellFraction: DEFAULT_SELL_FRACTION };
|
|
56
|
+
}
|
|
57
|
+
if (it.kind === "ranged") {
|
|
58
|
+
const r = it;
|
|
59
|
+
const massUnits = Math.floor((r.mass_kg / SCALE.kg) * 10);
|
|
60
|
+
return { itemId: r.id, baseValue: massUnits + 5, condition_Q, sellFraction: DEFAULT_SELL_FRACTION };
|
|
61
|
+
}
|
|
62
|
+
// Shield / Gear / Exoskeleton / Sensor — fallback
|
|
63
|
+
return { itemId: it.id, baseValue: Math.floor(it.mass_kg / SCALE.kg * 8), condition_Q: q(1.0), sellFraction: DEFAULT_SELL_FRACTION };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Convert armour degradation state to a wear_Q fraction.
|
|
67
|
+
*
|
|
68
|
+
* ```
|
|
69
|
+
* wear = q(1.0) − resistRemaining_J / resist_J
|
|
70
|
+
* ```
|
|
71
|
+
* Used to derive `condition_Q` for ablative armour via `computeItemValue`.
|
|
72
|
+
*/
|
|
73
|
+
export function armourConditionQ(resist_J, resistRemaining_J) {
|
|
74
|
+
if (resist_J <= 0)
|
|
75
|
+
return q(1.0);
|
|
76
|
+
const fraction = Math.trunc((resistRemaining_J * SCALE.Q) / resist_J);
|
|
77
|
+
return clampQ(fraction, 0, SCALE.Q);
|
|
78
|
+
}
|
|
79
|
+
// ── applyWear ─────────────────────────────────────────────────────────────────
|
|
80
|
+
/**
|
|
81
|
+
* Apply one strike's worth of use-wear to a melee weapon.
|
|
82
|
+
*
|
|
83
|
+
* @param weapon - The weapon being used (reads `wear_Q` field if present).
|
|
84
|
+
* @param actionIntensity_Q - Strike intensity (q(0)..q(1.0)); higher = more wear.
|
|
85
|
+
* Use q(1.0) for strikes against hard targets (plate armour),
|
|
86
|
+
* lower values for soft/unarmoured opponents.
|
|
87
|
+
* @param seed - Optional entropy seed for the deterministic fumble roll.
|
|
88
|
+
* Must be supplied when checking fumble; otherwise fumble = false.
|
|
89
|
+
*
|
|
90
|
+
* The returned `wear_Q` should be written back to `weapon.wear_Q` by the caller.
|
|
91
|
+
*/
|
|
92
|
+
export function applyWear(weapon, actionIntensity_Q, seed) {
|
|
93
|
+
const current = (weapon.wear_Q ?? q(0));
|
|
94
|
+
// Increment proportional to strike intensity (q(0.001) × intensity)
|
|
95
|
+
const increment = Math.max(1, Math.trunc(qMul(WEAR_BASE, actionIntensity_Q)));
|
|
96
|
+
const newWear = clampQ(current + increment, 0, SCALE.Q);
|
|
97
|
+
const broke = newWear >= SCALE.Q;
|
|
98
|
+
const penaltyActive = newWear >= WEAR_PENALTY_THRESHOLD;
|
|
99
|
+
let fumble = false;
|
|
100
|
+
if (!broke && newWear >= WEAR_FUMBLE_THRESHOLD && seed !== undefined) {
|
|
101
|
+
const rng = makeRng(eventSeed(seed, 0, 0, 0, 0xFADE1), SCALE.Q);
|
|
102
|
+
fumble = rng.q01() < FUMBLE_CHANCE_Q;
|
|
103
|
+
}
|
|
104
|
+
return { wear_Q: newWear, broke, fumble, penaltyActive };
|
|
105
|
+
}
|
|
106
|
+
// ── resolveDrops ──────────────────────────────────────────────────────────────
|
|
107
|
+
/**
|
|
108
|
+
* Compute the list of item IDs dropped by an entity on death or incapacitation.
|
|
109
|
+
*
|
|
110
|
+
* Default behaviour:
|
|
111
|
+
* - Dead entity → all equipped weapons, ranged weapons, and armour drop (guaranteed).
|
|
112
|
+
* - Incapacitated but living → nothing drops (use `config.dropOnIncapacitated = true` to override).
|
|
113
|
+
*
|
|
114
|
+
* Additional items from `extra.guaranteed` always drop (when applicable).
|
|
115
|
+
* `extra.probabilistic` items are rolled deterministically from `seed`.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveDrops(entity, seed, extra, config) {
|
|
118
|
+
const shouldDrop = entity.injury.dead || (config?.dropOnIncapacitated ?? false);
|
|
119
|
+
if (!shouldDrop)
|
|
120
|
+
return [];
|
|
121
|
+
const result = [];
|
|
122
|
+
// Guaranteed: all equipped weapons and armour
|
|
123
|
+
for (const item of entity.loadout.items) {
|
|
124
|
+
if (item.kind === "weapon" || item.kind === "armour" || item.kind === "ranged") {
|
|
125
|
+
result.push(item.id);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (extra) {
|
|
129
|
+
for (const id of extra.guaranteed) {
|
|
130
|
+
result.push(id);
|
|
131
|
+
}
|
|
132
|
+
// Probabilistic roll — deterministic per item index
|
|
133
|
+
for (let i = 0; i < extra.probabilistic.length; i++) {
|
|
134
|
+
const entry = extra.probabilistic[i];
|
|
135
|
+
const rollSeed = eventSeed(seed, i, 0, 0, 0xD50A5);
|
|
136
|
+
const rng = makeRng(rollSeed, SCALE.Q);
|
|
137
|
+
if (rng.q01() < entry.chance_Q) {
|
|
138
|
+
result.push(entry.itemId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
// ── evaluateTradeOffer ────────────────────────────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* Evaluate a trade offer from the accepting party's perspective.
|
|
147
|
+
*
|
|
148
|
+
* - `offer.give`: items the proposing party puts on the table.
|
|
149
|
+
* - `offer.want`: items the proposing party asks for in return.
|
|
150
|
+
* - `inventory`: accepting party's current stock.
|
|
151
|
+
*
|
|
152
|
+
* `netValue` > 0 → advantageous for the accepting party (they receive more than they give).
|
|
153
|
+
* `feasible` — the accepting party has all `want` items in sufficient quantities.
|
|
154
|
+
*/
|
|
155
|
+
export function evaluateTradeOffer(offer, inventory) {
|
|
156
|
+
// Feasibility: does the accepting party have all wanted items?
|
|
157
|
+
let feasible = true;
|
|
158
|
+
for (const w of offer.want) {
|
|
159
|
+
const entry = inventory.get(w.itemId);
|
|
160
|
+
if (!entry || entry.count < w.count) {
|
|
161
|
+
feasible = false;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Net value for accepting party: receive give items, pay want items
|
|
166
|
+
let netValue = 0;
|
|
167
|
+
for (const g of offer.give)
|
|
168
|
+
netValue += g.count * g.unitValue;
|
|
169
|
+
for (const w of offer.want)
|
|
170
|
+
netValue -= w.count * w.unitValue;
|
|
171
|
+
return { netValue, feasible };
|
|
172
|
+
}
|
|
173
|
+
// ── totalInventoryValue ───────────────────────────────────────────────────────
|
|
174
|
+
/**
|
|
175
|
+
* Sum the total value of all items in an inventory (count × unitValue for each entry).
|
|
176
|
+
*/
|
|
177
|
+
export function totalInventoryValue(inventory) {
|
|
178
|
+
let total = 0;
|
|
179
|
+
for (const [, entry] of inventory)
|
|
180
|
+
total += entry.count * entry.unitValue;
|
|
181
|
+
return total;
|
|
182
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { type Q } from "./units.js";
|
|
2
|
+
import type { PolityRegistry, PolityPair } from "./polity.js";
|
|
3
|
+
/** Whether the emotional wave helps or harms morale. */
|
|
4
|
+
export type EmotionalValence = "fear" | "hope";
|
|
5
|
+
/**
|
|
6
|
+
* Declarative profile for an emotional contagion event.
|
|
7
|
+
* Mirrors DiseaseProfile structure for API consistency.
|
|
8
|
+
*/
|
|
9
|
+
export interface EmotionalContagionProfile {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
/** "fear" drains moraleQ; "hope" restores it. */
|
|
13
|
+
valence: EmotionalValence;
|
|
14
|
+
/**
|
|
15
|
+
* Daily transmission probability to each adjacent polity [0, SCALE.Q].
|
|
16
|
+
* Rolled against eventSeed; analogous to DiseaseProfile.baseTransmissionRate_Q.
|
|
17
|
+
*/
|
|
18
|
+
baseTransmissionRate_Q: Q;
|
|
19
|
+
/**
|
|
20
|
+
* Fraction of wave intensity lost per day [0, SCALE.Q].
|
|
21
|
+
* At q(1.0) the wave expires in one tick.
|
|
22
|
+
*/
|
|
23
|
+
decayRate_Q: Q;
|
|
24
|
+
/**
|
|
25
|
+
* Maximum moraleQ change applied to a polity per day [0, SCALE.Q].
|
|
26
|
+
* Scaled by the effective wave intensity × transmission success.
|
|
27
|
+
*/
|
|
28
|
+
maxMoraleDelta_Q: Q;
|
|
29
|
+
/**
|
|
30
|
+
* Multiplier applied to initial wave intensity when a leader with high
|
|
31
|
+
* musical/performance intelligence triggers the event (Phase 39 hook).
|
|
32
|
+
* 0 = no amplification; q(1.0) = up to 2× base intensity.
|
|
33
|
+
*/
|
|
34
|
+
leaderAmplification_Q: Q;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* An active emotional event originating from one polity.
|
|
38
|
+
* Decays each day; removed when intensity_Q reaches 0.
|
|
39
|
+
*/
|
|
40
|
+
export interface EmotionalWave {
|
|
41
|
+
profileId: string;
|
|
42
|
+
/** The polity that originated the event (battle loss, speech, etc.). */
|
|
43
|
+
sourcePolityId: string;
|
|
44
|
+
/** Current intensity [0, SCALE.Q]. Decays by decayRate_Q each day. */
|
|
45
|
+
intensity_Q: Q;
|
|
46
|
+
/** Number of day-ticks this wave has been active. */
|
|
47
|
+
daysActive: number;
|
|
48
|
+
}
|
|
49
|
+
/** Per-polity morale delta produced by one batch of contagion spread. */
|
|
50
|
+
export interface ContagionResult {
|
|
51
|
+
polityId: string;
|
|
52
|
+
moraleDelta_Q: Q;
|
|
53
|
+
}
|
|
54
|
+
/** Heavy defeat in the field triggers mass panic. Spreads fast, decays fast. */
|
|
55
|
+
export declare const PROFILE_MILITARY_ROUT: EmotionalContagionProfile;
|
|
56
|
+
/** Plague outbreak news spreads slowly but persists. */
|
|
57
|
+
export declare const PROFILE_PLAGUE_PANIC: EmotionalContagionProfile;
|
|
58
|
+
/** Victory news bolsters allied and neutral neighbours. */
|
|
59
|
+
export declare const PROFILE_VICTORY_RALLY: EmotionalContagionProfile;
|
|
60
|
+
/** A charismatic leader's speech rallies citizens directly. */
|
|
61
|
+
export declare const PROFILE_CHARISMATIC_ADDRESS: EmotionalContagionProfile;
|
|
62
|
+
/** All built-in profiles, indexed by id. */
|
|
63
|
+
export declare const EMOTIONAL_PROFILES: ReadonlyArray<EmotionalContagionProfile>;
|
|
64
|
+
/** Look up a profile by id. Returns undefined if unknown. */
|
|
65
|
+
export declare function getEmotionalProfile(id: string): EmotionalContagionProfile | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Create a new emotional wave at full intensity.
|
|
68
|
+
* Pass `leaderPerformance_Q` > 0 to amplify the initial intensity
|
|
69
|
+
* (effective only when the profile has leaderAmplification_Q > 0).
|
|
70
|
+
*/
|
|
71
|
+
export declare function createEmotionalWave(profile: EmotionalContagionProfile, sourcePolityId: string, leaderPerformance_Q?: Q): EmotionalWave;
|
|
72
|
+
/** @internal Used by tests and convenience triggers. */
|
|
73
|
+
export declare function _makeWave(profile: EmotionalContagionProfile, sourcePolityId: string, leaderPerformance_Q?: Q): EmotionalWave;
|
|
74
|
+
/**
|
|
75
|
+
* Advance all active waves by one day: increment daysActive, apply decay.
|
|
76
|
+
* Returns the updated array with expired waves (intensity_Q === 0) removed.
|
|
77
|
+
*
|
|
78
|
+
* Does NOT modify the input array — returns a new array.
|
|
79
|
+
*/
|
|
80
|
+
export declare function stepEmotionalWaves(waves: ReadonlyArray<EmotionalWave>, profiles: ReadonlyArray<EmotionalContagionProfile>): EmotionalWave[];
|
|
81
|
+
/**
|
|
82
|
+
* Compute the morale delta a source wave inflicts on one adjacent target polity.
|
|
83
|
+
*
|
|
84
|
+
* Returns 0 if:
|
|
85
|
+
* - The source polity is not in the registry
|
|
86
|
+
* - The deterministic roll misses (no transmission)
|
|
87
|
+
*
|
|
88
|
+
* The sign of the returned Q matches the transmission direction — callers
|
|
89
|
+
* should negate it for "fear" valence before applying to `moraleQ`.
|
|
90
|
+
* (Positive return always means "some effect occurred"; valence is handled
|
|
91
|
+
* by `applyEmotionalContagion`.)
|
|
92
|
+
*/
|
|
93
|
+
export declare function computeEmotionalSpread(sourcePolityId: string, targetPolityId: string, wave: EmotionalWave, profile: EmotionalContagionProfile, worldSeed: number, tick: number): Q;
|
|
94
|
+
/**
|
|
95
|
+
* Apply all active emotional waves to the polity registry for one day-tick.
|
|
96
|
+
*
|
|
97
|
+
* For each wave, iterates all PolityPairs where the source polity appears.
|
|
98
|
+
* Calls `computeEmotionalSpread` for each neighbour polity.
|
|
99
|
+
* Applies the resulting morale delta (negative for fear, positive for hope)
|
|
100
|
+
* directly to `polity.moraleQ`, clamped to [0, SCALE.Q].
|
|
101
|
+
*
|
|
102
|
+
* Also applies the wave directly to the SOURCE polity at full intensity
|
|
103
|
+
* (the originating polity is always affected before it spreads outward).
|
|
104
|
+
*
|
|
105
|
+
* Returns a `ContagionResult[]` listing every polity that was affected.
|
|
106
|
+
* Polities with zero net delta are omitted.
|
|
107
|
+
*/
|
|
108
|
+
export declare function applyEmotionalContagion(registry: PolityRegistry, pairs: ReadonlyArray<PolityPair>, waves: ReadonlyArray<EmotionalWave>, profiles: ReadonlyArray<EmotionalContagionProfile>, worldSeed: number, tick: number): ContagionResult[];
|
|
109
|
+
/**
|
|
110
|
+
* A polity's army has been routed. Creates a MILITARY_ROUT fear wave at the
|
|
111
|
+
* source polity at full base intensity (no leader amplification for defeats).
|
|
112
|
+
*/
|
|
113
|
+
export declare function triggerMilitaryRout(sourcePolityId: string): EmotionalWave;
|
|
114
|
+
/**
|
|
115
|
+
* Plague outbreak confirmed in a polity. Creates a PLAGUE_PANIC fear wave.
|
|
116
|
+
*/
|
|
117
|
+
export declare function triggerPlaguePanic(sourcePolityId: string): EmotionalWave;
|
|
118
|
+
/**
|
|
119
|
+
* A decisive military victory. Creates a VICTORY_RALLY hope wave, optionally
|
|
120
|
+
* amplified by the commander's performance intelligence (Phase 39 hook).
|
|
121
|
+
*
|
|
122
|
+
* @param leaderPerformance_Q Commander's musical/intrapersonal mean [0, SCALE.Q].
|
|
123
|
+
* Pass q(0) for a leaderless victory.
|
|
124
|
+
*/
|
|
125
|
+
export declare function triggerVictoryRally(sourcePolityId: string, leaderPerformance_Q?: Q): EmotionalWave;
|
|
126
|
+
/**
|
|
127
|
+
* A charismatic leader addresses the populace. Uses CHARISMATIC_ADDRESS profile;
|
|
128
|
+
* `leaderPerformance_Q` amplifies wave intensity (Phase 39 hook).
|
|
129
|
+
*
|
|
130
|
+
* @param leaderPerformance_Q Leader's musical/intrapersonal mean [0, SCALE.Q].
|
|
131
|
+
*/
|
|
132
|
+
export declare function triggerLeaderAddress(sourcePolityId: string, leaderPerformance_Q: Q): EmotionalWave;
|
|
133
|
+
/**
|
|
134
|
+
* Check whether a wave has fully decayed (intensity === 0).
|
|
135
|
+
*/
|
|
136
|
+
export declare function isWaveExpired(wave: EmotionalWave): boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Summarise the net emotional pressure across all active waves for a polity.
|
|
139
|
+
* Returns a signed Q: positive = net hope, negative = net fear.
|
|
140
|
+
* Useful for AI queries ("is this polity in panic?").
|
|
141
|
+
*/
|
|
142
|
+
export declare function netEmotionalPressure(polityId: string, waves: ReadonlyArray<EmotionalWave>, profiles: ReadonlyArray<EmotionalContagionProfile>): Q;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// src/emotional-contagion.ts — Phase 65: Emotional Contagion at Polity Scale
|
|
2
|
+
//
|
|
3
|
+
// Fear and hope propagate between polities using the same transmission model
|
|
4
|
+
// as Phase 56 disease spread, with `fear_Q` / `hope_Q` as the "pathogen".
|
|
5
|
+
//
|
|
6
|
+
// Design:
|
|
7
|
+
// 1. EmotionalContagionProfile — transmission rate, valence, decay, amplitude
|
|
8
|
+
// 2. EmotionalWave — active event (battle rout, rally speech) with
|
|
9
|
+
// decaying intensity; multiple waves can stack
|
|
10
|
+
// 3. computeEmotionalSpread() — risk Q between a source and adjacent polity
|
|
11
|
+
// 4. applyEmotionalContagion() — batch spread + morale delta application
|
|
12
|
+
// 5. stepEmotionalWaves() — daily decay; expired waves removed
|
|
13
|
+
// 6. Convenience triggers — triggerMilitaryRout, triggerVictoryRally,
|
|
14
|
+
// triggerLeaderAddress, triggerPlaguePanic
|
|
15
|
+
//
|
|
16
|
+
// The host calls applyEmotionalContagion once per polity day-tick (alongside
|
|
17
|
+
// stepPolityDay and computePolityDiseaseSpread). No Entity import — this
|
|
18
|
+
// module operates purely at the Polity / PolityRegistry level.
|
|
19
|
+
//
|
|
20
|
+
// Four built-in profiles:
|
|
21
|
+
// military_rout — fear; fast spread, fast decay
|
|
22
|
+
// plague_panic — fear; moderate spread, slow decay
|
|
23
|
+
// victory_rally — hope; moderate spread, medium decay
|
|
24
|
+
// charismatic_address — hope; leader-amplified, short-range, fast decay
|
|
25
|
+
//
|
|
26
|
+
// Phase 39 hook: leaderPerformance_Q amplifies the initial wave intensity
|
|
27
|
+
// of a charismatic_address event via triggerLeaderAddress().
|
|
28
|
+
import { q, clampQ, qMul, SCALE } from "./units.js";
|
|
29
|
+
import { eventSeed, hashString } from "./sim/seeds.js";
|
|
30
|
+
import { makeRng } from "./rng.js";
|
|
31
|
+
// ── Built-in profiles ─────────────────────────────────────────────────────────
|
|
32
|
+
/** Heavy defeat in the field triggers mass panic. Spreads fast, decays fast. */
|
|
33
|
+
export const PROFILE_MILITARY_ROUT = {
|
|
34
|
+
id: "military_rout",
|
|
35
|
+
name: "Military Rout",
|
|
36
|
+
valence: "fear",
|
|
37
|
+
baseTransmissionRate_Q: q(0.60),
|
|
38
|
+
decayRate_Q: q(0.18), // ~5-day half-life
|
|
39
|
+
maxMoraleDelta_Q: q(0.08),
|
|
40
|
+
leaderAmplification_Q: q(0),
|
|
41
|
+
};
|
|
42
|
+
/** Plague outbreak news spreads slowly but persists. */
|
|
43
|
+
export const PROFILE_PLAGUE_PANIC = {
|
|
44
|
+
id: "plague_panic",
|
|
45
|
+
name: "Plague Panic",
|
|
46
|
+
valence: "fear",
|
|
47
|
+
baseTransmissionRate_Q: q(0.40),
|
|
48
|
+
decayRate_Q: q(0.05), // ~20-day half-life
|
|
49
|
+
maxMoraleDelta_Q: q(0.05),
|
|
50
|
+
leaderAmplification_Q: q(0),
|
|
51
|
+
};
|
|
52
|
+
/** Victory news bolsters allied and neutral neighbours. */
|
|
53
|
+
export const PROFILE_VICTORY_RALLY = {
|
|
54
|
+
id: "victory_rally",
|
|
55
|
+
name: "Victory Rally",
|
|
56
|
+
valence: "hope",
|
|
57
|
+
baseTransmissionRate_Q: q(0.45),
|
|
58
|
+
decayRate_Q: q(0.10), // ~10-day half-life
|
|
59
|
+
maxMoraleDelta_Q: q(0.04),
|
|
60
|
+
leaderAmplification_Q: q(0.50),
|
|
61
|
+
};
|
|
62
|
+
/** A charismatic leader's speech rallies citizens directly. */
|
|
63
|
+
export const PROFILE_CHARISMATIC_ADDRESS = {
|
|
64
|
+
id: "charismatic_address",
|
|
65
|
+
name: "Charismatic Address",
|
|
66
|
+
valence: "hope",
|
|
67
|
+
baseTransmissionRate_Q: q(0.30),
|
|
68
|
+
decayRate_Q: q(0.25), // ~4-day half-life (fades quickly)
|
|
69
|
+
maxMoraleDelta_Q: q(0.10),
|
|
70
|
+
leaderAmplification_Q: q(1.00), // leader skill doubles wave impact
|
|
71
|
+
};
|
|
72
|
+
/** All built-in profiles, indexed by id. */
|
|
73
|
+
export const EMOTIONAL_PROFILES = [
|
|
74
|
+
PROFILE_MILITARY_ROUT,
|
|
75
|
+
PROFILE_PLAGUE_PANIC,
|
|
76
|
+
PROFILE_VICTORY_RALLY,
|
|
77
|
+
PROFILE_CHARISMATIC_ADDRESS,
|
|
78
|
+
];
|
|
79
|
+
/** Look up a profile by id. Returns undefined if unknown. */
|
|
80
|
+
export function getEmotionalProfile(id) {
|
|
81
|
+
return EMOTIONAL_PROFILES.find(p => p.id === id);
|
|
82
|
+
}
|
|
83
|
+
// ── Wave management ────────────────────────────────────────────────────────────
|
|
84
|
+
/**
|
|
85
|
+
* Create a new emotional wave at full intensity.
|
|
86
|
+
* Pass `leaderPerformance_Q` > 0 to amplify the initial intensity
|
|
87
|
+
* (effective only when the profile has leaderAmplification_Q > 0).
|
|
88
|
+
*/
|
|
89
|
+
export function createEmotionalWave(profile, sourcePolityId, leaderPerformance_Q = q(0)) {
|
|
90
|
+
// amplification = base + leaderPerformance_Q × leaderAmplification_Q / SCALE.Q
|
|
91
|
+
// capped at SCALE.Q so intensity stays in [0, SCALE.Q]
|
|
92
|
+
const amplification = qMul(leaderPerformance_Q, profile.leaderAmplification_Q);
|
|
93
|
+
const intensity_Q = clampQ(SCALE.Q + amplification, 0, SCALE.Q * 2);
|
|
94
|
+
// clamp to SCALE.Q for the wave field
|
|
95
|
+
const clamped = clampQ(intensity_Q, 0, SCALE.Q);
|
|
96
|
+
return { profileId: sourcePolityId, sourcePolityId, intensity_Q: clamped, daysActive: 0 };
|
|
97
|
+
}
|
|
98
|
+
/** @internal Used by tests and convenience triggers. */
|
|
99
|
+
export function _makeWave(profile, sourcePolityId, leaderPerformance_Q = q(0)) {
|
|
100
|
+
const amplification = qMul(leaderPerformance_Q, profile.leaderAmplification_Q);
|
|
101
|
+
const raw = SCALE.Q + amplification;
|
|
102
|
+
const intensity_Q = clampQ(raw, 0, SCALE.Q);
|
|
103
|
+
return {
|
|
104
|
+
profileId: profile.id,
|
|
105
|
+
sourcePolityId,
|
|
106
|
+
intensity_Q,
|
|
107
|
+
daysActive: 0,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Advance all active waves by one day: increment daysActive, apply decay.
|
|
112
|
+
* Returns the updated array with expired waves (intensity_Q === 0) removed.
|
|
113
|
+
*
|
|
114
|
+
* Does NOT modify the input array — returns a new array.
|
|
115
|
+
*/
|
|
116
|
+
export function stepEmotionalWaves(waves, profiles) {
|
|
117
|
+
const result = [];
|
|
118
|
+
for (const wave of waves) {
|
|
119
|
+
const profile = profiles.find(p => p.id === wave.profileId);
|
|
120
|
+
const decayRate = profile?.decayRate_Q ?? q(0.10);
|
|
121
|
+
const decayed = qMul(wave.intensity_Q, SCALE.Q - decayRate);
|
|
122
|
+
const newIntensity = clampQ(decayed, 0, SCALE.Q);
|
|
123
|
+
if (newIntensity > 0) {
|
|
124
|
+
result.push({ ...wave, intensity_Q: newIntensity, daysActive: wave.daysActive + 1 });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
// ── Spread computation ─────────────────────────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Compute the morale delta a source wave inflicts on one adjacent target polity.
|
|
132
|
+
*
|
|
133
|
+
* Returns 0 if:
|
|
134
|
+
* - The source polity is not in the registry
|
|
135
|
+
* - The deterministic roll misses (no transmission)
|
|
136
|
+
*
|
|
137
|
+
* The sign of the returned Q matches the transmission direction — callers
|
|
138
|
+
* should negate it for "fear" valence before applying to `moraleQ`.
|
|
139
|
+
* (Positive return always means "some effect occurred"; valence is handled
|
|
140
|
+
* by `applyEmotionalContagion`.)
|
|
141
|
+
*/
|
|
142
|
+
export function computeEmotionalSpread(sourcePolityId, targetPolityId, wave, profile, worldSeed, tick) {
|
|
143
|
+
if (sourcePolityId === targetPolityId)
|
|
144
|
+
return q(0);
|
|
145
|
+
// Transmission roll — identical salt scheme to Phase 56
|
|
146
|
+
const salt = hashString(profile.id);
|
|
147
|
+
const seed = eventSeed(worldSeed, tick, hashString(sourcePolityId), hashString(targetPolityId), salt);
|
|
148
|
+
const rng = makeRng(seed, SCALE.Q);
|
|
149
|
+
const roll = rng.q01();
|
|
150
|
+
// Effective rate: base × wave intensity
|
|
151
|
+
const effectiveRate = qMul(profile.baseTransmissionRate_Q, wave.intensity_Q);
|
|
152
|
+
if (roll > effectiveRate)
|
|
153
|
+
return q(0);
|
|
154
|
+
// Morale delta: maxDelta × (effectiveRate / SCALE.Q)
|
|
155
|
+
const delta = Math.round(profile.maxMoraleDelta_Q * effectiveRate / SCALE.Q);
|
|
156
|
+
return clampQ(delta, 0, profile.maxMoraleDelta_Q);
|
|
157
|
+
}
|
|
158
|
+
// ── Batch application ──────────────────────────────────────────────────────────
|
|
159
|
+
/**
|
|
160
|
+
* Apply all active emotional waves to the polity registry for one day-tick.
|
|
161
|
+
*
|
|
162
|
+
* For each wave, iterates all PolityPairs where the source polity appears.
|
|
163
|
+
* Calls `computeEmotionalSpread` for each neighbour polity.
|
|
164
|
+
* Applies the resulting morale delta (negative for fear, positive for hope)
|
|
165
|
+
* directly to `polity.moraleQ`, clamped to [0, SCALE.Q].
|
|
166
|
+
*
|
|
167
|
+
* Also applies the wave directly to the SOURCE polity at full intensity
|
|
168
|
+
* (the originating polity is always affected before it spreads outward).
|
|
169
|
+
*
|
|
170
|
+
* Returns a `ContagionResult[]` listing every polity that was affected.
|
|
171
|
+
* Polities with zero net delta are omitted.
|
|
172
|
+
*/
|
|
173
|
+
export function applyEmotionalContagion(registry, pairs, waves, profiles, worldSeed, tick) {
|
|
174
|
+
if (waves.length === 0)
|
|
175
|
+
return [];
|
|
176
|
+
// Accumulate per-polity morale deltas (signed: positive = hope, negative = fear)
|
|
177
|
+
const deltas = new Map();
|
|
178
|
+
const applyDelta = (polityId, signed) => {
|
|
179
|
+
deltas.set(polityId, (deltas.get(polityId) ?? 0) + signed);
|
|
180
|
+
};
|
|
181
|
+
for (const wave of waves) {
|
|
182
|
+
const profile = profiles.find(p => p.id === wave.profileId);
|
|
183
|
+
if (!profile)
|
|
184
|
+
continue;
|
|
185
|
+
const sign = profile.valence === "fear" ? -1 : 1;
|
|
186
|
+
// Source polity always receives the full wave intensity as a direct morale hit
|
|
187
|
+
const sourceDelta = Math.round(profile.maxMoraleDelta_Q * wave.intensity_Q / SCALE.Q);
|
|
188
|
+
applyDelta(wave.sourcePolityId, sign * sourceDelta);
|
|
189
|
+
// Spread to adjacent polities via pairs
|
|
190
|
+
for (const pair of pairs) {
|
|
191
|
+
let targetId = null;
|
|
192
|
+
if (pair.polityAId === wave.sourcePolityId)
|
|
193
|
+
targetId = pair.polityBId;
|
|
194
|
+
else if (pair.polityBId === wave.sourcePolityId)
|
|
195
|
+
targetId = pair.polityAId;
|
|
196
|
+
if (!targetId)
|
|
197
|
+
continue;
|
|
198
|
+
const spread = computeEmotionalSpread(wave.sourcePolityId, targetId, wave, profile, worldSeed, tick);
|
|
199
|
+
if (spread > 0) {
|
|
200
|
+
applyDelta(targetId, sign * spread);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Apply accumulated deltas to registry and build result list
|
|
205
|
+
const results = [];
|
|
206
|
+
for (const [polityId, delta] of deltas) {
|
|
207
|
+
if (delta === 0)
|
|
208
|
+
continue;
|
|
209
|
+
const polity = registry.polities.get(polityId);
|
|
210
|
+
if (!polity)
|
|
211
|
+
continue;
|
|
212
|
+
const newMorale = clampQ(polity.moraleQ + delta, 0, SCALE.Q);
|
|
213
|
+
polity.moraleQ = newMorale;
|
|
214
|
+
results.push({ polityId, moraleDelta_Q: delta });
|
|
215
|
+
}
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
// ── Convenience triggers ───────────────────────────────────────────────────────
|
|
219
|
+
/**
|
|
220
|
+
* A polity's army has been routed. Creates a MILITARY_ROUT fear wave at the
|
|
221
|
+
* source polity at full base intensity (no leader amplification for defeats).
|
|
222
|
+
*/
|
|
223
|
+
export function triggerMilitaryRout(sourcePolityId) {
|
|
224
|
+
return _makeWave(PROFILE_MILITARY_ROUT, sourcePolityId);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Plague outbreak confirmed in a polity. Creates a PLAGUE_PANIC fear wave.
|
|
228
|
+
*/
|
|
229
|
+
export function triggerPlaguePanic(sourcePolityId) {
|
|
230
|
+
return _makeWave(PROFILE_PLAGUE_PANIC, sourcePolityId);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A decisive military victory. Creates a VICTORY_RALLY hope wave, optionally
|
|
234
|
+
* amplified by the commander's performance intelligence (Phase 39 hook).
|
|
235
|
+
*
|
|
236
|
+
* @param leaderPerformance_Q Commander's musical/intrapersonal mean [0, SCALE.Q].
|
|
237
|
+
* Pass q(0) for a leaderless victory.
|
|
238
|
+
*/
|
|
239
|
+
export function triggerVictoryRally(sourcePolityId, leaderPerformance_Q = q(0)) {
|
|
240
|
+
return _makeWave(PROFILE_VICTORY_RALLY, sourcePolityId, leaderPerformance_Q);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* A charismatic leader addresses the populace. Uses CHARISMATIC_ADDRESS profile;
|
|
244
|
+
* `leaderPerformance_Q` amplifies wave intensity (Phase 39 hook).
|
|
245
|
+
*
|
|
246
|
+
* @param leaderPerformance_Q Leader's musical/intrapersonal mean [0, SCALE.Q].
|
|
247
|
+
*/
|
|
248
|
+
export function triggerLeaderAddress(sourcePolityId, leaderPerformance_Q) {
|
|
249
|
+
return _makeWave(PROFILE_CHARISMATIC_ADDRESS, sourcePolityId, leaderPerformance_Q);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Check whether a wave has fully decayed (intensity === 0).
|
|
253
|
+
*/
|
|
254
|
+
export function isWaveExpired(wave) {
|
|
255
|
+
return wave.intensity_Q <= 0;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Summarise the net emotional pressure across all active waves for a polity.
|
|
259
|
+
* Returns a signed Q: positive = net hope, negative = net fear.
|
|
260
|
+
* Useful for AI queries ("is this polity in panic?").
|
|
261
|
+
*/
|
|
262
|
+
export function netEmotionalPressure(polityId, waves, profiles) {
|
|
263
|
+
let total = 0;
|
|
264
|
+
for (const wave of waves) {
|
|
265
|
+
if (wave.sourcePolityId !== polityId)
|
|
266
|
+
continue;
|
|
267
|
+
const profile = profiles.find(p => p.id === wave.profileId);
|
|
268
|
+
if (!profile)
|
|
269
|
+
continue;
|
|
270
|
+
const signed = profile.valence === "fear" ? -wave.intensity_Q : wave.intensity_Q;
|
|
271
|
+
total += signed;
|
|
272
|
+
}
|
|
273
|
+
return clampQ(total, -SCALE.Q, SCALE.Q);
|
|
274
|
+
}
|