2026-06-21 14:09:35 +09:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
(function (global) {
|
|
|
|
|
const World = global.World;
|
|
|
|
|
if (!World) throw new Error("World is not available for mixin: world_spatial_budget.js");
|
2026-06-21 22:29:00 +09:00
|
|
|
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
2026-06-24 21:20:53 +09:00
|
|
|
beginFramePerformanceBudgets() {
|
2026-07-10 16:40:06 +09:00
|
|
|
const profile = global.TarinaiPerf?.performanceProfile?.() || {};
|
2026-06-24 17:39:44 +09:00
|
|
|
this.workBudget = {
|
2026-07-10 16:40:06 +09:00
|
|
|
ai: Math.max(1, Number(profile.aiBudget || 10)),
|
|
|
|
|
env: Math.max(1, Number(profile.envBudget || 5)),
|
2026-06-24 17:39:44 +09:00
|
|
|
aiUsed: 0,
|
|
|
|
|
envUsed: 0,
|
|
|
|
|
};
|
2026-07-18 13:06:02 +09:00
|
|
|
const diagnostics = global.TarinaiPerf?.diagnosticsEnabled?.() === true;
|
|
|
|
|
this.workStats = diagnostics ? {
|
|
|
|
|
aiRuns: 0, aiSkips: 0, envRuns: 0, envSkips: 0,
|
|
|
|
|
collisionRuns: 0, collisionSkips: 0, bedConflictRuns: 0, bedConflictSkips: 0,
|
|
|
|
|
} : null;
|
|
|
|
|
this.spatialDirtyReasonCountsThisFrame = diagnostics ? {} : null;
|
|
|
|
|
this.spatialRebuildReasonCountsThisFrame = diagnostics ? {} : null;
|
2026-06-27 19:22:38 +09:00
|
|
|
this.deferredSpatialReadsThisFrame = 0;
|
2026-07-18 13:06:02 +09:00
|
|
|
this.terrainDirtyStatsThisFrame = diagnostics ? { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} } : null;
|
|
|
|
|
this.nearbyQueryStatsThisFrame = diagnostics ? { obstacleRectHits: 0, obstacleRectMisses: 0, obstacleRectBypass: 0, obstacleRectBuilt: 0 } : null;
|
|
|
|
|
this.constraintDirtyStatsThisFrame = diagnostics ? { marked: 0, coalesced: 0 } : null;
|
|
|
|
|
this.processTarinaiRuntimeCachePruneStep?.(48);
|
2026-07-05 18:01:36 +09:00
|
|
|
if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map();
|
2026-06-24 17:39:44 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
spendScheduledWork(kind = "ai", urgent = false) {
|
|
|
|
|
if (!this.workBudget) this.beginFramePerformanceBudgets?.();
|
|
|
|
|
const stats = this.workStats || {};
|
|
|
|
|
const budget = this.workBudget || {};
|
|
|
|
|
const usedKey = `${kind}Used`;
|
|
|
|
|
const runKey = `${kind}Runs`;
|
|
|
|
|
const skipKey = `${kind}Skips`;
|
|
|
|
|
if (urgent || (budget[usedKey] || 0) < (budget[kind] || 0)) {
|
|
|
|
|
budget[usedKey] = (budget[usedKey] || 0) + 1;
|
|
|
|
|
stats[runKey] = (stats[runKey] || 0) + 1;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
stats[skipKey] = (stats[skipKey] || 0) + 1;
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-25 14:51:58 +09:00
|
|
|
|
|
|
|
|
spatialDirtySummary() {
|
|
|
|
|
return {
|
|
|
|
|
staticItems: Boolean(this.spatialStaticItemsDirty),
|
|
|
|
|
dynamicItems: Boolean(this.spatialDynamicItemsDirty),
|
|
|
|
|
tarinai: Boolean(this.spatialTarinaiDirty),
|
|
|
|
|
ants: Boolean(this.spatialAntDirty),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-24 13:48:22 +09:00
|
|
|
runtimeDiagnostics() {
|
|
|
|
|
const behaviorCounts = {};
|
|
|
|
|
let forcedBehaviors = 0;
|
|
|
|
|
let lockedBehaviors = 0;
|
2026-06-24 17:39:44 +09:00
|
|
|
let liveTarinai = 0;
|
2026-06-24 13:48:22 +09:00
|
|
|
for (const t of this.tarinai || []) {
|
|
|
|
|
if (!t || t.dead) continue;
|
2026-06-24 17:39:44 +09:00
|
|
|
liveTarinai += 1;
|
2026-06-29 16:21:58 +09:00
|
|
|
const behavior = currentTarinaiBehavior(t);
|
2026-06-25 14:51:58 +09:00
|
|
|
const id = behavior?.actionId || t.state || "none";
|
2026-06-24 13:48:22 +09:00
|
|
|
behaviorCounts[id] = (behaviorCounts[id] || 0) + 1;
|
2026-06-29 16:21:58 +09:00
|
|
|
if (isTarinaiBehaviorValueForced(behavior)) forcedBehaviors += 1;
|
2026-06-25 14:51:58 +09:00
|
|
|
if ((t.behaviorLockTimer || 0) > 0.01) lockedBehaviors += 1;
|
2026-06-24 13:48:22 +09:00
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
spatial: {
|
|
|
|
|
version: this.spatialVersion || 0,
|
|
|
|
|
dirty: Boolean(this.spatialDirty),
|
2026-06-25 14:51:58 +09:00
|
|
|
dirtyParts: this.spatialDirtySummary?.() || null,
|
2026-06-24 13:48:22 +09:00
|
|
|
dirtyReason: this.spatialDirtyReason || this.lastSpatialDirtyReason || "",
|
|
|
|
|
dirtyMarksThisFrame: this.spatialDirtyMarksThisFrame || 0,
|
|
|
|
|
dirtyMarksTotal: this.spatialDirtyMarksTotal || 0,
|
|
|
|
|
rebuildsThisFrame: this.spatialRebuildsThisFrame || 0,
|
|
|
|
|
rebuildsTotal: this.spatialRebuildsTotal || 0,
|
2026-06-25 14:51:58 +09:00
|
|
|
partialRebuildsTotal: this.spatialPartialRebuildsTotal || 0,
|
2026-06-24 13:48:22 +09:00
|
|
|
lastRebuildReason: this.lastSpatialRebuildReason || "",
|
2026-06-27 19:22:38 +09:00
|
|
|
dirtyReasons: this.spatialDirtyReasonCountsThisFrame || {},
|
|
|
|
|
rebuildReasons: this.spatialRebuildReasonCountsThisFrame || {},
|
|
|
|
|
deferredReads: this.deferredSpatialReadsThisFrame || 0,
|
2026-06-24 13:48:22 +09:00
|
|
|
},
|
|
|
|
|
render: {
|
|
|
|
|
drawList: (this.drawList || []).length,
|
|
|
|
|
drawListDirty: Boolean(this.drawListDirty),
|
|
|
|
|
drawSortTimer: Number(this.drawSortTimer || 0),
|
|
|
|
|
},
|
|
|
|
|
items: {
|
|
|
|
|
total: (this.items || []).length,
|
|
|
|
|
bucketTypes: this.itemTypeBuckets?.size || 0,
|
|
|
|
|
bucketsDirty: Boolean(this.itemBucketsDirty),
|
|
|
|
|
idMapSize: this.itemIdMap?.size || 0,
|
2026-07-05 18:01:36 +09:00
|
|
|
ownerBuckets: this.itemOwnerBuckets?.size || 0,
|
2026-06-24 13:48:22 +09:00
|
|
|
bucketRebuildsTotal: this.itemBucketRebuildsTotal || 0,
|
|
|
|
|
},
|
|
|
|
|
behavior: {
|
|
|
|
|
forced: forcedBehaviors,
|
|
|
|
|
locked: lockedBehaviors,
|
|
|
|
|
counts: behaviorCounts,
|
|
|
|
|
},
|
2026-06-25 14:51:58 +09:00
|
|
|
runtime: {
|
2026-06-24 17:39:44 +09:00
|
|
|
liveTarinai,
|
2026-06-25 14:51:58 +09:00
|
|
|
fps: window.__tarinaiFps || 0,
|
2026-06-24 17:39:44 +09:00
|
|
|
dpr: (typeof uiCache !== "undefined" ? uiCache?.canvasDpr : window.uiCache?.canvasDpr) || 0,
|
|
|
|
|
},
|
|
|
|
|
work: {
|
|
|
|
|
budget: this.workBudget || null,
|
|
|
|
|
stats: this.workStats || null,
|
|
|
|
|
},
|
2026-06-25 14:51:58 +09:00
|
|
|
scheduler: this._itemUpdateSchedulerStats || null,
|
2026-06-27 19:22:38 +09:00
|
|
|
creatures: this._creatureUpdateStats || null,
|
2026-07-01 22:59:49 +09:00
|
|
|
effects: this._effectUpdateStats || null,
|
2026-07-10 16:40:06 +09:00
|
|
|
effectRender: this._effectRenderStats || null,
|
2026-06-27 19:22:38 +09:00
|
|
|
terrain: this.terrainDirtyStatsThisFrame || null,
|
|
|
|
|
nearby: this.nearbyQueryStatsThisFrame || null,
|
|
|
|
|
constraintDirty: this.constraintDirtyStatsThisFrame || null,
|
2026-06-29 16:21:58 +09:00
|
|
|
perf: window.TarinaiPerf.snapshot() || null,
|
2026-06-24 13:48:22 +09:00
|
|
|
};
|
|
|
|
|
},
|
2026-06-24 21:20:53 +09:00
|
|
|
grassLimit() {
|
2026-06-29 16:21:58 +09:00
|
|
|
return window.TarinaiGround.grassLimit(this.groundType || "soil", this.fieldType || "garden") ?? (CONFIG.grassLimit ?? 99);
|
2026-06-24 21:20:53 +09:00
|
|
|
},
|
|
|
|
|
canAddGrass(extra = 1) {
|
2026-06-25 14:51:58 +09:00
|
|
|
const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 99);
|
2026-06-24 21:20:53 +09:00
|
|
|
if (!Number.isFinite(limit)) return true;
|
2026-06-25 14:51:58 +09:00
|
|
|
const current = Math.max(0, Math.floor(Number(this.itemCounts?.grass || 0)));
|
|
|
|
|
return current + Math.max(0, Number(extra) || 0) <= limit;
|
2026-06-24 21:20:53 +09:00
|
|
|
},
|
|
|
|
|
|
2026-06-25 14:51:58 +09:00
|
|
|
enforceGrassLimit(reason = "grass-limit") {
|
|
|
|
|
const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 99);
|
|
|
|
|
if (!Number.isFinite(limit)) return 0;
|
|
|
|
|
this.updateItemCounts?.(`${reason}:count`);
|
|
|
|
|
let kept = 0;
|
|
|
|
|
let removed = 0;
|
|
|
|
|
for (const it of this.items || []) {
|
|
|
|
|
if (!it || it.dead || it.type !== "grass") continue;
|
|
|
|
|
if (kept < limit) {
|
|
|
|
|
kept += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
it.amount = 0;
|
|
|
|
|
it.growth = 0;
|
|
|
|
|
it.grassStage = 0;
|
|
|
|
|
removed += 1;
|
|
|
|
|
}
|
|
|
|
|
if (removed > 0) {
|
|
|
|
|
this.markItemBucketsDirty?.(reason);
|
|
|
|
|
this.markSpatialDirty?.(reason);
|
|
|
|
|
this.markTerrainDirty?.(reason);
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
2026-06-25 14:51:58 +09:00
|
|
|
return removed;
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
2026-06-24 13:48:22 +09:00
|
|
|
ensureSpatial(reason = "read") {
|
|
|
|
|
if (!this.spatial) return false;
|
|
|
|
|
if (!this.spatialDirty && (this.spatialVersion || 0) > 0) return false;
|
2026-06-27 19:22:38 +09:00
|
|
|
|
|
|
|
|
// During the creature pass, Tarinai movement marks only the Tarinai bucket
|
|
|
|
|
// dirty many times. Obstacle/static item cells are still valid, so spatial
|
|
|
|
|
// reads can use the frame-start Tarinai grid and collapse those invalidations
|
|
|
|
|
// into one post-creature rebuild. Do not defer static/dynamic/ant dirtiness:
|
|
|
|
|
// those buckets are used by collision and contact queries and need fresh data.
|
|
|
|
|
const inDeferredPhase = (this.deferSpatialRebuildDepth || 0) > 0 && (this.spatialVersion || 0) > 0;
|
|
|
|
|
const canDeferTarinaiOnly = inDeferredPhase
|
|
|
|
|
&& this.spatialTarinaiDirty
|
|
|
|
|
&& !this.spatialStaticItemsDirty
|
|
|
|
|
&& !this.spatialDynamicItemsDirty
|
|
|
|
|
&& !this.spatialAntDirty;
|
|
|
|
|
const canDeferMobileOnly = inDeferredPhase
|
|
|
|
|
&& String(this.deferSpatialRebuildMode || "") === "mobile"
|
|
|
|
|
&& !this.spatialStaticItemsDirty
|
|
|
|
|
&& (this.spatialDynamicItemsDirty || this.spatialTarinaiDirty || this.spatialAntDirty);
|
|
|
|
|
if (canDeferTarinaiOnly || canDeferMobileOnly) {
|
|
|
|
|
this.deferredSpatialDirtyReason = reason;
|
2026-07-18 13:06:02 +09:00
|
|
|
if (this.spatialDirtyReasonCountsThisFrame) this.deferredSpatialReadsThisFrame = (this.deferredSpatialReadsThisFrame || 0) + 1;
|
2026-06-27 19:22:38 +09:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collision/contact queries must not read a previous-grid position after
|
|
|
|
|
// moving static/dynamic obstacles in the same frame. Partial rebuilds are
|
|
|
|
|
// already split by static/dynamic/tarinai/ant buckets, so keep those reads
|
|
|
|
|
// correct here and let dirty flags decide how much work is required.
|
2026-06-24 13:48:22 +09:00
|
|
|
return this.rebuildSpatial(true, reason);
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
nearbyItems(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-items");
|
2026-06-25 14:51:58 +09:00
|
|
|
const out = this.spatialItemScratch || [];
|
|
|
|
|
out.length = 0;
|
|
|
|
|
if (this.spatial?.nearbyInto && this.spatial.staticItemCells && this.spatial.dynamicItemCells) {
|
|
|
|
|
this.spatial.nearbyInto(this.spatial.staticItemCells, x, y, radius, out, precise);
|
|
|
|
|
this.spatial.nearbyInto(this.spatial.dynamicItemCells, x, y, radius, out, precise);
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, out, precise);
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
2026-07-18 13:06:02 +09:00
|
|
|
nearbyGrass(x, y, radius, precise = false) {
|
|
|
|
|
this.ensureSpatial?.("nearby-grass");
|
|
|
|
|
const out = this.spatialGrassScratch || (this.spatialGrassScratch = []);
|
|
|
|
|
out.length = 0;
|
|
|
|
|
if (this.spatial?.grassCells) return this.spatial.nearby(this.spatial.grassCells, x, y, radius, out, precise);
|
|
|
|
|
// Compatibility fallback for an old/restored SpatialGrid instance. This
|
|
|
|
|
// path should disappear after the next normal spatial rebuild.
|
|
|
|
|
const source = this.nearbyItems(x, y, radius, precise);
|
|
|
|
|
for (const item of source) if (item?.type === "grass" && !item.dead) out.push(item);
|
|
|
|
|
return out;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nearbyNonGrassItems(x, y, radius, precise = false) {
|
|
|
|
|
this.ensureSpatial?.("nearby-non-grass-items");
|
|
|
|
|
const out = this.spatialNonGrassItemScratch || (this.spatialNonGrassItemScratch = []);
|
|
|
|
|
out.length = 0;
|
|
|
|
|
if (this.spatial?.nearbyInto && this.spatial.staticNonGrassItemCells && this.spatial.dynamicNonGrassItemCells) {
|
|
|
|
|
this.spatial.nearbyInto(this.spatial.staticNonGrassItemCells, x, y, radius, out, precise);
|
|
|
|
|
this.spatial.nearbyInto(this.spatial.dynamicNonGrassItemCells, x, y, radius, out, precise);
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
// Compatibility fallback for older/restored SpatialGrid instances.
|
|
|
|
|
const source = this.nearbyItems(x, y, radius, precise);
|
|
|
|
|
let write = 0;
|
|
|
|
|
for (let read = 0; read < source.length; read++) {
|
|
|
|
|
const item = source[read];
|
|
|
|
|
if (item?.type === "grass") continue;
|
|
|
|
|
out[write++] = item;
|
|
|
|
|
}
|
|
|
|
|
out.length = write;
|
|
|
|
|
return out;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
nearbyTarinai(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-tarinai");
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch, precise);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nearbyAnts(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-ants");
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.antCells, x, y, radius, this.spatialAntScratch, precise);
|
|
|
|
|
},
|
2026-06-25 14:51:58 +09:00
|
|
|
nearbyFood(x, y, radius, precise = false) {
|
|
|
|
|
this.ensureSpatial?.("nearby-food");
|
|
|
|
|
if (this.spatial?.nearbySplitInto && this.spatial.staticFoodCells && this.spatial.dynamicFoodCells) {
|
|
|
|
|
return this.spatial.nearbySplitInto(this.spatial.staticFoodCells, this.spatial.dynamicFoodCells, x, y, radius, this.spatialFoodScratch, precise);
|
|
|
|
|
}
|
|
|
|
|
return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise);
|
|
|
|
|
},
|
2026-06-21 14:09:35 +09:00
|
|
|
|
|
|
|
|
nearbyObstacles(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-obstacles");
|
2026-06-25 14:51:58 +09:00
|
|
|
if (this.spatial?.nearbySplitInto && this.spatial.staticObstacleCells && this.spatial.dynamicObstacleCells) {
|
|
|
|
|
return this.spatial.nearbySplitInto(this.spatial.staticObstacleCells, this.spatial.dynamicObstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
|
|
|
|
|
}
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.obstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nearbyHazards(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-hazards");
|
2026-06-25 14:51:58 +09:00
|
|
|
if (this.spatial?.nearbySplitInto && this.spatial.staticHazardCells && this.spatial.dynamicHazardCells) {
|
|
|
|
|
return this.spatial.nearbySplitInto(this.spatial.staticHazardCells, this.spatial.dynamicHazardCells, x, y, radius, this.spatialHazardScratch, precise);
|
|
|
|
|
}
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.hazardCells, x, y, radius, this.spatialHazardScratch, precise);
|
|
|
|
|
},
|
2026-06-24 13:48:22 +09:00
|
|
|
rebuildSpatial(force = false, reason = "manual") {
|
|
|
|
|
// Direct calls keep their old eager semantics. update() uses markSpatialDirty + ensureSpatial
|
|
|
|
|
// so repeated rebuild requests in the same phase are collapsed to one rebuild.
|
|
|
|
|
if (!force && this.spatialDirty === false && reason !== "manual") return false;
|
2026-06-25 14:51:58 +09:00
|
|
|
const profiler = window.TarinaiPerf;
|
2026-06-29 16:21:58 +09:00
|
|
|
const end = profiler.begin("update.spatial") || null;
|
2026-06-25 14:51:58 +09:00
|
|
|
try {
|
|
|
|
|
const full = reason === "manual" || reason === "reset" || !this.spatialVersion || !this.spatial?.rebuildStaticItems;
|
|
|
|
|
if (full) {
|
|
|
|
|
this.spatial.rebuild(this.items, this.tarinai, this.ants || []);
|
2026-07-18 13:06:02 +09:00
|
|
|
this.renderStaticVersion = (this.renderStaticVersion || 0) + 1;
|
2026-06-25 14:51:58 +09:00
|
|
|
} else {
|
|
|
|
|
const staticDirty = Boolean(this.spatialStaticItemsDirty);
|
|
|
|
|
const dynamicDirty = Boolean(this.spatialDynamicItemsDirty || staticDirty);
|
|
|
|
|
const tarinaiDirty = Boolean(this.spatialTarinaiDirty);
|
|
|
|
|
const antDirty = Boolean(this.spatialAntDirty);
|
2026-07-18 13:06:02 +09:00
|
|
|
if (staticDirty) {
|
|
|
|
|
this.spatial.rebuildStaticItems(this.items, { rebuildCombined: false });
|
|
|
|
|
this.renderStaticVersion = (this.renderStaticVersion || 0) + 1;
|
|
|
|
|
}
|
2026-06-25 14:51:58 +09:00
|
|
|
if (dynamicDirty) this.spatial.rebuildDynamicItems(this.items, { rebuildCombined: false });
|
|
|
|
|
if (tarinaiDirty) this.spatial.rebuildTarinai(this.tarinai);
|
|
|
|
|
if (antDirty) this.spatial.rebuildAnts(this.ants || []);
|
2026-07-18 13:06:02 +09:00
|
|
|
if (this.spatialRebuildReasonCountsThisFrame) this.spatialPartialRebuildsTotal = (this.spatialPartialRebuildsTotal || 0) + 1;
|
2026-06-25 14:51:58 +09:00
|
|
|
}
|
2026-07-18 13:06:02 +09:00
|
|
|
if (full || this.spatialStaticItemsDirty || this.spatialDynamicItemsDirty) this.spatial.rebuildLinkRenderItems?.(this.items);
|
2026-06-25 14:51:58 +09:00
|
|
|
} finally {
|
|
|
|
|
if (end) end();
|
|
|
|
|
}
|
2026-06-21 14:09:35 +09:00
|
|
|
this.spatialDirty = false;
|
2026-06-25 14:51:58 +09:00
|
|
|
this.spatialStaticItemsDirty = false;
|
|
|
|
|
this.spatialDynamicItemsDirty = false;
|
|
|
|
|
this.spatialTarinaiDirty = false;
|
|
|
|
|
this.spatialAntDirty = false;
|
2026-06-24 13:48:22 +09:00
|
|
|
this.spatialDirtyReason = "";
|
2026-06-21 14:09:35 +09:00
|
|
|
this.spatialVersion = (this.spatialVersion || 0) + 1;
|
2026-07-18 13:06:02 +09:00
|
|
|
if (this.spatialRebuildReasonCountsThisFrame) {
|
|
|
|
|
this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1;
|
|
|
|
|
this.spatialRebuildsTotal = (this.spatialRebuildsTotal || 0) + 1;
|
|
|
|
|
}
|
2026-06-24 13:48:22 +09:00
|
|
|
this.lastSpatialRebuildReason = reason;
|
2026-07-18 13:06:02 +09:00
|
|
|
if (this.spatialRebuildReasonCountsThisFrame) {
|
|
|
|
|
const key = String(reason || "manual");
|
|
|
|
|
this.spatialRebuildReasonCountsThisFrame[key] = (this.spatialRebuildReasonCountsThisFrame[key] || 0) + 1;
|
|
|
|
|
}
|
2026-06-24 13:48:22 +09:00
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
markItemBucketsDirty(reason = "manual") {
|
|
|
|
|
this.itemBucketsDirty = true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
ensureItemBuckets(reason = "read") {
|
2026-07-05 18:01:36 +09:00
|
|
|
if (!this.itemTypeBuckets || !this.itemIdMap || !this.itemOwnerBuckets || this.itemBucketsDirty) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.updateItemCounts(reason);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-09 16:08:11 +09:00
|
|
|
normalizeColonyLimit(value = 0) {
|
|
|
|
|
const n = Math.floor(Number(value) || 0);
|
2026-07-18 13:06:02 +09:00
|
|
|
return Number.isFinite(n) && n > 0 ? Math.min(300, n) : 0;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
rebuildTarinaiCountCache(reason = "manual") {
|
|
|
|
|
const cache = this._tarinaiCountCache || (this._tarinaiCountCache = { alive: 0, zunchiSlaves: 0, tarinaiKings: 0 });
|
|
|
|
|
cache.alive = 0;
|
|
|
|
|
cache.zunchiSlaves = 0;
|
|
|
|
|
cache.tarinaiKings = 0;
|
|
|
|
|
for (const t of this.tarinai || []) {
|
|
|
|
|
if (!t) continue;
|
|
|
|
|
const alive = !t.dead;
|
|
|
|
|
const slave = alive && Boolean(t.isZunchiSlave);
|
|
|
|
|
const king = alive && Boolean(t.isTarinaiChampion);
|
|
|
|
|
t._countedAlive = alive;
|
|
|
|
|
t._countedZunchiSlave = slave;
|
|
|
|
|
t._countedTarinaiKing = king;
|
|
|
|
|
if (!alive) continue;
|
|
|
|
|
cache.alive += 1;
|
|
|
|
|
if (slave) cache.zunchiSlaves += 1;
|
|
|
|
|
if (king) cache.tarinaiKings += 1;
|
|
|
|
|
}
|
|
|
|
|
this._tarinaiCountDirty = false;
|
|
|
|
|
return cache;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
tarinaiCounts() {
|
|
|
|
|
if (this._tarinaiCountDirty || !this._tarinaiCountCache) return this.rebuildTarinaiCountCache?.("lazy") || { alive: 0, zunchiSlaves: 0, tarinaiKings: 0 };
|
|
|
|
|
return this._tarinaiCountCache;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
markTarinaiCountsDirty(reason = "manual") {
|
|
|
|
|
this._tarinaiCountDirty = true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
syncTarinaiCountEntry(t) {
|
|
|
|
|
if (!t) return this.tarinaiCounts?.() || null;
|
|
|
|
|
if (this._tarinaiCountDirty || !this._tarinaiCountCache) return null;
|
|
|
|
|
const cache = this._tarinaiCountCache;
|
|
|
|
|
const alive = !t.dead;
|
|
|
|
|
const slave = alive && Boolean(t.isZunchiSlave);
|
|
|
|
|
const king = alive && Boolean(t.isTarinaiChampion);
|
|
|
|
|
const prevAlive = Boolean(t._countedAlive);
|
|
|
|
|
const prevSlave = Boolean(t._countedZunchiSlave);
|
|
|
|
|
const prevKing = Boolean(t._countedTarinaiKing);
|
|
|
|
|
if (alive !== prevAlive) cache.alive = Math.max(0, cache.alive + (alive ? 1 : -1));
|
|
|
|
|
if (slave !== prevSlave) cache.zunchiSlaves = Math.max(0, cache.zunchiSlaves + (slave ? 1 : -1));
|
|
|
|
|
if (king !== prevKing) cache.tarinaiKings = Math.max(0, cache.tarinaiKings + (king ? 1 : -1));
|
|
|
|
|
t._countedAlive = alive;
|
|
|
|
|
t._countedZunchiSlave = slave;
|
|
|
|
|
t._countedTarinaiKing = king;
|
|
|
|
|
return cache;
|
2026-07-09 16:08:11 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
activeTarinaiCount() {
|
2026-07-18 13:06:02 +09:00
|
|
|
return Math.max(0, Number(this.tarinaiCounts?.().alive || 0) || 0);
|
2026-07-09 16:08:11 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
activeObjectCount() {
|
2026-07-18 13:06:02 +09:00
|
|
|
if (!this.itemBucketsDirty && Number.isFinite(this._activeObjectCountCache)) return this._activeObjectCountCache;
|
2026-07-09 16:08:11 +09:00
|
|
|
let count = 0;
|
|
|
|
|
for (const item of this.items || []) if (item && !item.dead) count += 1;
|
2026-07-18 13:06:02 +09:00
|
|
|
this._activeObjectCountCache = count;
|
2026-07-09 16:08:11 +09:00
|
|
|
return count;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
canAddTarinai(count = 1) {
|
|
|
|
|
const limit = this.normalizeColonyLimit(this.tarinaiPopulationLimit);
|
2026-07-18 13:06:02 +09:00
|
|
|
if (limit <= 0) return true;
|
|
|
|
|
// Creation is infrequent compared with per-frame stats reads. Count live
|
|
|
|
|
// entries directly here so external/debug mutations cannot stale the hot
|
|
|
|
|
// differential counter and bypass the hard population limit.
|
|
|
|
|
let alive = 0;
|
|
|
|
|
for (const t of this.tarinai || []) if (t && !t.dead) alive += 1;
|
|
|
|
|
return alive + Math.max(0, Math.floor(Number(count) || 0)) <= limit;
|
2026-07-09 16:08:11 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
canAddObjects(count = 1) {
|
|
|
|
|
const limit = this.normalizeColonyLimit(this.objectLimit);
|
|
|
|
|
return limit <= 0 || this.activeObjectCount() + Math.max(0, Math.floor(Number(count) || 0)) <= limit;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setTarinaiPopulationLimit(value = 0) {
|
|
|
|
|
this.tarinaiPopulationLimit = this.normalizeColonyLimit(value);
|
|
|
|
|
return this.tarinaiPopulationLimit;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setObjectLimit(value = 0) {
|
|
|
|
|
this.objectLimit = this.normalizeColonyLimit(value);
|
|
|
|
|
return this.objectLimit;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-24 13:48:22 +09:00
|
|
|
addItem(item, reason = "add-item", opts = {}) {
|
|
|
|
|
if (!item) return null;
|
2026-07-09 16:08:11 +09:00
|
|
|
if (!this.canAddObjects(1)) return null;
|
2026-06-25 14:51:58 +09:00
|
|
|
if (item.type === "grass" && !this.canAddGrass?.(1)) return null;
|
|
|
|
|
item.world = this;
|
2026-06-24 13:48:22 +09:00
|
|
|
this.items.push(item);
|
|
|
|
|
this.markItemBucketsDirty?.(reason);
|
|
|
|
|
this.markSpatialDirty?.(reason);
|
2026-07-18 13:06:02 +09:00
|
|
|
if (opts.terrain !== false && (item.type === "grass" || item.type === "trace" || item.type === "splat")) {
|
|
|
|
|
this.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || item.radius || 24, 36), reason);
|
|
|
|
|
}
|
|
|
|
|
if (opts.countNow !== false && item.type) {
|
|
|
|
|
this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
|
|
|
|
|
item._worldCountedActive = true;
|
|
|
|
|
if (Number.isFinite(this._activeObjectCountCache)) this._activeObjectCountCache += 1;
|
|
|
|
|
} else {
|
|
|
|
|
item._worldCountedActive = false;
|
2026-06-25 14:51:58 +09:00
|
|
|
}
|
2026-07-18 13:06:02 +09:00
|
|
|
if (!this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { item, delta: 1, reason });
|
2026-06-24 13:48:22 +09:00
|
|
|
return item;
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-18 13:06:02 +09:00
|
|
|
noteItemInactive(item, reason = "item-inactive", notifyAchievements = true) {
|
|
|
|
|
if (!item || item._worldCountedActive !== true) return false;
|
|
|
|
|
item._worldCountedActive = false;
|
|
|
|
|
if (item.type) this.itemCounts[item.type] = Math.max(0, (Number(this.itemCounts[item.type] || 0) || 0) - 1);
|
|
|
|
|
if (Number.isFinite(this._activeObjectCountCache)) this._activeObjectCountCache = Math.max(0, this._activeObjectCountCache - 1);
|
|
|
|
|
if (notifyAchievements && !this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { item, delta: -1, reason });
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-24 13:48:22 +09:00
|
|
|
itemById(id) {
|
|
|
|
|
if (id == null || id === "") return null;
|
|
|
|
|
this.ensureItemBuckets?.("item-by-id");
|
|
|
|
|
const item = this.itemIdMap?.get?.(id) || null;
|
|
|
|
|
return item && !item.dead ? item : null;
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
2026-06-24 13:48:22 +09:00
|
|
|
itemsOfType(type) {
|
|
|
|
|
this.ensureItemBuckets?.(`items-of-type:${type}`);
|
|
|
|
|
const bucket = this.itemTypeBuckets?.get?.(type);
|
|
|
|
|
return bucket || [];
|
|
|
|
|
},
|
2026-07-05 18:01:36 +09:00
|
|
|
|
|
|
|
|
ownedItemsFor(ownerId) {
|
|
|
|
|
if (ownerId == null || ownerId === "") return [];
|
|
|
|
|
this.ensureItemBuckets?.(`owned-items:${ownerId}`);
|
|
|
|
|
return this.itemOwnerBuckets?.get?.(ownerId) || [];
|
|
|
|
|
},
|
2026-06-24 13:48:22 +09:00
|
|
|
updateItemCounts(reason = "manual") {
|
2026-06-21 14:09:35 +09:00
|
|
|
const counts = this.itemCounts;
|
2026-06-24 13:48:22 +09:00
|
|
|
if (!this.itemTypeBuckets) this.itemTypeBuckets = new Map();
|
|
|
|
|
if (!this.itemIdMap) this.itemIdMap = new Map();
|
2026-07-05 18:01:36 +09:00
|
|
|
if (!this.itemOwnerBuckets) this.itemOwnerBuckets = new Map();
|
2026-07-10 16:40:06 +09:00
|
|
|
if (!this.carriedPlushies) this.carriedPlushies = [];
|
2026-06-21 14:09:35 +09:00
|
|
|
for (const key of Object.keys(counts)) counts[key] = 0;
|
2026-07-18 13:06:02 +09:00
|
|
|
for (const it of this.items || []) if (it) it._worldCountedActive = false;
|
2026-06-24 13:48:22 +09:00
|
|
|
this.itemTypeBuckets.clear();
|
|
|
|
|
this.itemIdMap.clear();
|
2026-07-05 18:01:36 +09:00
|
|
|
this.itemOwnerBuckets.clear();
|
2026-07-10 16:40:06 +09:00
|
|
|
this.carriedPlushies.length = 0;
|
2026-07-18 13:06:02 +09:00
|
|
|
let activeObjectCount = 0;
|
2026-06-24 13:48:22 +09:00
|
|
|
for (const it of this.items) {
|
|
|
|
|
if (!it || it.dead) continue;
|
2026-07-18 13:06:02 +09:00
|
|
|
activeObjectCount += 1;
|
|
|
|
|
it._worldCountedActive = true;
|
2026-06-24 13:48:22 +09:00
|
|
|
counts[it.type] = (counts[it.type] || 0) + 1;
|
|
|
|
|
if (it.id != null) this.itemIdMap.set(it.id, it);
|
2026-07-10 16:40:06 +09:00
|
|
|
if (it.isStructure && it.type === "plushie" && it.carriedById) this.carriedPlushies.push(it);
|
2026-07-05 18:01:36 +09:00
|
|
|
if (it.ownerId) {
|
|
|
|
|
let ownerBucket = this.itemOwnerBuckets.get(it.ownerId);
|
|
|
|
|
if (!ownerBucket) {
|
|
|
|
|
ownerBucket = [];
|
|
|
|
|
this.itemOwnerBuckets.set(it.ownerId, ownerBucket);
|
|
|
|
|
}
|
|
|
|
|
ownerBucket.push(it);
|
|
|
|
|
}
|
2026-06-24 13:48:22 +09:00
|
|
|
let bucket = this.itemTypeBuckets.get(it.type);
|
|
|
|
|
if (!bucket) {
|
|
|
|
|
bucket = [];
|
|
|
|
|
this.itemTypeBuckets.set(it.type, bucket);
|
|
|
|
|
}
|
|
|
|
|
bucket.push(it);
|
|
|
|
|
}
|
2026-07-18 13:06:02 +09:00
|
|
|
this._activeObjectCountCache = activeObjectCount;
|
2026-06-24 13:48:22 +09:00
|
|
|
this.itemBucketsDirty = false;
|
|
|
|
|
this.itemBucketRebuildsTotal = (this.itemBucketRebuildsTotal || 0) + 1;
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateEffectCounts() {
|
|
|
|
|
const counts = this.effectCounts;
|
|
|
|
|
for (const key of Object.keys(counts)) counts[key] = 0;
|
|
|
|
|
for (const ef of this.effects) counts[ef.type] = (counts[ef.type] || 0) + 1;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
compactItems() {
|
|
|
|
|
const before = this.items.length;
|
|
|
|
|
let write = 0;
|
2026-07-18 13:06:02 +09:00
|
|
|
let terrainChanged = false;
|
2026-06-21 14:09:35 +09:00
|
|
|
for (let read = 0; read < this.items.length; read++) {
|
|
|
|
|
const it = this.items[read];
|
2026-07-18 13:06:02 +09:00
|
|
|
const removed = !it || it.type === "mirror" || it.dead;
|
|
|
|
|
if (removed) {
|
|
|
|
|
if (it) this.noteItemInactive?.(it, "compact-items", false);
|
|
|
|
|
if (it && (it.type === "grass" || it.type === "trace" || it.type === "splat")) terrainChanged = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
this.items[write++] = it;
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
|
|
|
|
this.items.length = write;
|
|
|
|
|
const changed = before !== this.items.length;
|
|
|
|
|
if (changed) {
|
2026-07-18 13:06:02 +09:00
|
|
|
this._activeObjectCountCache = write;
|
2026-06-21 14:09:35 +09:00
|
|
|
this.markSpatialDirty?.("compact-items");
|
2026-07-18 13:06:02 +09:00
|
|
|
if (terrainChanged) this.markTerrainDirty?.("compact-terrain-items");
|
2026-06-24 13:48:22 +09:00
|
|
|
this.markItemBucketsDirty?.("compact-items");
|
2026-07-18 13:06:02 +09:00
|
|
|
if (!this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { delta: 0, reason: "compact-items" });
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
|
|
|
|
return changed;
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-18 13:06:02 +09:00
|
|
|
pruneTarinaiRuntimeCaches(reason = "maintenance", opts = {}) {
|
|
|
|
|
const now = Number(this.time || 0) || 0;
|
|
|
|
|
const live = (this.tarinai || []).filter(t => t && !t.dead);
|
|
|
|
|
const aggressive = opts.aggressive === true;
|
|
|
|
|
const resetRelationPeerCaches = aggressive || opts.resetRelationPeerCaches === true;
|
|
|
|
|
const pendingDeadIds = this._deadTarinaiIdsPendingCleanup instanceof Set
|
|
|
|
|
? this._deadTarinaiIdsPendingCleanup
|
|
|
|
|
: (this._deadTarinaiIdsPendingCleanup = new Set());
|
|
|
|
|
let liveIds = null;
|
|
|
|
|
|
|
|
|
|
// Normal cleanup removes only IDs known to have died since the last prune.
|
|
|
|
|
// This avoids enumerating every relationship entry after each death batch.
|
|
|
|
|
if (aggressive) {
|
|
|
|
|
liveIds = new Set(live.map(t => t.id).filter(Boolean));
|
|
|
|
|
for (const t of live) {
|
|
|
|
|
const relationships = t.relationships && typeof t.relationships === "object" ? t.relationships : null;
|
|
|
|
|
if (!relationships) {
|
|
|
|
|
if (resetRelationPeerCaches) t.relationCache = null;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let removed = 0;
|
|
|
|
|
const next = {};
|
|
|
|
|
for (const [id, rel] of Object.entries(relationships)) {
|
|
|
|
|
if (liveIds.has(id) && id !== t.id) next[id] = rel;
|
|
|
|
|
else removed += 1;
|
|
|
|
|
}
|
|
|
|
|
if (removed > 0 || resetRelationPeerCaches) {
|
|
|
|
|
t.relationships = next;
|
|
|
|
|
t.relationCache = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (pendingDeadIds.size > 0) {
|
|
|
|
|
for (const t of live) {
|
|
|
|
|
if (typeof t.pruneDeadRelationshipRefs === "function") {
|
|
|
|
|
t.pruneDeadRelationshipRefs(pendingDeadIds);
|
|
|
|
|
} else if (t.relationships && typeof t.relationships === "object") {
|
|
|
|
|
// Restore/tests may temporarily use plain entity records rather than
|
|
|
|
|
// Tarinai instances. Keep the same dead-ID-only cleanup semantics.
|
|
|
|
|
for (const id of pendingDeadIds) delete t.relationships[id];
|
|
|
|
|
}
|
|
|
|
|
if (resetRelationPeerCaches) t.relationCache = null;
|
|
|
|
|
}
|
|
|
|
|
} else if (resetRelationPeerCaches) {
|
|
|
|
|
for (const t of live) t.relationCache = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.liveTarinai instanceof Map) {
|
|
|
|
|
for (const [id, entry] of this.liveTarinai) {
|
|
|
|
|
if (!entry?.target || entry.target.dead || (aggressive && liveIds && !liveIds.has(id))) this.liveTarinai.delete(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.tarinaiCollisionMemo instanceof Map) {
|
|
|
|
|
if (aggressive) this.tarinaiCollisionMemo = new Map();
|
|
|
|
|
else for (const [key, at] of this.tarinaiCollisionMemo) if (now - Number(at || 0) > 2.0) this.tarinaiCollisionMemo.delete(key);
|
|
|
|
|
}
|
|
|
|
|
if (this.relationNotices && typeof this.relationNotices === "object") {
|
|
|
|
|
const next = {};
|
|
|
|
|
for (const [key, at] of Object.entries(this.relationNotices)) {
|
|
|
|
|
if (now - Number(at || 0) <= 120) next[key] = at;
|
|
|
|
|
}
|
|
|
|
|
this.relationNotices = next;
|
|
|
|
|
}
|
|
|
|
|
if (this.fightPairCooldowns && typeof this.fightPairCooldowns === "object") {
|
|
|
|
|
if (aggressive) {
|
|
|
|
|
this.fightPairCooldowns = {};
|
|
|
|
|
} else {
|
|
|
|
|
const next = {};
|
|
|
|
|
for (const [key, until] of Object.entries(this.fightPairCooldowns)) {
|
|
|
|
|
if (Number(until || 0) > now) next[key] = until;
|
|
|
|
|
}
|
|
|
|
|
this.fightPairCooldowns = next;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (aggressive || now - Number(this._lastResolvedFightIdsResetAt || 0) >= 30) {
|
|
|
|
|
this.resolvedFightIds = {};
|
|
|
|
|
this._lastResolvedFightIdsResetAt = now;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pendingDeadIds.clear();
|
|
|
|
|
|
|
|
|
|
if (aggressive) {
|
|
|
|
|
this._mechanicalCrowdPressure = null;
|
|
|
|
|
this.tarinaiCollisionMemo = new Map();
|
|
|
|
|
this.relationNotices = {};
|
|
|
|
|
this.resolvedFightIds = {};
|
|
|
|
|
this.fightPairCooldowns = {};
|
|
|
|
|
this._solidObstacleRectQueryCache = new Map();
|
|
|
|
|
this.spatialTarinaiScratch = [];
|
|
|
|
|
this.spatial?.rebuildTarinai?.(this.tarinai || []);
|
|
|
|
|
this._renderVisibleTarinaiScratch = [];
|
|
|
|
|
this._renderVisibleSeen = new Set();
|
|
|
|
|
this._renderDynamicBack = [];
|
|
|
|
|
this._renderDynamicLayered = [];
|
|
|
|
|
this._renderDynamicLayerPool = [];
|
|
|
|
|
this._visibleRenderStack = null;
|
|
|
|
|
this.drawList = [];
|
|
|
|
|
this.drawListDirty = true;
|
|
|
|
|
this.markSpatialDirty?.("tarinai-runtime-shrink");
|
|
|
|
|
this._tarinaiHighSpeedCollisionCursor = 0;
|
|
|
|
|
global.TarinaiHistory?.trimMemory?.(this, { targetBytes: 8 * 1024 * 1024, keepRecent: 10 });
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
queueTarinaiRuntimeCachePrune(reason = "death-threshold", opts = {}) {
|
|
|
|
|
if (this._tarinaiRuntimePruneJob) return false;
|
|
|
|
|
const pending = this._deadTarinaiIdsPendingCleanup instanceof Set
|
|
|
|
|
? this._deadTarinaiIdsPendingCleanup
|
|
|
|
|
: (this._deadTarinaiIdsPendingCleanup = new Set());
|
|
|
|
|
const aggressive = opts.aggressive === true;
|
|
|
|
|
if (!aggressive && pending.size === 0) return false;
|
|
|
|
|
const live = this.tarinai || [];
|
|
|
|
|
this._tarinaiRuntimePruneJob = {
|
|
|
|
|
reason,
|
|
|
|
|
aggressive,
|
|
|
|
|
resetRelationPeerCaches: aggressive || opts.resetRelationPeerCaches === true,
|
|
|
|
|
deadIds: new Set(pending),
|
|
|
|
|
liveIds: aggressive ? new Set(live.filter(t => t && !t.dead && t.id).map(t => t.id)) : null,
|
|
|
|
|
cursor: 0,
|
|
|
|
|
};
|
|
|
|
|
this.deadTarinaiSinceRuntimePrune = 0;
|
|
|
|
|
this.tarinaiRuntimePrunePending = false;
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
processTarinaiRuntimeCachePruneStep(limit = 48) {
|
|
|
|
|
const job = this._tarinaiRuntimePruneJob;
|
|
|
|
|
if (!job) return false;
|
|
|
|
|
const live = this.tarinai || [];
|
|
|
|
|
let processed = 0;
|
|
|
|
|
while (job.cursor < live.length && processed < Math.max(1, limit | 0)) {
|
|
|
|
|
const t = live[job.cursor++];
|
|
|
|
|
processed += 1;
|
|
|
|
|
if (!t || t.dead) continue;
|
|
|
|
|
if (job.aggressive) {
|
|
|
|
|
const relationships = t.relationships && typeof t.relationships === "object" ? t.relationships : null;
|
|
|
|
|
if (relationships) {
|
|
|
|
|
const next = {};
|
|
|
|
|
for (const [id, rel] of Object.entries(relationships)) {
|
|
|
|
|
if (job.liveIds.has(id) && id !== t.id) next[id] = rel;
|
|
|
|
|
}
|
|
|
|
|
t.relationships = next;
|
|
|
|
|
}
|
|
|
|
|
if (job.resetRelationPeerCaches) t.relationCache = null;
|
|
|
|
|
} else if (job.deadIds.size > 0) {
|
|
|
|
|
if (typeof t.pruneDeadRelationshipRefs === "function") t.pruneDeadRelationshipRefs(job.deadIds);
|
|
|
|
|
else if (t.relationships && typeof t.relationships === "object") {
|
|
|
|
|
for (const id of job.deadIds) delete t.relationships[id];
|
|
|
|
|
}
|
|
|
|
|
if (job.resetRelationPeerCaches) t.relationCache = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (job.cursor < live.length) return false;
|
|
|
|
|
|
|
|
|
|
const now = Number(this.time || 0) || 0;
|
|
|
|
|
if (this.liveTarinai instanceof Map) {
|
|
|
|
|
for (const [id, entry] of this.liveTarinai) {
|
|
|
|
|
if (!entry?.target || entry.target.dead || (job.aggressive && job.liveIds && !job.liveIds.has(id))) this.liveTarinai.delete(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (this.tarinaiCollisionMemo instanceof Map) {
|
|
|
|
|
if (job.aggressive) this.tarinaiCollisionMemo = new Map();
|
|
|
|
|
else for (const [key, at] of this.tarinaiCollisionMemo) if (now - Number(at || 0) > 2.0) this.tarinaiCollisionMemo.delete(key);
|
|
|
|
|
}
|
|
|
|
|
if (this.relationNotices && typeof this.relationNotices === "object") {
|
|
|
|
|
const next = {};
|
|
|
|
|
for (const [key, at] of Object.entries(this.relationNotices)) if (now - Number(at || 0) <= 120) next[key] = at;
|
|
|
|
|
this.relationNotices = next;
|
|
|
|
|
}
|
|
|
|
|
if (this.fightPairCooldowns && typeof this.fightPairCooldowns === "object") {
|
|
|
|
|
if (job.aggressive) this.fightPairCooldowns = {};
|
|
|
|
|
else {
|
|
|
|
|
const next = {};
|
|
|
|
|
for (const [key, until] of Object.entries(this.fightPairCooldowns)) if (Number(until || 0) > now) next[key] = until;
|
|
|
|
|
this.fightPairCooldowns = next;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (job.aggressive || now - Number(this._lastResolvedFightIdsResetAt || 0) >= 30) {
|
|
|
|
|
this.resolvedFightIds = {};
|
|
|
|
|
this._lastResolvedFightIdsResetAt = now;
|
|
|
|
|
}
|
|
|
|
|
const pending = this._deadTarinaiIdsPendingCleanup;
|
|
|
|
|
if (pending instanceof Set) for (const id of job.deadIds) pending.delete(id);
|
|
|
|
|
|
|
|
|
|
if (job.aggressive) {
|
|
|
|
|
this._mechanicalCrowdPressure = null;
|
|
|
|
|
this.tarinaiCollisionMemo = new Map();
|
|
|
|
|
this.relationNotices = {};
|
|
|
|
|
this.resolvedFightIds = {};
|
|
|
|
|
this.fightPairCooldowns = {};
|
|
|
|
|
this._solidObstacleRectQueryCache = new Map();
|
|
|
|
|
this.spatialTarinaiScratch = [];
|
|
|
|
|
this._renderVisibleTarinaiScratch = [];
|
|
|
|
|
this._renderVisibleSeen = new Set();
|
|
|
|
|
this._renderDynamicBack = [];
|
|
|
|
|
this._renderDynamicLayered = [];
|
|
|
|
|
this._renderDynamicLayerPool = [];
|
|
|
|
|
this._visibleRenderStack = null;
|
|
|
|
|
this.drawList = [];
|
|
|
|
|
this.drawListDirty = true;
|
|
|
|
|
this.spatial?.rebuildTarinai?.(this.tarinai || []);
|
|
|
|
|
this._tarinaiHighSpeedCollisionCursor = 0;
|
|
|
|
|
global.TarinaiHistory?.trimMemory?.(this, { targetBytes: 8 * 1024 * 1024, keepRecent: 10 });
|
|
|
|
|
}
|
|
|
|
|
this._tarinaiRuntimePruneJob = null;
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
noteTarinaiDeathForRuntimeCleanup(tarinaiId = "") {
|
|
|
|
|
if (this.tarinaiRuntimePrunePending !== true) this.tarinaiRuntimePrunePending = false;
|
|
|
|
|
if (!(this._deadTarinaiIdsPendingCleanup instanceof Set)) this._deadTarinaiIdsPendingCleanup = new Set();
|
|
|
|
|
if (tarinaiId) this._deadTarinaiIdsPendingCleanup.add(String(tarinaiId));
|
|
|
|
|
this.deadTarinaiSinceRuntimePrune = Math.max(0, Number(this.deadTarinaiSinceRuntimePrune || 0) || 0) + 1;
|
|
|
|
|
const livingPopulation = this.liveTarinai instanceof Map
|
|
|
|
|
? this.liveTarinai.size
|
|
|
|
|
: Math.max(0, (this.tarinai?.length || 0) - this.deadTarinaiSinceRuntimePrune);
|
|
|
|
|
const cleanupThreshold = Math.max(20, Math.floor(livingPopulation * 0.15));
|
|
|
|
|
if (this.deadTarinaiSinceRuntimePrune >= cleanupThreshold) this.tarinaiRuntimePrunePending = true;
|
|
|
|
|
return this.tarinaiRuntimePrunePending === true;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
compactTarinai() {
|
2026-07-18 13:06:02 +09:00
|
|
|
const before = this.tarinai.length;
|
2026-06-21 14:09:35 +09:00
|
|
|
let write = 0;
|
|
|
|
|
for (let read = 0; read < this.tarinai.length; read++) {
|
|
|
|
|
const t = this.tarinai[read];
|
2026-07-18 13:06:02 +09:00
|
|
|
if (t && !t.dead) this.tarinai[write++] = t;
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
2026-07-18 13:06:02 +09:00
|
|
|
const changed = before !== write;
|
2026-06-21 14:09:35 +09:00
|
|
|
this.tarinai.length = write;
|
2026-07-18 13:06:02 +09:00
|
|
|
const previousPeak = Math.max(Number(this._tarinaiRuntimeHighWater || 0) || 0, before);
|
|
|
|
|
this._tarinaiRuntimeHighWater = Math.max(write, previousPeak);
|
|
|
|
|
if (changed) {
|
|
|
|
|
this.drawListDirty = true;
|
|
|
|
|
this.markSpatialDirty?.("compact-tarinai");
|
|
|
|
|
const collapsed = previousPeak >= 80 && write <= previousPeak * 0.72 && previousPeak - write >= 24;
|
|
|
|
|
const cleanupRequested = this.tarinaiRuntimePrunePending === true;
|
|
|
|
|
if (collapsed || cleanupRequested) {
|
|
|
|
|
this.queueTarinaiRuntimeCachePrune?.("death-threshold-runtime-cache-prune", {
|
|
|
|
|
aggressive: collapsed,
|
|
|
|
|
resetRelationPeerCaches: true,
|
|
|
|
|
});
|
|
|
|
|
if (collapsed) this._tarinaiRuntimeHighWater = Math.max(write, 32);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return changed;
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
compactAnts() {
|
|
|
|
|
if (!this.ants) this.ants = [];
|
|
|
|
|
let write = 0;
|
|
|
|
|
for (let read = 0; read < this.ants.length; read++) {
|
|
|
|
|
const a = this.ants[read];
|
|
|
|
|
if (a && !a.dead) this.ants[write++] = a;
|
|
|
|
|
}
|
|
|
|
|
if (this.ants.length !== write) this.drawListDirty = true;
|
|
|
|
|
this.ants.length = write;
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
})(typeof window !== "undefined" ? window : globalThis);
|