Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 291 additions & 4 deletions src/WorldObserver/WorldObserverServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 },
Expand All @@ -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';
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(/^(\.\.[/\\])+/, '');
Expand All @@ -332,6 +617,8 @@ const WorldObserverServer = {
compactPlayer,
compactHotBot,
compactStateBot,
compactColdDetail,
compactHotDetail,

init() {
if (!isEnabled() || this.server) return;
Expand Down
Loading