hoi4-agent-tools 2.5.3 → 2.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 (33) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -5
  3. package/dist/hoi4_agent_tools/map/catalog.js +398 -0
  4. package/dist/hoi4_agent_tools/map/index.js +1 -0
  5. package/dist/hoi4_agent_tools/map/operations.js +337 -30
  6. package/dist/hoi4_agent_tools/map/render.js +40 -147
  7. package/dist/hoi4_agent_tools/map/service.js +1 -0
  8. package/dist/hoi4_agent_tools/mcp/tools/map.js +85 -9
  9. package/dist/hoi4_agent_tools/schemas/map.js +69 -2
  10. package/dist/hoi4_agent_tools/version.js +1 -1
  11. package/docs/map.md +60 -23
  12. package/package.json +1 -1
  13. package/schemas/configuration.schema.json +1 -1
  14. package/schemas/continuous-focus-palette.schema.json +1 -1
  15. package/schemas/custom-weighted-pool.schema.json +1 -1
  16. package/schemas/event-feature-manifest.schema.json +1 -1
  17. package/schemas/focus-plan.schema.json +1 -1
  18. package/schemas/focus-planning-sidecar.schema.json +1 -1
  19. package/schemas/gui-animation-source.schema.json +1 -1
  20. package/schemas/gui-helper.schema.json +1 -1
  21. package/schemas/gui-scenario.schema.json +1 -1
  22. package/schemas/map-operation.schema.json +228 -74
  23. package/schemas/operation-result.schema.json +1 -1
  24. package/schemas/probability-analysis-result.schema.json +1 -1
  25. package/schemas/probability-compare-input.schema.json +1 -1
  26. package/schemas/probability-evaluate-input.schema.json +1 -1
  27. package/schemas/probability-inspect-input.schema.json +1 -1
  28. package/schemas/probability-render-input.schema.json +1 -1
  29. package/schemas/probability-scenario-set.schema.json +1 -1
  30. package/schemas/probability-sequence-input.schema.json +1 -1
  31. package/schemas/probability-simulate-input.schema.json +1 -1
  32. package/schemas/probability-sweep-input.schema.json +1 -1
  33. package/server.json +2 -2
@@ -2,6 +2,7 @@ import sharp from 'sharp';
2
2
  import { compareCodeUnits, canonicalJson, sha256Bytes } from '../core/canonical.js';
3
3
  import { assertRenderDimensions, RenderBudget, RENDER_MAX_PIXELS } from '../core/render-budget.js';
4
4
  import { ServiceError } from '../core/result.js';
5
+ import { buildMapCatalog } from './catalog.js';
5
6
  import { validateMapAsync } from './validation.js';
6
7
  function stableColor(key) {
7
8
  const hash = sha256Bytes(key);
@@ -277,163 +278,55 @@ async function applyOverlays(index, bytes, overlays, signal) {
277
278
  }
278
279
  }
