1
This commit is contained in:
parent
1a3ba56d9a
commit
810ad6f5cb
33 changed files with 10710 additions and 1087 deletions
50
README.md
50
README.md
|
|
@ -8,6 +8,7 @@ A browser-based procedural prefecture map generator.
|
||||||
- `styles/` - application styles
|
- `styles/` - application styles
|
||||||
- `tests/` - reusable browser and Node.js tests
|
- `tests/` - reusable browser and Node.js tests
|
||||||
- `scripts/` - local development server helpers
|
- `scripts/` - local development server helpers
|
||||||
|
- `docs/` - design history and release verification
|
||||||
- `archive/` - recoverable historical and legacy files, excluded from the active app
|
- `archive/` - recoverable historical and legacy files, excluded from the active app
|
||||||
- `index.html` - application entry point
|
- `index.html` - application entry point
|
||||||
|
|
||||||
|
|
@ -29,10 +30,55 @@ Then open `http://127.0.0.1:8000/`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Run the complete Node.js test suite from the project root:
|
Run the aggregate Node.js regression runner:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node tests/test-all.mjs
|
node tests/test-all.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
The browser test page is available at `http://127.0.0.1:8000/tests/test.html`.
|
Heavy full-map shards can also be run independently, which is the recommended CI layout for memory-constrained workers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node tests/additional-generation-unit.mjs
|
||||||
|
node tests/additional-generation-coverage-worker.mjs
|
||||||
|
node tests/test.js --suite=core
|
||||||
|
node tests/test.js --suite=terrain
|
||||||
|
node tests/test.js --suite=terrain-name
|
||||||
|
node tests/test.js --suite=admin
|
||||||
|
node tests/test.js --suite=patch
|
||||||
|
node tests/test.js --suite=patch-large
|
||||||
|
node tests/test.js --suite=determinism-114514
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the focused additional-generation release gates:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node tests/patch-worker-mirror-sync.mjs
|
||||||
|
node tests/patch-worker-cancel.mjs
|
||||||
|
node tests/additional-generation-max-worker.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The maximum-visible Expansion gate accepts a world seed and Variant through environment variables. CI should run these as independent matrix jobs rather than retaining multiple full worlds in one process:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PATCH_TEST_WORLD_SEED=12345 PATCH_TEST_VARIANT=0 node tests/additional-generation-max-worker.mjs
|
||||||
|
PATCH_TEST_WORLD_SEED=54321 PATCH_TEST_VARIANT=1 node tests/additional-generation-max-worker.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the browser smoke profile:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node tests/run-additional-generation-browser.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the 20-sample maximum-visible Expansion browser profile:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BROWSER_E2E_PROFILE=release \
|
||||||
|
BROWSER_E2E_WORKLOADS=expansion-max-visible \
|
||||||
|
node tests/run-additional-generation-browser.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser test page is also available at `http://127.0.0.1:8000/tests/test.html`.
|
||||||
|
|
||||||
|
For the current r3 additional-generation verification record, see `docs/additional-generation-release-verification-20260810.md`.
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,11 @@ function adapter(url){const code=`import {parentPort} from 'node:worker_threads'
|
||||||
const initial=generateMap(24681357,{terrainType:'auto',onProgress(){}}); const world=createWorldMap(initial); const edge=world.originX+MAP_W-1,cy=world.originY+Math.floor(MAP_H/2); const rect={x0:edge-40,y0:cy-100,x1:edge-40+280,y1:cy+100};
|
const initial=generateMap(24681357,{terrainType:'auto',onProgress(){}}); const world=createWorldMap(initial); const edge=world.originX+MAP_W-1,cy=world.originY+Math.floor(MAP_H/2); const rect={x0:edge-40,y0:cy-100,x1:edge-40+280,y1:cy+100};
|
||||||
const worker=adapter(new URL('./mapPatchWorker.js',import.meta.url)); const preview=structuredClone(world); const transfer=Array.from(collectTransferableBuffers(preview)); const t=Date.now(); let last=0;
|
const worker=adapter(new URL('./mapPatchWorker.js',import.meta.url)); const preview=structuredClone(world); const transfer=Array.from(collectTransferableBuffers(preview)); const t=Date.now(); let last=0;
|
||||||
const message=await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('timeout')),80000);worker.on('message',m=>{if(m?.type==='progress'){if(Date.now()-last>4000){last=Date.now(); console.error(Date.now()-t,m.progress?.label||m.progress?.key);}return;}clearTimeout(timer);resolve(m)});worker.on('error',e=>{clearTimeout(timer);reject(e)});worker.postMessage({id:1,world:preview,rect,options:{patchMode:'expansion',terrainType:'auto',seed:0x4a35b921,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true}},transfer);});
|
const message=await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('timeout')),80000);worker.on('message',m=>{if(m?.type==='progress'){if(Date.now()-last>4000){last=Date.now(); console.error(Date.now()-t,m.progress?.label||m.progress?.key);}return;}clearTimeout(timer);resolve(m)});worker.on('error',e=>{clearTimeout(timer);reject(e)});worker.postMessage({id:1,world:preview,rect,options:{patchMode:'expansion',terrainType:'auto',seed:0x4a35b921,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true}},transfer);});
|
||||||
console.log(JSON.stringify({ms:Date.now()-t,outer:message.ok,inner:message.result?.ok,tiled:message.result?.tiledExpansion,tileCount:message.result?.tileCount,seam:message.result?.seamDiagnostics?.status,reason:message.result?.reason||message.error||null},null,2)); await worker.terminate(); process.exit(0);
|
const summary={ms:Date.now()-t,outer:message.ok,inner:message.result?.ok,tiled:message.result?.tiledExpansion,tileCount:message.result?.tileCount,seam:message.result?.seamDiagnostics?.status,reason:message.result?.reason||message.error||null};
|
||||||
|
console.log(JSON.stringify(summary,null,2));
|
||||||
|
assert.equal(summary.outer,true,'worker transport must succeed');
|
||||||
|
assert.equal(summary.inner,true,`large patch must succeed: ${summary.reason||'unknown failure'}`);
|
||||||
|
assert.equal(summary.tiled,true,'large selection must use tiled production generation');
|
||||||
|
assert.ok(summary.tileCount>=2,'large selection must execute multiple production tiles');
|
||||||
|
assert.ok(summary.ms<60000,`large worker patch exceeded 60 s budget: ${summary.ms} ms`);
|
||||||
|
await worker.terminate();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
import { generateMap } from './mapGenerator.js';
|
import { generateMap } from './mapGenerator.js';
|
||||||
import { createWorldMap } from './worldMap.js';
|
import { createWorldMap } from './worldMap.js';
|
||||||
import { generatePatch } from './mapPatch.js';
|
import { generatePatch } from './mapPatch.js';
|
||||||
|
|
@ -8,10 +9,15 @@ const wa=createWorldMap(structuredClone(init)), wb=createWorldMap(structuredClon
|
||||||
const a=rectFor(wa,258,183), b=rectFor(wb,259,184);
|
const a=rectFor(wa,258,183), b=rectFor(wb,259,184);
|
||||||
const opts={patchMode:'expansion',terrainType:'auto',seed:0x4a35b921,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true};
|
const opts={patchMode:'expansion',terrainType:'auto',seed:0x4a35b921,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true};
|
||||||
const ra=generatePatch(wa,a,opts); const rb=generatePatch(wb,b,opts);
|
const ra=generatePatch(wa,a,opts); const rb=generatePatch(wb,b,opts);
|
||||||
if(!ra.ok||!rb.ok){console.log(JSON.stringify({ra:{ok:ra.ok,code:ra.code,reason:ra.reason},rb:{ok:rb.ok,code:rb.code,reason:rb.reason}},null,2));process.exit(2)}
|
assert.equal(ra.ok,true,`258x183 patch failed: ${ra.reason||ra.code||'unknown'}`);
|
||||||
|
assert.equal(rb.ok,true,`259x184 patch failed: ${rb.reason||rb.code||'unknown'}`);
|
||||||
const fields=['elevation','sea','plain','agriculture','populationDensity','prefectureRegionId','adminId'];
|
const fields=['elevation','sea','plain','agriculture','populationDensity','prefectureRegionId','adminId'];
|
||||||
const out={a:{tiled:!!ra.tiledExpansion,tileCount:ra.tileCount||1},b:{tiled:!!rb.tiledExpansion,tileCount:rb.tileCount||1},common:{}};
|
const out={a:{tiled:!!ra.tiledExpansion,tileCount:ra.tileCount||1},b:{tiled:!!rb.tiledExpansion,tileCount:rb.tileCount||1},common:{}};
|
||||||
for(const name of fields){const A=wa.fields[name],B=wb.fields[name];let n=0,diff=0,sum=0,max=0;for(let y=a.y0;y<a.y1;y++)for(let x=a.x0;x<a.x1;x++){const i=y*wa.width+x;const av=A[i],bv=B[i];const d=Math.abs(Number(av)-Number(bv));n++;if(d>1e-9)diff++;sum+=d;max=Math.max(max,d)}out.common[name]={n,diff,rate:diff/n,meanAbs:sum/n,maxAbs:max};}
|
for(const name of fields){const A=wa.fields[name],B=wb.fields[name];let n=0,diff=0,sum=0,max=0;for(let y=a.y0;y<a.y1;y++)for(let x=a.x0;x<a.x1;x++){const i=y*wa.width+x;const av=A[i],bv=B[i];const d=Math.abs(Number(av)-Number(bv));n++;if(d>1e-9)diff++;sum+=d;max=Math.max(max,d)}out.common[name]={n,diff,rate:diff/n,meanAbs:sum/n,maxAbs:max};}
|
||||||
function boundarySet(world,field,rect){const f=world.fields[field],s=new Set();for(let y=rect.y0;y<rect.y1-1;y++)for(let x=rect.x0;x<rect.x1-1;x++){const i=y*world.width+x;if(f[i]!==f[i+1]||f[i]!==f[i+world.width])s.add(`${x},${y}`)}return s}
|
function boundarySet(world,field,rect){const f=world.fields[field],s=new Set();for(let y=rect.y0;y<rect.y1-1;y++)for(let x=rect.x0;x<rect.x1-1;x++){const i=y*world.width+x;if(f[i]!==f[i+1]||f[i]!==f[i+world.width])s.add(`${x},${y}`)}return s}
|
||||||
for(const field of ['prefectureRegionId','adminId']){const A=boundarySet(wa,field,a),B=boundarySet(wb,field,a);let xor=0;for(const k of A)if(!B.has(k))xor++;for(const k of B)if(!A.has(k))xor++;out.common[field+'Boundary']={a:A.size,b:B.size,xor,normalized:xor/Math.max(1,A.size+B.size)};}
|
for(const field of ['prefectureRegionId','adminId']){const A=boundarySet(wa,field,a),B=boundarySet(wb,field,a);let xor=0;for(const k of A)if(!B.has(k))xor++;for(const k of B)if(!A.has(k))xor++;out.common[field+'Boundary']={a:A.size,b:B.size,xor,normalized:xor/Math.max(1,A.size+B.size)};}
|
||||||
console.log(JSON.stringify(out,null,2));
|
console.log(JSON.stringify(out,null,2));
|
||||||
|
assert.ok(out.common.elevation.rate<=0.08,`common elevation changed too much: ${out.common.elevation.rate}`);
|
||||||
|
assert.ok(out.common.sea.rate<=0.05,`common sea mask changed too much: ${out.common.sea.rate}`);
|
||||||
|
assert.ok(out.common.prefectureRegionIdBoundary.normalized<=0.15,`prefecture boundary threshold instability: ${out.common.prefectureRegionIdBoundary.normalized}`);
|
||||||
|
assert.ok(out.common.adminIdBoundary.normalized<=0.15,`municipal boundary threshold instability: ${out.common.adminIdBoundary.normalized}`);
|
||||||
|
|
|
||||||
|
|
@ -365,7 +365,7 @@ function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, b
|
||||||
return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10;
|
return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed) {
|
function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed, progress = null) {
|
||||||
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
|
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
|
||||||
const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
|
const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
|
||||||
const seeds = [];
|
const seeds = [];
|
||||||
|
|
@ -404,21 +404,50 @@ function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed
|
||||||
const candidates = cells
|
const candidates = cells
|
||||||
.map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) }))
|
.map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) }))
|
||||||
.sort((a, b) => b.score - a.score);
|
.sort((a, b) => b.score - a.score);
|
||||||
|
progress?.(`natural seed component ${componentOrder + 1}/${sortedComponents.length} scored`);
|
||||||
const localSeeds = [];
|
const localSeeds = [];
|
||||||
const idealSpacing = Math.sqrt(area / Math.max(1, localTarget));
|
const idealSpacing = Math.sqrt(area / Math.max(1, localTarget));
|
||||||
const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34];
|
const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34];
|
||||||
for (const factor of spacingPasses) {
|
for (let spacingPass = 0; spacingPass < spacingPasses.length; spacingPass++) {
|
||||||
|
const factor = spacingPasses[spacingPass];
|
||||||
const minDist = Math.max(2.2, idealSpacing * factor);
|
const minDist = Math.max(2.2, idealSpacing * factor);
|
||||||
|
// The old loop compared every candidate with every accepted seed. A
|
||||||
|
// bucket with side=minDist is an exact filter: any seed that can satisfy
|
||||||
|
// the unchanged Math.hypot(... ) < minDist predicate must be in the same
|
||||||
|
// or one of the eight adjacent buckets.
|
||||||
|
const seedBuckets = new Map();
|
||||||
|
const bucketKey = (x, y) => `${Math.floor(x / minDist)},${Math.floor(y / minDist)}`;
|
||||||
|
const addSeedToBucket = (cellIndex) => {
|
||||||
|
const x = cellIndex % MAP_W;
|
||||||
|
const y = Math.floor(cellIndex / MAP_W);
|
||||||
|
const key = bucketKey(x, y);
|
||||||
|
let bucket = seedBuckets.get(key);
|
||||||
|
if (!bucket) seedBuckets.set(key, (bucket = []));
|
||||||
|
bucket.push(cellIndex);
|
||||||
|
};
|
||||||
|
for (const existing of localSeeds) addSeedToBucket(existing);
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (localSeeds.length >= localTarget) break;
|
if (localSeeds.length >= localTarget) break;
|
||||||
const [x, y] = xyOf(candidate.i);
|
const x = candidate.i % MAP_W;
|
||||||
|
const y = Math.floor(candidate.i / MAP_W);
|
||||||
|
const bx = Math.floor(x / minDist);
|
||||||
|
const by = Math.floor(y / minDist);
|
||||||
let ok = true;
|
let ok = true;
|
||||||
for (const existing of localSeeds) {
|
for (let oy = -1; oy <= 1 && ok; oy++) {
|
||||||
const [ex, ey] = xyOf(existing);
|
for (let ox = -1; ox <= 1 && ok; ox++) {
|
||||||
if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; }
|
for (const existing of seedBuckets.get(`${bx + ox},${by + oy}`) || []) {
|
||||||
|
const ex = existing % MAP_W;
|
||||||
|
const ey = Math.floor(existing / MAP_W);
|
||||||
|
if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
localSeeds.push(candidate.i);
|
||||||
|
addSeedToBucket(candidate.i);
|
||||||
}
|
}
|
||||||
if (ok) localSeeds.push(candidate.i);
|
|
||||||
}
|
}
|
||||||
|
progress?.(`natural seed component ${componentOrder + 1}/${sortedComponents.length}, spacing ${spacingPass + 1}/${spacingPasses.length}`);
|
||||||
if (localSeeds.length >= localTarget) break;
|
if (localSeeds.length >= localTarget) break;
|
||||||
}
|
}
|
||||||
for (const i of localSeeds) {
|
for (const i of localSeeds) {
|
||||||
|
|
@ -697,16 +726,18 @@ function splitNaturalCompartmentByAxis(unit, newId, compartmentId, fields, seed)
|
||||||
function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
||||||
const progress = typeof options.progress === "function" ? options.progress : null;
|
const progress = typeof options.progress === "function" ? options.progress : null;
|
||||||
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
||||||
|
progress?.("natural barrier field complete");
|
||||||
const cellClass = new Int16Array(SIZE);
|
const cellClass = new Int16Array(SIZE);
|
||||||
cellClass.fill(-1);
|
cellClass.fill(-1);
|
||||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
|
||||||
|
progress?.("natural landscape classes complete");
|
||||||
const watershedId = options.watershedId || null;
|
const watershedId = options.watershedId || null;
|
||||||
const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass, watershedId };
|
const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass, watershedId };
|
||||||
const landComponents = collectNaturalGrowthComponents(prefectureMask, sea, watershedId);
|
const landComponents = collectNaturalGrowthComponents(prefectureMask, sea, watershedId);
|
||||||
const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
|
const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
|
||||||
const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360);
|
const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360);
|
||||||
const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8)));
|
const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8)));
|
||||||
const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0);
|
const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0, progress);
|
||||||
progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`);
|
progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`);
|
||||||
const compartmentId = new Int32Array(SIZE);
|
const compartmentId = new Int32Array(SIZE);
|
||||||
compartmentId.fill(-1);
|
compartmentId.fill(-1);
|
||||||
|
|
@ -741,7 +772,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
||||||
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
|
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
|
||||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||||
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
|
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
|
||||||
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5);
|
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5, progress);
|
||||||
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
||||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||||
splitCompartmentsByWatershed(compartmentId, compartments, fields);
|
splitCompartmentsByWatershed(compartmentId, compartments, fields);
|
||||||
|
|
@ -752,7 +783,9 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
||||||
|
|
||||||
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
|
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
|
||||||
let guard = Math.max(60, targetCount * 2);
|
let guard = Math.max(60, targetCount * 2);
|
||||||
|
let splitIterations = 0;
|
||||||
while (guard-- > 0) {
|
while (guard-- > 0) {
|
||||||
|
if (splitIterations++ % 4 === 0) progress?.(`natural compact split ${splitIterations}/${Math.max(60, targetCount * 2)}`);
|
||||||
let active = compartments.filter((unit) => unit && unit.area > 0);
|
let active = compartments.filter((unit) => unit && unit.area > 0);
|
||||||
const needMore = active.length < targetCount;
|
const needMore = active.length < targetCount;
|
||||||
const worst = active
|
const worst = active
|
||||||
|
|
@ -781,9 +814,12 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4);
|
progress?.("natural compact split complete");
|
||||||
|
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4, progress);
|
||||||
|
progress?.("natural weak-boundary merge complete");
|
||||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||||
splitCompartmentsByWatershed(compartmentId, compartments, fields);
|
splitCompartmentsByWatershed(compartmentId, compartments, fields);
|
||||||
|
progress?.("natural connectivity and watershed split complete");
|
||||||
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||||
refreshAllCompartmentStats(compartments, fields);
|
refreshAllCompartmentStats(compartments, fields);
|
||||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||||
|
|
@ -857,12 +893,13 @@ function mergeUnitInto(unitId, units, fromId, toId, fields) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeWeakArtificialLandscapeUnits(unitId, units, fields, prefectureMask, sea, maxMergedArea = 84, passes = 5) {
|
function mergeWeakArtificialLandscapeUnits(unitId, units, fields, prefectureMask, sea, maxMergedArea = 84, passes = 5, progress = null) {
|
||||||
// Seeded graph growth can create diagonal stair-step borders in uniform plains
|
// Seeded graph growth can create diagonal stair-step borders in uniform plains
|
||||||
// and gentle hills. If the shared edge is weak, balanced H/V, and the two
|
// and gentle hills. If the shared edge is weak, balanced H/V, and the two
|
||||||
// sides are the same natural group, merge it instead of preserving an
|
// sides are the same natural group, merge it instead of preserving an
|
||||||
// artificial Voronoi-like cut.
|
// artificial Voronoi-like cut.
|
||||||
for (let pass = 0; pass < passes; pass++) {
|
for (let pass = 0; pass < passes; pass++) {
|
||||||
|
progress?.(`natural weak-boundary merge pass ${pass + 1}/${passes}`);
|
||||||
rebuildLandscapeUnitAdjacency(unitId, units, fields.naturalBarrierScore, prefectureMask, sea);
|
rebuildLandscapeUnitAdjacency(unitId, units, fields.naturalBarrierScore, prefectureMask, sea);
|
||||||
let best = null;
|
let best = null;
|
||||||
let bestScore = 0.0;
|
let bestScore = 0.0;
|
||||||
|
|
@ -1235,4 +1272,3 @@ function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevatio
|
||||||
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1557
src/app.js
1557
src/app.js
File diff suppressed because it is too large
Load diff
430
src/committedWorldDelta.js
Normal file
430
src/committedWorldDelta.js
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
function typedArrayConstructor(name) {
|
||||||
|
return {
|
||||||
|
Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array,
|
||||||
|
Int32Array, Uint32Array, Float32Array, Float64Array,
|
||||||
|
}[name] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTypedRowDelta(current, delta) {
|
||||||
|
if (!delta) return current;
|
||||||
|
const Constructor = typedArrayConstructor(delta.constructorName);
|
||||||
|
if (!Constructor) throw new Error(`Unsupported delta field type ${delta.constructorName}.`);
|
||||||
|
if (delta.replace) return new Constructor(delta.replace);
|
||||||
|
const target = ArrayBuffer.isView(current) && current.constructor?.name === delta.constructorName && current.length === delta.length
|
||||||
|
? current
|
||||||
|
: new Constructor(delta.length);
|
||||||
|
for (const row of delta.rows || []) target.set(row.values, row.start);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyExactObjectDelta(target = {}, delta = {}, { cloneValues = true } = {}) {
|
||||||
|
for (const key of delta.removed || []) delete target[key];
|
||||||
|
for (const [key, value] of Object.entries(delta.set || {})) target[key] = cloneValues ? structuredClone(value) : value;
|
||||||
|
for (const [key, splice] of Object.entries(delta.arraySplices || {})) {
|
||||||
|
const current = target[key];
|
||||||
|
if (!Array.isArray(current)) throw new Error(`Cannot apply array splice delta to non-array metadata key ${key}.`);
|
||||||
|
const start = Math.max(0, Math.min(current.length, Math.floor(Number(splice?.start || 0))));
|
||||||
|
const deleteCount = Math.max(0, Math.min(current.length - start, Math.floor(Number(splice?.deleteCount || 0))));
|
||||||
|
const items = Array.isArray(splice?.items) ? splice.items : [];
|
||||||
|
const inserted = cloneValues ? structuredClone(items) : items;
|
||||||
|
target[key] = current.slice(0, start).concat(inserted, current.slice(start + deleteCount));
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCommittedWorldDelta(world, delta, { consumeMetadata = false, copyOnWrite = false } = {}) {
|
||||||
|
if (!world || !delta) throw new Error("Committed mirror delta is missing.");
|
||||||
|
if (world.width !== delta.width || world.height !== delta.height) {
|
||||||
|
throw new Error(`Committed mirror dimensions changed unexpectedly (${world.width}x${world.height} -> ${delta.width}x${delta.height}).`);
|
||||||
|
}
|
||||||
|
for (const [name, fieldDelta] of Object.entries(delta.fields || {})) {
|
||||||
|
if (fieldDelta.remove) delete world.fields[name];
|
||||||
|
else {
|
||||||
|
const current = world.fields?.[name];
|
||||||
|
const writable = copyOnWrite && ArrayBuffer.isView(current) && !fieldDelta.replace
|
||||||
|
? new current.constructor(current)
|
||||||
|
: current;
|
||||||
|
world.fields[name] = applyTypedRowDelta(writable, fieldDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const generatedMask = copyOnWrite && delta.generatedMask && ArrayBuffer.isView(world.generatedMask) && !delta.generatedMask.replace
|
||||||
|
? new world.generatedMask.constructor(world.generatedMask)
|
||||||
|
: world.generatedMask;
|
||||||
|
world.generatedMask = applyTypedRowDelta(generatedMask, delta.generatedMask);
|
||||||
|
if (delta.sourceMapDelta || delta.metaDelta) {
|
||||||
|
// Clone all changed metadata in one graph. sourceMap diagnostics and
|
||||||
|
// lastPatchResult often share the same seam/path objects; cloning each key
|
||||||
|
// separately multiplied both allocation and retained heap.
|
||||||
|
// postMessage already gives the main thread an isolated object graph, and
|
||||||
|
// the Worker ACK consumes its retained delta exactly once. Those hot paths
|
||||||
|
// can adopt the delta values directly instead of cloning every changed
|
||||||
|
// feature/path/diagnostic a second time at peak memory. Keep clone-by-default
|
||||||
|
// for reusable library callers.
|
||||||
|
const exactValues = consumeMetadata ? {
|
||||||
|
sourceSet: delta.sourceMapDelta?.set || {},
|
||||||
|
metaSet: delta.metaDelta?.set || {},
|
||||||
|
sourceArraySplices: delta.sourceMapDelta?.arraySplices || {},
|
||||||
|
metaArraySplices: delta.metaDelta?.arraySplices || {},
|
||||||
|
} : structuredClone({
|
||||||
|
sourceSet: delta.sourceMapDelta?.set || {},
|
||||||
|
metaSet: delta.metaDelta?.set || {},
|
||||||
|
sourceArraySplices: delta.sourceMapDelta?.arraySplices || {},
|
||||||
|
metaArraySplices: delta.metaDelta?.arraySplices || {},
|
||||||
|
});
|
||||||
|
if (delta.sourceMapDelta) {
|
||||||
|
world.sourceMap = applyExactObjectDelta(world.sourceMap || {}, {
|
||||||
|
...delta.sourceMapDelta,
|
||||||
|
set: exactValues.sourceSet,
|
||||||
|
arraySplices: exactValues.sourceArraySplices,
|
||||||
|
}, { cloneValues: false });
|
||||||
|
}
|
||||||
|
if (delta.metaDelta) {
|
||||||
|
applyExactObjectDelta(world, {
|
||||||
|
...delta.metaDelta,
|
||||||
|
set: exactValues.metaSet,
|
||||||
|
arraySplices: exactValues.metaArraySplices,
|
||||||
|
}, { cloneValues: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else world.sourceMap = structuredClone(delta.sourceMap || {});
|
||||||
|
if (!delta.metaDelta) {
|
||||||
|
for (const key of delta.removedMetaKeys || []) delete world[key];
|
||||||
|
for (const [key, value] of Object.entries(delta.meta || {})) world[key] = structuredClone(value);
|
||||||
|
}
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeCommittedWorldDelta(baseWorld, delta, { consumeMetadata = false } = {}) {
|
||||||
|
if (!baseWorld) throw new Error("Committed base world is missing.");
|
||||||
|
// Patch previews are immutable views. Copy the world containers, then clone
|
||||||
|
// only typed fields touched by row deltas. Exact metadata application replaces
|
||||||
|
// changed roots/arrays, so untouched production metadata can remain shared.
|
||||||
|
const previewWorld = {
|
||||||
|
...baseWorld,
|
||||||
|
fields: { ...(baseWorld.fields || {}) },
|
||||||
|
sourceMap: { ...(baseWorld.sourceMap || {}) },
|
||||||
|
};
|
||||||
|
return applyCommittedWorldDelta(previewWorld, delta, { consumeMetadata, copyOnWrite: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancellationError(message = "Committed world delta materialization cancelled.") {
|
||||||
|
if (typeof DOMException === "function") return new DOMException(message, "AbortError");
|
||||||
|
const error = new Error(message);
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneTypedArrayCooperatively(source, yieldControl, shouldCancel, chunkBytes) {
|
||||||
|
if (!ArrayBuffer.isView(source) || source instanceof DataView) return source;
|
||||||
|
const target = new source.constructor(source.length);
|
||||||
|
const bytesPerElement = Math.max(1, source.BYTES_PER_ELEMENT || 1);
|
||||||
|
const elementsPerChunk = Math.max(1, Math.floor(chunkBytes / bytesPerElement));
|
||||||
|
for (let offset = 0; offset < source.length; offset += elementsPerChunk) {
|
||||||
|
if (shouldCancel()) throw cancellationError();
|
||||||
|
const end = Math.min(source.length, offset + elementsPerChunk);
|
||||||
|
target.set(source.subarray(offset, end), offset);
|
||||||
|
if (end < source.length) await yieldControl();
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTypedRowDeltaCooperatively(current, delta, options) {
|
||||||
|
if (!delta) return current;
|
||||||
|
const Constructor = typedArrayConstructor(delta.constructorName);
|
||||||
|
if (!Constructor) throw new Error(`Unsupported delta field type ${delta.constructorName}.`);
|
||||||
|
const { yieldControl, shouldCancel, chunkBytes } = options;
|
||||||
|
if (delta.replace) {
|
||||||
|
// The main thread owns transferred replacement buffers exclusively. Adopt
|
||||||
|
// them directly instead of making a second full-size copy at peak memory.
|
||||||
|
if (ArrayBuffer.isView(delta.replace) && delta.replace.constructor === Constructor) return delta.replace;
|
||||||
|
return cloneTypedArrayCooperatively(new Constructor(delta.replace), yieldControl, shouldCancel, chunkBytes);
|
||||||
|
}
|
||||||
|
let target;
|
||||||
|
if (ArrayBuffer.isView(current) && current.constructor?.name === delta.constructorName && current.length === delta.length) {
|
||||||
|
target = await cloneTypedArrayCooperatively(current, yieldControl, shouldCancel, chunkBytes);
|
||||||
|
} else {
|
||||||
|
target = new Constructor(delta.length);
|
||||||
|
}
|
||||||
|
let bytesSinceYield = 0;
|
||||||
|
for (const row of delta.rows || []) {
|
||||||
|
if (shouldCancel()) throw cancellationError();
|
||||||
|
target.set(row.values, row.start);
|
||||||
|
bytesSinceYield += row.values?.byteLength || 0;
|
||||||
|
if (bytesSinceYield >= chunkBytes) {
|
||||||
|
bytesSinceYield = 0;
|
||||||
|
await yieldControl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function materializeCommittedWorldDeltaCooperative(baseWorld, delta, {
|
||||||
|
consumeMetadata = false,
|
||||||
|
yieldControl = () => Promise.resolve(),
|
||||||
|
shouldCancel = () => false,
|
||||||
|
chunkBytes = 4 * 1024 * 1024,
|
||||||
|
} = {}) {
|
||||||
|
if (!baseWorld) throw new Error("Committed base world is missing.");
|
||||||
|
if (!delta) throw new Error("Committed mirror delta is missing.");
|
||||||
|
if (baseWorld.width !== delta.width || baseWorld.height !== delta.height) {
|
||||||
|
throw new Error(`Committed mirror dimensions changed unexpectedly (${baseWorld.width}x${baseWorld.height} -> ${delta.width}x${delta.height}).`);
|
||||||
|
}
|
||||||
|
const previewWorld = {
|
||||||
|
...baseWorld,
|
||||||
|
fields: { ...(baseWorld.fields || {}) },
|
||||||
|
sourceMap: { ...(baseWorld.sourceMap || {}) },
|
||||||
|
};
|
||||||
|
for (const [name, fieldDelta] of Object.entries(delta.fields || {})) {
|
||||||
|
if (shouldCancel()) throw cancellationError();
|
||||||
|
if (fieldDelta.remove) delete previewWorld.fields[name];
|
||||||
|
else previewWorld.fields[name] = await applyTypedRowDeltaCooperatively(previewWorld.fields[name], fieldDelta, {
|
||||||
|
yieldControl, shouldCancel, chunkBytes,
|
||||||
|
});
|
||||||
|
await yieldControl();
|
||||||
|
}
|
||||||
|
if (delta.generatedMask) {
|
||||||
|
previewWorld.generatedMask = await applyTypedRowDeltaCooperatively(previewWorld.generatedMask, delta.generatedMask, {
|
||||||
|
yieldControl, shouldCancel, chunkBytes,
|
||||||
|
});
|
||||||
|
await yieldControl();
|
||||||
|
}
|
||||||
|
if (shouldCancel()) throw cancellationError();
|
||||||
|
if (delta.sourceMapDelta || delta.metaDelta) {
|
||||||
|
const exactValues = consumeMetadata ? {
|
||||||
|
sourceSet: delta.sourceMapDelta?.set || {},
|
||||||
|
metaSet: delta.metaDelta?.set || {},
|
||||||
|
sourceArraySplices: delta.sourceMapDelta?.arraySplices || {},
|
||||||
|
metaArraySplices: delta.metaDelta?.arraySplices || {},
|
||||||
|
} : structuredClone({
|
||||||
|
sourceSet: delta.sourceMapDelta?.set || {},
|
||||||
|
metaSet: delta.metaDelta?.set || {},
|
||||||
|
sourceArraySplices: delta.sourceMapDelta?.arraySplices || {},
|
||||||
|
metaArraySplices: delta.metaDelta?.arraySplices || {},
|
||||||
|
});
|
||||||
|
if (delta.sourceMapDelta) {
|
||||||
|
previewWorld.sourceMap = applyExactObjectDelta(previewWorld.sourceMap || {}, {
|
||||||
|
...delta.sourceMapDelta,
|
||||||
|
set: exactValues.sourceSet,
|
||||||
|
arraySplices: exactValues.sourceArraySplices,
|
||||||
|
}, { cloneValues: false });
|
||||||
|
await yieldControl();
|
||||||
|
}
|
||||||
|
if (shouldCancel()) throw cancellationError();
|
||||||
|
if (delta.metaDelta) {
|
||||||
|
applyExactObjectDelta(previewWorld, {
|
||||||
|
...delta.metaDelta,
|
||||||
|
set: exactValues.metaSet,
|
||||||
|
arraySplices: exactValues.metaArraySplices,
|
||||||
|
}, { cloneValues: false });
|
||||||
|
await yieldControl();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
previewWorld.sourceMap = consumeMetadata ? (delta.sourceMap || {}) : structuredClone(delta.sourceMap || {});
|
||||||
|
}
|
||||||
|
if (!delta.metaDelta) {
|
||||||
|
for (const key of delta.removedMetaKeys || []) delete previewWorld[key];
|
||||||
|
for (const [key, value] of Object.entries(delta.meta || {})) {
|
||||||
|
if (shouldCancel()) throw cancellationError();
|
||||||
|
previewWorld[key] = consumeMetadata ? value : structuredClone(value);
|
||||||
|
await yieldControl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return previewWorld;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mixHashText(hash, text) {
|
||||||
|
let value = hash >>> 0;
|
||||||
|
for (let index = 0; index < text.length; index++) {
|
||||||
|
value ^= text.charCodeAt(index);
|
||||||
|
value = Math.imul(value, 16777619) >>> 0;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mixHashValue(hash, value) {
|
||||||
|
let next = hash >>> 0;
|
||||||
|
if (value == null) return mixHashText(next, String(value));
|
||||||
|
if (ArrayBuffer.isView(value)) {
|
||||||
|
next = mixHashText(next, `${value.constructor.name}:${value.length}:`);
|
||||||
|
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||||
|
for (let index = 0; index < bytes.length; index++) {
|
||||||
|
next ^= bytes[index];
|
||||||
|
next = Math.imul(next, 16777619) >>> 0;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
next = mixHashText(next, `[${value.length}:`);
|
||||||
|
for (const item of value) next = mixHashValue(next, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
if (value instanceof Map) {
|
||||||
|
const entries = [...value.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)));
|
||||||
|
return mixHashValue(mixHashText(next, `Map:${entries.length}:`), entries);
|
||||||
|
}
|
||||||
|
if (value instanceof Set) return mixHashValue(mixHashText(next, `Set:${value.size}:`), [...value].sort());
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const keys = Object.keys(value).sort();
|
||||||
|
next = mixHashText(next, `{${keys.length}:`);
|
||||||
|
for (const key of keys) {
|
||||||
|
next = mixHashText(next, key);
|
||||||
|
next = mixHashValue(next, value[key]);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return mixHashText(next, `${typeof value}:${String(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashCommittedWorld(world) {
|
||||||
|
let hash = 2166136261 >>> 0;
|
||||||
|
hash = mixHashText(hash, `${world?.width || 0}x${world?.height || 0}|${world?.originX || 0},${world?.originY || 0}`);
|
||||||
|
for (const name of Object.keys(world?.fields || {}).sort()) {
|
||||||
|
hash = mixHashText(hash, `|${name}:`);
|
||||||
|
const field = world.fields[name];
|
||||||
|
if (!ArrayBuffer.isView(field)) continue;
|
||||||
|
const bytes = new Uint8Array(field.buffer, field.byteOffset, field.byteLength);
|
||||||
|
for (let index = 0; index < bytes.length; index++) {
|
||||||
|
hash ^= bytes[index];
|
||||||
|
hash = Math.imul(hash, 16777619) >>> 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(world?.generatedMask)) {
|
||||||
|
hash = mixHashText(hash, "|generatedMask:");
|
||||||
|
const bytes = new Uint8Array(world.generatedMask.buffer, world.generatedMask.byteOffset, world.generatedMask.byteLength);
|
||||||
|
for (let index = 0; index < bytes.length; index++) {
|
||||||
|
hash ^= bytes[index];
|
||||||
|
hash = Math.imul(hash, 16777619) >>> 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash = mixHashText(hash, "|sourceMap:");
|
||||||
|
hash = mixHashValue(hash, world?.sourceMap || {});
|
||||||
|
const meta = {};
|
||||||
|
for (const [key, value] of Object.entries(world || {})) {
|
||||||
|
if (key === "fields" || key === "generatedMask" || key === "sourceMap") continue;
|
||||||
|
meta[key] = value;
|
||||||
|
}
|
||||||
|
hash = mixHashText(hash, "|meta:");
|
||||||
|
hash = mixHashValue(hash, meta);
|
||||||
|
return hash.toString(16).padStart(8, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function cooperativeHashYield(state) {
|
||||||
|
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
|
||||||
|
state.pending = 0;
|
||||||
|
await state.yieldControl();
|
||||||
|
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mixHashTextAsync(hash, text, state) {
|
||||||
|
let value = hash >>> 0;
|
||||||
|
let index = 0;
|
||||||
|
while (index < text.length) {
|
||||||
|
const start = index;
|
||||||
|
const capacity = Math.max(1, state.yieldEvery - state.pending);
|
||||||
|
const end = Math.min(text.length, index + capacity);
|
||||||
|
for (; index < end; index++) {
|
||||||
|
value ^= text.charCodeAt(index);
|
||||||
|
value = Math.imul(value, 16777619) >>> 0;
|
||||||
|
}
|
||||||
|
state.pending += end - start;
|
||||||
|
if (state.pending >= state.yieldEvery) await cooperativeHashYield(state);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mixHashBytesAsync(hash, bytes, state) {
|
||||||
|
let value = hash >>> 0;
|
||||||
|
let index = 0;
|
||||||
|
while (index < bytes.length) {
|
||||||
|
const start = index;
|
||||||
|
const capacity = Math.max(1, state.yieldEvery - state.pending);
|
||||||
|
const end = Math.min(bytes.length, index + capacity);
|
||||||
|
for (; index < end; index++) {
|
||||||
|
value ^= bytes[index];
|
||||||
|
value = Math.imul(value, 16777619) >>> 0;
|
||||||
|
}
|
||||||
|
state.pending += end - start;
|
||||||
|
if (state.pending >= state.yieldEvery) await cooperativeHashYield(state);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mixHashValueAsync(hash, value, state) {
|
||||||
|
let next = hash >>> 0;
|
||||||
|
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
|
||||||
|
if (value == null) return mixHashTextAsync(next, String(value), state);
|
||||||
|
if (ArrayBuffer.isView(value)) {
|
||||||
|
next = await mixHashTextAsync(next, `${value.constructor.name}:${value.length}:`, state);
|
||||||
|
return mixHashBytesAsync(next, new Uint8Array(value.buffer, value.byteOffset, value.byteLength), state);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
next = await mixHashTextAsync(next, `[${value.length}:`, state);
|
||||||
|
for (const item of value) next = await mixHashValueAsync(next, item, state);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
if (value instanceof Map) {
|
||||||
|
const entries = [...value.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)));
|
||||||
|
next = await mixHashTextAsync(next, `Map:${entries.length}:`, state);
|
||||||
|
return mixHashValueAsync(next, entries, state);
|
||||||
|
}
|
||||||
|
if (value instanceof Set) {
|
||||||
|
next = await mixHashTextAsync(next, `Set:${value.size}:`, state);
|
||||||
|
return mixHashValueAsync(next, [...value].sort(), state);
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const keys = Object.keys(value).sort();
|
||||||
|
next = await mixHashTextAsync(next, `{${keys.length}:`, state);
|
||||||
|
for (const key of keys) {
|
||||||
|
next = await mixHashTextAsync(next, key, state);
|
||||||
|
next = await mixHashValueAsync(next, value[key], state);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return mixHashTextAsync(next, `${typeof value}:${String(value)}`, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bit-identical cooperative counterpart to hashCommittedWorld(). Yielding is
|
||||||
|
// only inserted between chunks; byte/text order and FNV-1a arithmetic are
|
||||||
|
// unchanged. This lets the main thread verify a transferred transactional
|
||||||
|
// preview without creating an uncancellable multi-megabyte long task.
|
||||||
|
export async function hashCommittedWorldAsync(world, {
|
||||||
|
yieldEvery = 262_144,
|
||||||
|
yieldControl = null,
|
||||||
|
shouldAbort = () => false,
|
||||||
|
} = {}) {
|
||||||
|
const fallbackYield = () => {
|
||||||
|
if (typeof globalThis.scheduler?.yield === "function") return globalThis.scheduler.yield();
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
yieldEvery: Math.max(1, Math.floor(Number(yieldEvery) || 262_144)),
|
||||||
|
pending: 0,
|
||||||
|
shouldAbort: typeof shouldAbort === "function" ? shouldAbort : () => false,
|
||||||
|
yieldControl: typeof yieldControl === "function" ? yieldControl : fallbackYield,
|
||||||
|
};
|
||||||
|
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
|
||||||
|
let hash = 2166136261 >>> 0;
|
||||||
|
hash = await mixHashTextAsync(hash, `${world?.width || 0}x${world?.height || 0}|${world?.originX || 0},${world?.originY || 0}`, state);
|
||||||
|
for (const name of Object.keys(world?.fields || {}).sort()) {
|
||||||
|
hash = await mixHashTextAsync(hash, `|${name}:`, state);
|
||||||
|
const field = world.fields[name];
|
||||||
|
if (!ArrayBuffer.isView(field)) continue;
|
||||||
|
hash = await mixHashBytesAsync(hash, new Uint8Array(field.buffer, field.byteOffset, field.byteLength), state);
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(world?.generatedMask)) {
|
||||||
|
hash = await mixHashTextAsync(hash, "|generatedMask:", state);
|
||||||
|
hash = await mixHashBytesAsync(hash, new Uint8Array(world.generatedMask.buffer, world.generatedMask.byteOffset, world.generatedMask.byteLength), state);
|
||||||
|
}
|
||||||
|
hash = await mixHashTextAsync(hash, "|sourceMap:", state);
|
||||||
|
hash = await mixHashValueAsync(hash, world?.sourceMap || {}, state);
|
||||||
|
const meta = {};
|
||||||
|
for (const [key, value] of Object.entries(world || {})) {
|
||||||
|
if (key === "fields" || key === "generatedMask" || key === "sourceMap") continue;
|
||||||
|
meta[key] = value;
|
||||||
|
}
|
||||||
|
hash = await mixHashTextAsync(hash, "|meta:", state);
|
||||||
|
hash = await mixHashValueAsync(hash, meta, state);
|
||||||
|
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
|
||||||
|
return hash.toString(16).padStart(8, "0");
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import { buildFeatureLanduse } from "./mapFeatureLanduse.js";
|
||||||
import { buildSettlementDemandFields } from "./mapFeatureSettlements.js";
|
import { buildSettlementDemandFields } from "./mapFeatureSettlements.js";
|
||||||
import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js";
|
import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js";
|
||||||
import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTransportGraph.js";
|
import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTransportGraph.js";
|
||||||
import { labelOccupancyComponents, normalizeTransportPathSet, smoothRasterPath } from "./mapTransportUtils.js";
|
import { getRadialInfluenceKernel, labelOccupancyComponents, normalizeTransportPathSet, smoothRasterPath } from "./mapTransportUtils.js";
|
||||||
|
|
||||||
// Lightweight Human Geography V2
|
// Lightweight Human Geography V2
|
||||||
// --------------------------------
|
// --------------------------------
|
||||||
|
|
@ -28,6 +28,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
|
||||||
const largePatchTile = options?.largeExpansionTile === true;
|
const largePatchTile = options?.largeExpansionTile === true;
|
||||||
const topCenterSuppression = clamp(Number.isFinite(options?.topCenterSuppression) ? options.topCenterSuppression : (patchMode ? 0.68 : 0), 0, 0.95);
|
const topCenterSuppression = clamp(Number.isFinite(options?.topCenterSuppression) ? options.topCenterSuppression : (patchMode ? 0.68 : 0), 0, 0.95);
|
||||||
const featureTimings = [];
|
const featureTimings = [];
|
||||||
|
const generationProgress = options?.onProgress;
|
||||||
let timingMark = nowMs();
|
let timingMark = nowMs();
|
||||||
function markFeatureTiming(key) {
|
function markFeatureTiming(key) {
|
||||||
const t = nowMs();
|
const t = nowMs();
|
||||||
|
|
@ -688,6 +689,8 @@ if (isRegionalCapital) {
|
||||||
const componentCapitalInfluence = influenceFromPoints(modernCities.filter((p) => p.isPrefecturalCapital), 16, () => 5.0);
|
const componentCapitalInfluence = influenceFromPoints(modernCities.filter((p) => p.isPrefecturalCapital), 16, () => 5.0);
|
||||||
|
|
||||||
const corridorSkeleton = new Uint8Array(SIZE);
|
const corridorSkeleton = new Uint8Array(SIZE);
|
||||||
|
const corridorAllowanceField = new Float64Array(SIZE);
|
||||||
|
const corridorTerrainFlowField = new Float64Array(SIZE);
|
||||||
for (let i = 0; i < SIZE; i++) {
|
for (let i = 0; i < SIZE; i++) {
|
||||||
if (sea[i]) continue;
|
if (sea[i]) continue;
|
||||||
corridorSkeleton[i] = Math.round(clamp(
|
corridorSkeleton[i] = Math.round(clamp(
|
||||||
|
|
@ -701,11 +704,19 @@ if (isRegionalCapital) {
|
||||||
ridgeField[i] * 0.22 -
|
ridgeField[i] * 0.22 -
|
||||||
slope[i] * 0.28
|
slope[i] * 0.28
|
||||||
) * 255);
|
) * 255);
|
||||||
|
corridorAllowanceField[i] = clamp(
|
||||||
|
settlementDemand[i] * 0.58 + valleyField[i] * 0.42 + coastalLowland[i] * 0.36
|
||||||
|
- plain[i] * 0.18 - agriculture[i] * 0.14
|
||||||
|
);
|
||||||
|
corridorTerrainFlowField[i] = clamp(
|
||||||
|
valleyField[i] * 0.54 + coastalLowland[i] * 0.28 + plain[i] * 0.16
|
||||||
|
+ (passSuitability?.[i] || 0) * 0.34 - ridgeField[i] * 0.24 - slope[i] * 0.22
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function corridorAllowance(i) {
|
function corridorAllowance(i) {
|
||||||
return clamp(settlementDemand[i] * 0.58 + valleyField[i] * 0.42 + coastalLowland[i] * 0.36 - plain[i] * 0.18 - agriculture[i] * 0.14);
|
return corridorAllowanceField[i] || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function endpointSupport(i) {
|
function endpointSupport(i) {
|
||||||
|
|
@ -744,6 +755,27 @@ if (isRegionalCapital) {
|
||||||
closed: new Int32Array(SIZE),
|
closed: new Int32Array(SIZE),
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
};
|
};
|
||||||
|
let corridorSurfaceNoise = null;
|
||||||
|
let routeProgressSerial = 0;
|
||||||
|
function getCorridorSurfaceNoise() {
|
||||||
|
if (corridorSurfaceNoise) return corridorSurfaceNoise;
|
||||||
|
corridorSurfaceNoise = new Float64Array(SIZE);
|
||||||
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
|
for (let x = 0; x < MAP_W; x++) corridorSurfaceNoise[indexOf(x, y)] = valueNoise(x, y, seed + 13941, 18);
|
||||||
|
if (y % 32 === 31 || y + 1 === MAP_H) {
|
||||||
|
generationProgress?.({
|
||||||
|
status: "route-heartbeat",
|
||||||
|
key: "route:surface-noise",
|
||||||
|
phase: "transport-routing",
|
||||||
|
workUnitId: "route-surface-noise",
|
||||||
|
label: `Preparing shared route surface field ${y + 1}/${MAP_H} rows`,
|
||||||
|
completed: y + 1,
|
||||||
|
total: MAP_H,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return corridorSurfaceNoise;
|
||||||
|
}
|
||||||
|
|
||||||
function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) {
|
function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) {
|
||||||
if (!start || !inside(start.x, start.y)) return [];
|
if (!start || !inside(start.x, start.y)) return [];
|
||||||
|
|
@ -761,7 +793,7 @@ if (isRegionalCapital) {
|
||||||
seen[startIndex] = epoch;
|
seen[startIndex] = epoch;
|
||||||
score[startIndex] = 0;
|
score[startIndex] = 0;
|
||||||
cameFrom[startIndex] = -1;
|
cameFrom[startIndex] = -1;
|
||||||
heap.push({ i: startIndex, f: 0 });
|
heap.push({ i: startIndex, f: 0, g: 0 });
|
||||||
const curvePenalty = options.curvePenalty ?? 0.12;
|
const curvePenalty = options.curvePenalty ?? 0.12;
|
||||||
const penaltyStrength = options.penaltyStrength ?? 1.0;
|
const penaltyStrength = options.penaltyStrength ?? 1.0;
|
||||||
const sameRegion = options.regionId ?? regionIdAt(start.x, start.y);
|
const sameRegion = options.regionId ?? regionIdAt(start.x, start.y);
|
||||||
|
|
@ -770,12 +802,34 @@ if (isRegionalCapital) {
|
||||||
const bounds = options.bounds || null;
|
const bounds = options.bounds || null;
|
||||||
const goalHint = options.goalHint || null;
|
const goalHint = options.goalHint || null;
|
||||||
const heuristicWeight = options.heuristicWeight ?? 0;
|
const heuristicWeight = options.heuristicWeight ?? 0;
|
||||||
|
const surfaceNoise = (options.surfaceGrain ?? 0) !== 0 ? getCorridorSurfaceNoise() : null;
|
||||||
|
// A display label such as "national corridor" is intentionally reused for
|
||||||
|
// many independent A* searches. Progress invariants must therefore key on
|
||||||
|
// one finite search invocation, never on that human-readable label. The
|
||||||
|
// ordinal is deterministic within a candidate and has no effect on output.
|
||||||
|
const routeWorkUnitId = String(options.progressWorkUnitId || `route-search-${++routeProgressSerial}`);
|
||||||
let goalIndex = -1;
|
let goalIndex = -1;
|
||||||
let expanded = 0;
|
let expanded = 0;
|
||||||
|
|
||||||
while (heap.length && expanded++ < maxExpanded) {
|
while (heap.length && expanded++ < maxExpanded) {
|
||||||
|
if (expanded % 2048 === 0) {
|
||||||
|
generationProgress?.({
|
||||||
|
status: "route-heartbeat",
|
||||||
|
key: `route:${options.progressLabel || "corridor"}`,
|
||||||
|
phase: "transport-routing",
|
||||||
|
workUnitId: routeWorkUnitId,
|
||||||
|
label: `Routing ${options.progressLabel || "transport corridor"}: ${expanded}/${maxExpanded} nodes`,
|
||||||
|
completed: expanded,
|
||||||
|
total: maxExpanded,
|
||||||
|
});
|
||||||
|
}
|
||||||
const current = heap.pop();
|
const current = heap.pop();
|
||||||
if (!current || closed[current.i] === epoch) continue;
|
if (!current || closed[current.i] === epoch) continue;
|
||||||
|
// A cell may be queued more than once when a cheaper route is found.
|
||||||
|
// Its older entry always has a larger f for the same heuristic, so
|
||||||
|
// dropping that stale entry preserves the selected route and avoids
|
||||||
|
// expanding thousands of superseded nodes.
|
||||||
|
if (current.g !== score[current.i]) continue;
|
||||||
closed[current.i] = epoch;
|
closed[current.i] = epoch;
|
||||||
const cx = current.i % MAP_W;
|
const cx = current.i % MAP_W;
|
||||||
const cy = Math.floor(current.i / MAP_W);
|
const cy = Math.floor(current.i / MAP_W);
|
||||||
|
|
@ -783,6 +837,12 @@ if (isRegionalCapital) {
|
||||||
goalIndex = current.i;
|
goalIndex = current.i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const prev = cameFrom[current.i];
|
||||||
|
const previousDx = prev >= 0 ? cx - (prev % MAP_W) : 0;
|
||||||
|
const previousDy = prev >= 0 ? cy - Math.floor(prev / MAP_W) : 0;
|
||||||
|
const currentScore = score[current.i];
|
||||||
|
const terrainFlowStrength = options.terrainFlowBias ?? 0;
|
||||||
|
const surfaceGrainStrength = options.surfaceGrain ?? 0;
|
||||||
for (let dy = -1; dy <= 1; dy++) {
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
for (let dx = -1; dx <= 1; dx++) {
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
if (!dx && !dy) continue;
|
if (!dx && !dy) continue;
|
||||||
|
|
@ -793,33 +853,22 @@ if (isRegionalCapital) {
|
||||||
const ni = indexOf(nx, ny);
|
const ni = indexOf(nx, ny);
|
||||||
if (closed[ni] === epoch || sea[ni] || costField[ni] >= INF) continue;
|
if (closed[ni] === epoch || sea[ni] || costField[ni] >= INF) continue;
|
||||||
if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue;
|
if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue;
|
||||||
const prev = cameFrom[current.i];
|
|
||||||
let turn = 0;
|
let turn = 0;
|
||||||
if (prev >= 0) {
|
if (prev >= 0) {
|
||||||
const px = prev % MAP_W;
|
turn = Math.abs(previousDx * dy - previousDy * dx) > 0 ? curvePenalty : 0;
|
||||||
const py = Math.floor(prev / MAP_W);
|
|
||||||
const ax = cx - px;
|
|
||||||
const ay = cy - py;
|
|
||||||
turn = Math.abs(ax * dy - ay * dx) > 0 ? curvePenalty : 0;
|
|
||||||
}
|
}
|
||||||
const existing = penaltyField?.[ni] || 0;
|
const existing = penaltyField?.[ni] || 0;
|
||||||
const antiConcentration = existing * penaltyStrength * (1 - corridorAllowance(ni) * 0.72);
|
const antiConcentration = existing * penaltyStrength * (1 - corridorAllowance(ni) * 0.72);
|
||||||
const terrainFlowBias = (options.terrainFlowBias ?? 0) * clamp(
|
const terrainFlowBias = terrainFlowStrength * corridorTerrainFlowField[ni];
|
||||||
valleyField[ni] * 0.54 +
|
const surfaceGrain = surfaceGrainStrength * (surfaceNoise?.[ni] || 0);
|
||||||
coastalLowland[ni] * 0.28 +
|
const stepDistance = dx && dy ? Math.SQRT2 : 1;
|
||||||
plain[ni] * 0.16 +
|
const nd = currentScore + Math.max(0.08, costField[ni] + antiConcentration + turn - terrainFlowBias + surfaceGrain) * stepDistance;
|
||||||
(passSuitability?.[ni] || 0) * 0.34 -
|
|
||||||
ridgeField[ni] * 0.24 -
|
|
||||||
slope[ni] * 0.22
|
|
||||||
);
|
|
||||||
const surfaceGrain = (options.surfaceGrain ?? 0) * valueNoise(nx, ny, seed + 13941, 18);
|
|
||||||
const nd = score[current.i] + Math.max(0.08, costField[ni] + antiConcentration + turn - terrainFlowBias + surfaceGrain) * Math.hypot(dx, dy);
|
|
||||||
if (seen[ni] !== epoch || nd < score[ni]) {
|
if (seen[ni] !== epoch || nd < score[ni]) {
|
||||||
seen[ni] = epoch;
|
seen[ni] = epoch;
|
||||||
score[ni] = nd;
|
score[ni] = nd;
|
||||||
cameFrom[ni] = current.i;
|
cameFrom[ni] = current.i;
|
||||||
const h = goalHint ? Math.hypot(nx - goalHint.x, ny - goalHint.y) * heuristicWeight : 0;
|
const h = goalHint ? Math.hypot(nx - goalHint.x, ny - goalHint.y) * heuristicWeight : 0;
|
||||||
heap.push({ i: ni, f: nd + h });
|
heap.push({ i: ni, f: nd + h, g: score[ni] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -834,6 +883,22 @@ if (isRegionalCapital) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCorridorInfluencePenalty(penaltyField, corridor, radius = 7, strength = 0.35) {
|
function addCorridorInfluencePenalty(penaltyField, corridor, radius = 7, strength = 0.35) {
|
||||||
|
const kernel = radius > 0 ? getRadialInfluenceKernel(radius, 1) : null;
|
||||||
|
if (kernel) {
|
||||||
|
for (const [x, y] of corridor || []) {
|
||||||
|
for (let k = 0; k < kernel.length; k++) {
|
||||||
|
const nx = x + kernel.dx[k];
|
||||||
|
const ny = y + kernel.dy[k];
|
||||||
|
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
|
||||||
|
const i = ny * MAP_W + nx;
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const openPlain = clamp(plain[i] * 0.54 + agriculture[i] * 0.34 - settlementDemand[i] * 0.24 - valleyField[i] * 0.22 - coastalLowland[i] * 0.18);
|
||||||
|
const allowParallel = corridorAllowance(i);
|
||||||
|
penaltyField[i] = Math.max(penaltyField[i], strength * kernel.weight[k] * (0.48 + openPlain * 1.15 - allowParallel * 0.42));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const [x, y] of corridor || []) {
|
for (const [x, y] of corridor || []) {
|
||||||
for (let dy = -radius; dy <= radius; dy++) {
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
for (let dx = -radius; dx <= radius; dx++) {
|
for (let dx = -radius; dx <= radius; dx++) {
|
||||||
|
|
@ -1606,6 +1671,7 @@ if (isRegionalCapital) {
|
||||||
bounds,
|
bounds,
|
||||||
goalHint: options.goalHint || target,
|
goalHint: options.goalHint || target,
|
||||||
heuristicWeight: options.heuristicWeight ?? (mode === "expressway" ? 0.66 : mode === "rail" ? 0.54 : mode === "national" ? 0.46 : 0.30),
|
heuristicWeight: options.heuristicWeight ?? (mode === "expressway" ? 0.66 : mode === "rail" ? 0.54 : mode === "national" ? 0.46 : 0.30),
|
||||||
|
progressLabel: `${mode} corridor`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2466,6 +2532,152 @@ const premodernRoads = [];
|
||||||
transportDebugLayers.preAdminRoadFinalization = finalizePreAdminRoadTopology();
|
transportDebugLayers.preAdminRoadFinalization = finalizePreAdminRoadTopology();
|
||||||
markFeatureTiming("road-finalization");
|
markFeatureTiming("road-finalization");
|
||||||
|
|
||||||
|
function completeLargePatchHumanDensity() {
|
||||||
|
const polygon = Array.isArray(options?.patchHumanFocusPolygon) ? options.patchHumanFocusPolygon : null;
|
||||||
|
const referenceDensity = Number(options?.patchTargetSettlementDensityPer1000);
|
||||||
|
const debug = {
|
||||||
|
enabled: largePatchTile && !!polygon?.length && Number.isFinite(referenceDensity),
|
||||||
|
focusLandCells: 0,
|
||||||
|
existingSettlements: 0,
|
||||||
|
targetSettlements: 0,
|
||||||
|
addedVillages: 0,
|
||||||
|
finalSettlements: 0,
|
||||||
|
};
|
||||||
|
if (!debug.enabled || polygon.length < 3 || referenceDensity <= 0) return debug;
|
||||||
|
|
||||||
|
const pointInsideFocus = (x, y) => {
|
||||||
|
const px = x + 0.5;
|
||||||
|
const py = y + 0.5;
|
||||||
|
let insideFocus = false;
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const a = polygon[i];
|
||||||
|
const b = polygon[j];
|
||||||
|
if (!Number.isFinite(a?.x) || !Number.isFinite(a?.y) || !Number.isFinite(b?.x) || !Number.isFinite(b?.y)) continue;
|
||||||
|
const intersects = ((a.y > py) !== (b.y > py))
|
||||||
|
&& (px < (b.x - a.x) * (py - a.y) / (b.y - a.y) + a.x);
|
||||||
|
if (intersects) insideFocus = !insideFocus;
|
||||||
|
}
|
||||||
|
return insideFocus;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
|
for (let x = 0; x < MAP_W; x++) {
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (!sea[i] && pointInsideFocus(x, y)) debug.focusLandCells++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const settlementLayers = [villages, markets, modernCities, satelliteCities, newTowns, ports];
|
||||||
|
const occupied = [];
|
||||||
|
for (const layer of settlementLayers) {
|
||||||
|
for (const point of layer || []) {
|
||||||
|
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) continue;
|
||||||
|
occupied.push(point);
|
||||||
|
if (pointInsideFocus(point.x, point.y) && !sea[indexOf(Math.round(point.x), Math.round(point.y))]) {
|
||||||
|
debug.existingSettlements++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const terrainType = terrain?.terrainTemplate?.terrainType || terrain?.terrainDebug?.terrainType || "auto";
|
||||||
|
const densityFactor = terrainType === "oceanic_archipelago" ? 0.65
|
||||||
|
: terrainType === "setouchi_inland_sea" ? 0.85
|
||||||
|
: 1;
|
||||||
|
// Only previously ungenerated cells are part of an Expansion candidate's
|
||||||
|
// human-quality denominator. The raw helper does not carry the full world,
|
||||||
|
// so the coordinator supplies this immutable pre-operation fraction. A
|
||||||
|
// small margin absorbs coastline/ownership changes during final merge.
|
||||||
|
const expansionFraction = clamp(Number.isFinite(options?.patchHumanExpansionFraction)
|
||||||
|
? options.patchHumanExpansionFraction : 1, 0, 1);
|
||||||
|
const finalizationMargin = 1.08;
|
||||||
|
const effectiveFocusLandCells = debug.focusLandCells * expansionFraction;
|
||||||
|
debug.expansionFraction = expansionFraction;
|
||||||
|
debug.effectiveFocusLandCells = effectiveFocusLandCells;
|
||||||
|
// A canonical tile can still be present when only a very small sliver is
|
||||||
|
// newly generated. Apply the same small-area exception to the effective
|
||||||
|
// ungenerated area, not the complete raw focus frame, or a near-zero
|
||||||
|
// Expansion fraction would manufacture the two-settlement floor.
|
||||||
|
debug.targetSettlements = effectiveFocusLandCells < 180
|
||||||
|
? 0
|
||||||
|
: Math.max(2, Math.floor(effectiveFocusLandCells * referenceDensity * densityFactor * finalizationMargin / 1000));
|
||||||
|
let deficit = Math.max(0, debug.targetSettlements - debug.existingSettlements);
|
||||||
|
if (!deficit) {
|
||||||
|
debug.finalSettlements = debug.existingSettlements;
|
||||||
|
return debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [];
|
||||||
|
for (let y = 1; y < MAP_H - 1; y++) {
|
||||||
|
for (let x = 1; x < MAP_W - 1; x++) {
|
||||||
|
if (!pointInsideFocus(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i] || slope[i] > 0.46 || ridgeField[i] > 0.72) continue;
|
||||||
|
const score = Math.max(villageScore[i] || 0, openPlainVillageScore[i] || 0)
|
||||||
|
+ (ruralSuitability[i] || 0) * 0.16
|
||||||
|
+ (plain[i] || 0) * 0.08
|
||||||
|
+ (agriculture[i] || 0) * 0.08
|
||||||
|
- (river[i] || 0) * 0.05;
|
||||||
|
if (score < 0.16) continue;
|
||||||
|
candidates.push({ x, y, i, score });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => b.score - a.score || a.y - b.y || a.x - b.x);
|
||||||
|
|
||||||
|
const tooClose = (x, y, minDistance) => {
|
||||||
|
const d2 = minDistance * minDistance;
|
||||||
|
for (const point of occupied) {
|
||||||
|
const dx = Number(point.x) - x;
|
||||||
|
const dy = Number(point.y) - y;
|
||||||
|
if (dx * dx + dy * dy < d2) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const addCandidate = (candidate) => {
|
||||||
|
const population = Math.round((900
|
||||||
|
+ Math.pow(rand(seed, 28900 + candidate.x * 31 + candidate.y * 17), 1.22) * 7600
|
||||||
|
+ (ruralSuitability[candidate.i] || 0) * 3600
|
||||||
|
+ (agriculture[candidate.i] || 0) * 2800) / 100) * 100;
|
||||||
|
const point = {
|
||||||
|
x: candidate.x,
|
||||||
|
y: candidate.y,
|
||||||
|
regionId: regionIdAt(candidate.x, candidate.y),
|
||||||
|
kind: (plain[candidate.i] || 0) > 0.30 ? "Plain Village" : "Village",
|
||||||
|
population,
|
||||||
|
score: candidate.score,
|
||||||
|
patchHumanDensityInfill: true,
|
||||||
|
};
|
||||||
|
villages.push(point);
|
||||||
|
occupied.push(point);
|
||||||
|
debug.addedVillages++;
|
||||||
|
deficit--;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preserve the initial generator's ordinary village spacing where possible;
|
||||||
|
// a tighter second pass is only a bounded fallback for thin/oblique lasso
|
||||||
|
// fragments whose usable land cannot satisfy the same density otherwise.
|
||||||
|
for (const minDistance of [5.75, 4.5]) {
|
||||||
|
if (deficit <= 0) break;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (deficit <= 0) break;
|
||||||
|
if (tooClose(candidate.x, candidate.y, minDistance)) continue;
|
||||||
|
addCandidate(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.finalSettlements = debug.existingSettlements + debug.addedVillages;
|
||||||
|
if (debug.addedVillages > 0) {
|
||||||
|
// The expensive transport skeleton is intentionally already complete.
|
||||||
|
// Recompute the rural influence consumed by the raster land-use stage so
|
||||||
|
// these full-production villages participate in population/land-use and
|
||||||
|
// later administration/naming rather than existing as display-only labels.
|
||||||
|
villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
|
||||||
|
}
|
||||||
|
return debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
transportDebugLayers.patchHumanDensityInfill = completeLargePatchHumanDensity();
|
||||||
|
markFeatureTiming("patch-human-density-infill");
|
||||||
|
|
||||||
const finalRoadInfluencePaths = [...nationalRoads, ...ringRoads, ...externalRoads];
|
const finalRoadInfluencePaths = [...nationalRoads, ...ringRoads, ...externalRoads];
|
||||||
roadLanduseInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 2.25, "road:landuse:final");
|
roadLanduseInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 2.25, "road:landuse:final");
|
||||||
roadInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 5.0, "road:influence:final");
|
roadInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 5.0, "road:influence:final");
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,7 @@ export function finishMapOutput({
|
||||||
height: MAP_H,
|
height: MAP_H,
|
||||||
seed,
|
seed,
|
||||||
});
|
});
|
||||||
|
outputProgress("municipality coherence");
|
||||||
if (adminDebug) adminDebug.municipalCoherence = municipalCoherence.debug;
|
if (adminDebug) adminDebug.municipalCoherence = municipalCoherence.debug;
|
||||||
const coherentMunicipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId || municipalityToPrefectureId;
|
const coherentMunicipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId || municipalityToPrefectureId;
|
||||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(municipalCoherence.adminCenters, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
const adminCenters = attachIdsAndNames(tagInsidePrefecture(municipalCoherence.adminCenters, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||||
|
|
@ -608,6 +609,7 @@ export function finishMapOutput({
|
||||||
...satelliteCities,
|
...satelliteCities,
|
||||||
...newTowns,
|
...newTowns,
|
||||||
]);
|
]);
|
||||||
|
outputProgress("population packaging");
|
||||||
|
|
||||||
function addMunicipalCenterLocalAccess() {
|
function addMunicipalCenterLocalAccess() {
|
||||||
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] })) : { repairedSegments: [], unservedSettlements: [] };
|
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] })) : { repairedSegments: [], unservedSettlements: [] };
|
||||||
|
|
@ -682,6 +684,7 @@ export function finishMapOutput({
|
||||||
let goal = -1;
|
let goal = -1;
|
||||||
let expanded = 0;
|
let expanded = 0;
|
||||||
while (heap.length && expanded++ < maxExpanded) {
|
while (heap.length && expanded++ < maxExpanded) {
|
||||||
|
if ((expanded & 2047) === 0) outputProgress(`municipal road search ${expanded}/${maxExpanded}`);
|
||||||
const current = heap.pop();
|
const current = heap.pop();
|
||||||
if (!current || closed[current.i]) continue;
|
if (!current || closed[current.i]) continue;
|
||||||
closed[current.i] = 1;
|
closed[current.i] = 1;
|
||||||
|
|
@ -739,7 +742,9 @@ export function finishMapOutput({
|
||||||
}) ? path : [];
|
}) ? path : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const center of candidates) {
|
for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
|
||||||
|
const center = candidates[candidateIndex];
|
||||||
|
if ((candidateIndex & 3) === 0) outputProgress(`municipal road ${candidateIndex + 1}/${candidates.length}`);
|
||||||
const path = routeAccess(center);
|
const path = routeAccess(center);
|
||||||
debugLayers.unservedSettlements.push({ x: center.x, y: center.y, kind: "Municipal Center", mode: "municipal-access", repaired: path.length >= 4 });
|
debugLayers.unservedSettlements.push({ x: center.x, y: center.y, kind: "Municipal Center", mode: "municipal-access", repaired: path.length >= 4 });
|
||||||
if (path.length < 4) continue;
|
if (path.length < 4) continue;
|
||||||
|
|
@ -859,6 +864,7 @@ export function finishMapOutput({
|
||||||
if (!current) break;
|
if (!current) break;
|
||||||
const cur = current.i;
|
const cur = current.i;
|
||||||
expanded++;
|
expanded++;
|
||||||
|
if ((expanded & 2047) === 0) outputProgress(`road component search ${expanded}/${maxExpanded}`);
|
||||||
if (mainMask[cur] && dist[cur] > 2) { goal = cur; break; }
|
if (mainMask[cur] && dist[cur] > 2) { goal = cur; break; }
|
||||||
const [x, y] = xyOf(cur);
|
const [x, y] = xyOf(cur);
|
||||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
|
@ -914,7 +920,10 @@ export function finishMapOutput({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const centroid = { x: sx / Math.max(1, sn), y: sy / Math.max(1, sn) };
|
const centroid = { x: sx / Math.max(1, sn), y: sy / Math.max(1, sn) };
|
||||||
for (const comp of comps.slice(1, 24)) {
|
const connectableComponents = comps.slice(1, 24);
|
||||||
|
for (let componentIndex = 0; componentIndex < connectableComponents.length; componentIndex++) {
|
||||||
|
const comp = connectableComponents[componentIndex];
|
||||||
|
if ((componentIndex & 3) === 0) outputProgress(`road component ${componentIndex + 1}/${connectableComponents.length}`);
|
||||||
if (!componentNearAdminCenter(comp)) continue;
|
if (!componentNearAdminCenter(comp)) continue;
|
||||||
const land = majorityLandId(comp.cells, landIds);
|
const land = majorityLandId(comp.cells, landIds);
|
||||||
if (land !== mainLand) { result.skippedIsland++; continue; }
|
if (land !== mainLand) { result.skippedIsland++; continue; }
|
||||||
|
|
@ -1080,9 +1089,13 @@ export function finishMapOutput({
|
||||||
// Build required municipal access before pruning so the prune pass can
|
// Build required municipal access before pruning so the prune pass can
|
||||||
// preserve those paths directly instead of deleting and re-adding them.
|
// preserve those paths directly instead of deleting and re-adding them.
|
||||||
addMunicipalCenterLocalAccess();
|
addMunicipalCenterLocalAccess();
|
||||||
|
outputProgress("municipal road access");
|
||||||
const requiredStubsAdded = ensureAdminCenterRoadStubs();
|
const requiredStubsAdded = ensureAdminCenterRoadStubs();
|
||||||
|
outputProgress("required road stubs");
|
||||||
const endpointConnectorsAdded = connectNearbyRoadEndpoints();
|
const endpointConnectorsAdded = connectNearbyRoadEndpoints();
|
||||||
|
outputProgress("road endpoint connectors");
|
||||||
const prune = pruneIsolatedFinalRoadComponents();
|
const prune = pruneIsolatedFinalRoadComponents();
|
||||||
|
outputProgress("road component pruning");
|
||||||
const finalComponents = occupancyComponentsFromPathGroups(
|
const finalComponents = occupancyComponentsFromPathGroups(
|
||||||
[minorRoads, nationalRoads, externalRoads, ringRoads, expressways, externalExpressways],
|
[minorRoads, nationalRoads, externalRoads, ringRoads, expressways, externalExpressways],
|
||||||
{ maxDistanceSq: 5, accept: (_x, _y, i) => !sea[i] }
|
{ maxDistanceSq: 5, accept: (_x, _y, i) => !sea[i] }
|
||||||
|
|
@ -1147,15 +1160,18 @@ export function finishMapOutput({
|
||||||
return renamed;
|
return renamed;
|
||||||
}
|
}
|
||||||
renameInterchangesFromMunicipalities();
|
renameInterchangesFromMunicipalities();
|
||||||
|
outputProgress("interchange naming");
|
||||||
|
|
||||||
nameDebug.maxDerivedPerBase = 0;
|
nameDebug.maxDerivedPerBase = 0;
|
||||||
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
|
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
|
||||||
|
outputProgress("prefecture regions");
|
||||||
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
|
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
|
||||||
// Municipal vectors are derived only after final prefecture IDs exist. This
|
// Municipal vectors are derived only after final prefecture IDs exist. This
|
||||||
// prevents prefecture edges from also being emitted as municipal borders in
|
// prevents prefecture edges from also being emitted as municipal borders in
|
||||||
// the initial map. The stage-level vectors remain useful during generation,
|
// the initial map. The stage-level vectors remain useful during generation,
|
||||||
// but are not authoritative output data.
|
// but are not authoritative output data.
|
||||||
const adminBorders = extractAdminBorderSegments(adminId, humanRegionMask, prefectureRegionId, sea);
|
const adminBorders = extractAdminBorderSegments(adminId, humanRegionMask, prefectureRegionId, sea);
|
||||||
|
outputProgress("administrative borders");
|
||||||
if (adminDebug) {
|
if (adminDebug) {
|
||||||
adminDebug.stageMunicipalBorderCount = Array.isArray(stageAdminBorders) ? stageAdminBorders.length : 0;
|
adminDebug.stageMunicipalBorderCount = Array.isArray(stageAdminBorders) ? stageAdminBorders.length : 0;
|
||||||
adminDebug.finalMunicipalBorderCount = adminBorders.length;
|
adminDebug.finalMunicipalBorderCount = adminBorders.length;
|
||||||
|
|
|
||||||
3886
src/mapPatch.js
3886
src/mapPatch.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +1,79 @@
|
||||||
export function createPatchContext({ world, rects, candidateWindow, seed, patchAlpha, sourceIndexForWorld, worldIndex }) {
|
export function createPatchContext({
|
||||||
|
world,
|
||||||
|
rects,
|
||||||
|
candidateWindow,
|
||||||
|
seed,
|
||||||
|
patchAlpha,
|
||||||
|
patchContinuousBlendAlpha,
|
||||||
|
patchCellIsNew,
|
||||||
|
generatedFootprintAlpha = 0.72,
|
||||||
|
sourceIndexForWorld,
|
||||||
|
worldIndex,
|
||||||
|
}) {
|
||||||
const writeRect = rects?.writeRect;
|
const writeRect = rects?.writeRect;
|
||||||
const writeCells = [];
|
let cellColumns = null;
|
||||||
if (world && writeRect && typeof patchAlpha === "function" && typeof sourceIndexForWorld === "function" && typeof worldIndex === "function") {
|
if (world && writeRect && typeof patchAlpha === "function" && typeof sourceIndexForWorld === "function" && typeof worldIndex === "function") {
|
||||||
|
const capacity = Math.max(0, (writeRect.x1 - writeRect.x0) * (writeRect.y1 - writeRect.y0));
|
||||||
|
const xValues = new Int32Array(capacity);
|
||||||
|
const yValues = new Int32Array(capacity);
|
||||||
|
const worldIndices = new Int32Array(capacity);
|
||||||
|
const sourceIndices = new Int32Array(capacity);
|
||||||
|
const alphaValues = new Float32Array(capacity);
|
||||||
|
const blendAlphaValues = new Float32Array(capacity);
|
||||||
|
const newOwnedValues = new Uint8Array(capacity);
|
||||||
|
let count = 0;
|
||||||
|
let unmappedActiveCells = 0;
|
||||||
|
let unmappedMinX = Infinity, unmappedMinY = Infinity, unmappedMaxX = -Infinity, unmappedMaxY = -Infinity;
|
||||||
|
const unmappedSamples = [];
|
||||||
for (let y = writeRect.y0; y < writeRect.y1; y++) {
|
for (let y = writeRect.y0; y < writeRect.y1; y++) {
|
||||||
for (let x = writeRect.x0; x < writeRect.x1; x++) {
|
for (let x = writeRect.x0; x < writeRect.x1; x++) {
|
||||||
const wi = worldIndex(world, x, y);
|
const wi = worldIndex(world, x, y);
|
||||||
if (wi < 0) continue;
|
if (wi < 0) continue;
|
||||||
const si = sourceIndexForWorld(rects, candidateWindow, x, y);
|
|
||||||
if (si < 0) continue;
|
|
||||||
const alpha = patchAlpha(x, y, rects, seed);
|
const alpha = patchAlpha(x, y, rects, seed);
|
||||||
if (alpha <= 0.005) continue;
|
if (alpha <= 0.005) continue;
|
||||||
writeCells.push({ x, y, wi, si, alpha });
|
const si = sourceIndexForWorld(rects, candidateWindow, x, y);
|
||||||
|
if (si < 0) {
|
||||||
|
unmappedActiveCells++;
|
||||||
|
unmappedMinX = Math.min(unmappedMinX, x);
|
||||||
|
unmappedMinY = Math.min(unmappedMinY, y);
|
||||||
|
unmappedMaxX = Math.max(unmappedMaxX, x);
|
||||||
|
unmappedMaxY = Math.max(unmappedMaxY, y);
|
||||||
|
if (unmappedSamples.length < 8) unmappedSamples.push({ x, y, alpha });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
xValues[count] = x;
|
||||||
|
yValues[count] = y;
|
||||||
|
worldIndices[count] = wi;
|
||||||
|
sourceIndices[count] = si;
|
||||||
|
alphaValues[count] = alpha;
|
||||||
|
blendAlphaValues[count] = typeof patchContinuousBlendAlpha === "function"
|
||||||
|
? patchContinuousBlendAlpha(rects, x, y, seed)
|
||||||
|
: alpha;
|
||||||
|
newOwnedValues[count] = typeof patchCellIsNew === "function" && patchCellIsNew(rects, x, y) && alpha >= generatedFootprintAlpha ? 1 : 0;
|
||||||
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cellColumns = {
|
||||||
|
count,
|
||||||
|
xValues,
|
||||||
|
yValues,
|
||||||
|
worldIndices,
|
||||||
|
sourceIndices,
|
||||||
|
alphaValues,
|
||||||
|
blendAlphaValues,
|
||||||
|
newOwnedValues,
|
||||||
|
unmappedActiveCells,
|
||||||
|
unmappedActiveBounds: unmappedActiveCells ? {
|
||||||
|
x0: unmappedMinX, y0: unmappedMinY, x1: unmappedMaxX + 1, y1: unmappedMaxY + 1,
|
||||||
|
} : null,
|
||||||
|
unmappedSamples,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
world,
|
world,
|
||||||
rects,
|
rects,
|
||||||
candidateWindow,
|
candidateWindow,
|
||||||
seed,
|
seed,
|
||||||
writeCells,
|
cellColumns,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
import { CELL_SIZE, MAP_H, MAP_W, indexOf, nowMs } from "./mapUtils.js";
|
import { CELL_SIZE, MAP_H, MAP_W, indexOf, nowMs } from "./mapUtils.js";
|
||||||
import { finalizeRectTerrainForFixedMap, generateTerrainAndRivers, generateTerrainRect } from "./mapTerrain.js";
|
import { generateTerrainAndRivers } from "./mapTerrain.js";
|
||||||
import { generateMapFeatures } from "./mapFeatures.js";
|
import { generateMapFeatures } from "./mapFeatures.js";
|
||||||
import { finishMapOutput } from "./mapOutput.js";
|
import { finishMapOutput } from "./mapOutput.js";
|
||||||
import { generateAdminLayout } from "./mapAdminStage.js";
|
import { generateAdminLayout } from "./mapAdminStage.js";
|
||||||
|
|
@ -91,60 +91,12 @@ function makeRuntimeOptions(options, baseSeed) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function localizeWorldNativePath(path, originX, originY) {
|
|
||||||
if (!Array.isArray(path)) return path;
|
|
||||||
const out = path.map((tuple) => Array.isArray(tuple) && tuple.length >= 2
|
|
||||||
? [tuple[0] - originX, tuple[1] - originY, ...tuple.slice(2)]
|
|
||||||
: tuple);
|
|
||||||
for (const key of Object.keys(path)) {
|
|
||||||
if (!/^\d+$/.test(key)) out[key] = path[key];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function localizeWorldNativeTerrainPaths(terrain, originX, originY) {
|
|
||||||
const out = { ...terrain };
|
|
||||||
for (const key of ["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]) {
|
|
||||||
if (!Array.isArray(terrain?.[key])) continue;
|
|
||||||
out[key] = terrain[key].map((path) => localizeWorldNativePath(path, originX, originY));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateStableWorldTerrain(options = {}) {
|
|
||||||
const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : 0);
|
|
||||||
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : 0);
|
|
||||||
// Keep terrain seed independent of candidate origin/window. Variant remains an
|
|
||||||
// intentional alternate-world input, while selection geometry does not alter
|
|
||||||
// terrain at a fixed absolute coordinate.
|
|
||||||
const terrainSeed = Number.isFinite(options.stableTerrainSeed)
|
|
||||||
? options.stableTerrainSeed >>> 0
|
|
||||||
: Number.isFinite(options.baseSeed) ? options.baseSeed >>> 0 : 0;
|
|
||||||
const terrain = generateTerrainRect({
|
|
||||||
...options,
|
|
||||||
seed: terrainSeed,
|
|
||||||
variant: options.variant || 0,
|
|
||||||
originX,
|
|
||||||
originY,
|
|
||||||
width: MAP_W,
|
|
||||||
height: MAP_H,
|
|
||||||
seaLevel: Number.isFinite(options.worldSeaLevel) ? options.worldSeaLevel : undefined,
|
|
||||||
name: "world-native-patch-terrain",
|
|
||||||
});
|
|
||||||
const finalized = finalizeRectTerrainForFixedMap(terrainSeed, terrain, options);
|
|
||||||
return localizeWorldNativeTerrainPaths(finalized, originX, originY);
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateInitialTerrain(seed, options = {}) {
|
function generateInitialTerrain(seed, options = {}) {
|
||||||
// Expansion may supply a quality-selected production terrain override
|
// Expansion may supply a quality-selected production terrain override
|
||||||
// candidate selector. It is fed through exactly the same geography,
|
// candidate selector. It is fed through exactly the same geography,
|
||||||
// settlement, administration, transport, and output stages as initial
|
// settlement, administration, transport, and output stages as initial
|
||||||
// generation; only the terrain search is performed ahead of time.
|
// generation; only the terrain search is performed ahead of time.
|
||||||
if (options.terrainOverride) return options.terrainOverride;
|
if (options.terrainOverride) return options.terrainOverride;
|
||||||
// Regeneration retains the legacy production terrain. Stable rect terrain is
|
|
||||||
// still available for deterministic diagnostics and compatibility, but normal
|
|
||||||
// expansion candidates now select from production terrain candidates.
|
|
||||||
if (options.stableWorldTerrain === true) return generateStableWorldTerrain(options);
|
|
||||||
return generateTerrainAndRivers(seed, options);
|
return generateTerrainAndRivers(seed, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,52 @@ import { createRectContext, createRectTerrainFields, rectIndexOf, rectInside, re
|
||||||
const ASPECT = MAP_W / MAP_H;
|
const ASPECT = MAP_W / MAP_H;
|
||||||
const SQRT2 = Math.SQRT2;
|
const SQRT2 = Math.SQRT2;
|
||||||
|
|
||||||
|
export function createExactNoiseMemo() {
|
||||||
|
const latticeBySeed = new Map();
|
||||||
|
const lattice = (x, y, seed) => {
|
||||||
|
let cache = latticeBySeed.get(seed);
|
||||||
|
if (!cache) {
|
||||||
|
cache = new Map();
|
||||||
|
latticeBySeed.set(seed, cache);
|
||||||
|
}
|
||||||
|
// Terrain noise lattice coordinates stay far inside this stride. Using one
|
||||||
|
// numeric key avoids allocating a string for every hot-loop lookup.
|
||||||
|
const key = x * 131072 + y;
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const value = hash2(x, y, seed);
|
||||||
|
cache.set(key, value);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const memoValueNoise = (x, y, seed, scale) => {
|
||||||
|
const sx = x / scale;
|
||||||
|
const sy = y / scale;
|
||||||
|
const x0 = Math.floor(sx);
|
||||||
|
const y0 = Math.floor(sy);
|
||||||
|
const tx = smoothstep(sx - x0);
|
||||||
|
const ty = smoothstep(sy - y0);
|
||||||
|
const a = lattice(x0, y0, seed);
|
||||||
|
const b = lattice(x0 + 1, y0, seed);
|
||||||
|
const c = lattice(x0, y0 + 1, seed);
|
||||||
|
const d = lattice(x0 + 1, y0 + 1, seed);
|
||||||
|
return lerp(lerp(a, b, tx), lerp(c, d, tx), ty);
|
||||||
|
};
|
||||||
|
const memoFbm = (x, y, seed) => {
|
||||||
|
let amp = 1;
|
||||||
|
let scale = 54;
|
||||||
|
let sum = 0;
|
||||||
|
let norm = 0;
|
||||||
|
for (let octave = 0; octave < 5; octave++) {
|
||||||
|
sum += memoValueNoise(x, y, seed + octave * 101, scale) * amp;
|
||||||
|
norm += amp;
|
||||||
|
amp *= 0.5;
|
||||||
|
scale *= 0.5;
|
||||||
|
}
|
||||||
|
return sum / norm;
|
||||||
|
};
|
||||||
|
return { valueNoise: memoValueNoise, fbm: memoFbm, latticeBySeed };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCoord(x, y) {
|
function normalizeCoord(x, y) {
|
||||||
return {
|
return {
|
||||||
px: (x + 0.5) / MAP_W,
|
px: (x + 0.5) / MAP_W,
|
||||||
|
|
@ -95,50 +141,82 @@ function largestComponent(mask, allowEdgePreference = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function distanceField(sourceMask, maxDistance = 999) {
|
function distanceField(sourceMask, maxDistance = 999) {
|
||||||
const dist = new Float32Array(SIZE);
|
// With no obstacles, the former Dijkstra computes the exact 8-neighbour
|
||||||
dist.fill(maxDistance);
|
// octile distance to the nearest source. Two causal raster passes compute
|
||||||
const heap = new MinHeap();
|
// the same metric in O(cells) instead of O(cells log cells). Keep a Float64
|
||||||
for (let i = 0; i < SIZE; i++) {
|
// work buffer so the final Float32 values are bit-identical to the heap path
|
||||||
if (!sourceMask[i]) continue;
|
// (verified against randomized masks and production candidates).
|
||||||
dist[i] = 0;
|
const work = new Float64Array(SIZE);
|
||||||
heap.push({ i, f: 0 });
|
work.fill(maxDistance);
|
||||||
}
|
for (let i = 0; i < SIZE; i++) if (sourceMask[i]) work[i] = 0;
|
||||||
while (heap.length) {
|
|
||||||
const cur = heap.pop();
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
const row = y * MAP_W;
|
||||||
const x = cur.i % MAP_W;
|
for (let x = 0; x < MAP_W; x++) {
|
||||||
const y = Math.floor(cur.i / MAP_W);
|
const i = row + x;
|
||||||
for (const [nx, ny] of neighbors8(x, y)) {
|
let value = work[i];
|
||||||
const ni = indexOf(nx, ny);
|
if (x > 0) value = Math.min(value, work[i - 1] + 1);
|
||||||
const step = (nx !== x && ny !== y) ? SQRT2 : 1;
|
if (y > 0) {
|
||||||
const nd = cur.f + step;
|
value = Math.min(value, work[i - MAP_W] + 1);
|
||||||
if (nd >= dist[ni] || nd > maxDistance) continue;
|
if (x > 0) value = Math.min(value, work[i - MAP_W - 1] + SQRT2);
|
||||||
dist[ni] = nd;
|
if (x + 1 < MAP_W) value = Math.min(value, work[i - MAP_W + 1] + SQRT2);
|
||||||
heap.push({ i: ni, f: nd });
|
}
|
||||||
|
work[i] = value > maxDistance ? maxDistance : value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let y = MAP_H - 1; y >= 0; y--) {
|
||||||
|
const row = y * MAP_W;
|
||||||
|
for (let x = MAP_W - 1; x >= 0; x--) {
|
||||||
|
const i = row + x;
|
||||||
|
let value = work[i];
|
||||||
|
if (x + 1 < MAP_W) value = Math.min(value, work[i + 1] + 1);
|
||||||
|
if (y + 1 < MAP_H) {
|
||||||
|
value = Math.min(value, work[i + MAP_W] + 1);
|
||||||
|
if (x + 1 < MAP_W) value = Math.min(value, work[i + MAP_W + 1] + SQRT2);
|
||||||
|
if (x > 0) value = Math.min(value, work[i + MAP_W - 1] + SQRT2);
|
||||||
|
}
|
||||||
|
work[i] = value > maxDistance ? maxDistance : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist = new Float32Array(SIZE);
|
||||||
|
for (let i = 0; i < SIZE; i++) dist[i] = work[i];
|
||||||
return dist;
|
return dist;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ridgeContribution(px, py, ridge, seed) {
|
function ridgeContribution(px, py, ridge, seed, noise = null) {
|
||||||
const dx = (px - ridge.x) * ASPECT;
|
const dx = (px - ridge.x) * ASPECT;
|
||||||
const dy = py - ridge.y;
|
const dy = py - ridge.y;
|
||||||
const { u, v } = rotate(dx, dy, ridge.angle);
|
const c = ridge._angleCos ?? Math.cos(ridge.angle);
|
||||||
|
const s = ridge._angleSin ?? Math.sin(ridge.angle);
|
||||||
|
const u = dx * c + dy * s;
|
||||||
|
const v = -dx * s + dy * c;
|
||||||
const half = ridge.length * 0.5;
|
const half = ridge.length * 0.5;
|
||||||
const along = Math.abs(u / Math.max(0.001, half));
|
const along = Math.abs(u / Math.max(0.001, half));
|
||||||
if (along >= 1.22) return 0;
|
if (along >= 1.22) return 0;
|
||||||
const taper = smoothstep(1 - clamp((along - 0.68) / 0.54));
|
const taper = smoothstep(1 - clamp((along - 0.68) / 0.54));
|
||||||
const wobble = (valueNoise((u + ridge.phase) * 720, (v + ridge.phase * 0.37) * 980, seed + ridge.seedOffset, 14) - 0.5) * ridge.width * ridge.wobble;
|
// Before evaluating two noise fields, reject only ridges whose Gaussian is
|
||||||
|
// guaranteed to underflow to exact zero for every possible wobble value.
|
||||||
|
// This is an exact fast path: exp(-28^2) is zero in IEEE-754 binary64, so the
|
||||||
|
// original contribution would also be exactly zero.
|
||||||
|
const maxWobbleDistance = ridge.width * ridge.wobble * 0.5;
|
||||||
|
if (Math.abs(v) - maxWobbleDistance > ridge.width * 28) return 0;
|
||||||
|
const valueNoiseFn = noise?.valueNoise || valueNoise;
|
||||||
|
const wobble = (valueNoiseFn((u + ridge.phase) * 720, (v + ridge.phase * 0.37) * 980, seed + ridge.seedOffset, 14) - 0.5) * ridge.width * ridge.wobble;
|
||||||
const cross = Math.abs(v + wobble);
|
const cross = Math.abs(v + wobble);
|
||||||
const core = Math.exp(-Math.pow(cross / Math.max(0.0008, ridge.width), 2.0));
|
const core = Math.exp(-Math.pow(cross / Math.max(0.0008, ridge.width), 2.0));
|
||||||
const serration = 0.82 + 0.36 * valueNoise((u + ridge.phase) * 900, (v - ridge.phase * 0.41) * 1200, seed + ridge.seedOffset + 71, 7.5);
|
const serration = 0.82 + 0.36 * valueNoiseFn((u + ridge.phase) * 900, (v - ridge.phase * 0.41) * 1200, seed + ridge.seedOffset + 71, 7.5);
|
||||||
return ridge.height * core * taper * serration;
|
return ridge.height * core * taper * serration;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ellipticalMask(px, py, system) {
|
function ellipticalMask(px, py, system) {
|
||||||
const dx = (px - system.x) * ASPECT;
|
const dx = (px - system.x) * ASPECT;
|
||||||
const dy = py - system.y;
|
const dy = py - system.y;
|
||||||
const { u, v } = rotate(dx, dy, system.angle);
|
const c = system._angleCos ?? Math.cos(system.angle);
|
||||||
|
const s = system._angleSin ?? Math.sin(system.angle);
|
||||||
|
const u = dx * c + dy * s;
|
||||||
|
const v = -dx * s + dy * c;
|
||||||
const a = Math.max(0.01, system.length * 0.5);
|
const a = Math.max(0.01, system.length * 0.5);
|
||||||
const along = clamp((u / a + 1) * 0.5);
|
const along = clamp((u / a + 1) * 0.5);
|
||||||
const widthWave = 1
|
const widthWave = 1
|
||||||
|
|
@ -602,13 +680,103 @@ function buildScratchRidges(system, seed, systemId) {
|
||||||
return ridges;
|
return ridges;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCoastLower(px, py, template, seed) {
|
function buildRidgeBucketIndex(ridges, terrainFrameScale, terrainFrameOffsetX, terrainFrameOffsetY, bucketSize = 4) {
|
||||||
|
const columns = Math.ceil(MAP_W / bucketSize);
|
||||||
|
const rows = Math.ceil(MAP_H / bucketSize);
|
||||||
|
const buckets = Array.from({ length: columns * rows }, () => []);
|
||||||
|
const frameX = (x) => 0.5 + (((x + 0.5) / MAP_W) - 0.5) / terrainFrameScale + terrainFrameOffsetX;
|
||||||
|
const frameY = (y) => 0.5 + (((y + 0.5) / MAP_H) - 0.5) / terrainFrameScale + terrainFrameOffsetY;
|
||||||
|
for (const ridge of ridges) {
|
||||||
|
const c = ridge._angleCos;
|
||||||
|
const s = ridge._angleSin;
|
||||||
|
const limit = Math.max(0.001, ridge.length * 0.5) * 1.22;
|
||||||
|
// ridgeContribution returns exact zero before evaluating noise whenever
|
||||||
|
// the cross-axis distance exceeds this conservative bound. Apply the same
|
||||||
|
// exact predicate to a whole bucket; linear u/v extrema occur at corners.
|
||||||
|
const crossLimit = ridge.width * ridge.wobble * 0.5 + ridge.width * 28;
|
||||||
|
for (let by = 0; by < rows; by++) {
|
||||||
|
const y0 = by * bucketSize;
|
||||||
|
const y1 = Math.min(MAP_H - 1, y0 + bucketSize - 1);
|
||||||
|
const py0 = frameY(y0);
|
||||||
|
const py1 = frameY(y1);
|
||||||
|
for (let bx = 0; bx < columns; bx++) {
|
||||||
|
const x0 = bx * bucketSize;
|
||||||
|
const x1 = Math.min(MAP_W - 1, x0 + bucketSize - 1);
|
||||||
|
const px0 = frameX(x0);
|
||||||
|
const px1 = frameX(x1);
|
||||||
|
const u00 = (px0 - ridge.x) * ASPECT * c + (py0 - ridge.y) * s;
|
||||||
|
const u10 = (px1 - ridge.x) * ASPECT * c + (py0 - ridge.y) * s;
|
||||||
|
const u01 = (px0 - ridge.x) * ASPECT * c + (py1 - ridge.y) * s;
|
||||||
|
const u11 = (px1 - ridge.x) * ASPECT * c + (py1 - ridge.y) * s;
|
||||||
|
const v00 = -(px0 - ridge.x) * ASPECT * s + (py0 - ridge.y) * c;
|
||||||
|
const v10 = -(px1 - ridge.x) * ASPECT * s + (py0 - ridge.y) * c;
|
||||||
|
const v01 = -(px0 - ridge.x) * ASPECT * s + (py1 - ridge.y) * c;
|
||||||
|
const v11 = -(px1 - ridge.x) * ASPECT * s + (py1 - ridge.y) * c;
|
||||||
|
const minU = Math.min(u00, u10, u01, u11);
|
||||||
|
const maxU = Math.max(u00, u10, u01, u11);
|
||||||
|
const minV = Math.min(v00, v10, v01, v11);
|
||||||
|
const maxV = Math.max(v00, v10, v01, v11);
|
||||||
|
if (minU <= limit + 1e-12 && maxU >= -limit - 1e-12
|
||||||
|
&& minV <= crossLimit + 1e-12 && maxV >= -crossLimit - 1e-12) {
|
||||||
|
buckets[by * columns + bx].push(ridge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bucketSize, columns, buckets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMountainSystemBucketIndex(systems, terrainFrameScale, terrainFrameOffsetX, terrainFrameOffsetY, bucketSize = 4) {
|
||||||
|
const columns = Math.ceil(MAP_W / bucketSize);
|
||||||
|
const rows = Math.ceil(MAP_H / bucketSize);
|
||||||
|
const buckets = Array.from({ length: columns * rows }, () => []);
|
||||||
|
const frameX = (x) => 0.5 + (((x + 0.5) / MAP_W) - 0.5) / terrainFrameScale + terrainFrameOffsetX;
|
||||||
|
const frameY = (y) => 0.5 + (((y + 0.5) / MAP_H) - 0.5) / terrainFrameScale + terrainFrameOffsetY;
|
||||||
|
for (const system of systems) {
|
||||||
|
const c = system._angleCos;
|
||||||
|
const s = system._angleSin;
|
||||||
|
const uLimit = Math.max(0.01, system.length * 0.5) * 1.2;
|
||||||
|
const vLimit = Math.max(0.012, system.width * 0.5 * 1.6) * 1.2;
|
||||||
|
for (let by = 0; by < rows; by++) {
|
||||||
|
const y0 = by * bucketSize;
|
||||||
|
const y1 = Math.min(MAP_H - 1, y0 + bucketSize - 1);
|
||||||
|
const py0 = frameY(y0);
|
||||||
|
const py1 = frameY(y1);
|
||||||
|
for (let bx = 0; bx < columns; bx++) {
|
||||||
|
const x0 = bx * bucketSize;
|
||||||
|
const x1 = Math.min(MAP_W - 1, x0 + bucketSize - 1);
|
||||||
|
const px0 = frameX(x0);
|
||||||
|
const px1 = frameX(x1);
|
||||||
|
let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity;
|
||||||
|
for (const [px, py] of [[px0, py0], [px1, py0], [px0, py1], [px1, py1]]) {
|
||||||
|
const dx = (px - system.x) * ASPECT;
|
||||||
|
const dy = py - system.y;
|
||||||
|
const u = dx * c + dy * s;
|
||||||
|
const v = -dx * s + dy * c;
|
||||||
|
minU = Math.min(minU, u); maxU = Math.max(maxU, u);
|
||||||
|
minV = Math.min(minV, v); maxV = Math.max(maxV, v);
|
||||||
|
}
|
||||||
|
if (minU <= uLimit + 1e-12 && maxU >= -uLimit - 1e-12
|
||||||
|
&& minV <= vLimit + 1e-12 && maxV >= -vLimit - 1e-12) {
|
||||||
|
buckets[by * columns + bx].push(system);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bucketSize, columns, buckets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCoastLower(px, py, template, seed, noise = null) {
|
||||||
|
const valueNoiseFn = noise?.valueNoise || valueNoise;
|
||||||
|
const fbmFn = noise?.fbm || fbm;
|
||||||
const angle = template.coastAngle;
|
const angle = template.coastAngle;
|
||||||
const axis = (px - 0.5) * Math.cos(angle) * ASPECT + (py - 0.5) * Math.sin(angle);
|
const c = template._coastAngleCos ?? Math.cos(angle);
|
||||||
const cross = -(px - 0.5) * Math.sin(angle) * ASPECT + (py - 0.5) * Math.cos(angle);
|
const s = template._coastAngleSin ?? Math.sin(angle);
|
||||||
const wave = (fbm(px * 220, py * 220, seed + 300) - 0.5) * template.coastNoise;
|
const axis = (px - 0.5) * c * ASPECT + (py - 0.5) * s;
|
||||||
const bay = (valueNoise(px * 500, py * 500, seed + 301, 22) - 0.5) * 0.055;
|
const cross = -(px - 0.5) * s * ASPECT + (py - 0.5) * c;
|
||||||
const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032;
|
const wave = (fbmFn(px * 220, py * 220, seed + 300) - 0.5) * template.coastNoise;
|
||||||
|
const bay = (valueNoiseFn(px * 500, py * 500, seed + 301, 22) - 0.5) * 0.055;
|
||||||
|
const islandNoise = (fbmFn(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032;
|
||||||
let pressure = 0;
|
let pressure = 0;
|
||||||
|
|
||||||
if (template.coastStyle === "oceanic_archipelago") {
|
if (template.coastStyle === "oceanic_archipelago") {
|
||||||
|
|
@ -651,7 +819,7 @@ function computeCoastLower(px, py, template, seed) {
|
||||||
pressure = Math.max(sideA, sideB, outerBite);
|
pressure = Math.max(sideA, sideB, outerBite);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { pressure: clamp(pressure), signedAxis: axis };
|
return clamp(pressure);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recomputeSlope(elevation, sea, slope) {
|
function recomputeSlope(elevation, sea, slope) {
|
||||||
|
|
@ -1094,7 +1262,7 @@ function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo,
|
||||||
return { riverPaths: sortedPaths.slice(0, 80), mainRivers, tributaryRivers, smallStreams };
|
return { riverPaths: sortedPaths.slice(0, 80), mainRivers, tributaryRivers, smallStreams };
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveFields(seed, template, fields, seaLevel) {
|
function deriveFields(seed, template, fields, seaLevel, progress = null) {
|
||||||
const {
|
const {
|
||||||
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture,
|
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture,
|
||||||
ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField,
|
ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField,
|
||||||
|
|
@ -1104,14 +1272,18 @@ function deriveFields(seed, template, fields, seaLevel) {
|
||||||
} = fields;
|
} = fields;
|
||||||
|
|
||||||
recomputeSlope(elevation, sea, slope);
|
recomputeSlope(elevation, sea, slope);
|
||||||
|
progress?.("derived slope reconstructed");
|
||||||
const waterDist = distanceField(sea, 80);
|
const waterDist = distanceField(sea, 80);
|
||||||
|
progress?.("derived coast distance field complete");
|
||||||
const riverMask = new Uint8Array(SIZE);
|
const riverMask = new Uint8Array(SIZE);
|
||||||
for (let i = 0; i < SIZE; i++) if (river[i] > 0.18) riverMask[i] = 1;
|
for (let i = 0; i < SIZE; i++) if (river[i] > 0.18) riverMask[i] = 1;
|
||||||
const riverDist = distanceField(riverMask, 40);
|
const riverDist = distanceField(riverMask, 40);
|
||||||
|
progress?.("derived river distance field complete");
|
||||||
const landElevationValues = [];
|
const landElevationValues = [];
|
||||||
for (let i = 0; i < SIZE; i++) if (!sea[i]) landElevationValues.push(elevation[i]);
|
for (let i = 0; i < SIZE; i++) if (!sea[i]) landElevationValues.push(elevation[i]);
|
||||||
const lowlandQuantile = template.terrainType === "chubu_mountain" || template.terrainType === "tohoku_spine" ? 0.24 : 0.31;
|
const lowlandQuantile = template.terrainType === "chubu_mountain" || template.terrainType === "tohoku_spine" ? 0.24 : 0.31;
|
||||||
const lowlandElevationCeiling = quantile(landElevationValues, lowlandQuantile);
|
const lowlandElevationCeiling = quantile(landElevationValues, lowlandQuantile);
|
||||||
|
progress?.("derived lowland quantile complete");
|
||||||
|
|
||||||
for (let y = 1; y < MAP_H - 1; y++) {
|
for (let y = 1; y < MAP_H - 1; y++) {
|
||||||
for (let x = 1; x < MAP_W - 1; x++) {
|
for (let x = 1; x < MAP_W - 1; x++) {
|
||||||
|
|
@ -1154,6 +1326,7 @@ function deriveFields(seed, template, fields, seaLevel) {
|
||||||
passSuitability[i] = clamp(slope[i] * 0.30 + valleyField[i] * 0.34 + clamp((0.75 - ridgeField[i]) * 0.8) + plain[i] * 0.18);
|
passSuitability[i] = clamp(slope[i] * 0.30 + valleyField[i] * 0.34 + clamp((0.75 - ridgeField[i]) * 0.8) + plain[i] * 0.18);
|
||||||
moisture[i] = clamp(0.30 + (1 - waterDist[i] / 65) * 0.36 + valleyField[i] * 0.22 + riverNear * 0.26 - Math.max(0, e - 0.55) * 0.36 + (fbm(x * 1.6, y * 1.6, seed + 15000) - 0.5) * 0.18);
|
moisture[i] = clamp(0.30 + (1 - waterDist[i] / 65) * 0.36 + valleyField[i] * 0.22 + riverNear * 0.26 - Math.max(0, e - 0.55) * 0.36 + (fbm(x * 1.6, y * 1.6, seed + 15000) - 0.5) * 0.18);
|
||||||
}
|
}
|
||||||
|
if (y % 24 === 23 || y + 2 === MAP_H) progress?.(`derived fields ${y}/${MAP_H - 2} rows`);
|
||||||
}
|
}
|
||||||
for (let i = 0; i < SIZE; i++) {
|
for (let i = 0; i < SIZE; i++) {
|
||||||
if (!sea[i]) continue;
|
if (!sea[i]) continue;
|
||||||
|
|
@ -1500,12 +1673,33 @@ function buildRectNaturalRegions(ctx, fields, seed, template) {
|
||||||
seeds.push({
|
seeds.push({
|
||||||
wx,
|
wx,
|
||||||
wy,
|
wy,
|
||||||
|
gx,
|
||||||
|
gy,
|
||||||
id: 40000000 + rectStableId(seed, gx, gy, 0xb5c0fbcf) % 42000000,
|
id: 40000000 + rectStableId(seed, gx, gy, 0xb5c0fbcf) % 42000000,
|
||||||
coarseId: 30000000 + rectStableId(seed, Math.floor((gx * spacing) / coarseSpacing), Math.floor((gy * spacing) / coarseSpacing), 0xc2b2ae35) % 42000000,
|
coarseId: 30000000 + rectStableId(seed, Math.floor((gx * spacing) / coarseSpacing), Math.floor((gy * spacing) / coarseSpacing), 0xc2b2ae35) % 42000000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!seeds.length) return { naturalCompartmentCount: 0, regionCount: 0 };
|
if (!seeds.length) return { naturalCompartmentCount: 0, regionCount: 0 };
|
||||||
|
// Seeds come from a regular world grid with bounded jitter. Searching every
|
||||||
|
// seed for every cell made this stage O(cells x all world-window seeds), even
|
||||||
|
// though a seed outside the surrounding 5x5 grid cannot be geographically
|
||||||
|
// relevant. The bucket lookup keeps the same stable world anchoring while
|
||||||
|
// bounding the hot loop to nearby candidates.
|
||||||
|
const seedsByGrid = new Map();
|
||||||
|
for (const item of seeds) seedsByGrid.set(`${item.gx},${item.gy}`, item);
|
||||||
|
function nearbySeeds(wx, wy) {
|
||||||
|
const centerGX = Math.floor(wx / spacing);
|
||||||
|
const centerGY = Math.floor(wy / spacing);
|
||||||
|
const nearby = [];
|
||||||
|
for (let gy = centerGY - 2; gy <= centerGY + 2; gy++) {
|
||||||
|
for (let gx = centerGX - 2; gx <= centerGX + 2; gx++) {
|
||||||
|
const item = seedsByGrid.get(`${gx},${gy}`);
|
||||||
|
if (item) nearby.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearby.length ? nearby : seeds;
|
||||||
|
}
|
||||||
for (let y = 0; y < ctx.height; y++) {
|
for (let y = 0; y < ctx.height; y++) {
|
||||||
for (let x = 0; x < ctx.width; x++) {
|
for (let x = 0; x < ctx.width; x++) {
|
||||||
const i = rectIndexOf(ctx, x, y);
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
|
@ -1516,13 +1710,18 @@ function buildRectNaturalRegions(ctx, fields, seed, template) {
|
||||||
let bestScore = Infinity;
|
let bestScore = Infinity;
|
||||||
const barrier = (naturalBarrierScore[i] || 0) + (ridgeField[i] || 0) * 0.55 + (flowAccum[i] || 0) * 0.18;
|
const barrier = (naturalBarrierScore[i] || 0) + (ridgeField[i] || 0) * 0.55 + (flowAccum[i] || 0) * 0.18;
|
||||||
const basinBonus = (basinField[i] || 0) * 0.18 + (valleyField[i] || 0) * 0.10;
|
const basinBonus = (basinField[i] || 0) * 0.18 + (valleyField[i] || 0) * 0.10;
|
||||||
for (const s of seeds) {
|
const cellAdjustment = barrier * spacing * 0.42 - basinBonus * spacing * 0.32;
|
||||||
|
for (const s of nearbySeeds(wx, wy)) {
|
||||||
const dx = (wx - s.wx) * 1.05;
|
const dx = (wx - s.wx) * 1.05;
|
||||||
const dy = wy - s.wy;
|
const dy = wy - s.wy;
|
||||||
const d = Math.hypot(dx, dy);
|
const d = Math.hypot(dx, dy);
|
||||||
|
// tileNoise is bounded to +/- 0.17 * spacing and the watershed term is
|
||||||
|
// non-negative. Avoid the comparatively expensive smooth-noise lookup
|
||||||
|
// when even the candidate's theoretical lower bound cannot win.
|
||||||
|
if (d + cellAdjustment - spacing * 0.17 > bestScore) continue;
|
||||||
const tileNoise = (valueNoise(wx + s.wx * 0.13, wy + s.wy * 0.13, seed ^ 0xa54ff53a, 52) - 0.5) * spacing * 0.34;
|
const tileNoise = (valueNoise(wx + s.wx * 0.13, wy + s.wy * 0.13, seed ^ 0xa54ff53a, 52) - 0.5) * spacing * 0.34;
|
||||||
const watershedPenalty = watershedId?.[i] >= 0 ? ((watershedId[i] ^ s.id) & 7) * 0.16 : 0;
|
const watershedPenalty = watershedId?.[i] >= 0 ? ((watershedId[i] ^ s.id) & 7) * 0.16 : 0;
|
||||||
const score = d + barrier * spacing * 0.42 - basinBonus * spacing * 0.32 + tileNoise + watershedPenalty;
|
const score = d + cellAdjustment + tileNoise + watershedPenalty;
|
||||||
if (score < bestScore) { bestScore = score; best = s; }
|
if (score < bestScore) { bestScore = score; best = s; }
|
||||||
}
|
}
|
||||||
naturalCompartmentId[i] = best.id;
|
naturalCompartmentId[i] = best.id;
|
||||||
|
|
@ -1688,6 +1887,13 @@ export function generateTerrainRect(options = {}) {
|
||||||
visibleRavineField, surfaceTextureField,
|
visibleRavineField, surfaceTextureField,
|
||||||
} = fields;
|
} = fields;
|
||||||
|
|
||||||
|
const progress = (key, label) => options.onProgress?.({
|
||||||
|
status: "start",
|
||||||
|
key: `terrain-rect:${key}`,
|
||||||
|
label: `Rect terrain: ${label}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
progress("base", "base elevation and climate");
|
||||||
for (let y = 0; y < ctx.height; y++) {
|
for (let y = 0; y < ctx.height; y++) {
|
||||||
for (let x = 0; x < ctx.width; x++) {
|
for (let x = 0; x < ctx.width; x++) {
|
||||||
const i = rectIndexOf(ctx, x, y);
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
|
@ -1730,14 +1936,22 @@ export function generateTerrainRect(options = {}) {
|
||||||
0.13,
|
0.13,
|
||||||
terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.50
|
terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.50
|
||||||
);
|
);
|
||||||
|
progress("water", "water classification");
|
||||||
const oceanCells = classifyRectWater(ctx, fields, seaLevel);
|
const oceanCells = classifyRectWater(ctx, fields, seaLevel);
|
||||||
|
progress("slope", "slope reconstruction");
|
||||||
recomputeRectSlope(ctx, fields);
|
recomputeRectSlope(ctx, fields);
|
||||||
|
progress("drainage", "priority flood and drainage");
|
||||||
const filled = priorityFloodRect(ctx, fields);
|
const filled = priorityFloodRect(ctx, fields);
|
||||||
computeRectFlowAccumulation(ctx, fields, filled);
|
computeRectFlowAccumulation(ctx, fields, filled);
|
||||||
|
progress("watersheds", "watershed assignment");
|
||||||
const watershedDebug = buildRectWatershedId(ctx, fields, rectSeedValue ^ 0x51ed270b);
|
const watershedDebug = buildRectWatershedId(ctx, fields, rectSeedValue ^ 0x51ed270b);
|
||||||
|
progress("derived", "derived terrain fields");
|
||||||
deriveRectTerrainFields(ctx, fields, seaLevel);
|
deriveRectTerrainFields(ctx, fields, seaLevel);
|
||||||
|
progress("rivers", "river paths");
|
||||||
const riverNetwork = buildRectRiverPaths(ctx, fields, rectSeedValue ^ 0x1f123bb5, terrainTemplate);
|
const riverNetwork = buildRectRiverPaths(ctx, fields, rectSeedValue ^ 0x1f123bb5, terrainTemplate);
|
||||||
|
progress("regions", "natural regions");
|
||||||
const naturalDebug = buildRectNaturalRegions(ctx, fields, rectSeedValue ^ 0xb5c0fbcf, terrainTemplate);
|
const naturalDebug = buildRectNaturalRegions(ctx, fields, rectSeedValue ^ 0xb5c0fbcf, terrainTemplate);
|
||||||
|
options.onProgress?.({ status: "done", key: "terrain-rect", label: "Rect terrain ready" });
|
||||||
|
|
||||||
let landCount = 0;
|
let landCount = 0;
|
||||||
let mountainCount = 0;
|
let mountainCount = 0;
|
||||||
|
|
@ -1837,6 +2051,15 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
||||||
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : (Number.isFinite(generationContext.originY) ? generationContext.originY : 0));
|
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : (Number.isFinite(generationContext.originY) ? generationContext.originY : 0));
|
||||||
const variant = Math.max(0, Math.floor(Number.isFinite(options.variant) ? options.variant : (Number.isFinite(generationContext.variant) ? generationContext.variant : 0))) >>> 0;
|
const variant = Math.max(0, Math.floor(Number.isFinite(options.variant) ? options.variant : (Number.isFinite(generationContext.variant) ? generationContext.variant : 0))) >>> 0;
|
||||||
const worldNative = options.worldNative === true || generationContext.worldNative === true;
|
const worldNative = options.worldNative === true || generationContext.worldNative === true;
|
||||||
|
const terrainProgress = (key, label, completed, total) => options.onProgress?.({
|
||||||
|
status: "terrain-step",
|
||||||
|
key: `terrain:${key}`,
|
||||||
|
phase: "terrain",
|
||||||
|
workUnitId: "terrain-production",
|
||||||
|
label,
|
||||||
|
completed,
|
||||||
|
total,
|
||||||
|
});
|
||||||
const fields = createMapFields();
|
const fields = createMapFields();
|
||||||
fields.visibleRavineField = new Float32Array(SIZE);
|
fields.visibleRavineField = new Float32Array(SIZE);
|
||||||
fields.surfaceTextureField = new Float32Array(SIZE);
|
fields.surfaceTextureField = new Float32Array(SIZE);
|
||||||
|
|
@ -1852,36 +2075,69 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
||||||
const terrainTemplate = buildTerrainTemplate(seed, options);
|
const terrainTemplate = buildTerrainTemplate(seed, options);
|
||||||
const systems = buildMountainSystems(terrainTemplate, seed);
|
const systems = buildMountainSystems(terrainTemplate, seed);
|
||||||
const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id));
|
const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id));
|
||||||
|
// These angles are invariant for the entire candidate. The old hot loop
|
||||||
|
// recomputed sin/cos for every cell x every mountain/ridge, accounting for
|
||||||
|
// millions of identical transcendental calls per production candidate.
|
||||||
|
Object.defineProperties(terrainTemplate, {
|
||||||
|
_coastAngleCos: { value: Math.cos(terrainTemplate.coastAngle), enumerable: false },
|
||||||
|
_coastAngleSin: { value: Math.sin(terrainTemplate.coastAngle), enumerable: false },
|
||||||
|
_mountainAngleCos: { value: Math.cos(terrainTemplate.mountainAngle), enumerable: false },
|
||||||
|
_mountainAngleSin: { value: Math.sin(terrainTemplate.mountainAngle), enumerable: false },
|
||||||
|
});
|
||||||
|
for (const item of [...systems, ...allRidges]) {
|
||||||
|
Object.defineProperties(item, {
|
||||||
|
_angleCos: { value: Math.cos(item.angle), enumerable: false },
|
||||||
|
_angleSin: { value: Math.sin(item.angle), enumerable: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
const terrainFrameScale = clamp(Number.isFinite(options.terrainFrameScale) ? options.terrainFrameScale : 1, 1, 2.5);
|
const terrainFrameScale = clamp(Number.isFinite(options.terrainFrameScale) ? options.terrainFrameScale : 1, 1, 2.5);
|
||||||
const terrainFrameOffsetX = clamp(Number.isFinite(options.terrainFrameOffsetX) ? options.terrainFrameOffsetX : 0, -0.28, 0.28);
|
const requestedTerrainFrameOffsetX = clamp(Number.isFinite(options.terrainFrameOffsetX) ? options.terrainFrameOffsetX : 0, -0.28, 0.28);
|
||||||
const terrainFrameOffsetY = clamp(Number.isFinite(options.terrainFrameOffsetY) ? options.terrainFrameOffsetY : 0, -0.24, 0.24);
|
const requestedTerrainFrameOffsetY = clamp(Number.isFinite(options.terrainFrameOffsetY) ? options.terrainFrameOffsetY : 0, -0.24, 0.24);
|
||||||
|
// A world-native patch is a crop of one larger virtual terrain frame. The
|
||||||
|
// former implementation used local candidate x/y for the template frame,
|
||||||
|
// so every canonical tile restarted the coastline and mountain template at
|
||||||
|
// its own left/top edge. Adjacent full-production candidates therefore
|
||||||
|
// disagreed along straight implementation boundaries even though their noise
|
||||||
|
// fields used world coordinates. Shift the normalized frame by the candidate
|
||||||
|
// origin so the same world cell receives the same template coordinates in
|
||||||
|
// every overlapping candidate. Requested artistic offsets remain bounded;
|
||||||
|
// the world-native crop offset itself is intentionally not clamped.
|
||||||
|
const worldFrameOffsetX = worldNative ? originX / (MAP_W * terrainFrameScale) : 0;
|
||||||
|
const worldFrameOffsetY = worldNative ? originY / (MAP_H * terrainFrameScale) : 0;
|
||||||
|
const terrainFrameOffsetX = requestedTerrainFrameOffsetX + worldFrameOffsetX;
|
||||||
|
const terrainFrameOffsetY = requestedTerrainFrameOffsetY + worldFrameOffsetY;
|
||||||
|
const ridgeBucketIndex = buildRidgeBucketIndex(allRidges, terrainFrameScale, terrainFrameOffsetX, terrainFrameOffsetY);
|
||||||
|
const systemBucketIndex = buildMountainSystemBucketIndex(systems, terrainFrameScale, terrainFrameOffsetX, terrainFrameOffsetY);
|
||||||
|
const exactNoise = createExactNoiseMemo();
|
||||||
|
|
||||||
for (let y = 0; y < MAP_H; y++) {
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
|
const ridgeBucketRow = Math.floor(y / ridgeBucketIndex.bucketSize) * ridgeBucketIndex.columns;
|
||||||
|
const systemBucketRow = Math.floor(y / systemBucketIndex.bucketSize) * systemBucketIndex.columns;
|
||||||
for (let x = 0; x < MAP_W; x++) {
|
for (let x = 0; x < MAP_W; x++) {
|
||||||
const i = indexOf(x, y);
|
const i = indexOf(x, y);
|
||||||
const wx = originX + x;
|
const wx = originX + x;
|
||||||
const wy = originY + y;
|
const wy = originY + y;
|
||||||
const normalized = normalizeCoord(x, y);
|
|
||||||
// Expansion candidates are a crop from a larger virtual production map.
|
// Expansion candidates are a crop from a larger virtual production map.
|
||||||
// This removes the initial generator's deliberate edge-ocean frame from
|
// This removes the initial generator's deliberate edge-ocean frame from
|
||||||
// the user's lasso boundary while retaining the same terrain system.
|
// the user's lasso boundary while retaining the same terrain system.
|
||||||
const px = 0.5 + (normalized.px - 0.5) / terrainFrameScale + terrainFrameOffsetX;
|
const px = 0.5 + (((x + 0.5) / MAP_W) - 0.5) / terrainFrameScale + terrainFrameOffsetX;
|
||||||
const py = 0.5 + (normalized.py - 0.5) / terrainFrameScale + terrainFrameOffsetY;
|
const py = 0.5 + (((y + 0.5) / MAP_H) - 0.5) / terrainFrameScale + terrainFrameOffsetY;
|
||||||
const terrainLarge = (fbm(wx * 0.65, wy * 0.65, seed + 1) - 0.5) * 0.23;
|
const terrainLarge = (exactNoise.fbm(wx * 0.65, wy * 0.65, seed + 1) - 0.5) * 0.23;
|
||||||
const terrainRegional = (valueNoise(wx * 0.8, wy * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
const terrainRegional = (exactNoise.valueNoise(wx * 0.8, wy * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
||||||
const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed);
|
const coastPressure = computeCoastLower(px, py, terrainTemplate, seed, exactNoise);
|
||||||
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040);
|
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040);
|
||||||
let mountainMaskMax = 0;
|
let mountainMaskMax = 0;
|
||||||
for (let s = 0; s < systems.length; s++) {
|
const activeSystems = systemBucketIndex.buckets[systemBucketRow + Math.floor(x / systemBucketIndex.bucketSize)];
|
||||||
const system = systems[s];
|
for (const system of activeSystems) {
|
||||||
const mask = ellipticalMask(px, py, system);
|
const mask = ellipticalMask(px, py, system);
|
||||||
mountainMaskMax = Math.max(mountainMaskMax, mask);
|
mountainMaskMax = Math.max(mountainMaskMax, mask);
|
||||||
const broad = Math.pow(mask, lerp(1.70, 1.16, system.massifness)) * system.height * lerp(0.22, 0.34, system.massifness);
|
const broad = Math.pow(mask, lerp(1.70, 1.16, system.massifness)) * system.height * lerp(0.22, 0.34, system.massifness);
|
||||||
e += broad;
|
e += broad;
|
||||||
arcSpineField[i] = Math.max(arcSpineField[i], mask * (system.role === "minor" ? 0.42 : system.role === "primary" ? 0.86 : 0.72));
|
arcSpineField[i] = Math.max(arcSpineField[i], mask * (system.role === "minor" ? 0.42 : system.role === "primary" ? 0.86 : 0.72));
|
||||||
}
|
}
|
||||||
for (const ridge of allRidges) {
|
const activeRidges = ridgeBucketIndex.buckets[ridgeBucketRow + Math.floor(x / ridgeBucketIndex.bucketSize)];
|
||||||
const r = ridgeContribution(px, py, ridge, seed);
|
for (const ridge of activeRidges) {
|
||||||
|
const r = ridgeContribution(px, py, ridge, seed, exactNoise);
|
||||||
if (r <= 0) continue;
|
if (r <= 0) continue;
|
||||||
e += r;
|
e += r;
|
||||||
branchRidgeField[i] = clamp(branchRidgeField[i] + r * 5.0);
|
branchRidgeField[i] = clamp(branchRidgeField[i] + r * 5.0);
|
||||||
|
|
@ -1892,19 +2148,20 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
||||||
if (terrainTemplate.terrainType === "tohoku_spine") {
|
if (terrainTemplate.terrainType === "tohoku_spine") {
|
||||||
const dx = (px - 0.5) * ASPECT;
|
const dx = (px - 0.5) * ASPECT;
|
||||||
const dy = py - 0.5;
|
const dy = py - 0.5;
|
||||||
const { u, v } = rotate(dx, dy, terrainTemplate.mountainAngle);
|
const u = dx * terrainTemplate._mountainAngleCos + dy * terrainTemplate._mountainAngleSin;
|
||||||
const warp = (valueNoise(wx * 0.50, wy * 0.50, seed + 504, 28) - 0.5) * 18;
|
const v = -dx * terrainTemplate._mountainAngleSin + dy * terrainTemplate._mountainAngleCos;
|
||||||
macro = (fbm(u * 520 + warp, v * 1850 - warp * 0.35, seed + 500) - 0.5) * 2;
|
const warp = (exactNoise.valueNoise(wx * 0.50, wy * 0.50, seed + 504, 28) - 0.5) * 18;
|
||||||
scratch = (fbm(u * 920 + warp * 0.8, v * 2300 + warp * 0.25, seed + 502) - 0.5) * 2;
|
macro = (exactNoise.fbm(u * 520 + warp, v * 1850 - warp * 0.35, seed + 500) - 0.5) * 2;
|
||||||
const passBreak = clamp((valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6);
|
scratch = (exactNoise.fbm(u * 920 + warp * 0.8, v * 2300 + warp * 0.25, seed + 502) - 0.5) * 2;
|
||||||
const lateralBranch = clamp((valueNoise(v * 1700 - 3, u * 540 + 5, seed + 506, 16) - 0.54) * 2.1);
|
const passBreak = clamp((exactNoise.valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6);
|
||||||
|
const lateralBranch = clamp((exactNoise.valueNoise(v * 1700 - 3, u * 540 + 5, seed + 506, 16) - 0.54) * 2.1);
|
||||||
e += lateralBranch * mountainMaskMax * 0.022;
|
e += lateralBranch * mountainMaskMax * 0.022;
|
||||||
e -= passBreak * mountainMaskMax * 0.052;
|
e -= passBreak * mountainMaskMax * 0.052;
|
||||||
} else {
|
} else {
|
||||||
macro = (fbm(wx * terrainTemplate.macroNoiseScale * 48, wy * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
macro = (exactNoise.fbm(wx * terrainTemplate.macroNoiseScale * 48, wy * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
||||||
scratch = (fbm(wx * 2.2, wy * 2.2, seed + 502) - 0.5) * 2;
|
scratch = (exactNoise.fbm(wx * 2.2, wy * 2.2, seed + 502) - 0.5) * 2;
|
||||||
}
|
}
|
||||||
const global = (valueNoise(wx * 0.23, wy * 0.23, seed + 501, 38) - 0.5) * 2;
|
const global = (exactNoise.valueNoise(wx * 0.23, wy * 0.23, seed + 501, 38) - 0.5) * 2;
|
||||||
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
||||||
e += global * 0.020;
|
e += global * 0.020;
|
||||||
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
||||||
|
|
@ -1918,9 +2175,9 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
||||||
// Setouchi should read as sea-dominant, with many compact wooded island
|
// Setouchi should read as sea-dominant, with many compact wooded island
|
||||||
// backbones rather than broad continental ridges. The small-massif term
|
// backbones rather than broad continental ridges. The small-massif term
|
||||||
// is band-limited so it forms believable islands, not one-cell speckle.
|
// is band-limited so it forms believable islands, not one-cell speckle.
|
||||||
const lowHillNoise = clamp((fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.39) * 2.7);
|
const lowHillNoise = clamp((exactNoise.fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.39) * 2.7);
|
||||||
const islandMassif = clamp((fbm(wx * 1.85 - 31, wy * 1.85 + 19, seed + 573) - 0.50) * 3.4);
|
const islandMassif = clamp((exactNoise.fbm(wx * 1.85 - 31, wy * 1.85 + 19, seed + 573) - 0.50) * 3.4);
|
||||||
const islandBackbone = clamp((valueNoise(wx * 2.8 + 7, wy * 2.8 - 11, seed + 574, 9) - 0.54) * 3.2);
|
const islandBackbone = clamp((exactNoise.valueNoise(wx * 2.8 + 7, wy * 2.8 - 11, seed + 574, 9) - 0.54) * 3.2);
|
||||||
const coastalIslandBias = clamp(coastPressure * 0.64 + mountainMaskMax * 0.46 + lowHillNoise * 0.24);
|
const coastalIslandBias = clamp(coastPressure * 0.64 + mountainMaskMax * 0.46 + lowHillNoise * 0.24);
|
||||||
e += lowHillNoise * 0.090;
|
e += lowHillNoise * 0.090;
|
||||||
e += islandMassif * coastalIslandBias * 0.105;
|
e += islandMassif * coastalIslandBias * 0.105;
|
||||||
|
|
@ -1931,7 +2188,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
||||||
}
|
}
|
||||||
if (terrainTemplate.terrainType === "oceanic_archipelago") {
|
if (terrainTemplate.terrainType === "oceanic_archipelago") {
|
||||||
// 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。
|
// 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。
|
||||||
const islandCore = clamp((fbm(wx * 0.82 + 41, wy * 0.82 - 29, seed + 572) - 0.46) * 2.7);
|
const islandCore = clamp((exactNoise.fbm(wx * 0.82 + 41, wy * 0.82 - 29, seed + 572) - 0.46) * 2.7);
|
||||||
const islandChain = clamp(mountainMaskMax * 0.92 + islandCore * 0.54 - coastPressure * 0.24);
|
const islandChain = clamp(mountainMaskMax * 0.92 + islandCore * 0.54 - coastPressure * 0.24);
|
||||||
e += islandChain * 0.135;
|
e += islandChain * 0.135;
|
||||||
e -= clamp((coastPressure - 0.38) * 1.55) * 0.040;
|
e -= clamp((coastPressure - 0.38) * 1.55) * 0.040;
|
||||||
|
|
@ -1944,32 +2201,47 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
||||||
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
|
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
|
||||||
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
||||||
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
||||||
moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(wx * 1.1, wy * 1.1, seed + 503) - 0.5) * 0.16);
|
moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (exactNoise.fbm(wx * 1.1, wy * 1.1, seed + 503) - 0.5) * 0.16);
|
||||||
|
}
|
||||||
|
if (y % 8 === 7 || y + 1 === MAP_H) {
|
||||||
|
terrainProgress("base-field", `Terrain base field ${y + 1}/${MAP_H} rows`, y + 1, MAP_H + 8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
terrainProgress("water", "Terrain water classification", MAP_H + 1, MAP_H + 8);
|
||||||
let seaLevel = quantile(elevation, terrainTemplate.seaRatio);
|
let seaLevel = quantile(elevation, terrainTemplate.seaRatio);
|
||||||
seaLevel = clamp(seaLevel, 0.14, terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.47);
|
seaLevel = clamp(seaLevel, 0.14, terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.47);
|
||||||
classifyWater(elevation, seaLevel, sea, ocean, lake);
|
classifyWater(elevation, seaLevel, sea, ocean, lake);
|
||||||
recomputeSlope(elevation, sea, slope);
|
recomputeSlope(elevation, sea, slope);
|
||||||
|
|
||||||
|
terrainProgress("drainage", "Terrain drainage and watersheds", MAP_H + 2, MAP_H + 8);
|
||||||
const filled = new Float32Array(SIZE);
|
const filled = new Float32Array(SIZE);
|
||||||
priorityFloodFlow(elevation, sea, flowTo, filled);
|
priorityFloodFlow(elevation, sea, flowTo, filled);
|
||||||
computeFlowAccumulation(sea, flowTo, filled, flowAccum);
|
computeFlowAccumulation(sea, flowTo, filled, flowAccum);
|
||||||
const watershedId = buildWatershedId(sea, flowTo, flowAccum);
|
const watershedId = buildWatershedId(sea, flowTo, flowAccum);
|
||||||
|
terrainProgress("rivers", "Terrain river network", MAP_H + 3, MAP_H + 8);
|
||||||
const { riverPaths, mainRivers, tributaryRivers, smallStreams } = buildRiverNetwork(seed, terrainTemplate, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField);
|
const { riverPaths, mainRivers, tributaryRivers, smallStreams } = buildRiverNetwork(seed, terrainTemplate, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField);
|
||||||
|
terrainProgress("derived", "Terrain derived fields", MAP_H + 4, MAP_H + 8);
|
||||||
enforceLandGradient(elevation, sea, seaLevel);
|
enforceLandGradient(elevation, sea, seaLevel);
|
||||||
deriveFields(seed, terrainTemplate, fields, seaLevel);
|
deriveFields(seed, terrainTemplate, fields, seaLevel, (label) => {
|
||||||
|
terrainProgress("derived-detail", `Terrain ${label}`, MAP_H + 4, MAP_H + 8);
|
||||||
|
});
|
||||||
|
|
||||||
const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
|
const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
|
||||||
const landMask = new Uint8Array(SIZE);
|
const landMask = new Uint8Array(SIZE);
|
||||||
for (let i = 0; i < SIZE; i++) landMask[i] = sea[i] ? 0 : 1;
|
for (let i = 0; i < SIZE; i++) landMask[i] = sea[i] ? 0 : 1;
|
||||||
const zeroDensity = new Float32Array(SIZE);
|
const zeroDensity = new Float32Array(SIZE);
|
||||||
const zeroLanduse = new Int8Array(SIZE);
|
const zeroLanduse = new Int8Array(SIZE);
|
||||||
|
terrainProgress("natural-regions", "Terrain natural compartments", MAP_H + 5, MAP_H + 8);
|
||||||
const natural = buildNaturalCompartments(
|
const natural = buildNaturalCompartments(
|
||||||
landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
|
landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||||
null, plain, agriculture, zeroDensity, zeroLanduse,
|
null, plain, agriculture, zeroDensity, zeroLanduse,
|
||||||
{ seed: seed + 17003, watershedId, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 45), 70, 360) }
|
{
|
||||||
|
seed: seed + 17003,
|
||||||
|
watershedId,
|
||||||
|
targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 45), 70, 360),
|
||||||
|
progress: (label) => terrainProgress("natural-regions-detail", `Terrain ${label}`, MAP_H + 5, MAP_H + 8),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore;
|
const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore;
|
||||||
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
||||||
|
|
@ -2015,6 +2287,10 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
||||||
terrainFrameScale,
|
terrainFrameScale,
|
||||||
terrainFrameOffsetX,
|
terrainFrameOffsetX,
|
||||||
terrainFrameOffsetY,
|
terrainFrameOffsetY,
|
||||||
|
requestedTerrainFrameOffsetX,
|
||||||
|
requestedTerrainFrameOffsetY,
|
||||||
|
worldFrameOffsetX,
|
||||||
|
worldFrameOffsetY,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -582,8 +582,18 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
|
||||||
pairs.sort((a, b) => a.score - b.score);
|
pairs.sort((a, b) => a.score - b.score);
|
||||||
let added = 0;
|
let added = 0;
|
||||||
const virtualPenalty = new Float32Array(SIZE);
|
const virtualPenalty = new Float32Array(SIZE);
|
||||||
for (const pair of pairs) {
|
for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) {
|
||||||
|
const pair = pairs[pairIndex];
|
||||||
if (added >= maxRoutes) break;
|
if (added >= maxRoutes) break;
|
||||||
|
onProgress?.({
|
||||||
|
status: "route-heartbeat",
|
||||||
|
key: `road:${mode}-flow-route`,
|
||||||
|
phase: "transport-routing",
|
||||||
|
workUnitId: `road-${mode}-flow-route-loop`,
|
||||||
|
label: `${mode} flow route ${pairIndex + 1}/${pairs.length}`,
|
||||||
|
completed: pairIndex,
|
||||||
|
total: pairs.length,
|
||||||
|
});
|
||||||
const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, baseCost, virtualPenalty, {
|
const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, baseCost, virtualPenalty, {
|
||||||
curvePenalty: mode === "expressway" ? 0.12 : 0.065,
|
curvePenalty: mode === "expressway" ? 0.12 : 0.065,
|
||||||
penaltyStrength: mode === "expressway" ? 1.05 : 0.72,
|
penaltyStrength: mode === "expressway" ? 1.05 : 0.72,
|
||||||
|
|
@ -786,8 +796,18 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
|
||||||
const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(7, Math.max(3, Math.ceil((options.nodeCount || 1) / 5))) : Math.min(26, Math.max(12, Math.ceil((options.nodeCount || 1) * 0.36))));
|
const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(7, Math.max(3, Math.ceil((options.nodeCount || 1) / 5))) : Math.min(26, Math.max(12, Math.ceil((options.nodeCount || 1) * 0.36))));
|
||||||
const maxExtra = options.maxExtra ?? config.backbone?.maxExtra ?? 0;
|
const maxExtra = options.maxExtra ?? config.backbone?.maxExtra ?? 0;
|
||||||
const maxDegree = options.maxDegree ?? config.backbone?.maxDegree ?? 3;
|
const maxDegree = options.maxDegree ?? config.backbone?.maxDegree ?? 3;
|
||||||
for (const pair of pairs) {
|
for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) {
|
||||||
|
const pair = pairs[pairIndex];
|
||||||
if (outPaths.length >= maxAdded) break;
|
if (outPaths.length >= maxAdded) break;
|
||||||
|
onProgress?.({
|
||||||
|
status: "route-heartbeat",
|
||||||
|
key: `road:${mode}-backbone-route`,
|
||||||
|
phase: "transport-routing",
|
||||||
|
workUnitId: `road-${mode}-backbone-route-loop`,
|
||||||
|
label: `${mode} backbone route ${pairIndex + 1}/${pairs.length}`,
|
||||||
|
completed: pairIndex,
|
||||||
|
total: pairs.length,
|
||||||
|
});
|
||||||
const ak = keyOf(pair.A);
|
const ak = keyOf(pair.A);
|
||||||
const bk = keyOf(pair.B);
|
const bk = keyOf(pair.B);
|
||||||
const connects = find(ak) !== find(bk);
|
const connects = find(ak) !== find(bk);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,40 @@
|
||||||
import { MAP_W, SIZE, clamp, indexOf, inside, walkGridPath } from "./mapUtils.js";
|
import { MAP_H, MAP_W, SIZE, clamp, indexOf, inside, walkGridPath } from "./mapUtils.js";
|
||||||
|
|
||||||
|
const radialInfluenceKernelCache = new Map();
|
||||||
|
|
||||||
|
// Exact cache for the integer-radius radial kernels used repeatedly by road,
|
||||||
|
// rail and expressway influence passes. Offset order is deliberately the same
|
||||||
|
// dy-major/dx-minor order as the former nested loops, and weights are Float64,
|
||||||
|
// so replacing repeated hypot/pow calls does not change field results.
|
||||||
|
export function getRadialInfluenceKernel(radius, exponent = 1.35) {
|
||||||
|
const r = Number(radius);
|
||||||
|
const e = Number(exponent);
|
||||||
|
if (!Number.isInteger(r) || r < 0 || !Number.isFinite(e)) return null;
|
||||||
|
const key = `${r}:${e}`;
|
||||||
|
let cached = radialInfluenceKernelCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
const dx = [];
|
||||||
|
const dy = [];
|
||||||
|
const weight = [];
|
||||||
|
const denominator = Math.max(1, r);
|
||||||
|
for (let oy = -r; oy <= r; oy++) {
|
||||||
|
for (let ox = -r; ox <= r; ox++) {
|
||||||
|
if (ox * ox + oy * oy > r * r) continue;
|
||||||
|
const d = Math.hypot(ox, oy);
|
||||||
|
dx.push(ox);
|
||||||
|
dy.push(oy);
|
||||||
|
weight.push(Math.pow(1 - d / denominator, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cached = {
|
||||||
|
dx: Int16Array.from(dx),
|
||||||
|
dy: Int16Array.from(dy),
|
||||||
|
weight: Float64Array.from(weight),
|
||||||
|
length: dx.length,
|
||||||
|
};
|
||||||
|
radialInfluenceKernelCache.set(key, cached);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
export function pathSetSignature(paths) {
|
export function pathSetSignature(paths) {
|
||||||
let cells = 0;
|
let cells = 0;
|
||||||
|
|
@ -47,6 +83,24 @@ export function pathAverageField(path, field) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markPathInfluence(field, path, radius = 5, strength = 1, sea = null) {
|
export function markPathInfluence(field, path, radius = 5, strength = 1, sea = null) {
|
||||||
|
const kernel = getRadialInfluenceKernel(radius, 1.35);
|
||||||
|
if (kernel) {
|
||||||
|
for (const [px, py] of path || []) {
|
||||||
|
for (let k = 0; k < kernel.length; k++) {
|
||||||
|
const x = px + kernel.dx[k];
|
||||||
|
const y = py + kernel.dy[k];
|
||||||
|
// The production grid is fixed MAP_W x MAP_H. Inline the bounds/index
|
||||||
|
// check in this hot loop while preserving the exact same accepted cells.
|
||||||
|
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue;
|
||||||
|
const i = y * MAP_W + x;
|
||||||
|
if (sea?.[i]) continue;
|
||||||
|
const v = strength * kernel.weight[k];
|
||||||
|
if (v > field[i]) field[i] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Preserve legacy behavior for unusual non-integer radii.
|
||||||
for (const [px, py] of path || []) {
|
for (const [px, py] of path || []) {
|
||||||
for (let dy = -radius; dy <= radius; dy++) {
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
for (let dx = -radius; dx <= radius; dx++) {
|
for (let dx = -radius; dx <= radius; dx++) {
|
||||||
|
|
|
||||||
64
src/patchCandidateWorker.js
Normal file
64
src/patchCandidateWorker.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { generateMap } from "./mapPipeline.js";
|
||||||
|
import { collectTransferableBuffers } from "./transferUtils.js";
|
||||||
|
import { compactRawPatchCandidate, summarizeRawPatchCandidate } from "./rawPatchCandidate.js";
|
||||||
|
|
||||||
|
function scopeTaskProgress(event = {}, taskId) {
|
||||||
|
const scoped = { ...event };
|
||||||
|
if (event?.workUnitId != null && String(event.workUnitId).length > 0) {
|
||||||
|
scoped.workUnitId = `${taskId}/${String(event.workUnitId)}`;
|
||||||
|
}
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof self !== "undefined") {
|
||||||
|
self.onmessage = (event) => {
|
||||||
|
const message = event.data || {};
|
||||||
|
if (message.type !== "generate-raw-patch-candidate") return;
|
||||||
|
const taskId = String(message.taskId || `raw-${message.id || 0}`);
|
||||||
|
const startedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||||
|
try {
|
||||||
|
let candidate = generateMap(Number(message.seed) >>> 0, {
|
||||||
|
...(message.mapOptions || {}),
|
||||||
|
// A boolean sentinel is sufficient: mapPipeline only records whether a
|
||||||
|
// boundary world exists. Actual seam/quality work remains in the parent
|
||||||
|
// patch worker after this raw candidate has been transferred back.
|
||||||
|
boundaryWorld: true,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
self.postMessage({
|
||||||
|
type: "raw-patch-candidate-progress",
|
||||||
|
id: message.id,
|
||||||
|
taskId,
|
||||||
|
progress: scopeTaskProgress(progress, taskId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const elapsedMs = (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - startedAt;
|
||||||
|
// Transfer only the roots consumed by mapPatch. Full-map geography/debug
|
||||||
|
// graphs can exceed the actual patch payload and are never consulted by
|
||||||
|
// candidate merge/quality logic. Dropping them before structured clone
|
||||||
|
// creates a hard cross-worker memory bound for large tiled operations.
|
||||||
|
candidate = compactRawPatchCandidate(candidate);
|
||||||
|
const transferSummary = summarizeRawPatchCandidate(candidate);
|
||||||
|
const transferables = [...collectTransferableBuffers(candidate)];
|
||||||
|
self.postMessage({
|
||||||
|
type: "raw-patch-candidate-result",
|
||||||
|
id: message.id,
|
||||||
|
taskId,
|
||||||
|
ok: true,
|
||||||
|
elapsedMs,
|
||||||
|
transferSummary,
|
||||||
|
candidate,
|
||||||
|
}, transferables);
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({
|
||||||
|
type: "raw-patch-candidate-result",
|
||||||
|
id: message.id,
|
||||||
|
taskId,
|
||||||
|
ok: false,
|
||||||
|
code: error?.code || "raw-patch-candidate-error",
|
||||||
|
error: error?.message || String(error),
|
||||||
|
stack: error?.stack || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/rawPatchCandidate.js
Normal file
81
src/rawPatchCandidate.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// Raw production candidates are generated in nested workers for large patch
|
||||||
|
// selections. The complete generateMap() result contains large immutable
|
||||||
|
// geography/debug graphs that are useful to full-map callers but are never
|
||||||
|
// consumed by the patch merge pipeline. Structured-cloning those graphs for
|
||||||
|
// every tile multiplies peak memory and can trigger browser/OS memory pressure.
|
||||||
|
//
|
||||||
|
// Keep this schema deliberately conservative: every top-level typed array is
|
||||||
|
// retained (copyFullPipelineFields discovers cell fields dynamically), while
|
||||||
|
// only vector/metadata roots that mapPatch consumes are retained from ordinary
|
||||||
|
// arrays/objects. Primitive roots are cheap and kept for forward-compatible
|
||||||
|
// generation metadata.
|
||||||
|
|
||||||
|
export const RAW_PATCH_POINT_LAYER_KEYS = Object.freeze([
|
||||||
|
"villages", "geographicUrbanAnchors", "markets", "castles", "castleTowns", "castleRuins",
|
||||||
|
"ports", "crossings", "passes", "modernCities", "satelliteCities", "stations",
|
||||||
|
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
|
||||||
|
"externalGateways", "prefectureRegions",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const RAW_PATCH_PATH_LAYER_KEYS = Object.freeze([
|
||||||
|
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
||||||
|
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
||||||
|
"icAccessRoads", "mainRivers", "tributaryRivers", "smallStreams", "riverPaths",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REQUIRED_ARRAY_ROOTS = new Set([
|
||||||
|
...RAW_PATCH_POINT_LAYER_KEYS,
|
||||||
|
...RAW_PATCH_PATH_LAYER_KEYS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REQUIRED_OBJECT_ROOTS = new Set([
|
||||||
|
// validatePrecomputedPatchCandidate() verifies generationContext before the
|
||||||
|
// raw graph is accepted by the production patch generator.
|
||||||
|
"generationContext",
|
||||||
|
// Terrain identity is used by patch quality/continuity logic.
|
||||||
|
"terrainTemplate",
|
||||||
|
"terrainDebug",
|
||||||
|
// Only compartmentBorders is read today, but retaining the bounded admin
|
||||||
|
// diagnostic object avoids coupling this transport boundary to its internals.
|
||||||
|
"adminDebug",
|
||||||
|
// Preserve this if a future precompute path performs quality scoring before
|
||||||
|
// transfer. It is normally attached later by mapPatch.
|
||||||
|
"patchQuality",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function compactRawPatchCandidate(candidate) {
|
||||||
|
if (!candidate || typeof candidate !== "object") return candidate;
|
||||||
|
const compact = {};
|
||||||
|
for (const [key, value] of Object.entries(candidate)) {
|
||||||
|
if (value == null || typeof value !== "object") {
|
||||||
|
compact[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(value)) {
|
||||||
|
compact[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (REQUIRED_ARRAY_ROOTS.has(key)) compact[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (REQUIRED_OBJECT_ROOTS.has(key)) compact[key] = value;
|
||||||
|
}
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRawPatchCandidate(candidate) {
|
||||||
|
if (!candidate || typeof candidate !== "object") return { rootCount: 0, transferableBytes: 0 };
|
||||||
|
let transferableBytes = 0;
|
||||||
|
let typedArrayCount = 0;
|
||||||
|
for (const value of Object.values(candidate)) {
|
||||||
|
if (!ArrayBuffer.isView(value)) continue;
|
||||||
|
typedArrayCount += 1;
|
||||||
|
transferableBytes += Number(value.byteLength || 0);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rootCount: Object.keys(candidate).length,
|
||||||
|
typedArrayCount,
|
||||||
|
transferableBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,9 +10,15 @@ const baseImageCache = new Map();
|
||||||
const urbanOverlayCache = new Map();
|
const urbanOverlayCache = new Map();
|
||||||
const prefectureFillCache = new Map();
|
const prefectureFillCache = new Map();
|
||||||
const transportDebugHeatmapCache = new WeakMap();
|
const transportDebugHeatmapCache = new WeakMap();
|
||||||
const MAX_BASE_CACHE_IMAGES = 18;
|
// Keep only the currently useful raster frame. Previous camera/revision canvases
|
||||||
const MAX_OVERLAY_CACHE_IMAGES = 18;
|
// can each be tens of MiB at low zoom and are cheaper to redraw than to retain
|
||||||
const MAX_SEGMENT_VECTOR_CACHE = 48;
|
// while a full candidate world is also resident.
|
||||||
|
const MAX_BASE_CACHE_IMAGES = 1;
|
||||||
|
// Only the committed frame and current preview need raster overlays. Retaining
|
||||||
|
// 18 full-canvas generations made repeated Alternative previews consume
|
||||||
|
// hundreds of MiB at low zoom.
|
||||||
|
const MAX_OVERLAY_CACHE_IMAGES = 1;
|
||||||
|
const MAX_SEGMENT_VECTOR_CACHE = 16;
|
||||||
const CONTINUOUS_BASE_MODES = ["terrain", "development", "all"];
|
const CONTINUOUS_BASE_MODES = ["terrain", "development", "all"];
|
||||||
|
|
||||||
function fieldRefSignature(map) {
|
function fieldRefSignature(map) {
|
||||||
|
|
@ -1247,7 +1253,7 @@ function drawScaleBar(ctx, cellScreenSize = CELL_SIZE) {
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function drawMap(canvas, map, options) {
|
function* drawMapSteps(canvas, map, options) {
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
|
@ -1295,12 +1301,15 @@ export function drawMap(canvas, map, options) {
|
||||||
// 1. Base Terrain & Urban
|
// 1. Base Terrain & Urban
|
||||||
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
|
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
|
||||||
markTiming("baseTerrain");
|
markTiming("baseTerrain");
|
||||||
|
yield { phase: "baseTerrain" };
|
||||||
drawUrbanAreas(ctx, map, mode);
|
drawUrbanAreas(ctx, map, mode);
|
||||||
markTiming("urbanFill");
|
markTiming("urbanFill");
|
||||||
|
yield { phase: "urbanFill" };
|
||||||
const coastSegments = getCoastlineSegments(map);
|
const coastSegments = getCoastlineSegments(map);
|
||||||
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
markTiming("coastline");
|
markTiming("coastline");
|
||||||
|
yield { phase: "coastline" };
|
||||||
|
|
||||||
// 2. Rivers
|
// 2. Rivers
|
||||||
const waterBlue = "rgba(116, 165, 202, 0.92)";
|
const waterBlue = "rgba(116, 165, 202, 0.92)";
|
||||||
|
|
@ -1352,6 +1361,7 @@ export function drawMap(canvas, map, options) {
|
||||||
}, 1.0);
|
}, 1.0);
|
||||||
}
|
}
|
||||||
markTiming("rivers");
|
markTiming("rivers");
|
||||||
|
yield { phase: "rivers" };
|
||||||
|
|
||||||
const showTransportDebug = mode === "transport-debug";
|
const showTransportDebug = mode === "transport-debug";
|
||||||
const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode);
|
const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode);
|
||||||
|
|
@ -1391,10 +1401,12 @@ export function drawMap(canvas, map, options) {
|
||||||
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
}
|
}
|
||||||
markTiming("adminBorders");
|
markTiming("adminBorders");
|
||||||
|
yield { phase: "adminBorders" };
|
||||||
|
|
||||||
if (!showFeatures) {
|
if (!showFeatures) {
|
||||||
if (showSeamDiagnostics) drawSeamDiagnostics(ctx, map);
|
if (showSeamDiagnostics) drawSeamDiagnostics(ctx, map);
|
||||||
markTiming("seamDiagnostics");
|
markTiming("seamDiagnostics");
|
||||||
|
yield { phase: "seamDiagnostics" };
|
||||||
return finish();
|
return finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1446,6 +1458,7 @@ export function drawMap(canvas, map, options) {
|
||||||
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
||||||
}
|
}
|
||||||
markTiming("transport");
|
markTiming("transport");
|
||||||
|
yield { phase: "transport" };
|
||||||
|
|
||||||
// 6. Icons & Labels
|
// 6. Icons & Labels
|
||||||
if (["admin", "borders-debug"].includes(mode)) {
|
if (["admin", "borders-debug"].includes(mode)) {
|
||||||
|
|
@ -1487,9 +1500,11 @@ export function drawMap(canvas, map, options) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
markTiming("icons");
|
markTiming("icons");
|
||||||
|
yield { phase: "icons" };
|
||||||
|
|
||||||
if (showSeamDiagnostics) drawSeamDiagnostics(ctx, map);
|
if (showSeamDiagnostics) drawSeamDiagnostics(ctx, map);
|
||||||
markTiming("seamDiagnostics");
|
markTiming("seamDiagnostics");
|
||||||
|
yield { phase: "seamDiagnostics" };
|
||||||
|
|
||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
|
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
|
||||||
|
|
@ -1499,11 +1514,13 @@ export function drawMap(canvas, map, options) {
|
||||||
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
||||||
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
||||||
markTiming("labels");
|
markTiming("labels");
|
||||||
|
yield { phase: "labels" };
|
||||||
return finish();
|
return finish();
|
||||||
}
|
}
|
||||||
if (mode === "borders-debug") {
|
if (mode === "borders-debug") {
|
||||||
drawLabels(ctx, prefectureLabels, Infinity);
|
drawLabels(ctx, prefectureLabels, Infinity);
|
||||||
markTiming("labels");
|
markTiming("labels");
|
||||||
|
yield { phase: "labels" };
|
||||||
return finish();
|
return finish();
|
||||||
}
|
}
|
||||||
const important = [
|
const important = [
|
||||||
|
|
@ -1516,5 +1533,49 @@ export function drawMap(canvas, map, options) {
|
||||||
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
|
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
|
||||||
}
|
}
|
||||||
markTiming("labels");
|
markTiming("labels");
|
||||||
|
yield { phase: "labels" };
|
||||||
return finish();
|
return finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function drawMap(canvas, map, options) {
|
||||||
|
const steps = drawMapSteps(canvas, map, options);
|
||||||
|
let state = steps.next();
|
||||||
|
while (!state.done) state = steps.next();
|
||||||
|
return state.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function drawMapCooperative(canvas, map, options, cooperative = {}) {
|
||||||
|
const yieldControl = typeof cooperative.yieldControl === "function"
|
||||||
|
? cooperative.yieldControl
|
||||||
|
: () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
const shouldCancel = typeof cooperative.shouldCancel === "function" ? cooperative.shouldCancel : () => false;
|
||||||
|
const steps = drawMapSteps(canvas, map, options);
|
||||||
|
let sliceStartedAt = nowMs();
|
||||||
|
let maxSliceMs = 0;
|
||||||
|
while (true) {
|
||||||
|
const state = steps.next();
|
||||||
|
const sliceMs = nowMs() - sliceStartedAt;
|
||||||
|
maxSliceMs = Math.max(maxSliceMs, sliceMs);
|
||||||
|
if (state.done) {
|
||||||
|
if (state.value && typeof state.value === "object") state.value.cooperativeMaxSliceMs = Math.round(maxSliceMs * 10) / 10;
|
||||||
|
return state.value;
|
||||||
|
}
|
||||||
|
if (shouldCancel()) {
|
||||||
|
try { steps.return?.(); } catch {}
|
||||||
|
const error = typeof DOMException === "function"
|
||||||
|
? new DOMException("Preview rendering cancelled.", "AbortError")
|
||||||
|
: Object.assign(new Error("Preview rendering cancelled."), { name: "AbortError" });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await yieldControl(state.value?.phase || "render");
|
||||||
|
if (shouldCancel()) {
|
||||||
|
try { steps.return?.(); } catch {}
|
||||||
|
const error = typeof DOMException === "function"
|
||||||
|
? new DOMException("Preview rendering cancelled.", "AbortError")
|
||||||
|
: Object.assign(new Error("Preview rendering cancelled."), { name: "AbortError" });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
sliceStartedAt = nowMs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
119
src/worldMap.js
119
src/worldMap.js
|
|
@ -1,8 +1,59 @@
|
||||||
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
|
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
|
||||||
import { defaultCellFieldValue, isTypedCellField, worldFieldConstructor } from "./fieldSchema.js";
|
import { defaultCellFieldValue, isTypedCellField, worldFieldConstructor } from "./fieldSchema.js";
|
||||||
|
|
||||||
const DEFAULT_WORLD_PADDING_X = MAP_W;
|
// Keep enough off-screen room for immediate panning/expansion without paying
|
||||||
const DEFAULT_WORLD_PADDING_Y = MAP_H;
|
// for a nine-map-cell backing allocation before the first patch. Additional
|
||||||
|
// padding is still allocated on demand near camera edges.
|
||||||
|
const DEFAULT_WORLD_PADDING_X = Math.ceil(MAP_W * 0.5);
|
||||||
|
const DEFAULT_WORLD_PADDING_Y = Math.ceil(MAP_H * 0.5);
|
||||||
|
const MAX_WORLD_WIDTH = MAP_W * 6;
|
||||||
|
const MAX_WORLD_HEIGHT = MAP_H * 6;
|
||||||
|
|
||||||
|
const INITIAL_QUALITY_SETTLEMENT_KEYS = ["villages", "markets", "modernCities", "satelliteCities", "newTowns", "ports"];
|
||||||
|
const INITIAL_QUALITY_LABEL_KEYS = [...INITIAL_QUALITY_SETTLEMENT_KEYS, "adminCenters"];
|
||||||
|
const INITIAL_QUALITY_ROAD_KEYS = ["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "expressways", "icAccessRoads"];
|
||||||
|
const INITIAL_QUALITY_RAIL_KEYS = ["railways", "branchRailways", "ringRailways"];
|
||||||
|
|
||||||
|
const FULL_MAP_ONLY_SOURCE_ROOTS = new Set([
|
||||||
|
// These graphs are required while generateMap() is building the initial map,
|
||||||
|
// but no renderer, viewport, patch, Apply or diagnostics path reads them once
|
||||||
|
// the padded world has been created. Keeping them in sourceMap makes every
|
||||||
|
// committed Worker mirror clone a large immutable geography graph.
|
||||||
|
"geography",
|
||||||
|
"naturalCompartments",
|
||||||
|
"geographicCompartmentProfiles",
|
||||||
|
"watershedProfiles",
|
||||||
|
"entitiesForNames",
|
||||||
|
"geographyDebug",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function captureInitialQualityReference(initialMap) {
|
||||||
|
const sea = initialMap?.sea;
|
||||||
|
let landCells = 0;
|
||||||
|
if (sea && typeof sea.length === "number") {
|
||||||
|
for (let i = 0; i < sea.length; i++) if (!sea[i]) landCells++;
|
||||||
|
}
|
||||||
|
const count = (keys) => keys.reduce((sum, key) => sum + (Array.isArray(initialMap?.[key]) ? initialMap[key].length : 0), 0);
|
||||||
|
return {
|
||||||
|
landCells: Math.max(1, landCells),
|
||||||
|
settlements: count(INITIAL_QUALITY_SETTLEMENT_KEYS),
|
||||||
|
labels: count(INITIAL_QUALITY_LABEL_KEYS),
|
||||||
|
roadPathCount: count(INITIAL_QUALITY_ROAD_KEYS),
|
||||||
|
railPathCount: count(INITIAL_QUALITY_RAIL_KEYS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSourceMetadata(initialMap) {
|
||||||
|
const sourceMap = { ...(initialMap || {}) };
|
||||||
|
// Raster cell fields already live in the padded world.fields arrays. Keeping
|
||||||
|
// the original 258x183 copies in sourceMap made every Worker mirror/input and
|
||||||
|
// accepted result carry a second obsolete raster set. Metadata, point layers,
|
||||||
|
// paths, diagnostics, and small lookup arrays remain intact.
|
||||||
|
for (const [name, value] of Object.entries(sourceMap)) {
|
||||||
|
if (isTypedCellField(value, SIZE) || FULL_MAP_ONLY_SOURCE_ROOTS.has(name)) delete sourceMap[name];
|
||||||
|
}
|
||||||
|
return sourceMap;
|
||||||
|
}
|
||||||
|
|
||||||
function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) {
|
function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) {
|
||||||
const Constructor = worldFieldConstructor(name, source.constructor);
|
const Constructor = worldFieldConstructor(name, source.constructor);
|
||||||
|
|
@ -13,7 +64,7 @@ function makeWorldField(name, source, worldWidth, worldHeight, originX, originY)
|
||||||
for (let y = 0; y < MAP_H; y++) {
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
const srcRow = y * MAP_W;
|
const srcRow = y * MAP_W;
|
||||||
const dstRow = (originY + y) * worldWidth + originX;
|
const dstRow = (originY + y) * worldWidth + originX;
|
||||||
for (let x = 0; x < MAP_W; x++) out[dstRow + x] = source[srcRow + x];
|
out.set(source.subarray(srcRow, srcRow + MAP_W), dstRow);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +109,10 @@ export function createWorldMap(initialMap, options = {}) {
|
||||||
fields.elevation.fill(0.08);
|
fields.elevation.fill(0.08);
|
||||||
}
|
}
|
||||||
sanitizeInitialWorldFields(fields, worldWidth, worldHeight);
|
sanitizeInitialWorldFields(fields, worldWidth, worldHeight);
|
||||||
|
const generatedMask = new Uint8Array(worldWidth * worldHeight);
|
||||||
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
|
generatedMask.fill(1, (originY + y) * worldWidth + originX, (originY + y) * worldWidth + originX + MAP_W);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seed: initialMap?.seed ?? 0,
|
seed: initialMap?.seed ?? 0,
|
||||||
|
|
@ -70,10 +125,10 @@ export function createWorldMap(initialMap, options = {}) {
|
||||||
// Sea level is a world invariant. Patch candidates must classify water
|
// Sea level is a world invariant. Patch candidates must classify water
|
||||||
// against this value instead of recalculating a local quantile.
|
// against this value instead of recalculating a local quantile.
|
||||||
seaLevel: Number.isFinite(initialMap?.seaLevel) ? initialMap.seaLevel : 0.30,
|
seaLevel: Number.isFinite(initialMap?.seaLevel) ? initialMap.seaLevel : 0.30,
|
||||||
sourceMap: initialMap,
|
sourceMap: createSourceMetadata(initialMap),
|
||||||
|
initialQualityReference: captureInitialQualityReference(initialMap),
|
||||||
fields,
|
fields,
|
||||||
invalidatedRects: [],
|
generatedMask,
|
||||||
humanPatchHistory: [],
|
|
||||||
generatedRects: [{
|
generatedRects: [{
|
||||||
x0: originX,
|
x0: originX,
|
||||||
y0: originY,
|
y0: originY,
|
||||||
|
|
@ -104,7 +159,12 @@ export function clampCameraToWorld(camera, world, viewWidth = MAP_W, viewHeight
|
||||||
|
|
||||||
function expandRectByOffset(rect, dx, dy) {
|
function expandRectByOffset(rect, dx, dy) {
|
||||||
if (!rect) return rect;
|
if (!rect) return rect;
|
||||||
return { ...rect, x0: rect.x0 + dx, y0: rect.y0 + dy, x1: rect.x1 + dx, y1: rect.y1 + dy };
|
const out = { ...rect };
|
||||||
|
if (Number.isFinite(rect.x0)) out.x0 = rect.x0 + dx;
|
||||||
|
if (Number.isFinite(rect.y0)) out.y0 = rect.y0 + dy;
|
||||||
|
if (Number.isFinite(rect.x1)) out.x1 = rect.x1 + dx;
|
||||||
|
if (Number.isFinite(rect.y1)) out.y1 = rect.y1 + dy;
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftSelectionShape(shape, dx, dy) {
|
function shiftSelectionShape(shape, dx, dy) {
|
||||||
|
|
@ -140,12 +200,26 @@ function shiftPatchMetadata(item, dx, dy) {
|
||||||
}
|
}
|
||||||
if (out?.selectionShape) out.selectionShape = shiftSelectionShape(out.selectionShape, dx, dy);
|
if (out?.selectionShape) out.selectionShape = shiftSelectionShape(out.selectionShape, dx, dy);
|
||||||
if (out?.generatedFootprint) out.generatedFootprint = shiftGeneratedFootprint(out.generatedFootprint, dx, dy);
|
if (out?.generatedFootprint) out.generatedFootprint = shiftGeneratedFootprint(out.generatedFootprint, dx, dy);
|
||||||
|
if (out?.rects) out.rects = shiftPatchMetadata(out.rects, dx, dy);
|
||||||
|
if (out?.validation?.rect) out.validation = { ...out.validation, rect: shiftPatchMetadata(out.validation.rect, dx, dy) };
|
||||||
|
if (out?.seamDiagnostics) {
|
||||||
|
const seam = { ...out.seamDiagnostics };
|
||||||
|
for (const key of ["issuePoints", "markerPoints"]) {
|
||||||
|
if (Array.isArray(seam[key])) seam[key] = seam[key].map((point) => ({ ...point, x: point.x + dx, y: point.y + dy }));
|
||||||
|
}
|
||||||
|
for (const key of ["outlineSegments", "seamSegments"]) {
|
||||||
|
if (Array.isArray(seam[key])) seam[key] = seam[key].map((segment) => Array.isArray(segment)
|
||||||
|
? segment.map((point) => Array.isArray(point) ? [point[0] + dx, point[1] + dy] : point)
|
||||||
|
: segment);
|
||||||
|
}
|
||||||
|
out.seamDiagnostics = seam;
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftRectCollections(world, dx, dy) {
|
function shiftRectCollections(world, dx, dy) {
|
||||||
if (!dx && !dy) return;
|
if (!dx && !dy) return;
|
||||||
for (const key of ["generatedRects", "invalidatedRects", "humanPatchHistory"]) {
|
for (const key of ["generatedRects"]) {
|
||||||
if (!Array.isArray(world[key])) continue;
|
if (!Array.isArray(world[key])) continue;
|
||||||
world[key] = world[key].map((item) => shiftPatchMetadata(item, dx, dy));
|
world[key] = world[key].map((item) => shiftPatchMetadata(item, dx, dy));
|
||||||
}
|
}
|
||||||
|
|
@ -154,10 +228,20 @@ function shiftRectCollections(world, dx, dy) {
|
||||||
|
|
||||||
function expandWorldMap(world, margins = {}) {
|
function expandWorldMap(world, margins = {}) {
|
||||||
if (!world) return { world, dx: 0, dy: 0, expanded: false };
|
if (!world) return { world, dx: 0, dy: 0, expanded: false };
|
||||||
const left = Math.max(0, Math.floor(margins.left || 0));
|
let left = Math.max(0, Math.floor(margins.left || 0));
|
||||||
const right = Math.max(0, Math.floor(margins.right || 0));
|
let right = Math.max(0, Math.floor(margins.right || 0));
|
||||||
const top = Math.max(0, Math.floor(margins.top || 0));
|
let top = Math.max(0, Math.floor(margins.top || 0));
|
||||||
const bottom = Math.max(0, Math.floor(margins.bottom || 0));
|
let bottom = Math.max(0, Math.floor(margins.bottom || 0));
|
||||||
|
const remainingWidth = Math.max(0, MAX_WORLD_WIDTH - world.width);
|
||||||
|
const remainingHeight = Math.max(0, MAX_WORLD_HEIGHT - world.height);
|
||||||
|
if (left + right > remainingWidth) {
|
||||||
|
left = Math.min(left, remainingWidth);
|
||||||
|
right = Math.min(right, remainingWidth - left);
|
||||||
|
}
|
||||||
|
if (top + bottom > remainingHeight) {
|
||||||
|
top = Math.min(top, remainingHeight);
|
||||||
|
bottom = Math.min(bottom, remainingHeight - top);
|
||||||
|
}
|
||||||
if (!left && !right && !top && !bottom) return { world, dx: 0, dy: 0, expanded: false };
|
if (!left && !right && !top && !bottom) return { world, dx: 0, dy: 0, expanded: false };
|
||||||
const oldWidth = world.width;
|
const oldWidth = world.width;
|
||||||
const oldHeight = world.height;
|
const oldHeight = world.height;
|
||||||
|
|
@ -173,10 +257,19 @@ function expandWorldMap(world, margins = {}) {
|
||||||
for (let y = 0; y < oldHeight; y++) {
|
for (let y = 0; y < oldHeight; y++) {
|
||||||
const srcRow = y * oldWidth;
|
const srcRow = y * oldWidth;
|
||||||
const dstRow = (y + top) * newWidth + left;
|
const dstRow = (y + top) * newWidth + left;
|
||||||
for (let x = 0; x < oldWidth; x++) out[dstRow + x] = field[srcRow + x];
|
out.set(field.subarray(srcRow, srcRow + oldWidth), dstRow);
|
||||||
}
|
}
|
||||||
newFields[name] = out;
|
newFields[name] = out;
|
||||||
}
|
}
|
||||||
|
if (ArrayBuffer.isView(world.generatedMask)) {
|
||||||
|
const generatedMask = new Uint8Array(newWidth * newHeight);
|
||||||
|
for (let y = 0; y < oldHeight; y++) {
|
||||||
|
const srcStart = y * oldWidth;
|
||||||
|
const dstStart = (y + top) * newWidth + left;
|
||||||
|
generatedMask.set(world.generatedMask.subarray(srcStart, srcStart + oldWidth), dstStart);
|
||||||
|
}
|
||||||
|
world.generatedMask = generatedMask;
|
||||||
|
}
|
||||||
world.width = newWidth;
|
world.width = newWidth;
|
||||||
world.height = newHeight;
|
world.height = newHeight;
|
||||||
world.originX += left;
|
world.originX += left;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { MAP_H, MAP_W, SIZE, worldIndexOf } from "./mapUtils.js";
|
import { MAP_H, MAP_W, worldIndexOf } from "./mapUtils.js";
|
||||||
import { defaultCellFieldValue } from "./fieldSchema.js";
|
import { defaultCellFieldValue } from "./fieldSchema.js";
|
||||||
|
|
||||||
const EMPTY_ARRAY_KEYS = new Set([
|
const EMPTY_ARRAY_KEYS = new Set([
|
||||||
|
|
@ -26,6 +26,43 @@ const POINT_ARRAY_KEYS = new Set([
|
||||||
"externalGateways", "prefectureRegions",
|
"externalGateways", "prefectureRegions",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Renderer, hover/stat, and transport-debug consumers only read this raster
|
||||||
|
// subset. Copying every simulation/debug field into a viewport on every
|
||||||
|
// preview, Apply, pan, and zoom caused avoidable typed-array allocation and GC.
|
||||||
|
const VIEWPORT_RASTER_FIELDS = new Set([
|
||||||
|
"adminId", "municipalityId", "agriculture", "coastalLowland", "elevation", "flowAccum",
|
||||||
|
"focusedPrefectureMask", "humanRegionMask", "landuse", "naturalBarrierScore", "plain",
|
||||||
|
"populationDensity", "prefectureMask", "prefectureRegionId", "railInfluence2", "regionId",
|
||||||
|
"ridgeField", "river", "roadInfluence", "sea", "settlementCluster", "settlementScore",
|
||||||
|
"slope", "stationInfluence", "surfaceTextureField", "valleyField", "visibleRavineField",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const viewportCache = new WeakMap();
|
||||||
|
|
||||||
|
function viewportCacheKey(world, camera, viewWidth, viewHeight, light) {
|
||||||
|
return [
|
||||||
|
Math.round(camera?.x || 0), Math.round(camera?.y || 0), viewWidth, viewHeight,
|
||||||
|
light ? 1 : 0, world?.width || 0, world?.height || 0,
|
||||||
|
world?.originX || 0, world?.originY || 0,
|
||||||
|
world?.renderRevision || 0, world?.patchGenerationSerial || 0,
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberViewport(world, key, viewport) {
|
||||||
|
if (!world || typeof world !== "object") return viewport;
|
||||||
|
let entries = viewportCache.get(world);
|
||||||
|
if (!entries) {
|
||||||
|
entries = new Map();
|
||||||
|
viewportCache.set(world, entries);
|
||||||
|
}
|
||||||
|
entries.set(key, viewport);
|
||||||
|
// A viewport owns one typed array per rendered raster field. Retaining four
|
||||||
|
// camera/revision snapshots multiplied memory during preview generation; the
|
||||||
|
// current view is the only one required for correctness.
|
||||||
|
while (entries.size > 1) entries.delete(entries.keys().next().value);
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
|
|
||||||
function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
|
function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
|
||||||
const Constructor = source.constructor;
|
const Constructor = source.constructor;
|
||||||
const out = new Constructor(viewWidth * viewHeight);
|
const out = new Constructor(viewWidth * viewHeight);
|
||||||
|
|
@ -111,48 +148,17 @@ function transformSegments(segments, camera, originX, originY, viewWidth = MAP_W
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function copySourceMapViewportField(source, camera, originX, originY, viewWidth, viewHeight) {
|
function transformTransportDebug(debug, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
if (!ArrayBuffer.isView(source) || typeof source.length !== "number" || source.length !== SIZE) return source;
|
|
||||||
const out = new source.constructor(viewWidth * viewHeight);
|
|
||||||
const cx = Math.round(camera.x || 0);
|
|
||||||
const cy = Math.round(camera.y || 0);
|
|
||||||
for (let y = 0; y < viewHeight; y++) {
|
|
||||||
for (let x = 0; x < viewWidth; x++) {
|
|
||||||
const sx = cx + x - originX;
|
|
||||||
const sy = cy + y - originY;
|
|
||||||
if (sx >= 0 && sy >= 0 && sx < MAP_W && sy < MAP_H) out[y * viewWidth + x] = source[sy * MAP_W + sx];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformTransportDebug(debug, camera, originX, originY, viewport = null, viewWidth = MAP_W, viewHeight = MAP_H) {
|
|
||||||
if (!debug?.layers) return debug;
|
if (!debug?.layers) return debug;
|
||||||
const layers = { ...debug.layers };
|
const layers = { ...debug.layers };
|
||||||
|
// Potential rasters are never read from this transformed debug object.
|
||||||
|
// renderer.js deterministically derives the four visible heatmaps from the
|
||||||
|
// current viewport fields. Copying every fixed-map potential raster and then
|
||||||
|
// synthesizing four Float32 viewport rasters here allocated both versions only
|
||||||
|
// for them to be ignored. Keep vector/point diagnostics and let the renderer
|
||||||
|
// create its single Uint8 heatmap set.
|
||||||
for (const [key, value] of Object.entries(layers)) {
|
for (const [key, value] of Object.entries(layers)) {
|
||||||
if (ArrayBuffer.isView(value) && value.length === SIZE) layers[key] = copySourceMapViewportField(value, camera, originX, originY, viewWidth, viewHeight);
|
if (ArrayBuffer.isView(value)) delete layers[key];
|
||||||
}
|
|
||||||
// The original transport-debug potential layers are fixed-map arrays. Once the
|
|
||||||
// viewport pans into patched world cells, synthesize equivalent viewport-sized
|
|
||||||
// debug fields from the current world-backed fields so the color overlay moves
|
|
||||||
// with the terrain instead of staying tied to the initial source map.
|
|
||||||
if (viewport) {
|
|
||||||
const n = viewWidth * viewHeight;
|
|
||||||
const make = (fn) => {
|
|
||||||
const out = new Float32Array(n);
|
|
||||||
for (let i = 0; i < n; i++) out[i] = fn(i);
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
const sea = viewport.sea || new Uint8Array(n);
|
|
||||||
const slope = viewport.slope || new Float32Array(n);
|
|
||||||
const plain = viewport.plain || new Float32Array(n);
|
|
||||||
const pop = viewport.populationDensity || viewport.settlementScore || new Float32Array(n);
|
|
||||||
const road = viewport.roadInfluence || new Float32Array(n);
|
|
||||||
const rail = viewport.railInfluence2 || viewport.stationInfluence || new Float32Array(n);
|
|
||||||
layers.expresswayPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.72 + pop[i] * 0.42 + plain[i] * 0.22 - slope[i] * 0.52)));
|
|
||||||
layers.nationalRoadPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.58 + pop[i] * 0.55 + plain[i] * 0.18 - slope[i] * 0.38)));
|
|
||||||
layers.railPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, rail[i] * 0.72 + pop[i] * 0.38 + plain[i] * 0.26 - slope[i] * 0.72)));
|
|
||||||
layers.slopeSeaPenalty = make((i) => sea[i] ? 1 : Math.max(0, Math.min(1, slope[i] * 1.35)));
|
|
||||||
}
|
}
|
||||||
if (Array.isArray(layers.components)) {
|
if (Array.isArray(layers.components)) {
|
||||||
layers.components = layers.components.map((component) => ({
|
layers.components = layers.components.map((component) => ({
|
||||||
|
|
@ -191,9 +197,13 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA
|
||||||
x: Math.round(camera?.x || 0),
|
x: Math.round(camera?.x || 0),
|
||||||
y: Math.round(camera?.y || 0),
|
y: Math.round(camera?.y || 0),
|
||||||
};
|
};
|
||||||
|
const cacheKey = viewportCacheKey(world, normalizedCamera, viewWidth, viewHeight, !!options.light);
|
||||||
|
const cached = viewportCache.get(world)?.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
const viewport = buildEmptyViewportFromSource(sourceMap, world, normalizedCamera, viewWidth, viewHeight);
|
const viewport = buildEmptyViewportFromSource(sourceMap, world, normalizedCamera, viewWidth, viewHeight);
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(world?.fields || {})) {
|
for (const [name, value] of Object.entries(world?.fields || {})) {
|
||||||
|
if (options.includeAllFields !== true && !VIEWPORT_RASTER_FIELDS.has(name)) continue;
|
||||||
viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight);
|
viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,7 +214,7 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA
|
||||||
viewport.adminDebug = null;
|
viewport.adminDebug = null;
|
||||||
viewport.transportDebug = null;
|
viewport.transportDebug = null;
|
||||||
viewport.patchSeamDiagnostics = null;
|
viewport.patchSeamDiagnostics = null;
|
||||||
return viewport;
|
return rememberViewport(world, cacheKey, viewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
const originX = world?.originX || 0;
|
const originX = world?.originX || 0;
|
||||||
|
|
@ -223,7 +233,7 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA
|
||||||
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport, viewWidth, viewHeight);
|
if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewWidth, viewHeight);
|
||||||
if (sourceMap.patchSeamDiagnostics) {
|
if (sourceMap.patchSeamDiagnostics) {
|
||||||
viewport.patchSeamDiagnostics = {
|
viewport.patchSeamDiagnostics = {
|
||||||
...sourceMap.patchSeamDiagnostics,
|
...sourceMap.patchSeamDiagnostics,
|
||||||
|
|
@ -240,5 +250,5 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return viewport;
|
return rememberViewport(world, cacheKey, viewport);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
tests/additional-generation-coverage-worker.mjs
Normal file
81
tests/additional-generation-coverage-worker.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { generateMap } from "../src/mapPipeline.js";
|
||||||
|
import { createWorldMap } from "../src/worldMap.js";
|
||||||
|
|
||||||
|
const worldSeed = 8;
|
||||||
|
const initial = generateMap(worldSeed);
|
||||||
|
const baseline = createWorldMap(initial);
|
||||||
|
const rect = { x0: 20, y0: 120, x1: 180, y1: 230 };
|
||||||
|
const terrainType = initial.terrainTemplate?.terrainType || initial.terrainDebug?.terrainType || "auto";
|
||||||
|
|
||||||
|
function runWorkerCase(patchMode, id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const worker = new Worker(new URL("./browser-worker-node-shim.mjs", import.meta.url), { type: "module" });
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try { await worker.terminate(); } catch {}
|
||||||
|
reject(new Error(`${patchMode} coverage regression timed out`));
|
||||||
|
}, 60_000);
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const seed = 123;
|
||||||
|
worker.on("error", reject);
|
||||||
|
worker.on("message", async (message) => {
|
||||||
|
if (message.id !== id || message.type === "progress") return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { await worker.terminate(); } catch {}
|
||||||
|
resolve({ message, elapsedMs: Math.round(performance.now() - startedAt) });
|
||||||
|
});
|
||||||
|
worker.postMessage({
|
||||||
|
id,
|
||||||
|
world: structuredClone(baseline),
|
||||||
|
rect,
|
||||||
|
options: {
|
||||||
|
patchMode,
|
||||||
|
terrainType,
|
||||||
|
variant: 0,
|
||||||
|
seed,
|
||||||
|
maxQualityRetries: 0,
|
||||||
|
qualityTerrainAttempts: 1,
|
||||||
|
acceptBestAvailableQuality: false,
|
||||||
|
includeSeamVisualization: false,
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
searchId: `coverage-${patchMode}`,
|
||||||
|
operationId: `coverage-${patchMode}`,
|
||||||
|
committedRevision: 1,
|
||||||
|
workerEpoch: 1,
|
||||||
|
executionAttempt: 1,
|
||||||
|
totalCandidateCount: 1,
|
||||||
|
candidatePlan: [{ candidateId: `coverage-${patchMode}:0`, candidateOrdinal: 1, variant: 0, seed }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, patchMode] of ["auto", "expansion"].entries()) {
|
||||||
|
const { message, elapsedMs } = await runWorkerCase(patchMode, index + 1);
|
||||||
|
const result = message.result || {};
|
||||||
|
const ok = message.ok === true
|
||||||
|
&& result.ok === true
|
||||||
|
&& result.searchStatus === "succeeded"
|
||||||
|
&& result.patchMode === "expansion"
|
||||||
|
&& result.tiledExpansion === true
|
||||||
|
&& Number(result.tileCount) === 1
|
||||||
|
&& Number(result.candidateUnmappedActiveCells || 0) === 0
|
||||||
|
&& result.seamDiagnostics?.hardPass === true;
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
patchMode,
|
||||||
|
ok,
|
||||||
|
elapsedMs,
|
||||||
|
workerOk: message.ok === true,
|
||||||
|
searchStatus: result.searchStatus || null,
|
||||||
|
resultCode: result.code || null,
|
||||||
|
resolvedPatchMode: result.patchMode || null,
|
||||||
|
tiledExpansion: result.tiledExpansion === true,
|
||||||
|
tileCount: Number(result.tileCount || 0),
|
||||||
|
candidateUnmappedActiveCells: Number(result.candidateUnmappedActiveCells || 0),
|
||||||
|
seamPass: result.seamDiagnostics?.hardPass === true,
|
||||||
|
reason: result.reason || message.error || null,
|
||||||
|
}, null, 2));
|
||||||
|
if (!ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
19
tests/additional-generation-e2e.html
Normal file
19
tests/additional-generation-e2e.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Additional generation browser E2E</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 24px; background: #111827; color: #e5e7eb; font: 14px/1.5 ui-monospace, monospace; }
|
||||||
|
pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 18px; border-radius: 10px; background: #030712; }
|
||||||
|
[data-status="pass"] pre { border: 2px solid #22c55e; }
|
||||||
|
[data-status="fail"] pre { border: 2px solid #ef4444; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Additional generation browser E2E</h1>
|
||||||
|
<pre id="result">RUNNING</pre>
|
||||||
|
<script type="module" src="./additional-generation-e2e.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
231
tests/additional-generation-e2e.js
Normal file
231
tests/additional-generation-e2e.js
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { createWorldMap } from "../src/worldMap.js";
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const resultEl = document.getElementById("result");
|
||||||
|
const seed = (Number(params.get("seed")) || 114514) >>> 0;
|
||||||
|
const startVariant = (Number(params.get("variant")) || 0) >>> 0;
|
||||||
|
const candidateLimit = Math.max(1, Math.min(3, Number(params.get("candidates")) || 2));
|
||||||
|
const selectionWidth = Math.max(48, Math.floor(Number(params.get("width")) || 60));
|
||||||
|
const selectionHeight = Math.max(48, Math.floor(Number(params.get("height")) || 60));
|
||||||
|
const patchBudgetMs = Math.max(1000, Number(params.get("budgetMs")) || 60000);
|
||||||
|
|
||||||
|
function deriveSeed(worldSeed, terrainType, variant) {
|
||||||
|
let h = (worldSeed >>> 0) ^ 0x9e3779b9;
|
||||||
|
h = Math.imul(h ^ (variant >>> 0), 668265263) >>> 0;
|
||||||
|
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForGeneration(worker) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = 1;
|
||||||
|
const progress = [];
|
||||||
|
const onMessage = (event) => {
|
||||||
|
if (event.data?.id !== id) return;
|
||||||
|
if (event.data.type === "progress") {
|
||||||
|
progress.push({ at: performance.now(), ...(event.data.progress || event.data.event || {}) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
worker.removeEventListener("message", onMessage);
|
||||||
|
if (event.data.ok) resolve({ map: event.data.map, progress });
|
||||||
|
else reject(new Error(event.data.error || "Initial generation failed"));
|
||||||
|
};
|
||||||
|
worker.addEventListener("message", onMessage);
|
||||||
|
worker.addEventListener("error", (event) => reject(new Error(event.message || "Initial generation Worker crashed")), { once: true });
|
||||||
|
worker.postMessage({ id, seed, options: { terrainType: params.get("terrain") || "auto" } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForApplyAck(worker, patch) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const applyToken = patch.result?.applyToken;
|
||||||
|
if (!applyToken) {
|
||||||
|
reject(new Error("Accepted patch did not provide a transactional Apply token."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ackId = `e2e-apply-${Date.now()}`;
|
||||||
|
const timer = setTimeout(() => reject(new Error("Transactional Apply ACK timed out.")), 30000);
|
||||||
|
const onMessage = (event) => {
|
||||||
|
const data = event.data || {};
|
||||||
|
if (data.type !== "patch-apply-ack-result" || data.ackId !== ackId) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
worker.removeEventListener("message", onMessage);
|
||||||
|
if (data.ok) resolve(data);
|
||||||
|
else reject(new Error(data.error || "Transactional Apply ACK failed."));
|
||||||
|
};
|
||||||
|
worker.addEventListener("message", onMessage);
|
||||||
|
worker.postMessage({
|
||||||
|
type: "patch-apply-ack",
|
||||||
|
ackId,
|
||||||
|
applyToken,
|
||||||
|
baseCommittedRevision: 1,
|
||||||
|
committedRevision: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function heapSnapshot(label) {
|
||||||
|
return performance.memory ? {
|
||||||
|
label,
|
||||||
|
usedJSHeapSize: performance.memory.usedJSHeapSize,
|
||||||
|
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
||||||
|
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForPatch(worker, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const progress = [];
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const onMessage = (event) => {
|
||||||
|
if (event.data?.id !== message.id) return;
|
||||||
|
if (event.data.type === "progress") {
|
||||||
|
progress.push({ at: performance.now(), ...event.data.progress });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
worker.removeEventListener("message", onMessage);
|
||||||
|
if (event.data.ok) resolve({ ...event.data, progress, wallMs: performance.now() - startedAt });
|
||||||
|
else reject(new Error(event.data.error || "Patch Worker failed"));
|
||||||
|
};
|
||||||
|
worker.addEventListener("message", onMessage);
|
||||||
|
worker.addEventListener("messageerror", () => reject(new Error("Patch result could not be deserialized")), { once: true });
|
||||||
|
worker.addEventListener("error", (event) => reject(new Error(event.message || "Patch Worker crashed")), { once: true });
|
||||||
|
worker.postMessage(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxProgressGap(progress, start, end) {
|
||||||
|
const times = [start, ...(progress || []).map((entry) => entry.at), end];
|
||||||
|
let max = 0;
|
||||||
|
for (let index = 1; index < times.length; index++) max = Math.max(max, times[index] - times[index - 1]);
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const heap = [heapSnapshot("start")].filter(Boolean);
|
||||||
|
const initialWorker = new Worker(new URL("../src/generationWorker.js", import.meta.url), { type: "module" });
|
||||||
|
const initialStartedAt = performance.now();
|
||||||
|
const initial = await waitForGeneration(initialWorker);
|
||||||
|
const afterInitialHeap = heapSnapshot("after-initial");
|
||||||
|
if (afterInitialHeap) heap.push(afterInitialHeap);
|
||||||
|
const initialEndedAt = performance.now();
|
||||||
|
initialWorker.terminate();
|
||||||
|
const world = createWorldMap(initial.map);
|
||||||
|
const rect = {
|
||||||
|
x0: world.originX + Math.max(0, Math.floor((258 - selectionWidth) / 2)),
|
||||||
|
y0: world.originY + Math.max(0, Math.floor((183 - selectionHeight) / 2)),
|
||||||
|
x1: world.originX + Math.max(0, Math.floor((258 - selectionWidth) / 2)) + selectionWidth,
|
||||||
|
y1: world.originY + Math.max(0, Math.floor((183 - selectionHeight) / 2)) + selectionHeight,
|
||||||
|
};
|
||||||
|
if (params.get("shape") === "lasso") {
|
||||||
|
const insetX = Math.max(4, Math.floor(selectionWidth * 0.16));
|
||||||
|
const insetY = Math.max(4, Math.floor(selectionHeight * 0.16));
|
||||||
|
rect.kind = "lasso";
|
||||||
|
rect.polygon = [
|
||||||
|
{ x: rect.x0 + insetX, y: rect.y0 },
|
||||||
|
{ x: rect.x1 - 1, y: rect.y0 + insetY },
|
||||||
|
{ x: rect.x1 - insetX, y: rect.y1 - 1 },
|
||||||
|
{ x: rect.x0, y: rect.y1 - insetY },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const terrainType = params.get("patchTerrain") || initial.map.terrainTemplate?.terrainType || "auto";
|
||||||
|
const candidatePlan = Array.from({ length: candidateLimit }, (_, index) => {
|
||||||
|
const variant = (startVariant + index) >>> 0;
|
||||||
|
return { candidateId: `e2e:${variant}`, candidateOrdinal: index + 1, variant, seed: deriveSeed(world.seed, terrainType, variant) };
|
||||||
|
});
|
||||||
|
const patchWorker = new Worker(new URL("../src/mapPatchWorker.js", import.meta.url), { type: "module" });
|
||||||
|
const patchStartedAt = performance.now();
|
||||||
|
const patch = await waitForPatch(patchWorker, {
|
||||||
|
id: 2,
|
||||||
|
world,
|
||||||
|
rect,
|
||||||
|
options: {
|
||||||
|
patchMode: params.get("mode") || "regeneration",
|
||||||
|
terrainType,
|
||||||
|
variant: startVariant,
|
||||||
|
seed: candidatePlan[0].seed,
|
||||||
|
maxQualityRetries: 0,
|
||||||
|
qualityTerrainAttempts: 1,
|
||||||
|
acceptBestAvailableQuality: false,
|
||||||
|
includeSeamVisualization: false,
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
searchId: "browser-e2e",
|
||||||
|
operationId: "browser-e2e",
|
||||||
|
committedRevision: 1,
|
||||||
|
workerEpoch: 1,
|
||||||
|
candidatePlan,
|
||||||
|
totalCandidateCount: candidateLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const patchEndedAt = performance.now();
|
||||||
|
const afterPatchHeap = heapSnapshot("after-patch");
|
||||||
|
if (afterPatchHeap) heap.push(afterPatchHeap);
|
||||||
|
if (patch.result?.ok !== true) {
|
||||||
|
throw new Error(`No accepted preview was produced (${patch.result?.code || patch.result?.searchStatus || "unknown rejection"}).`);
|
||||||
|
}
|
||||||
|
const applyAck = await waitForApplyAck(patchWorker, patch);
|
||||||
|
const afterApplyHeap = heapSnapshot("after-apply-ack");
|
||||||
|
if (afterApplyHeap) heap.push(afterApplyHeap);
|
||||||
|
patchWorker.terminate();
|
||||||
|
const attempts = patch.result?.searchAttempts || [];
|
||||||
|
const boundedEvents = patch.progress.filter((entry) => entry.boundedWork === true);
|
||||||
|
const invalidBoundedEvents = boundedEvents.filter((entry) => !Number.isFinite(entry.completed)
|
||||||
|
|| !Number.isFinite(entry.total) || entry.completed < 0 || entry.total < 0 || entry.completed > entry.total);
|
||||||
|
const assertions = {
|
||||||
|
workerTransportSucceeded: patch.ok === true,
|
||||||
|
candidateAuditPresent: attempts.length > 0,
|
||||||
|
previewPublished: patch.result?.ok === true && patch.result?.searchStatus === "succeeded",
|
||||||
|
boundedAttempts: attempts.length <= candidateLimit,
|
||||||
|
fullPipelineTimingsPresent: attempts.every((attempt) => (attempt.patchTimings || []).some((entry) => entry.key === "candidate" || entry.key === "tiled-total" || entry.key === "tiled-regeneration-total")),
|
||||||
|
noBestAvailableAcceptance: attempts.every((attempt) => attempt.candidateQuality?.acceptedAsBestAvailable !== true),
|
||||||
|
boundedProgressValid: boundedEvents.length > 0 && invalidBoundedEvents.length === 0,
|
||||||
|
applyAckHashMatches: applyAck.mirrorHash === patch.result?.acceptedWorldHash,
|
||||||
|
patchBudgetMet: patch.wallMs < patchBudgetMs,
|
||||||
|
};
|
||||||
|
const report = {
|
||||||
|
status: Object.values(assertions).every(Boolean) ? "pass" : "fail",
|
||||||
|
environment: {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
hardwareConcurrency: navigator.hardwareConcurrency || null,
|
||||||
|
deviceMemoryGiB: navigator.deviceMemory || null,
|
||||||
|
crossOriginIsolated,
|
||||||
|
},
|
||||||
|
workload: {
|
||||||
|
seed, startVariant, candidateLimit, selection: rect, selectionWidth, selectionHeight,
|
||||||
|
selectionShape: rect.kind || "rect", terrainType, patchMode: params.get("mode") || "regeneration",
|
||||||
|
plannedTileUpperBound: Math.ceil(selectionWidth / Math.floor(258 / 1.72)) * Math.ceil(selectionHeight / Math.floor(183 / 1.72)),
|
||||||
|
},
|
||||||
|
timing: {
|
||||||
|
initialWallMs: initialEndedAt - initialStartedAt,
|
||||||
|
patchWallMs: patch.wallMs,
|
||||||
|
patchBudgetMs,
|
||||||
|
initialMaxProgressGapMs: maxProgressGap(initial.progress, initialStartedAt, initialEndedAt),
|
||||||
|
patchMaxProgressGapMs: maxProgressGap(patch.progress, patchStartedAt, patchEndedAt),
|
||||||
|
},
|
||||||
|
memory: heap.length ? {
|
||||||
|
snapshots: heap,
|
||||||
|
peakUsedJSHeapSize: Math.max(...heap.map((entry) => entry.usedJSHeapSize)),
|
||||||
|
} : null,
|
||||||
|
assertions,
|
||||||
|
result: {
|
||||||
|
ok: patch.result?.ok === true,
|
||||||
|
code: patch.result?.code || null,
|
||||||
|
searchStatus: patch.result?.searchStatus || null,
|
||||||
|
actualVariant: patch.result?.actualVariant ?? null,
|
||||||
|
nextVariant: patch.result?.nextVariant ?? null,
|
||||||
|
attempts,
|
||||||
|
acceptedWorldHash: patch.result?.acceptedWorldHash || null,
|
||||||
|
applyAck,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
document.documentElement.dataset.status = report.status;
|
||||||
|
resultEl.textContent = JSON.stringify(report, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} catch (error) {
|
||||||
|
document.documentElement.dataset.status = "fail";
|
||||||
|
resultEl.textContent = JSON.stringify({ status: "fail", infrastructureError: error?.message || String(error), stack: error?.stack || null }, null, 2);
|
||||||
|
}
|
||||||
134
tests/additional-generation-max-worker.mjs
Normal file
134
tests/additional-generation-max-worker.mjs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { generateMap } from "../src/mapPipeline.js";
|
||||||
|
import { createWorldMap } from "../src/worldMap.js";
|
||||||
|
|
||||||
|
const budgetMs = Math.max(1, Number(process.env.PATCH_MAX_WORKER_BUDGET_MS) || 60_000);
|
||||||
|
const timeoutMs = Math.max(90_000, budgetMs + 30_000);
|
||||||
|
const worldSeed = Number(process.env.PATCH_TEST_WORLD_SEED ?? 114514) >>> 0;
|
||||||
|
const candidateVariant = Number(process.env.PATCH_TEST_VARIANT ?? 0) >>> 0;
|
||||||
|
|
||||||
|
function deriveSeed(worldSeed, terrainType, variant) {
|
||||||
|
let h = (worldSeed >>> 0) ^ 0x9e3779b9;
|
||||||
|
h = Math.imul(h ^ (variant >>> 0), 668265263) >>> 0;
|
||||||
|
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialStartedAt = performance.now();
|
||||||
|
const initial = generateMap(worldSeed);
|
||||||
|
const initialGenerationMs = Math.round(performance.now() - initialStartedAt);
|
||||||
|
const world = createWorldMap(initial);
|
||||||
|
const width = 470;
|
||||||
|
const height = 333;
|
||||||
|
const x0 = Math.max(0, Math.min(world.width - width, world.originX + 238));
|
||||||
|
const y0 = Math.max(0, Math.min(world.height - height, world.originY));
|
||||||
|
const rect = { x0, y0, x1: x0 + width, y1: y0 + height, kind: "lasso" };
|
||||||
|
const insetX = Math.max(4, Math.floor(width * 0.16));
|
||||||
|
const insetY = Math.max(4, Math.floor(height * 0.16));
|
||||||
|
rect.polygon = [
|
||||||
|
{ x: rect.x0 + insetX, y: rect.y0 },
|
||||||
|
{ x: rect.x1 - 1, y: rect.y0 + insetY },
|
||||||
|
{ x: rect.x1 - insetX, y: rect.y1 - 1 },
|
||||||
|
{ x: rect.x0, y: rect.y1 - insetY },
|
||||||
|
];
|
||||||
|
|
||||||
|
const terrainType = initial.terrainTemplate?.terrainType || initial.terrainDebug?.terrainType || "auto";
|
||||||
|
const seed = deriveSeed(world.seed, terrainType, candidateVariant);
|
||||||
|
const candidatePlan = [{ candidateId: `max-worker:${candidateVariant}`, candidateOrdinal: 1, variant: candidateVariant, seed }];
|
||||||
|
const worker = new Worker(new URL("./browser-worker-node-shim.mjs", import.meta.url), { type: "module" });
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let progressEvents = 0;
|
||||||
|
let maxRssBytes = process.memoryUsage().rss;
|
||||||
|
let maxHeapBytes = process.memoryUsage().heapUsed;
|
||||||
|
let lastLabel = "";
|
||||||
|
const memoryTimer = setInterval(() => {
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
maxRssBytes = Math.max(maxRssBytes, memory.rss);
|
||||||
|
maxHeapBytes = Math.max(maxHeapBytes, memory.heapUsed);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
async function finish(exitCode, payload) {
|
||||||
|
clearInterval(memoryTimer);
|
||||||
|
clearTimeout(timeoutTimer);
|
||||||
|
try { await worker.terminate(); } catch {}
|
||||||
|
const elapsedMs = Math.round(performance.now() - startedAt);
|
||||||
|
const finalMerge = payload?.candidateQuality?.finalMerge || null;
|
||||||
|
const qualityPass = payload?.candidateQuality?.hardPass === true && finalMerge?.hardPass === true;
|
||||||
|
const seamPass = payload?.seamDiagnostics?.hardPass === true;
|
||||||
|
const withinBudget = elapsedMs < budgetMs;
|
||||||
|
const ok = payload?.ok === true && payload?.searchStatus === "succeeded" && qualityPass && seamPass && withinBudget;
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok,
|
||||||
|
worldSeed,
|
||||||
|
candidateVariant,
|
||||||
|
workerResultOk: payload?.ok === true,
|
||||||
|
initialGenerationMs,
|
||||||
|
elapsedMs,
|
||||||
|
budgetMs,
|
||||||
|
withinBudget,
|
||||||
|
maxRssBytes,
|
||||||
|
maxHeapBytes,
|
||||||
|
progressEvents,
|
||||||
|
lastLabel,
|
||||||
|
searchStatus: payload?.searchStatus || null,
|
||||||
|
terminalCode: payload?.terminalCode || payload?.code || null,
|
||||||
|
qualityPass,
|
||||||
|
seamPass,
|
||||||
|
finalMerge,
|
||||||
|
humanTerrainReconciliation: payload?.seamDiagnostics?.humanTerrainReconciliation || null,
|
||||||
|
error: payload?.error || null,
|
||||||
|
reason: payload?.reason || null,
|
||||||
|
}, null, 2));
|
||||||
|
process.exit(ok ? 0 : exitCode || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutTimer = setTimeout(() => finish(2, { ok: false, error: `Timed out after ${timeoutMs} ms.` }), timeoutMs);
|
||||||
|
worker.on("message", (message) => {
|
||||||
|
if (message.id !== 1) return;
|
||||||
|
if (message.type === "progress") {
|
||||||
|
progressEvents++;
|
||||||
|
const progress = message.progress || {};
|
||||||
|
const label = String(progress.label || "");
|
||||||
|
if (label && label !== lastLabel && (/Large expansion tile|finalization complete/i.test(label))) {
|
||||||
|
lastLabel = label;
|
||||||
|
console.error(`[max-worker] ${Math.round(performance.now() - startedAt)} ms: ${label}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish(message.ok ? 1 : 3, {
|
||||||
|
ok: message.ok,
|
||||||
|
code: message.code,
|
||||||
|
error: message.error,
|
||||||
|
searchStatus: message.result?.searchStatus,
|
||||||
|
terminalCode: message.result?.code,
|
||||||
|
reason: message.result?.reason,
|
||||||
|
candidateQuality: message.result?.candidateQuality,
|
||||||
|
seamDiagnostics: message.result?.seamDiagnostics,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
worker.on("error", (error) => finish(3, { ok: false, error: error?.stack || String(error) }));
|
||||||
|
worker.postMessage({
|
||||||
|
id: 1,
|
||||||
|
world,
|
||||||
|
rect,
|
||||||
|
options: {
|
||||||
|
patchMode: "expansion",
|
||||||
|
terrainType,
|
||||||
|
variant: candidateVariant,
|
||||||
|
seed,
|
||||||
|
maxQualityRetries: 0,
|
||||||
|
qualityTerrainAttempts: 1,
|
||||||
|
acceptBestAvailableQuality: false,
|
||||||
|
includeSeamVisualization: false,
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
searchId: "max-worker-benchmark",
|
||||||
|
operationId: "max-worker-benchmark",
|
||||||
|
committedRevision: 1,
|
||||||
|
workerEpoch: 1,
|
||||||
|
executionAttempt: 1,
|
||||||
|
totalCandidateCount: 1,
|
||||||
|
candidatePlan,
|
||||||
|
},
|
||||||
|
});
|
||||||
942
tests/additional-generation-unit.mjs
Normal file
942
tests/additional-generation-unit.mjs
Normal file
|
|
@ -0,0 +1,942 @@
|
||||||
|
import { MAP_H, MAP_W, fbm, valueNoise } from "../src/mapUtils.js";
|
||||||
|
import { createExactNoiseMemo } from "../src/mapTerrain.js";
|
||||||
|
import {
|
||||||
|
capturePatchTransactionSnapshot,
|
||||||
|
buildLargeExpansionTiles,
|
||||||
|
buildPatchRects,
|
||||||
|
buildRawPatchCandidateRequest,
|
||||||
|
buildTiledFinalQualityBasis,
|
||||||
|
captureStrictMetadataSnapshot,
|
||||||
|
captureStrictSelectionFieldSnapshot,
|
||||||
|
normalizePatchPrefectureCapitals,
|
||||||
|
preparePatchTransactionFields,
|
||||||
|
reconcileGeneratedHumanPointsWithFinalTerrain,
|
||||||
|
restorePatchTransactionSnapshot,
|
||||||
|
refreshPatchPrefectureMetadata,
|
||||||
|
synchronizePatchAdministrativeMetadata,
|
||||||
|
synchronizePatchMunicipalityField,
|
||||||
|
} from "../src/mapPatch.js";
|
||||||
|
import {
|
||||||
|
applyCommittedMirrorDelta,
|
||||||
|
buildCommittedMirrorDelta,
|
||||||
|
buildCommittedMirrorDeltaFromTransaction,
|
||||||
|
buildMainThreadTransferDelta,
|
||||||
|
hashCommittedWorld,
|
||||||
|
runPatchCandidateSearch,
|
||||||
|
runPatchCandidateSearchAsync,
|
||||||
|
scheduleRawCandidateSequence,
|
||||||
|
shutdownRawCandidateWorkers,
|
||||||
|
} from "../src/mapPatchWorker.js";
|
||||||
|
import { hashCommittedWorldAsync, materializeCommittedWorldDelta, materializeCommittedWorldDeltaCooperative } from "../src/committedWorldDelta.js";
|
||||||
|
import { getViewportMap } from "../src/worldViewport.js";
|
||||||
|
import { createWorldMap } from "../src/worldMap.js";
|
||||||
|
import { compactRawPatchCandidate, summarizeRawPatchCandidate } from "../src/rawPatchCandidate.js";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
console.log(`OK: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPointInPolygon(px, py, polygon) {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const xi = polygon[i].x + 0.5;
|
||||||
|
const yi = polygon[i].y + 0.5;
|
||||||
|
const xj = polygon[j].x + 0.5;
|
||||||
|
const yj = polygon[j].y + 0.5;
|
||||||
|
const denomRaw = yj - yi;
|
||||||
|
const denom = Math.abs(denomRaw) < 1e-6 ? (denomRaw < 0 ? -1e-6 : 1e-6) : denomRaw;
|
||||||
|
if (((yi > py) !== (yj > py)) && px < ((xj - xi) * (py - yi)) / denom + xi) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = { fields: { marker: new Uint8Array([1]) } };
|
||||||
|
const calls = [];
|
||||||
|
const progress = [];
|
||||||
|
const search = runPatchCandidateSearch({
|
||||||
|
id: 1,
|
||||||
|
world: base,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: {
|
||||||
|
searchId: "unit-search",
|
||||||
|
committedRevision: 1,
|
||||||
|
workerEpoch: 1,
|
||||||
|
candidatePlan: [{ variant: 5, seed: 105 }, { variant: 6, seed: 106 }],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
cloneWorld: structuredClone,
|
||||||
|
onProgress: (message) => progress.push(message.progress),
|
||||||
|
generateCandidate: (world, rect, options) => {
|
||||||
|
calls.push({ variant: options.variant, baseline: world.fields.marker[0] });
|
||||||
|
world.fields.marker[0] = options.variant;
|
||||||
|
return options.variant === 5
|
||||||
|
? { ok: false, code: "patch-quality-gate-failed", reason: "forced reject" }
|
||||||
|
: { ok: true, variant: options.variant, seed: options.seed, seamDiagnostics: { hardPass: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(search.result?.ok && search.result.actualVariant === 6, "content rejection advances to the next complete candidate");
|
||||||
|
assert(calls.length === 2 && calls.every((call) => call.baseline === 1), "candidate worlds start from one immutable baseline");
|
||||||
|
assert(progress.filter((event) => event.boundedWork).every((event) => event.completed <= event.total), "bounded progress remains inside its finite work total");
|
||||||
|
|
||||||
|
const repeatedPipelineProgress = [];
|
||||||
|
const repeatedPipelineSearch = runPatchCandidateSearch({
|
||||||
|
id: 101,
|
||||||
|
world: base,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: {
|
||||||
|
searchId: "unit-repeated-pipeline-progress",
|
||||||
|
candidatePlan: [{ variant: 11, seed: 111 }, { variant: 12, seed: 112 }],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
cloneWorld: structuredClone,
|
||||||
|
onProgress: (message) => repeatedPipelineProgress.push(message.progress),
|
||||||
|
generateCandidate: (world, rect, options) => {
|
||||||
|
// Every complete candidate legitimately reuses the same producer-local
|
||||||
|
// work-unit name. The search controller must scope it by candidate rather
|
||||||
|
// than treating candidate 2 as a reset of candidate 1.
|
||||||
|
options.onProgress({ phase: "terrain", key: "terrain:base", workUnitId: "terrain-production", completed: 0, total: 10 });
|
||||||
|
options.onProgress({ phase: "terrain", key: "terrain:base", workUnitId: "terrain-production", completed: 10, total: 10 });
|
||||||
|
return options.variant === 11
|
||||||
|
? { ok: false, code: "patch-quality-gate-failed", reason: "forced first-candidate reject" }
|
||||||
|
: { ok: true, seamDiagnostics: { hardPass: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const repeatedTerrainUnits = repeatedPipelineProgress
|
||||||
|
.filter((event) => event.key === "terrain:base")
|
||||||
|
.map((event) => event.workUnitId);
|
||||||
|
assert(repeatedPipelineSearch.ok && repeatedPipelineSearch.result?.actualVariant === 12
|
||||||
|
&& repeatedTerrainUnits.some((id) => id === "candidate-1/terrain-production")
|
||||||
|
&& repeatedTerrainUnits.some((id) => id === "candidate-2/terrain-production"),
|
||||||
|
"rejected candidates may restart the same producer-local bounded work under a candidate-scoped workUnitId");
|
||||||
|
|
||||||
|
|
||||||
|
const sequenceForwardSentinel = () => ({ promises: [], done: Promise.resolve(), release() {}, cancel() {} });
|
||||||
|
let forwardedSequence = null;
|
||||||
|
const asyncForwardSearch = await runPatchCandidateSearchAsync({
|
||||||
|
id: 102,
|
||||||
|
world: base,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: { searchId: "unit-sequence-forward", candidatePlan: [{ variant: 1, seed: 1 }] },
|
||||||
|
}, {
|
||||||
|
cloneWorld: structuredClone,
|
||||||
|
precomputeRawCandidateSequence: sequenceForwardSentinel,
|
||||||
|
generateCandidate: async (world, rect, options) => {
|
||||||
|
forwardedSequence = options._precomputeRawCandidateSequence;
|
||||||
|
return { ok: true, seamDiagnostics: { hardPass: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(asyncForwardSearch.ok && asyncForwardSearch.result?.ok && forwardedSequence === sequenceForwardSentinel,
|
||||||
|
"async candidate search forwards the continuous raw-tile sequence provider into production generation");
|
||||||
|
|
||||||
|
const invalidProgress = runPatchCandidateSearch({
|
||||||
|
id: 2,
|
||||||
|
world: base,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: { searchId: "unit-invalid-progress", candidatePlan: [{ variant: 1, seed: 1 }] },
|
||||||
|
}, {
|
||||||
|
cloneWorld: structuredClone,
|
||||||
|
generateCandidate: (world, rect, options) => {
|
||||||
|
options.onProgress({ key: "runaway", completed: 4, total: 3 });
|
||||||
|
return { ok: true, seamDiagnostics: { hardPass: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(!invalidProgress.ok && invalidProgress.code === "worker-progress-invariant", "runaway bounded work is an invariant failure");
|
||||||
|
|
||||||
|
const distinctRouteUnits = runPatchCandidateSearch({
|
||||||
|
id: 3,
|
||||||
|
world: base,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: { searchId: "unit-distinct-route-progress", candidatePlan: [{ variant: 1, seed: 1 }] },
|
||||||
|
}, {
|
||||||
|
cloneWorld: structuredClone,
|
||||||
|
generateCandidate: (world, rect, options) => {
|
||||||
|
options.onProgress({ phase: "transport-routing", key: "route:corridor", workUnitId: "corridor-a", completed: 2048, total: 8800 });
|
||||||
|
options.onProgress({ phase: "transport-routing", key: "route:corridor", workUnitId: "corridor-b", completed: 2048, total: 6094 });
|
||||||
|
options.onProgress({ phase: "transport-routing-detail", key: "route:corridor", workUnitId: "corridor-b", completed: 4096, total: 6094 });
|
||||||
|
return { ok: true, seamDiagnostics: { hardPass: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(distinctRouteUnits.ok && distinctRouteUnits.result?.ok, "independent route work units may share a display key and use different finite totals");
|
||||||
|
|
||||||
|
const changedRouteTotal = runPatchCandidateSearch({
|
||||||
|
id: 4,
|
||||||
|
world: base,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: { searchId: "unit-changed-route-total", candidatePlan: [{ variant: 1, seed: 1 }] },
|
||||||
|
}, {
|
||||||
|
cloneWorld: structuredClone,
|
||||||
|
generateCandidate: (world, rect, options) => {
|
||||||
|
options.onProgress({ phase: "transport-routing", key: "route:corridor", workUnitId: "corridor-one", completed: 1024, total: 8800 });
|
||||||
|
options.onProgress({ phase: "transport-routing-detail", key: "route:corridor", workUnitId: "corridor-one", completed: 2048, total: 6094 });
|
||||||
|
return { ok: true, seamDiagnostics: { hardPass: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(!changedRouteTotal.ok && changedRouteTotal.code === "worker-progress-invariant", "one finite workUnitId cannot change its total across phase changes");
|
||||||
|
|
||||||
|
const missingRouteWorkUnit = runPatchCandidateSearch({
|
||||||
|
id: 5,
|
||||||
|
world: base,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: { searchId: "unit-missing-route-work-unit", candidatePlan: [{ variant: 1, seed: 1 }] },
|
||||||
|
}, {
|
||||||
|
cloneWorld: structuredClone,
|
||||||
|
generateCandidate: (world, rect, options) => {
|
||||||
|
options.onProgress({ phase: "transport-routing", key: "route:corridor", completed: 2048, total: 8800 });
|
||||||
|
return { ok: true, seamDiagnostics: { hardPass: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(!missingRouteWorkUnit.ok && missingRouteWorkUnit.code === "worker-progress-invariant", "bounded progress requires an explicit machine workUnitId");
|
||||||
|
|
||||||
|
const recoveredCandidateProgress = [];
|
||||||
|
const recoveredCandidateSearch = runPatchCandidateSearch({
|
||||||
|
id: 6,
|
||||||
|
world: base,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: {
|
||||||
|
searchId: "unit-recovered-candidate-progress",
|
||||||
|
totalCandidateCount: 3,
|
||||||
|
executionAttempt: 2,
|
||||||
|
candidatePlan: [
|
||||||
|
{ candidateId: "candidate-2", candidateOrdinal: 2, variant: 2, seed: 2 },
|
||||||
|
{ candidateId: "candidate-3", candidateOrdinal: 3, variant: 3, seed: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
cloneWorld: structuredClone,
|
||||||
|
onProgress: (message) => recoveredCandidateProgress.push(message.progress),
|
||||||
|
generateCandidate: (world, rect, options) => options.variant === 2
|
||||||
|
? { ok: false, code: "patch-quality-gate-failed", reason: "forced post-restart reject" }
|
||||||
|
: { ok: true, seamDiagnostics: { hardPass: true } },
|
||||||
|
});
|
||||||
|
const recoveredSearchTicks = recoveredCandidateProgress
|
||||||
|
.filter((event) => event.workUnitId === "candidate-search" && Number.isFinite(event.completed))
|
||||||
|
.map((event) => Number(event.completed));
|
||||||
|
assert(recoveredCandidateSearch.ok && recoveredCandidateSearch.result?.actualVariant === 3
|
||||||
|
&& recoveredSearchTicks.every((value, index) => index === 0 || value >= recoveredSearchTicks[index - 1]),
|
||||||
|
"Worker recovery may resume at candidate ordinal 2 without moving candidate-search progress backward");
|
||||||
|
|
||||||
|
const recoveredAsyncCandidateProgress = [];
|
||||||
|
const recoveredAsyncCandidateSearch = await runPatchCandidateSearchAsync({
|
||||||
|
id: 7,
|
||||||
|
world: base,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: {
|
||||||
|
searchId: "unit-recovered-async-candidate-progress",
|
||||||
|
totalCandidateCount: 3,
|
||||||
|
executionAttempt: 2,
|
||||||
|
candidatePlan: [
|
||||||
|
{ candidateId: "candidate-2", candidateOrdinal: 2, variant: 2, seed: 2 },
|
||||||
|
{ candidateId: "candidate-3", candidateOrdinal: 3, variant: 3, seed: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
cloneWorld: structuredClone,
|
||||||
|
onProgress: (message) => recoveredAsyncCandidateProgress.push(message.progress),
|
||||||
|
generateCandidate: async (world, rect, options) => options.variant === 2
|
||||||
|
? { ok: false, code: "patch-quality-gate-failed", reason: "forced post-restart reject" }
|
||||||
|
: { ok: true, seamDiagnostics: { hardPass: true } },
|
||||||
|
});
|
||||||
|
const recoveredAsyncTicks = recoveredAsyncCandidateProgress
|
||||||
|
.filter((event) => event.workUnitId === "candidate-search" && Number.isFinite(event.completed))
|
||||||
|
.map((event) => Number(event.completed));
|
||||||
|
assert(recoveredAsyncCandidateSearch.ok && recoveredAsyncCandidateSearch.result?.actualVariant === 3
|
||||||
|
&& recoveredAsyncTicks.every((value, index) => index === 0 || value >= recoveredAsyncTicks[index - 1]),
|
||||||
|
"async production recovery preserves global candidate ordinals in bounded search progress");
|
||||||
|
|
||||||
|
|
||||||
|
const asyncHashFixture = {
|
||||||
|
width: 3, height: 2, originX: -4, originY: 7,
|
||||||
|
fields: {
|
||||||
|
a: new Float32Array([0, 1.25, -2.5, 3.75, 0, 9]),
|
||||||
|
b: new Int16Array([7, -8, 9, -10, 11, -12]),
|
||||||
|
},
|
||||||
|
generatedMask: new Uint8Array([0, 1, 1, 0, 1, 0]),
|
||||||
|
sourceMap: { points: [{ x: 1, y: 2, name: "甲" }], tags: new Set(["b", "a"]) },
|
||||||
|
serial: 4,
|
||||||
|
};
|
||||||
|
let asyncHashYields = 0;
|
||||||
|
const exactAsyncHash = await hashCommittedWorldAsync(asyncHashFixture, {
|
||||||
|
yieldEvery: 7,
|
||||||
|
yieldControl: async () => { asyncHashYields++; },
|
||||||
|
});
|
||||||
|
assert(exactAsyncHash === hashCommittedWorld(asyncHashFixture) && asyncHashYields > 0,
|
||||||
|
"cooperative committed-world hash is bit-identical to the synchronous Worker hash while yielding bounded chunks");
|
||||||
|
let abortHash = false;
|
||||||
|
try {
|
||||||
|
let abortAfterYield = false;
|
||||||
|
await hashCommittedWorldAsync(asyncHashFixture, {
|
||||||
|
yieldEvery: 4,
|
||||||
|
yieldControl: async () => { abortAfterYield = true; },
|
||||||
|
shouldAbort: () => abortAfterYield,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
abortHash = error?.name === "AbortError";
|
||||||
|
}
|
||||||
|
assert(abortHash, "cooperative committed-world hash observes cancellation between chunks");
|
||||||
|
|
||||||
|
const deltaBase = {
|
||||||
|
width: 4, height: 2,
|
||||||
|
fields: {
|
||||||
|
field: new Int32Array([1, 2, 3, 4, 5, 6, 7, 8]),
|
||||||
|
stable: new Uint8Array([9, 9, 9, 9, 9, 9, 9, 9]),
|
||||||
|
},
|
||||||
|
generatedMask: new Uint8Array(8), sourceMap: { points: [{ x: 1 }] }, serial: 1,
|
||||||
|
};
|
||||||
|
const deltaTarget = structuredClone(deltaBase);
|
||||||
|
deltaBase.sourceMap.unchangedPaths = [[[0, 0], [1, 1]]];
|
||||||
|
deltaTarget.sourceMap.unchangedPaths = [[[0, 0], [1, 1]]];
|
||||||
|
deltaTarget.fields.field[2] = 30;
|
||||||
|
deltaTarget.fields.field[7] = 80;
|
||||||
|
deltaTarget.generatedMask[6] = 1;
|
||||||
|
deltaTarget.sourceMap.points.push({ x: 2 });
|
||||||
|
deltaTarget.serial = 2;
|
||||||
|
const committedDelta = buildCommittedMirrorDelta(deltaBase, deltaTarget);
|
||||||
|
const deltaApplied = applyCommittedMirrorDelta(structuredClone(deltaBase), committedDelta);
|
||||||
|
assert(JSON.stringify(Array.from(deltaApplied.fields.field)) === JSON.stringify(Array.from(deltaTarget.fields.field))
|
||||||
|
&& JSON.stringify(Array.from(deltaApplied.generatedMask)) === JSON.stringify(Array.from(deltaTarget.generatedMask))
|
||||||
|
&& JSON.stringify(deltaApplied.sourceMap) === JSON.stringify(deltaTarget.sourceMap)
|
||||||
|
&& deltaApplied.serial === 2, "transactional Apply delta exactly reproduces the accepted world");
|
||||||
|
assert(!("sourceMap" in committedDelta)
|
||||||
|
&& !("unchangedPaths" in (committedDelta.sourceMapDelta?.set || {}))
|
||||||
|
&& !("points" in (committedDelta.sourceMapDelta?.set || {}))
|
||||||
|
&& committedDelta.sourceMapDelta?.arraySplices?.points?.start === 1
|
||||||
|
&& committedDelta.sourceMapDelta?.arraySplices?.points?.deleteCount === 0
|
||||||
|
&& committedDelta.sourceMapDelta?.arraySplices?.points?.items?.length === 1
|
||||||
|
&& !("width" in (committedDelta.metaDelta?.set || {})), "Apply delta retains only changed source and metadata keys");
|
||||||
|
const materializeBaseHash = hashCommittedWorld(deltaBase);
|
||||||
|
const materializedDelta = buildCommittedMirrorDelta(deltaBase, deltaTarget);
|
||||||
|
const materializedPreview = materializeCommittedWorldDelta(deltaBase, materializedDelta, { consumeMetadata: true });
|
||||||
|
assert(hashCommittedWorld(materializedPreview) === hashCommittedWorld(deltaTarget)
|
||||||
|
&& hashCommittedWorld(deltaBase) === materializeBaseHash
|
||||||
|
&& materializedPreview.fields.field !== deltaBase.fields.field
|
||||||
|
&& materializedPreview.fields.stable === deltaBase.fields.stable
|
||||||
|
&& materializedPreview.sourceMap.points !== deltaBase.sourceMap.points
|
||||||
|
&& materializedPreview.sourceMap.unchangedPaths === deltaBase.sourceMap.unchangedPaths,
|
||||||
|
"preview materialization clones changed fields/layers only and leaves the committed world immutable");
|
||||||
|
let cooperativeYields = 0;
|
||||||
|
const cooperativePreview = await materializeCommittedWorldDeltaCooperative(deltaBase, buildCommittedMirrorDelta(deltaBase, deltaTarget), {
|
||||||
|
consumeMetadata: true,
|
||||||
|
chunkBytes: 8,
|
||||||
|
yieldControl: async () => { cooperativeYields++; },
|
||||||
|
});
|
||||||
|
assert(hashCommittedWorld(cooperativePreview) === hashCommittedWorld(deltaTarget)
|
||||||
|
&& hashCommittedWorld(deltaBase) === materializeBaseHash
|
||||||
|
&& cooperativeYields > 0,
|
||||||
|
"cooperative preview materialization is exact and yields between bounded raster chunks");
|
||||||
|
const cancellationBase = {
|
||||||
|
width: 4096, height: 1,
|
||||||
|
fields: { field: new Uint8Array(4096) },
|
||||||
|
generatedMask: new Uint8Array(4096),
|
||||||
|
sourceMap: {},
|
||||||
|
};
|
||||||
|
const cancellationTarget = structuredClone(cancellationBase);
|
||||||
|
cancellationTarget.fields.field.fill(7);
|
||||||
|
let cancellationYields = 0;
|
||||||
|
let materializationCancelled = false;
|
||||||
|
try {
|
||||||
|
await materializeCommittedWorldDeltaCooperative(cancellationBase, buildCommittedMirrorDelta(cancellationBase, cancellationTarget), {
|
||||||
|
consumeMetadata: true,
|
||||||
|
chunkBytes: 64,
|
||||||
|
yieldControl: async () => { cancellationYields++; },
|
||||||
|
shouldCancel: () => cancellationYields >= 2,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
materializationCancelled = error?.name === "AbortError";
|
||||||
|
}
|
||||||
|
assert(materializationCancelled && cancellationYields >= 2 && cancellationBase.fields.field[0] === 0,
|
||||||
|
"cooperative preview materialization observes cancellation without mutating the committed world");
|
||||||
|
const transferDelta = buildMainThreadTransferDelta(materializedDelta);
|
||||||
|
const transferApplied = applyCommittedMirrorDelta(structuredClone(deltaBase), transferDelta);
|
||||||
|
assert(transferDelta.sourceMapDelta === materializedDelta.sourceMapDelta
|
||||||
|
&& transferDelta.metaDelta === materializedDelta.metaDelta
|
||||||
|
&& transferDelta.fields !== materializedDelta.fields
|
||||||
|
&& transferDelta.fields.field.rows[0].values.buffer !== materializedDelta.fields.field.rows[0].values.buffer
|
||||||
|
&& transferDelta.generatedMask.rows[0].values.buffer !== materializedDelta.generatedMask.rows[0].values.buffer
|
||||||
|
&& hashCommittedWorld(transferApplied) === hashCommittedWorld(deltaTarget),
|
||||||
|
"main transfer delta copies detachable raster rows only and leaves metadata for one postMessage clone");
|
||||||
|
|
||||||
|
const transportDebugWorld = {
|
||||||
|
width: 2, height: 2, originX: 0, originY: 0, renderRevision: 1,
|
||||||
|
fields: { sea: new Uint8Array(4), slope: new Float32Array(4), plain: new Float32Array(4) },
|
||||||
|
generatedRects: [],
|
||||||
|
sourceMap: {
|
||||||
|
transportDebug: {
|
||||||
|
layers: {
|
||||||
|
expresswayPotential: new Float32Array([0.5]),
|
||||||
|
components: [{ mode: "rail", cells: [[0, 0]] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const transportDebugViewport = getViewportMap(transportDebugWorld, { x: 0, y: 0 }, 2, 2);
|
||||||
|
assert(!ArrayBuffer.isView(transportDebugViewport.transportDebug.layers.expresswayPotential)
|
||||||
|
&& transportDebugViewport.transportDebug.layers.components.length === 1,
|
||||||
|
"viewport drops unused transport potential rasters while preserving vector diagnostics");
|
||||||
|
|
||||||
|
const arrayDeltaBase = {
|
||||||
|
width: 1, height: 1,
|
||||||
|
fields: { marker: new Uint8Array([1]) }, generatedMask: new Uint8Array(1),
|
||||||
|
sourceMap: {
|
||||||
|
roads: [{ id: "keep-a" }, { id: "replace" }, { id: "keep-b" }],
|
||||||
|
annotated: Object.assign([{ id: "old" }], { patchGenerated: true }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const arrayDeltaTarget = structuredClone(arrayDeltaBase);
|
||||||
|
arrayDeltaTarget.sourceMap.roads.splice(1, 1, { id: "new-1" }, { id: "new-2" });
|
||||||
|
arrayDeltaTarget.sourceMap.annotated = Object.assign([{ id: "new" }], { patchGenerated: true });
|
||||||
|
const exactArrayDelta = buildCommittedMirrorDelta(arrayDeltaBase, arrayDeltaTarget);
|
||||||
|
const exactArrayApplied = applyCommittedMirrorDelta(structuredClone(arrayDeltaBase), exactArrayDelta);
|
||||||
|
assert(exactArrayDelta.sourceMapDelta?.arraySplices?.roads?.start === 1
|
||||||
|
&& exactArrayDelta.sourceMapDelta?.arraySplices?.roads?.deleteCount === 1
|
||||||
|
&& exactArrayDelta.sourceMapDelta?.arraySplices?.roads?.items?.length === 2
|
||||||
|
&& Array.isArray(exactArrayDelta.sourceMapDelta?.set?.annotated)
|
||||||
|
&& hashCommittedWorld(exactArrayApplied) === hashCommittedWorld(arrayDeltaTarget),
|
||||||
|
"metadata delta uses exact middle splices for dense layers and full replacement for annotated arrays");
|
||||||
|
const oneShotArrayDelta = buildCommittedMirrorDelta(arrayDeltaBase, arrayDeltaTarget);
|
||||||
|
const oneShotInsertedRoad = oneShotArrayDelta.sourceMapDelta.arraySplices.roads.items[0];
|
||||||
|
const oneShotArrayApplied = applyCommittedMirrorDelta(structuredClone(arrayDeltaBase), oneShotArrayDelta, { consumeMetadata: true });
|
||||||
|
assert(oneShotArrayApplied.sourceMap.roads[1] === oneShotInsertedRoad
|
||||||
|
&& hashCommittedWorld(oneShotArrayApplied) === hashCommittedWorld(arrayDeltaTarget),
|
||||||
|
"one-shot Worker/main delta application adopts its isolated metadata graph without a second full clone");
|
||||||
|
|
||||||
|
const transactionBase = {
|
||||||
|
seed: 1, width: 8, height: 6, originX: 0, originY: 0, sourceWidth: 8, sourceHeight: 6, seaLevel: 0.3,
|
||||||
|
fields: {
|
||||||
|
sea: new Uint8Array(48), elevation: new Float32Array(48), municipalityId: new Int32Array(48),
|
||||||
|
flowTo: Int32Array.from({ length: 48 }, (_, index) => index - 1),
|
||||||
|
},
|
||||||
|
generatedMask: new Uint8Array(48), sourceMap: {
|
||||||
|
villages: [{ x: 1, y: 1 }],
|
||||||
|
adminDebug: { compartmentBorders: [[[0, 0], [1, 1]]] },
|
||||||
|
terrainTemplate: { immutableReference: { label: "production terrain" } },
|
||||||
|
stable: [1, 2],
|
||||||
|
},
|
||||||
|
generatedRects: [{
|
||||||
|
x0: 0, y0: 0, x1: 8, y1: 6,
|
||||||
|
generatedFootprint: { x0: 0, y0: 0, x1: 8, y1: 6, rowRuns: [[0, 8], [0, 8]] },
|
||||||
|
}],
|
||||||
|
lastPatchResult: { ok: true, seamDiagnostics: { hardPass: true, issuePoints: [] } },
|
||||||
|
patchGenerationSerial: 0,
|
||||||
|
};
|
||||||
|
transactionBase.fields.municipalityId.fill(-1);
|
||||||
|
const committedGeneratedRectsRef = transactionBase.generatedRects;
|
||||||
|
const committedLastPatchResultRef = transactionBase.lastPatchResult;
|
||||||
|
const transactionOriginal = structuredClone(transactionBase);
|
||||||
|
const transaction = capturePatchTransactionSnapshot(transactionBase, { lightweight: false });
|
||||||
|
assert(transaction.sourceMap.terrainTemplate === transactionBase.sourceMap.terrainTemplate
|
||||||
|
&& transaction.sourceMap.stable === transactionBase.sourceMap.stable
|
||||||
|
&& transaction.sourceMap.villages !== transactionBase.sourceMap.villages
|
||||||
|
&& transaction.sourceMap.villages[0] !== transactionBase.sourceMap.villages[0]
|
||||||
|
&& transaction.sourceMap.adminDebug !== transactionBase.sourceMap.adminDebug
|
||||||
|
&& transaction.generatedRects === committedGeneratedRectsRef
|
||||||
|
&& transaction.lastPatchResult === committedLastPatchResultRef,
|
||||||
|
"transaction source snapshots share read-only roots and own every patch-mutable root");
|
||||||
|
|
||||||
|
const isolatedSourceWorld = structuredClone(transactionOriginal);
|
||||||
|
const isolatedCommittedSourceRef = isolatedSourceWorld.sourceMap;
|
||||||
|
const isolatedCommittedHash = hashCommittedWorld(isolatedSourceWorld);
|
||||||
|
const isolatedSourceTransaction = capturePatchTransactionSnapshot(isolatedSourceWorld, {
|
||||||
|
lightweight: false,
|
||||||
|
isolateSourceMap: true,
|
||||||
|
});
|
||||||
|
const isolatedCandidatePoint = { x: 6, y: 4, name: "candidate-only" };
|
||||||
|
isolatedSourceWorld.sourceMap.villages.push(isolatedCandidatePoint);
|
||||||
|
const isolatedSourceDelta = buildCommittedMirrorDeltaFromTransaction(isolatedSourceTransaction, isolatedSourceWorld);
|
||||||
|
restorePatchTransactionSnapshot(isolatedSourceWorld, isolatedSourceTransaction);
|
||||||
|
assert(isolatedSourceTransaction.sourceMap === isolatedCommittedSourceRef
|
||||||
|
&& isolatedSourceWorld.sourceMap === isolatedCommittedSourceRef
|
||||||
|
&& isolatedSourceTransaction.sourceMapIsolated === true
|
||||||
|
&& isolatedSourceDelta.sourceMapDelta.arraySplices.villages.items[0] === isolatedCandidatePoint
|
||||||
|
&& hashCommittedWorld(isolatedSourceWorld) === isolatedCommittedHash,
|
||||||
|
"Worker transactions mutate an isolated sourceMap and rollback by restoring the untouched committed reference");
|
||||||
|
preparePatchTransactionFields(transaction, transactionBase, { x0: 2, y0: 1, x1: 6, y1: 5 });
|
||||||
|
transactionBase.fields.sea[19] = 1;
|
||||||
|
transactionBase.fields.elevation[28] = 0.75;
|
||||||
|
transactionBase.fields.municipalityId[10] = 42;
|
||||||
|
transactionBase.generatedMask[19] = 1;
|
||||||
|
const acceptedVillageRef = { x: 3, y: 2 };
|
||||||
|
transactionBase.sourceMap.villages.push(acceptedVillageRef);
|
||||||
|
transactionBase.sourceMap.adminDebug.compartmentBorders.push([[2, 2], [3, 3]]);
|
||||||
|
const sharedSeamDiagnostic = { hardPass: true, issuePoints: [{ x: 3, y: 2 }] };
|
||||||
|
transactionBase.sourceMap.patchSeamDiagnostics = sharedSeamDiagnostic;
|
||||||
|
transactionBase.generatedRects = [...transactionBase.generatedRects, { x0: 2, y0: 1, x1: 6, y1: 5 }];
|
||||||
|
transactionBase.lastPatchResult = { ok: true, seamDiagnostics: sharedSeamDiagnostic };
|
||||||
|
transactionBase.patchGenerationSerial = 1;
|
||||||
|
const transactionTarget = structuredClone(transactionBase);
|
||||||
|
const transactionDelta = buildCommittedMirrorDeltaFromTransaction(transaction, transactionBase);
|
||||||
|
restorePatchTransactionSnapshot(transactionBase, transaction);
|
||||||
|
const transactionApplied = applyCommittedMirrorDelta(structuredClone(transactionOriginal), transactionDelta);
|
||||||
|
assert(JSON.stringify(transactionBase) === JSON.stringify(transactionOriginal), "transactional candidate restores the committed mirror exactly");
|
||||||
|
assert(transactionBase.generatedRects === committedGeneratedRectsRef
|
||||||
|
&& transactionBase.lastPatchResult === committedLastPatchResultRef,
|
||||||
|
"transaction rollback restores immutable history and prior diagnostics by reference without cloning them");
|
||||||
|
|
||||||
|
const regenerationMaskWorld = structuredClone(transactionOriginal);
|
||||||
|
const regenerationMaskRef = regenerationMaskWorld.generatedMask;
|
||||||
|
const regenerationMaskTransaction = capturePatchTransactionSnapshot(regenerationMaskWorld, {
|
||||||
|
lightweight: false,
|
||||||
|
copyGeneratedMask: false,
|
||||||
|
});
|
||||||
|
regenerationMaskWorld.generatedMask = new Uint8Array(regenerationMaskWorld.generatedMask.length).fill(1);
|
||||||
|
const regenerationMaskDelta = buildCommittedMirrorDeltaFromTransaction(regenerationMaskTransaction, regenerationMaskWorld);
|
||||||
|
restorePatchTransactionSnapshot(regenerationMaskWorld, regenerationMaskTransaction);
|
||||||
|
assert(regenerationMaskTransaction.generatedMask === null
|
||||||
|
&& regenerationMaskTransaction.generatedMaskRef === regenerationMaskRef
|
||||||
|
&& regenerationMaskDelta.generatedMask?.rows?.length > 0
|
||||||
|
&& regenerationMaskWorld.generatedMask === regenerationMaskRef,
|
||||||
|
"Regeneration transactions retain the read-only generated mask by reference and still detect array replacement");
|
||||||
|
assert(JSON.stringify(transactionApplied) === JSON.stringify(transactionTarget), "transaction snapshot delta exactly materializes the accepted preview");
|
||||||
|
assert(transactionDelta.sourceMapDelta.arraySplices.villages.items[0] === acceptedVillageRef,
|
||||||
|
"transaction delta adopts completed candidate metadata directly instead of cloning it before rollback");
|
||||||
|
assert(transactionDelta.previewDelta.changedCells === 3
|
||||||
|
&& transactionDelta.previewDelta.terrainChangedCells === 2
|
||||||
|
&& transactionDelta.previewDelta.adminChangedCells === 1
|
||||||
|
&& transactionDelta.previewDelta.featureLayersChanged === 1,
|
||||||
|
"transaction delta reuses its field comparison to produce exact preview statistics");
|
||||||
|
assert(transactionDelta.sourceMapDelta.set.patchSeamDiagnostics
|
||||||
|
=== transactionDelta.metaDelta.set.lastPatchResult.seamDiagnostics
|
||||||
|
&& transactionApplied.sourceMap.patchSeamDiagnostics
|
||||||
|
=== transactionApplied.lastPatchResult.seamDiagnostics,
|
||||||
|
"delta build and apply preserve shared diagnostic objects instead of cloning them per metadata key");
|
||||||
|
|
||||||
|
const transactionalCalls = [];
|
||||||
|
const transactionalSearchBase = structuredClone(transactionOriginal);
|
||||||
|
const transactionalSearch = runPatchCandidateSearch({
|
||||||
|
id: 3,
|
||||||
|
world: transactionalSearchBase,
|
||||||
|
rect: { x0: 2, y0: 1, x1: 6, y1: 5 },
|
||||||
|
options: {},
|
||||||
|
search: { candidatePlan: [{ variant: 8, seed: 108 }, { variant: 9, seed: 109 }] },
|
||||||
|
}, {
|
||||||
|
transactional: true,
|
||||||
|
generateCandidate: (world, rect, options) => {
|
||||||
|
transactionalCalls.push([world.fields.sea[19], world.fields.municipalityId[10], world.sourceMap.villages.length]);
|
||||||
|
preparePatchTransactionFields(options._externalTransactionSnapshot, world, rect);
|
||||||
|
world.fields.sea[19] = options.variant;
|
||||||
|
world.fields.municipalityId[10] = options.variant;
|
||||||
|
world.sourceMap.villages.push({ x: options.variant, y: 2 });
|
||||||
|
world.patchGenerationSerial++;
|
||||||
|
return options.variant === 8
|
||||||
|
? { ok: false, code: "patch-quality-gate-failed", reason: "forced reject" }
|
||||||
|
: { ok: true, seamDiagnostics: { hardPass: true }, rects: { writeRect: rect } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(transactionalSearch.result?.ok && !transactionalSearch.world && transactionalSearch.transactionDelta,
|
||||||
|
"production candidate search returns a bounded delta instead of a second full world");
|
||||||
|
assert(transactionalSearch.result.previewDelta?.changedCells === 2
|
||||||
|
&& transactionalSearch.result.previewDelta?.featureLayersChanged === 1,
|
||||||
|
"transactional search returns precomputed preview statistics without a main-thread field rescan");
|
||||||
|
assert(transactionalCalls.every(([sea, municipality, villages]) => sea === 0 && municipality === -1 && villages === 1)
|
||||||
|
&& JSON.stringify(transactionalSearchBase) === JSON.stringify(transactionOriginal),
|
||||||
|
"rejected and accepted transactional candidates both start from and restore the same committed mirror");
|
||||||
|
|
||||||
|
const municipalityWorld = {
|
||||||
|
width: 8, height: 6,
|
||||||
|
fields: {
|
||||||
|
adminId: Int32Array.from({ length: 48 }, (_, index) => index % 5),
|
||||||
|
municipalityId: new Int32Array(48).fill(99),
|
||||||
|
sea: new Uint8Array(48),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const municipalityRects = {
|
||||||
|
patchMode: "regeneration",
|
||||||
|
coreRect: { x0: 2, y0: 1, x1: 6, y1: 5 },
|
||||||
|
writeRect: { x0: 2, y0: 1, x1: 6, y1: 5 },
|
||||||
|
repairRect: { x0: 0, y0: 0, x1: 8, y1: 6 },
|
||||||
|
writeMargin: 1,
|
||||||
|
};
|
||||||
|
const municipalityBefore = new Int32Array(municipalityWorld.fields.municipalityId);
|
||||||
|
const municipalityWrites = synchronizePatchMunicipalityField(municipalityWorld, municipalityRects, 17);
|
||||||
|
let municipalityOutsideChanged = 0;
|
||||||
|
for (let y = 0; y < municipalityWorld.height; y++) {
|
||||||
|
for (let x = 0; x < municipalityWorld.width; x++) {
|
||||||
|
if (x >= 2 && x < 6 && y >= 1 && y < 5) continue;
|
||||||
|
const index = y * municipalityWorld.width + x;
|
||||||
|
if (municipalityWorld.fields.municipalityId[index] !== municipalityBefore[index]) municipalityOutsideChanged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(municipalityWrites > 0 && municipalityOutsideChanged === 0,
|
||||||
|
"Regeneration municipality coherence writes only the owned patch alpha instead of rewriting the full world");
|
||||||
|
|
||||||
|
const adminWorldBase = {
|
||||||
|
seed: 3, width: 6, height: 4, originX: 0, originY: 0, sourceWidth: 6, sourceHeight: 4,
|
||||||
|
fields: {
|
||||||
|
adminId: Int32Array.from({ length: 24 }, (_, index) => index % 6 < 3 ? 0 : 1),
|
||||||
|
municipalityId: new Int32Array(24).fill(-1),
|
||||||
|
prefectureRegionId: Int32Array.from({ length: 24 }, (_, index) => index % 6 < 3 ? 10 : 11),
|
||||||
|
sea: new Uint8Array(24), populationDensity: new Float32Array(24).fill(0.5),
|
||||||
|
plain: new Float32Array(24).fill(0.6), agriculture: new Float32Array(24).fill(0.2),
|
||||||
|
slope: new Float32Array(24), ridgeField: new Float32Array(24), landuse: new Uint8Array(24),
|
||||||
|
},
|
||||||
|
generatedMask: new Uint8Array(24), generatedRects: [],
|
||||||
|
sourceMap: {
|
||||||
|
adminCenters: [], prefectureRegions: [], municipalityToPrefectureId: new Int32Array(2).fill(-1),
|
||||||
|
modernCities: [{ x: 1, y: 1, population: 1000 }, { x: 4, y: 1, population: 900 }],
|
||||||
|
},
|
||||||
|
patchGenerationSerial: 0,
|
||||||
|
};
|
||||||
|
const adminFullSecond = structuredClone(adminWorldBase);
|
||||||
|
const adminOptimizedSecond = structuredClone(adminWorldBase);
|
||||||
|
const adminFirstFull = synchronizePatchAdministrativeMetadata(adminFullSecond, adminFullSecond.sourceMap, 3, { writeMunicipalityField: true });
|
||||||
|
const adminFirstOptimized = synchronizePatchAdministrativeMetadata(adminOptimizedSecond, adminOptimizedSecond.sourceMap, 3, { writeMunicipalityField: true });
|
||||||
|
adminFullSecond.sourceMap.modernCities[0].isPrefecturalCapital = true;
|
||||||
|
adminOptimizedSecond.sourceMap.modernCities[0].isPrefecturalCapital = true;
|
||||||
|
synchronizePatchAdministrativeMetadata(adminFullSecond, adminFullSecond.sourceMap, 3, { writeMunicipalityField: false });
|
||||||
|
refreshPatchPrefectureMetadata(adminOptimizedSecond, adminOptimizedSecond.sourceMap, adminFirstOptimized.municipalCoherence, { afterCapitalNormalization: true });
|
||||||
|
assert(hashCommittedWorld(adminOptimizedSecond) === hashCommittedWorld(adminFullSecond)
|
||||||
|
&& adminFirstFull.municipalCoherence.debug.activeMunicipalities === adminFirstOptimized.municipalCoherence.debug.activeMunicipalities,
|
||||||
|
"post-capital prefecture refresh matches the former second full municipal scan exactly");
|
||||||
|
|
||||||
|
const capitalWorld = {
|
||||||
|
width: 6, height: 2, originX: 0, originY: 0,
|
||||||
|
fields: {
|
||||||
|
prefectureRegionId: Int32Array.from([0, 0, 1, 1, 2, 2, 0, 0, 1, 1, 2, 2]),
|
||||||
|
sea: new Uint8Array(12),
|
||||||
|
populationDensity: Float32Array.from([1, 3, 1, 4, 1, 5, 2, 2, 3, 2, 4, 3]),
|
||||||
|
habitability: new Float32Array(12), slope: new Float32Array(12),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const capitalSource = { modernCities: [], markets: [], adminCenters: [] };
|
||||||
|
const capitalDebug = normalizePatchPrefectureCapitals(capitalWorld, capitalSource);
|
||||||
|
assert(capitalDebug.activePrefectures === 3 && capitalDebug.fallbackPrefectureCapitalCitiesAdded === 3
|
||||||
|
&& capitalSource.modernCities.map((city) => `${city.worldX},${city.worldY}`).join("|") === "1,0|3,0|5,0",
|
||||||
|
"capital fallback collects every prefecture's best cell in one world pass with stable tie order");
|
||||||
|
|
||||||
|
const tilePlanningWorld = { width: 1000, height: 700, originX: 0, originY: 0 };
|
||||||
|
// Expansion implementation tiles are selection-anchored so a maximum visible
|
||||||
|
// request does not generate thin world-grid edge candidates. Each tile still
|
||||||
|
// maps into one complete MAP_W x MAP_H production candidate; Regeneration keeps
|
||||||
|
// its historical full-candidate world-grid assignment.
|
||||||
|
const tilePlanningSelection = { x0: 205, y0: 20, x1: 205 + 470, y1: 20 + 333 };
|
||||||
|
const expansionTiles = buildLargeExpansionTiles(tilePlanningSelection, tilePlanningWorld);
|
||||||
|
const regenerationTiles = buildLargeExpansionTiles(tilePlanningSelection, tilePlanningWorld, {
|
||||||
|
_largeSelectionThresholdWidth: MAP_W,
|
||||||
|
_largeSelectionThresholdHeight: MAP_H,
|
||||||
|
_tileCoreWidth: MAP_W,
|
||||||
|
_tileCoreHeight: MAP_H,
|
||||||
|
});
|
||||||
|
assert(expansionTiles.length === 4
|
||||||
|
&& expansionTiles.every((tile) => tile._candidateWindowOverride?.width === MAP_W && tile._candidateWindowOverride?.height === MAP_H)
|
||||||
|
&& regenerationTiles.every((tile) => tile.x1 - tile.x0 <= MAP_W && tile.y1 - tile.y0 <= MAP_H),
|
||||||
|
"large Expansion packs a maximum visible selection into four full-production candidates while Regeneration retains full-size cores");
|
||||||
|
|
||||||
|
const standaloneSafeSelection = { x0: 200, y0: 200, x1: 320, y1: 290 };
|
||||||
|
const intermediateCoverageSelection = { x0: 200, y0: 200, x1: 360, y1: 310 };
|
||||||
|
const standaloneSafeTiles = buildLargeExpansionTiles(standaloneSafeSelection, tilePlanningWorld);
|
||||||
|
const intermediateCoverageTiles = buildLargeExpansionTiles(intermediateCoverageSelection, tilePlanningWorld);
|
||||||
|
assert(standaloneSafeTiles.length === 0
|
||||||
|
&& intermediateCoverageTiles.length === 1
|
||||||
|
&& intermediateCoverageTiles[0]._candidateWindowOverride?.width === MAP_W
|
||||||
|
&& intermediateCoverageTiles[0]._candidateWindowOverride?.height === MAP_H,
|
||||||
|
"Expansion tiling starts when the exact standalone write footprint no longer fits one production candidate, including the former 160x110 coverage hole");
|
||||||
|
|
||||||
|
let uncoveredStandaloneGeometry = null;
|
||||||
|
for (const [width, height] of [[48, 48], [96, 72], [120, 90], [128, 96], [132, 98], [160, 110], [220, 150], [242, 167]]) {
|
||||||
|
for (const [x0, y0] of [[0, 0], [200, 180], [1000 - width, 700 - height]]) {
|
||||||
|
const selection = { x0, y0, x1: x0 + width, y1: y0 + height };
|
||||||
|
if (buildLargeExpansionTiles(selection, tilePlanningWorld).length > 0) continue;
|
||||||
|
const rects = buildPatchRects(selection, tilePlanningWorld, { patchMode: "expansion", _geometryOnly: true });
|
||||||
|
const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
|
||||||
|
const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2;
|
||||||
|
const sourceCenterX = (MAP_W - 1) / 2;
|
||||||
|
const sourceCenterY = (MAP_H - 1) / 2;
|
||||||
|
const corners = [
|
||||||
|
[rects.writeRect.x0, rects.writeRect.y0],
|
||||||
|
[rects.writeRect.x1 - 1, rects.writeRect.y1 - 1],
|
||||||
|
];
|
||||||
|
const covered = corners.every(([x, y]) => {
|
||||||
|
const sx = Math.round(x - cx + sourceCenterX);
|
||||||
|
const sy = Math.round(y - cy + sourceCenterY);
|
||||||
|
return sx >= 0 && sx < MAP_W && sy >= 0 && sy < MAP_H;
|
||||||
|
});
|
||||||
|
if (!covered) uncoveredStandaloneGeometry = { width, height, x0, y0, writeRect: rects.writeRect };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(uncoveredStandaloneGeometry === null,
|
||||||
|
"every Expansion that remains on the standalone fast path has complete fixed-candidate coverage, including world-edge placements");
|
||||||
|
|
||||||
|
const rasterPartitionLasso = {
|
||||||
|
kind: "lasso",
|
||||||
|
polygon: [
|
||||||
|
{ x: 120, y: 40 },
|
||||||
|
{ x: 399, y: 60 },
|
||||||
|
{ x: 250, y: 140 },
|
||||||
|
{ x: 370, y: 239 },
|
||||||
|
{ x: 100, y: 210 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const rasterPartitionTiles = buildLargeExpansionTiles(rasterPartitionLasso, tilePlanningWorld);
|
||||||
|
let partitionMissing = 0;
|
||||||
|
let partitionExtra = 0;
|
||||||
|
let partitionDuplicate = 0;
|
||||||
|
for (let y = 40; y < 240; y++) {
|
||||||
|
for (let x = 100; x < 400; x++) {
|
||||||
|
const selected = testPointInPolygon(x + 0.5, y + 0.5, rasterPartitionLasso.polygon);
|
||||||
|
let tileHits = 0;
|
||||||
|
for (const tile of rasterPartitionTiles) {
|
||||||
|
if (x < tile.x0 || x >= tile.x1 || y < tile.y0 || y >= tile.y1) continue;
|
||||||
|
if (testPointInPolygon(x + 0.5, y + 0.5, tile.polygon)) tileHits++;
|
||||||
|
}
|
||||||
|
if (selected && tileHits === 0) partitionMissing++;
|
||||||
|
if (!selected && tileHits > 0) partitionExtra++;
|
||||||
|
if (tileHits > 1) partitionDuplicate++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(rasterPartitionTiles.length >= 2
|
||||||
|
&& partitionMissing === 0
|
||||||
|
&& partitionExtra === 0
|
||||||
|
&& partitionDuplicate === 0
|
||||||
|
&& rasterPartitionTiles.every((tile) => tile.partitionBounds && tile.areaCells > 0),
|
||||||
|
"large lasso tiles partition the original even-odd raster exactly without missing, extra, or duplicate seam cells");
|
||||||
|
|
||||||
|
const strictMetadataWorld = { width: 8, height: 6, originX: 0, originY: 0 };
|
||||||
|
const strictMetadataSource = {
|
||||||
|
modernCities: [{ x: 0, y: 0, name: "outside" }, { x: 3, y: 2, name: "inside" }],
|
||||||
|
adminCenters: [{ x: 0, y: 0, adminId: 1, name: "office" }],
|
||||||
|
prefectureRegions: [{ x: 0, y: 0, prefectureRegionId: 2, name: "region" }],
|
||||||
|
};
|
||||||
|
const strictMetadataBaseline = structuredClone(strictMetadataSource);
|
||||||
|
const strictMetadataSnapshot = captureStrictMetadataSnapshot(strictMetadataWorld, strictMetadataSource, municipalityRects, 17, strictMetadataBaseline);
|
||||||
|
assert(strictMetadataSnapshot.outsidePointLayers.get("modernCities")[0] === strictMetadataBaseline.modernCities[0]
|
||||||
|
&& strictMetadataSnapshot.byLayer.get("adminCenters")[0] === strictMetadataSnapshot.allByLayerId.get("adminCenters").get(1)
|
||||||
|
&& strictMetadataSnapshot.byLayer.get("prefectureRegions")[0] === strictMetadataSnapshot.allByLayerId.get("prefectureRegions").get(2),
|
||||||
|
"strict metadata snapshots reuse the immutable transaction before-image instead of cloning the same points into multiple stores");
|
||||||
|
|
||||||
|
const strictFieldWorld = structuredClone(transactionOriginal);
|
||||||
|
const strictFieldTransaction = capturePatchTransactionSnapshot(strictFieldWorld, { lightweight: false });
|
||||||
|
preparePatchTransactionFields(strictFieldTransaction, strictFieldWorld, municipalityRects.repairRect);
|
||||||
|
const strictFieldSnapshot = captureStrictSelectionFieldSnapshot(strictFieldWorld, municipalityRects, 17, {
|
||||||
|
deferGlobalSnapshot: true,
|
||||||
|
transactionSnapshot: strictFieldTransaction,
|
||||||
|
});
|
||||||
|
assert(strictFieldSnapshot.transactionSnapshot === strictFieldTransaction
|
||||||
|
&& strictFieldSnapshot.fieldNames.size > 0
|
||||||
|
&& strictFieldSnapshot.fields.size === 0
|
||||||
|
&& !strictFieldTransaction.fields.has("flowTo")
|
||||||
|
&& !strictFieldSnapshot.fieldNames.has("flowTo")
|
||||||
|
&& !("globalFields" in strictFieldTransaction)
|
||||||
|
&& strictFieldSnapshot.protectedIndexLookup === null,
|
||||||
|
"strict field restore reuses local transaction before-images without read-only or whole-world duplicates");
|
||||||
|
|
||||||
|
|
||||||
|
const rawFlagWorld = {
|
||||||
|
seed: 7, width: 900, height: 700, originX: 0, originY: 0,
|
||||||
|
fields: { sea: new Uint8Array(900 * 700), elevation: new Float32Array(900 * 700) },
|
||||||
|
sourceMap: {}, generatedMask: new Uint8Array(900 * 700),
|
||||||
|
};
|
||||||
|
// Half of this internal tile already exists in the committed world. Large
|
||||||
|
// Expansion human-density completion must therefore target only the genuinely
|
||||||
|
// new fraction rather than treating the entire raw production frame as new.
|
||||||
|
for (let y = 0; y < rawFlagWorld.height; y++) {
|
||||||
|
for (let x = 0; x < 225; x++) rawFlagWorld.generatedMask[y * rawFlagWorld.width + x] = 1;
|
||||||
|
}
|
||||||
|
const internalExpansionRaw = buildRawPatchCandidateRequest(rawFlagWorld, { x0: 150, y0: 120, x1: 300, y1: 226 }, {
|
||||||
|
patchMode: "expansion", _internalTile: true, seed: 701, variant: 0,
|
||||||
|
});
|
||||||
|
const ordinaryExpansionRaw = buildRawPatchCandidateRequest(rawFlagWorld, { x0: 150, y0: 120, x1: 300, y1: 226 }, {
|
||||||
|
patchMode: "expansion", seed: 701, variant: 0,
|
||||||
|
});
|
||||||
|
assert(internalExpansionRaw.ok && internalExpansionRaw.mapOptions.largeExpansionTile === true
|
||||||
|
&& ordinaryExpansionRaw.ok && ordinaryExpansionRaw.mapOptions.largeExpansionTile === false,
|
||||||
|
"canonical internal Expansion tiles activate the bounded large-tile transport policy without affecting ordinary candidates");
|
||||||
|
assert(internalExpansionRaw.mapOptions.patchHumanFocusPolygon?.length >= 3
|
||||||
|
&& Math.abs(internalExpansionRaw.mapOptions.patchHumanExpansionFraction - 0.5) < 1e-9
|
||||||
|
&& ordinaryExpansionRaw.mapOptions.patchHumanExpansionFraction == null,
|
||||||
|
"large Expansion raw candidates carry a selection focus and immutable ungenerated fraction without leaking it into ordinary candidates");
|
||||||
|
|
||||||
|
const savedWorker = globalThis.Worker;
|
||||||
|
const fakeStarts = [];
|
||||||
|
const fakeFinishes = [];
|
||||||
|
let fakeActive = 0;
|
||||||
|
let fakePeak = 0;
|
||||||
|
class FakeRawCandidateWorker {
|
||||||
|
constructor() {
|
||||||
|
this.onmessage = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.terminated = false;
|
||||||
|
}
|
||||||
|
postMessage(message) {
|
||||||
|
const index = Number(message.mapOptions?.testIndex || 0);
|
||||||
|
const delay = Number(message.mapOptions?.testDelay || 0);
|
||||||
|
fakeStarts.push({ index, at: performance.now() });
|
||||||
|
fakeActive++;
|
||||||
|
fakePeak = Math.max(fakePeak, fakeActive);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.terminated) return;
|
||||||
|
fakeActive--;
|
||||||
|
fakeFinishes.push({ index, at: performance.now() });
|
||||||
|
this.onmessage?.({ data: {
|
||||||
|
type: "raw-patch-candidate-result",
|
||||||
|
id: message.id,
|
||||||
|
ok: true,
|
||||||
|
candidate: { index },
|
||||||
|
} });
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
terminate() {
|
||||||
|
this.terminated = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.Worker = FakeRawCandidateWorker;
|
||||||
|
try {
|
||||||
|
const requests = [70, 5, 5, 5, 5, 5].map((delay, index) => ({
|
||||||
|
seed: index + 1,
|
||||||
|
taskId: `scheduler-${index}`,
|
||||||
|
mapOptions: { testIndex: index, testDelay: delay },
|
||||||
|
}));
|
||||||
|
const sequence = scheduleRawCandidateSequence(requests, null, { parallelism: 2, windowSize: 3 });
|
||||||
|
await sequence.promises[1];
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
assert(fakeStarts.some((entry) => entry.index === 2) && !fakeFinishes.some((entry) => entry.index === 0),
|
||||||
|
"continuous two-worker scheduler lets a free lane start tile 3 while tile 1 is a straggler");
|
||||||
|
assert(!fakeStarts.some((entry) => entry.index === 3),
|
||||||
|
"raw candidate prefetch is bounded to one lookahead beyond the two active workers before merge-head release");
|
||||||
|
const merged = [];
|
||||||
|
for (let index = 0; index < sequence.promises.length; index++) {
|
||||||
|
const candidate = await sequence.promises[index];
|
||||||
|
merged.push(candidate.index);
|
||||||
|
sequence.promises[index] = null;
|
||||||
|
sequence.release(index);
|
||||||
|
}
|
||||||
|
await sequence.done;
|
||||||
|
assert(merged.join(",") === "0,1,2,3,4,5" && fakePeak <= 2 && fakeStarts.length === 6,
|
||||||
|
"raw candidates still merge deterministically in ordinal order with at most two helper workers");
|
||||||
|
} finally {
|
||||||
|
await shutdownRawCandidateWorkers();
|
||||||
|
if (savedWorker === undefined) delete globalThis.Worker;
|
||||||
|
else globalThis.Worker = savedWorker;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syntheticRawCandidate = {
|
||||||
|
baseSeed: 77,
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
sea: new Uint8Array([0, 1, 0, 1]),
|
||||||
|
municipalityToPrefectureId: new Int32Array([0, 1, 1]),
|
||||||
|
villages: [{ x: 2, y: 3 }],
|
||||||
|
nationalRoads: [[[0, 0], [1, 1]]],
|
||||||
|
generationContext: { width: MAP_W, height: MAP_H },
|
||||||
|
terrainTemplate: { terrainType: "test" },
|
||||||
|
adminDebug: { compartmentBorders: [] },
|
||||||
|
geography: { veryLargeGraph: new Array(500).fill({ id: 1 }) },
|
||||||
|
naturalCompartments: [{ id: 1 }],
|
||||||
|
unrelatedDebug: { shouldDrop: true },
|
||||||
|
};
|
||||||
|
const compactedRawCandidate = compactRawPatchCandidate(syntheticRawCandidate);
|
||||||
|
const compactedRawSummary = summarizeRawPatchCandidate(compactedRawCandidate);
|
||||||
|
assert(compactedRawCandidate.sea === syntheticRawCandidate.sea
|
||||||
|
&& compactedRawCandidate.municipalityToPrefectureId === syntheticRawCandidate.municipalityToPrefectureId
|
||||||
|
&& compactedRawCandidate.villages === syntheticRawCandidate.villages
|
||||||
|
&& compactedRawCandidate.nationalRoads === syntheticRawCandidate.nationalRoads
|
||||||
|
&& compactedRawCandidate.generationContext === syntheticRawCandidate.generationContext,
|
||||||
|
"raw-candidate compaction preserves dynamically discovered raster fields and merge-required vector metadata");
|
||||||
|
assert(!("geography" in compactedRawCandidate)
|
||||||
|
&& !("naturalCompartments" in compactedRawCandidate)
|
||||||
|
&& !("unrelatedDebug" in compactedRawCandidate)
|
||||||
|
&& compactedRawSummary.typedArrayCount === 2
|
||||||
|
&& compactedRawSummary.transferableBytes === syntheticRawCandidate.sea.byteLength + syntheticRawCandidate.municipalityToPrefectureId.byteLength,
|
||||||
|
"raw-candidate compaction drops full-map-only graphs without hiding transferable-byte accounting");
|
||||||
|
|
||||||
|
const terrainRehomeWorld = {
|
||||||
|
width: 24, height: 24, originX: 0, originY: 0,
|
||||||
|
fields: {
|
||||||
|
sea: new Uint8Array(24 * 24).fill(1),
|
||||||
|
slope: new Float32Array(24 * 24),
|
||||||
|
adminId: new Int32Array(24 * 24).fill(-1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (let y = 8; y <= 14; y++) for (let x = 8; x <= 14; x++) {
|
||||||
|
terrainRehomeWorld.fields.sea[y * 24 + x] = 0;
|
||||||
|
terrainRehomeWorld.fields.adminId[y * 24 + x] = 7;
|
||||||
|
}
|
||||||
|
const terrainRehomeSource = {
|
||||||
|
villages: [
|
||||||
|
{ x: 6, y: 10, adminId: 7, patchGenerated: true },
|
||||||
|
{ x: 2, y: 2, adminId: 3, patchGenerated: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const terrainRehomeRects = {
|
||||||
|
coreRect: { x0: 4, y0: 4, x1: 20, y1: 20 },
|
||||||
|
writeRect: { x0: 4, y0: 4, x1: 20, y1: 20 },
|
||||||
|
patchMode: "expansion",
|
||||||
|
expansionOverlap: 0,
|
||||||
|
};
|
||||||
|
const terrainRehomeDebug = reconcileGeneratedHumanPointsWithFinalTerrain(
|
||||||
|
terrainRehomeWorld, terrainRehomeSource, terrainRehomeRects, 123
|
||||||
|
);
|
||||||
|
assert(terrainRehomeDebug.relocated === 1 && terrainRehomeDebug.dropped === 0
|
||||||
|
&& terrainRehomeSource.villages.length === 2
|
||||||
|
&& terrainRehomeWorld.fields.sea[terrainRehomeSource.villages[0].y * 24 + terrainRehomeSource.villages[0].x] === 0
|
||||||
|
&& terrainRehomeSource.villages[1].x === 2 && terrainRehomeSource.villages[1].y === 2,
|
||||||
|
"final coastline reconciliation relocates only current generated settlements from water to owned land and preserves legacy points");
|
||||||
|
|
||||||
|
const tiledQualityBasis = buildTiledFinalQualityBasis([
|
||||||
|
{ candidateQuality: { terrain: { terrainType: "auto" }, human: {
|
||||||
|
minLabels: 30, minSettlements: 18, minAdminCenters: 2, transportRequired: true,
|
||||||
|
labelCount: 24, settlementCount: 15, counts: { adminCenters: 2 },
|
||||||
|
} } },
|
||||||
|
{ candidateQuality: { terrain: { terrainType: "auto" }, human: {
|
||||||
|
minLabels: 20, minSettlements: 12, minAdminCenters: 1, transportRequired: false,
|
||||||
|
labelCount: 17, settlementCount: 11, counts: { adminCenters: 1 },
|
||||||
|
} } },
|
||||||
|
], "auto");
|
||||||
|
assert(tiledQualityBasis.human.minLabels === 50
|
||||||
|
&& tiledQualityBasis.human.minSettlements === 30
|
||||||
|
&& tiledQualityBasis.human.preMergeLabelCount === 41
|
||||||
|
&& tiledQualityBasis.human.preMergeSettlementCount === 26
|
||||||
|
&& tiledQualityBasis.human.minAdminCenters === 3
|
||||||
|
&& tiledQualityBasis.human.preMergeAdminCenterCount === 3
|
||||||
|
&& tiledQualityBasis.human.transportRequired === true,
|
||||||
|
"whole-selection tiled quality basis retains both theoretical floors and actual pre-merge human-geography counts");
|
||||||
|
|
||||||
|
const sourcePruneInitial = {
|
||||||
|
seed: 1,
|
||||||
|
sea: new Uint8Array(MAP_W * MAP_H),
|
||||||
|
elevation: new Float32Array(MAP_W * MAP_H),
|
||||||
|
geography: { cells: [1, 2, 3] },
|
||||||
|
naturalCompartments: [{ id: 1 }],
|
||||||
|
geographicCompartmentProfiles: [{ id: 1 }],
|
||||||
|
watershedProfiles: [{ id: 1 }],
|
||||||
|
entitiesForNames: [{ id: 1 }],
|
||||||
|
geographyDebug: { heavy: true },
|
||||||
|
transportDebug: { keep: true },
|
||||||
|
generationTimings: [{ key: "terrain", ms: 1 }],
|
||||||
|
villages: [{ x: 1, y: 1 }],
|
||||||
|
};
|
||||||
|
const sourcePruneWorld = createWorldMap(sourcePruneInitial, { paddingX: 0, paddingY: 0 });
|
||||||
|
assert(!("geography" in sourcePruneWorld.sourceMap)
|
||||||
|
&& !("naturalCompartments" in sourcePruneWorld.sourceMap)
|
||||||
|
&& !("geographicCompartmentProfiles" in sourcePruneWorld.sourceMap)
|
||||||
|
&& !("watershedProfiles" in sourcePruneWorld.sourceMap)
|
||||||
|
&& !("entitiesForNames" in sourcePruneWorld.sourceMap)
|
||||||
|
&& !("geographyDebug" in sourcePruneWorld.sourceMap),
|
||||||
|
"committed sourceMap prunes generator-only full-map graphs after world materialization");
|
||||||
|
assert(sourcePruneWorld.sourceMap.transportDebug === sourcePruneInitial.transportDebug
|
||||||
|
&& sourcePruneWorld.sourceMap.generationTimings === sourcePruneInitial.generationTimings
|
||||||
|
&& sourcePruneWorld.sourceMap.villages === sourcePruneInitial.villages,
|
||||||
|
"committed sourceMap retains runtime diagnostics and vector layers that viewport/patch logic still consumes");
|
||||||
|
|
||||||
|
const memo = createExactNoiseMemo();
|
||||||
|
let mismatches = 0;
|
||||||
|
for (let index = 0; index < 2000; index++) {
|
||||||
|
const x = (index * 17 % 997) - 480.25;
|
||||||
|
const y = (index * 43 % 1231) - 610.75;
|
||||||
|
const seed = (114514 + index * 101) >>> 0;
|
||||||
|
const scale = 3.75 + (index % 53);
|
||||||
|
if (memo.valueNoise(x, y, seed, scale) !== valueNoise(x, y, seed, scale)) mismatches++;
|
||||||
|
if (memo.fbm(x, y, seed) !== fbm(x, y, seed)) mismatches++;
|
||||||
|
}
|
||||||
|
assert(mismatches === 0, "exact terrain noise memo preserves every sampled value and octave order");
|
||||||
|
|
||||||
|
console.log("All additional-generation unit tests passed.");
|
||||||
19
tests/browser-nested-worker-node-shim.mjs
Normal file
19
tests/browser-nested-worker-node-shim.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { parentPort, workerData } from "node:worker_threads";
|
||||||
|
|
||||||
|
if (!parentPort) throw new Error("browser-nested-worker-node-shim requires a parent port.");
|
||||||
|
if (!workerData?.moduleUrl) throw new Error("browser-nested-worker-node-shim requires moduleUrl.");
|
||||||
|
|
||||||
|
globalThis.self = {
|
||||||
|
onmessage: null,
|
||||||
|
postMessage(message, transfer = []) {
|
||||||
|
parentPort.postMessage(message, transfer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await import(workerData.moduleUrl);
|
||||||
|
|
||||||
|
if (typeof globalThis.self.onmessage !== "function") {
|
||||||
|
throw new Error(`Nested browser worker module did not install onmessage: ${workerData.moduleUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPort.on("message", (data) => globalThis.self.onmessage({ data }));
|
||||||
41
tests/browser-worker-node-shim.mjs
Normal file
41
tests/browser-worker-node-shim.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { parentPort, Worker as NodeWorker } from "node:worker_threads";
|
||||||
|
|
||||||
|
if (!parentPort) throw new Error("browser-worker-node-shim requires a worker_threads parent port.");
|
||||||
|
|
||||||
|
class BrowserStyleNestedWorker {
|
||||||
|
constructor(url) {
|
||||||
|
const moduleUrl = url instanceof URL ? url.href : new URL(String(url), import.meta.url).href;
|
||||||
|
this.onmessage = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this._worker = new NodeWorker(new URL("./browser-nested-worker-node-shim.mjs", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
workerData: { moduleUrl },
|
||||||
|
});
|
||||||
|
this._worker.on("message", (data) => this.onmessage?.({ data }));
|
||||||
|
this._worker.on("error", (error) => this.onerror?.({ message: error?.message || String(error), error }));
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage(message, transfer = []) {
|
||||||
|
this._worker.postMessage(message, transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
return this._worker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.Worker = BrowserStyleNestedWorker;
|
||||||
|
globalThis.self = {
|
||||||
|
onmessage: null,
|
||||||
|
postMessage(message, transfer = []) {
|
||||||
|
parentPort.postMessage(message, transfer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await import("../src/mapPatchWorker.js");
|
||||||
|
|
||||||
|
if (typeof globalThis.self.onmessage !== "function") {
|
||||||
|
throw new Error("mapPatchWorker did not install its browser Worker message handler.");
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPort.on("message", (data) => globalThis.self.onmessage({ data }));
|
||||||
296
tests/chromium-cdp-page.mjs
Normal file
296
tests/chromium-cdp-page.mjs
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
function wait(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpSocket {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.ws = null;
|
||||||
|
this.nextId = 0;
|
||||||
|
this.pending = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
const ws = new WebSocket(this.url);
|
||||||
|
this.ws = ws;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`Timed out connecting to CDP target ${this.url}`)), 10_000);
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}, { once: true });
|
||||||
|
ws.addEventListener("error", (event) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(event?.error || new Error("CDP WebSocket connection failed."));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
ws.addEventListener("message", (event) => {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(String(event.data));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.id == null) return;
|
||||||
|
const waiter = this.pending.get(message.id);
|
||||||
|
if (!waiter) return;
|
||||||
|
this.pending.delete(message.id);
|
||||||
|
if (message.error) waiter.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
||||||
|
else waiter.resolve(message.result || {});
|
||||||
|
});
|
||||||
|
ws.addEventListener("close", () => {
|
||||||
|
for (const waiter of this.pending.values()) waiter.reject(new Error("CDP target closed."));
|
||||||
|
this.pending.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(method, params = {}) {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return Promise.reject(new Error(`CDP socket is not open for ${method}.`));
|
||||||
|
}
|
||||||
|
const id = ++this.nextId;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject });
|
||||||
|
this.ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
try { this.ws?.close(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpPage {
|
||||||
|
constructor(browser, target) {
|
||||||
|
this.browser = browser;
|
||||||
|
this.target = target;
|
||||||
|
this.timeoutMs = 30_000;
|
||||||
|
this.cdp = new CdpSocket(target.webSocketDebuggerUrl);
|
||||||
|
this.mouse = {
|
||||||
|
click: async (x, y) => {
|
||||||
|
const px = Number(x);
|
||||||
|
const py = Number(y);
|
||||||
|
if (!Number.isFinite(px) || !Number.isFinite(py)) {
|
||||||
|
throw new TypeError(`Invalid mouse coordinates: ${x}, ${y}`);
|
||||||
|
}
|
||||||
|
await this.cdp.send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved", x: px, y: py, button: "none", buttons: 0,
|
||||||
|
});
|
||||||
|
await this.cdp.send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mousePressed", x: px, y: py, button: "left", buttons: 1, clickCount: 1,
|
||||||
|
});
|
||||||
|
await this.cdp.send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseReleased", x: px, y: py, button: "left", buttons: 0, clickCount: 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.cdp.connect();
|
||||||
|
await Promise.all([
|
||||||
|
this.cdp.send("Page.enable"),
|
||||||
|
this.cdp.send("Runtime.enable"),
|
||||||
|
]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefaultTimeout(ms) {
|
||||||
|
this.timeoutMs = Number(ms) || this.timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _runtimeEvaluate(expression) {
|
||||||
|
const result = await this.cdp.send("Runtime.evaluate", {
|
||||||
|
expression,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
userGesture: true,
|
||||||
|
});
|
||||||
|
if (result.exceptionDetails) {
|
||||||
|
const description = result.exceptionDetails.exception?.description
|
||||||
|
|| result.exceptionDetails.text
|
||||||
|
|| "Runtime.evaluate failed.";
|
||||||
|
throw new Error(description);
|
||||||
|
}
|
||||||
|
return result.result?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(fn, arg) {
|
||||||
|
if (typeof fn === "string") return this._runtimeEvaluate(fn);
|
||||||
|
if (typeof fn !== "function") throw new TypeError("page.evaluate requires a function or expression string.");
|
||||||
|
const argument = arguments.length >= 2 ? JSON.stringify(arg) : "";
|
||||||
|
const expression = argument
|
||||||
|
? `(${fn.toString()})(${argument})`
|
||||||
|
: `(${fn.toString()})()`;
|
||||||
|
return this._runtimeEvaluate(expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto(url, options = {}) {
|
||||||
|
const timeout = Number(options.timeout || this.timeoutMs);
|
||||||
|
const navigation = await this.cdp.send("Page.navigate", { url });
|
||||||
|
if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`);
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeout) {
|
||||||
|
const ready = await this._runtimeEvaluate("document.readyState").catch(() => "loading");
|
||||||
|
if (ready === "interactive" || ready === "complete") return;
|
||||||
|
await wait(25);
|
||||||
|
}
|
||||||
|
throw new Error(`Navigation timed out after ${timeout} ms: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForFunction(fn, arg, options = {}) {
|
||||||
|
const timeout = Number(options.timeout || this.timeoutMs);
|
||||||
|
const started = Date.now();
|
||||||
|
let lastError = null;
|
||||||
|
while (Date.now() - started < timeout) {
|
||||||
|
try {
|
||||||
|
if (await this.evaluate(fn, arg)) return true;
|
||||||
|
lastError = null;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await wait(40);
|
||||||
|
}
|
||||||
|
const suffix = lastError ? ` Last evaluation error: ${lastError.message}` : "";
|
||||||
|
throw new Error(`waitForFunction timed out after ${timeout} ms.${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
locator(selector) {
|
||||||
|
const page = this;
|
||||||
|
const normalized = String(selector || "");
|
||||||
|
return {
|
||||||
|
async click(options = {}) {
|
||||||
|
const timeout = Number(options.timeout || page.timeoutMs);
|
||||||
|
const started = Date.now();
|
||||||
|
let rect = null;
|
||||||
|
while (Date.now() - started < timeout) {
|
||||||
|
rect = await page.evaluate((css) => {
|
||||||
|
const element = document.querySelector(css);
|
||||||
|
if (!element) return null;
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
const disabled = Boolean(element.disabled || element.getAttribute("aria-disabled") === "true");
|
||||||
|
if (disabled || style.display === "none" || style.visibility === "hidden" || box.width <= 0 || box.height <= 0) return null;
|
||||||
|
return {
|
||||||
|
x: box.left + box.width / 2,
|
||||||
|
y: box.top + box.height / 2,
|
||||||
|
};
|
||||||
|
}, normalized).catch(() => null);
|
||||||
|
if (rect && Number.isFinite(rect.x) && Number.isFinite(rect.y)) break;
|
||||||
|
await wait(25);
|
||||||
|
}
|
||||||
|
if (!rect || !Number.isFinite(rect.x) || !Number.isFinite(rect.y)) {
|
||||||
|
throw new Error(`Unable to click ${normalized}: element was not actionable within ${timeout} ms.`);
|
||||||
|
}
|
||||||
|
// CDP Input events enter Chromium through the browser input pipeline and
|
||||||
|
// therefore produce trusted DOM events. This is materially different from
|
||||||
|
// calling element.click(), which would bypass the input-latency gate.
|
||||||
|
await page.mouse.click(rect.x, rect.y);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
this.cdp.close();
|
||||||
|
try {
|
||||||
|
await fetch(`${this.browser.httpOrigin}/json/close/${encodeURIComponent(this.target.id)}`);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpBrowser {
|
||||||
|
constructor(process, userDataDir, httpOrigin) {
|
||||||
|
this.process = process;
|
||||||
|
this.userDataDir = userDataDir;
|
||||||
|
this.httpOrigin = httpOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async newPage() {
|
||||||
|
const response = await fetch(`${this.httpOrigin}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" });
|
||||||
|
if (!response.ok) throw new Error(`Unable to create Chromium target: HTTP ${response.status}`);
|
||||||
|
const target = await response.json();
|
||||||
|
return new CdpPage(this, target).init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (this.process.exitCode == null && !this.process.killed) {
|
||||||
|
try { this.process.kill("SIGTERM"); } catch {}
|
||||||
|
await Promise.race([
|
||||||
|
new Promise((resolve) => this.process.once("exit", resolve)),
|
||||||
|
wait(3000),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (this.process.exitCode == null && !this.process.killed) {
|
||||||
|
try { this.process.kill("SIGKILL"); } catch {}
|
||||||
|
}
|
||||||
|
await rm(this.userDataDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchChromiumCdp({ executablePath = process.env.CHROMIUM_PATH || "/usr/bin/chromium" } = {}) {
|
||||||
|
const userDataDir = await mkdtemp(join(tmpdir(), "jmg-chromium-"));
|
||||||
|
const child = spawn(executablePath, [
|
||||||
|
"--headless=new",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-background-networking",
|
||||||
|
// Headless targets otherwise receive Chromium's background renderer/worker
|
||||||
|
// scheduling priority. Additional-generation acceptance is defined for an
|
||||||
|
// actively used foreground map, so keep the CDP fallback at foreground
|
||||||
|
// scheduling semantics rather than benchmarking an artificially throttled
|
||||||
|
// tab.
|
||||||
|
"--disable-background-timer-throttling",
|
||||||
|
"--disable-backgrounding-occluded-windows",
|
||||||
|
"--disable-renderer-backgrounding",
|
||||||
|
"--no-proxy-server",
|
||||||
|
"--host-resolver-rules=MAP jmg.test 127.0.0.1",
|
||||||
|
"--disable-default-apps",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--disable-sync",
|
||||||
|
"--metrics-recording-only",
|
||||||
|
"--mute-audio",
|
||||||
|
"--no-first-run",
|
||||||
|
"--enable-precise-memory-info",
|
||||||
|
"--remote-debugging-port=0",
|
||||||
|
`--user-data-dir=${userDataDir}`,
|
||||||
|
"about:blank",
|
||||||
|
], { stdio: ["ignore", "ignore", "pipe"] });
|
||||||
|
|
||||||
|
let stderr = "";
|
||||||
|
const endpoint = await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error(`Chromium did not expose a DevTools endpoint.\n${stderr.slice(-4000)}`));
|
||||||
|
}, 15_000);
|
||||||
|
const onData = (chunk) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
stderr = `${stderr}${text}`.slice(-8000);
|
||||||
|
const match = stderr.match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//);
|
||||||
|
if (!match) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.stderr.off("data", onData);
|
||||||
|
resolve(`http://127.0.0.1:${match[1]}`);
|
||||||
|
};
|
||||||
|
child.stderr.on("data", onData);
|
||||||
|
child.once("error", (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
if (code == null || code === 0) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`Chromium exited before CDP startup with code ${code}.\n${stderr}`));
|
||||||
|
});
|
||||||
|
}).catch(async (error) => {
|
||||||
|
try { child.kill("SIGKILL"); } catch {}
|
||||||
|
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new CdpBrowser(child, userDataDir, endpoint);
|
||||||
|
}
|
||||||
102
tests/patch-worker-cancel.mjs
Normal file
102
tests/patch-worker-cancel.mjs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { generateMap } from "../src/mapPipeline.js";
|
||||||
|
import { createWorldMap } from "../src/worldMap.js";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
console.log(`OK: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveSeed(worldSeed, terrainType, variant) {
|
||||||
|
let hash = (worldSeed >>> 0) ^ 0x9e3779b9;
|
||||||
|
hash = Math.imul(hash ^ (variant >>> 0), 668265263) >>> 0;
|
||||||
|
for (const char of String(terrainType || "auto")) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619) >>> 0;
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = generateMap(114514);
|
||||||
|
const world = createWorldMap(initial);
|
||||||
|
const width = 470;
|
||||||
|
const height = 333;
|
||||||
|
const x0 = Math.max(0, Math.min(world.width - width, world.originX + 238));
|
||||||
|
const y0 = Math.max(0, Math.min(world.height - height, world.originY));
|
||||||
|
const rect = { x0, y0, x1: x0 + width, y1: y0 + height, kind: "lasso" };
|
||||||
|
const insetX = Math.max(4, Math.floor(width * 0.16));
|
||||||
|
const insetY = Math.max(4, Math.floor(height * 0.16));
|
||||||
|
rect.polygon = [
|
||||||
|
{ x: rect.x0 + insetX, y: rect.y0 },
|
||||||
|
{ x: rect.x1 - 1, y: rect.y0 + insetY },
|
||||||
|
{ x: rect.x1 - insetX, y: rect.y1 - 1 },
|
||||||
|
{ x: rect.x0, y: rect.y1 - insetY },
|
||||||
|
];
|
||||||
|
const terrainType = initial.terrainTemplate?.terrainType || initial.terrainDebug?.terrainType || "auto";
|
||||||
|
const seed = deriveSeed(world.seed, terrainType, 0);
|
||||||
|
const worker = new Worker(new URL("./browser-worker-node-shim.mjs", import.meta.url), { type: "module" });
|
||||||
|
let terminalMessage = null;
|
||||||
|
let cancelStartedAt = 0;
|
||||||
|
let terminationMs = Infinity;
|
||||||
|
let cancellationTriggered = false;
|
||||||
|
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
try { await worker.terminate(); } catch {}
|
||||||
|
console.error("FAIL: production patch worker did not reach cancellable large-precompute work in time");
|
||||||
|
process.exit(2);
|
||||||
|
}, 35_000);
|
||||||
|
|
||||||
|
worker.on("message", async (message) => {
|
||||||
|
if (message.id !== 1) return;
|
||||||
|
if (message.type !== "progress") {
|
||||||
|
terminalMessage = message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const progress = message.progress || {};
|
||||||
|
if (cancellationTriggered || progress.phase !== "large-candidate-precompute") return;
|
||||||
|
cancellationTriggered = true;
|
||||||
|
// Let the coordinator enter nested-worker work rather than measuring an idle
|
||||||
|
// worker immediately after the phase marker.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
cancelStartedAt = performance.now();
|
||||||
|
await worker.terminate();
|
||||||
|
terminationMs = performance.now() - cancelStartedAt;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
assert(terminationMs < 500, `production patch Worker termination settles within 500 ms (${terminationMs.toFixed(1)} ms)`);
|
||||||
|
// terminate() is the hard cancellation boundary. No terminal candidate can
|
||||||
|
// be published after it resolves.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
assert(terminalMessage === null, "terminated production Worker cannot publish a candidate after cancellation");
|
||||||
|
console.log("All patch-worker cancellation tests passed.");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on("error", async (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
try { await worker.terminate(); } catch {}
|
||||||
|
console.error(error?.stack || String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.postMessage({
|
||||||
|
id: 1,
|
||||||
|
world,
|
||||||
|
rect,
|
||||||
|
options: {
|
||||||
|
patchMode: "expansion",
|
||||||
|
terrainType,
|
||||||
|
variant: 0,
|
||||||
|
seed,
|
||||||
|
maxQualityRetries: 0,
|
||||||
|
qualityTerrainAttempts: 1,
|
||||||
|
acceptBestAvailableQuality: false,
|
||||||
|
includeSeamVisualization: false,
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
searchId: "cancel-integration",
|
||||||
|
operationId: "cancel-integration",
|
||||||
|
committedRevision: 1,
|
||||||
|
workerEpoch: 1,
|
||||||
|
executionAttempt: 1,
|
||||||
|
totalCandidateCount: 1,
|
||||||
|
candidatePlan: [{ candidateId: "cancel:0", candidateOrdinal: 1, variant: 0, seed }],
|
||||||
|
},
|
||||||
|
});
|
||||||
182
tests/patch-worker-mirror-sync.mjs
Normal file
182
tests/patch-worker-mirror-sync.mjs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { generateMap } from "../src/mapPipeline.js";
|
||||||
|
import { createWorldMap } from "../src/worldMap.js";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
console.log(`OK: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value) || ArrayBuffer.isView(value)) return false;
|
||||||
|
const prototype = Object.getPrototypeOf(value);
|
||||||
|
return prototype === Object.prototype || prototype === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceEntryNeedsChunking(value) {
|
||||||
|
return isPlainObject(value) && Object.values(value).some((entry) => ArrayBuffer.isView(entry) || entry instanceof ArrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestFor(world) {
|
||||||
|
const sourceKeys = Object.keys(world.sourceMap || {});
|
||||||
|
const expandedSourceObjects = {};
|
||||||
|
for (const key of sourceKeys) if (sourceEntryNeedsChunking(world.sourceMap[key])) expandedSourceObjects[key] = Object.keys(world.sourceMap[key]);
|
||||||
|
return {
|
||||||
|
rootKeys: Object.keys(world).filter((key) => key !== "fields" && key !== "sourceMap" && key !== "generatedMask"),
|
||||||
|
fieldKeys: Object.keys(world.fields || {}),
|
||||||
|
sourceKeys,
|
||||||
|
expandedSourceObjects,
|
||||||
|
hasGeneratedMask: !!world.generatedMask,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferCopy(value) {
|
||||||
|
if (ArrayBuffer.isView(value)) {
|
||||||
|
if (value instanceof DataView) {
|
||||||
|
const buffer = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
||||||
|
return { value: new DataView(buffer), transfer: [buffer], bytes: buffer.byteLength };
|
||||||
|
}
|
||||||
|
const copy = new value.constructor(value);
|
||||||
|
return { value: copy, transfer: [copy.buffer], bytes: copy.byteLength };
|
||||||
|
}
|
||||||
|
if (value instanceof ArrayBuffer) {
|
||||||
|
const copy = value.slice(0);
|
||||||
|
return { value: copy, transfer: [copy], bytes: copy.byteLength };
|
||||||
|
}
|
||||||
|
return { value, transfer: [], bytes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitFor(worker, predicate, timeoutMs = 120_000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => done(new Error("Worker response timed out.")), timeoutMs);
|
||||||
|
const onMessage = (message) => { if (predicate(message)) done(null, message); };
|
||||||
|
const onError = (error) => done(error);
|
||||||
|
const done = (error, value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
worker.off("message", onMessage);
|
||||||
|
worker.off("error", onError);
|
||||||
|
if (error) reject(error); else resolve(value);
|
||||||
|
};
|
||||||
|
worker.on("message", onMessage);
|
||||||
|
worker.on("error", onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSync(worker, envelope, type, payload = {}, transfer = []) {
|
||||||
|
const sequence = ++envelope.sequence;
|
||||||
|
const reply = waitFor(worker, (message) => message?.type === "patch-mirror-sync-ack"
|
||||||
|
&& message.id === envelope.id && message.syncId === envelope.syncId && message.sequence === sequence);
|
||||||
|
worker.postMessage({ id: envelope.id, type, syncId: envelope.syncId, sequence, ...payload }, transfer);
|
||||||
|
const ack = await reply;
|
||||||
|
if (!ack.ok) throw new Error(ack.error || `${type} failed`);
|
||||||
|
return ack;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const initial = generateMap(114514);
|
||||||
|
const world = createWorldMap(initial);
|
||||||
|
const originalElevationBuffer = world.fields.elevation.buffer;
|
||||||
|
const worker = new Worker(new URL("./browser-worker-node-shim.mjs", import.meta.url), { type: "module" });
|
||||||
|
const envelope = { id: 91, syncId: "node-cold-sync:1", sequence: 0 };
|
||||||
|
const manifest = manifestFor(world);
|
||||||
|
let maxBinaryChunkBytes = 0;
|
||||||
|
const syncStartedAt = performance.now();
|
||||||
|
try {
|
||||||
|
await sendSync(worker, envelope, "patch-mirror-sync-start", { committedRevision: 1, manifest });
|
||||||
|
for (const key of manifest.rootKeys) {
|
||||||
|
const prepared = transferCopy(world[key]);
|
||||||
|
maxBinaryChunkBytes = Math.max(maxBinaryChunkBytes, prepared.bytes);
|
||||||
|
await sendSync(worker, envelope, "patch-mirror-sync-root", { key, value: prepared.value }, prepared.transfer);
|
||||||
|
}
|
||||||
|
for (const key of manifest.fieldKeys) {
|
||||||
|
const prepared = transferCopy(world.fields[key]);
|
||||||
|
maxBinaryChunkBytes = Math.max(maxBinaryChunkBytes, prepared.bytes);
|
||||||
|
await sendSync(worker, envelope, "patch-mirror-sync-field", { key, value: prepared.value }, prepared.transfer);
|
||||||
|
}
|
||||||
|
if (manifest.hasGeneratedMask) {
|
||||||
|
const prepared = transferCopy(world.generatedMask);
|
||||||
|
maxBinaryChunkBytes = Math.max(maxBinaryChunkBytes, prepared.bytes);
|
||||||
|
await sendSync(worker, envelope, "patch-mirror-sync-generated-mask", { value: prepared.value }, prepared.transfer);
|
||||||
|
}
|
||||||
|
for (const key of manifest.sourceKeys) {
|
||||||
|
const value = world.sourceMap[key];
|
||||||
|
const childKeys = manifest.expandedSourceObjects[key];
|
||||||
|
if (Array.isArray(childKeys)) {
|
||||||
|
await sendSync(worker, envelope, "patch-mirror-sync-source-object-start", { key });
|
||||||
|
for (const childKey of childKeys) {
|
||||||
|
const prepared = transferCopy(value[childKey]);
|
||||||
|
maxBinaryChunkBytes = Math.max(maxBinaryChunkBytes, prepared.bytes);
|
||||||
|
await sendSync(worker, envelope, "patch-mirror-sync-source-object-entry", { key, childKey, value: prepared.value }, prepared.transfer);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const prepared = transferCopy(value);
|
||||||
|
maxBinaryChunkBytes = Math.max(maxBinaryChunkBytes, prepared.bytes);
|
||||||
|
await sendSync(worker, envelope, "patch-mirror-sync-source", { key, value: prepared.value }, prepared.transfer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const finish = await sendSync(worker, envelope, "patch-mirror-sync-finish");
|
||||||
|
const syncMs = performance.now() - syncStartedAt;
|
||||||
|
assert(finish.mirrorCommittedRevision === 1, "cold mirror bootstrap installs the requested committed revision only after finish");
|
||||||
|
assert(world.fields.elevation.buffer === originalElevationBuffer && world.fields.elevation.byteLength > 0,
|
||||||
|
"cold mirror bootstrap transfers copies and never detaches the main committed rasters");
|
||||||
|
assert(maxBinaryChunkBytes < 1024 * 1024,
|
||||||
|
`cold mirror binary synchronization remains sub-megabyte per main-thread dispatch (${maxBinaryChunkBytes} bytes max)`);
|
||||||
|
assert(syncMs < 10_000, `cold mirror synchronization completes without a whole-world structured-clone stall (${Math.round(syncMs)} ms)`);
|
||||||
|
|
||||||
|
const rect = {
|
||||||
|
x0: world.originX + 72,
|
||||||
|
y0: world.originY + 58,
|
||||||
|
x1: world.originX + 132,
|
||||||
|
y1: world.originY + 118,
|
||||||
|
};
|
||||||
|
const candidate = { candidateId: "node-sync:1", candidateOrdinal: 1, variant: 1, seed: 0x51a7c3d3 };
|
||||||
|
const resultPromise = waitFor(worker, (message) => message?.id === 92 && message?.type !== "progress", 180_000);
|
||||||
|
worker.postMessage({
|
||||||
|
id: 92,
|
||||||
|
world: null,
|
||||||
|
rect,
|
||||||
|
options: {
|
||||||
|
patchMode: "regeneration",
|
||||||
|
terrainType: "auto",
|
||||||
|
variant: candidate.variant,
|
||||||
|
seed: candidate.seed,
|
||||||
|
maxQualityRetries: 0,
|
||||||
|
qualityTerrainAttempts: 1,
|
||||||
|
acceptBestAvailableQuality: false,
|
||||||
|
includeSeamVisualization: false,
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
searchId: "node-sync-search",
|
||||||
|
operationId: "node-sync-search",
|
||||||
|
committedRevision: 1,
|
||||||
|
workerEpoch: 1,
|
||||||
|
executionAttempt: 1,
|
||||||
|
totalCandidateCount: 1,
|
||||||
|
reuseCommittedMirror: true,
|
||||||
|
candidatePlan: [candidate],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await resultPromise;
|
||||||
|
assert(result.ok === true && result.result?.ok === true, "a cold-synchronized mirror can run a complete production patch without retransmitting world");
|
||||||
|
assert(result.result?.acceptedWorldHash && result.result?.applyToken, "cold-synchronized candidate returns transactional hash and Apply token");
|
||||||
|
|
||||||
|
const ackId = "node-sync-apply";
|
||||||
|
const applyPromise = waitFor(worker, (message) => message?.type === "patch-apply-ack-result" && message.ackId === ackId);
|
||||||
|
worker.postMessage({
|
||||||
|
type: "patch-apply-ack",
|
||||||
|
ackId,
|
||||||
|
applyToken: result.result.applyToken,
|
||||||
|
baseCommittedRevision: 1,
|
||||||
|
committedRevision: 2,
|
||||||
|
});
|
||||||
|
const applyAck = await applyPromise;
|
||||||
|
assert(applyAck.ok === true && applyAck.mirrorCommittedRevision === 2, "Apply ACK advances the synchronized persistent mirror by exactly one revision");
|
||||||
|
assert(applyAck.mirrorHash === result.result.acceptedWorldHash, "Apply ACK mirror hash matches the accepted candidate hash");
|
||||||
|
} finally {
|
||||||
|
await worker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
|
console.log("All patch Worker mirror synchronization tests passed.");
|
||||||
268
tests/run-additional-generation-browser.mjs
Normal file
268
tests/run-additional-generation-browser.mjs
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { extname, resolve, sep } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const workspace = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||||
|
const profile = process.env.BROWSER_E2E_PROFILE === "release" ? "release" : "smoke";
|
||||||
|
const configuredRepetitions = Number(process.env.BROWSER_E2E_REPETITIONS);
|
||||||
|
const repetitions = Number.isFinite(configuredRepetitions) && configuredRepetitions > 0
|
||||||
|
? Math.max(1, Math.floor(configuredRepetitions))
|
||||||
|
: profile === "release" ? 20 : 1;
|
||||||
|
const timeoutMs = profile === "release" ? 30 * 60_000 : 12 * 60_000;
|
||||||
|
const allWorkloads = [
|
||||||
|
{ name: "regeneration-rect", mode: "regeneration", shape: "rect", width: 60, height: 60 },
|
||||||
|
{ name: "expansion-lasso", mode: "expansion", shape: "lasso", width: 96, height: 72 },
|
||||||
|
{ name: "regeneration-cancel", mode: "regeneration", shape: "rect", width: 60, height: 60, kind: "cancel" },
|
||||||
|
{ name: "regeneration-large", mode: "regeneration", shape: "rect", width: 259, height: 184 },
|
||||||
|
{ name: "expansion-max-visible", mode: "expansion", shape: "lasso", width: 470, height: 333 },
|
||||||
|
];
|
||||||
|
const requestedWorkloads = new Set(String(process.env.BROWSER_E2E_WORKLOADS || "")
|
||||||
|
.split(",").map((value) => value.trim()).filter(Boolean));
|
||||||
|
const workloads = requestedWorkloads.size
|
||||||
|
? allWorkloads.filter((workload) => requestedWorkloads.has(workload.name))
|
||||||
|
: allWorkloads;
|
||||||
|
if (!workloads.length) {
|
||||||
|
throw new Error(`No browser E2E workloads matched BROWSER_E2E_WORKLOADS=${process.env.BROWSER_E2E_WORKLOADS || ""}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
function percentile95(values) {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browserHeap(page, label) {
|
||||||
|
return page.evaluate((sampleLabel) => performance.memory ? {
|
||||||
|
label: sampleLabel,
|
||||||
|
usedJSHeapSize: performance.memory.usedJSHeapSize,
|
||||||
|
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
||||||
|
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
|
||||||
|
} : null, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAppWorkload(page, origin, workload) {
|
||||||
|
await page.goto(`${origin}/index.html?additionalGenerationE2E=1`, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||||
|
await page.waitForFunction(() => window.__additionalGenerationE2E?.snapshot().ready === true, null, { timeout: timeoutMs });
|
||||||
|
const ready = await page.evaluate(() => window.__additionalGenerationE2E.snapshot());
|
||||||
|
const x0 = workload.mode === "expansion"
|
||||||
|
? Math.max(0, Math.min(ready.worldWidth - workload.width, ready.originX + 238))
|
||||||
|
: Math.max(0, Math.min(ready.worldWidth - workload.width, ready.originX));
|
||||||
|
const y0 = Math.max(0, Math.min(ready.worldHeight - workload.height, ready.originY));
|
||||||
|
const rect = { x0, y0, x1: x0 + workload.width, y1: y0 + workload.height };
|
||||||
|
if (workload.shape === "lasso") {
|
||||||
|
const insetX = Math.max(4, Math.floor(workload.width * 0.16));
|
||||||
|
const insetY = Math.max(4, Math.floor(workload.height * 0.16));
|
||||||
|
rect.kind = "lasso";
|
||||||
|
rect.polygon = [
|
||||||
|
{ x: rect.x0 + insetX, y: rect.y0 },
|
||||||
|
{ x: rect.x1 - 1, y: rect.y0 + insetY },
|
||||||
|
{ x: rect.x1 - insetX, y: rect.y1 - 1 },
|
||||||
|
{ x: rect.x0, y: rect.y1 - insetY },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const heap = [await browserHeap(page, "ready")].filter(Boolean);
|
||||||
|
const before = await page.evaluate((config) => window.__additionalGenerationE2E.configure(config), {
|
||||||
|
rect, patchMode: workload.mode, terrainType: "auto", variant: 0, seamDiagnostics: false,
|
||||||
|
});
|
||||||
|
if (workload.kind === "cancel") {
|
||||||
|
// Resolve the physical target before generation begins. A selector lookup
|
||||||
|
// performed after dispatch can itself be delayed by a synchronous main-
|
||||||
|
// thread slice and would therefore hide the very input-latency regression
|
||||||
|
// this test is intended to detect.
|
||||||
|
const cancelPoint = await page.evaluate(() => {
|
||||||
|
const button = document.querySelector("#cancelPatchGeneration");
|
||||||
|
if (!button) throw new Error("Cancel button is missing.");
|
||||||
|
button.scrollIntoView({ block: "center", inline: "center" });
|
||||||
|
const box = button.getBoundingClientRect();
|
||||||
|
if (box.width <= 0 || box.height <= 0) throw new Error("Cancel button is not visible.");
|
||||||
|
return { x: box.left + box.width / 2, y: box.top + box.height / 2 };
|
||||||
|
});
|
||||||
|
await page.evaluate(() => { window.__additionalGenerationE2EPending = window.__additionalGenerationE2E.generate(); });
|
||||||
|
// Give the operation enough time to cross the first animation-frame yield
|
||||||
|
// and enter Worker/mirror work, but do not probe the renderer with
|
||||||
|
// Runtime.evaluate here: that probe can block behind a long task and cause
|
||||||
|
// the test to click only after generation has already finished.
|
||||||
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
||||||
|
const cancelStartedAt = performance.now();
|
||||||
|
await page.mouse.click(cancelPoint.x, cancelPoint.y);
|
||||||
|
const inputDispatchWallMs = performance.now() - cancelStartedAt;
|
||||||
|
await page.evaluate(() => window.__additionalGenerationE2EPending);
|
||||||
|
const inputToSettledWallMs = performance.now() - cancelStartedAt;
|
||||||
|
const { settled, cancelTiming } = await page.evaluate(() => ({
|
||||||
|
settled: window.__additionalGenerationE2E.snapshot(),
|
||||||
|
cancelTiming: window.__additionalGenerationE2ECancelTiming,
|
||||||
|
}));
|
||||||
|
const assertions = {
|
||||||
|
trustedPhysicalInput: cancelTiming?.isTrusted === true,
|
||||||
|
inputReachedHandlerWithinBudget: Number(cancelTiming?.inputToHandlerMs) < 500,
|
||||||
|
handlerCancelledWithinBudget: Number(cancelTiming?.handlerToCancelledMs) < 500,
|
||||||
|
inputCancelledWithinBudget: Number(cancelTiming?.inputToCancelledMs) < 500,
|
||||||
|
inputDispatchReturnedWithinBudget: inputDispatchWallMs < 500,
|
||||||
|
externalInputToSettledWithinBudget: inputToSettledWallMs < 500,
|
||||||
|
generationStopped: !settled.patchBusy && cancelTiming?.patchBusyAfter === false,
|
||||||
|
noPreviewPublished: !settled.pending,
|
||||||
|
previousCanvasPreserved: settled.canvasDigest === before.canvasDigest,
|
||||||
|
cancellationReported: /cancel/i.test(`${settled.patchStatus} ${settled.progressText}`),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
status: Object.values(assertions).every(Boolean) ? "pass" : "fail",
|
||||||
|
workload: { ...workload, selection: rect },
|
||||||
|
timing: {
|
||||||
|
patchWallMs: inputToSettledWallMs,
|
||||||
|
inputDispatchWallMs,
|
||||||
|
inputToSettledWallMs,
|
||||||
|
patchBudgetMs: 500,
|
||||||
|
patchMaxProgressGapMs: 0,
|
||||||
|
...cancelTiming,
|
||||||
|
},
|
||||||
|
memory: null,
|
||||||
|
assertions,
|
||||||
|
before,
|
||||||
|
cancelTiming,
|
||||||
|
settled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const patchStartedAt = performance.now();
|
||||||
|
const generated = await page.evaluate(() => window.__additionalGenerationE2E.generate());
|
||||||
|
const patchWallMs = performance.now() - patchStartedAt;
|
||||||
|
const afterGenerateHeap = await browserHeap(page, "after-generate");
|
||||||
|
if (afterGenerateHeap) heap.push(afterGenerateHeap);
|
||||||
|
const applyStartedAt = performance.now();
|
||||||
|
const applied = generated.pending
|
||||||
|
? await page.evaluate(() => window.__additionalGenerationE2E.apply())
|
||||||
|
: generated;
|
||||||
|
const applyWallMs = performance.now() - applyStartedAt;
|
||||||
|
const afterApplyHeap = await browserHeap(page, "after-apply");
|
||||||
|
if (afterApplyHeap) heap.push(afterApplyHeap);
|
||||||
|
const boundedProgress = (generated.progressEvents || []).filter((event) => event.boundedWork);
|
||||||
|
const progressTimes = (generated.progressEvents || []).map((event) => Number(event.at)).filter(Number.isFinite);
|
||||||
|
let patchMaxProgressGapMs = 0;
|
||||||
|
for (let index = 1; index < progressTimes.length; index++) {
|
||||||
|
patchMaxProgressGapMs = Math.max(patchMaxProgressGapMs, progressTimes[index] - progressTimes[index - 1]);
|
||||||
|
}
|
||||||
|
const boundedProtocolByUnit = new Map();
|
||||||
|
let boundedProgressMonotonic = boundedProgress.length > 0;
|
||||||
|
for (const event of boundedProgress) {
|
||||||
|
const unitId = String(event.workUnitId || "");
|
||||||
|
const completed = Number(event.completed);
|
||||||
|
const total = Number(event.total);
|
||||||
|
if (!unitId || !Number.isFinite(completed) || !Number.isFinite(total) || completed < 0 || completed > total) {
|
||||||
|
boundedProgressMonotonic = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const previous = boundedProtocolByUnit.get(unitId);
|
||||||
|
if (previous && (previous.total !== total || completed < previous.completed)) {
|
||||||
|
boundedProgressMonotonic = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
boundedProtocolByUnit.set(unitId, { completed, total });
|
||||||
|
}
|
||||||
|
const assertions = {
|
||||||
|
acceptedPreviewPublished: generated.pending && generated.publicationStatus === "published" && generated.searchStatus === "succeeded",
|
||||||
|
canvasChangedAtPublish: !!generated.canvasDigest && generated.canvasDigest !== before.canvasDigest,
|
||||||
|
completeStatsPublished: !!generated.statsText && !!generated.diagnosticsText,
|
||||||
|
applyCommittedOneRevision: generated.pending && applied.committedRevision === before.committedRevision + 1,
|
||||||
|
applyClearedPending: generated.pending && !applied.pending,
|
||||||
|
applyMirrorAcked: generated.pending && applied.workerMirrorRevision === applied.committedRevision,
|
||||||
|
boundedProgressValid: boundedProgressMonotonic,
|
||||||
|
patchBudgetMet: patchWallMs < 60_000,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
status: Object.values(assertions).every(Boolean) ? "pass" : "fail",
|
||||||
|
workload: { ...workload, selection: rect },
|
||||||
|
timing: { patchWallMs, applyWallMs, patchBudgetMs: 60_000, patchMaxProgressGapMs },
|
||||||
|
memory: heap.length ? { snapshots: heap, peakUsedJSHeapSize: Math.max(...heap.map((item) => item.usedJSHeapSize)) } : null,
|
||||||
|
assertions,
|
||||||
|
before,
|
||||||
|
generated,
|
||||||
|
applied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let launchBrowser;
|
||||||
|
let browserBackend = "playwright";
|
||||||
|
try {
|
||||||
|
const playwright = await import("playwright");
|
||||||
|
launchBrowser = () => playwright.chromium.launch({ headless: true });
|
||||||
|
} catch (playwrightError) {
|
||||||
|
try {
|
||||||
|
const { launchChromiumCdp } = await import("./chromium-cdp-page.mjs");
|
||||||
|
launchBrowser = () => launchChromiumCdp();
|
||||||
|
browserBackend = "chromium-cdp";
|
||||||
|
console.error(`[browser-e2e] Playwright unavailable; using direct Chromium CDP backend (${playwrightError?.message || playwrightError}).`);
|
||||||
|
} catch (cdpError) {
|
||||||
|
console.error("Browser E2E infrastructure error: neither Playwright nor direct Chromium CDP is available.");
|
||||||
|
console.error(cdpError?.message || String(cdpError));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launchBrowser) {
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url || "/", "http://127.0.0.1");
|
||||||
|
const relative = decodeURIComponent(url.pathname === "/" ? "/tests/additional-generation-e2e.html" : url.pathname);
|
||||||
|
const file = resolve(workspace, `.${relative}`);
|
||||||
|
if (file !== workspace && !file.startsWith(`${workspace}${sep}`)) throw new Error("Path outside workspace");
|
||||||
|
const bytes = await readFile(file);
|
||||||
|
response.writeHead(200, { "content-type": mime[extname(file)] || "application/octet-stream", "cache-control": "no-store" });
|
||||||
|
response.end(bytes);
|
||||||
|
} catch (error) {
|
||||||
|
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
||||||
|
response.end(error?.message || "Not found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
|
||||||
|
const address = server.address();
|
||||||
|
// System Chromium policies in some CI images block loopback URL literals.
|
||||||
|
// The direct-CDP launcher maps this reserved test host back to 127.0.0.1,
|
||||||
|
// preserving a real HTTP origin for modules/workers without bypassing app code.
|
||||||
|
const originHost = browserBackend === "chromium-cdp" ? "jmg.test" : "127.0.0.1";
|
||||||
|
const origin = `http://${originHost}:${address.port}`;
|
||||||
|
const browser = await launchBrowser();
|
||||||
|
const reports = [];
|
||||||
|
try {
|
||||||
|
for (const workload of workloads) {
|
||||||
|
for (let iteration = 0; iteration < repetitions; iteration++) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
page.setDefaultTimeout(timeoutMs);
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const report = await runAppWorkload(page, origin, workload);
|
||||||
|
reports.push({ workloadName: workload.name, iteration, runnerWallMs: performance.now() - startedAt, ...report });
|
||||||
|
await page.close();
|
||||||
|
console.error(`[browser-e2e:${browserBackend}] ${workload.name} ${iteration + 1}/${repetitions}: ${report.status} ${Math.round(report.timing?.patchWallMs || 0)}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await new Promise((resolveClose) => server.close(resolveClose));
|
||||||
|
}
|
||||||
|
|
||||||
|
const strata = Object.fromEntries(workloads.map((workload) => {
|
||||||
|
const rows = reports.filter((report) => report.workloadName === workload.name);
|
||||||
|
const budgetMs = workload.kind === "cancel" ? 500 : 60_000;
|
||||||
|
return [workload.name, {
|
||||||
|
samples: rows.length,
|
||||||
|
pass: rows.length > 0 && rows.every((row) => row.status === "pass"),
|
||||||
|
patchBudgetMs: budgetMs,
|
||||||
|
patchP95Ms: rows.length ? percentile95(rows.map((row) => Number(row.timing?.patchWallMs || Infinity))) : Infinity,
|
||||||
|
maxProgressGapMs: rows.length ? Math.max(...rows.map((row) => Number(row.timing?.patchMaxProgressGapMs || 0))) : Infinity,
|
||||||
|
peakHeapBytes: rows.length ? Math.max(...rows.map((row) => Number(row.memory?.peakUsedJSHeapSize || 0))) : 0,
|
||||||
|
}];
|
||||||
|
}));
|
||||||
|
const expectedSamples = repetitions;
|
||||||
|
const releasePass = Object.values(strata).every(
|
||||||
|
(row) => row.samples === expectedSamples && row.pass && row.patchP95Ms < row.patchBudgetMs,
|
||||||
|
);
|
||||||
|
const summary = { profile, repetitions, browserBackend, releasePass, strata, reports };
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
if (!releasePass) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
@ -2,27 +2,57 @@ import { performance } from "node:perf_hooks";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const suites = [
|
const defaultSuites = [
|
||||||
|
"additional-generation-unit",
|
||||||
|
"additional-generation-coverage-worker",
|
||||||
"core",
|
"core",
|
||||||
"terrain",
|
"terrain",
|
||||||
|
"terrain-name",
|
||||||
"admin",
|
"admin",
|
||||||
|
"patch",
|
||||||
|
"patch-large",
|
||||||
"determinism-114514",
|
"determinism-114514",
|
||||||
"determinism-12345",
|
"determinism-12345",
|
||||||
"determinism-54321",
|
"determinism-54321",
|
||||||
"determinism-777",
|
"determinism-777",
|
||||||
"determinism-999",
|
"determinism-999",
|
||||||
];
|
];
|
||||||
|
const requestedSuites = String(process.env.TEST_SUITES || "").split(",").map((value) => value.trim()).filter(Boolean);
|
||||||
|
const suites = requestedSuites.length ? requestedSuites : defaultSuites;
|
||||||
const concurrency = Math.max(1, Math.min(2, Number(process.env.TEST_CONCURRENCY) || 1));
|
const concurrency = Math.max(1, Math.min(2, Number(process.env.TEST_CONCURRENCY) || 1));
|
||||||
const timeoutMs = 180_000;
|
// Full-map shards can briefly peak at several hundred MB. CI may opt into a
|
||||||
|
// handoff delay when its runtime needs extra time to reclaim a completed child.
|
||||||
|
const suiteCooldownMs = Math.max(0, Number(process.env.TEST_SUITE_COOLDOWN_MS ?? 0));
|
||||||
|
const suiteTimeoutMs = {
|
||||||
|
"additional-generation-unit": 180_000,
|
||||||
|
"additional-generation-coverage-worker": 120_000,
|
||||||
|
core: 360_000,
|
||||||
|
terrain: 600_000,
|
||||||
|
"terrain-name": 240_000,
|
||||||
|
admin: 300_000,
|
||||||
|
patch: 300_000,
|
||||||
|
"patch-large": 600_000,
|
||||||
|
};
|
||||||
|
const defaultTimeoutMs = 240_000;
|
||||||
const maxOutputBytes = 32 * 1024 * 1024;
|
const maxOutputBytes = 32 * 1024 * 1024;
|
||||||
const cwd = fileURLToPath(new URL(".", import.meta.url));
|
const cwd = fileURLToPath(new URL(".", import.meta.url));
|
||||||
const testFile = fileURLToPath(new URL("./test.js", import.meta.url));
|
const testFile = fileURLToPath(new URL("./test.js", import.meta.url));
|
||||||
|
const additionalUnitFile = fileURLToPath(new URL("./additional-generation-unit.mjs", import.meta.url));
|
||||||
|
const additionalCoverageWorkerFile = fileURLToPath(new URL("./additional-generation-coverage-worker.mjs", import.meta.url));
|
||||||
|
|
||||||
function runSuite(suite) {
|
function runSuite(suite) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
const timeoutMs = suiteTimeoutMs[suite] || defaultTimeoutMs;
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
console.error(`[test-all] start ${suite}`);
|
console.error(`[test-all] start ${suite}`);
|
||||||
const child = spawn(process.execPath, [testFile, `--suite=${suite}`], {
|
const standaloneFile = suite === "additional-generation-unit"
|
||||||
|
? additionalUnitFile
|
||||||
|
: suite === "additional-generation-coverage-worker"
|
||||||
|
? additionalCoverageWorkerFile
|
||||||
|
: null;
|
||||||
|
const commandFile = standaloneFile || testFile;
|
||||||
|
const commandArgs = standaloneFile ? [commandFile] : [commandFile, `--suite=${suite}`];
|
||||||
|
const child = spawn(process.execPath, commandArgs, {
|
||||||
cwd,
|
cwd,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
@ -112,6 +142,9 @@ async function worker() {
|
||||||
const suite = queue.shift();
|
const suite = queue.shift();
|
||||||
if (!suite) return;
|
if (!suite) return;
|
||||||
completed.push(await runSuite(suite));
|
completed.push(await runSuite(suite));
|
||||||
|
if (suiteCooldownMs > 0 && queue.length > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, suiteCooldownMs));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,17 +159,16 @@ const failures = completed.reduce(
|
||||||
const infrastructureFailure = completed.some(
|
const infrastructureFailure = completed.some(
|
||||||
(row) => row.status !== 0 || row.signal || row.timedOut || row.outputOverflow || row.infrastructureError,
|
(row) => row.status !== 0 || row.signal || row.timedOut || row.outputOverflow || row.infrastructureError,
|
||||||
);
|
);
|
||||||
const underThreeMinutes = wallSeconds < 180 && completed.every(
|
const withinSuiteBudgets = completed.every((row) => !row.timedOut);
|
||||||
(row) => row.seconds < 180 && !row.timedOut,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
underThreeMinutes,
|
withinSuiteBudgets,
|
||||||
wallSeconds,
|
wallSeconds,
|
||||||
concurrency,
|
concurrency,
|
||||||
|
suiteCooldownMs,
|
||||||
failures,
|
failures,
|
||||||
infrastructureFailure,
|
infrastructureFailure,
|
||||||
suites: completed,
|
suites: completed,
|
||||||
};
|
};
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
if (!underThreeMinutes || infrastructureFailure || failures > 0) process.exitCode = 1;
|
if (!withinSuiteBudgets || infrastructureFailure || failures > 0) process.exitCode = 1;
|
||||||
|
|
|
||||||
363
tests/test.js
363
tests/test.js
|
|
@ -1,4 +1,7 @@
|
||||||
import { generateMap, MAP_W, MAP_H, indexOf } from "../src/mapPipeline.js";
|
import { generateMap, MAP_W, MAP_H, indexOf } from "../src/mapPipeline.js";
|
||||||
|
import { createWorldMap } from "../src/worldMap.js";
|
||||||
|
import { PATCH_MIN_HEIGHT, generatePatch } from "../src/mapPatch.js";
|
||||||
|
import { applyCommittedMirrorDelta, buildCommittedMirrorDelta, runPatchCandidateSearch } from "../src/mapPatchWorker.js";
|
||||||
import {
|
import {
|
||||||
CUSTOM_NAME_LIST,
|
CUSTOM_NAME_LIST,
|
||||||
NAME_KANJI_POOLS,
|
NAME_KANJI_POOLS,
|
||||||
|
|
@ -40,7 +43,7 @@ async function readLocalText(path) {
|
||||||
return readFile(new URL(path, import.meta.url), "utf8");
|
return readFile(new URL(path, import.meta.url), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, mapPatchSource, worldMapSource, municipalSource, testSource] = await Promise.all([
|
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, mapPatchSource, mapPatchWorkerSource, committedWorldDeltaSource, worldMapSource, municipalSource, testSource] = await Promise.all([
|
||||||
readLocalText("../src/names.js"),
|
readLocalText("../src/names.js"),
|
||||||
readLocalText("../src/mapPipeline.js"),
|
readLocalText("../src/mapPipeline.js"),
|
||||||
readLocalText("../src/mapOutput.js"),
|
readLocalText("../src/mapOutput.js"),
|
||||||
|
|
@ -50,10 +53,17 @@ const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rende
|
||||||
readLocalText("../src/mapPipeline.js"),
|
readLocalText("../src/mapPipeline.js"),
|
||||||
readLocalText("../src/mapAdminStage.js"),
|
readLocalText("../src/mapAdminStage.js"),
|
||||||
readLocalText("../src/mapPatch.js"),
|
readLocalText("../src/mapPatch.js"),
|
||||||
|
readLocalText("../src/mapPatchWorker.js"),
|
||||||
|
readLocalText("../src/committedWorldDelta.js"),
|
||||||
readLocalText("../src/worldMap.js"),
|
readLocalText("../src/worldMap.js"),
|
||||||
readLocalText("../src/mapMunicipalCoherence.js"),
|
readLocalText("../src/mapMunicipalCoherence.js"),
|
||||||
readLocalText("./test.js"),
|
readLocalText("./test.js"),
|
||||||
]);
|
]);
|
||||||
|
const derivePatchSeedStart = appSource.indexOf("function derivePatchSeed");
|
||||||
|
const derivePatchSeedEnd = derivePatchSeedStart >= 0 ? appSource.indexOf("\n}", derivePatchSeedStart) : -1;
|
||||||
|
const derivePatchSeedSource = derivePatchSeedStart >= 0 && derivePatchSeedEnd > derivePatchSeedStart
|
||||||
|
? appSource.slice(derivePatchSeedStart, derivePatchSeedEnd + 2)
|
||||||
|
: "";
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (condition) logLines.push(`OK: ${message}`);
|
if (condition) logLines.push(`OK: ${message}`);
|
||||||
|
|
@ -63,6 +73,14 @@ function assert(condition, message) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a, b) {
|
||||||
|
if (!a || !b || a.length !== b.length) return false;
|
||||||
|
for (let index = 0; index < a.length; index++) {
|
||||||
|
if (a[index] !== b[index] && !(Number.isNaN(a[index]) && Number.isNaN(b[index]))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function terrainBoundaryTargetForMetrics(map, i) {
|
function terrainBoundaryTargetForMetrics(map, i) {
|
||||||
const lu = map.landuse[i];
|
const lu = map.landuse[i];
|
||||||
const urbanPenalty = Math.min(1, (lu === 3 ? 1.45 : lu === 2 ? 1.12 : lu === 4 ? 0.95 : lu === 7 ? 0.90 : lu === 8 ? 0.64 : lu === 5 || lu === 6 ? 0.48 : 0) + map.populationDensity[i] * 1.35);
|
const urbanPenalty = Math.min(1, (lu === 3 ? 1.45 : lu === 2 ? 1.12 : lu === 4 ? 0.95 : lu === 7 ? 0.90 : lu === 8 ? 0.64 : lu === 5 || lu === 6 ? 0.48 : 0) + map.populationDensity[i] * 1.35);
|
||||||
|
|
@ -686,7 +704,7 @@ function transportConnectivityMetrics(map) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const size = MAP_W * MAP_H;
|
const size = MAP_W * MAP_H;
|
||||||
let capitalNameMaps = [];
|
let terrainSeedSummaries = [];
|
||||||
if (suiteEnabled("core")) {
|
if (suiteEnabled("core")) {
|
||||||
const map = generateTestMap(12345);
|
const map = generateTestMap(12345);
|
||||||
const other = generateTestMap(54321);
|
const other = generateTestMap(54321);
|
||||||
|
|
@ -820,11 +838,216 @@ try {
|
||||||
assert(!mapPatchSource.includes("patchAlpha(x, y, rects, 0)"), "patch admin repair uses the active patch seed");
|
assert(!mapPatchSource.includes("patchAlpha(x, y, rects, 0)"), "patch admin repair uses the active patch seed");
|
||||||
assert(mapPatchSource.includes("refreshPatchInfluenceFields") && mapPatchSource.includes("roadCellsPainted"), "patch generation refreshes derived transport influence fields after path merges");
|
assert(mapPatchSource.includes("refreshPatchInfluenceFields") && mapPatchSource.includes("roadCellsPainted"), "patch generation refreshes derived transport influence fields after path merges");
|
||||||
assert(mapPatchSource.includes("normalizeGeneratedPointIds"), "patch generation normalizes generated point admin and prefecture ids");
|
assert(mapPatchSource.includes("normalizeGeneratedPointIds"), "patch generation normalizes generated point admin and prefecture ids");
|
||||||
assert(mapPatchSource.includes("prepareProductionTerrain") && mapPatchSource.includes("expansion-production-natural-overflow") && mapPatchSource.includes("regeneration-full-pipeline"), "patch modes retain the full human/admin pipeline while fast-selecting naturalized expansion terrain");
|
assert(mapPatchSource.includes("generateUnifiedWorldNativePatchCandidate") && mapPatchSource.includes("generateMap(seed") && mapPatchSource.includes("unified-world-native-patch"), "patch modes execute the complete production generation pipeline");
|
||||||
assert(!mapPatchSource.includes("generateVariablePatchCandidate") && !mapPatchSource.includes("PATCH_VARIABLE_CANDIDATE_ENABLED"), "retired variable rectangle candidate implementation is removed");
|
assert(!mapPatchSource.includes("generateVariablePatchCandidate") && !mapPatchSource.includes("PATCH_VARIABLE_CANDIDATE_ENABLED"), "retired variable rectangle candidate implementation is removed");
|
||||||
assert(mapPatchSource.includes("resolvePatchMode") && mapPatchSource.includes("PATCH_MODE_EXPANSION") && mapPatchSource.includes("PATCH_MODE_REGENERATION"), "patch generation separates expansion and regeneration modes");
|
assert(mapPatchSource.includes("resolvePatchMode") && mapPatchSource.includes("PATCH_MODE_EXPANSION") && mapPatchSource.includes("PATCH_MODE_REGENERATION"), "patch generation separates expansion and regeneration modes");
|
||||||
assert(mapPatchSource.includes("resolveWorldSeaLevel") && mapPatchSource.includes("buildTerrainBoundaryContract") && mapPatchSource.includes("applyTerrainBoundaryContract"), "patch terrain uses a shared world sea level and an explicit boundary contract");
|
assert(mapPatchSource.includes("resolveWorldSeaLevel") && mapPatchSource.includes("buildTerrainBoundaryContract") && mapPatchSource.includes("applyTerrainBoundaryContract"), "patch terrain uses a shared world sea level and an explicit boundary contract");
|
||||||
assert(mapPipelineSource.includes("generateStableWorldTerrain") && mapPipelineSource.includes("stableTerrainSeed") && mapPipelineSource.includes("generateTerrainRect"), "expansion terrain is stable in absolute world coordinates");
|
assert(mapPatchSource.includes("candidateOriginX") && mapPatchSource.includes("world?.originX") && mapPatchSource.includes("canonicalWorldGrid"), "patch candidates use padding-invariant world coordinates and canonical tile windows");
|
||||||
|
assert(appSource.includes("activePatchOperation") && appSource.includes("selectionRevision") && appSource.includes("isPatchOperationCurrent"), "patch preview publication is guarded by immutable operation and selection generations");
|
||||||
|
assert(appSource.includes("fullGenerationBusy") && appSource.includes("state.patchBusy || state.fullGenerationBusy") && appSource.includes("cancelPatchGeneration"), "full and patch generation share one explicit busy/cancellation domain");
|
||||||
|
assert(derivePatchSeedSource.includes("function derivePatchSeed(world, terrainType, variant") && !derivePatchSeedSource.includes("rect.x") && !derivePatchSeedSource.includes("rect.y"), "UI patch seed is independent of selection bounds and backing-world padding");
|
||||||
|
assert(!appSource.includes("qualityWorkerRetries: 1") && !appSource.includes("attemptVariant = (attemptVariant + 3)"), "UI does not run the obsolete hidden whole-patch retry wrapper");
|
||||||
|
assert(mapPatchSource.includes("generateTiledRegenerationPatch") && mapPatchSource.includes("patch-candidate-coverage-incomplete"), "large Regeneration is tiled and rejects uncovered active cells instead of silently skipping them");
|
||||||
|
assert(mapPatchSource.includes("single-explicit-production-candidate-v2") && mapPatchSource.includes("const requestedAttempts = 1"), "each search candidate runs exactly one explicit complete production variant");
|
||||||
|
assert(mapPatchWorkerSource.includes("runPatchCandidateSearch") && mapPatchWorkerSource.includes("candidatePlan") && mapPatchWorkerSource.includes("patch-search-exhausted"), "worker owns a bounded multi-candidate search controller");
|
||||||
|
assert(mapPatchWorkerSource.includes("persistentCommittedMirror") && appSource.includes("reuseCommittedMirror") && appSource.includes("committedRevision"), "warm Alternative searches reuse a revision-checked committed Worker mirror");
|
||||||
|
assert(mapPatchWorkerSource.includes("patch-apply-ack") && appSource.includes("acknowledgePatchApply"), "Apply advances the persistent mirror through a revision-checked transactional ACK");
|
||||||
|
assert(appSource.includes("stagePreviewRenderBundle") && appSource.includes("publishPreviewRenderBundle"), "preview rendering is staged offscreen before atomic state and canvas publication");
|
||||||
|
assert(mapPatchSource.includes("auditRepairAndReauditPatchSeam") && mapPatchSource.includes("PATCH_SEAM_INVARIANT_REASONS"), "seam repair is audit-driven and keeps invariant failures outside the repair path");
|
||||||
|
assert(mapPatchSource.includes("large-regeneration-final-quality") && mapPatchSource.includes("whole-selection-post-merge"), "tiled Regeneration applies one authoritative whole-selection quality audit");
|
||||||
|
assert(mapPatchSource.includes("minFinalAdminCenters") && mapPatchSource.includes("transportRequired")
|
||||||
|
&& mapPatchSource.includes("roadPaths > 0"), "final quality rejects Regeneration candidates that lose required administration or transport");
|
||||||
|
assert(mapPatchWorkerSource.includes("computePreviewDelta") && appSource.includes("previewPatchDelta(baseWorld, job.world"),
|
||||||
|
"preview raster and feature change auditing remains available for both immutable-reference and transactional-delta paths");
|
||||||
|
assert(appSource.includes("buildPatchCandidatePlan") && appSource.includes("consumedCandidateIds") && appSource.includes("nextVariant"), "UI continues Alternative batches without repeating content-rejected candidates");
|
||||||
|
const controllerBase = { fields: { marker: new Uint8Array([1]) } };
|
||||||
|
const controllerCalls = [];
|
||||||
|
const controllerProgress = [];
|
||||||
|
const controllerResult = runPatchCandidateSearch({
|
||||||
|
id: 1,
|
||||||
|
world: controllerBase,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: {
|
||||||
|
searchId: "controller-regression",
|
||||||
|
workerEpoch: 1,
|
||||||
|
committedRevision: 1,
|
||||||
|
candidatePlan: [{ variant: 5, seed: 105 }, { variant: 6, seed: 106 }, { variant: 7, seed: 107 }],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
cloneWorld: (value) => structuredClone(value),
|
||||||
|
onProgress: (message) => controllerProgress.push(message.progress),
|
||||||
|
generateCandidate: (candidateWorld, rect, options) => {
|
||||||
|
controllerCalls.push({ variant: options.variant, baseline: candidateWorld.fields.marker[0] });
|
||||||
|
candidateWorld.fields.marker[0] = options.variant;
|
||||||
|
if (options.variant === 5) return { ok: false, code: "patch-quality-gate-failed", reason: "forced content rejection" };
|
||||||
|
return { ok: true, variant: options.variant, seed: options.seed, seamDiagnostics: { hardPass: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(controllerResult.result?.ok === true && controllerResult.result?.actualVariant === 6, "content rejection automatically advances to the next complete candidate");
|
||||||
|
assert(controllerCalls.length === 2 && controllerCalls.every((call) => call.baseline === 1) && controllerBase.fields.marker[0] === 1, "each candidate starts from an isolated immutable committed baseline");
|
||||||
|
assert(controllerResult.result?.searchAttempts?.map((attempt) => attempt.status).join(",") === "rejected,success", "candidate search preserves an ordered rejection and success audit trail");
|
||||||
|
assert(controllerProgress.length > 0 && controllerProgress.every((event) => Number.isFinite(event.eventSeq) && event.phase && event.workUnitId), "worker progress carries ordered operation, phase, and work-unit identity");
|
||||||
|
assert(controllerProgress.filter((event) => event.boundedWork).every((event) => event.completed >= 0 && event.completed <= event.total), "bounded worker progress never exceeds its declared finite work total");
|
||||||
|
const invalidProgressResult = runPatchCandidateSearch({
|
||||||
|
id: 4,
|
||||||
|
world: controllerBase,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: { searchId: "controller-progress-invariant", candidatePlan: [{ variant: 12, seed: 112 }] },
|
||||||
|
}, {
|
||||||
|
cloneWorld: (value) => structuredClone(value),
|
||||||
|
generateCandidate: (candidateWorld, rect, options) => {
|
||||||
|
options.onProgress({ status: "step", key: "invalid-bounds", completed: 2, total: 1 });
|
||||||
|
return { ok: true, seamDiagnostics: { hardPass: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(invalidProgressResult.ok === false && invalidProgressResult.code === "worker-progress-invariant", "invalid or runaway bounded progress stops the worker search as an invariant failure");
|
||||||
|
const invariantCalls = [];
|
||||||
|
const invariantResult = runPatchCandidateSearch({
|
||||||
|
id: 2,
|
||||||
|
world: controllerBase,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: {
|
||||||
|
searchId: "controller-invariant",
|
||||||
|
workerEpoch: 1,
|
||||||
|
committedRevision: 1,
|
||||||
|
candidatePlan: [{ variant: 8, seed: 108 }, { variant: 9, seed: 109 }],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
cloneWorld: (value) => structuredClone(value),
|
||||||
|
generateCandidate: (candidateWorld, rect, options) => {
|
||||||
|
invariantCalls.push(options.variant);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "patch-seam-gate-failed",
|
||||||
|
reason: "forced write escape",
|
||||||
|
seamDiagnostics: { hardPass: false, gateReasons: ["generated-footprint-write-escape"] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(invariantResult.result?.searchStatus === "invariant-breach" && invariantCalls.length === 1, "write invariant failures stop the search without consuming another variant");
|
||||||
|
const cloneFailure = runPatchCandidateSearch({
|
||||||
|
id: 3,
|
||||||
|
world: controllerBase,
|
||||||
|
rect: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
||||||
|
options: {},
|
||||||
|
search: { searchId: "controller-clone", candidatePlan: [{ variant: 10, seed: 110 }, { variant: 11, seed: 111 }] },
|
||||||
|
}, {
|
||||||
|
cloneWorld: () => { throw new Error("forced clone failure"); },
|
||||||
|
generateCandidate: () => { throw new Error("candidate must not start after clone failure"); },
|
||||||
|
});
|
||||||
|
assert(cloneFailure.result?.searchStatus === "infrastructure-error" && cloneFailure.result?.nextVariant === 10, "candidate clone failure preserves the current variant for infrastructure retry");
|
||||||
|
const deltaBase = {
|
||||||
|
width: 4, height: 2,
|
||||||
|
fields: { elevation: new Float32Array([0, 1, 2, 3, 4, 5, 6, 7]), adminId: new Int32Array(8) },
|
||||||
|
generatedMask: new Uint8Array(8), sourceMap: { villages: [{ x: 1, y: 1 }] }, patchGenerationSerial: 1,
|
||||||
|
};
|
||||||
|
const deltaTarget = structuredClone(deltaBase);
|
||||||
|
deltaTarget.fields.elevation[2] = 20;
|
||||||
|
deltaTarget.fields.adminId[7] = 9;
|
||||||
|
deltaTarget.generatedMask[6] = 1;
|
||||||
|
deltaTarget.sourceMap.villages.push({ x: 2, y: 2 });
|
||||||
|
deltaTarget.patchGenerationSerial = 2;
|
||||||
|
const committedDelta = buildCommittedMirrorDelta(deltaBase, deltaTarget);
|
||||||
|
const deltaApplied = applyCommittedMirrorDelta(structuredClone(deltaBase), committedDelta);
|
||||||
|
assert(
|
||||||
|
arraysEqual(deltaApplied.fields.elevation, deltaTarget.fields.elevation)
|
||||||
|
&& arraysEqual(deltaApplied.fields.adminId, deltaTarget.fields.adminId)
|
||||||
|
&& arraysEqual(deltaApplied.generatedMask, deltaTarget.generatedMask)
|
||||||
|
&& JSON.stringify(deltaApplied.sourceMap) === JSON.stringify(deltaTarget.sourceMap)
|
||||||
|
&& deltaApplied.patchGenerationSerial === 2,
|
||||||
|
"transactional Apply delta reproduces the accepted world fields, mask, metadata, and serial"
|
||||||
|
);
|
||||||
|
assert(mapPatchWorkerSource.includes("sourceMapDelta") && mapPatchWorkerSource.includes("metaDelta")
|
||||||
|
&& !mapPatchWorkerSource.includes("sourceMap: structuredClone(nextWorld.sourceMap"), "Apply ACK retains only changed source/world metadata instead of duplicating the complete map metadata");
|
||||||
|
assert(mapPatchWorkerSource.includes("buildExactArraySplice")
|
||||||
|
&& mapPatchWorkerSource.includes("arraySplices")
|
||||||
|
&& mapPatchWorkerSource.includes("isDensePlainArray")
|
||||||
|
&& committedWorldDeltaSource.includes("applyCommittedWorldDelta"),
|
||||||
|
"changed dense feature/path/history layers use exact splice deltas instead of transferring the complete layer");
|
||||||
|
assert(appSource.includes("consumeMetadata: true")
|
||||||
|
&& mapPatchWorkerSource.includes("pending.delta, { consumeMetadata: true }"),
|
||||||
|
"main preview publication and Worker Apply ACK consume their isolated metadata delta without cloning it a second time");
|
||||||
|
assert(mapPatchWorkerSource.includes("buildMainThreadTransferDelta")
|
||||||
|
&& !mapPatchWorkerSource.includes("const mainThreadDelta = structuredClone(delta)")
|
||||||
|
&& mapPatchWorkerSource.includes("{ fields: payload.worldDelta.fields, generatedMask: payload.worldDelta.generatedMask }"),
|
||||||
|
"Worker main-transfer preparation copies detachable raster rows without cloning or detaching retained metadata");
|
||||||
|
assert(mapPatchWorkerSource.includes("const exactDeltas = { sourceMapDelta: rawSourceMapDelta, metaDelta: rawMetaDelta }")
|
||||||
|
&& !mapPatchWorkerSource.includes("structuredClone({ sourceMapDelta: rawSourceMapDelta, metaDelta: rawMetaDelta })"),
|
||||||
|
"transaction delta owns completed candidate metadata directly instead of cloning the graph before rollback");
|
||||||
|
assert(mapPatchWorkerSource.includes("transactional: true") && mapPatchWorkerSource.includes("buildCommittedMirrorDeltaFromTransaction")
|
||||||
|
&& appSource.includes("materializeCommittedWorldDeltaCooperative(world, event.data.worldDelta"), "production previews mutate the Worker mirror transactionally and materialize only the accepted delta on the main thread");
|
||||||
|
assert(appSource.includes("materializeCommittedWorldDelta")
|
||||||
|
&& !appSource.includes("previewWorld = structuredClone(world)"),
|
||||||
|
"main preview creation uses copy-on-write changed fields instead of cloning every committed raster and metadata layer");
|
||||||
|
assert(appSource.includes("Accepted preview hash mismatch") && appSource.includes("hashCommittedWorldAsync")
|
||||||
|
&& mapPatchWorkerSource.includes("acceptedWorldHash"),
|
||||||
|
"main-thread preview publication cooperatively rejects any transaction delta that does not reproduce the Worker-completed world hash");
|
||||||
|
assert(mapPatchWorkerSource.includes("changeTracker.mask") && mapPatchWorkerSource.includes("successDelta?.previewDelta"),
|
||||||
|
"transaction delta construction also produces preview statistics so the main thread does not repeat the field comparison");
|
||||||
|
assert(mapPatchWorkerSource.includes("const scanLocalRect = localEntry && rect")
|
||||||
|
&& mapPatchWorkerSource.includes("const scanStart = scanLocalRect"),
|
||||||
|
"transaction delta compares local fields only inside their captured mutation rectangle");
|
||||||
|
assert(mapPatchWorkerSource.includes("const transferRoot = payload.worldDelta")
|
||||||
|
&& mapPatchWorkerSource.includes("{ fields: payload.worldDelta.fields, generatedMask: payload.worldDelta.generatedMask }")
|
||||||
|
&& mapPatchWorkerSource.includes(": (payload.world || null)"),
|
||||||
|
"result transfer discovery scans only the transferable raster delta/full-world root instead of the complete diagnostic graph");
|
||||||
|
assert(!worldMapSource.includes("invalidatedRects") && !mapPatchSource.includes("addInvalidatedRect")
|
||||||
|
&& !worldMapSource.includes("humanPatchHistory") && !mapPatchSource.includes("humanPatchHistory"),
|
||||||
|
"unused invalidation and patch-history arrays are absent from world state, transactions, padding shifts, and Worker payloads");
|
||||||
|
assert(mapPatchSource.includes("PATCH_MUTABLE_SOURCE_KEYS")
|
||||||
|
&& mapPatchSource.includes("PATCH_MUTABLE_SOURCE_KEYS.has(key)")
|
||||||
|
&& mapPatchSource.includes("out[key] = PATCH_MUTABLE_SOURCE_KEYS.has(key)"),
|
||||||
|
"transaction snapshots recursively clone only patch-mutable sourceMap roots and share read-only production metadata");
|
||||||
|
assert(mapPatchWorkerSource.includes("isolateSourceMap: true")
|
||||||
|
&& mapPatchSource.includes("sourceMapIsolated: isolateSourceMap")
|
||||||
|
&& mapPatchSource.includes("world.sourceMap = snapshot.sourceMapRef || {}"),
|
||||||
|
"Worker candidates mutate an isolated sourceMap and rollback by reference without a second metadata clone");
|
||||||
|
assert(mapPatchSource.includes("generatedRects: lightweight ? null : (world.generatedRects || [])")
|
||||||
|
&& mapPatchSource.includes("lastPatchResult: lightweight ? null : (world.lastPatchResult ?? null)")
|
||||||
|
&& !mapPatchSource.includes("cloneTransactionValue(world.generatedRects || [])")
|
||||||
|
&& !mapPatchSource.includes("cloneTransactionValue(world.lastPatchResult ?? null)"),
|
||||||
|
"transaction rollback retains immutable history and prior diagnostics by reference instead of cloning their debug graphs");
|
||||||
|
assert(mapPatchSource.includes("baselineSourceMap") && mapPatchSource.includes("snapshotPoint = baselineSourceMap")
|
||||||
|
&& mapPatchSource.includes("capturePrefectureIdentitySnapshot(sourceMap, strictMetadataSnapshot)"),
|
||||||
|
"strict Regeneration metadata reuses the immutable transaction baseline across outside-point and identity snapshots");
|
||||||
|
assert(mapPatchSource.includes("rects.patchMode === PATCH_MODE_EXPANSION")
|
||||||
|
&& mapPatchSource.includes("protectedIndexLookup?.fill(-1)"),
|
||||||
|
"Regeneration strict snapshots do not allocate the Expansion-only random lookup table");
|
||||||
|
assert(mapPatchSource.includes("_strictBaselineTransactionSnapshot: transaction")
|
||||||
|
&& mapPatchSource.includes("transactionSnapshot?.fields?.has(name)"),
|
||||||
|
"large internal tiles reuse the outer transaction's field before-images instead of cloning strict fields per tile");
|
||||||
|
assert(mapPatchSource.includes("PATCH_TRANSACTION_READ_ONLY_FIELDS")
|
||||||
|
&& mapPatchSource.includes("PATCH_TRANSACTION_READ_ONLY_FIELDS.has(name)"),
|
||||||
|
"transaction and strict snapshots omit the read-only flowTo field instead of copying unused rollback values");
|
||||||
|
assert(appSource.includes("resolvedPatchMode: operation.resolvedPatchMode")
|
||||||
|
&& mapPatchWorkerSource.includes("copyGeneratedMask:")
|
||||||
|
&& mapPatchWorkerSource.includes("resolvedPatchMode || \"\").toLowerCase() !== \"regeneration\"")
|
||||||
|
&& mapPatchSource.includes("generatedMaskRef"),
|
||||||
|
"Regeneration transactions retain generatedMask by reference while Expansion keeps an exact writable before-image");
|
||||||
|
assert(mapPatchSource.includes("synchronizePatchMunicipalityField") && mapPatchSource.includes("municipalityWriteRects: aggregateSourceRects"),
|
||||||
|
"Regeneration administrative coherence avoids a whole-world municipality write followed by whole-world strict restoration");
|
||||||
|
assert(mapPatchSource.includes("municipalityWriteRects: rects,")
|
||||||
|
&& !mapPatchSource.includes("snapshot.globalFields.set(\"municipalityId\"")
|
||||||
|
&& !mapPatchSource.includes("new municipality.constructor(municipality)")
|
||||||
|
&& !mapPatchSource.includes("globalFields:"),
|
||||||
|
"Expansion and Regeneration scope municipality writes to patch alpha and omit the full-world rollback copy");
|
||||||
|
assert(mapPatchSource.includes("bestFallbackCellByPrefecture") && mapPatchSource.includes("previous || score > previous.score"),
|
||||||
|
"prefecture capital fallback collects best cells in one world pass instead of rescanning the world per missing prefecture");
|
||||||
|
assert(mapPatchSource.includes("_tileCoreWidth: MAP_W") && mapPatchSource.includes("_tileCoreHeight: MAP_H")
|
||||||
|
&& appSource.includes("buildLargeExpansionTiles(rect, world, regeneration ? {")
|
||||||
|
&& appSource.includes("_tileCoreWidth: MAP_W") && appSource.includes("_tileCoreHeight: MAP_H"),
|
||||||
|
"large Regeneration plans full-size production cores and the UI derives the same tile count from the production tiler");
|
||||||
|
assert(!mapPatchWorkerSource.includes("stableLayerText") && !appSource.includes("stableLayerText"), "preview feature comparison does not allocate full JSON strings");
|
||||||
|
assert(rendererSource.includes("MAX_BASE_CACHE_IMAGES = 1") && rendererSource.includes("MAX_OVERLAY_CACHE_IMAGES = 1")
|
||||||
|
&& !appSource.includes("snapshotVisibleCanvas"), "preview raster caches and publication rollback do not retain obsolete full canvases");
|
||||||
|
assert(worldMapSource.includes("initialQualityReference") && mapPatchSource.includes("world?.initialQualityReference"), "quality reference is fixed at initial generation instead of growing with patch history");
|
||||||
|
assert(worldMapSource.includes("generatedMask") && mapPatchSource.includes("addGeneratedFootprintToMask"), "generated coverage uses a world-sized mask rather than scanning all patch history per cell");
|
||||||
|
assert(appSource.includes("featureLayersChanged") && appSource.includes("transportReachRect") && appSource.includes("fieldNames"), "preview identical diagnostics cover all raster fields and feature/path layers in the affected range");
|
||||||
|
assert(mapPatchSource.includes("_deferInternalSeamDiagnostics") && mapPatchSource.includes("_coverageDistanceBaseline"), "large tiles reuse coverage distances and defer internal seam diagnostics to the whole selection");
|
||||||
|
assert(worldMapSource.includes("MAX_WORLD_WIDTH") && worldMapSource.includes("MAX_WORLD_HEIGHT"), "backing-world padding has an explicit upper bound");
|
||||||
assert(worldMapSource.includes("seaLevel: Number.isFinite(initialMap?.seaLevel)"), "world map persists the initial sea level as a world invariant");
|
assert(worldMapSource.includes("seaLevel: Number.isFinite(initialMap?.seaLevel)"), "world map persists the initial sea level as a world invariant");
|
||||||
assert(mapPatchSource.includes("capturePatchSeamSnapshot") && mapPatchSource.includes("analyzePatchSeam") && mapPatchSource.includes("roadPortalsBroken") && mapPatchSource.includes("duplicateBoundaryPairs"), "patch generation records coast, transport, and boundary seam diagnostics");
|
assert(mapPatchSource.includes("capturePatchSeamSnapshot") && mapPatchSource.includes("analyzePatchSeam") && mapPatchSource.includes("roadPortalsBroken") && mapPatchSource.includes("duplicateBoundaryPairs"), "patch generation records coast, transport, and boundary seam diagnostics");
|
||||||
assert(appSource.includes("advancedSeamDiagnostics") && appSource.includes("showSeamDiagnostics") && appSource.includes("seamDiagnosticRows"), "seam diagnostics are exposed in the UI and map overlay controls");
|
assert(appSource.includes("advancedSeamDiagnostics") && appSource.includes("showSeamDiagnostics") && appSource.includes("seamDiagnosticRows"), "seam diagnostics are exposed in the UI and map overlay controls");
|
||||||
|
|
@ -1064,18 +1287,26 @@ try {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (suiteEnabled("terrain-name")) {
|
||||||
|
const semeMap = generateTestMap(8363712);
|
||||||
|
const semeAdmin = (semeMap.adminCenters || []).find((center) => center.canonicalSettlementName);
|
||||||
|
assert(!semeAdmin || String(semeAdmin.name).replace(/[市町村]$/u, "") === String(semeAdmin.canonicalSettlementName).replace(/[市町村]$/u, ""),
|
||||||
|
"seed 8363712: municipality label preserves the canonical settlement root");
|
||||||
|
}
|
||||||
|
|
||||||
if (suiteEnabled("terrain")) {
|
if (suiteEnabled("terrain")) {
|
||||||
const blockedCapitalName = "\u52A0\u8302";
|
const blockedCapitalName = "\u52A0\u8302";
|
||||||
capitalNameMaps = [114514, 12345, 54321, 777, 999].map((seedValue) => generateTestMap(seedValue));
|
const terrainSeeds = [114514, 12345, 54321, 777, 999];
|
||||||
const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean);
|
const capitalNames = [];
|
||||||
assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds");
|
terrainSeedSummaries = [];
|
||||||
assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name");
|
for (const seedValue of terrainSeeds) {
|
||||||
const semeMap = generateTestMap(8363712);
|
// Evaluate and release each complete map before generating the next seed.
|
||||||
const semeAdmin = (semeMap.adminCenters || []).find((center) => center.canonicalSettlementName);
|
// Retaining five full raster worlds at once made this validation shard
|
||||||
assert(!semeAdmin || String(semeAdmin.name).replace(/[市町村]$/u, "") === String(semeAdmin.canonicalSettlementName).replace(/[市町村]$/u, ""), "seed 8363712: municipality label preserves the canonical settlement root");
|
// memory-pressure dependent without increasing its coverage.
|
||||||
for (const [n, seeded] of capitalNameMaps.entries()) {
|
const seeded = generateTestMap(seedValue);
|
||||||
const seedValue = [114514, 12345, 54321, 777, 999][n];
|
if (seeded.prefecturalCapital?.name) capitalNames.push(seeded.prefecturalCapital.name);
|
||||||
const metrics = terrainCoreMetrics(seeded);
|
const metrics = terrainCoreMetrics(seeded);
|
||||||
|
terrainSeedSummaries.push({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: metrics.lowlandRatio });
|
||||||
assert(seeded.mainRivers.length > 0 && seeded.tributaryRivers.length > 0 && seeded.smallStreams.length > 0, `seed ${seedValue}: river hierarchy exists`);
|
assert(seeded.mainRivers.length > 0 && seeded.tributaryRivers.length > 0 && seeded.smallStreams.length > 0, `seed ${seedValue}: river hierarchy exists`);
|
||||||
assert(metrics.mountainRatio > 0.08 && metrics.lowlandRatio > 0.06, `seed ${seedValue}: mountain and lowland terrain both exist`);
|
assert(metrics.mountainRatio > 0.08 && metrics.lowlandRatio > 0.06, `seed ${seedValue}: mountain and lowland terrain both exist`);
|
||||||
assert(metrics.ridgeVariance > 0.003 && metrics.ridgeSinuosity > 0.010, `seed ${seedValue}: ridges have varied jagged structure`);
|
assert(metrics.ridgeVariance > 0.003 && metrics.ridgeSinuosity > 0.010, `seed ${seedValue}: ridges have varied jagged structure`);
|
||||||
|
|
@ -1113,6 +1344,8 @@ try {
|
||||||
assert(seededAdminMetrics.denseUrbanRate < 0.52, `seed ${seedValue}: dense urban boundary crossing rate remains low`);
|
assert(seededAdminMetrics.denseUrbanRate < 0.52, `seed ${seedValue}: dense urban boundary crossing rate remains low`);
|
||||||
assert(seededAdminMetrics.voronoiLikeRate < 0.66, `seed ${seedValue}: Voronoi-like municipal borders do not dominate`);
|
assert(seededAdminMetrics.voronoiLikeRate < 0.66, `seed ${seedValue}: Voronoi-like municipal borders do not dominate`);
|
||||||
}
|
}
|
||||||
|
assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds");
|
||||||
|
assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TEST_SUITE === "determinism" || TEST_SUITE.startsWith("determinism-")) {
|
if (TEST_SUITE === "determinism" || TEST_SUITE.startsWith("determinism-")) {
|
||||||
|
|
@ -1125,9 +1358,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (suiteEnabled("terrain")) {
|
if (suiteEnabled("terrain")) {
|
||||||
const byDeposition = capitalNameMaps
|
const byDeposition = terrainSeedSummaries.slice().sort((a, b) => a.deposition - b.deposition);
|
||||||
.map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio }))
|
|
||||||
.sort((a, b) => a.deposition - b.deposition);
|
|
||||||
assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area");
|
assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area");
|
||||||
const originalCustomNames = [...CUSTOM_NAME_LIST];
|
const originalCustomNames = [...CUSTOM_NAME_LIST];
|
||||||
CUSTOM_NAME_LIST.length = 0;
|
CUSTOM_NAME_LIST.length = 0;
|
||||||
|
|
@ -1187,8 +1418,106 @@ try {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (suiteEnabled("patch")) {
|
||||||
|
const initial = generateTestMap(DETERMINISM_SEED);
|
||||||
|
const world = createWorldMap(initial);
|
||||||
|
assert(!world.sourceMap.sea && !world.sourceMap.elevation && world.initialQualityReference?.landCells > 0, "world metadata omits duplicate fixed-map raster fields while retaining the immutable quality reference");
|
||||||
|
const rect = {
|
||||||
|
x0: world.originX + 72,
|
||||||
|
y0: world.originY + 58,
|
||||||
|
x1: world.originX + 132,
|
||||||
|
y1: world.originY + 118,
|
||||||
|
};
|
||||||
|
const outsideIndex = (world.originY + 12) * world.width + world.originX + 12;
|
||||||
|
const outsideAdminId = world.fields.adminId?.[outsideIndex];
|
||||||
|
const patchStartedAt = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||||
|
const patch = generatePatch(world, rect, {
|
||||||
|
patchMode: "regeneration",
|
||||||
|
terrainType: "auto",
|
||||||
|
seed: 0x51a7c3d3,
|
||||||
|
variant: 1,
|
||||||
|
maxQualityRetries: 0,
|
||||||
|
qualityTerrainAttempts: 1,
|
||||||
|
includeSeamVisualization: true,
|
||||||
|
});
|
||||||
|
const patchElapsedMs = (typeof performance !== "undefined" ? performance.now() : Date.now()) - patchStartedAt;
|
||||||
|
assert(patch?.ok === true, "standard patch suite executes generatePatch and returns a preview candidate");
|
||||||
|
assert(patch?.variant === 1 && patch?.seed === 0x51a7c3d3, "executed patch preserves the explicitly requested variant and seed");
|
||||||
|
assert(patchElapsedMs < 30000, `small production patch completes within the 30 s budget (${Math.round(patchElapsedMs)} ms)`);
|
||||||
|
assert(patch?.seamDiagnostics?.hardPass === true && (patch?.seamDiagnostics?.prefectureSeamBreakEdges || 0) === 0, "small Regeneration repairs only the real ownership seam and passes the unchanged hard seam gate");
|
||||||
|
const restoredPrefectureCells = patch?.seamDiagnostics?.administrativeSeamRepair?.prefectureCellsRestored || 0;
|
||||||
|
const regeneratedArea = Math.max(1, (rect.x1 - rect.x0) * (rect.y1 - rect.y0));
|
||||||
|
// Administrative seam repair is allowed only as a narrow boundary repair.
|
||||||
|
// Use an area-relative cap rather than an obsolete fixture-specific count:
|
||||||
|
// this still catches accidental interior rewrites while allowing a handful
|
||||||
|
// of independent broken ownership edges to be restored deterministically.
|
||||||
|
const localizedAdminRepairCap = Math.max(12, Math.ceil(regeneratedArea * 0.005));
|
||||||
|
assert(restoredPrefectureCells <= localizedAdminRepairCap,
|
||||||
|
`administrative seam repair remains localized instead of rewriting the regenerated interior (restored=${restoredPrefectureCells}, cap=${localizedAdminRepairCap})`);
|
||||||
|
assert((patch?.candidateUnmappedActiveCells || 0) === 0, "executed patch maps every active write cell into the production candidate");
|
||||||
|
assert(world.fields.adminId?.[outsideIndex] === outsideAdminId,
|
||||||
|
`strict Regeneration preserves canonical administrative fields outside the selection (before=${outsideAdminId}, after=${world.fields.adminId?.[outsideIndex]})`);
|
||||||
|
const serializedRects = JSON.stringify(patch?.rects || {});
|
||||||
|
const serializedRectKeys = Object.getOwnPropertyNames(JSON.parse(serializedRects));
|
||||||
|
const serializedWorkCacheKeys = serializedRectKeys.filter((key) => key === "patchAlphaCache" || key === "patchSourceIndexCache");
|
||||||
|
if (serializedWorkCacheKeys.length > 0) {
|
||||||
|
failed += 1;
|
||||||
|
logLines.push(`NG: worker-only patch caches are absent from the serialized result payload (cacheKeys=${serializedWorkCacheKeys.join(",")})`);
|
||||||
|
} else {
|
||||||
|
logLines.push("OK: worker-only patch caches are absent from the serialized result payload");
|
||||||
|
}
|
||||||
|
const expectedPopulation = [...(world.sourceMap.modernCities || []), ...(world.sourceMap.satelliteCities || [])]
|
||||||
|
.reduce((sum, city) => sum + (Number(city?.population) || 0), 0);
|
||||||
|
assert(world.sourceMap.totalPopulation === expectedPopulation, "patch application refreshes total population metadata");
|
||||||
|
const generatedPoint = [
|
||||||
|
...(world.sourceMap.modernCities || []), ...(world.sourceMap.satelliteCities || []),
|
||||||
|
...(world.sourceMap.villages || []), ...(world.sourceMap.markets || []),
|
||||||
|
].find((point) => point?.patchGenerated && Number.isFinite(point.regionId));
|
||||||
|
if (generatedPoint && world.fields.regionId) {
|
||||||
|
const wx = Math.round((generatedPoint.worldX ?? generatedPoint.x + world.originX));
|
||||||
|
const wy = Math.round((generatedPoint.worldY ?? generatedPoint.y + world.originY));
|
||||||
|
const wi = wy * world.width + wx;
|
||||||
|
assert(generatedPoint.regionId === world.fields.regionId[wi], "patch-generated point regionId matches the persisted world raster regionId");
|
||||||
|
} else if (generatedPoint) {
|
||||||
|
// regionId remains point metadata in the current final world schema; the
|
||||||
|
// internal generation raster is intentionally not persisted by mapOutput.
|
||||||
|
assert(Number.isFinite(generatedPoint.regionId), "patch-generated point keeps a finite regionId when no persisted regionId raster exists");
|
||||||
|
} else {
|
||||||
|
assert(true, "executed patch produced no region-tagged point requiring a regionId check");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suiteEnabled("patch-large")) {
|
||||||
|
const initial = generateTestMap(DETERMINISM_SEED);
|
||||||
|
const world = createWorldMap(initial);
|
||||||
|
const rect = {
|
||||||
|
x0: world.originX,
|
||||||
|
y0: world.originY + 64,
|
||||||
|
x1: world.originX + MAP_W + 1,
|
||||||
|
y1: world.originY + 64 + PATCH_MIN_HEIGHT,
|
||||||
|
};
|
||||||
|
const serialBefore = world.patchGenerationSerial || 0;
|
||||||
|
const largeStartedAt = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||||
|
const large = generatePatch(world, rect, {
|
||||||
|
patchMode: "regeneration",
|
||||||
|
terrainType: "auto",
|
||||||
|
seed: 0x6d2b79f5,
|
||||||
|
variant: 1,
|
||||||
|
maxQualityRetries: 0,
|
||||||
|
qualityTerrainAttempts: 1,
|
||||||
|
includeSeamVisualization: true,
|
||||||
|
});
|
||||||
|
const largeElapsedMs = (typeof performance !== "undefined" ? performance.now() : Date.now()) - largeStartedAt;
|
||||||
|
assert(large?.ok === true && large?.tiledRegeneration === true && Number(large?.tileCount || 0) >= 2, "large Regeneration must publish a complete canonical-tile preview; rollback alone is not a passing result");
|
||||||
|
assert(largeElapsedMs < 60000, `large production Regeneration completes within the 60 s budget (${Math.round(largeElapsedMs)} ms)`);
|
||||||
|
assert(large?.seamDiagnostics?.hardPass === true, "published large Regeneration passes the whole-selection seam gate");
|
||||||
|
assert((large?.candidateUnmappedActiveCells || 0) === 0, "large Regeneration maps every active write cell across all tiles");
|
||||||
|
assert((world.patchGenerationSerial || 0) === serialBefore + 1, "large Regeneration records one logical operation rather than internal tile history");
|
||||||
|
}
|
||||||
|
|
||||||
const elapsedMs = (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - TEST_STARTED_AT;
|
const elapsedMs = (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - TEST_STARTED_AT;
|
||||||
assert(elapsedMs < 180000, `test shard ${TEST_SUITE} completes under three minutes`);
|
const shardBudgetMs = Math.max(180000, fullMapGenerations * 75000);
|
||||||
|
assert(elapsedMs < shardBudgetMs, `test shard ${TEST_SUITE} completes within its complete-generation workload budget (${Math.round(elapsedMs)} / ${shardBudgetMs} ms)`);
|
||||||
logLines.push(`INFO: suite=${TEST_SUITE}; fullMapGenerations=${fullMapGenerations}; elapsedMs=${Math.round(elapsedMs)}`);
|
logLines.push(`INFO: suite=${TEST_SUITE}; fullMapGenerations=${fullMapGenerations}; elapsedMs=${Math.round(elapsedMs)}`);
|
||||||
result.className = failed === 0 ? "ok" : "ng";
|
result.className = failed === 0 ? "ok" : "ng";
|
||||||
result.textContent = `${failed === 0 ? "All tests passed." : `${failed} tests failed.`}\n\n${logLines.join("\n")}`;
|
result.textContent = `${failed === 0 ? "All tests passed." : `${failed} tests failed.`}\n\n${logLines.join("\n")}`;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue