@its-not-rocket-science/ananke 0.1.14 → 0.1.16

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.
@@ -0,0 +1,126 @@
1
+ // src/battle-bridge.ts — Campaign ↔ Combat bridge for the Persistent World Server.
2
+ //
3
+ // Pure functions that translate between polity-scale state and tactical combat
4
+ // configuration, and back. No I/O, no timers, no side effects.
5
+ //
6
+ // Flow:
7
+ // 1. Polity layer detects a war (activeWars contains pair key).
8
+ // 2. Caller invokes battleConfigFromPolities() to get a BattleConfig.
9
+ // 3. Caller runs a tactical combat instance until one team is wiped out.
10
+ // 4. Caller invokes polityImpactFromBattle() to get PolityImpact[] to apply.
11
+ import { SCALE, q, clampQ } from "./units.js";
12
+ import { TechEra } from "./sim/tech.js";
13
+ // ── Constants ──────────────────────────────────────────────────────────────────
14
+ /** Minimum and maximum combatants per side. */
15
+ export const MIN_TEAM_SIZE = 2;
16
+ export const MAX_TEAM_SIZE = 16;
17
+ /** Battle ends after this many ticks regardless of outcome (prevents infinite loops). */
18
+ export const DEFAULT_MAX_TICKS = 6000; // 5 min at 20 Hz
19
+ /** Morale bonus for winning a battle (Q units). */
20
+ export const WIN_MORALE_BONUS = q(0.08);
21
+ /** Morale penalty for losing a battle (Q units). */
22
+ export const LOSS_MORALE_PENALTY = q(0.12);
23
+ /** Stability penalty per 10% casualties above 20% casualty rate. */
24
+ export const CASUALTY_STABILITY_RATE = q(0.02);
25
+ /** Population lost per combatant casualty (polity headcount, not Q). */
26
+ export const POP_PER_CASUALTY = 50;
27
+ // ── Era → Loadout mapping ──────────────────────────────────────────────────────
28
+ /**
29
+ * Returns the best available weapon and armour for a given tech era.
30
+ * Prehistoric → club + none; Ancient → knife + leather; Medieval+ → longsword + mail/plate.
31
+ */
32
+ export function techEraToLoadout(era) {
33
+ switch (era) {
34
+ case TechEra.Prehistoric:
35
+ return { archetype: "HUMAN_BASE", weaponId: "wpn_club", armourId: "arm_leather" };
36
+ case TechEra.Ancient:
37
+ return { archetype: "HUMAN_BASE", weaponId: "wpn_knife", armourId: "arm_leather" };
38
+ case TechEra.Medieval:
39
+ return { archetype: "HUMAN_BASE", weaponId: "wpn_longsword", armourId: "arm_mail" };
40
+ case TechEra.EarlyModern:
41
+ return { archetype: "HUMAN_BASE", weaponId: "wpn_longsword", armourId: "arm_plate" };
42
+ default:
43
+ return { archetype: "HUMAN_BASE", weaponId: "wpn_longsword", armourId: "arm_plate" };
44
+ }
45
+ }
46
+ // ── Military strength → team size ─────────────────────────────────────────────
47
+ /**
48
+ * Converts a polity's military strength (Q) to a team size.
49
+ * q(0) → MIN_TEAM_SIZE; q(1.0) → MAX_TEAM_SIZE. Linear interpolation.
50
+ */
51
+ export function militaryStrengthToTeamSize(militaryStrength_Q) {
52
+ const frac = Math.max(0, Math.min(SCALE.Q, militaryStrength_Q)) / SCALE.Q;
53
+ const size = Math.round(MIN_TEAM_SIZE + frac * (MAX_TEAM_SIZE - MIN_TEAM_SIZE));
54
+ return Math.max(MIN_TEAM_SIZE, Math.min(MAX_TEAM_SIZE, size));
55
+ }
56
+ // ── Deterministic seed ─────────────────────────────────────────────────────────
57
+ /**
58
+ * Produces a deterministic battle seed from the world seed, day, and polity ids.
59
+ * Ensures each polity pair on each day gets a unique, reproducible seed.
60
+ */
61
+ export function battleSeed(worldSeed, day, polityAId, polityBId) {
62
+ const idSalt = [...(polityAId + polityBId)].reduce((acc, c) => acc + c.charCodeAt(0), 0);
63
+ return ((worldSeed * 1000003 + day * 6151 + idSalt) >>> 0);
64
+ }
65
+ // ── BattleConfig factory ───────────────────────────────────────────────────────
66
+ /**
67
+ * Builds a BattleConfig for a war between two polities.
68
+ * Team sizes scale with militaryStrength_Q; loadouts reflect each side's tech era.
69
+ */
70
+ export function battleConfigFromPolities(polityA, polityB, worldSeed, day, maxTicks = DEFAULT_MAX_TICKS) {
71
+ return {
72
+ seed: battleSeed(worldSeed, day, polityA.id, polityB.id),
73
+ polityAId: polityA.id,
74
+ polityBId: polityB.id,
75
+ teamASize: militaryStrengthToTeamSize(polityA.militaryStrength_Q),
76
+ teamBSize: militaryStrengthToTeamSize(polityB.militaryStrength_Q),
77
+ loadoutA: techEraToLoadout(polityA.techEra),
78
+ loadoutB: techEraToLoadout(polityB.techEra),
79
+ maxTicks,
80
+ };
81
+ }
82
+ // ── PolityImpact derivation ────────────────────────────────────────────────────
83
+ /**
84
+ * Derives the per-polity state changes to apply after a battle.
85
+ *
86
+ * Win: +WIN_MORALE_BONUS morale.
87
+ * Loss: −LOSS_MORALE_PENALTY morale.
88
+ * Draw: no morale change.
89
+ * Both sides: stability penalty proportional to casualties above 20%.
90
+ * Population: POP_PER_CASUALTY headcount lost per casualty.
91
+ */
92
+ export function polityImpactFromBattle(outcome, config) {
93
+ const results = [];
94
+ const sides = [
95
+ { id: config.polityAId, casualties: outcome.teamACasualties, teamSize: config.teamASize, isWinner: outcome.winner === 1 },
96
+ { id: config.polityBId, casualties: outcome.teamBCasualties, teamSize: config.teamBSize, isWinner: outcome.winner === 2 },
97
+ ];
98
+ for (const side of sides) {
99
+ const isLoser = outcome.winner !== 0 && !side.isWinner;
100
+ const moraleDelta_Q = side.isWinner
101
+ ? WIN_MORALE_BONUS
102
+ : isLoser
103
+ ? -LOSS_MORALE_PENALTY
104
+ : 0;
105
+ // Stability penalty for casualty rates above 20%
106
+ const casualtyRate = side.teamSize > 0 ? side.casualties / side.teamSize : 0;
107
+ const excessCasualties = Math.max(0, casualtyRate - 0.20);
108
+ const stabilityDelta_Q = -Math.round(excessCasualties * 10 * CASUALTY_STABILITY_RATE);
109
+ results.push({
110
+ polityId: side.id,
111
+ moraleDelta_Q,
112
+ stabilityDelta_Q,
113
+ populationLost: side.casualties * POP_PER_CASUALTY,
114
+ });
115
+ }
116
+ return results;
117
+ }
118
+ /**
119
+ * Apply a PolityImpact to a polity in-place.
120
+ * Clamps morale and stability to [0, SCALE.Q].
121
+ */
122
+ export function applyPolityImpact(polity, impact) {
123
+ polity.moraleQ = clampQ(polity.moraleQ + impact.moraleDelta_Q, 0, SCALE.Q);
124
+ polity.stabilityQ = clampQ(polity.stabilityQ + impact.stabilityDelta_Q, 0, SCALE.Q);
125
+ polity.population = Math.max(0, polity.population - impact.populationLost);
126
+ }
@@ -0,0 +1,58 @@
1
+ import type { WorldState } from "./sim/world.js";
2
+ interface PushExports {
3
+ MAX_ENTITIES: WebAssembly.Global;
4
+ writeEntity: (slot: number, posX: number, posY: number, alive: number) => void;
5
+ readDvX: (slot: number) => number;
6
+ readDvY: (slot: number) => number;
7
+ stepRepulsionPairs: (n: number, radius_m: number, repelAccel_mps2: number) => void;
8
+ }
9
+ interface InjuryExports {
10
+ MAX_ENTITIES: WebAssembly.Global;
11
+ writeVitals: (slot: number, fluidLoss: number, shock: number, consciousness: number, dead: number, fatigue: number, suffocation: number) => void;
12
+ writeRegion: (slot: number, r: number, bleedingRate: number, structuralDamage: number, internalDamage: number, surfaceDamage: number) => void;
13
+ readFluidLoss: (slot: number) => number;
14
+ readShock: (slot: number) => number;
15
+ readConsciousness: (slot: number) => number;
16
+ readDead: (slot: number) => number;
17
+ stepBleedAndShock: (n: number) => void;
18
+ }
19
+ export interface WasmEntityReport {
20
+ entityId: number;
21
+ /** Repulsion velocity delta computed by WASM push kernel (SCALE.mps units). */
22
+ pushDvX: number;
23
+ pushDvY: number;
24
+ /** Projected injury state after one WASM injury tick. */
25
+ projFluidLoss: number;
26
+ projShock: number;
27
+ projConsciousness: number;
28
+ projDead: boolean;
29
+ }
30
+ export interface WasmStepReport {
31
+ tick: number;
32
+ entities: WasmEntityReport[];
33
+ /** True if WASM is available and ran successfully. */
34
+ ok: boolean;
35
+ summary: string;
36
+ }
37
+ export declare class WasmKernel {
38
+ private readonly push;
39
+ private readonly injury;
40
+ private static readonly PUSH_RADIUS_M;
41
+ private static readonly PUSH_REPEL_MPS2;
42
+ constructor(push: PushExports, injury: InjuryExports);
43
+ /**
44
+ * Run WASM push + injury steps on the current world state (shadow mode — does not
45
+ * mutate world). Returns a per-entity report and a one-line summary string.
46
+ *
47
+ * Call this after stepWorld() each tick for validation / diagnostics.
48
+ */
49
+ shadowStep(world: WorldState, tick: number): WasmStepReport;
50
+ }
51
+ /**
52
+ * Load push.wasm and injury.wasm from dist/as/ (co-located with this compiled module)
53
+ * and return a WasmKernel ready for use.
54
+ *
55
+ * Throws if the WASM files are not found (e.g. npm run build:wasm:all not yet run).
56
+ */
57
+ export declare function loadWasmKernel(): Promise<WasmKernel>;
58
+ export {};
@@ -0,0 +1,96 @@
1
+ // src/wasm-kernel.ts — Node.js loader and host bridge for the AssemblyScript WASM modules.
2
+ //
3
+ // Provides a shadow-mode step that runs as/push.wasm + as/injury.wasm alongside the
4
+ // TypeScript kernel. In shadow mode the WASM outputs are NOT applied to world state;
5
+ // they are returned for the caller to log or validate.
6
+ //
7
+ // Usage:
8
+ // const kernel = await loadWasmKernel(); // once at startup
9
+ // const report = kernel.shadowStep(world); // after stepWorld() each tick
10
+ // console.log(report.summary);
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { SCALE } from "./units.js";
14
+ // ── Region order shared with as/injury.ts ─────────────────────────────────────
15
+ const REGION_ORDER = ["head", "torso", "leftArm", "rightArm", "leftLeg", "rightLeg"];
16
+ // ── Kernel class ──────────────────────────────────────────────────────────────
17
+ export class WasmKernel {
18
+ push;
19
+ injury;
20
+ // Canonical kernel push tuning (mirrors src/sim/kernel.ts)
21
+ static PUSH_RADIUS_M = Math.trunc(0.45 * SCALE.m); // 4500
22
+ static PUSH_REPEL_MPS2 = Math.trunc(1.5 * SCALE.mps2); // 15000
23
+ constructor(push, injury) {
24
+ this.push = push;
25
+ this.injury = injury;
26
+ }
27
+ /**
28
+ * Run WASM push + injury steps on the current world state (shadow mode — does not
29
+ * mutate world). Returns a per-entity report and a one-line summary string.
30
+ *
31
+ * Call this after stepWorld() each tick for validation / diagnostics.
32
+ */
33
+ shadowStep(world, tick) {
34
+ const entities = world.entities;
35
+ const pushMax = this.push.MAX_ENTITIES.value;
36
+ const injMax = this.injury.MAX_ENTITIES.value;
37
+ const n = Math.min(entities.length, pushMax, injMax);
38
+ // ── Push pass (position-based repulsion) ──────────────────────────────────
39
+ for (let i = 0; i < n; i++) {
40
+ const e = entities[i];
41
+ this.push.writeEntity(i, e.position_m.x, e.position_m.y, e.injury.dead ? 0 : 1);
42
+ }
43
+ this.push.stepRepulsionPairs(n, WasmKernel.PUSH_RADIUS_M, WasmKernel.PUSH_REPEL_MPS2);
44
+ // ── Injury pass (clotting / bleed / shock / consciousness) ────────────────
45
+ for (let i = 0; i < n; i++) {
46
+ const e = entities[i];
47
+ const suff = e.condition["suffocation"] ?? 0;
48
+ this.injury.writeVitals(i, e.injury.fluidLoss, e.injury.shock, e.injury.consciousness, e.injury.dead ? 1 : 0, e.energy.fatigue, suff);
49
+ for (let r = 0; r < REGION_ORDER.length; r++) {
50
+ const reg = e.injury.byRegion[REGION_ORDER[r]];
51
+ this.injury.writeRegion(i, r, reg?.bleedingRate ?? 0, reg?.structuralDamage ?? 0, reg?.internalDamage ?? 0, reg?.["surfaceDamage"] ?? 0);
52
+ }
53
+ }
54
+ this.injury.stepBleedAndShock(n);
55
+ // ── Build report ──────────────────────────────────────────────────────────
56
+ const reports = [];
57
+ for (let i = 0; i < n; i++) {
58
+ const e = entities[i];
59
+ reports.push({
60
+ entityId: e.id,
61
+ pushDvX: this.push.readDvX(i),
62
+ pushDvY: this.push.readDvY(i),
63
+ projFluidLoss: this.injury.readFluidLoss(i),
64
+ projShock: this.injury.readShock(i),
65
+ projConsciousness: this.injury.readConsciousness(i),
66
+ projDead: this.injury.readDead(i) === 1,
67
+ });
68
+ }
69
+ const summary = reports
70
+ .map(r => `e${r.entityId} dv=(${r.pushDvX},${r.pushDvY}) ` +
71
+ `fl=${(r.projFluidLoss / SCALE.Q).toFixed(3)} ` +
72
+ `sh=${(r.projShock / SCALE.Q).toFixed(3)} ` +
73
+ `co=${(r.projConsciousness / SCALE.Q).toFixed(3)}` +
74
+ (r.projDead ? " DEAD" : ""))
75
+ .join(" | ");
76
+ return { tick, entities: reports, ok: true, summary: `[wasm] tick ${tick}: ${summary}` };
77
+ }
78
+ }
79
+ // ── Factory ───────────────────────────────────────────────────────────────────
80
+ /**
81
+ * Load push.wasm and injury.wasm from dist/as/ (co-located with this compiled module)
82
+ * and return a WasmKernel ready for use.
83
+ *
84
+ * Throws if the WASM files are not found (e.g. npm run build:wasm:all not yet run).
85
+ */
86
+ export async function loadWasmKernel() {
87
+ // Compiled to dist/src/wasm-kernel.js → WASM files are at ../as/*.wasm
88
+ const base = new URL("../as/", import.meta.url);
89
+ const pushBuf = readFileSync(fileURLToPath(new URL("push.wasm", base)));
90
+ const injuryBuf = readFileSync(fileURLToPath(new URL("injury.wasm", base)));
91
+ const [pushResult, injuryResult] = await Promise.all([
92
+ WebAssembly.instantiate(pushBuf),
93
+ WebAssembly.instantiate(injuryBuf),
94
+ ]);
95
+ return new WasmKernel(pushResult.instance.exports, injuryResult.instance.exports);
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -54,10 +54,15 @@
54
54
  "./competence": {
55
55
  "import": "./dist/src/competence/index.js",
56
56
  "types": "./dist/src/competence/index.d.ts"
57
+ },
58
+ "./wasm-kernel": {
59
+ "import": "./dist/src/wasm-kernel.js",
60
+ "types": "./dist/src/wasm-kernel.d.ts"
57
61
  }
58
62
  },