279
280
  async function renderMetadata(index, options, signal) {
280
- const victoryPoints = index.states
281
- .flatMap((state) => state.victoryPoints.map(({ provinceId, value }) => ({
282
- stateId: state.id,
283
- provinceId,
284
- value,
285
- sourcePath: state.file.displayPath,
286
- })))
287
- .sort((left, right) => left.stateId - right.stateId ||
288
- left.provinceId - right.provinceId ||
289
- left.value - right.value);
290
- const normalAdjacencies = [...(index.raster?.adjacency ?? [])]
291
- .flatMap(([from, neighbors]) => [...neighbors].flatMap((to) => (from < to ? [{ from, to }] : [])))
292
- .sort((left, right) => left.from - right.from || left.to - right.to);
281
+ const catalog = buildMapCatalog(index);
293
282
  return {
294
- offline: true,
295
- renderer: 'hoi4-agent-tools-agent-nudger',
283
+ ...catalog,
284
+ renderer: 'hoi4-agent-tools-map-workbench',
296
285
  layer: options.layer,
297
286
  overlays: options.overlays,
298
287
  scale: options.scale,
299
288
  width: index.raster?.width ?? 0,
300
289
  height: index.raster?.height ?? 0,
301
- definitions: index.definitions.map(({ id, color, type, coastal, terrain, continent }) => ({
302
- id,
303
- color,
304
- type,
305
- coastal,
306
- terrain,
307
- continent,
308
- })),
309
- states: [...index.states]
310
- .sort((left, right) => left.id - right.id)
311
- .map(({ id, name, capital, provinces, resources, stateBuildings, provinceBuildings, owner, controller, cores, claims, }) => ({
312
- id,
313
- name,
314
- capital: capital ?? null,
315
- manpower: index.statesById.get(id)?.manpower ?? 0,
316
- category: index.statesById.get(id)?.category ?? '',
317
- provinces: [...provinces].sort((a, b) => a - b),
318
- resources: Object.fromEntries([...resources].sort(([left], [right]) => compareCodeUnits(left, right))),
319
- stateBuildings: Object.fromEntries([...stateBuildings].sort(([left], [right]) => compareCodeUnits(left, right))),
320
- provinceBuildings: Object.fromEntries([...provinceBuildings]
321
- .sort(([left], [right]) => left - right)
322
- .map(([provinceId, buildings]) => [
323
- String(provinceId),
324
- Object.fromEntries([...buildings].sort(([left], [right]) => compareCodeUnits(left, right))),
325
- ])),
326
- owner: owner ?? null,
327
- controller: controller ?? null,
328
- cores: [...cores].sort(),
329
- claims: [...claims].sort(),
330
- })),
331
- regions: [...index.regions]
332
- .sort((left, right) => left.id - right.id)
333
- .map(({ id, name, provinces }) => ({
334
- id,
335
- name,
336
- provinces: [...provinces].sort((a, b) => a - b),
337
- })),
338
- victoryPoints,
339
- ports: [...index.ports]
340
- .sort((left, right) => left.stateId - right.stateId || left.provinceId - right.provinceId)
341
- .map(({ stateId, provinceId, level, coastal, adjacentSeaProvinceIds, positions }) => ({
342
- stateId,
343
- provinceId,
344
- level,
345
- coastal,
346
- adjacentSeaProvinceIds: [...adjacentSeaProvinceIds].sort((left, right) => left - right),
347
- positions: positions.map(({ building, x, y, z, rotation, adjacentSeaProvince, document, line }) => ({
348
- building,
349
- x,
350
- y,
351
- z,
352
- rotation,
353
- adjacentSeaProvince,
354
- sourcePath: document.file.displayPath,
355
- line,
356
- })),
357
- })),
358
- supplyNodes: index.supplyNodes.map(({ level, provinceId, document, line }) => ({
359
- level,
360
- provinceId,
361
- sourcePath: document.file.displayPath,
362
- line,
363
- })),
364
- railways: index.railways.map(({ level, declaredCount, provinces, document, line }, ordinal) => ({
365
- ordinal,
366
- level,
367
- declaredCount,
368
- provinces: [...provinces],
369
- sourcePath: document.file.displayPath,
370
- line,
371
- })),
372
- adjacencies: index.adjacencies.map(({ from, to, type, through, startX, startY, stopX, stopY, rule, comment, document, line }, ordinal) => ({
373
- ordinal,
374
- from,
375
- to,
376
- type,
377
- through,
378
- startX,
379
- startY,
380
- stopX,
381
- stopY,
382
- rule,
383
- comment,
384
- sourcePath: document.file.displayPath,
385
- line,
386
- })),
387
- normalAdjacencies,
388
- buildingPositions: index.buildingPositions.map(({ stateId, building, x, y, z, rotation, adjacentSeaProvince, document, line }) => ({
389
- stateId,
390
- building,
391
- x,
392
- y,
393
- z,
394
- rotation,
395
- adjacentSeaProvince,
396
- sourcePath: document.file.displayPath,
397
- line,
398
- })),
399
- unitPositions: index.unitPositions.map(({ provinceId, type, x, y, z, rotation, offset, document, line }) => ({
400
- provinceId,
401
- type,
402
- x,
403
- y,
404
- z,
405
- rotation,
406
- offset,
407
- sourcePath: document.file.displayPath,
408
- line,
409
- })),
410
- weatherPositions: index.weatherPositions.map(({ strategicRegionId, x, y, z, size, document, line }) => ({
411
- strategicRegionId,
412
- x,
413
- y,
414
- z,
415
- size,
416
- sourcePath: document.file.displayPath,
417
- line,
418
- })),
419
- entityLocators: [...index.entityLocators]
420
- .sort((left, right) => compareCodeUnits(left.entity, right.entity) || compareCodeUnits(left.name, right.name))
421
- .map(({ entity, name, position, file }) => ({
422
- entity,
423
- name,
424
- position: [...position],
425
- sourcePath: file.displayPath,
426
- })),
290
+ definitions: catalog.provinces.map(({ id, definition }) => ({ id, ...definition })),
291
+ regions: catalog.strategicRegions,
292
+ adjacencies: catalog.specialAdjacencies,
427
293
  validation: await validateMapAsync(index, signal === undefined ? {} : { signal }),
428
294
  };
429
295
  }
430
- function htmlDocument(title, png, json) {
296
+ function htmlDocument(title, png, provinceHitMap, json, renderScale) {
297
+ const escapedTitle = title
298
+ .replaceAll('&', '&amp;')
299
+ .replaceAll('<', '&lt;')
300
+ .replaceAll('>', '&gt;');
301
+ const scriptJson = json.replaceAll('<', '\\u003c');
302
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapedTitle}</title><style>html{background:#10151b;color:#edf2f7;font:14px system-ui}body{margin:0;height:100vh;display:grid;grid-template-columns:minmax(0,1fr) 360px;overflow:hidden}.main{min-width:0;padding:14px;display:flex;flex-direction:column}.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}button,input,select{background:#202b38;color:#edf2f7;border:1px solid #526173;border-radius:4px;padding:6px 9px}input{min-width:260px;flex:1}.viewport{flex:1;min-height:0;overflow:hidden;border:1px solid #48515c;background:#080b0f;position:relative;touch-action:none}.viewport img{image-rendering:pixelated;transform-origin:0 0;position:absolute;max-width:none;cursor:grab;user-select:none}.viewport img.dragging{cursor:grabbing}.cursor{position:absolute;left:8px;bottom:8px;background:#10151bd9;padding:4px 7px;border-radius:3px;pointer-events:none}.side{border-left:1px solid #48515c;padding:14px;overflow:auto;background:#151d26}.results{display:grid;gap:4px;margin:8px 0 14px}.result{text-align:left;width:100%}.kind{color:#8fb7d9}.muted{color:#9ca9b5}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}h1{font-size:18px;margin:0 12px 0 0}h2{font-size:15px}</style></head><body><main class="main"><div class="toolbar"><h1>${escapedTitle}</h1><button id="out" type="button">-</button><button id="fit" type="button">Fit</button><button id="in" type="button">+</button><output id="zoom">100%</output><select id="kind"><option value="all">All IDs</option><option value="province">Provinces</option><option value="state">States</option><option value="strategic-region">Strategic regions</option></select><input id="search" type="search" placeholder="Find ID, localisation key, or name" autocomplete="off"></div><div class="viewport" id="viewport"><img id="map" draggable="false" alt="${escapedTitle}" src="data:image/png;base64,${png.toString('base64')}"><div class="cursor" id="cursor">Move over the map to inspect coordinates; click for IDs.</div></div></main><aside class="side"><h2>Search results</h2><div class="results" id="results"><span class="muted">Search by ID or name.</span></div><h2>Selection</h2><pre id="selection">Click a province or choose a search result.</pre><p class="muted">The complete source-linked catalog is embedded for navigation and is also available as the linked JSON resource.</p></aside><img id="hit" hidden src="data:image/png;base64,${provinceHitMap.toString('base64')}"><canvas id="hitCanvas" hidden></canvas><script>(()=>{const data=${scriptJson};const renderScale=${renderScale};const image=document.getElementById('map'),hit=document.getElementById('hit'),canvas=document.getElementById('hitCanvas'),viewport=document.getElementById('viewport'),zoomLabel=document.getElementById('zoom'),cursor=document.getElementById('cursor'),search=document.getElementById('search'),kind=document.getElementById('kind'),results=document.getElementById('results'),selection=document.getElementById('selection');const provinces=new Map(data.provinces.map(value=>[value.id,value])),states=new Map(data.states.map(value=>[value.id,value])),regions=new Map(data.strategicRegions.map(value=>[value.id,value]));let scale=1,x=0,y=0,drag=false,moved=false,lastX=0,lastY=0,hitContext=null;const draw=()=>{image.style.transform='translate('+x+'px,'+y+'px) scale('+scale+')';zoomLabel.value=Math.round(scale*100)+'%'};const fit=()=>{scale=Math.min(viewport.clientWidth/image.naturalWidth,viewport.clientHeight/image.naturalHeight);x=(viewport.clientWidth-image.naturalWidth*scale)/2;y=(viewport.clientHeight-image.naturalHeight*scale)/2;draw()};const zoom=(factor,cx=viewport.clientWidth/2,cy=viewport.clientHeight/2)=>{const next=Math.min(32,Math.max(.03,scale*factor));x=cx-(cx-x)*(next/scale);y=cy-(cy-y)*(next/scale);scale=next;draw()};const entity=(entityKind,id)=>entityKind==='province'?provinces.get(id):entityKind==='state'?states.get(id):regions.get(id);const linked=(province)=>({province,states:province.stateIds.map(id=>states.get(id)),strategicRegions:province.regionIds.map(id=>regions.get(id))});const show=(entityKind,id)=>{const value=entity(entityKind,id);selection.textContent=value?JSON.stringify(entityKind==='province'?linked(value):value,null,2):'No matching record.';if(value?.geometry){const g=value.geometry;x=viewport.clientWidth/2-g.centerX*renderScale*scale;y=viewport.clientHeight/2-g.centerY*renderScale*scale;draw()}};const searchable=[...data.provinces,...data.states,...data.strategicRegions].map(value=>({...value,search:[String(value.id),value.name.key,value.name.displayName,...value.name.values.map(item=>item.value)].join(' ').toLocaleLowerCase('en-US')}));const message=(text)=>Object.assign(document.createElement('span'),{className:'muted',textContent:text});const updateSearch=()=>{const query=search.value.trim().toLocaleLowerCase('en-US'),filter=kind.value;if(!query){results.replaceChildren(message('Search by ID or name.'));return}const matches=searchable.filter(value=>(filter==='all'||value.kind===filter)&&value.search.includes(query)).slice(0,100);results.replaceChildren(...matches.map(value=>{const button=document.createElement('button'),label=document.createElement('span');button.type='button';button.className='result';label.className='kind';label.textContent=value.kind;button.replaceChildren(label,document.createTextNode(' '+value.id+' — '+value.name.displayName));button.onclick=()=>show(value.kind,value.id);return button}),...(matches.length?[]:[message('No matches.')]))};const point=(event)=>{const bounds=viewport.getBoundingClientRect();const imageX=(event.clientX-bounds.left-x)/scale/renderScale,imageY=(event.clientY-bounds.top-y)/scale/renderScale;return{x:Math.floor(imageX),y:Math.floor(imageY)}};const provinceAt=(point)=>{if(!hitContext||point.x<0||point.y<0||point.x>=canvas.width||point.y>=canvas.height)return null;const pixel=hitContext.getImageData(point.x,point.y,1,1).data;const encoded=(pixel[0]<<16)|(pixel[1]<<8)|pixel[2];return encoded===0?null:encoded-1};hit.onload=()=>{canvas.width=hit.naturalWidth;canvas.height=hit.naturalHeight;hitContext=canvas.getContext('2d',{willReadFrequently:true});hitContext.drawImage(hit,0,0)};search.oninput=updateSearch;kind.onchange=updateSearch;document.getElementById('in').onclick=()=>zoom(1.25);document.getElementById('out').onclick=()=>zoom(.8);document.getElementById('fit').onclick=fit;viewport.addEventListener('wheel',event=>{event.preventDefault();const bounds=viewport.getBoundingClientRect();zoom(event.deltaY<0?1.15:.87,event.clientX-bounds.left,event.clientY-bounds.top)},{passive:false});image.addEventListener('pointerdown',event=>{drag=true;moved=false;lastX=event.clientX;lastY=event.clientY;image.setPointerCapture(event.pointerId);image.classList.add('dragging')});image.addEventListener('pointermove',event=>{const p=point(event),provinceId=provinceAt(p),province=provinceId===null?null:provinces.get(provinceId);cursor.textContent='pixel '+p.x+','+p.y+' | map '+p.x+','+(data.height-1-p.y)+(province?' | province '+province.id+' '+province.name.displayName+' | state '+province.stateIds.join(',')+' | region '+province.regionIds.join(','):'');if(!drag)return;const dx=event.clientX-lastX,dy=event.clientY-lastY;if(Math.abs(dx)+Math.abs(dy)>2)moved=true;x+=dx;y+=dy;lastX=event.clientX;lastY=event.clientY;draw()});image.addEventListener('pointerup',event=>{drag=false;image.classList.remove('dragging');if(!moved){const provinceId=provinceAt(point(event));if(provinceId!==null)show('province',provinceId)}});image.onload=fit;window.addEventListener('resize',fit);draw()})();</script></body></html>`;
303
+ }
304
+ function diffHtmlDocument(title, png, json) {
431
305
  const escapedTitle = title
432
306
  .replaceAll('&', '&amp;')
433
307
  .replaceAll('<', '&lt;')
434
308
  .replaceAll('>', '&gt;');
435
309
  const escapedJson = json.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
436
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapedTitle}</title><style>html{background:#10151b;color:#edf2f7;font:14px system-ui}body{margin:20px}.controls{display:flex;gap:8px;align-items:center;margin:12px 0}button{background:#263241;color:#edf2f7;border:1px solid #526173;border-radius:4px;padding:6px 10px}.viewport{height:70vh;overflow:hidden;border:1px solid #48515c;background:#080b0f;position:relative;touch-action:none}.viewport img{image-rendering:pixelated;transform-origin:0 0;position:absolute;max-width:none;cursor:grab;user-select:none}.viewport img.dragging{cursor:grabbing}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style></head><body><h1>${escapedTitle}</h1><div class="controls"><button id="out" type="button">-</button><button id="reset" type="button">Reset</button><button id="in" type="button">+</button><output id="zoom">100%</output></div><div class="viewport" id="viewport"><img id="map" draggable="false" alt="${escapedTitle}" src="data:image/png;base64,${png.toString('base64')}"></div><details><summary>Render metadata</summary><pre>${escapedJson}</pre></details><script>(()=>{const image=document.getElementById('map'),viewport=document.getElementById('viewport'),label=document.getElementById('zoom');let scale=1,x=0,y=0,drag=false,lastX=0,lastY=0;const draw=()=>{image.style.transform='translate('+x+'px,'+y+'px) scale('+scale+')';label.value=Math.round(scale*100)+'%'};const zoom=(factor,cx=viewport.clientWidth/2,cy=viewport.clientHeight/2)=>{const next=Math.min(16,Math.max(.1,scale*factor));x=cx-(cx-x)*(next/scale);y=cy-(cy-y)*(next/scale);scale=next;draw()};document.getElementById('in').onclick=()=>zoom(1.25);document.getElementById('out').onclick=()=>zoom(.8);document.getElementById('reset').onclick=()=>{scale=1;x=0;y=0;draw()};viewport.addEventListener('wheel',event=>{event.preventDefault();const bounds=viewport.getBoundingClientRect();zoom(event.deltaY<0?1.15:.87,event.clientX-bounds.left,event.clientY-bounds.top)},{passive:false});image.addEventListener('pointerdown',event=>{drag=true;lastX=event.clientX;lastY=event.clientY;image.setPointerCapture(event.pointerId);image.classList.add('dragging')});image.addEventListener('pointermove',event=>{if(!drag)return;x+=event.clientX-lastX;y+=event.clientY-lastY;lastX=event.clientX;lastY=event.clientY;draw()});image.addEventListener('pointerup',()=>{drag=false;image.classList.remove('dragging')});draw()})();</script></body></html>`;
310
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapedTitle}</title><style>html{background:#10151b;color:#edf2f7;font:14px system-ui}body{margin:20px}.viewport{height:70vh;overflow:auto;border:1px solid #48515c;background:#080b0f}.viewport img{image-rendering:pixelated;max-width:none}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style></head><body><h1>${escapedTitle}</h1><div class="viewport"><img alt="${escapedTitle}" src="data:image/png;base64,${png.toString('base64')}"></div><details><summary>Diff metadata</summary><pre>${escapedJson}</pre></details></body></html>`;
311
+ }
312
+ async function encodeProvinceHitMap(index) {
313
+ const raster = index.raster;
314
+ if (raster === undefined)
315
+ throw new ServiceError('MAP_RENDER_RASTER_MISSING', 'Cannot build a province hit map without a valid province raster');
316
+ const raw = Buffer.alloc(raster.width * raster.height * 3);
317
+ for (let offset = 0; offset < raster.provinceIds.length; offset += 1) {
318
+ const encoded = (raster.provinceIds[offset] ?? -1) + 1;
319
+ const target = offset * 3;
320
+ raw[target] = (encoded >> 16) & 255;
321
+ raw[target + 1] = (encoded >> 8) & 255;
322
+ raw[target + 2] = encoded & 255;
323
+ }
324
+ return sharp(raw, {
325
+ raw: { width: raster.width, height: raster.height, channels: 3 },
326
+ limitInputPixels: RENDER_MAX_PIXELS,
327
+ })
328
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
329
+ .toBuffer();
437
330
  }
438
331
  async function encodePng(raw, width, height, scale) {
439
332
  assertRenderDimensions(width, height, 'map Sharp source raster');
@@ -481,7 +374,8 @@ export async function renderMap(index, options = {}) {
481
374
  const png = await encodePng(raw, raster.width, raster.height, resolved.scale);
482
375
  options.signal?.throwIfAborted();
483
376
  const json = `${canonicalJson(await renderMetadata(index, resolved, options.signal))}\n`;
484
- const html = htmlDocument(`Agent Nudger map - ${resolved.layer}`, png, json);
377
+ const provinceHitMap = await encodeProvinceHitMap(index);
378
+ const html = htmlDocument(`HOI4 map - ${resolved.layer}`, png, provinceHitMap, json, resolved.scale);
485
379
  return {
486
380
  width: outputDimensions.width,
487
381
  height: outputDimensions.height,
@@ -734,15 +628,14 @@ export async function renderMapDiff(before, after, options = {}) {
734
628
  const semantic = semanticMapDiff(before, after);
735
629
  const changedBounds = left.diffBounds(right);
736
630
  const metadata = {
737
- offline: true,
738
- renderer: 'hoi4-agent-tools-agent-nudger-diff',
631
+ renderer: 'hoi4-agent-tools-map-diff',
739
632
  changedBounds: changedBounds ?? null,
740
633
  changedProvinceIds: [...changedProvinceIds].sort((a, b) => a - b),
741
634
  semantic,
742
635
  ...(options.review === undefined ? {} : { review: options.review }),
743
636
  };
744
637
  const json = `${canonicalJson(metadata)}\n`;
745
- const html = htmlDocument('Agent Nudger pixel and semantic diff', png, json);
638
+ const html = diffHtmlDocument('HOI4 map pixel and semantic diff', png, json);
746
639
  return {
747
640
  width: outputDimensions.width,
748
641
  height: outputDimensions.height,
@@ -137,6 +137,7 @@ const provinceOperationKinds = new Set([
137
137
  'merge_provinces',
138
138
  'remove_province',
139
139
  'update_province_definition',
140
+ 'renumber_map_entity',
140
141
  'add_normal_adjacency',
141
142
  'remove_normal_adjacency',
142
143
  ]);
@@ -2,7 +2,7 @@ import { z } from 'zod/v4';
2
2
  import { boundedSourceHashEvidence, publicArtifactLink, } from '../../core/artifacts.js';
3
3
  import { canonicalJson, hashCanonical } from '../../core/canonical.js';
4
4
  import { emptyServiceResult } from '../../core/result.js';
5
- import { AgentNudger, allocateMapIdentifiersAsync, exportProvinceGeometryRowRuns, MAP_PROVINCE_GEOMETRY_SELECTOR_LIMIT, } from '../../map/index.js';
5
+ import { AgentNudger, allocateMapIdentifiersAsync, buildMapCatalog, exportProvinceGeometryRowRuns, lookupMapCoordinate, MAP_PROVINCE_GEOMETRY_SELECTOR_LIMIT, renderMap, searchMapCatalog, } from '../../map/index.js';
6
6
  import { workspaceIdSchema } from '../../schemas/common.js';
7
7
  import { mapAllocationRequestSchema, mapOperationSchema } from '../../schemas/map.js';
8
8
  import { PACKAGE_VERSION } from '../../version.js';
@@ -78,6 +78,9 @@ const mapWorkspaceInspectOutputSchema = strictOperationResultSchema(z
78
78
  inspectedStateCount: nonNegativeIntegerSchema,
79
79
  inspectedRegionCount: nonNegativeIntegerSchema,
80
80
  allocationCount: nonNegativeIntegerSchema,
81
+ queryMatchCount: nonNegativeIntegerSchema,
82
+ coordinateMatchCount: nonNegativeIntegerSchema,
83
+ overviewRendered: z.boolean(),
81
84
  provinceGeometryCount: nonNegativeIntegerSchema,
82
85
  provinceGeometryPixelCount: nonNegativeIntegerSchema,
83
86
  provinceGeometryRowRunCount: nonNegativeIntegerSchema,
@@ -348,7 +351,7 @@ export function registerMapTools(server, engine, context) {
348
351
  const nudger = new AgentNudger(engine);
349
352
  server.registerTool('hoi4.map_inspect', {
350
353
  title: 'Inspect HOI4 map',
351
- description: 'Validate the map and write linked inspection evidence. ID selectors return bounded records; provinceIds also export exact row-run geometry.',
354
+ description: 'Render, validate, search, and click the complete map.',
352
355
  inputSchema: z
353
356
  .object({
354
357
  workspaceId: workspaceIdSchema,
@@ -356,21 +359,47 @@ export function registerMapTools(server, engine, context) {
356
359
  .array(z.number().int().min(0))
357
360
  .max(MAP_PROVINCE_GEOMETRY_SELECTOR_LIMIT)
358
361
  .default([])
359
- .describe(`Optional exact-geometry selector; exports row runs for at most ${MAP_PROVINCE_GEOMETRY_SELECTOR_LIMIT} province IDs`),
362
+ .describe(`Export row runs for up to ${MAP_PROVINCE_GEOMETRY_SELECTOR_LIMIT} province IDs`),
360
363
  stateIds: z.array(z.number().int().positive()).max(1_000).default([]),
361
364
  regionIds: z.array(z.number().int().positive()).max(1_000).default([]),
365
+ query: z.string().max(256).optional(),
366
+ queryLimit: z.number().int().min(1).max(1_000).default(100),
367
+ coordinates: z
368
+ .array(z.discriminatedUnion('kind', [
369
+ z
370
+ .object({
371
+ kind: z.literal('pixel'),
372
+ x: z.number().int().min(0),
373
+ y: z.number().int().min(0),
374
+ })
375
+ .strict(),
376
+ z
377
+ .object({
378
+ kind: z.literal('map'),
379
+ x: z.number(),
380
+ z: z.number(),
381
+ })
382
+ .strict(),
383
+ ]))
384
+ .max(100)
385
+ .default([]),
386
+ includeOverview: z.boolean().default(true),
362
387
  allocationRequests: z.array(mapAllocationRequestSchema).max(100).default([]),
363
388
  })
364
389
  .strict(),
365
390
  outputSchema: mapWorkspaceInspectOutputSchema,
366
391
  annotations: artifactProducing,
367
- }, async ({ workspaceId: requestedWorkspaceId, provinceIds, stateIds, regionIds, allocationRequests }, extra) => {
392
+ }, async ({ workspaceId: requestedWorkspaceId, provinceIds, stateIds, regionIds, query, queryLimit, coordinates, includeOverview, allocationRequests, }, extra) => {
368
393
  const workspaceId = await resolveServerWorkspaceId(engine, context, requestedWorkspaceId, extra.signal);
369
394
  try {
370
395
  const progress = progressReporter(extra);
371
396
  await progress.report(0, 3, 'Inspecting and validating the map');
372
397
  const { snapshot, validation } = await nudger.validate(workspaceId, context.principal, progress.signal);
373
398
  const sharedRevision = snapshot.revision;
399
+ const catalog = buildMapCatalog(snapshot.index);
400
+ const queryMatches = query === undefined ? [] : searchMapCatalog(catalog, query, queryLimit);
401
+ const coordinateMatches = coordinates.map((coordinate) => lookupMapCoordinate(snapshot.index, coordinate));
402
+ const overviewRendered = includeOverview && snapshot.index.raster !== undefined;
374
403
  const selected = selectedMapEntities(snapshot, provinceIds, stateIds, regionIds);
375
404
  const provinceGeometry = provinceIds.length === 0
376
405
  ? undefined
@@ -387,7 +416,7 @@ export function registerMapTools(server, engine, context) {
387
416
  const inspectionProvenance = {
388
417
  kind: 'map-inspect',
389
418
  toolVersion: PACKAGE_VERSION,
390
- schemaVersion: 'map-inspect.v1',
419
+ schemaVersion: 'map-inspect.v2',
391
420
  sourceHashes: sourceEvidence.bounded.sourceHashes,
392
421
  metadata: {
393
422
  sharedRevision,
@@ -399,7 +428,7 @@ export function registerMapTools(server, engine, context) {
399
428
  name: `map-inspect.${snapshot.revision.slice(0, 16)}.json`,
400
429
  mimeType: 'application/json',
401
430
  content: `${canonicalJson({
402
- schemaVersion: 1,
431
+ schemaVersion: 2,
403
432
  revision: snapshot.revision,
404
433
  sharedRevision,
405
434
  dimensions: snapshot.index.raster === undefined
@@ -420,15 +449,59 @@ export function registerMapTools(server, engine, context) {
420
449
  ports: snapshot.index.ports.length,
421
450
  locators: snapshot.index.entityLocators.length,
422
451
  },
452
+ catalog,
453
+ query: query ?? null,
454
+ queryMatches,
455
+ coordinateMatches,
423
456
  selected,
424
457
  allocationPreviews,
425
458
  validation,
426
459
  sourceHashes: sourceEvidence.complete,
427
460
  })}\n`,
428
461
  provenance: inspectionProvenance,
429
- description: 'Complete map inspection, validation, selected records, and allocation previews',
462
+ description: 'Complete searchable map catalog, inspection, validation, selected records, and allocation previews',
430
463
  },
431
464
  ];
465
+ if (overviewRendered) {
466
+ const overviewLayer = 'state';
467
+ const overviewOverlays = [
468
+ 'coastlines',
469
+ 'ports',
470
+ 'victory-points',
471
+ 'supply-nodes',
472
+ 'railways',
473
+ ];
474
+ const overview = await renderMap(snapshot.index, {
475
+ layer: overviewLayer,
476
+ overlays: overviewOverlays,
477
+ signal: progress.signal,
478
+ });
479
+ const overviewProvenance = {
480
+ kind: 'map-inspect-overview',
481
+ toolVersion: PACKAGE_VERSION,
482
+ schemaVersion: 'map-inspect-overview.v1',
483
+ sourceHashes: sourceEvidence.bounded.sourceHashes,
484
+ renderProfile: {
485
+ layer: overviewLayer,
486
+ overlays: [...new Set(overviewOverlays)].sort(),
487
+ scale: 1,
488
+ },
489
+ metadata: { sourceHashInventory: sourceEvidence.bounded.inventory },
490
+ };
491
+ artifactWrites.push({
492
+ name: `map-overview.${snapshot.revision.slice(0, 16)}.png`,
493
+ mimeType: 'image/png',
494
+ content: overview.png,
495
+ provenance: overviewProvenance,
496
+ description: 'Complete rendered map overview',
497
+ }, {
498
+ name: `map-overview.${snapshot.revision.slice(0, 16)}.html`,
499
+ mimeType: 'text/html',
500
+ content: overview.html,
501
+ provenance: overviewProvenance,
502
+ description: 'Searchable and clickable full-map navigator with exact IDs, names, coordinates, and linked records',
503
+ });
504
+ }
432
505
  if (provinceGeometry !== undefined) {
433
506
  const { width, height, requestedProvinceIds, unknownProvinceIds, missingGeometryProvinceIds, pixelCount, rowRunCount, provinces, } = provinceGeometry;
434
507
  const selectorHash = hashCanonical(requestedProvinceIds).slice(0, 16);
@@ -480,6 +553,9 @@ export function registerMapTools(server, engine, context) {
480
553
  inspectedStateCount: selected.states.length,
481
554
  inspectedRegionCount: selected.regions.length,
482
555
  allocationCount: allocationPreviews.length,
556
+ queryMatchCount: queryMatches.length,
557
+ coordinateMatchCount: coordinateMatches.length,
558
+ overviewRendered,
483
559
  provinceGeometryCount: provinceGeometry?.provinces.length ?? 0,
484
560
  provinceGeometryPixelCount: provinceGeometry?.pixelCount ?? 0,
485
561
  provinceGeometryRowRunCount: provinceGeometry?.rowRunCount ?? 0,
@@ -500,7 +576,7 @@ export function registerMapTools(server, engine, context) {
500
576
  });
501
577
  server.registerTool('hoi4.map_render', {
502
578
  title: 'Render map inspection artifacts',
503
- description: 'Render deterministic offline map artifacts for one layer and bounded overlays.',
579
+ description: 'Render searchable full-map PNG, JSON, and HTML.',
504
580
  inputSchema: z
505
581
  .object({
506
582
  workspaceId: workspaceIdSchema,
@@ -542,7 +618,7 @@ export function registerMapTools(server, engine, context) {
542
618
  });
543
619
  server.registerTool('hoi4.map_rewrite', {
544
620
  title: 'Create or clean up map content',
545
- description: 'Apply validated declarative map operations in one call and return linked pixel and semantic evidence.',
621
+ description: 'Apply map creation, edits, and ID swaps with visual and semantic evidence.',
546
622
  inputSchema: z
547
623
  .object({
548
624
  workspaceId: workspaceIdSchema,
@@ -133,6 +133,22 @@ const provinceGeometrySchema = z.discriminatedUnion('kind', [
133
133
  .describe(`Exact coordinates to recolor; at most ${MAP_SELECTED_PIXEL_LIMIT} pixels per operation`),
134
134
  })
135
135
  .strict(),
136
+ z
137
+ .object({
138
+ kind: z.literal('rectangle'),
139
+ origin: pointSchema,
140
+ width: z.number().int().positive().max(RENDER_MAX_DIMENSION),
141
+ height: z.number().int().positive().max(RENDER_MAX_DIMENSION),
142
+ })
143
+ .strict()
144
+ .superRefine((value, context) => {
145
+ if (value.width * value.height > MAP_SELECTED_PIXEL_LIMIT)
146
+ context.addIssue({
147
+ code: 'custom',
148
+ path: ['width'],
149
+ message: `Rectangle selects more than ${MAP_SELECTED_PIXEL_LIMIT} pixels`,
150
+ });
151
+ }),
136
152
  z
137
153
  .object({
138
154
  kind: z.literal('mask'),
@@ -262,6 +278,24 @@ const splitStateOperation = (kind) => z
262
278
  distribution: splitStateDistributionSchema,
263
279
  })
264
280
  .strict();
281
+ const createStateOperation = z
282
+ .object({
283
+ ...operationBase,
284
+ kind: z.literal('create_state'),
285
+ sourceStateId: stateId.optional(),
286
+ stateId: stateId.optional(),
287
+ provinceIds: z.array(provinceId).min(1).max(100_000),
288
+ name: z.string().min(1).max(1000).optional(),
289
+ displayName: z.string().min(1).max(100_000).optional(),
290
+ fileName: z.string().max(255).optional(),
291
+ localisation: stateLocalisationSchema.optional(),
292
+ distribution: z.union([z.literal('proportional'), splitStateDistributionSchema]).optional(),
293
+ })
294
+ .strict()
295
+ .refine((value) => value.name !== undefined || value.displayName !== undefined, {
296
+ message: 'create_state requires name or displayName',
297
+ path: ['displayName'],
298
+ });
265
299
  const splitProvinceOperation = (kind) => z
266
300
  .object({
267
301
  ...operationBase,
@@ -273,6 +307,19 @@ const splitProvinceOperation = (kind) => z
273
307
  distribution: splitProvinceDistributionSchema,
274
308
  })
275
309
  .strict();
310
+ const createProvinceOperation = z
311
+ .object({
312
+ ...operationBase,
313
+ kind: z.literal('create_province'),
314
+ sourceProvinceId: provinceId,
315
+ provinceId: provinceId.optional(),
316
+ geometry: provinceGeometrySchema,
317
+ definition: provinceDefinitionSchema.optional(),
318
+ distribution: z
319
+ .union([z.literal('inherit-source'), splitProvinceDistributionSchema])
320
+ .optional(),
321
+ })
322
+ .strict();
276
323
  const occurrenceSchema = z.number().int().min(0).optional();
277
324
  export const mapOperationSchema = z.discriminatedUnion('kind', [
278
325
  z
@@ -354,7 +401,7 @@ export const mapOperationSchema = z.discriminatedUnion('kind', [
354
401
  })
355
402
  .strict(),
356
403
  splitStateOperation('split_state'),
357
- splitStateOperation('create_state'),
404
+ createStateOperation,
358
405
  z
359
406
  .object({
360
407
  ...operationBase,
@@ -365,7 +412,7 @@ export const mapOperationSchema = z.discriminatedUnion('kind', [
365
412
  })
366
413
  .strict(),
367
414
  splitProvinceOperation('split_province'),
368
- splitProvinceOperation('create_province'),
415
+ createProvinceOperation,
369
416
  z
370
417
  .object({
371
418
  ...operationBase,
@@ -405,6 +452,26 @@ export const mapOperationSchema = z.discriminatedUnion('kind', [
405
452
  distribution: z.literal('move-membership'),
406
453
  })
407
454
  .strict(),
455
+ z
456
+ .object({
457
+ ...operationBase,
458
+ kind: z.literal('renumber_map_entity'),
459
+ entity: z.enum(['province', 'state', 'strategic-region']),
460
+ fromId: z.number().int().min(0),
461
+ toId: z.number().int().min(0),
462
+ collision: z.enum(['swap', 'reject']).default('swap'),
463
+ renameLocalisation: z.boolean().default(true),
464
+ })
465
+ .strict()
466
+ .superRefine((value, context) => {
467
+ if (value.entity !== 'province' && (value.fromId === 0 || value.toId === 0)) {
468
+ context.addIssue({
469
+ code: 'custom',
470
+ path: ['fromId'],
471
+ message: 'State and strategic-region IDs must be positive',
472
+ });
473
+ }
474
+ }),
408
475
  z
409
476
  .object({
410
477
  ...operationBase,
@@ -1,6 +1,6 @@
1
1
  import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const PACKAGE_NAME = 'hoi4-agent-tools';
3
- export const PACKAGE_VERSION = '2.5.3';
3
+ export const PACKAGE_VERSION = '2.6.0';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;