From 5e6f4625ba4117080776486bce9737b1c9775a42 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:38:55 -0400 Subject: [PATCH] Improve World Observer usability --- src/WorldObserver/WorldObserverServer.js | 295 ++++++- src/WorldObserver/public/app.js | 1013 ++++++++++++++++------ src/WorldObserver/public/index.html | 109 ++- src/WorldObserver/public/styles.css | 683 ++++++--------- tests/test_world_observer_pk.js | 54 ++ 5 files changed, 1419 insertions(+), 735 deletions(-) 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 `
+ ${phase} +
+ ${count.toLocaleString()} +
`; + }).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('') : '
No actors match this view.
'; } -function renderEvents() { - const events = state.snapshot?.events || []; - els.eventList.innerHTML = ''; +function actorById(id, kind) { + if (!state.snapshot) return null; + return kind === 'player' + ? state.snapshot.players.find((player) => String(player.id) === String(id)) + : state.snapshot.bots.find((bot) => String(bot.id) === String(id)); +} - events.slice(0, 18).forEach((event) => { - const row = document.createElement('div'); - row.className = 'event-row'; - row.innerHTML = ` - ${event.type || 'event'} -

${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(label)}${number(value)}
`; } -function selectActor(id, kind = 'bot') { - state.selectedId = { id, kind }; - renderSelected(); +function vitalBar(label, vital, color) { + const pct = clamp(Number(vital?.[`${label.toLowerCase()}Pct`] || 0), 0, 100); + return `
${label}
${pct}%
`; } -function renderSelected() { - if (!state.selectedId) return; - const actor = actorById(state.selectedId.id, state.selectedId.kind); - if (!actor) return; - - const loc = actor.loc ? `${Math.round(actor.loc.locX)}, ${Math.round(actor.loc.locY)}, ${Math.round(actor.loc.locZ || 0)}` : 'unknown'; - const hpPct = actor.vitals?.hpPct ?? 0; - const mpPct = actor.vitals?.mpPct ?? 0; - const kind = actor.isPk ? 'PK' : 'Player'; - const detail = actor.kind === 'player' || state.selectedId.kind === 'player' - ? `${kind} / ${actor.online ? 'online' : 'offline'}` - : `${actor.phase} / ${actor.mode} / ${actor.intent || 'idle'} / ${actor.role}`; - const target = actor.target?.name ? `Target: ${actor.target.name}` : actor.spot?.name ? `Spot: ${actor.spot.name}` : 'No target'; +function renderEquipment(equipment, combat) { + if (!equipment) return ''; + const items = equipment.equipped || []; + const totals = equipment.totals || combat || {}; + return `
+

Equipped

