World Observer
-Waiting for server snapshot
+World Observer
+Waiting for server snapshot
+World Observer
World Observer
Click any bot or player point on the map.
+Choose a point or cluster to inspect the bot.
diff --git a/src/WorldObserver/WorldObserverServer.js b/src/WorldObserver/WorldObserverServer.js index 9249d85c..38a17086 100644 --- a/src/WorldObserver/WorldObserverServer.js +++ b/src/WorldObserver/WorldObserverServer.js @@ -3,6 +3,9 @@ const fs = require('fs'); const path = require('path'); const PUBLIC_DIR = path.join(__dirname, 'public'); +const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); +const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); +const ColdCombatProfile = invoke('GameServer/Bot/Population/ColdCombatProfile'); const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', @@ -167,16 +170,18 @@ function compactHotBot(status, pkIds = new Set()) { function compactStateBot(state, hotIds) { if (hotIds.has(Number(state.characterId))) return null; + const stats = state.stats || {}; return { id: Number(state.characterId), name: state.name || 'Bot', phase: state.phase || 'cold', level: Number(state.level || 1), + classId: Number(stats.classId || stats.classProgressionClassId || 0) || null, mode: state.activity || 'hunting', intent: state.phase === 'warm' ? 'background_active' : 'background_resolve', - role: state.party?.role || state.stats?.role || 'dps', + role: state.party?.role || stats.role || 'dps', home: { - region: state.homeRegion || state.currentRegion || null, + region: state.currentRegion || state.homeRegion || null, visitor: false }, loc: state.loc || { locX: 0, locY: 0, locZ: 0 }, @@ -194,12 +199,262 @@ function compactStateBot(state, hotIds) { movement: { moving: false, towards: false, stuckTicks: 0 }, nearby: null, trade: null, - blockers: [], + blockers: state.activity === 'dead' ? ['dead'] : [], updatedAt: state.updatedAt || 0, isPk: state.activity === 'pk_hunting' }; } +const EQUIPMENT_SLOTS = { + 1: 'earring', + 2: 'earring', + 3: 'necklace', + 4: 'ring', + 5: 'ring', + 6: 'head', + 7: 'weapon', + 8: 'shield', + 9: 'gloves', + 10: 'chest', + 11: 'legs', + 12: 'feet', + 14: 'dual weapon', + 15: 'full armor' +}; + +function equipmentSlot(slot) { + if (typeof slot === 'string' && slot.trim() && !/^\d+$/.test(slot.trim())) return slot; + return EQUIPMENT_SLOTS[Number(slot)] || (slot ? `slot ${slot}` : 'other'); +} + +function compactItem(item) { + if (!item) return null; + return { + selfId: Number(item.selfId || item.objectId || 0) || null, + name: item.name || 'Unknown item', + slot: item.slot?.name || equipmentSlot(item.slot), + rank: item.rank || 'none', + kind: item.kind || '', + stats: item.stats ? { + pAtk: Number(item.stats.pAtk || 0), + mAtk: Number(item.stats.mAtk || 0), + pDef: Number(item.stats.pDef || 0), + mDef: Number(item.stats.mDef || 0), + evasion: Number(item.stats.evasion || 0), + critical: Number(item.stats.critical || 0) + } : null + }; +} + +function compactEquipment(equipment) { + if (!equipment) return null; + const equipped = Array.isArray(equipment.equipped) + ? equipment.equipped.map(compactItem).filter(Boolean) + : []; + const weapon = compactItem(equipment.weapon) || equipped.find((item) => item.kind.startsWith('Weapon.')) || null; + const totals = equipment.totals || null; + return { + weapon, + equipped, + totals: { + pAtk: totals ? Number(totals.pAtk || 0) : null, + mAtk: totals ? Number(totals.mAtk || 0) : null, + pDef: totals ? Number(totals.pDef || 0) : null, + mDef: totals ? Number(totals.mDef || 0) : null, + load: totals ? Number(totals.load || 0) : null + } + }; +} + +function compactBuild(build) { + if (!build) return null; + return { + role: build.role || null, + classId: Number(build.classId || 0) || null, + classFamily: build.classFamily || null, + grade: build.grade || null, + tier: build.tier || null, + armor: build.armor || null, + weapon: build.weapon || null, + playstyle: build.playstyle || null, + partyNeed: build.partyNeed || null, + statPriority: Array.isArray(build.statPriority) ? build.statPriority.slice(0, 5) : [], + exampleGear: Array.isArray(build.exampleGear) ? build.exampleGear.slice(0, 4) : [], + skills: Array.isArray(build.skills) ? build.skills.slice(0, 6) : [], + warnings: Array.isArray(build.warnings) ? build.warnings.slice(0, 3) : [] + }; +} + +function compactDecision(decision) { + if (!decision) return null; + return { + action: decision.action || null, + reason: decision.reason || null, + targetId: Number(decision.targetId || 0) || null, + targetName: decision.targetName || null, + skillId: Number(decision.skillId || 0) || null, + skillName: decision.skillName || null, + score: Number.isFinite(Number(decision.score)) ? Number(decision.score) : null, + reasons: Array.isArray(decision.reasons) ? decision.reasons.slice(0, 4) : [] + }; +} + +function fullVitals(vitals = {}) { + return { + hp: Number(vitals.hp || 0), + maxHp: Number(vitals.maxHp || 0), + hpPct: safePercent(vitals.hpPct ?? (Number(vitals.hp || 0) / Math.max(1, Number(vitals.maxHp || 1)))), + mp: Number(vitals.mp || 0), + maxMp: Number(vitals.maxMp || 0), + mpPct: safePercent(vitals.mpPct ?? (Number(vitals.mp || 0) / Math.max(1, Number(vitals.maxMp || 1)))) + }; +} + +function effectiveColdCombat(state) { + const combat = state.stats?.coldCombat || {}; + if (!combat.base && !combat.equipment) return null; + const profile = ColdCombatProfile.profileFor(state); + return { + pAtk: Number(profile.pAtk || 0), + mAtk: Number(profile.mAtk || 0), + pDef: Number(profile.pDef || 0), + mDef: Number(profile.mDef || 0), + critical: Number(profile.critical || 0), + accuracy: Number(profile.accur || 0), + evasion: Number(profile.evasion || 0), + atkSpd: Number(profile.atkSpd || 0), + castSpd: Number(profile.castSpd || 0), + maxMp: Number(profile.maxMp || 0) + }; +} + +function compactColdEquipment(state) { + const items = Array.isArray(state.stats?.equipment) ? state.stats.equipment : []; + const combat = effectiveColdCombat(state); + return compactEquipment({ + weapon: items.find((item) => String(item.kind || '').startsWith('Weapon.')) || null, + equipped: items, + totals: combat + }); +} + +function coldIntent(state) { + const activity = state.activity || 'hunting'; + if (activity === 'dead') return 'dead'; + if (activity === 'resting') return 'recover'; + if (activity === 'traveling') return 'travel'; + if (activity === 'party_wait') return 'find_party'; + if (activity === 'merchant') return 'trade'; + if (activity === 'crafting') return 'craft'; + if (state.stats?.equipmentPlan?.next) return 'progress_gear'; + return 'background_hunting'; +} + +function compactColdPlan(state) { + const plan = state.stats?.equipmentPlan; + if (!plan) return null; + return { + status: plan.status || null, + phase: plan.phase || null, + grade: plan.grade || null, + target: plan.target ? { + name: plan.target.name || null, + slot: equipmentSlot(plan.target.slot), + selfId: Number(plan.target.selfId || 0) || null + } : null, + next: plan.next ? { + spotId: plan.next.spotId || null, + npcName: plan.next.npcName || null, + itemId: Number(plan.next.itemId || 0) || null, + kind: plan.next.kind || null + } : null, + expectedKills: Number(plan.expectedKills || 0) || null, + requiresParty: !!plan.requiresParty, + partyNeed: plan.partyNeed || null + }; +} + +function compactHotDetail(status, session) { + const context = BotBrainContext.compactStatus(session, status, '', { + includeInventory: false, + includeSkills: false + }); + const pkIds = isPkActor(session?.actor) ? new Set([Number(status.id)]) : new Set(); + return { + ...compactHotBot(status, pkIds), + kind: 'bot', + vitals: fullVitals(status.vitals), + movement: status.movement || null, + nearby: status.nearby || null, + target: status.target || null, + party: status.party || null, + trade: status.trade || null, + buffs: context?.buffs || status.buffs || null, + debuffs: status.debuffs || [], + timers: status.timers || {}, + decisions: Object.fromEntries(Object.entries(status.decisions || {}).map(([key, value]) => [key, compactDecision(value)])), + build: compactBuild(status.build), + equipment: compactEquipment(context?.equipment), + persona: status.persona || null, + social: status.social || null, + ambient: status.ambient || null, + inference: status.inference || null, + updatedAt: Date.now() + }; +} + +function compactColdDetail(state) { + const stats = state.stats || {}; + const lastResolve = stats.lastResolveDebug || null; + return { + ...compactStateBot(state, new Set()), + kind: 'bot', + classId: Number(stats.classId || stats.classProgressionClassId || 0) || null, + phase: state.phase || 'cold', + mode: state.activity || 'hunting', + intent: coldIntent(state), + region: state.currentRegion || state.homeRegion || null, + home: { + region: state.homeRegion || state.currentRegion || null, + visitor: false + }, + vitals: fullVitals(state.vitals), + party: state.party?.partyId ? { + id: state.party.partyId, + role: state.party.role || stats.role || 'dps', + leaderId: state.party.leaderId || stats.leaderId || null + } : null, + build: compactBuild(stats.build), + equipment: compactColdEquipment(state), + combat: effectiveColdCombat(state), + adena: Number(state.adena || 0), + exp: Number(state.exp || 0), + sp: Number(state.sp || 0), + timing: state.timing || {}, + counters: { + fightsWon: Number(stats.fightsWon || 0), + fightsResolved: Number(stats.fightsResolved || 0), + deaths: Number(stats.deaths || 0), + expEarned: Number(stats.expEarned || 0), + spEarned: Number(stats.spEarned || 0), + adenaEarned: Number(stats.adenaEarned || 0), + partyGearReceived: Number(stats.partyGearReceived || 0) + }, + lastResolve: lastResolve ? { + route: lastResolve.route || null, + targetNpcId: Number(lastResolve.targetNpcId || 0) || null, + fights: Number(lastResolve.fights || 0), + wins: Number(lastResolve.wins || 0), + at: lastResolve.at || null + } : null, + plan: compactColdPlan(state), + travel: stats.travel || null, + goal: stats.goal || null, + persona: BotPersona.generate(state), + updatedAt: state.updatedAt || 0 + }; +} + function countBy(items, field) { return items.reduce((counts, item) => { const value = item[field] || 'unknown'; @@ -239,7 +494,11 @@ function snapshot() { .filter((status) => status && status.available) .map((status) => compactHotBot(status, pkHotIds)); const hotIds = new Set(hotBots.map((bot) => Number(bot.id))); - const stateBots = LifeState.allStates(700) + // The previous 700-item cap made the observer silently report 735 bots + // (35 hot + 700 cold) while PopulationStatus already knew about the full + // persisted population. Keep the payload bounded by the cache contract, + // but do not hide the rest of the world from the map. + const stateBots = LifeState.allStates(2000) .map((state) => compactStateBot(state, hotIds)) .filter(Boolean); const bots = [...hotBots, ...stateBots]; @@ -271,6 +530,22 @@ function snapshot() { })); } +async function botDetail(characterId) { + const id = Number(characterId); + if (!Number.isSafeInteger(id) || id <= 0) return null; + + const BotManager = invoke('GameServer/Bot/BotManager'); + const hotSession = BotManager.findSessionById(id); + if (hotSession?.actor) { + const status = BotManager.getBotStatus(hotSession); + return status?.available ? compactHotDetail(status, hotSession) : null; + } + + const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); + const state = await LifeState.findByCharacterId(id); + return state ? compactColdDetail(state) : null; +} + function sendJson(response, data, statusCode = 200) { response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', @@ -310,6 +585,16 @@ function route(request, response) { return; } + const botMatch = url.pathname.match(/^\/observer\/api\/bot\/(\d+)$/); + if (botMatch) { + botDetail(botMatch[1]) + .then((data) => data + ? sendJson(response, data) + : sendJson(response, { error: 'Bot not found' }, 404)) + .catch((err) => sendJson(response, { error: err.message }, 500)); + return; + } + if (url.pathname.startsWith('/observer/')) { const relative = url.pathname.replace(/^\/observer\/?/, '') || 'index.html'; const safeRelative = path.normalize(relative).replace(/^(\.\.[/\\])+/, ''); @@ -332,6 +617,8 @@ const WorldObserverServer = { compactPlayer, compactHotBot, compactStateBot, + compactColdDetail, + compactHotDetail, init() { if (!isEnabled() || this.server) return; diff --git a/src/WorldObserver/public/app.js b/src/WorldObserver/public/app.js index 9c7ceeab..07fb0699 100644 --- a/src/WorldObserver/public/app.js +++ b/src/WorldObserver/public/app.js @@ -2,11 +2,17 @@ const state = { snapshot: null, selectedId: null, phase: 'all', + search: '', live: true, fit: false, renderedTileKey: null, viewport: null, - drag: null + drag: null, + detail: null, + detailLoading: false, + detailError: null, + detailRequest: 0, + clusterScope: null }; const COLORS = { @@ -16,31 +22,32 @@ const COLORS = { player: '#57c7e8', merchant: '#d8b96d', dead: '#e66d61', - pk: '#ff3b30' + pk: '#ff3b30', + mixed: '#d8b96d' }; const els = { serverLine: document.querySelector('#serverLine'), liveToggle: document.querySelector('#liveToggle'), + liveLabel: document.querySelector('.live-label'), fitButton: document.querySelector('#fitButton'), filterStrip: document.querySelector('#filterStrip'), + actorSearch: document.querySelector('#actorSearch'), worldMap: document.querySelector('#worldMap'), tileLayer: document.querySelector('#tileLayer'), gridLines: document.querySelector('#gridLines'), regionLabels: document.querySelector('#regionLabels'), pointsLayer: document.querySelector('#pointsLayer'), selectedCard: document.querySelector('#selectedCard'), + selectedInspector: document.querySelector('#selectedInspector'), botsTotal: document.querySelector('#botsTotal'), playersTotal: document.querySelector('#playersTotal'), - movingTotal: document.querySelector('#movingTotal'), - targetsTotal: document.querySelector('#targetsTotal'), + populationSubline: document.querySelector('#populationSubline'), phaseBars: document.querySelector('#phaseBars'), - modeList: document.querySelector('#modeList'), actorList: document.querySelector('#actorList'), - eventList: document.querySelector('#eventList'), lastRefresh: document.querySelector('#lastRefresh'), - heapLine: document.querySelector('#heapLine'), - visibleCount: document.querySelector('#visibleCount') + visibleCount: document.querySelector('#visibleCount'), + inspectorFreshness: document.querySelector('#inspectorFreshness') }; const DEFAULT_TILES = { @@ -67,6 +74,25 @@ function svgEl(name, attrs = {}) { return node; } +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function text(value, fallback = '—') { + return escapeHtml(value === null || value === undefined || value === '' ? fallback : value); +} + +function number(value, fallback = '—') { + if (value === null || value === undefined || value === '') return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed.toLocaleString() : fallback; +} + function formatDuration(ms) { const totalSeconds = Math.max(0, Math.floor(Number(ms || 0) / 1000)); const hours = Math.floor(totalSeconds / 3600); @@ -77,14 +103,34 @@ function formatDuration(ms) { return `${seconds}s`; } +function formatTime(timestamp) { + if (!timestamp) return '—'; + return new Date(Number(timestamp)).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function formatRelative(timestamp) { + if (!timestamp) return 'no update'; + const seconds = Math.max(0, Math.round((Date.now() - Number(timestamp)) / 1000)); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + return `${Math.round(seconds / 60)}m ago`; +} + function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } +function mapMeta() { + const tiles = state.snapshot?.mapTiles || DEFAULT_TILES; + const width = (tiles.x.max - tiles.x.min + 1) * tiles.blockPx; + const height = (tiles.y.max - tiles.y.min + 1) * tiles.blockPx; + return { ...tiles, width, height }; +} + function clampViewport(viewport) { const tiles = mapMeta(); - const minWidth = 700; - const minHeight = 500; + const minWidth = 240; + const minHeight = 170; const width = clamp(viewport.width, minWidth, tiles.width); const height = clamp(viewport.height, minHeight, tiles.height); return { @@ -95,19 +141,14 @@ function clampViewport(viewport) { }; } -function mapMeta() { - const tiles = state.snapshot?.mapTiles || DEFAULT_TILES; - const width = (tiles.x.max - tiles.x.min + 1) * tiles.blockPx; - const height = (tiles.y.max - tiles.y.min + 1) * tiles.blockPx; - return { ...tiles, width, height }; -} - function worldToMap(loc) { const tiles = mapMeta(); - const blockX = Math.floor(Number(loc.locX || 0) / tiles.blockSize) + tiles.x.mid; - const blockY = Math.floor(Number(loc.locY || 0) / tiles.blockSize) + tiles.y.mid; - let modX = (Number(loc.locX || 0) / tiles.blockSize) % 1; - let modY = (Number(loc.locY || 0) / tiles.blockSize) % 1; + const locX = Number(loc?.locX || 0); + const locY = Number(loc?.locY || 0); + const blockX = Math.floor(locX / tiles.blockSize) + tiles.x.mid; + const blockY = Math.floor(locY / tiles.blockSize) + tiles.y.mid; + let modX = (locX / tiles.blockSize) % 1; + let modY = (locY / tiles.blockSize) % 1; if (modX < 0) modX += 1; if (modY < 0) modY += 1; @@ -126,6 +167,11 @@ function project(loc) { }; } +function setViewBox() { + const viewport = state.viewport || { x: 0, y: 0, width: mapMeta().width, height: mapMeta().height }; + els.worldMap.setAttribute('viewBox', `${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}`); +} + function setSvgViewBox() { const tiles = mapMeta(); els.worldMap.querySelector('.sea').setAttribute('width', tiles.width); @@ -136,16 +182,15 @@ function setSvgViewBox() { } if (!state.fit || !state.snapshot) { - const viewport = clampViewport(state.viewport); - state.viewport = viewport; - els.worldMap.setAttribute('viewBox', `${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}`); + state.viewport = clampViewport(state.viewport); + setViewBox(); return; } const locs = [...state.snapshot.bots, ...state.snapshot.players].map((item) => item.loc).filter(Boolean); if (locs.length < 2) { state.viewport = { x: 0, y: 0, width: tiles.width, height: tiles.height }; - els.worldMap.setAttribute('viewBox', `0 0 ${tiles.width} ${tiles.height}`); + setViewBox(); return; } @@ -163,20 +208,28 @@ function setSvgViewBox() { width: Math.max(1800, maxX - minX), height: Math.max(1200, maxY - minY) }); - els.worldMap.setAttribute('viewBox', `${state.viewport.x} ${state.viewport.y} ${state.viewport.width} ${state.viewport.height}`); + setViewBox(); } function clientToMapPoint(clientX, clientY) { - const rect = els.worldMap.getBoundingClientRect(); - const viewport = state.viewport || { - x: 0, - y: 0, - width: mapMeta().width, - height: mapMeta().height + const viewport = state.viewport || { x: 0, y: 0, width: mapMeta().width, height: mapMeta().height }; + const metrics = mapViewportMetrics(viewport); + return { + x: viewport.x + ((clientX - metrics.left) / metrics.scale), + y: viewport.y + ((clientY - metrics.top) / metrics.scale) }; +} + +function mapViewportMetrics(viewport = state.viewport || { x: 0, y: 0, width: mapMeta().width, height: mapMeta().height }) { + const rect = els.worldMap.getBoundingClientRect(); + const scale = Math.max(0.0001, Math.min(rect.width / viewport.width, rect.height / viewport.height)); + const renderedWidth = viewport.width * scale; + const renderedHeight = viewport.height * scale; return { - x: viewport.x + ((clientX - rect.left) / rect.width) * viewport.width, - y: viewport.y + ((clientY - rect.top) / rect.height) * viewport.height + rect, + scale, + left: rect.left + (rect.width - renderedWidth) / 2, + top: rect.top + (rect.height - renderedHeight) / 2 }; } @@ -184,21 +237,61 @@ function applyViewport(viewport) { state.fit = false; els.fitButton.classList.remove('is-live'); state.viewport = clampViewport(viewport); - els.worldMap.setAttribute('viewBox', `${state.viewport.x} ${state.viewport.y} ${state.viewport.width} ${state.viewport.height}`); + setViewBox(); + renderLabels(); + renderPoints(); } function phaseColor(item) { if (item.isPk) return COLORS.pk; if (item.kind === 'player') return COLORS.player; if (item.mode === 'merchant') return COLORS.merchant; - if (item.blockers && item.blockers.includes('dead')) return COLORS.dead; + if (item.blockers?.includes('dead')) return COLORS.dead; return COLORS[item.phase] || COLORS.cold; } -function isVisible(item) { - if (state.phase === 'all') return true; - if (state.phase === 'players') return item.kind === 'player'; - return item.kind !== 'player' && item.phase === state.phase; +function actors() { + const snap = state.snapshot; + if (!snap) return []; + return [ + ...snap.bots.map((bot) => ({ ...bot, kind: 'bot' })), + ...snap.players.map((player) => ({ + ...player, + kind: 'player', + phase: 'player', + mode: 'player', + role: 'player', + intent: player.online ? 'online' : 'offline' + })) + ]; +} + +function actorSearchText(actor) { + return [ + actor.name, + actor.phase, + actor.mode, + actor.intent, + actor.role, + actor.home?.region, + actor.spot?.name, + actor.classId + ].filter(Boolean).join(' ').toLowerCase(); +} + +function isVisible(actor) { + if (state.phase !== 'all' && state.phase !== 'players' && actor.phase !== state.phase) return false; + if (state.phase === 'players' && actor.kind !== 'player') return false; + return !state.search || actorSearchText(actor).includes(state.search); +} + +function filteredActors() { + const scope = state.clusterScope?.actorKeys; + return actors().filter((actor) => (!scope || scope.has(actorKey(actor))) && isVisible(actor)); +} + +function actorKey(actor) { + return `${actor.kind || 'bot'}:${actor.id}`; } function renderGrid() { @@ -221,7 +314,6 @@ function renderTiles() { if (state.renderedTileKey === tileKey) return; els.tileLayer.innerHTML = ''; - for (let x = tiles.x.min; x <= tiles.x.max; x += 1) { for (let y = tiles.y.min; y <= tiles.y.max; y += 1) { const hidden = (tiles.hiddenRanges || []).some((range) => ( @@ -229,18 +321,16 @@ function renderTiles() { )); if (hidden || missingTiles.has(`${x}_${y}`)) continue; - const image = svgEl('image', { + els.tileLayer.appendChild(svgEl('image', { href: `${tiles.rawBaseUrl}/${x}_${y}.jpg`, x: (x - tiles.x.min) * tiles.blockPx, y: (y - tiles.y.min) * tiles.blockPx, width: tiles.blockPx, height: tiles.blockPx, preserveAspectRatio: 'none' - }); - els.tileLayer.appendChild(image); + })); } } - state.renderedTileKey = tileKey; } @@ -249,6 +339,9 @@ function renderLabels() { els.regionLabels.innerHTML = ''; if (!snap) return; + const viewportWidth = state.viewport?.width || mapMeta().width; + const showLabels = viewportWidth < 7600; + const labelSize = clamp(viewportWidth / 55, 44, 135); snap.labels.forEach((label) => { const point = project(label); els.regionLabels.appendChild(svgEl('circle', { @@ -256,244 +349,621 @@ function renderLabels() { cy: point.y, r: label.kind === 'town' ? 48 : 36 })); - const text = svgEl('text', { - x: point.x + 85, - y: point.y - 70 - }); - text.textContent = label.name; - els.regionLabels.appendChild(text); + if (!showLabels) return; + const labelText = svgEl('text', { x: point.x + 85, y: point.y - 70, style: `font-size:${labelSize}px` }); + labelText.textContent = label.name; + els.regionLabels.appendChild(labelText); }); } -function renderPoints() { - const snap = state.snapshot; - els.pointsLayer.innerHTML = ''; - if (!snap) return; +function clusterCellSize() { + return Math.max(12, screenUnits(66)); +} - const actors = [ - ...snap.bots.map((bot) => ({ ...bot, kind: 'bot' })), - ...snap.players.map((player) => ({ - ...player, - kind: 'player', - phase: 'player', - mode: 'player', - role: 'player', - intent: player.online ? 'online' : 'offline' - })) - ]; +function screenUnits(pixels) { + return pixels / mapViewportMetrics().scale; +} - actors.forEach((actor) => { - if (!actor.loc) return; +function pointHitElement(screenSize = 30) { + const size = screenUnits(screenSize); + return svgEl('rect', { + class: 'point-hit', + x: -size / 2, + y: -size / 2, + width: size, + height: size, + fill: 'transparent' + }); +} +function clusterActors(items) { + const mergeDistance = clusterCellSize(); + const groups = []; + + items.forEach((actor) => { + if (!actor.loc) return; const point = project(actor.loc); - const color = phaseColor(actor); - const visible = isVisible(actor); - const group = svgEl('g', { - class: `point${visible ? '' : ' is-muted'}`, - transform: `translate(${point.x}, ${point.y})`, - tabindex: 0, - role: 'button', - 'data-actor-id': actor.id, - 'data-actor-kind': actor.kind, - 'aria-label': actor.name - }); - group.addEventListener('pointerdown', (event) => { - event.stopPropagation(); - }); - group.addEventListener('pointerup', (event) => { - event.stopPropagation(); - selectActor(actor.id, actor.kind); - }); - group.addEventListener('click', (event) => { - event.stopPropagation(); - selectActor(actor.id, actor.kind); - }); - group.addEventListener('keydown', (event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - selectActor(actor.id, actor.kind); + let group = groups.find((candidate) => Math.hypot(point.x - candidate.x, point.y - candidate.y) <= mergeDistance); + if (!group) { + group = { members: [], x: point.x, y: point.y }; + groups.push(group); + } + group.members.push({ actor, point }); + const size = group.members.length; + group.x += (point.x - group.x) / size; + group.y += (point.y - group.y) / size; + }); + + let merged = true; + while (merged) { + merged = false; + mergeLoop: for (let left = 0; left < groups.length; left += 1) { + for (let right = left + 1; right < groups.length; right += 1) { + const a = groups[left]; + const b = groups[right]; + if (Math.hypot(a.x - b.x, a.y - b.y) > mergeDistance) continue; + const total = a.members.length + b.members.length; + a.x = ((a.x * a.members.length) + (b.x * b.members.length)) / total; + a.y = ((a.y * a.members.length) + (b.y * b.members.length)) / total; + a.members.push(...b.members); + groups.splice(right, 1); + merged = true; + break mergeLoop; } - }); + } + } - const radius = actor.kind === 'player' ? 72 : actor.phase === 'hot' ? 62 : 45; - group.appendChild(svgEl('circle', { - class: 'point-hit', - r: radius + 92, - fill: 'transparent' - })); - group.appendChild(svgEl('circle', { - class: 'point-ring', - r: radius + 58, - stroke: color, - opacity: visible ? 0.45 : 0.12 - })); - group.appendChild(svgEl('circle', { - class: 'point-core', - r: radius, - fill: color - })); + return groups.map((group) => ({ + members: group.members, + point: { x: group.x, y: group.y }, + size: group.members.length, + color: clusterColor(group.members), + selected: group.members.some(({ actor }) => String(actor.id) === String(state.selectedId?.id)) + })); +} - if (actor.phase === 'hot' || actor.kind === 'player') { - const text = svgEl('text', { - class: 'point-label', - x: radius + 84, - y: 42 - }); - text.textContent = actor.name; - group.appendChild(text); - } +function clusterColor(cluster) { + if (cluster.some(({ actor }) => actor.isPk)) return COLORS.pk; + const colors = new Set(cluster.map(({ actor }) => phaseColor(actor))); + return colors.size === 1 ? colors.values().next().value : COLORS.mixed; +} + +function actorLabel(actor) { + return `${actor.isPk ? 'PK ' : ''}${actor.name} · Lv ${actor.level} · ${actor.intent || actor.mode || 'idle'}`; +} - els.pointsLayer.appendChild(group); +function addPointHandlers(group, handler) { + group.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + event.stopPropagation(); + }); + group.addEventListener('pointerup', (event) => { + if (event.button !== 0) return; + event.stopPropagation(); + }); + group.addEventListener('click', (event) => { + event.stopPropagation(); + handler(); + }); + group.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + handler(); }); +} - els.visibleCount.textContent = `${actors.filter(isVisible).length} visible`; +function renderSinglePoint(cluster) { + const actor = cluster.members[0].actor; + const point = cluster.point; + const color = phaseColor(actor); + const radius = screenUnits(actor.kind === 'player' ? 7.5 : actor.phase === 'hot' ? 7 : 5.5); + const selected = String(actor.id) === String(state.selectedId?.id); + const group = svgEl('g', { + class: `point${selected ? ' is-selected' : ''}`, + transform: `translate(${point.x}, ${point.y})`, + tabindex: 0, + role: 'button', + 'aria-label': actorLabel(actor) + }); + addPointHandlers(group, () => selectActor(actor.id, actor.kind)); + group.appendChild(pointHitElement(30)); + group.appendChild(svgEl('circle', { class: 'point-ring', r: radius + screenUnits(3.5), stroke: color, 'vector-effect': 'non-scaling-stroke' })); + group.appendChild(svgEl('circle', { class: 'point-core', r: radius, fill: color, 'vector-effect': 'non-scaling-stroke' })); + + const showName = actor.kind === 'player' || actor.phase === 'hot' || (state.viewport?.width || 99999) < 4200; + if (showName) { + const label = svgEl('text', { + class: 'point-label', + x: radius + screenUnits(7), + y: screenUnits(3), + style: `font-size:${screenUnits(11)}px;stroke-width:${screenUnits(3)}px` + }); + label.textContent = actor.name; + group.appendChild(label); + } + els.pointsLayer.appendChild(group); } -function sortedEntries(object) { - return Object.entries(object || {}).sort((a, b) => b[1] - a[1]); +function renderCluster(cluster) { + const radiusPx = clamp(16 + (Math.log2(cluster.size) * 2.1), 18, 29); + const radius = screenUnits(radiusPx); + const selected = cluster.selected; + const first = cluster.members[0].actor; + const group = svgEl('g', { + class: `point cluster-point${selected ? ' is-selected' : ''}`, + transform: `translate(${cluster.point.x}, ${cluster.point.y})`, + tabindex: 0, + role: 'button', + 'aria-label': `${cluster.size} actors near ${first.home?.region || first.spot?.name || 'this area'}` + }); + addPointHandlers(group, () => focusCluster(cluster)); + group.appendChild(pointHitElement(Math.max(36, radiusPx * 2 + 8))); + group.appendChild(svgEl('circle', { class: 'cluster-ring', r: radius + screenUnits(3), stroke: cluster.color, 'vector-effect': 'non-scaling-stroke' })); + group.appendChild(svgEl('circle', { class: 'cluster-core', r: radius, fill: cluster.color, 'vector-effect': 'non-scaling-stroke' })); + const count = svgEl('text', { + class: 'cluster-count', + x: 0, + y: screenUnits(4.5), + 'text-anchor': 'middle', + style: `font-size:${screenUnits(clamp(12 + Math.log2(cluster.size) * 0.55, 12, 16))}px` + }); + count.textContent = cluster.size.toLocaleString(); + group.appendChild(count); + const phaseCounts = Object.entries(cluster.members.reduce((counts, { actor }) => { + const key = actor.kind === 'player' ? 'players' : actor.phase; + counts[key] = (counts[key] || 0) + 1; + return counts; + }, {})).sort((a, b) => b[1] - a[1]).slice(0, 2); + if (phaseCounts.length > 1) { + const breakdown = svgEl('text', { + class: 'cluster-breakdown', + x: 0, + y: radius + screenUnits(13), + 'text-anchor': 'middle', + style: `font-size:${screenUnits(9)}px;stroke-width:${screenUnits(2.2)}px` + }); + breakdown.textContent = phaseCounts.map(([key, count]) => `${key} ${count}`).join(' · '); + group.appendChild(breakdown); + } + els.pointsLayer.appendChild(group); } -function renderPhaseBars() { - const counts = state.snapshot?.stats?.botsByPhase || {}; - const total = Math.max(1, Object.values(counts).reduce((sum, value) => sum + value, 0)); - els.phaseBars.innerHTML = ''; +function renderPoints() { + els.pointsLayer.innerHTML = ''; + if (!state.snapshot) return; + + const visible = filteredActors(); + const clusters = clusterActors(visible); + clusters.forEach((cluster) => cluster.size === 1 ? renderSinglePoint(cluster) : renderCluster(cluster)); + els.visibleCount.textContent = state.clusterScope + ? `${visible.length.toLocaleString()} in cluster · ${clusters.length.toLocaleString()} groups` + : `${visible.length.toLocaleString()} shown · ${clusters.length.toLocaleString()} groups`; +} - ['hot', 'warm', 'cold'].forEach((phase) => { - const count = counts[phase] || 0; - const row = document.createElement('div'); - row.className = 'bar-row'; - row.innerHTML = ` - ${phase} -
- ${count} - `; - els.phaseBars.appendChild(row); +function renderFilterCounts() { + const items = actors().filter((actor) => !state.search || actorSearchText(actor).includes(state.search)); + const counts = { + all: items.length, + hot: items.filter((actor) => actor.phase === 'hot').length, + warm: items.filter((actor) => actor.phase === 'warm').length, + cold: items.filter((actor) => actor.phase === 'cold').length, + players: items.filter((actor) => actor.kind === 'player').length + }; + Object.entries(counts).forEach(([key, value]) => { + const count = els.filterStrip.querySelector(`[data-count-for="${key}"]`); + if (count) count.textContent = value.toLocaleString(); }); } -function renderModeList() { - const entries = sortedEntries(state.snapshot?.stats?.botsByMode).slice(0, 8); - els.modeList.innerHTML = ''; +function renderPopulation() { + const snap = state.snapshot; + const population = snap.population || {}; + const total = Number(population.total || snap.bots.length || 0); + els.botsTotal.textContent = total.toLocaleString(); + els.playersTotal.textContent = (snap.players?.length || 0).toLocaleString(); + els.populationSubline.textContent = `${number(population.hot || 0)} active on field · ${number(population.persisted || total)} persisted · ${number(population.parties || 0)} background parties`; + els.lastRefresh.textContent = formatTime(snap.generatedAt); + + const phaseTotal = Math.max(1, Number(population.hot || 0) + Number(population.warm || 0) + Number(population.cold || 0)); + els.phaseBars.innerHTML = ['hot', 'warm', 'cold'].map((phase) => { + const count = Number(population[phase] || 0); + const width = Math.max(count ? 2 : 0, (count / phaseTotal) * 100); + return ``; + }).join(''); +} - entries.forEach(([mode, count]) => { - const row = document.createElement('div'); - row.className = 'mode-row'; - row.innerHTML = `${mode}${count}`; - els.modeList.appendChild(row); - }); +function displayActivity(actor) { + if (actor.kind === 'player') return actor.online ? 'online' : 'offline'; + return actor.intent || actor.mode || 'idle'; } -function renderActorList() { - const bots = [...(state.snapshot?.bots || [])] - .sort((a, b) => { - const phaseRank = { hot: 0, warm: 1, cold: 2 }; - return (phaseRank[a.phase] ?? 9) - (phaseRank[b.phase] ?? 9) || String(a.name).localeCompare(String(b.name)); - }) - .slice(0, 70); - - els.actorList.innerHTML = ''; - bots.forEach((bot) => { - const button = document.createElement('button'); - button.className = 'actor-row'; - button.type = 'button'; - button.addEventListener('click', () => selectActor(bot.id, 'bot')); - button.innerHTML = ` - +function renderRoster() { + const phaseRank = { hot: 0, warm: 1, cold: 2, player: 3 }; + const list = filteredActors() + .sort((a, b) => (phaseRank[a.phase] ?? 9) - (phaseRank[b.phase] ?? 9) || Number(b.level || 0) - Number(a.level || 0) || String(a.name).localeCompare(String(b.name))) + .slice(0, 90); + els.actorList.innerHTML = list.length ? list.map((actor) => ` + + `).join('') : '${event.summary || 'No summary'}
- `; - els.eventList.appendChild(row); - }); +function selectedActor() { + if (!state.selectedId) return null; + return state.detail || actorById(state.selectedId.id, state.selectedId.kind); } -function actorById(id, kind) { - const snap = state.snapshot; - if (!snap) return null; - if (kind === 'player') return snap.players.find((player) => String(player.id) === String(id)); - return snap.bots.find((bot) => String(bot.id) === String(id)); +function statCell(label, value) { + return `${text(build.armor || '—')} armor · ${text(build.weapon || '—')} weapon
+${text(build.playstyle || gear)}
+ ${build.statPriority?.length ? `${text(decision?.action || decisionReason(decision) || secondary)}
+${text(reason)}
+Choose a bot from the scoped roster or open a smaller cluster.
+ + `; + return; + } + els.selectedCard.innerHTML = 'SelectionNo actor selectedChoose a point or cluster to inspect the bot.
'; + return; + } + const activity = actor.target?.name || actor.spot?.name || actor.intent || actor.mode || 'idle'; els.selectedCard.innerHTML = ` - Selected - ${actor.name} - -