hoi4-agent-tools 4.4.0 → 4.6.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
  3. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  4. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  5. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  6. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  7. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  8. package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
  9. package/dist/hoi4_agent_tools/map/derived.js +16 -3
  10. package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
  11. package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
  12. package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
  13. package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
  14. package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
  15. package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
  16. package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
  17. package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
  18. package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
  19. package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
  20. package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
  21. package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
  22. package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
  23. package/dist/hoi4_agent_tools/map/model.js +2 -1
  24. package/dist/hoi4_agent_tools/map/operations.js +282 -38
  25. package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
  26. package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
  27. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
  28. package/dist/hoi4_agent_tools/map/service.js +93 -16
  29. package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
  30. package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
  31. package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
  32. package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
  33. package/dist/hoi4_agent_tools/map/territory.js +1108 -0
  34. package/dist/hoi4_agent_tools/map/tool-operations.js +119 -1
  35. package/dist/hoi4_agent_tools/map/validation.js +4 -0
  36. package/dist/hoi4_agent_tools/map/world-context.js +23 -0
  37. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  38. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  39. package/dist/hoi4_agent_tools/map/world.js +400 -145
  40. package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
  41. package/dist/hoi4_agent_tools/schemas/map.js +277 -34
  42. package/dist/hoi4_agent_tools/version.js +1 -1
  43. package/docs/focus.md +6 -0
  44. package/docs/map.md +132 -4
  45. package/npm-shrinkwrap.json +2 -2
  46. package/package.json +1 -1
  47. package/schemas/configuration.schema.json +1 -1
  48. package/schemas/continuous-focus-palette.schema.json +1 -1
  49. package/schemas/custom-weighted-pool.schema.json +1 -1
  50. package/schemas/decision-inspect-input.schema.json +1 -1
  51. package/schemas/error-log-input.schema.json +1 -1
  52. package/schemas/event-feature-manifest.schema.json +1 -1
  53. package/schemas/focus-plan.schema.json +1 -1
  54. package/schemas/focus-planning-sidecar.schema.json +1 -1
  55. package/schemas/geography-model.schema.json +13 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +1541 -786
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/reference-sheets.schema.json +1 -1
  81. package/schemas/scenario-suite.schema.json +1 -1
  82. package/schemas/scenario-test-input.schema.json +1 -1
  83. package/schemas/script-validate-input.schema.json +1 -1
  84. package/schemas/shared-scenario.schema.json +1 -1
  85. package/schemas/source-lookup-input.schema.json +1 -1
  86. package/server.json +2 -2
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Ports and naval bases from the shape of the coast (ADR 0063). Every coastal province gets a
3
+ * harbour score from how sheltered its coast is (water share in a small and a large disc around
4
+ * the coast point), river mouths, the depth class of the sea it faces, the length of its coast
5
+ * and the port and city sites of the model. A state gets a port where the score is high, where a
6
+ * port site stands, or where its country has no other port on that coast.
7
+ */
8
+ import { LAND, SEA, centerOf, distanceOnMap, pairKey } from './facts.js';
9
+ import { SITE_WEIGHT } from './sites.js';
10
+ function clamp01(value) {
11
+ return value < 0 ? 0 : value > 1 ? 1 : value;
12
+ }
13
+ const DEPTH_SCORE = { shelf: 1, open: 0.6, abyss: 0.3 };
14
+ /** Share of non-land samples in the disc of `radius` pixels around a point. */
15
+ function waterShare(base, cx, cy, radius) {
16
+ const { width, height } = base.input;
17
+ const kind = base.input.provinces.kind;
18
+ const step = Math.max(1, Math.round(radius / 7));
19
+ let water = 0;
20
+ let total = 0;
21
+ for (let dy = -radius; dy <= radius; dy += step)
22
+ for (let dx = -radius; dx <= radius; dx += step) {
23
+ if (dx * dx + dy * dy > radius * radius)
24
+ continue;
25
+ const y = Math.round(cy + dy);
26
+ if (y < 0 || y >= height)
27
+ continue;
28
+ const x = ((Math.round(cx + dx) % width) + width) % width;
29
+ total += 1;
30
+ if (kind[base.view.pixel[y * width + x]] !== LAND)
31
+ water += 1;
32
+ }
33
+ return total === 0 ? 0.5 : water / total;
34
+ }
35
+ export function harbourScores(base, metrics, sites) {
36
+ const { width, geography, provinces } = base.input;
37
+ const kind = provinces.kind;
38
+ const count = base.view.count;
39
+ const small = Math.max(5, Math.round(0.006 * width));
40
+ const large = Math.max(14, Math.round(0.02 * width));
41
+ // Site strength near each province: in the province itself, or one province away.
42
+ const siteHere = new Float64Array(count);
43
+ for (const { site, province } of sites) {
44
+ if (province === undefined)
45
+ continue;
46
+ const weight = site.siteKind === 'port'
47
+ ? 1
48
+ : site.siteKind === 'capital' || site.siteKind === 'city'
49
+ ? 0.5
50
+ : SITE_WEIGHT[site.siteKind] * 0.3;
51
+ siteHere[province] = Math.max(siteHere[province], weight * (0.5 + site.importance / 10));
52
+ }
53
+ const out = new Map();
54
+ for (let id = 0; id < count; id += 1) {
55
+ if (kind[id] !== LAND)
56
+ continue;
57
+ let sea = -1;
58
+ let coast = 0;
59
+ let stat;
60
+ for (const other of [...provinces.neighbors[id]].sort((a, b) => a - b)) {
61
+ if (kind[other] !== SEA)
62
+ continue;
63
+ const candidate = base.borders.get(pairKey(id, other, count));
64
+ if (candidate !== undefined && candidate.count > coast) {
65
+ coast = candidate.count;
66
+ sea = other;
67
+ stat = candidate;
68
+ }
69
+ }
70
+ if (sea < 0 || stat === undefined)
71
+ continue;
72
+ let totalCoast = 0;
73
+ for (const other of provinces.neighbors[id])
74
+ if (kind[other] === SEA)
75
+ totalCoast += base.borders.get(pairKey(id, other, count))?.count ?? 0;
76
+ const cx = stat.sumX / stat.count;
77
+ const cy = stat.sumY / stat.count;
78
+ const w1 = waterShare(base, cx, cy, small);
79
+ const w2 = waterShare(base, cx, cy, large);
80
+ const concavity = clamp01((0.58 - w1) / 0.3);
81
+ const shelter = clamp01((0.6 - w2) / 0.35);
82
+ const mouth = clamp01(metrics.mouthWidth[id] / 11);
83
+ const seaFeature = base.features.sea[sea];
84
+ const lakeFeature = base.features.lake[sea];
85
+ const depthClass = lakeFeature >= 0 && seaFeature < 0
86
+ ? geography.lakes[lakeFeature].depthClass
87
+ : seaFeature >= 0
88
+ ? geography.seas[seaFeature].depthClass
89
+ : 'open';
90
+ const depth = DEPTH_SCORE[depthClass] ?? 0.6;
91
+ let near = siteHere[id];
92
+ for (const other of provinces.neighbors[id])
93
+ if (kind[other] === LAND)
94
+ near = Math.max(near, siteHere[other] * 0.6);
95
+ const contact = clamp01(totalCoast / Math.max(6, 2 * Math.sqrt(provinces.size[id])));
96
+ const score = clamp01(0.28 * concavity + 0.2 * shelter + 0.14 * mouth + 0.1 * depth + 0.18 * near + 0.1 * contact);
97
+ out.set(id, {
98
+ province: id,
99
+ sea,
100
+ score,
101
+ concavity,
102
+ shelter,
103
+ mouth,
104
+ depth,
105
+ sites: near,
106
+ coast: totalCoast,
107
+ });
108
+ }
109
+ return out;
110
+ }
111
+ /** The share of coastal states that get a port by score alone; port sites and capitals add more. */
112
+ export const PORT_STATE_SHARE = 0.45;
113
+ export function planPorts(base, scores, sites, states) {
114
+ const { provinces, geography } = base.input;
115
+ const kind = provinces.kind;
116
+ const ports = new Map();
117
+ const dockyards = new Set();
118
+ const notes = [];
119
+ const stateOfProvince = new Map();
120
+ for (const state of states)
121
+ for (const id of state.provinces)
122
+ stateOfProvince.set(id, state);
123
+ // Port sites name their province directly; one outside the coast cannot hold a port.
124
+ const portSiteOf = new Map();
125
+ for (const located of sites) {
126
+ const { site, province } = located;
127
+ if (site.siteKind !== 'port' || province === undefined)
128
+ continue;
129
+ if (site.lakeId !== undefined) {
130
+ const lake = geography.get('lake', site.lakeId);
131
+ if (lake !== undefined && lake.gameplay !== 'inland-sea') {
132
+ notes.push({
133
+ code: 'MAP_PORT_LAKE_UNSUPPORTED',
134
+ severity: 'warning',
135
+ message: `Port ${site.id} stands on lake ${lake.id}, which is a decorative lake: the engine gives a lake province no fleet and no naval base. Set the lake's gameplay to "inland-sea" to give it ports, or make the site a city`,
136
+ details: { siteId: site.id, lakeId: lake.id },
137
+ });
138
+ continue;
139
+ }
140
+ }
141
+ // A site on the shore may fall in a province just inland; the nearest harbour within a few
142
+ // percent of the map width then holds the port.
143
+ let target = province;
144
+ if (!scores.has(target)) {
145
+ const x = site.at.x * base.input.width;
146
+ const y = site.at.y * base.input.height;
147
+ let nearest = 0.03 * base.input.width;
148
+ for (const candidate of scores.keys()) {
149
+ const centre = centerOf(provinces, candidate);
150
+ const d = distanceOnMap(centre.x, centre.y, x, y, base.input.width);
151
+ if (d < nearest) {
152
+ nearest = d;
153
+ target = candidate;
154
+ }
155
+ }
156
+ }
157
+ if (!scores.has(target)) {
158
+ notes.push({
159
+ code: 'MAP_PORT_SITE_NOT_COASTAL',
160
+ severity: 'warning',
161
+ message: `Port site ${site.id} falls in an inland province (${province + 1}); no naval base was placed. Move the site to the coast`,
162
+ details: { siteId: site.id, province: province + 1 },
163
+ });
164
+ continue;
165
+ }
166
+ portSiteOf.set(target, Math.max(portSiteOf.get(target) ?? 0, site.importance));
167
+ }
168
+ const levelOf = (score, capital, importance) => Math.max(1, Math.min(6, 1 + Math.round(3 * score.score) + Math.max(0, importance - 2) + (capital ? 1 : 0)));
169
+ // States ranked by their best harbour.
170
+ const best = new Map();
171
+ for (const state of states) {
172
+ let top;
173
+ for (const id of state.provinces) {
174
+ const score = scores.get(id);
175
+ if (score === undefined)
176
+ continue;
177
+ if (top === undefined ||
178
+ score.score > top.score ||
179
+ (score.score === top.score && id < top.province))
180
+ top = score;
181
+ }
182
+ if (top !== undefined)
183
+ best.set(state.id, top);
184
+ }
185
+ const ranked = [...best.values()].map((score) => score.score).sort((a, b) => b - a);
186
+ const cutoff = ranked.length === 0
187
+ ? 1
188
+ : ranked[Math.min(ranked.length - 1, Math.floor(PORT_STATE_SHARE * ranked.length))];
189
+ const assign = (state, score, reason, importance) => {
190
+ const level = levelOf(score, state.capital, importance);
191
+ const previous = ports.get(score.province);
192
+ if (previous !== undefined && previous.level >= level)
193
+ return;
194
+ ports.set(score.province, { province: score.province, level, score: score.score, reason });
195
+ if (level >= 3 || state.capital)
196
+ dockyards.add(state.id);
197
+ };
198
+ for (const state of states) {
199
+ const sited = state.provinces
200
+ .filter((id) => portSiteOf.has(id))
201
+ .sort((a, b) => portSiteOf.get(b) - portSiteOf.get(a) || a - b);
202
+ for (const id of sited)
203
+ assign(state, scores.get(id), 'site', portSiteOf.get(id));
204
+ const top = best.get(state.id);
205
+ if (top === undefined || sited.length > 0)
206
+ continue;
207
+ if (state.capital)
208
+ assign(state, top, 'capital', 0);
209
+ else if (top.score >= cutoff && top.score > 0.25)
210
+ assign(state, top, 'score', 0);
211
+ }
212
+ // Every coastal territory of a country keeps at least one port.
213
+ const seen = new Set();
214
+ for (const state of states) {
215
+ for (const start of state.provinces) {
216
+ if (seen.has(start) || kind[start] !== LAND)
217
+ continue;
218
+ const queue = [start];
219
+ seen.add(start);
220
+ let hasPort = false;
221
+ let top;
222
+ for (const id of queue) {
223
+ if (ports.has(id))
224
+ hasPort = true;
225
+ const score = scores.get(id);
226
+ if (score !== undefined && (top === undefined || score.score > top.score))
227
+ top = score;
228
+ for (const next of provinces.neighbors[id])
229
+ if (!seen.has(next) &&
230
+ kind[next] === LAND &&
231
+ stateOfProvince.get(next)?.owner === state.owner) {
232
+ seen.add(next);
233
+ queue.push(next);
234
+ }
235
+ }
236
+ if (!hasPort && top !== undefined) {
237
+ const owner = stateOfProvince.get(top.province);
238
+ if (owner !== undefined)
239
+ assign(owner, top, 'coast-floor', 0);
240
+ }
241
+ }
242
+ }
243
+ return { ports, dockyards, notes };
244
+ }
@@ -0,0 +1,27 @@
1
+ import { planCrossings } from './crossings.js';
2
+ import { buildBorderTable, provinceFeatures, provinceViewOf, } from './facts.js';
3
+ export function prepareGameplay(input) {
4
+ const view = provinceViewOf(input.provinces, input.width, input.height);
5
+ const features = provinceFeatures({
6
+ geography: input.geography,
7
+ view,
8
+ kind: input.provinces.kind,
9
+ });
10
+ const borders = buildBorderTable({ view, heights: input.heights, rivers: input.riverBitmap });
11
+ const crossings = planCrossings({
12
+ width: input.width,
13
+ height: input.height,
14
+ geography: input.geography,
15
+ view,
16
+ kind: input.provinces.kind,
17
+ neighbors: input.provinces.neighbors,
18
+ center: input.provinces,
19
+ features,
20
+ borders,
21
+ autoStraits: input.autoStraits,
22
+ inlandSeas: input.inlandSeas,
23
+ impassableLimit: input.impassableLimit,
24
+ signal: input.signal,
25
+ });
26
+ return { input, view, features, borders, crossings, notes: [...crossings.notes] };
27
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The cost of laying a railway between two neighbouring provinces (ADR 0063). A railway follows
3
+ * the cheapest route through the land, so the cost has to say what is hard to build over: distance
4
+ * between the province centres, the ground (mountain, marsh and jungle cost more), the climb
5
+ * across the border (a pass is a low point of the ridge, so crossing it is cheap and crossing the
6
+ * ridge elsewhere is not), a river on the border, and a model road, which makes the way easier.
7
+ */
8
+ import { nearestOnPolyline } from '../geography/index.js';
9
+ import { LAND, centerOf, distanceOnMap, pairKey } from './facts.js';
10
+ /** Cost per unit of distance by the ground of the province entered. */
11
+ export const RAIL_TERRAIN_FACTOR = {
12
+ urban: 0.8,
13
+ plains: 1,
14
+ desert: 1.3,
15
+ forest: 1.25,
16
+ hills: 1.6,
17
+ jungle: 2,
18
+ marsh: 2.2,
19
+ mountain: 3,
20
+ };
21
+ /** Height units of climb that cost one step of plain ground. */
22
+ const CLIMB_PER_STEP = 12;
23
+ const RIVER_CROSSING_COST = 1.1;
24
+ const GATE_DISCOUNT = 0.7;
25
+ const ROAD_DISCOUNT = [1, 0.85, 0.72, 0.6];
26
+ export function buildRailCost(base, metrics, terrain, trunk) {
27
+ const { geography, provinces, width, height } = base.input;
28
+ const count = base.view.count;
29
+ let landPixels = 0;
30
+ let landCount = 0;
31
+ for (let id = 0; id < count; id += 1)
32
+ if (provinces.kind[id] === LAND) {
33
+ landPixels += provinces.size[id];
34
+ landCount += 1;
35
+ }
36
+ const spacing = Math.max(8, Math.sqrt(landPixels / Math.max(1, landCount)));
37
+ const gates = new Set();
38
+ for (const [a, b] of base.crossings.gates)
39
+ gates.add(pairKey(a, b, count));
40
+ const cache = new Map();
41
+ const edge = (a, b) => {
42
+ const key = pairKey(a, b, count);
43
+ const known = cache.get(key);
44
+ if (known !== undefined)
45
+ return known;
46
+ const ca = centerOf(provinces, a);
47
+ const cb = centerOf(provinces, b);
48
+ const distance = distanceOnMap(ca.x, ca.y, cb.x, cb.y, width) / spacing;
49
+ const ground = Math.max(RAIL_TERRAIN_FACTOR[terrain.get(a) ?? 'plains'] ?? 1, RAIL_TERRAIN_FACTOR[terrain.get(b) ?? 'plains'] ?? 1);
50
+ let cost = distance * ground;
51
+ const stat = base.borders.get(key);
52
+ const ha = metrics.meanHeight[a];
53
+ const hb = metrics.meanHeight[b];
54
+ cost += Math.abs(ha - hb) / CLIMB_PER_STEP;
55
+ if (stat !== undefined && stat.count > 0) {
56
+ const crest = stat.top / stat.count;
57
+ cost += Math.max(0, crest - Math.max(ha, hb)) / CLIMB_PER_STEP;
58
+ cost += RIVER_CROSSING_COST * Math.min(1, (stat.rivers / stat.count) * 2);
59
+ if (gates.has(key))
60
+ cost *= GATE_DISCOUNT;
61
+ for (const road of geography.roads) {
62
+ const near = nearestOnPolyline(road.path, geography.frame, stat.sumX / stat.count / width, stat.sumY / stat.count / height);
63
+ if (near.distance <= Math.max(0.004, (1.5 * spacing) / width)) {
64
+ cost *= ROAD_DISCOUNT[Math.min(3, road.importance)] ?? 0.85;
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ const result = Math.max(0.2, cost);
70
+ cache.set(key, result);
71
+ return result;
72
+ };
73
+ return {
74
+ cost: (from, to) => edge(from - 1, to - 1),
75
+ trunk,
76
+ };
77
+ }
@@ -0,0 +1,61 @@
1
+ import { LAND } from './facts.js';
2
+ /** The land province at a pixel, else the nearest one within `reach` pixels. */
3
+ export function landProvinceNear(base, x, y, reach) {
4
+ const { width, height } = base.input;
5
+ const kind = base.input.provinces.kind;
6
+ const pixel = base.view.pixel;
7
+ const px = Math.max(0, Math.min(width - 1, Math.floor(x)));
8
+ const py = Math.max(0, Math.min(height - 1, Math.floor(y)));
9
+ const here = pixel[py * width + px];
10
+ if (kind[here] === LAND)
11
+ return { province: here, moved: false };
12
+ for (let radius = 1; radius <= reach; radius += 1) {
13
+ for (let dy = -radius; dy <= radius; dy += 1) {
14
+ const yy = py + dy;
15
+ if (yy < 0 || yy >= height)
16
+ continue;
17
+ // The square ring: whole rows at the top and bottom, two cells on the rows between.
18
+ const step = Math.abs(dy) === radius ? 1 : 2 * radius;
19
+ for (let dx = -radius; dx <= radius; dx += step) {
20
+ const xx = (((px + dx) % width) + width) % width;
21
+ const candidate = pixel[yy * width + xx];
22
+ if (kind[candidate] === LAND)
23
+ return { province: candidate, moved: true };
24
+ }
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+ export function locateSites(base) {
30
+ const { width, height, geography } = base.input;
31
+ const reach = Math.max(10, Math.round(width * 0.012));
32
+ return geography.sites.map((site) => {
33
+ if (site.onWater)
34
+ return { site, province: undefined, moved: false };
35
+ const found = landProvinceNear(base, site.at.x * width, site.at.y * height, reach);
36
+ return {
37
+ site,
38
+ province: found?.province,
39
+ moved: found?.moved ?? false,
40
+ };
41
+ });
42
+ }
43
+ /** Sites that become victory points, and the weight each kind gives to development. */
44
+ export const VICTORY_SITE_KINDS = new Set([
45
+ 'capital',
46
+ 'city',
47
+ 'fortress',
48
+ 'port',
49
+ ]);
50
+ export const SITE_WEIGHT = {
51
+ capital: 1,
52
+ city: 0.9,
53
+ port: 0.7,
54
+ fortress: 0.6,
55
+ mine: 0.5,
56
+ temple: 0.4,
57
+ ruin: 0.2,
58
+ landmark: 0.1,
59
+ };
60
+ /** Victory points of a site by importance 1 to 5. */
61
+ export const SITE_VICTORY_POINTS = [3, 5, 8, 12, 20];
@@ -1082,16 +1082,26 @@ export function resolveGeography(model, options) {
1082
1082
  continue;
1083
1083
  const first = ends[0], second = ends[1];
1084
1084
  const idOf = (end) => (end.type === 'land' ? end.id : end.landId);
1085
- if (idOf(first) !== undefined && idOf(first) === idOf(second))
1086
- sink('MAP_GEOGRAPHY_STRAIT_SAME_LAND', derived ? 'warning' : 'error', `Strait ${input.id} joins ${idOf(first)} to itself; a strait separates two lands`, { featureId: input.id, land: idOf(first), derived });
1087
- if (first.type === 'point' && second.type === 'point') {
1088
- let water = false;
1085
+ const bothPoints = first.type === 'point' && second.type === 'point';
1086
+ let water = false;
1087
+ if (first.type === 'point' && second.type === 'point')
1089
1088
  for (let step = 1; step < 32 && !water; step++) {
1090
1089
  const f = step / 32;
1091
1090
  if (surface(first.x + (second.x - first.x) * f, first.y + (second.y - first.y) * f)
1092
1091
  .surface !== 'land')
1093
1092
  water = true;
1094
1093
  }
1094
+ if (idOf(first) !== undefined && idOf(first) === idOf(second)) {
1095
+ // Two declared shore points of one land across water: a crossing between two coasts of the
1096
+ // land, such as a bay mouth or a neck of sea (ADR 0062).
1097
+ if (bothPoints && !derived && water)
1098
+ sink('MAP_GEOGRAPHY_STRAIT_SAME_LAND', 'info', `Strait ${input.id} crosses water between two coasts of ${idOf(first)}`, { featureId: input.id, land: idOf(first), derived, coasts: 'same-land' });
1099
+ else
1100
+ sink('MAP_GEOGRAPHY_STRAIT_SAME_LAND', derived ? 'warning' : 'error', bothPoints
1101
+ ? `Strait ${input.id} joins ${idOf(first)} to itself across no water; a strait crosses water between two shores`
1102
+ : `Strait ${input.id} joins ${idOf(first)} to itself; give two shore points across the water for a crossing between two coasts of one land`, { featureId: input.id, land: idOf(first), derived });
1103
+ }
1104
+ if (bothPoints) {
1095
1105
  if (!water)
1096
1106
  sink('MAP_GEOGRAPHY_STRAIT_NO_WATER', 'warning', `The straight line of strait ${input.id} crosses no water`, { featureId: input.id });
1097
1107
  }
@@ -1139,6 +1149,7 @@ export function resolveGeography(model, options) {
1139
1149
  importance: input.importance ?? GEOGRAPHY_DEFAULTS.site.importance,
1140
1150
  onWater,
1141
1151
  ...opt('country', input.country),
1152
+ ...opt('resource', input.resource),
1142
1153
  ...(landId === undefined ? {} : { landId }),
1143
1154
  ...(regionId === undefined ? {} : { regionId }),
1144
1155
  ...(lakeId === undefined ? {} : { lakeId }),
@@ -4,7 +4,7 @@ import { SymbolIndex } from '../core/index.js';
4
4
  import { assertRenderDimensions } from '../core/render-budget.js';
5
5
  import { addMapDiagnostic, addMapDiagnostics } from './diagnostic-limit.js';
6
6
  import { ServiceError } from '../core/result.js';
7
- import { assignments, childBlocks, decodeSource, encodeSource, firstScalar, nodeLocation, parseClausewitz, parseLocalisation, } from '../core/source/index.js';
7
+ import { assignments, assertSourceEncodingPreserved, childBlocks, decodeSource, encodeSource, firstScalar, nodeLocation, parseClausewitz, parseLocalisation, } from '../core/source/index.js';
8
8
  import { BmpImage, rgbKey } from './bmp.js';
9
9
  const defaultMapIndexSourceRoots = {
10
10
  map: ['map'],
@@ -119,6 +119,7 @@ export function parseTextDocument(file) {
119
119
  return { ...decoded, file, lines };
120
120
  }
121
121
  export function encodeTextDocument(document, text) {
122
+ assertSourceEncodingPreserved(document, document.file.displayPath);
122
123
  return encodeSource(text, document.encoding);
123
124
  }
124
125
  function lineLocation(document, line) {