${items.length} items
+
+ ${statCell('P. Atk', totals.pAtk)} + ${statCell('M. Atk', totals.mAtk)} + ${statCell('P. Def', totals.pDef)} + ${statCell('M. Def', totals.mDef)} +
+
${items.length ? items.map((item) => ` +
+ ${text(item.slot)} + ${text(item.name)} + ${text(item.rank, '—')} +
+ `).join('') : '
No equipment snapshot
'}
+
`; +} + +function renderBuild(build) { + if (!build) return ''; + const gear = build.exampleGear?.length ? build.exampleGear.join(' · ') : `${build.armor || '—'} · ${build.weapon || '—'}`; + return `
+

Build

${text(build.grade || build.classFamily || '')}
+

${text(build.armor || '—')} armor · ${text(build.weapon || '—')} weapon

+

${text(build.playstyle || gear)}

+ ${build.statPriority?.length ? `
Priority ${text(build.statPriority.join(' · '))}
` : ''} +
`; +} + +function renderAction(actor) { + const decision = actor.decisions?.combat || actor.decisions?.hunt || actor.decisions?.role || actor.roleDecision; + const plan = actor.plan; + const target = actor.target?.name || actor.target?.id || actor.spot?.name || actor.spot?.id; + const secondary = actor.travel?.reason + ? `${actor.travel.reason} → ${actor.travel.townName || 'field'}` + : plan?.next?.npcName + ? `${plan.next.npcName} at ${plan.next.spotId || 'next spot'}` + : target + ? `near ${target}` + : actor.blockers?.[0] || 'no active target'; + return `
+ Doing now + ${text(actor.intent || actor.mode || 'idle')} +

${text(decision?.action || decisionReason(decision) || secondary)}

+
`; +} + +function readableDecisionValue(value) { + if (value === null || value === undefined || value === '') return null; + if (Array.isArray(value)) return value.map(readableDecisionValue).filter(Boolean).join(' · ') || null; + if (typeof value !== 'object') return String(value); + const preferred = ['message', 'reason', 'label', 'action', 'route', 'target', 'spotId', 'code', 'type']; + for (const key of preferred) { + const readable = readableDecisionValue(value[key]); + if (readable) return readable; + } + const parts = Object.entries(value) + .map(([key, entry]) => { + const readable = typeof entry === 'object' ? null : readableDecisionValue(entry); + return readable ? `${key} ${readable}` : null; + }) + .filter(Boolean) + .slice(0, 3); + return parts.join(' · ') || null; +} + +function decisionReason(decision) { + if (!decision) return null; + return readableDecisionValue(decision.reasons) + || readableDecisionValue(decision.reason) + || readableDecisionValue(decision.route) + || (decision.targetNpcId ? `NPC ${decision.targetNpcId}` : null); +} +function renderDecisions(actor) { + const decision = actor.decisions?.combat || actor.decisions?.hunt || actor.decisions?.role || actor.roleDecision || actor.lastResolve; + if (!decision) return ''; + const reason = decisionReason(decision); + return `
+

Last decision

${text(decision.action || 'resolve')}
+

${text(reason)}

+
`; +} + +function renderSignals(actor) { + const buffs = actor.buffs; + const nearby = actor.nearby; + const store = actor.trade?.store; + const ambient = actor.ambient; + if (!buffs && !nearby && !store && !ambient) return ''; + const buffText = buffs + ? (buffs.needsRefresh ? 'refresh needed' : `${buffs.active?.length || 0} active`) + : null; + const nearbyText = nearby + ? `${nearby.realPlayers || 0} players · ${nearby.friendlyBots || 0} bots · ${nearby.attackableNpcs || 0} mobs` + : null; + const tradeText = store ? `${store.type || 'store'} · ${store.title || 'open'} · ${store.items || 0} lines` : null; + const moodText = ambient ? `${ambient.mood || 'neutral'} · ${ambient.intent || 'idle'}` : null; + return `
+

Runtime signals

bot info
+
+ ${buffText ? `
Buffs${text(buffText)}
` : ''} + ${nearbyText ? `
Nearby${text(nearbyText)}
` : ''} + ${tradeText ? `
Trade${text(tradeText)}
` : ''} + ${moodText ? `
Ambient${text(moodText)}
` : ''} +
+
`; +} + +function renderSelectedCard() { + const actor = selectedActor(); + if (!actor) { + if (state.clusterScope) { + els.selectedCard.innerHTML = ` + Opened cluster + ${number(state.clusterScope.actorKeys.size)} actors · ${text(state.clusterScope.label)} +

Choose a bot from the scoped roster or open a smaller cluster.

+ + `; + return; + } + els.selectedCard.innerHTML = 'SelectionNo actor selected

Choose 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} -

Lv ${actor.level} / ${detail}

-
-
- HP -
- ${hpPct}% -
-
- MP -
- ${mpPct}% -
+ Selected ${text(actor.phase || '')} + ${text(actor.isPk ? `PK ${actor.name}` : actor.name)} +

Lv ${number(actor.level, '?')} · ${text(actor.role || actor.classFamily || 'bot')} · ${text(activity)}

+ ${state.clusterScope ? '' : ''} + `; +} + +function renderInspector() { + const actor = selectedActor(); + if (!actor) { + els.inspectorFreshness.textContent = 'live'; + if (state.clusterScope) { + const scoped = filteredActors(); + const counts = scoped.reduce((result, item) => { + const phase = item.kind === 'player' ? 'players' : item.phase; + result[phase] = (result[phase] || 0) + 1; + return result; + }, {}); + els.selectedInspector.innerHTML = ` +
+ + ${number(scoped.length)} actors in this cluster +

${text(state.clusterScope.label)} · roster is scoped to this area.

+
+ ${['hot', 'warm', 'cold', 'players'].filter((phase) => counts[phase]).map((phase) => `${phase} ${number(counts[phase])}`).join('')} +
+ +
+ `; + return; + } + els.selectedInspector.innerHTML = `
Nothing selected

Click any bot on the map to see its equipment, current action and runtime status.

`; + return; + } + + if (state.detailLoading && !state.detail) { + els.inspectorFreshness.textContent = 'loading'; + els.selectedInspector.innerHTML = '
Loading bot info

Reading the live status and persisted equipment snapshot.

'; + return; + } + + if (state.detailError && !state.detail) { + els.inspectorFreshness.textContent = 'error'; + els.selectedInspector.innerHTML = `
+ ! + Bot info unavailable +

${text(state.detailError)} · the compact map snapshot may be incomplete.

+ +
`; + return; + } + + const build = actor.build; + const family = build?.classFamily || (actor.classId ? `class ${actor.classId}` : (actor.kind === 'player' ? 'player' : 'bot')); + const location = actor.loc ? `${Math.round(actor.loc.locX)}, ${Math.round(actor.loc.locY)}, ${Math.round(actor.loc.locZ || 0)}` : 'unknown'; + const party = actor.party + ? actor.party.leader?.name + ? `${actor.party.role || actor.role || 'member'} · leader ${actor.party.leader.name}` + : `${actor.party.role || actor.role || 'member'} · leader ${actor.party.leaderId || 'unknown'}` + : 'solo'; + const freshness = actor.updatedAt ? formatRelative(actor.updatedAt) : 'live'; + els.inspectorFreshness.textContent = state.detailError ? 'stale' : freshness; + const detailWarning = state.detailError ? `
+ Refresh failed · ${text(state.detailError)} + +
` : ''; + els.selectedInspector.innerHTML = ` + ${detailWarning} +
+
${text(String(actor.name || '?').slice(0, 1).toUpperCase())}
+
${text(actor.isPk ? `PK ${actor.name}` : actor.name)}Lv ${number(actor.level, '?')} · ${text(family)} · ${text(actor.role || '—')}
+ ${text(actor.phase || 'bot')} +
+
+ ${vitalBar('HP', actor.vitals, '#63d37b')} + ${vitalBar('MP', actor.vitals, '#57c7e8')}
-

${target}
Loc ${loc}

+ ${renderAction(actor)} +
+
Mode${text(actor.mode)}
+
Region${text(actor.region || actor.home?.region)}
+
Spot${text(actor.spot?.name || actor.spot?.id)}
+
Party${text(party)}
+
Position${text(location)}
+
Blockers${text(actor.blockers?.join(' · '), 'none')}
+
+ ${renderSignals(actor)} + ${actor.equipment || actor.combat ? renderEquipment(actor.equipment, actor.combat) : ''} + ${renderBuild(build)} + ${renderDecisions(actor)} + ${actor.counters ? `

Progress

${formatRelative(actor.updatedAt)}
${statCell('Wins', actor.counters.fightsWon)}${statCell('Resolves', actor.counters.fightsResolved)}${statCell('Deaths', actor.counters.deaths)}${statCell('Adena', actor.adena)}
` : ''} `; } +function renderSelected() { + renderSelectedCard(); + renderInspector(); +} + function renderSnapshot() { const snap = state.snapshot; if (!snap) return; - els.botsTotal.textContent = snap.bots.length; - els.playersTotal.textContent = snap.players.length; - els.movingTotal.textContent = snap.stats.moving || 0; - els.targetsTotal.textContent = snap.stats.activeTargets || 0; - els.lastRefresh.textContent = new Date(snap.generatedAt).toLocaleTimeString(); - els.heapLine.textContent = `heap ${snap.runtime.heapUsedMb} MB`; - els.serverLine.textContent = `uptime ${formatDuration(snap.uptimeMs)} / hot ${snap.population.hot} / warm ${snap.population.warm} / cold ${snap.population.cold}`; - + const population = snap.population || {}; + els.serverLine.textContent = `${number(population.total || snap.bots.length)} bots in simulation · ${number(population.hot || 0)} active · uptime ${formatDuration(snap.uptimeMs)}`; setSvgViewBox(); renderTiles(); renderGrid(); renderLabels(); + renderFilterCounts(); renderPoints(); - renderPhaseBars(); - renderModeList(); - renderActorList(); - renderEvents(); + renderPopulation(); + renderRoster(); + renderSelected(); +} + +async function loadBotDetail(id, showLoading = true) { + if (!id || state.detailLoading) return; + const requestId = ++state.detailRequest; + state.detailLoading = showLoading; + state.detailError = null; + if (showLoading) renderSelected(); + try { + const response = await fetch(`/observer/api/bot/${encodeURIComponent(id)}`, { cache: 'no-store' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const detail = await response.json(); + if (requestId !== state.detailRequest || String(state.selectedId?.id) !== String(id)) return; + state.detail = detail; + } catch (error) { + if (requestId === state.detailRequest) state.detailError = error.message; + } finally { + if (requestId === state.detailRequest) { + state.detailLoading = false; + renderSelected(); + } + } +} + +function selectActor(id, kind = 'bot', focus = false) { + state.selectedId = { id, kind }; + state.detail = null; + state.detailError = null; + state.detailLoading = false; + if (focus) { + const actor = actorById(id, kind); + if (actor?.loc) { + const viewport = state.viewport || { x: 0, y: 0, width: mapMeta().width, height: mapMeta().height }; + const point = worldToMap(actor.loc); + applyViewport({ + x: point.x - viewport.width * 0.5, + y: point.y - viewport.height * 0.5, + width: viewport.width, + height: viewport.height + }); + } + } + renderPoints(); + renderRoster(); + renderSelected(); + if (kind === 'bot') loadBotDetail(id); +} + +function focusCluster(cluster) { + if (cluster.size === 1) { + selectActor(cluster.members[0].actor.id, cluster.members[0].actor.kind); + return; + } + state.clusterScope = { + actorKeys: new Set(cluster.members.map(({ actor }) => actorKey(actor))), + label: clusterLocation(cluster) + }; + state.selectedId = null; + state.detail = null; + state.detailLoading = false; + state.detailRequest += 1; + + const points = cluster.members.map(({ point }) => point); + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const pad = screenUnits(44); + const rect = els.worldMap.getBoundingClientRect(); + const aspect = Math.max(1, rect.width / Math.max(1, rect.height)); + let width = Math.max(240, Math.max(...xs) - Math.min(...xs) + pad * 2); + let height = Math.max(170, Math.max(...ys) - Math.min(...ys) + pad * 2); + if (width / height < aspect) width = height * aspect; + else height = width / aspect; + applyViewport({ + x: ((Math.min(...xs) + Math.max(...xs)) / 2) - width / 2, + y: ((Math.min(...ys) + Math.max(...ys)) / 2) - height / 2, + width, + height + }); + renderRoster(); + renderSelected(); +} + +function clusterLocation(cluster) { + const labels = cluster.members + .map(({ actor }) => actor.home?.region || actor.region || actor.spot?.name) + .filter((label) => label && !/^-?\d+_-?\d+$/.test(label)); + if (!labels.length) return 'this area'; + const counts = labels.reduce((result, label) => result.set(label, (result.get(label) || 0) + 1), new Map()); + return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0]; +} + +function clearClusterScope(resetViewport = false) { + state.clusterScope = null; + if (resetViewport) { + state.fit = true; + els.fitButton.classList.add('is-live'); + setSvgViewBox(); + renderLabels(); + } + renderPoints(); + renderRoster(); renderSelected(); } @@ -504,8 +974,9 @@ async function refresh() { if (!response.ok) throw new Error(`HTTP ${response.status}`); state.snapshot = await response.json(); renderSnapshot(); - } catch (err) { - els.serverLine.textContent = `Observer snapshot failed: ${err.message}`; + if (state.selectedId?.kind === 'bot' && !state.detailLoading) loadBotDetail(state.selectedId.id, false); + } catch (error) { + els.serverLine.textContent = `Observer snapshot failed: ${error.message}`; } } @@ -513,43 +984,61 @@ els.liveToggle.addEventListener('click', () => { state.live = !state.live; els.liveToggle.classList.toggle('is-live', state.live); els.liveToggle.title = state.live ? 'Pause live refresh' : 'Resume live refresh'; - els.liveToggle.lastChild.textContent = state.live ? 'Live' : 'Paused'; + els.liveLabel.textContent = state.live ? 'Live' : 'Paused'; if (state.live) refresh(); }); els.fitButton.addEventListener('click', () => { + state.clusterScope = null; state.fit = true; els.fitButton.classList.add('is-live'); setSvgViewBox(); renderLabels(); renderPoints(); + renderRoster(); + renderSelected(); +}); + +document.addEventListener('click', (event) => { + if (event.target.closest('[data-retry-detail]')) { + if (state.selectedId?.kind === 'bot') loadBotDetail(state.selectedId.id); + return; + } + if (!event.target.closest('[data-clear-cluster]')) return; + clearClusterScope(true); }); els.filterStrip.addEventListener('click', (event) => { const button = event.target.closest('[data-phase]'); if (!button) return; state.phase = button.dataset.phase; - els.filterStrip.querySelectorAll('.filter').forEach((item) => { - item.classList.toggle('is-active', item === button); - }); + els.filterStrip.querySelectorAll('.filter').forEach((item) => item.classList.toggle('is-active', item === button)); + renderPoints(); + renderRoster(); +}); + +els.actorSearch.addEventListener('input', (event) => { + state.search = String(event.target.value || '').trim().toLowerCase(); + renderFilterCounts(); renderPoints(); + renderRoster(); +}); + +els.actorList.addEventListener('click', (event) => { + const row = event.target.closest('[data-roster-id]'); + if (!row) return; + selectActor(row.dataset.rosterId, row.dataset.rosterKind, true); }); els.worldMap.addEventListener('wheel', (event) => { event.preventDefault(); - 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 focus = clientToMapPoint(event.clientX, event.clientY); const zoomFactor = event.deltaY < 0 ? 0.82 : 1.22; const nextWidth = viewport.width * zoomFactor; const nextHeight = viewport.height * zoomFactor; const focusRatioX = (focus.x - viewport.x) / viewport.width; const focusRatioY = (focus.y - viewport.y) / viewport.height; - applyViewport({ x: focus.x - nextWidth * focusRatioX, y: focus.y - nextHeight * focusRatioY, @@ -559,7 +1048,7 @@ els.worldMap.addEventListener('wheel', (event) => { }, { passive: false }); els.worldMap.addEventListener('pointerdown', (event) => { - if (event.button !== 0) return; + if (event.button !== 0 || event.target.closest?.('.point')) return; els.worldMap.setPointerCapture(event.pointerId); state.drag = { pointerId: event.pointerId, @@ -572,15 +1061,10 @@ els.worldMap.addEventListener('pointerdown', (event) => { els.worldMap.addEventListener('pointermove', (event) => { if (!state.drag || state.drag.pointerId !== event.pointerId) return; - const rect = els.worldMap.getBoundingClientRect(); - const dx = ((event.clientX - state.drag.startX) / rect.width) * state.drag.viewport.width; - const dy = ((event.clientY - state.drag.startY) / rect.height) * state.drag.viewport.height; - - applyViewport({ - ...state.drag.viewport, - x: state.drag.viewport.x - dx, - y: state.drag.viewport.y - dy - }); + const metrics = mapViewportMetrics(state.drag.viewport); + const dx = (event.clientX - state.drag.startX) / metrics.scale; + const dy = (event.clientY - state.drag.startY) / metrics.scale; + applyViewport({ ...state.drag.viewport, x: state.drag.viewport.x - dx, y: state.drag.viewport.y - dy }); }); function finishDrag(event) { @@ -592,5 +1076,22 @@ function finishDrag(event) { els.worldMap.addEventListener('pointerup', finishDrag); els.worldMap.addEventListener('pointercancel', finishDrag); +document.addEventListener('keydown', (event) => { + if (event.key === '/' && document.activeElement !== els.actorSearch) { + event.preventDefault(); + els.actorSearch.focus(); + } + if (event.key === 'Escape' && document.activeElement === els.actorSearch) { + els.actorSearch.value = ''; + state.search = ''; + renderFilterCounts(); + renderPoints(); + renderRoster(); + els.actorSearch.blur(); + return; + } + if (event.key === 'Escape' && state.clusterScope) clearClusterScope(true); +}); + refresh(); setInterval(refresh, 2000); diff --git a/src/WorldObserver/public/index.html b/src/WorldObserver/public/index.html index 290619d3..1bda1abd 100644 --- a/src/WorldObserver/public/index.html +++ b/src/WorldObserver/public/index.html @@ -11,27 +11,37 @@
-
-

World Observer

-

Waiting for server snapshot

+
+ W +
+

World Observer

+

Waiting for server snapshot

+
-
- - - - - +
+
+ + + + + +
+
@@ -41,11 +51,6 @@

World Observer

- - - - - @@ -61,64 +66,58 @@

World Observer

+
Scroll to zoom · drag to pan · click a cluster to drill down
+
+ hot + warm + cold + player + PK +
+
- Selected + Selection No actor selected -

Click any bot or player point on the map.

+

Choose a point or cluster to inspect the bot.

-
diff --git a/src/WorldObserver/public/styles.css b/src/WorldObserver/public/styles.css index f25eb4e6..6dd4feef 100644 --- a/src/WorldObserver/public/styles.css +++ b/src/WorldObserver/public/styles.css @@ -1,92 +1,100 @@ :root { --bg: #071012; - --panel: #10191b; - --panel-2: #152124; + --surface: rgba(14, 24, 27, 0.9); + --surface-strong: #101b1e; + --surface-soft: rgba(237, 240, 232, 0.045); --line: rgba(189, 178, 135, 0.18); + --line-soft: rgba(237, 240, 232, 0.09); --text: #edf0e8; --muted: #9aa59f; - --faint: #6f7b76; + --faint: #66736d; --gold: #d8b96d; --green: #63d37b; --cyan: #57c7e8; --amber: #e2a84f; --blue: #7aa7ff; --red: #e66d61; - --shadow: 0 18px 60px rgba(0, 0, 0, 0.35); + --shadow: 0 22px 70px rgba(0, 0, 0, 0.32); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -* { - box-sizing: border-box; -} +* { box-sizing: border-box; } body { margin: 0; min-height: 100vh; + overflow: hidden; color: var(--text); background: - linear-gradient(135deg, rgba(216, 185, 109, 0.08), transparent 28%), + radial-gradient(circle at 20% 0%, rgba(216, 185, 109, 0.08), transparent 30%), linear-gradient(180deg, #0a1517 0%, var(--bg) 100%); } -button { - font: inherit; -} +button, +input { font: inherit; } + +button { -webkit-tap-highlight-color: transparent; } + +h1, +h2, +h3, +p { margin: 0; } .observer-shell { display: grid; - grid-template-columns: minmax(0, 1fr) 380px; - gap: 18px; + grid-template-columns: minmax(0, 1fr) 410px; + gap: 16px; height: 100vh; - overflow: hidden; - padding: 18px; + padding: 16px; } .map-panel, -.side-panel { - min-width: 0; -} +.side-panel { min-width: 0; min-height: 0; } .map-panel { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 12px; - min-height: 0; overflow: hidden; } -.topbar { +.topbar, +.brand-lockup, +.topbar-actions, +.map-toolbar, +.filter-strip, +.population-foot, +.section-title, +.population-headline, +.inspector-hero, +.inspector-block-title, +.vital-row, +.equipment-row, +.priority-line { display: flex; - justify-content: space-between; - gap: 16px; align-items: center; - padding: 8px 2px 0; } -h1, -h2, -p { - margin: 0; -} +.topbar { justify-content: space-between; gap: 16px; padding: 3px 3px 0; } +.brand-lockup { gap: 11px; } +.topbar-actions { gap: 8px; } -h1 { - font-size: 30px; - line-height: 1.05; - font-weight: 760; - letter-spacing: 0; +.brand-mark { + display: grid; + width: 35px; + height: 35px; + place-items: center; + border: 1px solid rgba(216, 185, 109, 0.56); + border-radius: 10px; + color: var(--gold); + font-size: 14px; + font-weight: 850; + letter-spacing: 0.08em; + box-shadow: inset 0 0 20px rgba(216, 185, 109, 0.08); } -.topbar p { - margin-top: 7px; - color: var(--muted); - font-size: 13px; -} - -.topbar-actions { - display: flex; - align-items: center; - gap: 10px; -} +h1 { font-size: 25px; line-height: 1; font-weight: 790; letter-spacing: -0.025em; } +.topbar p { margin-top: 6px; color: var(--muted); font-size: 12px; } .icon-button, .live-toggle, @@ -95,67 +103,76 @@ h1 { color: var(--text); background: rgba(16, 25, 27, 0.86); cursor: pointer; + transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease; } +.icon-button:hover, +.live-toggle:hover, +.filter:hover { border-color: rgba(216, 185, 109, 0.55); transform: translateY(-1px); } + .icon-button { - width: 38px; - height: 38px; - border-radius: 8px; - font-weight: 800; + width: 37px; + height: 37px; + border-radius: 9px; + color: var(--gold); + font-size: 19px; } .live-toggle { display: inline-flex; align-items: center; gap: 8px; - height: 38px; - padding: 0 13px; - border-radius: 8px; - font-size: 13px; - font-weight: 700; -} - -.pulse { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--faint); + height: 37px; + padding: 0 12px; + border-radius: 9px; + font-size: 12px; + font-weight: 780; } -.is-live .pulse { - background: var(--green); - box-shadow: 0 0 18px rgba(99, 211, 123, 0.9); -} +.pulse { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); } +.is-live .pulse { background: var(--green); box-shadow: 0 0 18px rgba(99, 211, 123, 0.85); } -.filter-strip { - display: flex; - gap: 8px; - flex-wrap: wrap; -} +.map-toolbar { justify-content: space-between; gap: 14px; min-width: 0; } +.filter-strip { gap: 7px; flex-wrap: wrap; } .filter { - min-width: 72px; - height: 34px; - padding: 0 12px; + height: 32px; + padding: 0 10px; border-radius: 8px; color: var(--muted); - font-size: 13px; - font-weight: 700; + font-size: 12px; + font-weight: 750; } -.filter.is-active { - color: #111712; - background: var(--gold); - border-color: transparent; +.filter.is-active { color: #101712; background: var(--gold); border-color: transparent; } +.filter-count { margin-left: 4px; opacity: 0.68; font-variant-numeric: tabular-nums; } +.filter.is-active .filter-count { opacity: 0.8; } + +.search-field { + display: flex; + align-items: center; + gap: 8px; + width: min(270px, 35vw); + height: 33px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--faint); + background: rgba(16, 25, 27, 0.74); } +.search-field:focus-within { border-color: rgba(216, 185, 109, 0.6); color: var(--gold); } +.search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 12px; } +.search-field input::placeholder { color: var(--faint); } +.search-field kbd { padding: 2px 5px; border: 1px solid var(--line-soft); border-radius: 4px; color: var(--faint); font-size: 10px; } + .map-stage { position: relative; - overflow: hidden; min-height: 0; height: 100%; + overflow: hidden; border: 1px solid var(--line); - border-radius: 8px; + border-radius: 10px; background: #081113; box-shadow: var(--shadow); } @@ -171,372 +188,198 @@ h1 { user-select: none; } -#worldMap.is-dragging { - cursor: grabbing; -} - -.sea { - fill: url("#seaGlow"); -} - -.tile-layer image { - opacity: 0.92; -} - -.grid-lines line { - stroke: rgba(237, 240, 232, 0.11); - stroke-width: 9; -} +#worldMap.is-dragging { cursor: grabbing; } +.sea { fill: url("#seaGlow"); } +.tile-layer image { opacity: 0.88; } +.grid-lines line { stroke: rgba(237, 240, 232, 0.1); stroke-width: 8; } .region-labels text { fill: rgba(237, 240, 232, 0.68); - font-size: 150px; - font-weight: 700; + font-size: 135px; + font-weight: 720; paint-order: stroke; - stroke: rgba(7, 16, 18, 0.86); - stroke-width: 44px; -} - -.region-labels circle { - fill: rgba(216, 185, 109, 0.18); - stroke: rgba(216, 185, 109, 0.68); - stroke-width: 12; -} - -.point { - cursor: pointer; - filter: url("#pinGlow"); + stroke: rgba(7, 16, 18, 0.88); + stroke-width: 40px; } -.point-hit { - pointer-events: all; -} - -.point-ring { - fill: transparent; - stroke-width: 24; -} +.region-labels circle { fill: rgba(216, 185, 109, 0.18); stroke: rgba(216, 185, 109, 0.62); stroke-width: 12; } +.point { cursor: pointer; filter: url("#pinGlow"); transition: opacity 140ms ease; } +.point-hit { pointer-events: all; } +.point-ring { fill: transparent; stroke-width: 2px; opacity: 0.48; } +.point-core { stroke: rgba(7, 16, 18, 0.82); stroke-width: 1.5px; } +.point.is-selected .point-core { stroke: var(--text); stroke-width: 2.5px; } +.point.is-selected .point-ring { stroke-width: 3px; opacity: 0.95; } -.point-core { - stroke: rgba(7, 16, 18, 0.8); - stroke-width: 12; -} - -.point-label { +.point-label, +.cluster-breakdown { fill: var(--text); - font-size: 128px; font-weight: 760; paint-order: stroke; - stroke: rgba(7, 16, 18, 0.9); - stroke-width: 34px; + stroke: rgba(7, 16, 18, 0.92); pointer-events: none; } -.point.is-muted { - opacity: 0.22; -} +.cluster-ring { fill: rgba(7, 16, 18, 0.3); stroke-width: 2px; opacity: 0.8; } +.cluster-core { stroke: rgba(7, 16, 18, 0.86); stroke-width: 1.5px; } +.cluster-count { fill: #091212; font-weight: 900; pointer-events: none; } +.map-hint, +.map-legend, .selected-card { position: absolute; - left: 18px; - bottom: 18px; - width: min(360px, calc(100% - 36px)); - padding: 14px; border: 1px solid var(--line); border-radius: 8px; - background: rgba(10, 17, 19, 0.84); - backdrop-filter: blur(14px); - pointer-events: none; -} - -.eyeline { - display: block; - margin-bottom: 6px; - color: var(--gold); - font-size: 11px; - font-weight: 800; - text-transform: uppercase; -} - -.selected-card strong { - display: block; - font-size: 18px; -} - -.selected-card p { - margin-top: 6px; - color: var(--muted); - font-size: 13px; - line-height: 1.45; -} - -.selected-meta { - margin-top: 5px; -} - -.selected-bars { - display: grid; - gap: 7px; - margin-top: 11px; - margin-bottom: 9px; -} - -.selected-bar { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) 42px; - align-items: center; - gap: 8px; - color: var(--muted); - font-size: 11px; - font-weight: 800; -} - -.selected-bar-track { - height: 8px; - overflow: hidden; - border-radius: 999px; - background: rgba(237, 240, 232, 0.1); -} - -.selected-bar-fill { - height: 100%; - min-width: 2px; - border-radius: inherit; -} - -.selected-bar-fill.hp { - background: var(--green); -} - -.selected-bar-fill.mp { - background: var(--cyan); -} - -.selected-bar strong { - color: var(--text); - font-size: 11px; - text-align: right; -} + background: rgba(8, 17, 19, 0.82); + backdrop-filter: blur(13px); +} + +.map-hint { top: 14px; right: 14px; padding: 8px 10px; color: var(--muted); font-size: 11px; } +.map-legend { right: 14px; bottom: 14px; display: flex; gap: 10px; padding: 8px 10px; color: var(--muted); font-size: 10px; font-weight: 750; } +.map-hint, .map-legend { pointer-events: none; } +.map-legend span, .population-foot span { display: inline-flex; align-items: center; gap: 5px; } +.legend-dot { display: inline-block; width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; } +.legend-dot.hot { background: var(--green); } +.legend-dot.warm { background: var(--amber); } +.legend-dot.cold { background: var(--blue); } +.legend-dot.player { background: var(--cyan); } +.legend-dot.pk { background: var(--red); } + +.selected-card { left: 16px; bottom: 16px; width: min(330px, calc(100% - 32px)); padding: 13px 14px; pointer-events: none; } +.selected-card .selection-clear { pointer-events: auto; } +.eyeline, .section-kicker { display: block; color: var(--gold); font-size: 10px; font-weight: 820; letter-spacing: 0.12em; text-transform: uppercase; } +.selected-card strong { display: block; margin-top: 5px; font-size: 17px; } +.selected-card p { margin-top: 5px; color: var(--muted); font-size: 12px; line-height: 1.4; } +.selection-clear { margin-top: 10px; padding: 6px 9px; border: 1px solid rgba(216, 185, 109, 0.38); border-radius: 6px; color: var(--gold); background: rgba(216, 185, 109, 0.08); font-size: 10px; font-weight: 800; cursor: pointer; } +.selection-clear:hover { background: rgba(216, 185, 109, 0.15); } +.detail-failure .selection-clear { margin-top: 13px; } +.detail-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 11px; padding: 8px 9px; border: 1px solid rgba(230, 109, 97, 0.34); border-radius: 6px; color: #f0aaa3; background: rgba(230, 109, 97, 0.08); font-size: 10px; } +.detail-error .selection-clear { flex: 0 0 auto; margin-top: 0; } .side-panel { display: grid; - grid-template-rows: auto auto auto minmax(200px, 1fr) minmax(160px, 0.72fr); + grid-template-rows: auto minmax(0, 1fr) minmax(180px, 0.68fr); gap: 12px; - min-height: 0; - overflow: hidden; -} - -.metric-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.metric, -.panel-section { - border: 1px solid var(--line); - border-radius: 8px; - background: rgba(16, 25, 27, 0.88); - box-shadow: 0 14px 40px rgba(0, 0, 0, 0.22); -} - -.metric { - min-height: 82px; - padding: 14px; -} - -.metric span { - display: block; - color: var(--muted); - font-size: 12px; - font-weight: 760; -} - -.metric strong { - display: block; - margin-top: 8px; - font-size: 30px; - line-height: 1; -} - -.panel-section { - min-height: 0; - padding: 14px; -} - -.section-title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 12px; -} - -.section-title h2 { - font-size: 14px; - font-weight: 800; -} - -.section-title span { - color: var(--faint); - font-size: 12px; - font-weight: 700; -} - -.phase-bars, -.mode-list, -.actor-list, -.event-list { - display: grid; - gap: 8px; -} - -.bar-row { - display: grid; - grid-template-columns: 54px minmax(0, 1fr) 34px; - align-items: center; - gap: 9px; - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -.bar-track { - height: 8px; - overflow: hidden; - border-radius: 999px; - background: rgba(237, 240, 232, 0.08); -} - -.bar-fill { - height: 100%; - min-width: 2px; - border-radius: inherit; -} - -.mode-row, -.actor-row, -.event-row { - border: 1px solid rgba(237, 240, 232, 0.08); - border-radius: 8px; - background: rgba(237, 240, 232, 0.035); -} - -.mode-row { - display: flex; - justify-content: space-between; - gap: 10px; - padding: 9px 10px; - color: var(--muted); - font-size: 12px; - font-weight: 760; -} - -.actor-list-section, -.events-section { - min-height: 0; overflow: hidden; } -.actor-list, -.event-list { - max-height: 100%; - overflow: auto; - padding-right: 2px; -} - -.actor-row { - display: grid; - grid-template-columns: 10px minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - width: 100%; - padding: 10px; - color: var(--text); - text-align: left; - cursor: pointer; -} - -.phase-dot { - width: 10px; - height: 10px; - border-radius: 50%; -} - -.actor-main strong { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 13px; -} - -.actor-main span { - display: block; - margin-top: 3px; - overflow: hidden; - color: var(--muted); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.actor-vitals { - display: grid; - gap: 4px; - color: var(--faint); - font-size: 11px; - font-weight: 800; - text-align: right; -} - -.event-row { - padding: 10px; -} - -.event-row strong { - display: block; - color: var(--gold); - font-size: 11px; - text-transform: uppercase; -} - -.event-row p { - margin-top: 4px; - color: var(--muted); - font-size: 12px; - line-height: 1.35; -} - -@media (max-width: 1080px) { - .observer-shell { - grid-template-columns: 1fr; - } - - .side-panel { - grid-template-rows: auto; - } +.population-panel, +.panel-section { min-height: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); box-shadow: 0 15px 46px rgba(0, 0, 0, 0.2); } +.population-panel { padding: 15px; } +.population-headline { gap: 9px; margin-top: 7px; } +.population-headline strong { font-size: 34px; line-height: 1; letter-spacing: -0.045em; font-variant-numeric: tabular-nums; } +.population-headline span { color: var(--muted); font-size: 12px; } +.population-subline { margin-top: 7px; color: var(--muted); font-size: 11px; line-height: 1.45; } +.population-bars { display: grid; gap: 8px; margin-top: 14px; } +.population-bar-row { display: grid; grid-template-columns: 55px minmax(0, 1fr) 43px; align-items: center; gap: 8px; color: var(--muted); font-size: 11px; } +.phase-label { display: inline-flex; align-items: center; gap: 6px; } +.population-track { height: 7px; overflow: hidden; border-radius: 999px; background: rgba(237, 240, 232, 0.08); } +.population-fill { height: 100%; min-width: 2px; border-radius: inherit; } +.population-fill.hot { background: var(--green); } +.population-fill.warm { background: var(--amber); } +.population-fill.cold { background: var(--blue); } +.population-bar-row strong { color: var(--text); text-align: right; font-variant-numeric: tabular-nums; } +.population-foot { justify-content: space-between; gap: 10px; margin-top: 13px; padding-top: 11px; border-top: 1px solid var(--line-soft); color: var(--faint); font-size: 11px; } +.population-foot b { color: var(--text); } + +.panel-section { padding: 14px; } +.section-title { justify-content: space-between; gap: 10px; margin-bottom: 11px; } +.section-title h2 { font-size: 14px; font-weight: 820; } +.section-title span { color: var(--faint); font-size: 11px; font-weight: 720; } +.inspector-section, .roster-section { overflow: hidden; } +.selected-inspector, .actor-list { min-height: 0; overflow: auto; padding-right: 2px; } +.selected-inspector { height: calc(100% - 29px); } + +.inspector-empty { display: grid; min-height: 220px; place-items: center; align-content: center; padding: 24px; text-align: center; } +.empty-glyph { color: var(--gold); font-size: 38px; line-height: 1; opacity: 0.8; } +.inspector-empty strong { margin-top: 12px; font-size: 14px; } +.inspector-empty p { max-width: 250px; margin-top: 7px; color: var(--muted); font-size: 12px; line-height: 1.5; } +.cluster-inspector { display: grid; min-height: 220px; place-items: center; align-content: center; padding: 22px; text-align: center; } +.cluster-inspector > strong { margin-top: 10px; font-size: 15px; } +.cluster-inspector > p { max-width: 270px; margin-top: 6px; color: var(--muted); font-size: 11px; line-height: 1.45; } +.cluster-phase-counts { display: flex; flex-wrap: wrap; justify-content: center; gap: 7px 12px; margin-top: 13px; color: var(--muted); font-size: 10px; } +.cluster-phase-counts span { display: inline-flex; align-items: center; gap: 5px; } +.cluster-phase-counts strong { color: var(--text); } +.loading-orbit { width: 28px; height: 28px; border: 2px solid rgba(216, 185, 109, 0.25); border-top-color: var(--gold); border-radius: 50%; animation: orbit 800ms linear infinite; } +@keyframes orbit { to { transform: rotate(360deg); } } + +.inspector-hero { gap: 10px; padding-bottom: 13px; border-bottom: 1px solid var(--line-soft); } +.inspector-avatar { display: grid; width: 37px; height: 37px; place-items: center; border: 1px solid color-mix(in srgb, var(--avatar-color), transparent 30%); border-radius: 10px; color: var(--avatar-color); background: color-mix(in srgb, var(--avatar-color), transparent 88%); font-weight: 900; } +.inspector-name { min-width: 0; flex: 1; } +.inspector-name strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; } +.inspector-name span { display: block; margin-top: 3px; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.phase-badge { padding: 4px 7px; border-radius: 5px; color: var(--text); background: rgba(237, 240, 232, 0.08); font-size: 10px; font-weight: 850; text-transform: uppercase; } +.phase-badge.hot { color: #09150c; background: var(--green); } +.phase-badge.warm { color: #1b1508; background: var(--amber); } +.phase-badge.cold { color: #0c1423; background: var(--blue); } +.phase-badge.player { color: #07171c; background: var(--cyan); } + +.inspector-vitals { display: grid; gap: 7px; margin: 13px 0; } +.vital-row { display: grid; grid-template-columns: 25px minmax(0, 1fr) 36px; gap: 8px; color: var(--muted); font-size: 10px; font-weight: 800; } +.vital-track { height: 6px; overflow: hidden; border-radius: 999px; background: rgba(237, 240, 232, 0.08); } +.vital-fill { height: 100%; min-width: 2px; border-radius: inherit; } +.vital-row strong { color: var(--text); text-align: right; } + +.activity-callout { padding: 11px 12px; border-left: 2px solid var(--gold); background: rgba(216, 185, 109, 0.07); } +.activity-callout span { color: var(--gold); font-size: 10px; font-weight: 820; letter-spacing: 0.1em; text-transform: uppercase; } +.activity-callout strong { display: block; margin-top: 4px; font-size: 15px; } +.activity-callout p { margin-top: 4px; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } + +.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 13px 0; overflow: hidden; border: 1px solid var(--line-soft); border-radius: 7px; } +.detail-grid > div { min-width: 0; padding: 9px; background: rgba(237, 240, 232, 0.025); } +.detail-grid span, .stat-cell span { display: block; color: var(--faint); font-size: 9px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; } +.detail-grid strong { display: block; margin-top: 4px; overflow: hidden; color: var(--text); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.signal-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; } +.signal-list > div { min-width: 0; padding: 7px; border-radius: 6px; background: var(--surface-soft); } +.signal-list span { display: block; color: var(--faint); font-size: 9px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; } +.signal-list strong { display: block; margin-top: 4px; overflow: hidden; color: var(--text); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } + +.inspector-block { padding-top: 13px; border-top: 1px solid var(--line-soft); } +.inspector-block + .inspector-block { margin-top: 13px; } +.inspector-block-title { justify-content: space-between; gap: 8px; margin-bottom: 9px; } +.inspector-block-title h3 { font-size: 12px; font-weight: 820; } +.inspector-block-title span { color: var(--faint); font-size: 10px; } +.combat-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; margin-bottom: 9px; } +.stat-cell { min-width: 0; padding: 7px; border-radius: 6px; background: var(--surface-soft); } +.stat-cell strong { display: block; margin-top: 4px; overflow: hidden; color: var(--text); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.equipment-list { display: grid; gap: 4px; } +.equipment-row { gap: 7px; min-width: 0; padding: 6px 7px; border-radius: 5px; background: rgba(237, 240, 232, 0.03); font-size: 11px; } +.equipment-slot { width: 52px; flex: 0 0 auto; overflow: hidden; color: var(--faint); font-size: 9px; text-overflow: ellipsis; text-transform: uppercase; } +.equipment-row strong { min-width: 0; flex: 1; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.equipment-rank { color: var(--gold); font-size: 10px; text-transform: uppercase; } +.build-line, .muted-copy, .priority-line { color: var(--muted); font-size: 11px; line-height: 1.45; } +.build-line strong { color: var(--text); } +.priority-line { justify-content: space-between; gap: 10px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line-soft); } +.priority-line strong { color: var(--gold); font-size: 10px; text-align: right; } +.compact-block { padding-bottom: 2px; } + +.actor-list { display: grid; gap: 5px; height: calc(100% - 29px); } +.actor-row { display: grid; grid-template-columns: 9px minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; padding: 8px; border: 1px solid transparent; border-radius: 7px; color: var(--text); text-align: left; background: rgba(237, 240, 232, 0.035); cursor: pointer; transition: border-color 140ms ease, background 140ms ease; } +.actor-row:hover, .actor-row.is-selected { border-color: rgba(216, 185, 109, 0.36); background: rgba(216, 185, 109, 0.09); } +.phase-dot { width: 8px; height: 8px; border-radius: 50%; } +.actor-main { min-width: 0; } +.actor-main strong, .actor-main span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.actor-main strong { font-size: 11px; } +.actor-main span { margin-top: 3px; color: var(--muted); font-size: 10px; } +.actor-loc { max-width: 70px; overflow: hidden; color: var(--faint); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.list-empty { padding: 18px 8px; color: var(--faint); font-size: 11px; text-align: center; } + +@media (max-width: 1120px) { + body { overflow: auto; } + .observer-shell { grid-template-columns: 1fr; height: auto; min-height: 100vh; } + .map-panel { min-height: 70vh; } + .side-panel { grid-template-rows: auto minmax(480px, auto) minmax(260px, auto); min-height: 860px; } } @media (max-width: 680px) { - .observer-shell { - padding: 12px; - } - - .topbar { - align-items: flex-start; - flex-direction: column; - } - - h1 { - font-size: 24px; - } - - .metric-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .filter { - min-width: 64px; - } + .observer-shell { gap: 11px; padding: 10px; } + .topbar { align-items: flex-start; } + .brand-mark { width: 30px; height: 30px; } + h1 { font-size: 21px; } + .map-toolbar { align-items: stretch; flex-direction: column; } + .search-field { width: 100%; } + .map-stage { min-height: 58vh; } + .map-hint { display: none; } + .map-legend { right: 10px; bottom: 10px; gap: 7px; } + .selected-card { left: 10px; bottom: 10px; } + .combat-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/tests/test_world_observer_pk.js b/tests/test_world_observer_pk.js index 65c42c4e..01ffd6e1 100644 --- a/tests/test_world_observer_pk.js +++ b/tests/test_world_observer_pk.js @@ -3,6 +3,7 @@ const assert = require('assert'); require('../src/Global'); const Observer = invoke('WorldObserver/WorldObserverServer'); +const ColdCombatProfile = invoke('GameServer/Bot/Population/ColdCombatProfile'); function actor(karma = 0) { return { @@ -39,6 +40,21 @@ const hotBot = Observer.compactHotBot({ }, new Set([42])); assert.strictEqual(hotBot.isPk, true, 'hot PK bots must be marked for red rendering'); +const hotDetail = Observer.compactHotDetail({ + id: 42, + name: 'Kharz', + level: 46, + classId: 44, + mode: 'pk_hunting', + intent: 'hunting', + role: 'dps', + loc: { locX: 76576, locY: 50151, locZ: -3200 }, + vitals: { hp: 100, maxHp: 100, hpPct: 1, mp: 50, maxMp: 50, mpPct: 1 }, + buffs: {}, + available: true +}, { actor: { fetchKarma: () => 720, activeBuffs: {} } }); +assert.strictEqual(hotDetail.isPk, true, 'hot PK detail must preserve the red-name marker after selection'); + const coldPk = Observer.compactStateBot({ characterId: 43, name: 'Cold PK', @@ -50,4 +66,42 @@ const coldPk = Observer.compactStateBot({ }, new Set()); assert.strictEqual(coldPk.isPk, true, 'stored PK encounters must remain marked between activations'); +const coldState = { + characterId: 44, + name: 'Cold Detail', + level: 20, + phase: 'cold', + activity: 'hunting', + homeRegion: 'Dion', + currentRegion: 'Dion', + loc: { locX: 1, locY: 2, locZ: 3 }, + vitals: { hp: 10, maxHp: 20, mp: 5, maxMp: 10 }, + party: {}, + stats: { + classId: 15, + role: 'healer', + equipment: [{ selfId: 1, name: 'Test Staff', slot: 7, rank: 'd', kind: 'Weapon.Blunt' }], + coldCombat: { + base: { str: 40, dex: 30, con: 43, int: 21, wit: 11, men: 25, pAtk: 3, mAtk: 6, pDef: 10, mDef: 5 }, + equipment: { pAtk: 20, mAtk: 30, pDef: 40, mDef: 10, atkSpd: 379 } + }, + build: { classId: 15, classFamily: 'bishop', grade: 'd', armor: 'robe', weapon: 'staff' } + }, + updatedAt: 1 +}; +const coldDetail = Observer.compactColdDetail(coldState); +const authoritativeCombat = ColdCombatProfile.profileFor(coldState); +assert.strictEqual(coldDetail.classId, 15, 'cold bot detail must preserve class metadata'); +assert.strictEqual(coldDetail.equipment.equipped[0].slot, 'weapon', 'cold outfit slots must be human-readable'); +assert.strictEqual(coldDetail.combat.pDef, authoritativeCombat.pDef, 'cold bot detail must use authoritative combat formulas'); +assert.strictEqual(coldDetail.combat.atkSpd, authoritativeCombat.atkSpd, 'cold bot detail must not add base and equipment attack speed'); + +const deadDetail = Observer.compactColdDetail({ + ...coldState, + activity: 'dead', + vitals: { hp: 0, maxHp: 20, mp: 0, maxMp: 10 } +}); +assert.strictEqual(deadDetail.intent, 'dead', 'dead cold bots must not claim to be hunting'); +assert.deepStrictEqual(deadDetail.blockers, ['dead'], 'dead cold bots must retain the map marker blocker'); + console.log('World Observer PK marker checks passed');