59
63
  "files": [
60
64
  "dist/src",
65
+ "dist/as",
61
66
  "docs/project-overview.md",
62
67
  "docs/host-contract.md",
63
68
  "docs/integration-primer.md",
@@ -113,17 +118,25 @@
113
118
  "generate-zoo": "node dist/tools/generate-zoo.js",
114
119
  "generate-map": "node dist/tools/generate-map.js",
115
120
  "world-server": "node dist/tools/world-server.js",
121
+ "persistent-world": "node dist/tools/persistent-world.js",
116
122
  "replication-server": "node dist/tools/replication-server.js",
117
123
  "benchmark-check": "node dist/tools/benchmark-check.js",
118
124
  "benchmark-check:strict": "node dist/tools/benchmark-check.js --threshold=0.10",
119
125
  "benchmark-check:update": "node dist/tools/benchmark-check.js --update-baseline",
120
126
  "benchmark:guide": "node dist/tools/benchmark-guide.js",
121
- "benchmark:parallel": "node dist/tools/benchmark-parallel.js"
127
+ "benchmark:parallel": "node dist/tools/benchmark-parallel.js",
128
+ "run:renderer-bridge": "node dist/tools/renderer-bridge.js",
129
+ "build:wasm": "asc as/units.ts --outFile dist/as/units.wasm --textFile dist/as/units.wat --runtime stub --optimize",
130
+ "build:wasm:push": "asc as/push.ts --outFile dist/as/push.wasm --textFile dist/as/push.wat --runtime stub --optimize --initialMemory 1",
131
+ "build:wasm:injury": "asc as/injury.ts --outFile dist/as/injury.wasm --textFile dist/as/injury.wat --runtime stub --optimize --initialMemory 1",
132
+ "build:wasm:all": "npm run build:wasm && npm run build:wasm:push && npm run build:wasm:injury",
133
+ "test:wasm": "npm run build:wasm:all && vitest run test/as/"
122
134
  },
123
135
  "devDependencies": {
124
136
  "@eslint/js": "^9.39.3",
125
137
  "@types/node": "^20.0.0",
126
138
  "@vitest/coverage-v8": "^2.1.8",
139
+ "assemblyscript": "^0.27.37",
127
140
  "eslint": "^9.39.3",
128
141
  "fast-check": "^4.6.0",
129
142
  "typescript": "^5.5.4",