Repository navigation
Expand file tree
/
Copy pathscenario-state.ts
More file actions
164 lines (150 loc) · 7.07 KB
/
Copy pathscenario-state.ts
File metadata and controls
164 lines (150 loc) · 7.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
/**
* What a run's prompts say about the world's state, limited to what the
* scenario declares.
*
* The kernel seeds its Mars-heritage values (food, power, life support,
* Earth dependency and the rest) only in a run whose scenario declares
* them, or a run with no scenario. A run saved before that, or forked from
* such a run, still carries them all, and a scenario that does not declare
* them has no such things ("Food 18.0mo" in an AI lab); the Atlas Lab
* commanders wrote of "Earth-governed amplification" once the director
* read `governanceStatus: earth-governed`. So the prompts read them
* through here, and a scenario sees them only by declaring them in `world`.
*
* @module paracosm/runtime/util/scenario-state
*/
import type { ScenarioWorldSchema } from '../../engine/types.js';
/** The parts of a scenario's world these helpers read. */
export type DeclaredWorld = Partial<Pick<ScenarioWorldSchema, 'metrics' | 'capacities' | 'statuses' | 'politics'>>;
/** The kernel's default metrics that belong to the Mars and Lunar settlements. */
const HERITAGE_METRICS = new Set([
'powerKw',
'foodMonthsReserve',
'waterLitersPerDay',
'pressurizedVolumeM3',
'lifeSupportCapacity',
'infrastructureModules',
'scienceOutput',
]);
/** The kernel's default politics that belong to the Mars and Lunar settlements. */
const HERITAGE_POLITICS = new Set(['earthDependencyPct', 'governanceStatus', 'independencePressure']);
/** How to show a metric: every run has people and morale, declared or not. */
interface MetricDisplay {
label: string;
unit: string;
max?: number;
}
const UNIVERSAL_METRICS: Record<string, MetricDisplay> = {
population: { label: 'Population', unit: '' },
morale: { label: 'Morale', unit: '%', max: 1 },
};
/** The metrics a scenario's prompts may name: all but undeclared heritage ones. */
export function declaredMetrics(metrics: Record<string, number>, world?: DeclaredWorld | null): Record<string, number> {
const declared = { ...world?.metrics, ...world?.capacities };
return Object.fromEntries(
Object.entries(metrics).filter(([key]) => !HERITAGE_METRICS.has(key) || key in declared),
);
}
/**
* The politics a scenario's prompts may name: all but undeclared heritage
* ones. Mars declares `governanceStatus` among its statuses, so it counts
* there too.
*/
export function declaredPolitics(
politics: Record<string, number | string | boolean>,
world?: DeclaredWorld | null,
): Record<string, number | string | boolean> {
const declared = { ...world?.politics, ...world?.statuses };
return Object.fromEntries(
Object.entries(politics).filter(([key]) => !HERITAGE_POLITICS.has(key) || key in declared),
);
}
function formatValue(value: number, display?: MetricDisplay): string {
const plain = String(Number(value.toFixed(3)));
const unit = display?.unit ?? '';
if (unit === '%') {
// A percent may be declared as 0..1 (morale) or 0..100 (hull integrity).
const fraction = display?.max !== undefined ? display.max <= 1 : Math.abs(value) <= 1;
return fraction ? `${Math.round(value * 100)}%` : `${plain}%`;
}
return unit ? `${plain} ${unit}` : plain;
}
/**
* One metric's value as the prompts show it: with the unit the scenario
* declares for it (morale, declared or not, as a percent of 1: "100%";
* Mars food as "18 months"), else a share strictly between 0 and 1 as a
* percent and anything else as it is. The director's state lines read it.
*/
export function formatMetricValue(key: string, value: unknown, world?: DeclaredWorld | null): string {
if (typeof value !== 'number') return String(value);
const displays: Record<string, MetricDisplay> = { ...UNIVERSAL_METRICS, ...world?.capacities, ...world?.metrics };
const display = displays[key];
if (display) return formatValue(value, display);
return value < 1 && value > 0 ? `${Math.round(value * 100)}%` : String(value);
}
/**
* One politics value as the prompts show it: with the unit the scenario
* declares for it in `world.politics` (Mars independence pressure, a share
* of 1, at 1 as "100%"; Atlas Lab's board confidence, held on a 0 to 100
* scale, at 0.5 as "0.5%"), else a share strictly between 0 and 1 as a
* percent and anything else as it is. The director's politics lines read it.
*/
export function formatPoliticsValue(key: string, value: unknown, world?: DeclaredWorld | null): string {
if (typeof value !== 'number') return String(value);
const display = world?.politics?.[key];
if (display) return formatValue(value, display);
return value < 1 && value > 0 ? `${Math.round(value * 100)}%` : String(value);
}
/**
* One line of the scenario's metrics with their labels and units, for the
* department and commander prompts: "Researchers 480 people | Morale 85% |
* AlignmentBench 0.84 score | Release Readiness 65%".
*/
export function formatStateLine(metrics: Record<string, number>, world?: DeclaredWorld | null): string {
const displays: Record<string, MetricDisplay> = { ...UNIVERSAL_METRICS, ...world?.capacities, ...world?.metrics };
return Object.entries(declaredMetrics(metrics, world))
.filter(([, value]) => typeof value === 'number')
.map(([key, value]) => `${displays[key]?.label ?? key} ${formatValue(value, displays[key])}`)
.join(' | ');
}
type KeyDisplay = { label?: string; unit?: string; min?: number; max?: number };
/** A key a department's effect may move: one the run holds as a number. */
export interface EffectKey {
id: string;
/** The effect field it belongs in: `systemDelta` for a metric or capacity, `politicsDelta` for a politics value. */
bag: 'systemDelta' | 'politicsDelta';
label: string;
unit: string;
min?: number;
max?: number;
}
/**
* The keys a department's `recommendedEffects` may name: each number the
* run holds in its metrics (capacities included) or its politics, as the
* prompts may name them (see {@link declaredMetrics}), with the label,
* unit and range the scenario declares. The kernel drops a delta on any
* other key (`applyDeltas`), so a report is shown these and refused for
* any other.
*/
export function effectKeys(
state: { metrics: Record<string, number>; politics: Record<string, number | string | boolean> },
world?: DeclaredWorld | null,
): EffectKey[] {
const systems: Record<string, KeyDisplay> = { ...UNIVERSAL_METRICS, ...world?.capacities, ...world?.metrics };
const politics: Record<string, KeyDisplay> = { ...world?.politics };
const key = (id: string, bag: EffectKey['bag'], d: KeyDisplay | undefined): EffectKey => ({
id, bag,
label: d?.label ?? id,
unit: d?.unit ?? '',
...(typeof d?.min === 'number' ? { min: d.min } : {}),
...(typeof d?.max === 'number' ? { max: d.max } : {}),
});
const metricKeys = Object.entries(declaredMetrics(state.metrics, world))
.filter(([, v]) => typeof v === 'number')
.map(([id]) => key(id, 'systemDelta', systems[id]));
const held = new Set(metricKeys.map(k => k.id));
const politicsKeys = Object.entries(declaredPolitics(state.politics, world))
.filter(([id, v]) => typeof v === 'number' && !held.has(id))
.map(([id]) => key(id, 'politicsDelta', politics[id]));
return [...metricKeys, ...politicsKeys];
}