@its-not-rocket-science/ananke 0.1.15 → 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.
- package/CHANGELOG.md +18 -2
- package/dist/src/battle-bridge.d.ts +94 -0
- package/dist/src/battle-bridge.js +126 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.1.16] — 2026-03-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **CE-5 · Persistent World Server** — campaign ↔ combat battle bridge:
|
|
14
|
+
- src/battle-bridge.ts: pure functions translating polity state to
|
|
15
|
+
BattleConfig and BattleOutcome back to PolityImpact[]. Covers
|
|
16
|
+
tech-era→loadout mapping, military-strength→team-size scaling,
|
|
17
|
+
deterministic battle seed, morale/stability/population impact.
|
|
18
|
+
27 tests in test/battle-bridge.test.ts.
|
|
19
|
+
- tools/persistent-world.ts: integrated server running polity tick +
|
|
20
|
+
synchronous tactical battles every 7 days per active war. Battle
|
|
21
|
+
outcomes mutate polity morale, stability, and population. Full
|
|
22
|
+
checkpoint/resume, WebSocket push, HTTP war/peace/save/reset/battles
|
|
23
|
+
endpoints. Run with: npm run persistent-world
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
9
27
|
## [0.1.15] — 2026-03-25
|
|
10
28
|
|
|
11
29
|
### Added
|
|
@@ -33,8 +51,6 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
33
51
|
accumulation parity with the TypeScript reference implementation.
|
|
34
52
|
- Build scripts: `npm run build:wasm:all`, `npm run test:wasm`.
|
|
35
53
|
|
|
36
|
-
## [Unreleased]
|
|
37
|
-
|
|
38
54
|
### Added
|
|
39
55
|
|
|
40
56
|
- **Phase 71 · Cultural Generation & Evolution Framework** (`src/culture.ts`)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { TechEra } from "./sim/tech.js";
|
|
2
|
+
import type { Polity } from "./polity.js";
|
|
3
|
+
/** Equipment loadout inferred from a polity's tech era. */
|
|
4
|
+
export interface EraLoadout {
|
|
5
|
+
archetype: string;
|
|
6
|
+
weaponId: string;
|
|
7
|
+
armourId: string;
|
|
8
|
+
}
|
|
9
|
+
/** Configuration for a single tactical battle between two polities. */
|
|
10
|
+
export interface BattleConfig {
|
|
11
|
+
/** Deterministic seed: combines world seed + day + polity ids. */
|
|
12
|
+
seed: number;
|
|
13
|
+
polityAId: string;
|
|
14
|
+
polityBId: string;
|
|
15
|
+
teamASize: number;
|
|
16
|
+
teamBSize: number;
|
|
17
|
+
loadoutA: EraLoadout;
|
|
18
|
+
loadoutB: EraLoadout;
|
|
19
|
+
/** Tick limit — battle is a draw if neither side wins by this tick. */
|
|
20
|
+
maxTicks: number;
|
|
21
|
+
}
|
|
22
|
+
/** Result reported by the caller after the battle completes. */
|
|
23
|
+
export interface BattleOutcome {
|
|
24
|
+
/** 1 = team A won, 2 = team B won, 0 = draw (timeout or mutual annihilation). */
|
|
25
|
+
winner: 0 | 1 | 2;
|
|
26
|
+
ticksElapsed: number;
|
|
27
|
+
teamACasualties: number;
|
|
28
|
+
teamBCasualties: number;
|
|
29
|
+
}
|
|
30
|
+
/** Per-polity state changes to apply after a battle. */
|
|
31
|
+
export interface PolityImpact {
|
|
32
|
+
polityId: string;
|
|
33
|
+
moraleDelta_Q: number;
|
|
34
|
+
stabilityDelta_Q: number;
|
|
35
|
+
populationLost: number;
|
|
36
|
+
}
|
|
37
|
+
/** Summary record written to the battle log. */
|
|
38
|
+
export interface BattleRecord {
|
|
39
|
+
day: number;
|
|
40
|
+
polityAId: string;
|
|
41
|
+
polityBId: string;
|
|
42
|
+
winner: 0 | 1 | 2;
|
|
43
|
+
teamACasualties: number;
|
|
44
|
+
teamBCasualties: number;
|
|
45
|
+
ticksElapsed: number;
|
|
46
|
+
}
|
|
47
|
+
/** Minimum and maximum combatants per side. */
|
|
48
|
+
export declare const MIN_TEAM_SIZE = 2;
|
|
49
|
+
export declare const MAX_TEAM_SIZE = 16;
|
|
50
|
+
/** Battle ends after this many ticks regardless of outcome (prevents infinite loops). */
|
|
51
|
+
export declare const DEFAULT_MAX_TICKS = 6000;
|
|
52
|
+
/** Morale bonus for winning a battle (Q units). */
|
|
53
|
+
export declare const WIN_MORALE_BONUS: number;
|
|
54
|
+
/** Morale penalty for losing a battle (Q units). */
|
|
55
|
+
export declare const LOSS_MORALE_PENALTY: number;
|
|
56
|
+
/** Stability penalty per 10% casualties above 20% casualty rate. */
|
|
57
|
+
export declare const CASUALTY_STABILITY_RATE: number;
|
|
58
|
+
/** Population lost per combatant casualty (polity headcount, not Q). */
|
|
59
|
+
export declare const POP_PER_CASUALTY = 50;
|
|
60
|
+
/**
|
|
61
|
+
* Returns the best available weapon and armour for a given tech era.
|
|
62
|
+
* Prehistoric → club + none; Ancient → knife + leather; Medieval+ → longsword + mail/plate.
|
|
63
|
+
*/
|
|
64
|
+
export declare function techEraToLoadout(era: TechEra): EraLoadout;
|
|
65
|
+
/**
|
|
66
|
+
* Converts a polity's military strength (Q) to a team size.
|
|
67
|
+
* q(0) → MIN_TEAM_SIZE; q(1.0) → MAX_TEAM_SIZE. Linear interpolation.
|
|
68
|
+
*/
|
|
69
|
+
export declare function militaryStrengthToTeamSize(militaryStrength_Q: number): number;
|
|
70
|
+
/**
|
|
71
|
+
* Produces a deterministic battle seed from the world seed, day, and polity ids.
|
|
72
|
+
* Ensures each polity pair on each day gets a unique, reproducible seed.
|
|
73
|
+
*/
|
|
74
|
+
export declare function battleSeed(worldSeed: number, day: number, polityAId: string, polityBId: string): number;
|
|
75
|
+
/**
|
|
76
|
+
* Builds a BattleConfig for a war between two polities.
|
|
77
|
+
* Team sizes scale with militaryStrength_Q; loadouts reflect each side's tech era.
|
|
78
|
+
*/
|
|
79
|
+
export declare function battleConfigFromPolities(polityA: Polity, polityB: Polity, worldSeed: number, day: number, maxTicks?: number): BattleConfig;
|
|
80
|
+
/**
|
|
81
|
+
* Derives the per-polity state changes to apply after a battle.
|
|
82
|
+
*
|
|
83
|
+
* Win: +WIN_MORALE_BONUS morale.
|
|
84
|
+
* Loss: −LOSS_MORALE_PENALTY morale.
|
|
85
|
+
* Draw: no morale change.
|
|
86
|
+
* Both sides: stability penalty proportional to casualties above 20%.
|
|
87
|
+
* Population: POP_PER_CASUALTY headcount lost per casualty.
|
|
88
|
+
*/
|
|
89
|
+
export declare function polityImpactFromBattle(outcome: BattleOutcome, config: BattleConfig): PolityImpact[];
|
|
90
|
+
/**
|
|
91
|
+
* Apply a PolityImpact to a polity in-place.
|
|
92
|
+
* Clamps morale and stability to [0, SCALE.Q].
|
|
93
|
+
*/
|
|
94
|
+
export declare function applyPolityImpact(polity: Polity, impact: PolityImpact): void;
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@its-not-rocket-science/ananke",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
"generate-zoo": "node dist/tools/generate-zoo.js",
|
|
119
119
|
"generate-map": "node dist/tools/generate-map.js",
|
|
120
120
|
"world-server": "node dist/tools/world-server.js",
|
|
121
|
+
"persistent-world": "node dist/tools/persistent-world.js",
|
|
121
122
|
"replication-server": "node dist/tools/replication-server.js",
|
|
122
123
|
"benchmark-check": "node dist/tools/benchmark-check.js",
|
|
123
124
|
"benchmark-check:strict": "node dist/tools/benchmark-check.js --threshold=0.10",
|