This commit is contained in:
33333-33333 2026-09-29 23:45:12 +09:00
commit f5fc9b76ce
73 changed files with 32628 additions and 376 deletions

View file

@ -1,107 +1,58 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {performance} from 'node:perf_hooks';
import {World,DT,H,GW,GH,CELL,INITIAL_SPECIES} from '../engine.js';
import {inWater} from '../water.js';
import {phytoplanktonMuMax,producerGrowthPerDay,updateZooplanktonCohort} from '../ecology/resources.js';
import {interactionEdge,rallFeedingParameters,functionalResponseRatePerDay} from '../ecology/feeding.js';
import {rateToProbability} from '../ecology/units.js';
import {reproductionConfig,energyLimitedGrowth,initialStructuralMassKg} from '../ecology/demography.js';
import {maximumSpeedMPerDay,routineTravelSpeedMPerDay,homeRangeRadiusM,sampleDispersalDistanceM,dailyMovementBudgetM,locomotionCalibration} from '../ecology/movement.js';
import {FRESHWATER,TERRESTRIAL} from '../ecology/profiles.js';
import {ClimateProvider} from '../ecology/climate.js';
import {World,DT} from '../engine.js?v=45';
import {fieldResourceIntakeKgPerDay,interactionEdge} from '../ecology/feeding.js?v=45';
const approx=(actual,expected,tol,msg)=>assert.ok(Math.abs(actual-expected)<=tol,`${msg}: ${actual} vs ${expected}`);
const sumField=(field)=>field.reduce((sum,v)=>sum+v,0)*CELL*CELL;
const quantile=(a,p)=>a.sort((x,y)=>x-y)[Math.floor((a.length-1)*p)]||0;
// Published phytoplankton upper envelope and aquatic limitation structure.
approx(phytoplanktonMuMax(10),1.52,.015,'muMax 10C');
approx(phytoplanktonMuMax(20),2.86,.02,'muMax 20C');
approx(phytoplanktonMuMax(30),5.38,.03,'muMax 30C');
approx(producerGrowthPerDay(FRESHWATER,20,{light:.5,nutrientN:.8,nutrientP:.4,dissolvedOxygen:0}),phytoplanktonMuMax(20)*.4*.5,1e-12,'aquatic producer limitation');
approx(producerGrowthPerDay(TERRESTRIAL,20,{moisture:1,light:.4}),.03*.4,1e-12,'terrestrial light limitation is applied once');
// 1) Module contract: catches missing named exports before browser release.
assert.equal(typeof fieldResourceIntakeKgPerDay,'function');
const edge=interactionEdge('primary-consumer','producer');
assert.ok(fieldResourceIntakeKgPerDay(8,.3,edge)>fieldResourceIntakeKgPerDay(8,.02,edge));
// Static food-web edges: no runtime interaction API, but the ecological graph remains explicit.
assert.equal(interactionEdge('primary-consumer','producer').assimilationEfficiency,.45);
assert.equal(interactionEdge('secondary-consumer','primary-consumer').assimilationEfficiency,.85);
assert.equal(interactionEdge('primary-consumer','secondary-consumer'),null);
// Continuous rates must compose independently of timestep.
const daily=rateToProbability(.7,1),hourly=rateToProbability(.7,DT),combined=1-Math.pow(1-hourly,24);
approx(combined,daily,1e-12,'hourly rate composition');
// Handling time is truly connected to Holling denominator; Rall allometric/temperature scaling is live.
// 2) User water edits must remain exact.
{
const edge=interactionEdge('secondary-consumer','primary-consumer');
const p=rallFeedingParameters(edge,5,.1,20,FRESHWATER),pHot=rallFeedingParameters(edge,5,.1,30,FRESHWATER),term={...p,resourceDensity:2,q:1};
const rate=functionalResponseRatePerDay(p,2,[term],1),slower=functionalResponseRatePerDay({...p,handlingTimeDays:p.handlingTimeDays*3},2,[{...term,handlingTimeDays:p.handlingTimeDays*3}],1);
const competitor={...p,resourceDensity:4,q:1},withCompetition=functionalResponseRatePerDay(p,2,[term,competitor],1);
assert.ok(rate>slower,'handling time is not connected to Holling denominator');
assert.ok(withCompetition<rate,'multi-resource Holling denominator is not connected');
assert.ok(pHot.attackRate>p.attackRate,'Rall temperature scaling does not increase attack rate');
assert.ok(pHot.handlingTimeDays<p.handlingTimeDays,'Rall temperature scaling does not reduce handling time from 20C to 30C');
assert.notEqual(rallFeedingParameters(edge,5,.1,20,FRESHWATER).attackRate,rallFeedingParameters(edge,5,1,20,FRESHWATER).attackRate,'resource mass does not affect attack rate');
const scaled=rallFeedingParameters(edge,10,.2,20,FRESHWATER);
approx(scaled.attackRate/p.attackRate,Math.pow(2,.62),1e-10,'Rall joint mass scaling attack');
approx(scaled.handlingTimeDays/p.handlingTimeDays,Math.pow(2,-.14),1e-10,'Rall joint mass scaling handling');
const w=new World(77), edited=w.water.map((v,i)=>({...v,r:i===0?v.r*.98:v.r}));
assert.equal(w.setWater(edited),true);
assert.deepEqual(w.water,edited);
}
// The World predation path uses the multi-resource Holling denominator, not just the standalone helper.
{const w=new World(913);for(const a of w.animals)a.dead=true;const predator=w.make(w.species.find(s=>s?.trophicRole==='secondary-consumer').g,w.species.find(s=>s?.trophicRole==='secondary-consumer').id,1000,1000,200),p1=w.make(w.species.find(s=>s?.trophicRole==='primary-consumer').g,w.species.find(s=>s?.trophicRole==='primary-consumer').id,1010,1000,100),p2=w.make(w.species.find(s=>s?.trophicRole==='primary-consumer').g,w.species.find(s=>s?.trophicRole==='primary-consumer').id,1020,1000,100);for(const a of [predator,p1,p2]){a.habitat='陸棲';a.dead=false}predator.structuralMassKg=3.5;p1.structuralMassKg=.1;p2.structuralMassKg=.1;w.animals=[predator,p1];w.hash.rebuild(w.animals);const one=w.predationRateForTarget(predator,p1,20).rate;w.animals.push(p2);w.hash.rebuild(w.animals);const two=w.predationRateForTarget(predator,p1,20).rate;assert.ok(one>0&&two>0&&two<one,'World predation path does not apply shared local prey saturation');}
// 3) Free-movement smoke test: no spawn lock, widespread freezing, or ping-pong reversal.
const w=new World(481516), start=new Map(w.animals.map(a=>[a.id,{x:a.x,y:a.y}]));
const track=new Map(w.animals.map(a=>[a.id,{x:a.x,y:a.y,still:0,ticks:0,dx:0,dy:0,moves:0,rev:0}]));
for(let step=0;step<3*24;step++){
w.step();
for(const a of w.animals){
if(a.dead||!track.has(a.id))continue;
const t=track.get(a.id),dx=a.x-t.x,dy=a.y-t.y,d=Math.hypot(dx,dy),last=Math.hypot(t.dx,t.dy);
t.ticks++; if(d<1e-6)t.still++;
if(d>.1){t.moves++;if(last>.1&&(dx*t.dx+dy*t.dy)/(d*last)<-.7)t.rev++;t.dx=dx;t.dy=dy}
t.x=a.x;t.y=a.y;
}
}
const alive=w.animals.filter(a=>!a.dead&&start.has(a.id));
const displacement=alive.map(a=>Math.hypot(a.x-start.get(a.id).x,a.y-start.get(a.id).y));
const still=alive.map(a=>track.get(a.id).still/track.get(a.id).ticks);
const reversal=alive.map(a=>track.get(a.id).rev/Math.max(1,track.get(a.id).moves));
assert.ok(quantile(displacement,.1)>100);
assert.ok(quantile(displacement,.5)>350);
assert.ok(quantile(still,.9)<.3);
assert.ok(quantile(reversal,.9)<.22);
// Reconstruct v38 procedural water without the sqrt(2) radius scale, then check only rough doubling.
const W=4800;
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
function baseWater(seed){let value=(seed^0x397b4c63)>>>0;const random=()=>{let t=value=(value+0x6D2B79F5)>>>0;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296};const edge=Math.floor(random()*4),radius=320+random()*110,along=.14+random()*.72;const start=edge===0?{x:radius*.42,y:H*along}:edge===1?{x:W-radius*.42,y:H*along}:edge===2?{x:W*along,y:radius*.42}:{x:W*along,y:H-radius*.42};const destination=edge===0?{x:W-300,y:H*(.14+random()*.72)}:edge===1?{x:300,y:H*(.14+random()*.72)}:edge===2?{x:W*(.14+random()*.72),y:H-300}:{x:W*(.14+random()*.72),y:300};const water=[{...start,r:radius,seed:random()*Math.PI*2}];for(let i=1;i<7;i++){const last=water.at(-1),r=290+random()*170,heading=Math.atan2(destination.y-last.y,destination.x-last.x)+(random()-.5)*1.25,step=(last.r+r)*(.43+random()*.12);water.push({x:clamp(last.x+Math.cos(heading)*step,r*.42,W-r*.42),y:clamp(last.y+Math.sin(heading)*step,r*.42,H-r*.42),r,seed:random()*Math.PI*2})}for(const index of [2,4]){const parent=water[index],before=water[index-1],r=270+random()*140,heading=Math.atan2(parent.y-before.y,parent.x-before.x)+(random()<.5?-1:1)*(1.05+random()*.65),step=(parent.r+r)*(.42+random()*.13);water.push({x:clamp(parent.x+Math.cos(heading)*step,r*.42,W-r*.42),y:clamp(parent.y+Math.sin(heading)*step,r*.42,H-r*.42),r,seed:random()*Math.PI*2})}return water}
function wetCells(water){let n=0;for(let j=0;j<GH;j++)for(let i=0;i<GW;i++)if(inWater(water,(i+.5)*CELL,(j+.5)*CELL))n++;return n}
const ratios=[];
for(const seed of [481516,1,123456789,0xffffffff]){const base=wetCells(baseWater(seed)),world=new World(seed),now=world.wet.reduce((a,b)=>a+b,0),ratio=now/base;ratios.push(ratio);assert.ok(ratio>1.7&&ratio<2.3,`rough water area ratio ${ratio}`)}
{const w=new World(77),edited=w.water.map((v,i)=>({...v,r:i===0?v.r*.98:v.r}));assert.equal(w.setWater(edited),true);assert.deepEqual(w.water,edited,'user water geometry was altered')}
// 4) Short population smoke: births and at least one net-increase day must be possible.
const pop=new World(481516), firstNextId=pop.nextId;let previous=pop.stats().population,sawIncrease=false;
for(let day=0;day<14;day++){
for(let i=0;i<24;i++)pop.step();
const now=pop.stats().population;if(now>previous)sawIncrease=true;previous=now;
}
assert.ok(pop.nextId>firstNextId);
assert.ok(sawIncrease);
assert.ok(pop.animals.every(a=>[a.x,a.y,a.energy,a.age,a.structuralMassKg].every(Number.isFinite)));
// Hirt maximum-speed coefficients and separate routine/home-range/dispersal paths.
assert.ok(maximumSpeedMPerDay(3,'running')>routineTravelSpeedMPerDay(3,900,'running'));
assert.ok(maximumSpeedMPerDay(3,'flying')>maximumSpeedMPerDay(3,'running'));
assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('flying')),{a:142.8,b:.24,h:2.4,i:-.72});
assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('running')),{a:25.5,b:.26,h:22,i:-.60});
assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('swimming')),{a:11.2,b:.36,h:19.5,i:-.56});
assert.ok(homeRangeRadiusM(10,'swimming')>homeRangeRadiusM(10,'running'));
assert.ok(sampleDispersalDistanceM(()=>.5,3,'running')>0);
// 5) Removed constraints/workarounds must not return.
const productionFiles=['engine.js','worker.js','ecology/movement.js'];
const code=(await Promise.all(productionFiles.map(f=>readFile(new URL('../'+f,import.meta.url),'utf8')))).join('\n');
assert.ok(!/homeRange|homeX|homeY|counter-torque|rotationIndex|angularScale/i.test(code));
assert.ok(!/set-interactions|set-climate|run-batch|schedule-disturbance/i.test(code));
// Cohort resource path is active.
{const z=updateZooplanktonCohort(.1,.02,1);assert.ok(z.producerConsumedKgPerM2>0);assert.notEqual(z.nextZooplanktonKgPerM2,.02)}
// Reproduction must not kill the parent.
{const w=new World(481516),parent=w.animals.find(a=>w.roleOf(a)==='primary-consumer');w.time=100;parent.age=parent.g.maturityAge+1;parent.energy=w.maxEnergy(parent);parent.lastBirth=w.time-reproductionConfig('primary-consumer').reproductionIntervalDays-1;const before=w.animals.length;w.reproduce(parent);assert.equal(parent.dead,false);assert.ok(w.animals.length>before)}
// Juvenile structural growth is energy-limited rather than age-only.
{const birth=initialStructuralMassKg(10,.2,0,100);approx(birth,2,1e-12,'birth structural mass');const rich=energyLimitedGrowth({structuralMassKg:birth,adultMassKg:10,energyKJ:5000,ageDays:10,maturityAgeDays:100,dtDays:1}),poor=energyLimitedGrowth({structuralMassKg:birth,adultMassKg:10,energyKJ:500,ageDays:10,maturityAgeDays:100,dtDays:1});assert.ok(rich.gainKg>0,'surplus energy did not produce structural growth');assert.ok(rich.energyKJ<5000,'growth did not consume energy');assert.equal(poor.gainKg,0,'growth occurred below reserve floor')}
// Profile switching remains an internal UI operation. Climate series support is internal, not a World/Worker experiment API.
{const w=new World(7);assert.throws(()=>w.setAquaticProfile('bogus'));const c=new ClimateProvider();c.setSeries('surfaceTemperatureC',[12,13,14]);c.setSeries('soilMoisture',[.2,.4,.6]);c.setSeries('nutrientN',[.3,.4,.5]);assert.equal(c.temperatureC(1,{aquatic:true}),13);assert.equal(c.soilMoisture(1,.9),.4);assert.equal(c.value('nutrientN',1),.4)}
// Ordinary stepping: finite state, no spontaneous immigration/speciation, daily movement budget enforced.
const bench=new World(481516),startSpeciesId=bench.nextSpeciesId,start=performance.now();
for(let i=0;i<30*24;i++)bench.step();
const elapsedMs=performance.now()-start,stats=bench.stats(),zooMass=sumField(bench.zooplankton);
assert.equal(startSpeciesId,INITIAL_SPECIES);assert.equal(bench.nextSpeciesId,INITIAL_SPECIES);
assert.ok(Number.isFinite(stats.plant)&&stats.plant>=0);assert.ok(Number.isFinite(zooMass)&&zooMass>=0);
assert.ok(bench.plants.every(Number.isFinite));assert.ok(bench.zooplankton.every(Number.isFinite));
assert.ok(bench.animals.every(a=>[a.x,a.y,a.energy,a.age,a.structuralMassKg,a.movedToday].every(Number.isFinite)));
for(const a of bench.animals){if(a.dead)continue;const mode=bench.species[a.sid]?.locomotionMode||'running',budget=dailyMovementBudgetM(bench.biomass(a),a.g.moveSpeed,mode);assert.ok(a.movedToday<=budget+1e-6,`daily movement budget exceeded: ${a.movedToday} > ${budget}`)}
assert.equal(bench.resourceFields[0].representation,'field');assert.equal(bench.resourceFields[1].representation,'cohort');
assert.deepEqual(Object.keys(stats).sort(),['generation','plant','population','species','temp','time'].sort(),'detailed diagnostic output leaked into UI stats');
// Requested omissions and removed experiment API/output stay absent.
const codeFiles=['../engine.js','../worker.js','../ecology/profiles.js','../ecology/resources.js','../ecology/metabolism.js','../ecology/feeding.js','../ecology/demography.js','../ecology/movement.js','../ecology/schema.js'];
const code=(await Promise.all(codeFiles.map(f=>readFile(new URL(f,import.meta.url),'utf8')))).join('\n');
assert.ok(!/DisturbanceEvent|schedule-disturbance|pesticide|harvest|habitat-loss|nutrient-pulse/.test(code),'human disturbance code remains');
assert.ok(!/waterDepth|depthLayer|mixed-layer|deepLayer/.test(code),'water-depth code remains');
assert.ok(!/doubleWaterArea|target=Math\.min\(GW\*GH,base\*2\)|for\(let iter=0;iter<14/.test(code),'exact water-area optimizer remains');
assert.ok(!/nearest=1400;for\(const b of this\.animals\)/.test(code),'omniscient global prey scan remains');
assert.ok(!/set-interactions|set-climate|run-batch|batch-result|batch-progress|validateBatchRequest|setInteractions|setClimate|introduce\(/.test(code),'removed experiment API remains');
assert.ok(!/consumptionFlux|assimilationFlux|respirationLoss|resourceTurnover|occupiedArea|meanBodyMass|biomassByGroup|populationByGroup|trophicFlux/.test(code),'removed detailed output remains');
// Deterministic repeatability after removing diagnostics.
const a=new World(20260929),b=new World(20260929);for(let i=0;i<7*24;i++){a.step();b.step()}assert.deepEqual(a.stats(),b.stats());assert.equal(sumField(a.zooplankton),sumField(b.zooplankton));
console.log(JSON.stringify({ok:true,dtDays:DT,waterAreaRatios:ratios,benchmark30DaysMs:+elapsedMs.toFixed(1),benchmark30DaysPopulation:stats.population,benchmark30DaysSpecies:stats.species.length,producerBiomassKg:+stats.plant.toFixed(2),zooplanktonBiomassKg:+zooMass.toFixed(2)},null,2));
console.log(JSON.stringify({ok:true,dtDays:DT,animals:pop.stats().population,births:pop.nextId-firstNextId},null,2));