@its-not-rocket-science/ananke 0.1.35 → 0.1.36
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 -0
- package/dist/src/research.d.ts +103 -0
- package/dist/src/research.js +175 -0
- package/package.json +5 -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.36] — 2026-03-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Phase 91 · Technology Research** (`src/research.ts`)
|
|
14
|
+
- `ResearchState { polityId, progress }` — per-polity accumulator stored externally by the host.
|
|
15
|
+
- `RESEARCH_POINTS_REQUIRED: Record<number, number>` — numeric TechEra keys; Prehistoric 2 k → FarFuture 5 M; DeepSpace absent (no advancement).
|
|
16
|
+
- `computeDailyResearchPoints(polity, bonusPoints?)` → integer points/day: `baseUnits = max(1, floor(pop / RESEARCH_POP_DIVISOR=5000))`; `stabilityFactor ∈ [5000, 10000]`; `max(1, round(baseUnits × stabilityFactor / SCALE.Q)) + bonusPoints`.
|
|
17
|
+
- `stepResearch(polity, state, elapsedDays, bonusPoints?)` → `ResearchStepResult`: accumulates `daily × elapsedDays`; on threshold: increments `polity.techEra`, calls `deriveMilitaryStrength`, carries surplus; no-op at DeepSpace.
|
|
18
|
+
- `investInResearch(polity, state, amount)` — drains treasury at `RESEARCH_COST_PER_POINT = 10` cu/point; capped at available treasury; returns points added.
|
|
19
|
+
- `computeKnowledgeDiffusion(sourcePolity, targetPolity, contactIntensity_Q)` → bonus points/day: fires when `source.techEra > target.techEra`; `sourceDaily × eraDiff × KNOWLEDGE_DIFFUSION_RATE_Q(q(0.10)) × contactIntensity / SCALE.Q²`.
|
|
20
|
+
- `computeResearchProgress_Q(polity, state)` → Q [0, SCALE.Q]: fraction toward next era; SCALE.Q at DeepSpace.
|
|
21
|
+
- `estimateDaysToNextEra(polity, state, bonusPoints?)` → ceiling days; Infinity at DeepSpace or zero rate.
|
|
22
|
+
- Added `./research` subpath export to `package.json`.
|
|
23
|
+
- 57 new tests; 4,779 total. Coverage maintained above all thresholds.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
9
27
|
## [0.1.35] — 2026-03-26
|
|
10
28
|
|
|
11
29
|
### Added
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Q } from "./units.js";
|
|
2
|
+
import type { Polity } from "./polity.js";
|
|
3
|
+
/** Per-polity research progress. Store one externally per polity. */
|
|
4
|
+
export interface ResearchState {
|
|
5
|
+
polityId: string;
|
|
6
|
+
/** Accumulated research points toward the next era. */
|
|
7
|
+
progress: number;
|
|
8
|
+
}
|
|
9
|
+
/** Result returned by `stepResearch`. */
|
|
10
|
+
export interface ResearchStepResult {
|
|
11
|
+
/** Raw points added this step. */
|
|
12
|
+
pointsGained: number;
|
|
13
|
+
/** Whether the polity advanced to a new era this step. */
|
|
14
|
+
advanced: boolean;
|
|
15
|
+
/** New era if `advanced === true`, otherwise `undefined`. */
|
|
16
|
+
newEra?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Population divisor for base daily research units.
|
|
20
|
+
* `baseUnits = floor(population / RESEARCH_POP_DIVISOR)` — minimum 1.
|
|
21
|
+
*/
|
|
22
|
+
export declare const RESEARCH_POP_DIVISOR = 5000;
|
|
23
|
+
/**
|
|
24
|
+
* Research points required to advance FROM each TechEra to the next.
|
|
25
|
+
* Keyed by numeric TechEra value. `Infinity` (absent) = max era, no advancement.
|
|
26
|
+
*/
|
|
27
|
+
export declare const RESEARCH_POINTS_REQUIRED: Record<number, number>;
|
|
28
|
+
/**
|
|
29
|
+
* Treasury cost per research point when using `investInResearch`.
|
|
30
|
+
* 10 cost-units = 1 research point.
|
|
31
|
+
*/
|
|
32
|
+
export declare const RESEARCH_COST_PER_POINT = 10;
|
|
33
|
+
/**
|
|
34
|
+
* Fraction of the source polity's daily research rate that diffuses to a
|
|
35
|
+
* less-advanced trade partner per era of difference.
|
|
36
|
+
*/
|
|
37
|
+
export declare const KNOWLEDGE_DIFFUSION_RATE_Q: Q;
|
|
38
|
+
/** Create a fresh `ResearchState` with zero progress. */
|
|
39
|
+
export declare function createResearchState(polityId: string): ResearchState;
|
|
40
|
+
/**
|
|
41
|
+
* Points required to advance from the polity's current era.
|
|
42
|
+
* Returns `Infinity` at max era (no advancement possible).
|
|
43
|
+
*/
|
|
44
|
+
export declare function pointsRequiredForNextEra(polity: Polity): number;
|
|
45
|
+
/**
|
|
46
|
+
* Compute the daily research rate for a polity [integer points/day].
|
|
47
|
+
*
|
|
48
|
+
* Formula:
|
|
49
|
+
* baseUnits = max(1, floor(population / RESEARCH_POP_DIVISOR))
|
|
50
|
+
* stabilityFactor = SCALE.Q/2 + mulDiv(SCALE.Q/2, stabilityQ, SCALE.Q)
|
|
51
|
+
* ∈ [q(0.50), q(1.00)] = [5000, 10000]
|
|
52
|
+
* dailyPoints = max(1, round(baseUnits × stabilityFactor / SCALE.Q))
|
|
53
|
+
*
|
|
54
|
+
* @param bonusPoints Additional flat bonus points per day (e.g., from
|
|
55
|
+
* knowledge diffusion or Phase-89 infrastructure).
|
|
56
|
+
*/
|
|
57
|
+
export declare function computeDailyResearchPoints(polity: Polity, bonusPoints?: number): number;
|
|
58
|
+
/**
|
|
59
|
+
* Advance research for `elapsedDays` days.
|
|
60
|
+
*
|
|
61
|
+
* Adds `computeDailyResearchPoints(polity) × elapsedDays` to `state.progress`.
|
|
62
|
+
* When progress meets or exceeds `pointsRequiredForNextEra`:
|
|
63
|
+
* - Excess progress carries over.
|
|
64
|
+
* - `polity.techEra` is incremented.
|
|
65
|
+
* - `deriveMilitaryStrength` is refreshed.
|
|
66
|
+
*
|
|
67
|
+
* Only one era advancement occurs per call regardless of elapsed days.
|
|
68
|
+
* At DeepSpace (max era) the call is a no-op.
|
|
69
|
+
*
|
|
70
|
+
* @param bonusPoints Flat daily bonus from knowledge diffusion or infrastructure.
|
|
71
|
+
*/
|
|
72
|
+
export declare function stepResearch(polity: Polity, state: ResearchState, elapsedDays: number, bonusPoints?: number): ResearchStepResult;
|
|
73
|
+
/**
|
|
74
|
+
* Invest treasury into research, immediately adding points.
|
|
75
|
+
*
|
|
76
|
+
* Rate: `RESEARCH_COST_PER_POINT` cost-units = 1 point.
|
|
77
|
+
* Drains `min(amount, polity.treasury_cu)`. No-ops if treasury is empty.
|
|
78
|
+
*
|
|
79
|
+
* Returns the actual number of research points added.
|
|
80
|
+
*/
|
|
81
|
+
export declare function investInResearch(polity: Polity, state: ResearchState, amount: number): number;
|
|
82
|
+
/**
|
|
83
|
+
* Compute daily knowledge diffusion bonus that a source polity grants to a
|
|
84
|
+
* less-advanced target polity through trade or diplomatic contact.
|
|
85
|
+
*
|
|
86
|
+
* Diffusion fires only when `sourcePolity.techEra > targetPolity.techEra`.
|
|
87
|
+
*
|
|
88
|
+
* Formula: `round(sourceDaily × eraDiff × DIFFUSION_RATE × contactIntensity / SCALE.Q²)`
|
|
89
|
+
*
|
|
90
|
+
* @param contactIntensity_Q Trade or diplomatic contact [0, SCALE.Q].
|
|
91
|
+
* Derive from Phase-83 route efficiency or Phase-80 treaty strength.
|
|
92
|
+
*/
|
|
93
|
+
export declare function computeKnowledgeDiffusion(sourcePolity: Polity, targetPolity: Polity, contactIntensity_Q: Q): number;
|
|
94
|
+
/**
|
|
95
|
+
* Return current research progress as a Q fraction [0, SCALE.Q] toward the next era.
|
|
96
|
+
* Returns `SCALE.Q` at max era (DeepSpace).
|
|
97
|
+
*/
|
|
98
|
+
export declare function computeResearchProgress_Q(polity: Polity, state: ResearchState): Q;
|
|
99
|
+
/**
|
|
100
|
+
* Estimate days until the next era advance at the current daily research rate.
|
|
101
|
+
* Returns `Infinity` at max era or when rate is zero.
|
|
102
|
+
*/
|
|
103
|
+
export declare function estimateDaysToNextEra(polity: Polity, state: ResearchState, bonusPoints?: number): number;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// src/research.ts — Phase 91: Technology Research
|
|
2
|
+
//
|
|
3
|
+
// Polities accumulate research points from their population and stability.
|
|
4
|
+
// When accumulated points reach the era threshold the polity advances to the
|
|
5
|
+
// next TechEra; treasury investment buys additional progress; contact with a
|
|
6
|
+
// more advanced polity (via Phase-83 trade routes) grants knowledge diffusion.
|
|
7
|
+
//
|
|
8
|
+
// Design:
|
|
9
|
+
// - Pure data layer — no Entity fields, no kernel changes.
|
|
10
|
+
// - `ResearchState` is separate from Polity; host stores one per polity.
|
|
11
|
+
// - Uses numeric TechEra values (0–8) from Phase-11 tech.ts.
|
|
12
|
+
// - `stepResearch` mutates both `state.progress` and `polity.techEra`.
|
|
13
|
+
// - All arithmetic is integer fixed-point; no floating-point accumulation.
|
|
14
|
+
//
|
|
15
|
+
// Integration:
|
|
16
|
+
// Phase 11 (Tech): TechEra numeric enum — advancement increments polity.techEra.
|
|
17
|
+
// Phase 61 (Polity): population, stabilityQ, treasury_cu are read/mutated.
|
|
18
|
+
// Phase 83 (Trade): contactIntensity_Q drives knowledge diffusion.
|
|
19
|
+
// Phase 89 (Infra): hosts may add infrastructure bonuses to daily rate.
|
|
20
|
+
import { q, SCALE, clampQ, mulDiv } from "./units.js";
|
|
21
|
+
import { deriveMilitaryStrength } from "./polity.js";
|
|
22
|
+
import { TechEra } from "./sim/tech.js";
|
|
23
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* Population divisor for base daily research units.
|
|
26
|
+
* `baseUnits = floor(population / RESEARCH_POP_DIVISOR)` — minimum 1.
|
|
27
|
+
*/
|
|
28
|
+
export const RESEARCH_POP_DIVISOR = 5_000;
|
|
29
|
+
/**
|
|
30
|
+
* Research points required to advance FROM each TechEra to the next.
|
|
31
|
+
* Keyed by numeric TechEra value. `Infinity` (absent) = max era, no advancement.
|
|
32
|
+
*/
|
|
33
|
+
export const RESEARCH_POINTS_REQUIRED = {
|
|
34
|
+
[TechEra.Prehistoric]: 2_000,
|
|
35
|
+
[TechEra.Ancient]: 8_000,
|
|
36
|
+
[TechEra.Medieval]: 30_000,
|
|
37
|
+
[TechEra.EarlyModern]: 80_000,
|
|
38
|
+
[TechEra.Industrial]: 200_000,
|
|
39
|
+
[TechEra.Modern]: 500_000,
|
|
40
|
+
[TechEra.NearFuture]: 1_500_000,
|
|
41
|
+
[TechEra.FarFuture]: 5_000_000,
|
|
42
|
+
// TechEra.DeepSpace (8): no entry → no advancement
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Treasury cost per research point when using `investInResearch`.
|
|
46
|
+
* 10 cost-units = 1 research point.
|
|
47
|
+
*/
|
|
48
|
+
export const RESEARCH_COST_PER_POINT = 10;
|
|
49
|
+
/**
|
|
50
|
+
* Fraction of the source polity's daily research rate that diffuses to a
|
|
51
|
+
* less-advanced trade partner per era of difference.
|
|
52
|
+
*/
|
|
53
|
+
export const KNOWLEDGE_DIFFUSION_RATE_Q = q(0.10);
|
|
54
|
+
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
55
|
+
/** Create a fresh `ResearchState` with zero progress. */
|
|
56
|
+
export function createResearchState(polityId) {
|
|
57
|
+
return { polityId, progress: 0 };
|
|
58
|
+
}
|
|
59
|
+
// ── Rate computation ──────────────────────────────────────────────────────────
|
|
60
|
+
/**
|
|
61
|
+
* Points required to advance from the polity's current era.
|
|
62
|
+
* Returns `Infinity` at max era (no advancement possible).
|
|
63
|
+
*/
|
|
64
|
+
export function pointsRequiredForNextEra(polity) {
|
|
65
|
+
return RESEARCH_POINTS_REQUIRED[polity.techEra] ?? Infinity;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compute the daily research rate for a polity [integer points/day].
|
|
69
|
+
*
|
|
70
|
+
* Formula:
|
|
71
|
+
* baseUnits = max(1, floor(population / RESEARCH_POP_DIVISOR))
|
|
72
|
+
* stabilityFactor = SCALE.Q/2 + mulDiv(SCALE.Q/2, stabilityQ, SCALE.Q)
|
|
73
|
+
* ∈ [q(0.50), q(1.00)] = [5000, 10000]
|
|
74
|
+
* dailyPoints = max(1, round(baseUnits × stabilityFactor / SCALE.Q))
|
|
75
|
+
*
|
|
76
|
+
* @param bonusPoints Additional flat bonus points per day (e.g., from
|
|
77
|
+
* knowledge diffusion or Phase-89 infrastructure).
|
|
78
|
+
*/
|
|
79
|
+
export function computeDailyResearchPoints(polity, bonusPoints = 0) {
|
|
80
|
+
const baseUnits = Math.max(1, Math.floor(polity.population / RESEARCH_POP_DIVISOR));
|
|
81
|
+
const stabilityFactor = SCALE.Q / 2 + mulDiv(SCALE.Q / 2, polity.stabilityQ, SCALE.Q);
|
|
82
|
+
const base = Math.max(1, Math.round(baseUnits * stabilityFactor / SCALE.Q));
|
|
83
|
+
return base + bonusPoints;
|
|
84
|
+
}
|
|
85
|
+
// ── Research step ─────────────────────────────────────────────────────────────
|
|
86
|
+
/**
|
|
87
|
+
* Advance research for `elapsedDays` days.
|
|
88
|
+
*
|
|
89
|
+
* Adds `computeDailyResearchPoints(polity) × elapsedDays` to `state.progress`.
|
|
90
|
+
* When progress meets or exceeds `pointsRequiredForNextEra`:
|
|
91
|
+
* - Excess progress carries over.
|
|
92
|
+
* - `polity.techEra` is incremented.
|
|
93
|
+
* - `deriveMilitaryStrength` is refreshed.
|
|
94
|
+
*
|
|
95
|
+
* Only one era advancement occurs per call regardless of elapsed days.
|
|
96
|
+
* At DeepSpace (max era) the call is a no-op.
|
|
97
|
+
*
|
|
98
|
+
* @param bonusPoints Flat daily bonus from knowledge diffusion or infrastructure.
|
|
99
|
+
*/
|
|
100
|
+
export function stepResearch(polity, state, elapsedDays, bonusPoints = 0) {
|
|
101
|
+
const daily = computeDailyResearchPoints(polity, bonusPoints);
|
|
102
|
+
const gained = daily * elapsedDays;
|
|
103
|
+
state.progress += gained;
|
|
104
|
+
const required = pointsRequiredForNextEra(polity);
|
|
105
|
+
const maxEra = TechEra.DeepSpace;
|
|
106
|
+
const canAdvance = polity.techEra < maxEra && isFinite(required) && state.progress >= required;
|
|
107
|
+
if (canAdvance) {
|
|
108
|
+
state.progress -= required; // carry over surplus
|
|
109
|
+
polity.techEra = (polity.techEra + 1);
|
|
110
|
+
deriveMilitaryStrength(polity);
|
|
111
|
+
return { pointsGained: gained, advanced: true, newEra: polity.techEra };
|
|
112
|
+
}
|
|
113
|
+
return { pointsGained: gained, advanced: false };
|
|
114
|
+
}
|
|
115
|
+
// ── Treasury investment ───────────────────────────────────────────────────────
|
|
116
|
+
/**
|
|
117
|
+
* Invest treasury into research, immediately adding points.
|
|
118
|
+
*
|
|
119
|
+
* Rate: `RESEARCH_COST_PER_POINT` cost-units = 1 point.
|
|
120
|
+
* Drains `min(amount, polity.treasury_cu)`. No-ops if treasury is empty.
|
|
121
|
+
*
|
|
122
|
+
* Returns the actual number of research points added.
|
|
123
|
+
*/
|
|
124
|
+
export function investInResearch(polity, state, amount) {
|
|
125
|
+
const actual = Math.min(amount, polity.treasury_cu);
|
|
126
|
+
const points = Math.floor(actual / RESEARCH_COST_PER_POINT);
|
|
127
|
+
polity.treasury_cu -= actual;
|
|
128
|
+
state.progress += points;
|
|
129
|
+
return points;
|
|
130
|
+
}
|
|
131
|
+
// ── Knowledge diffusion ───────────────────────────────────────────────────────
|
|
132
|
+
/**
|
|
133
|
+
* Compute daily knowledge diffusion bonus that a source polity grants to a
|
|
134
|
+
* less-advanced target polity through trade or diplomatic contact.
|
|
135
|
+
*
|
|
136
|
+
* Diffusion fires only when `sourcePolity.techEra > targetPolity.techEra`.
|
|
137
|
+
*
|
|
138
|
+
* Formula: `round(sourceDaily × eraDiff × DIFFUSION_RATE × contactIntensity / SCALE.Q²)`
|
|
139
|
+
*
|
|
140
|
+
* @param contactIntensity_Q Trade or diplomatic contact [0, SCALE.Q].
|
|
141
|
+
* Derive from Phase-83 route efficiency or Phase-80 treaty strength.
|
|
142
|
+
*/
|
|
143
|
+
export function computeKnowledgeDiffusion(sourcePolity, targetPolity, contactIntensity_Q) {
|
|
144
|
+
if (sourcePolity.techEra <= targetPolity.techEra)
|
|
145
|
+
return 0;
|
|
146
|
+
const eraDiff = sourcePolity.techEra - targetPolity.techEra;
|
|
147
|
+
const sourceRate = computeDailyResearchPoints(sourcePolity);
|
|
148
|
+
const step1 = mulDiv(sourceRate * eraDiff, KNOWLEDGE_DIFFUSION_RATE_Q, SCALE.Q);
|
|
149
|
+
return Math.max(0, Math.round(step1 * contactIntensity_Q / SCALE.Q));
|
|
150
|
+
}
|
|
151
|
+
// ── Progress reporting ────────────────────────────────────────────────────────
|
|
152
|
+
/**
|
|
153
|
+
* Return current research progress as a Q fraction [0, SCALE.Q] toward the next era.
|
|
154
|
+
* Returns `SCALE.Q` at max era (DeepSpace).
|
|
155
|
+
*/
|
|
156
|
+
export function computeResearchProgress_Q(polity, state) {
|
|
157
|
+
const required = pointsRequiredForNextEra(polity);
|
|
158
|
+
if (!isFinite(required))
|
|
159
|
+
return SCALE.Q;
|
|
160
|
+
return clampQ(Math.round(state.progress * SCALE.Q / required), 0, SCALE.Q);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Estimate days until the next era advance at the current daily research rate.
|
|
164
|
+
* Returns `Infinity` at max era or when rate is zero.
|
|
165
|
+
*/
|
|
166
|
+
export function estimateDaysToNextEra(polity, state, bonusPoints = 0) {
|
|
167
|
+
const required = pointsRequiredForNextEra(polity);
|
|
168
|
+
if (!isFinite(required))
|
|
169
|
+
return Infinity;
|
|
170
|
+
const remaining = Math.max(0, required - state.progress);
|
|
171
|
+
const daily = computeDailyResearchPoints(polity, bonusPoints);
|
|
172
|
+
if (daily <= 0)
|
|
173
|
+
return Infinity;
|
|
174
|
+
return Math.ceil(remaining / daily);
|
|
175
|
+
}
|
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.36",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -126,6 +126,10 @@
|
|
|
126
126
|
"./unrest": {
|
|
127
127
|
"import": "./dist/src/unrest.js",
|
|
128
128
|
"types": "./dist/src/unrest.d.ts"
|
|
129
|
+
},
|
|
130
|
+
"./research": {
|
|
131
|
+
"import": "./dist/src/research.js",
|
|
132
|
+
"types": "./dist/src/research.d.ts"
|
|
129
133
|
}
|
|
130
134
|
},
|
|
131
135
|
"files": [
|