"use strict"; (function (global) { const World = global.World; if (!World) throw new Error("World is not available for mixin: world_spatial_budget.js"); Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ beginFramePerformanceBudgets() { const profile = global.TarinaiPerf?.performanceProfile?.() || {}; this.workBudget = { ai: Math.max(1, Number(profile.aiBudget || 10)), env: Math.max(1, Number(profile.envBudget || 5)), aiUsed: 0, envUsed: 0, }; 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; this.deferredSpatialReadsThisFrame = 0; 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); if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map(); }, 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; }, spatialDirtySummary() { return { staticItems: Boolean(this.spatialStaticItemsDirty), dynamicItems: Boolean(this.spatialDynamicItemsDirty), tarinai: Boolean(this.spatialTarinaiDirty), ants: Boolean(this.spatialAntDirty), }; }, runtimeDiagnostics() { const behaviorCounts = {}; let forcedBehaviors = 0; let lockedBehaviors = 0; let liveTarinai = 0; for (const t of this.tarinai || []) { if (!t || t.dead) continue; liveTarinai += 1; const behavior = currentTarinaiBehavior(t); const id = behavior?.actionId || t.state || "none"; behaviorCounts[id] = (behaviorCounts[id] || 0) + 1; if (isTarinaiBehaviorValueForced(behavior)) forcedBehaviors += 1; if ((t.behaviorLockTimer || 0) > 0.01) lockedBehaviors += 1; } return { spatial: { version: this.spatialVersion || 0, dirty: Boolean(this.spatialDirty), dirtyParts: this.spatialDirtySummary?.() || null, dirtyReason: this.spatialDirtyReason || this.lastSpatialDirtyReason || "", dirtyMarksThisFrame: this.spatialDirtyMarksThisFrame || 0, dirtyMarksTotal: this.spatialDirtyMarksTotal || 0, rebuildsThisFrame: this.spatialRebuildsThisFrame || 0, rebuildsTotal: this.spatialRebuildsTotal || 0, partialRebuildsTotal: this.spatialPartialRebuildsTotal || 0, lastRebuildReason: this.lastSpatialRebuildReason || "", dirtyReasons: this.spatialDirtyReasonCountsThisFrame || {}, rebuildReasons: this.spatialRebuildReasonCountsThisFrame || {}, deferredReads: this.deferredSpatialReadsThisFrame || 0, }, 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, ownerBuckets: this.itemOwnerBuckets?.size || 0, bucketRebuildsTotal: this.itemBucketRebuildsTotal || 0, }, behavior: { forced: forcedBehaviors, locked: lockedBehaviors, counts: behaviorCounts, }, runtime: { liveTarinai, fps: window.__tarinaiFps || 0, dpr: (typeof uiCache !== "undefined" ? uiCache?.canvasDpr : window.uiCache?.canvasDpr) || 0, }, runtimeCaches: { ballCollisions: this.ballCollisionMemo?.size || 0, tarinaiCollisions: this.tarinaiCollisionMemo?.size || 0, relationNotices: Object.keys(this.relationNotices || {}).length, fightCooldowns: Object.keys(this.fightPairCooldowns || {}).length, resolvedFights: Object.keys(this.resolvedFightIds || {}).length, familyRecords: Object.keys(this.family || {}).length, }, work: { budget: this.workBudget || null, stats: this.workStats || null, }, scheduler: this._itemUpdateSchedulerStats || null, creatures: this._creatureUpdateStats || null, effects: this._effectUpdateStats || null, effectRender: this._effectRenderStats || null, terrain: this.terrainDirtyStatsThisFrame || null, nearby: this.nearbyQueryStatsThisFrame || null, constraintDirty: this.constraintDirtyStatsThisFrame || null, perf: window.TarinaiPerf.snapshot() || null, }; }, grassLimit() { return window.TarinaiGround.grassLimit(this.groundType || "soil", this.fieldType || "garden") ?? (CONFIG.grassLimit ?? 99); }, canAddGrass(extra = 1) { const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 99); if (!Number.isFinite(limit)) return true; const current = Math.max(0, Math.floor(Number(this.itemCounts?.grass || 0))); return current + Math.max(0, Number(extra) || 0) <= limit; }, 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); } return removed; }, ensureSpatial(reason = "read") { if (!this.spatial) return false; if (!this.spatialDirty && (this.spatialVersion || 0) > 0) return false; // 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; if (this.spatialDirtyReasonCountsThisFrame) this.deferredSpatialReadsThisFrame = (this.deferredSpatialReadsThisFrame || 0) + 1; 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. return this.rebuildSpatial(true, reason); }, nearbyItems(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-items"); const out = this.spatialItemScratch || []; out.length = 0; if (!this.spatial?.nearbyInto) return out; this.spatial.nearbyInto(this.spatial.staticItemCells, x, y, radius, out, precise); this.spatial.nearbyInto(this.spatial.dynamicItemCells, x, y, radius, out, precise); return out; }, 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; }, nearbyTarinai(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-tarinai"); return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch, precise); }, nearbyAnts(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-ants"); return this.spatial.nearby(this.spatial.antCells, x, y, radius, this.spatialAntScratch, precise); }, nearbyFood(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-food"); if (!this.spatial?.nearbySplitInto) return this.spatialFoodScratch; return this.spatial.nearbySplitInto(this.spatial.staticFoodCells, this.spatial.dynamicFoodCells, x, y, radius, this.spatialFoodScratch, precise); }, nearbyObstacles(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-obstacles"); if (!this.spatial?.nearbySplitInto) return this.spatialObstacleScratch; return this.spatial.nearbySplitInto(this.spatial.staticObstacleCells, this.spatial.dynamicObstacleCells, x, y, radius, this.spatialObstacleScratch, precise); }, nearbyHazards(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-hazards"); if (!this.spatial?.nearbySplitInto) return this.spatialHazardScratch; return this.spatial.nearbySplitInto(this.spatial.staticHazardCells, this.spatial.dynamicHazardCells, x, y, radius, this.spatialHazardScratch, precise); }, 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; const profiler = window.TarinaiPerf; const end = profiler.begin("update.spatial") || null; try { const full = reason === "manual" || reason === "reset" || !this.spatialVersion || !this.spatial?.rebuildStaticItems; if (full) { this.spatial.rebuild(this.items, this.tarinai, this.ants || []); this.renderStaticVersion = (this.renderStaticVersion || 0) + 1; } else { const staticDirty = Boolean(this.spatialStaticItemsDirty); const dynamicDirty = Boolean(this.spatialDynamicItemsDirty || staticDirty); const tarinaiDirty = Boolean(this.spatialTarinaiDirty); const antDirty = Boolean(this.spatialAntDirty); if (staticDirty) { this.spatial.rebuildStaticItems(this.items, { rebuildCombined: false }); this.renderStaticVersion = (this.renderStaticVersion || 0) + 1; } if (dynamicDirty) this.spatial.rebuildDynamicItems(this.items, { rebuildCombined: false }); if (tarinaiDirty) this.spatial.rebuildTarinai(this.tarinai); if (antDirty) this.spatial.rebuildAnts(this.ants || []); if (this.spatialRebuildReasonCountsThisFrame) this.spatialPartialRebuildsTotal = (this.spatialPartialRebuildsTotal || 0) + 1; } if (full || this.spatialStaticItemsDirty || this.spatialDynamicItemsDirty) this.spatial.rebuildLinkRenderItems?.(this.items); } finally { if (end) end(); } this.spatialDirty = false; this.spatialStaticItemsDirty = false; this.spatialDynamicItemsDirty = false; this.spatialTarinaiDirty = false; this.spatialAntDirty = false; this.spatialDirtyReason = ""; this.spatialVersion = (this.spatialVersion || 0) + 1; if (this.spatialRebuildReasonCountsThisFrame) { this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1; this.spatialRebuildsTotal = (this.spatialRebuildsTotal || 0) + 1; } this.lastSpatialRebuildReason = reason; if (this.spatialRebuildReasonCountsThisFrame) { const key = String(reason || "manual"); this.spatialRebuildReasonCountsThisFrame[key] = (this.spatialRebuildReasonCountsThisFrame[key] || 0) + 1; } return true; }, markItemBucketsDirty(reason = "manual") { this.itemBucketsDirty = true; }, ensureItemBuckets(reason = "read") { if (!this.itemTypeBuckets || !this.itemIdMap || !this.itemOwnerBuckets || this.itemBucketsDirty) { this.updateItemCounts(reason); return true; } return false; }, normalizeColonyLimit(value = 0) { const n = Math.floor(Number(value) || 0); 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; }, activeTarinaiCount() { return Math.max(0, Number(this.tarinaiCounts?.().alive || 0) || 0); }, activeObjectCount() { if (!this.itemBucketsDirty && Number.isFinite(this._activeObjectCountCache)) return this._activeObjectCountCache; let count = 0; for (const item of this.items || []) if (item && !item.dead) count += 1; this._activeObjectCountCache = count; return count; }, canAddTarinai(count = 1) { const limit = this.normalizeColonyLimit(this.tarinaiPopulationLimit); 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; }, 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; }, addItem(item, reason = "add-item", opts = {}) { if (!item) return null; if (!this.canAddObjects(1)) return null; if (item.type === "grass" && !this.canAddGrass?.(1)) return null; item.world = this; this.items.push(item); this.markItemBucketsDirty?.(reason); this.markSpatialDirty?.(reason); 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; } if (!this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { item, delta: 1, reason }); return item; }, residueCellKey(x, y) { const size = 160; return `${Math.floor((Number(x) || 0) / size)}:${Math.floor((Number(y) || 0) / size)}`; }, addResidue(type, x, y, opts = {}) { const residueType = String(type || ""); if (residueType !== "trace" && residueType !== "splat") return null; if (!(this.residueById instanceof Map)) this.residueById = new Map(); if (!(this.residueTypeBuckets instanceof Map)) this.residueTypeBuckets = new Map(); if (!(this.residueCells instanceof Map)) this.residueCells = new Map(); if (!Array.isArray(this.residues)) this.residues = []; const id = String(opts.id || `res${++this.residueSerial}`); const residue = { id, type: residueType, x: Number(x) || 0, y: Number(y) || 0, r: Math.max(1, Number(opts.r ?? (residueType === "splat" ? 26 : 16)) || 1), amount: Math.max(0, Number(opts.amount ?? (residueType === "splat" ? 260 : 220)) || 0), seed: Number.isFinite(Number(opts.seed)) ? Number(opts.seed) : Math.random() * 1000, dead: false, _memorialReservedBy: String(opts._memorialReservedBy || ""), }; this.residues.push(residue); this.residueById.set(id, residue); let typeBucket = this.residueTypeBuckets.get(residueType); if (!typeBucket) { typeBucket = new Set(); this.residueTypeBuckets.set(residueType, typeBucket); } typeBucket.add(residue); const cellKey = this.residueCellKey(residue.x, residue.y); residue._cellKey = cellKey; let cell = this.residueCells.get(cellKey); if (!cell) { cell = new Set(); this.residueCells.set(cellKey, cell); } cell.add(residue); this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r, 36), opts.reason || "residue-add"); return residue; }, rebuildResidueIndex() { if (!(this.residueById instanceof Map)) this.residueById = new Map(); if (!(this.residueTypeBuckets instanceof Map)) this.residueTypeBuckets = new Map(); if (!(this.residueCells instanceof Map)) this.residueCells = new Map(); this.residueById.clear(); this.residueTypeBuckets.clear(); this.residueCells.clear(); for (const residue of this.residues || []) { if (!residue || residue.dead || residue.amount <= 0) continue; this.residueById.set(String(residue.id), residue); let typeBucket = this.residueTypeBuckets.get(residue.type); if (!typeBucket) { typeBucket = new Set(); this.residueTypeBuckets.set(residue.type, typeBucket); } typeBucket.add(residue); const key = this.residueCellKey(residue.x, residue.y); residue._cellKey = key; let cell = this.residueCells.get(key); if (!cell) { cell = new Set(); this.residueCells.set(key, cell); } cell.add(residue); } return this.residueById.size; }, residueByRuntimeId(id) { if (id == null || id === "") return null; const residue = this.residueById?.get?.(String(id)) || null; return residue && !residue.dead && residue.amount > 0 ? residue : null; }, residuesOfType(type) { return this.residueTypeBuckets?.get?.(String(type || "")) || []; }, nearbyResidues(x, y, radius, type = "") { const out = this._nearbyResidueScratch || (this._nearbyResidueScratch = []); out.length = 0; if (!(this.residueCells instanceof Map) || !this.residueCells.size) return out; const size = 160; const r = Math.max(0, Number(radius) || 0); const minX = Math.floor(((Number(x) || 0) - r) / size), maxX = Math.floor(((Number(x) || 0) + r) / size); const minY = Math.floor(((Number(y) || 0) - r) / size), maxY = Math.floor(((Number(y) || 0) + r) / size); const wanted = String(type || ""); const r2 = r * r; for (let cy = minY; cy <= maxY; cy++) for (let cx = minX; cx <= maxX; cx++) { const cell = this.residueCells.get(`${cx}:${cy}`); if (!cell) continue; for (const residue of cell) { if (!residue || residue.dead || residue.amount <= 0 || (wanted && residue.type !== wanted)) continue; const dx = residue.x - x, dy = residue.y - y; if (dx * dx + dy * dy <= r2) out.push(residue); } } return out; }, removeResidue(residue, reason = "residue-remove") { if (!residue || residue.dead) return false; residue.dead = true; residue.amount = 0; this.residueById?.delete?.(residue.id); const typeBucket = this.residueTypeBuckets?.get?.(residue.type); typeBucket?.delete?.(residue); if (typeBucket && typeBucket.size === 0) this.residueTypeBuckets.delete(residue.type); const cell = this.residueCells?.get?.(residue._cellKey || this.residueCellKey(residue.x, residue.y)); cell?.delete?.(residue); if (cell && cell.size === 0) this.residueCells.delete(residue._cellKey || this.residueCellKey(residue.x, residue.y)); this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r || 24, 36), reason); return true; }, updateResidues(dt) { if (!Array.isArray(this.residues) || !this.residues.length) return 0; this.residueDecayAccumulator = Math.max(0, Number(this.residueDecayAccumulator || 0)) + Math.max(0, Number(dt) || 0); if (this.residueDecayAccumulator < 0.75) return 0; const step = this.residueDecayAccumulator; this.residueDecayAccumulator = 0; let removed = 0; for (const residue of this.residues) { if (!residue || residue.dead) continue; const before = Number(residue.amount || 0) || 0; residue.amount = Math.max(0, before - step * (residue.type === "splat" ? 1.05 : 1.35)); if (residue.type === "splat" && Math.floor(before / 18) !== Math.floor(residue.amount / 18)) { this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r || 24, 36), "splat-fade"); } if (residue.amount <= 0.001 && this.removeResidue(residue, "residue-decay")) removed += 1; } if (removed > 0 || this.residues.length > 512) this.residues = this.residues.filter(r => r && !r.dead && r.amount > 0); return removed; }, noteItemInactive(item, reason = "item-inactive", notifyAchievements = true) { if (!item || item._worldCountedActive !== true) return false; item._worldCountedActive = false; if (item._achievementPlayerPlaced && item._achievementPlayerPlacedCounted !== false) { this.achievementPlayerPlacedActiveCount = Math.max(0, (Number(this.achievementPlayerPlacedActiveCount || 0) || 0) - 1); item._achievementPlayerPlacedCounted = 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; }, 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; }, itemsOfType(type) { this.ensureItemBuckets?.(`items-of-type:${type}`); const bucket = this.itemTypeBuckets?.get?.(type); return bucket || []; }, ownedItemsFor(ownerId) { if (ownerId == null || ownerId === "") return []; this.ensureItemBuckets?.(`owned-items:${ownerId}`); return this.itemOwnerBuckets?.get?.(ownerId) || []; }, updateItemCounts(reason = "manual") { const counts = this.itemCounts; if (!this.itemTypeBuckets) this.itemTypeBuckets = new Map(); if (!this.itemIdMap) this.itemIdMap = new Map(); if (!this.itemOwnerBuckets) this.itemOwnerBuckets = new Map(); if (!this.carriedPlushies) this.carriedPlushies = []; for (const key of Object.keys(counts)) counts[key] = 0; for (const it of this.items || []) if (it) it._worldCountedActive = false; this.itemTypeBuckets.clear(); this.itemIdMap.clear(); this.itemOwnerBuckets.clear(); this.carriedPlushies.length = 0; let activeObjectCount = 0; let playerPlacedActiveCount = 0; for (const it of this.items) { if (!it || it.dead) continue; activeObjectCount += 1; it._worldCountedActive = true; if (it._achievementPlayerPlaced) { playerPlacedActiveCount += 1; it._achievementPlayerPlacedCounted = true; } else { it._achievementPlayerPlacedCounted = false; } counts[it.type] = (counts[it.type] || 0) + 1; if (it.id != null) this.itemIdMap.set(it.id, it); if (it.isStructure && it.type === "plushie" && it.carriedById) this.carriedPlushies.push(it); if (it.ownerId) { let ownerBucket = this.itemOwnerBuckets.get(it.ownerId); if (!ownerBucket) { ownerBucket = []; this.itemOwnerBuckets.set(it.ownerId, ownerBucket); } ownerBucket.push(it); } let bucket = this.itemTypeBuckets.get(it.type); if (!bucket) { bucket = []; this.itemTypeBuckets.set(it.type, bucket); } bucket.push(it); } this._activeObjectCountCache = activeObjectCount; this.achievementPlayerPlacedActiveCount = playerPlacedActiveCount; this.itemBucketsDirty = false; this.itemBucketRebuildsTotal = (this.itemBucketRebuildsTotal || 0) + 1; }, 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; let terrainChanged = false; for (let read = 0; read < this.items.length; read++) { const it = this.items[read]; 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; } this.items.length = write; const changed = before !== this.items.length; if (changed) { this._activeObjectCountCache = write; this.markSpatialDirty?.("compact-items"); if (terrainChanged) this.markTerrainDirty?.("compact-terrain-items"); this.markItemBucketsDirty?.("compact-items"); if (!this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { delta: 0, reason: "compact-items" }); } return changed; }, 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; }, compactTarinai() { const before = this.tarinai.length; let write = 0; for (let read = 0; read < this.tarinai.length; read++) { const t = this.tarinai[read]; if (t && !t.dead) this.tarinai[write++] = t; } const changed = before !== write; this.tarinai.length = write; 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; }, 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);