From 807d52198aa6d0c1ce362fc940c64e781e81ab00 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:42:18 -0400
Subject: [PATCH 01/17] Add deterministic bot personas
---
database/sql/sqlite.sql | 14 +
scripts/migrate-mariadb-to-sqlite.js | 4 +-
scripts/run-tests.js | 1 +
scripts/world-wipe.js | 2 +-
src/GameServer/Bot/AI/BotPersona.js | 244 ++++++++++++++++++
src/GameServer/Bot/BotManager.js | 14 +
.../Bot/Population/GeneratedColdSeeder.js | 6 +-
.../Bot/Population/PopulationService.js | 34 +++
tests/test_bot_persona.js | 67 +++++
9 files changed, 382 insertions(+), 4 deletions(-)
create mode 100644 src/GameServer/Bot/AI/BotPersona.js
create mode 100644 tests/test_bot_persona.js
diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql
index 55ebf506..ca4d45dd 100644
--- a/database/sql/sqlite.sql
+++ b/database/sql/sqlite.sql
@@ -185,6 +185,20 @@ CREATE TABLE IF NOT EXISTS bot_goal_state (
updatedAt INTEGER NOT NULL
);
+CREATE TABLE IF NOT EXISTS bot_personas (
+ characterId INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
+ version INTEGER NOT NULL DEFAULT 1,
+ seed TEXT NOT NULL,
+ primaryDrive TEXT NOT NULL,
+ archetype TEXT NOT NULL,
+ traitsJson TEXT NOT NULL,
+ textCard TEXT NOT NULL DEFAULT '',
+ createdAt INTEGER NOT NULL DEFAULT 0,
+ updatedAt INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS bot_personas_primaryDrive ON bot_personas(primaryDrive);
+CREATE INDEX IF NOT EXISTS bot_personas_archetype ON bot_personas(archetype);
+
CREATE TABLE IF NOT EXISTS bot_social_memory (
playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
diff --git a/scripts/migrate-mariadb-to-sqlite.js b/scripts/migrate-mariadb-to-sqlite.js
index 2aab7c44..8ea1e99e 100644
--- a/scripts/migrate-mariadb-to-sqlite.js
+++ b/scripts/migrate-mariadb-to-sqlite.js
@@ -11,7 +11,7 @@ const rootDir = path.resolve(__dirname, '..');
const TABLE_ORDER = [
'accounts', 'characters', 'clans', 'clan_crests', 'items',
'character_recipes', 'character_quests', 'warehouse_items', 'skills',
- 'shortcuts', 'macros', 'bot_life_state', 'bot_goal_state',
+ 'shortcuts', 'macros', 'bot_life_state', 'bot_goal_state', 'bot_personas',
'bot_social_memory', 'bot_life_events', 'bot_background_parties'
];
const SEQUENCE_TABLES = ['characters', 'clans', 'clan_crests', 'items', 'warehouse_items', 'bot_life_events'];
@@ -97,7 +97,7 @@ function normalizeValue(value) {
function sourceSelect(table) {
const characterChildren = new Set([
'items', 'character_recipes', 'character_quests', 'warehouse_items',
- 'skills', 'shortcuts', 'macros', 'bot_life_state', 'bot_goal_state', 'bot_life_events'
+ 'skills', 'shortcuts', 'macros', 'bot_life_state', 'bot_goal_state', 'bot_personas', 'bot_life_events'
]);
if (characterChildren.has(table)) {
return `SELECT source.* FROM \`${table}\` source INNER JOIN \`characters\` character_ref ON character_ref.id = source.characterId`;
diff --git a/scripts/run-tests.js b/scripts/run-tests.js
index 285e57fd..f1617912 100644
--- a/scripts/run-tests.js
+++ b/scripts/run-tests.js
@@ -23,6 +23,7 @@ const tests = [
'tests/test_spot_profile_state_priority.js',
'tests/test_population_starter_party_grouping.js',
'tests/test_bot_goal_state.js',
+ 'tests/test_bot_persona.js',
'tests/test_bot_goal_planner.js',
'tests/test_bot_goal_market_priority.js',
'tests/test_bot_market_goal_reconcile.js',
diff --git a/scripts/world-wipe.js b/scripts/world-wipe.js
index 0da10108..ab42418e 100644
--- a/scripts/world-wipe.js
+++ b/scripts/world-wipe.js
@@ -56,7 +56,7 @@ function wipeWithConnection(db, scope) {
try {
if (normalizedScope === 'all') {
[
- 'bot_life_events', 'bot_life_state', 'bot_goal_state', 'bot_social_memory',
+ 'bot_life_events', 'bot_life_state', 'bot_goal_state', 'bot_personas', 'bot_social_memory',
'character_recipes', 'character_quests', 'warehouse_items', 'macros',
'shortcuts', 'skills', 'items', 'bot_background_parties', 'clan_crests', 'clans'
].forEach((table) => db.exec(`DELETE FROM ${table}`));
diff --git a/src/GameServer/Bot/AI/BotPersona.js b/src/GameServer/Bot/AI/BotPersona.js
new file mode 100644
index 00000000..b69c392a
--- /dev/null
+++ b/src/GameServer/Bot/AI/BotPersona.js
@@ -0,0 +1,244 @@
+const Database = invoke('Database');
+
+const TABLE = 'bot_personas';
+const VERSION = 1;
+const TRAITS = Object.freeze(['sociability', 'commitment', 'caution', 'ambition', 'assertiveness', 'empathy', 'resilience']);
+const PRIMARY_DRIVES = Object.freeze(['progression', 'wealth', 'social']);
+
+// Archetypes provide coherent starting clusters. Small deterministic variance
+// keeps a population from looking like copied templates.
+const ARCHETYPES = Object.freeze({
+ progression: [
+ { id: 'steady_achiever', traits: { sociability: 0.48, commitment: 0.65, caution: 0.60, ambition: 0.78, assertiveness: 0.53, empathy: 0.55, resilience: 0.68 } },
+ { id: 'competitive_climber', traits: { sociability: 0.60, commitment: 0.40, caution: 0.38, ambition: 0.88, assertiveness: 0.80, empathy: 0.35, resilience: 0.68 } }
+ ],
+ wealth: [
+ { id: 'pragmatic_earner', traits: { sociability: 0.36, commitment: 0.45, caution: 0.62, ambition: 0.78, assertiveness: 0.48, empathy: 0.38, resilience: 0.72 } },
+ { id: 'patient_crafter', traits: { sociability: 0.42, commitment: 0.62, caution: 0.72, ambition: 0.60, assertiveness: 0.33, empathy: 0.64, resilience: 0.75 } }
+ ],
+ social: [
+ { id: 'steadfast_helper', traits: { sociability: 0.72, commitment: 0.86, caution: 0.64, ambition: 0.56, assertiveness: 0.46, empathy: 0.90, resilience: 0.75 } },
+ { id: 'party_regular', traits: { sociability: 0.82, commitment: 0.66, caution: 0.48, ambition: 0.58, assertiveness: 0.55, empathy: 0.66, resilience: 0.62 } }
+ ]
+});
+
+const cache = new Map();
+let initialized = false;
+let initPromise = null;
+
+function now() { return Date.now(); }
+
+function parseJson(value, fallback = {}) {
+ if (!value) return fallback;
+ try { return JSON.parse(value); } catch (_) { return fallback; }
+}
+
+function clamp(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }
+function text(value) { return typeof value === 'string' ? value.trim() : ''; }
+
+function seedFor(subject = {}) {
+ const generated = subject?.stats?.generatedIndex;
+ if (generated !== undefined && generated !== null && generated !== '') return String(generated);
+ return String(subject.characterId || subject.id || '0');
+}
+
+function hash(seed, salt = '') {
+ let value = 2166136261;
+ const source = `${seed}:${salt}`;
+ for (let index = 0; index < source.length; index++) {
+ value ^= source.charCodeAt(index);
+ value = Math.imul(value, 16777619);
+ }
+ value += value << 13;
+ value ^= value >>> 7;
+ value += value << 3;
+ value ^= value >>> 17;
+ value += value << 5;
+ return value >>> 0;
+}
+
+function random(seed, salt) { return hash(seed, salt) / 4294967296; }
+function pick(seed, salt, values) { return values[Math.min(values.length - 1, Math.floor(random(seed, salt) * values.length))]; }
+
+function driveLabel(drive) {
+ return ({
+ progression: 'character progression',
+ wealth: 'building wealth through practical opportunities',
+ social: 'lasting party bonds and reliable cooperation'
+ })[drive] || 'a steady life in Aden';
+}
+
+function traitLabel(value, low, high) {
+ return value >= 0.62 ? high : value <= 0.38 ? low : '';
+}
+
+function buildTextCard(persona) {
+ const traits = persona.traits;
+ const style = [
+ traitLabel(traits.sociability, 'reserved in conversation', 'comfortable starting a conversation'),
+ traitLabel(traits.assertiveness - traits.empathy + 0.5, 'careful not to impose', 'direct when making a point'),
+ traitLabel(traits.resilience, 'easily shaken by setbacks', 'calm after setbacks')
+ ].filter(Boolean).join(', ');
+ const group = traits.commitment >= 0.62
+ ? 'prefers to keep faith with familiar companions'
+ : traits.sociability <= 0.38 ? 'is comfortable working alone' : 'will choose a group when it clearly helps';
+ const risk = traits.caution >= 0.62
+ ? 'avoids needless danger'
+ : traits.caution <= 0.38 ? 'will take a calculated chance' : 'weighs danger against the reward';
+ return `${persona.archetype.replace(/_/g, ' ')} focused on ${driveLabel(persona.primaryDrive)}. ${group}; ${risk}. ${style || 'Speaks plainly and stays in character.'}.`;
+}
+
+function normalize(row) {
+ const characterId = Number(row?.characterId || 0);
+ const primaryDrive = PRIMARY_DRIVES.includes(row?.primaryDrive) ? row.primaryDrive : null;
+ const archetype = text(row?.archetype);
+ const traits = parseJson(row?.traitsJson, {});
+ if (!characterId || !primaryDrive || !archetype || !TRAITS.every((trait) => Number.isFinite(Number(traits[trait])))) return null;
+ const normalizedTraits = Object.fromEntries(TRAITS.map((trait) => [trait, clamp(traits[trait])]));
+ const persona = {
+ characterId,
+ version: Math.max(1, Number(row.version) || VERSION),
+ seed: text(row.seed),
+ primaryDrive,
+ archetype,
+ traits: normalizedTraits,
+ createdAt: Number(row.createdAt || 0),
+ updatedAt: Number(row.updatedAt || 0)
+ };
+ return { ...persona, textCard: text(row.textCard) || buildTextCard(persona) };
+}
+
+function generated(subject = {}) {
+ const characterId = Number(subject.characterId || subject.id || 0);
+ if (!characterId) return null;
+ const seed = seedFor(subject);
+ const primaryDrive = pick(seed, 'drive', PRIMARY_DRIVES);
+ const archetype = pick(seed, 'archetype', ARCHETYPES[primaryDrive]);
+ const traits = Object.fromEntries(TRAITS.map((trait) => {
+ const variance = (random(seed, `trait:${trait}`) - 0.5) * 0.22;
+ return [trait, Math.round(clamp(archetype.traits[trait] + variance) * 100) / 100];
+ }));
+ const persona = { characterId, version: VERSION, seed, primaryDrive, archetype: archetype.id, traits };
+ return { ...persona, textCard: buildTextCard(persona) };
+}
+
+function save(persona) {
+ return Database.execute([
+ `INSERT INTO ${TABLE} (
+ characterId, version, seed, primaryDrive, archetype, traitsJson, textCard, createdAt, updatedAt
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(characterId) DO NOTHING`,
+ [persona.characterId, persona.version, persona.seed, persona.primaryDrive, persona.archetype,
+ JSON.stringify(persona.traits), persona.textCard, persona.createdAt, persona.updatedAt]
+ ]);
+}
+
+const BotPersona = {
+ VERSION,
+ TRAITS,
+ PRIMARY_DRIVES,
+ ARCHETYPES,
+
+ init() {
+ if (initialized) return Promise.resolve(true);
+ if (initPromise) return initPromise;
+ initPromise = Database.execute(['SELECT 1', []], 'schema:bot-personas').then(() => {
+ initialized = true;
+ return true;
+ }).catch((err) => {
+ utils.infoWarn('BotPersona', 'persona table unavailable: %s', err.message);
+ initPromise = null;
+ return false;
+ });
+ return initPromise;
+ },
+
+ generate: generated,
+
+ snapshot(characterId) { return cache.get(Number(characterId || 0)) || null; },
+
+ load(characterId) {
+ const id = Number(characterId || 0);
+ if (!id) return Promise.resolve(null);
+ const cached = cache.get(id);
+ if (cached) return Promise.resolve(cached);
+ return this.init().then((ready) => {
+ if (!ready) return null;
+ return Database.execute([
+ `SELECT characterId, version, seed, primaryDrive, archetype, traitsJson, textCard, createdAt, updatedAt
+ FROM ${TABLE} WHERE characterId = ? LIMIT 1`, [id]
+ ]).then((rows) => {
+ const persona = normalize(rows?.[0]);
+ if (persona) cache.set(id, persona);
+ return persona;
+ });
+ }).catch((err) => {
+ utils.infoWarn('BotPersona', 'failed to load persona for %d: %s', id, err.message);
+ return null;
+ });
+ },
+
+ ensure(subject) {
+ const candidate = generated(subject);
+ if (!candidate) return Promise.resolve(null);
+ const cached = cache.get(candidate.characterId);
+ if (cached) return Promise.resolve(cached);
+ return this.load(candidate.characterId).then((existing) => {
+ if (existing) return existing;
+ const timestamp = now();
+ const persisted = { ...candidate, createdAt: timestamp, updatedAt: timestamp };
+ return save(persisted).then(() => {
+ cache.set(persisted.characterId, persisted);
+ return persisted;
+ });
+ }).catch((err) => {
+ utils.infoWarn('BotPersona', 'failed to persist persona for %d: %s', candidate.characterId, err.message);
+ return null;
+ });
+ },
+
+ // Generated cold bots are the only population currently eligible here.
+ // Static merchant/craft services have different account prefixes and do
+ // not receive a simulated player persona.
+ backfillGenerated(limit = 100) {
+ const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100));
+ return this.init().then((ready) => {
+ if (!ready) return { created: 0, exhausted: false };
+ return Database.execute([
+ `SELECT states.characterId, states.statsJson
+ FROM bot_life_state states
+ LEFT JOIN ${TABLE} personas ON personas.characterId = states.characterId
+ WHERE personas.characterId IS NULL
+ AND states.accountName LIKE 'bot_pop_%'
+ AND json_extract(COALESCE(states.statsJson, '{}'), '$.generatedCold') = 1
+ LIMIT ${safeLimit}`,
+ []
+ ]).then((rows) => {
+ const candidates = rows || [];
+ return candidates.reduce((chain, row) => chain.then((result) => (
+ this.ensure({ characterId: row.characterId, stats: parseJson(row.statsJson, {}) })
+ .then((persona) => ({
+ created: result.created + (persona ? 1 : 0),
+ failed: result.failed + (persona ? 0 : 1)
+ }))
+ )), Promise.resolve({ created: 0, failed: 0 })).then((result) => ({
+ created: result.created,
+ // Do not confuse a failed save with the end of the
+ // migration; transient database errors need another pass.
+ exhausted: candidates.length < safeLimit && result.failed === 0
+ }));
+ });
+ }).catch((err) => {
+ utils.infoWarn('BotPersona', 'generated persona backfill failed: %s', err.message);
+ return { created: 0, exhausted: false };
+ });
+ },
+
+ reset() {
+ cache.clear();
+ initialized = false;
+ initPromise = null;
+ }
+};
+
+module.exports = BotPersona;
diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js
index 1980d678..4a006dd6 100644
--- a/src/GameServer/Bot/BotManager.js
+++ b/src/GameServer/Bot/BotManager.js
@@ -5,6 +5,7 @@ const World = invoke('GameServer/World/World');
const BotSession = invoke('GameServer/Bot/BotSession');
const BotAI = invoke('GameServer/Bot/BotAI');
const BotBrain = invoke('GameServer/Bot/AI/BotBrain');
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
const GeodataEngine = invoke('GameServer/Geodata/GeodataEngine');
const MerchantConfigs = invoke('GameServer/Bot/MerchantStoreConfigs');
const TradeService = invoke('GameServer/Bot/TradeService');
@@ -545,6 +546,19 @@ const BotManager = {
session.setActor({
...character, ...utils.crushOb(classInfo)
});
+ // Persona generation is intentionally independent from
+ // spawning. Static shop/craft services represent a fixed
+ // service surface, while every other bot is modelled as a
+ // simulated player and gets a durable seed-based profile.
+ const staticService = !!storeCfg || (!!manufactureShop && !botData.coldCraftState);
+ if (!staticService) {
+ BotPersona.ensure({
+ characterId: character.id,
+ stats: botData.coldLifeState?.stats || {}
+ }).then((persona) => {
+ session.persona = persona;
+ });
+ }
// Hot bots do not have a client hotbar request to enable
// shots. Their stock is prepared before actor creation, so
// enable the compatible C4 auto-shot explicitly.
diff --git a/src/GameServer/Bot/Population/GeneratedColdSeeder.js b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
index f87627d3..7f15a5cc 100644
--- a/src/GameServer/Bot/Population/GeneratedColdSeeder.js
+++ b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
@@ -12,6 +12,7 @@ const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService');
const SeedPlanner = invoke('GameServer/Bot/Population/PopulationSeedPlanner');
const BotNameGenerator = invoke('GameServer/Bot/Population/BotNameGenerator');
const ColdCombatProfile = invoke('GameServer/Bot/Population/ColdCombatProfile');
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
const NAME_GENERATOR_VERSION = 2;
@@ -504,7 +505,9 @@ const GeneratedColdSeeder = {
if (!limit || this.running) return Promise.resolve({ created: 0, seeded: 0, total: 0, limit });
this.running = true;
- return Promise.resolve().then(() => migratePopulationNames(LifeState.allStates(limit + 100))).then(() => {
+ return Promise.resolve()
+ .then(() => migratePopulationNames(LifeState.allStates(limit + 100)))
+ .then(() => {
const plan = SeedPlanner.plan(
SpotProfiles.ensure(),
LifeState.allStates(limit + 100),
@@ -537,6 +540,7 @@ const GeneratedColdSeeder = {
loc: result.loc || randomNear(spot.center, index)
});
return hydrateColdCombatProfile(state)
+ .then((profiledState) => BotPersona.ensure(profiledState).then(() => profiledState))
.then((profiledState) => LifeState.upsertState(profiledState, 'population_wave_seed'))
.then((saved) => {
if (saved && result.created) created += 1;
diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js
index 3e888dc0..8c1a1cae 100644
--- a/src/GameServer/Bot/Population/PopulationService.js
+++ b/src/GameServer/Bot/Population/PopulationService.js
@@ -23,6 +23,7 @@ const PartyRecruitmentChat = invoke('GameServer/Bot/Population/ColdPartyRecruitm
const GearAcquisitionPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner');
const ColdCraftingService = invoke('GameServer/Bot/Economy/ColdCraftingService');
const CraftTelemetry = invoke('GameServer/Bot/Economy/CraftTelemetry');
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
function groupBySpot(states, options = {}) {
const grouped = new Map();
@@ -165,6 +166,8 @@ const PopulationService = {
nextColdCombatProfileMigrationAt: 0,
nextMarketTownMigrationAt: 0,
marketExpiryCleanupTimer: null,
+ personaBackfillTimer: null,
+ personaBackfillRunning: false,
nextMarketExpiryCleanupAt: 0,
resolving: false,
classProgressionMigrationRunning: false,
@@ -265,6 +268,7 @@ const PopulationService = {
}
this.scheduleGeneratedColdSeed(Config.generatedColdSeedDelayMs);
+ this.schedulePersonaBackfill();
Director.start();
},
@@ -307,6 +311,11 @@ const PopulationService = {
clearInterval(this.marketExpiryCleanupTimer);
this.marketExpiryCleanupTimer = null;
}
+ if (this.personaBackfillTimer) {
+ clearInterval(this.personaBackfillTimer);
+ this.personaBackfillTimer = null;
+ }
+ this.personaBackfillRunning = false;
Director.stop();
Metrics.stopEventLoopMonitor();
this.started = false;
@@ -345,6 +354,31 @@ const PopulationService = {
}
},
+ schedulePersonaBackfill() {
+ if (this.personaBackfillTimer) return;
+
+ const run = () => {
+ if (this.personaBackfillRunning) return;
+ this.personaBackfillRunning = true;
+ BotPersona.backfillGenerated().then((result) => {
+ // Only a successful short read closes this one-time migration.
+ // A failed write stays scheduled for a later retry.
+ if (result.exhausted && this.personaBackfillTimer) {
+ clearInterval(this.personaBackfillTimer);
+ this.personaBackfillTimer = null;
+ }
+ }).finally(() => {
+ this.personaBackfillRunning = false;
+ });
+ };
+
+ run();
+ this.personaBackfillTimer = setInterval(run, 2000);
+ if (typeof this.personaBackfillTimer.unref === 'function') {
+ this.personaBackfillTimer.unref();
+ }
+ },
+
migrateLegacyClassProgression() {
// Database uses one ordered connection. Never queue a migration behind
// an active resolver: a skipped migration tick is harmless, but a
diff --git a/tests/test_bot_persona.js b/tests/test_bot_persona.js
new file mode 100644
index 00000000..e359ef75
--- /dev/null
+++ b/tests/test_bot_persona.js
@@ -0,0 +1,67 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const Database = invoke('Database');
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
+
+const subject = { characterId: 2000123, stats: { generatedIndex: 987654 } };
+const first = BotPersona.generate(subject);
+const second = BotPersona.generate(subject);
+
+assert.deepStrictEqual(first, second, 'the same generated seed must produce the exact same persona');
+assert.deepStrictEqual(BotPersona.PRIMARY_DRIVES, ['progression', 'wealth', 'social'], 'the intentional drive model must not silently grow vague categories');
+assert(BotPersona.PRIMARY_DRIVES.includes(first.primaryDrive), 'persona drive must stay inside the intentional three-drive model');
+assert(first.textCard.includes('focused on'), 'every persona needs a compact model-facing text card');
+BotPersona.TRAITS.forEach((trait) => {
+ assert(Number.isFinite(first.traits[trait]), `${trait} must be numeric`);
+ assert(first.traits[trait] >= 0 && first.traits[trait] <= 1, `${trait} must remain normalized`);
+});
+
+const originalExecute = Database.execute;
+const statements = [];
+try {
+ BotPersona.reset();
+ Database.execute = ([sql, params]) => {
+ statements.push({ sql: String(sql), params });
+ if (String(sql).startsWith('SELECT 1')) return Promise.resolve([]);
+ if (String(sql).includes('FROM bot_personas WHERE characterId')) return Promise.resolve([]);
+ if (String(sql).startsWith('INSERT INTO bot_personas')) return Promise.resolve({ affectedRows: 1 });
+ return Promise.resolve([]);
+ };
+
+ BotPersona.ensure(subject).then((persona) => {
+ assert.strictEqual(persona.seed, first.seed, 'persistence must retain the generated seed without mutation');
+ assert.deepStrictEqual(persona.traits, first.traits, 'persistence must retain the seed-generated trait profile');
+ const insert = statements.find((entry) => entry.sql.startsWith('INSERT INTO bot_personas'));
+ assert(insert, 'a missing persona must be stored in its own durable table');
+ assert.strictEqual(insert.params[2], '987654', 'generated population index must be the durable persona seed');
+ assert.strictEqual(insert.params[3], persona.primaryDrive, 'primary drive must be queryable without parsing traits');
+ assert.strictEqual(insert.params[4], persona.archetype, 'archetype must be queryable without parsing traits');
+ const originalEnsure = BotPersona.ensure;
+ BotPersona.reset();
+ Database.execute = ([sql, params]) => {
+ if (String(sql).startsWith('SELECT 1')) return Promise.resolve([]);
+ if (String(sql).includes('FROM bot_life_state states')) {
+ return Promise.resolve([{ characterId: 2000456, statsJson: '{"generatedIndex":456}' }]);
+ }
+ return Promise.resolve([]);
+ };
+ BotPersona.ensure = () => Promise.resolve(null);
+ return BotPersona.backfillGenerated(100).then((backfill) => {
+ assert.deepStrictEqual(backfill, { created: 0, exhausted: false }, 'a failed persona write must keep the migration eligible for retry');
+ BotPersona.ensure = originalEnsure;
+ console.log('Bot persona checks passed');
+ });
+ }).catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ }).finally(() => {
+ Database.execute = originalExecute;
+ BotPersona.reset();
+ });
+} catch (err) {
+ Database.execute = originalExecute;
+ BotPersona.reset();
+ throw err;
+}
From 7172c9c10876882fa38cd36f6426071803d205e9 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:54:09 -0400
Subject: [PATCH 02/17] Apply personas to bot dialogue context
---
src/GameServer/Bot/AI/BotBrain.js | 1 +
src/GameServer/Bot/AI/BotBrainContext.js | 1 +
src/GameServer/Bot/AI/BotConversation.js | 49 +++++++++++++++++++-----
src/GameServer/Bot/AI/BotStatus.js | 13 +++++++
tests/test_bot_conversation.js | 11 ++++++
tests/test_bot_persona.js | 20 ++++++++++
6 files changed, 86 insertions(+), 9 deletions(-)
diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js
index 2745641d..8ba6393f 100644
--- a/src/GameServer/Bot/AI/BotBrain.js
+++ b/src/GameServer/Bot/AI/BotBrain.js
@@ -166,6 +166,7 @@ function systemPrompt() {
'follow_player only means approach a visible player unless the bot is already an invited party companion.',
'For buff_target and heal_target, choose a visible player and let the server validate class, learned skill, MP, range, and safety.',
'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.',
+ 'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.',
'Do not offer trading, selling, price negotiation, or private stores; those tools are intentionally unavailable for now.',
'Never invent unavailable actions, players, items, or spells.'
].join(' ');
diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js
index 080d801e..2ed5f6f4 100644
--- a/src/GameServer/Bot/AI/BotBrainContext.js
+++ b/src/GameServer/Bot/AI/BotBrainContext.js
@@ -272,6 +272,7 @@ function compactStatus(session, status, text = '') {
inventory: inventorySnapshot(actor, text),
skills: skillsSnapshot(actor, text),
roleDecision: status.roleDecision || null,
+ persona: status.persona || null,
social: status.social || null
};
}
diff --git a/src/GameServer/Bot/AI/BotConversation.js b/src/GameServer/Bot/AI/BotConversation.js
index f080fe8f..e33cdc52 100644
--- a/src/GameServer/Bot/AI/BotConversation.js
+++ b/src/GameServer/Bot/AI/BotConversation.js
@@ -5,13 +5,40 @@ function areaFor(session) {
return session?.botStatus?.home?.region || session?.homeRegion || session?.spotId || 'town';
}
+function personaFor(session) {
+ return session?.persona?.traits ? session.persona : null;
+}
+
+function trait(session, name, fallback = 0.5) {
+ const value = Number(personaFor(session)?.traits?.[name]);
+ return Number.isFinite(value) ? value : fallback;
+}
+
function roleLine(session) {
const role = String(session?.botStatus?.role || session?.role || '').toLowerCase();
- if (role === 'healer' || role === 'buffer') return 'I will keep an eye on everyone\'s health.';
- if (role === 'tank') return 'I can take the first hits if things get rough.';
- if (role === 'archer') return 'I will keep some distance and watch the edges.';
- if (role === 'dagger') return 'I will look for a clean opening behind them.';
- return 'I could use a steadier run than the last one.';
+ let line = 'I could use a steadier run than the last one.';
+ if (role === 'healer' || role === 'buffer') line = 'I will keep an eye on everyone\'s health.';
+ else if (role === 'tank') line = 'I can take the first hits if things get rough.';
+ else if (role === 'archer') line = 'I will keep some distance and watch the edges.';
+ else if (role === 'dagger') line = 'I will look for a clean opening behind them.';
+
+ if (trait(session, 'empathy') >= 0.78) return `${line} No one gets left behind.`;
+ if (trait(session, 'assertiveness') >= 0.78) return `${line} Just give the word.`;
+ return line;
+}
+
+function restOpener(session, area) {
+ const drive = personaFor(session)?.primaryDrive;
+ if (drive === 'wealth') return `Quiet around ${area} for once. A steady run could pay for the next upgrade.`;
+ if (drive === 'social') return `Quiet around ${area} for once. It is better to head out with familiar company.`;
+ if (drive === 'progression') return `Quiet around ${area} for once. Ready to make the next run count?`;
+ return `Quiet around ${area} for once. Heading out when you are recovered?`;
+}
+
+function restCloser(session) {
+ if (trait(session, 'caution') >= 0.70) return 'Sounds good. We can keep it steady and avoid rushing back in.';
+ if (trait(session, 'sociability') >= 0.70) return 'Sounds good. Better than rushing back in alone.';
+ return 'Sounds good. Better than rushing back in alone.';
}
function chooseTopic(initiator, responder) {
@@ -21,16 +48,18 @@ function chooseTopic(initiator, responder) {
candidates.push({
id: 'rest',
- opener: `Quiet around ${area} for once. Heading out when you are recovered?`,
+ opener: restOpener(initiator, area),
reply: roleLine(responder),
- closer: 'Sounds good. Better than rushing back in alone.'
+ closer: restCloser(initiator)
});
if (responder?.botStatus?.role === 'tank' || initiator?.botStatus?.role === 'tank' ||
responder?.botStatus?.role === 'healer' || initiator?.botStatus?.role === 'healer') {
candidates.push({
id: 'party',
- opener: `We have the right roles for a small group around ${area}.`,
+ opener: personaFor(initiator)?.primaryDrive === 'social'
+ ? `We have the right roles for a small group around ${area}. It would be good to keep the team together.`
+ : `We have the right roles for a small group around ${area}.`,
reply: roleLine(responder),
closer: 'Then let us watch for someone who wants to join the next run.'
});
@@ -40,7 +69,9 @@ function chooseTopic(initiator, responder) {
candidates.push({
id: 'trade',
opener: `The market around ${area} has been busy. Did you find what you needed?`,
- reply: 'Enough to get by. I would rather spend the next hour earning than browsing.',
+ reply: personaFor(responder)?.primaryDrive === 'wealth'
+ ? 'Enough to get by. I would rather spend the next hour earning than browsing.'
+ : 'Enough to get by. I would rather spend the next hour fighting than browsing.',
closer: 'Same. A little more adena always makes the next trip easier.'
});
}
diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js
index 9e80f747..55449e60 100644
--- a/src/GameServer/Bot/AI/BotStatus.js
+++ b/src/GameServer/Bot/AI/BotStatus.js
@@ -207,6 +207,18 @@ function tradeSnapshot(session, bot) {
};
}
+function personaSnapshot(session) {
+ const persona = session?.persona;
+ if (!persona?.primaryDrive || !persona?.archetype || !persona?.traits) return null;
+
+ return {
+ primaryDrive: persona.primaryDrive,
+ archetype: persona.archetype,
+ traits: { ...persona.traits },
+ textCard: persona.textCard || ''
+ };
+}
+
const BotStatus = {
getStatus(session) {
const bot = session.actor;
@@ -325,6 +337,7 @@ const BotStatus = {
},
nearby: nearbySnapshot(bot),
trade: tradeSnapshot(session, bot),
+ persona: personaSnapshot(session),
social: session.socialSummary || null,
lastSocialEvent: session.lastSocialEvent || null,
blockers: []
diff --git a/tests/test_bot_conversation.js b/tests/test_bot_conversation.js
index 556a2cb2..52df3077 100644
--- a/tests/test_bot_conversation.js
+++ b/tests/test_bot_conversation.js
@@ -46,4 +46,15 @@ assert.strictEqual(
const companion = session('Companion', { partyCompanion: true });
assert.strictEqual(BotConversation.canStart(companion, belen, startedAt + 999999), false, 'player companions must not gossip autonomously');
+const socialRest = BotConversation.chooseTopic(
+ session('Social', { persona: { primaryDrive: 'social', traits: { sociability: 0.9 } } }),
+ session('Solo')
+);
+const wealthRest = BotConversation.chooseTopic(
+ session('Wealth', { persona: { primaryDrive: 'wealth', traits: { sociability: 0.2 } } }),
+ session('Solo')
+);
+assert(socialRest.opener.includes('familiar company'), 'social personas should frame rest dialogue around companionship');
+assert(wealthRest.opener.includes('pay for the next upgrade'), 'wealth personas should frame rest dialogue around practical earnings');
+
console.log('Bot conversation checks passed');
diff --git a/tests/test_bot_persona.js b/tests/test_bot_persona.js
index e359ef75..78f080ad 100644
--- a/tests/test_bot_persona.js
+++ b/tests/test_bot_persona.js
@@ -4,6 +4,7 @@ require('../src/Global');
const Database = invoke('Database');
const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
+const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext');
const subject = { characterId: 2000123, stats: { generatedIndex: 987654 } };
const first = BotPersona.generate(subject);
@@ -18,6 +19,25 @@ BotPersona.TRAITS.forEach((trait) => {
assert(first.traits[trait] >= 0 && first.traits[trait] <= 1, `${trait} must remain normalized`);
});
+const compact = BotBrainContext.compactStatus({ actor: null }, {
+ available: true,
+ name: 'PersonaProbe',
+ level: 20,
+ classId: 31,
+ mode: 'hunting',
+ intent: 'find_target',
+ role: 'dps',
+ vitals: { hpPct: 1, mpPct: 1 },
+ target: null,
+ party: null,
+ nearby: {},
+ blockers: [],
+ spot: null,
+ persona: first,
+ social: null
+});
+assert.deepStrictEqual(compact.persona, first, 'the model context must receive the stable persona alongside live status');
+
const originalExecute = Database.execute;
const statements = [];
try {
From 7def15b97acdae154af5ef8fcd3242dea890508b Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:55:56 -0400
Subject: [PATCH 03/17] Use personas for background party formation
---
.../Population/BackgroundPartyComposition.js | 4 ++
.../Bot/Population/PersonaPartyPolicy.js | 44 +++++++++++++++++++
.../Bot/Population/PopulationService.js | 5 +++
tests/test_bot_background_party_affinity.js | 5 +++
4 files changed, 58 insertions(+)
create mode 100644 src/GameServer/Bot/Population/PersonaPartyPolicy.js
diff --git a/src/GameServer/Bot/Population/BackgroundPartyComposition.js b/src/GameServer/Bot/Population/BackgroundPartyComposition.js
index 356abff6..3d84b307 100644
--- a/src/GameServer/Bot/Population/BackgroundPartyComposition.js
+++ b/src/GameServer/Bot/Population/BackgroundPartyComposition.js
@@ -1,6 +1,7 @@
const SUPPORT_ROLES = ['tank', 'healer', 'buffer'];
const DEFAULT_LEVEL_RANGE = 4;
const PartyAffinity = invoke('GameServer/Bot/Population/BackgroundPartyAffinity');
+const PersonaPartyPolicy = invoke('GameServer/Bot/Population/PersonaPartyPolicy');
function levelOf(state) {
return Math.max(1, Number(state?.level || 1));
@@ -28,6 +29,9 @@ function compareCandidate(anchor, coverage, peers = [anchor]) {
const aAffinity = PartyAffinity.affinity(a, peers);
const bAffinity = PartyAffinity.affinity(b, peers);
+ const aPreference = PersonaPartyPolicy.preference(a, peers, coverage).score;
+ const bPreference = PersonaPartyPolicy.preference(b, peers, coverage).score;
+ if (aPreference !== bPreference) return bPreference - aPreference;
if (aAffinity !== bAffinity) return bAffinity - aAffinity;
const aDistance = Math.abs(levelOf(a) - levelOf(anchor));
diff --git a/src/GameServer/Bot/Population/PersonaPartyPolicy.js b/src/GameServer/Bot/Population/PersonaPartyPolicy.js
new file mode 100644
index 00000000..7243cee5
--- /dev/null
+++ b/src/GameServer/Bot/Population/PersonaPartyPolicy.js
@@ -0,0 +1,44 @@
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
+const PartyAffinity = invoke('GameServer/Bot/Population/BackgroundPartyAffinity');
+
+function supportCount(coverage = {}) {
+ return ['tank', 'healer', 'buffer'].filter((role) => Number(coverage[role] || 0) > 0).length;
+}
+
+function profileFor(state) {
+ return BotPersona.generate(state);
+}
+
+function preference(state, peers = [], coverage = {}) {
+ const persona = profileFor(state);
+ if (!persona) return { score: 0, reasons: [] };
+
+ const traits = persona.traits;
+ const familiarity = PartyAffinity.affinity(state, peers);
+ const supports = supportCount(coverage);
+ const score = Math.round(
+ traits.sociability * 40 +
+ traits.ambition * 12 +
+ traits.empathy * 8 +
+ familiarity * (10 + traits.commitment * 10) +
+ traits.caution * supports * 5
+ );
+ const reasons = [];
+ if (familiarity > 0 && traits.commitment >= 0.5) reasons.push('familiar_party');
+ if (traits.sociability >= 0.65) reasons.push('social');
+ if (traits.caution >= 0.65 && supports > 0) reasons.push('safe_composition');
+ if (traits.ambition >= 0.7) reasons.push('progress_focus');
+ return { score, reasons: reasons.slice(0, 3), persona };
+}
+
+function explain(state, peers = [], coverage = {}) {
+ const result = preference(state, peers, coverage);
+ return {
+ score: result.score,
+ reasons: result.reasons,
+ primaryDrive: result.persona?.primaryDrive || null,
+ archetype: result.persona?.archetype || null
+ };
+}
+
+module.exports = { preference, explain };
diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js
index 8c1a1cae..519f998c 100644
--- a/src/GameServer/Bot/Population/PopulationService.js
+++ b/src/GameServer/Bot/Population/PopulationService.js
@@ -24,6 +24,7 @@ const GearAcquisitionPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner'
const ColdCraftingService = invoke('GameServer/Bot/Economy/ColdCraftingService');
const CraftTelemetry = invoke('GameServer/Bot/Economy/CraftTelemetry');
const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
+const PersonaPartyPolicy = invoke('GameServer/Bot/Population/PersonaPartyPolicy');
function groupBySpot(states, options = {}) {
const grouped = new Map();
@@ -713,6 +714,10 @@ const PopulationService = {
stats: {
formedAt: Date.now(),
memberNames: members.map((state) => state.name),
+ personaFormation: Object.fromEntries(members.map((member) => [
+ member.characterId,
+ PersonaPartyPolicy.explain(member, members.filter((peer) => peer !== member), PartyComposition.roleCoverage(members))
+ ])),
route: partySpot?.route || null,
acquisitionGoal: leader.stats?.equipmentPlan?.status === 'active'
? leader.stats.equipmentPlan
diff --git a/tests/test_bot_background_party_affinity.js b/tests/test_bot_background_party_affinity.js
index 2ac6da59..60256244 100644
--- a/tests/test_bot_background_party_affinity.js
+++ b/tests/test_bot_background_party_affinity.js
@@ -4,6 +4,7 @@ require('../src/Global');
const PartyAffinity = invoke('GameServer/Bot/Population/BackgroundPartyAffinity');
const PartyComposition = invoke('GameServer/Bot/Population/BackgroundPartyComposition');
+const PersonaPartyPolicy = invoke('GameServer/Bot/Population/PersonaPartyPolicy');
const tank = { characterId: 1, level: 15, party: { role: 'tank' } };
const healer = { characterId: 2, level: 15, party: { role: 'healer' } };
@@ -13,6 +14,10 @@ const strangerBuffer = { characterId: 4, level: 15, party: { role: 'buffer' } };
const history = PartyAffinity.recordRun(tank, [tank, healer], 100);
assert.deepStrictEqual(history, { 2: { runs: 1, lastGroupedAt: 100 } });
assert.strictEqual(PartyAffinity.affinity(familiarBuffer, [tank, healer]), 4);
+const familiarPreference = PersonaPartyPolicy.preference(familiarBuffer, [tank, healer], { tank: 1, healer: 1 });
+const strangerPreference = PersonaPartyPolicy.preference(strangerBuffer, [tank, healer], { tank: 1, healer: 1 });
+assert(familiarPreference.score > strangerPreference.score, 'commitment must amplify a proven party history rather than replace it with random personality');
+assert(familiarPreference.reasons.includes('familiar_party'), 'party preference explanations must expose why a familiar group won');
assert.deepStrictEqual(
PartyComposition.selectRecruits([tank, healer], [strangerBuffer, familiarBuffer], { maxSize: 3 }).map((state) => state.characterId),
[3],
From ddc429e64d1f1ec12c56df2857dce014ee498dae Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:00:37 -0400
Subject: [PATCH 04/17] Refine bot persona status and party selection
---
src/GameServer/Bot/BotManager.js | 23 +++++++++++++
.../Population/BackgroundPartyComposition.js | 9 ++++--
tests/test_bot_background_party_affinity.js | 32 +++++++++++++++++++
3 files changed, 61 insertions(+), 3 deletions(-)
diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js
index 4a006dd6..91250e66 100644
--- a/src/GameServer/Bot/BotManager.js
+++ b/src/GameServer/Bot/BotManager.js
@@ -33,6 +33,19 @@ const MERCHANT_BOTS = Object.keys(MerchantConfigs).map(name => {
return { name: name, merchantConfigName: name, race: 4, sex: 0, classId: 53, face: 0, hair: 0, hairColor: 0, locX: cfg.locX, locY: cfg.locY, locZ: cfg.locZ };
});
+function personaTraits(persona) {
+ if (!persona?.traits) return { social: 'unavailable', style: 'unavailable' };
+ const pct = (name) => Math.round(Number(persona.traits[name] || 0) * 100);
+ return {
+ social: `soc ${pct('sociability')} / bond ${pct('commitment')} / help ${pct('empathy')}`,
+ style: `safe ${pct('caution')} / aim ${pct('ambition')} / lead ${pct('assertiveness')} / calm ${pct('resilience')}`
+ };
+}
+
+function personaArchetype(persona) {
+ return persona?.archetype ? String(persona.archetype).replace(/_/g, ' ') : 'unavailable';
+}
+
const merchantConfigFor = (botData, characterName) => MerchantConfigs[botData.merchantConfigName || characterName];
function isOnGiranMarketPlaza(loc = {}) {
@@ -148,6 +161,8 @@ const BotManager = {
const availability = BotAvailability.evaluate(playerSession, botSession);
const social = availability.memory ? `${availability.relationship}, trust ${availability.memory.trust}, familiarity ${availability.memory.familiarity}` : 'none';
const invite = availability.available ? 'available' : availability.reasonText;
+ const persona = status.persona;
+ const traits = personaTraits(persona);
let body = `${Html.font(status.name, Html.COLOR.title)}
`;
body += Html.statusTable([
@@ -169,6 +184,10 @@ const BotManager = {
['PvP AI', safe(pvpDecision)],
['Buffs', safe(buffs)],
['Trade', safe(trade)],
+ ['Type', safe(personaArchetype(persona))],
+ ['Drive', safe(persona?.primaryDrive || 'unavailable')],
+ ['Social', safe(traits.social)],
+ ['Style', safe(traits.style)],
['Social', safe(social)],
['Invite', safe(invite)]
]);
@@ -190,6 +209,8 @@ const BotManager = {
const lead = state.stats?.marketLead;
const wanted = state.stats?.marketWanted;
const history = Object.values(state.stats?.partyHistory || {});
+ const persona = BotPersona.generate(state);
+ const traits = personaTraits(persona);
const body = `${Html.font(state.name, Html.COLOR.title)}
` + Html.statusTable([
['Phase', 'cold'], ['Activity', safe(state.activity)], ['Level', safe(String(state.level))],
['Role', safe(state.party?.role || state.stats?.role || 'dps')], ['Region / Spot', safe(`${state.currentRegion || 'unknown'} / ${state.spotId || 'none'}`)],
@@ -197,6 +218,8 @@ const BotManager = {
['Travel', safe(travel ? `${travel.reason} -> ${travel.townName || 'field'}` : 'none')],
['Market Lead', safe(lead ? `${lead.itemName} in ${lead.town} for ${lead.price}` : 'none')],
['WTB', safe(wanted ? wanted.itemName || `Item ${wanted.itemId}` : 'none')],
+ ['Type', safe(personaArchetype(persona))], ['Drive', safe(persona?.primaryDrive || 'unavailable')],
+ ['Social', safe(traits.social)], ['Style', safe(traits.style)],
['Party Bonds', safe(`${history.length} remembered partners`)]
]) + '
' + Html.actionFooter([{ label: 'Refresh', command: `bot-status ${state.name}` }]);
playerSession.dataSendToMe(ServerResponse.npcHtml(playerSession.actor.fetchId(), Html.page(body, { title: 'Cold Bot Status' })));
diff --git a/src/GameServer/Bot/Population/BackgroundPartyComposition.js b/src/GameServer/Bot/Population/BackgroundPartyComposition.js
index 3d84b307..e3fb248e 100644
--- a/src/GameServer/Bot/Population/BackgroundPartyComposition.js
+++ b/src/GameServer/Bot/Population/BackgroundPartyComposition.js
@@ -29,14 +29,17 @@ function compareCandidate(anchor, coverage, peers = [anchor]) {
const aAffinity = PartyAffinity.affinity(a, peers);
const bAffinity = PartyAffinity.affinity(b, peers);
- const aPreference = PersonaPartyPolicy.preference(a, peers, coverage).score;
- const bPreference = PersonaPartyPolicy.preference(b, peers, coverage).score;
- if (aPreference !== bPreference) return bPreference - aPreference;
if (aAffinity !== bAffinity) return bAffinity - aAffinity;
const aDistance = Math.abs(levelOf(a) - levelOf(anchor));
const bDistance = Math.abs(levelOf(b) - levelOf(anchor));
if (aDistance !== bDistance) return aDistance - bDistance;
+
+ // Persona only distinguishes otherwise equally effective choices. It
+ // must not displace established party bonds or a tighter level match.
+ const aPreference = PersonaPartyPolicy.preference(a, peers, coverage).score;
+ const bPreference = PersonaPartyPolicy.preference(b, peers, coverage).score;
+ if (aPreference !== bPreference) return bPreference - aPreference;
return Number(a.characterId || 0) - Number(b.characterId || 0);
};
}
diff --git a/tests/test_bot_background_party_affinity.js b/tests/test_bot_background_party_affinity.js
index 60256244..b9168d53 100644
--- a/tests/test_bot_background_party_affinity.js
+++ b/tests/test_bot_background_party_affinity.js
@@ -24,6 +24,38 @@ assert.deepStrictEqual(
'a familiar bot should win between otherwise equal candidates'
);
+const personaCandidates = Array.from({ length: 100 }, (_, index) => ({
+ characterId: 1000 + index,
+ level: 15,
+ party: { role: 'buffer' }
+}));
+const byPersonaPreference = personaCandidates.slice().sort((a, b) => (
+ PersonaPartyPolicy.preference(a, [tank, healer], { tank: 1, healer: 1 }).score
+ - PersonaPartyPolicy.preference(b, [tank, healer], { tank: 1, healer: 1 }).score
+));
+const lowPreferenceFamiliar = {
+ ...byPersonaPreference[0],
+ stats: { partyHistory: { 1: { runs: 1, lastGroupedAt: 1 } } }
+};
+const highPreferenceStranger = byPersonaPreference.at(-1);
+assert(
+ PersonaPartyPolicy.preference(highPreferenceStranger, [tank, healer], { tank: 1, healer: 1 }).score
+ > PersonaPartyPolicy.preference(lowPreferenceFamiliar, [tank, healer], { tank: 1, healer: 1 }).score,
+ 'the test needs contrasting persona preferences'
+);
+assert.deepStrictEqual(
+ PartyComposition.selectRecruits([tank, healer], [highPreferenceStranger, lowPreferenceFamiliar], { maxSize: 3 }).map((state) => state.characterId),
+ [lowPreferenceFamiliar.characterId],
+ 'a proven party bond must outrank a stronger persona preference'
+);
+const lowPreferenceNear = { ...byPersonaPreference[0], level: 15 };
+const highPreferenceFar = { ...byPersonaPreference.at(-1), level: 19 };
+assert.deepStrictEqual(
+ PartyComposition.selectRecruits([tank, healer], [highPreferenceFar, lowPreferenceNear], { maxSize: 3 }).map((state) => state.characterId),
+ [lowPreferenceNear.characterId],
+ 'a closer level match must outrank a stronger persona preference'
+);
+
const crowdedHistory = Object.fromEntries(Array.from({ length: 20 }, (_, index) => [
String(index + 100),
{ runs: index === 0 ? 50 : 1, lastGroupedAt: index === 0 ? 1 : 1000 + index }
From 4f1cc677fbc273c3cfc645ede7981d97316a54f1 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 07:28:13 -0400
Subject: [PATCH 05/17] Use personas for bot party invitations
---
src/GameServer/Bot/AI/BotAvailability.js | 20 +++++-
.../Bot/AI/PersonaPartyDecisionPolicy.js | 67 +++++++++++++++++++
src/GameServer/World/World.js | 9 ++-
tests/test_bot_availability.js | 19 ++++++
tests/test_bot_persona_party_decision.js | 34 ++++++++++
5 files changed, 145 insertions(+), 4 deletions(-)
create mode 100644 src/GameServer/Bot/AI/PersonaPartyDecisionPolicy.js
create mode 100644 tests/test_bot_persona_party_decision.js
diff --git a/src/GameServer/Bot/AI/BotAvailability.js b/src/GameServer/Bot/AI/BotAvailability.js
index 560891d5..fb8b5b78 100644
--- a/src/GameServer/Bot/AI/BotAvailability.js
+++ b/src/GameServer/Bot/AI/BotAvailability.js
@@ -1,4 +1,5 @@
const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory');
+const PersonaPartyDecisionPolicy = invoke('GameServer/Bot/AI/PersonaPartyDecisionPolicy');
const SpeckMath = invoke('GameServer/SpeckMath');
const Config = invoke('GameServer/Bot/Population/PopulationConfig');
@@ -30,6 +31,7 @@ function reasonText(reason) {
low_trust: 'low trust',
recently_abandoned: 'recently abandoned',
level_gap_too_large: 'level gap too large',
+ prefers_solo: 'prefers a solo run for now',
hunting_target: 'busy fighting'
};
return text[reason] || reason;
@@ -84,9 +86,16 @@ const BotAvailability = {
else if (result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
else if (Math.abs(bot.fetchLevel() - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';
+ if (reason === 'available' && !result.clanmate) {
+ result.partyDecision = PersonaPartyDecisionPolicy.evaluate(botSession, result.memory);
+ if (!result.partyDecision.accept) {
+ reason = result.partyDecision.reason;
+ }
+ }
result.available = reason === 'available';
result.reason = reason;
- result.reasonText = reasonText(reason);
+ result.reasonText = result.partyDecision?.reason === reason
+ ? result.partyDecision.reasonText : reasonText(reason);
return result;
},
@@ -108,9 +117,16 @@ const BotAvailability = {
else if (result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
else if (Math.abs(Number(state.level || 1) - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';
+ if (reason === 'available' && !result.clanmate) {
+ result.partyDecision = PersonaPartyDecisionPolicy.evaluate(state, result.memory);
+ if (!result.partyDecision.accept) {
+ reason = result.partyDecision.reason;
+ }
+ }
result.available = reason === 'available';
result.reason = reason;
- result.reasonText = reasonText(reason);
+ result.reasonText = result.partyDecision?.reason === reason
+ ? result.partyDecision.reasonText : reasonText(reason);
return result;
},
diff --git a/src/GameServer/Bot/AI/PersonaPartyDecisionPolicy.js b/src/GameServer/Bot/AI/PersonaPartyDecisionPolicy.js
new file mode 100644
index 00000000..549f7d28
--- /dev/null
+++ b/src/GameServer/Bot/AI/PersonaPartyDecisionPolicy.js
@@ -0,0 +1,67 @@
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
+
+const ACCEPT_SCORE = 45;
+
+function personaFor(subject = {}) {
+ if (subject?.persona?.traits) return subject.persona;
+ const characterId = Number(subject?.characterId || subject?.id || subject?.actor?.fetchId?.() || 0);
+ return BotPersona.generate({ ...subject, characterId });
+}
+
+function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, Number(value) || 0));
+}
+
+function evaluate(subject, memory = {}) {
+ const persona = personaFor(subject);
+ if (!persona?.traits) {
+ return { accept: true, reason: 'available', reasonText: 'available', score: null, persona: null };
+ }
+
+ const traits = persona.traits;
+ const trust = Number(memory.trust || 0);
+ const familiarity = Number(memory.familiarity || 0);
+ const knownPartner = trust >= 3 || familiarity >= 5;
+ const driveBonus = persona.primaryDrive === 'social' ? 18
+ : persona.primaryDrive === 'progression' ? 6 : -12;
+ const score = Math.round(clamp(
+ traits.sociability * 60 +
+ traits.empathy * 10 +
+ traits.commitment * 10 +
+ driveBonus +
+ trust * 4 +
+ familiarity * 1.5,
+ 0,
+ 100
+ ));
+ const accept = knownPartner || score >= ACCEPT_SCORE;
+
+ if (accept) {
+ return {
+ accept: true,
+ reason: 'available',
+ reasonText: 'available',
+ score,
+ persona
+ };
+ }
+
+ return {
+ accept: false,
+ reason: 'prefers_solo',
+ reasonText: 'prefers a solo run for now',
+ score,
+ persona
+ };
+}
+
+function reply(decision) {
+ if (!decision?.accept) {
+ return 'I am keeping this run focused for now. Let us get to know each other first.';
+ }
+ if (decision.persona?.primaryDrive === 'social') return 'Gladly. A steady party is better than going alone.';
+ if (decision.persona?.primaryDrive === 'wealth') return 'I can make time for a familiar partner. Let us make the run count.';
+ return 'A good party will help the next run. I am in.';
+}
+
+module.exports = { ACCEPT_SCORE, evaluate, reply };
diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js
index e1906a01..4edd1b17 100644
--- a/src/GameServer/World/World.js
+++ b/src/GameServer/World/World.js
@@ -147,6 +147,7 @@ const World = {
const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability');
const BotManager = invoke('GameServer/Bot/BotManager');
const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory');
+ const PersonaPartyDecisionPolicy = invoke('GameServer/Bot/AI/PersonaPartyDecisionPolicy');
const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService');
const availability = BotAvailability.evaluate(session, targetSession);
const bot = targetSession.actor;
@@ -156,7 +157,9 @@ const World = {
if (!availability.available) {
BotSocialMemory.recordEvent(session, targetSession, 'party_refused', availability.reason);
session.dataSendToMe(ServerResponse.joinParty(0));
- BotManager.botTell(targetSession, session, `I can't join right now: ${availability.reasonText}.`);
+ BotManager.botTell(targetSession, session, availability.partyDecision
+ ? PersonaPartyDecisionPolicy.reply(availability.partyDecision)
+ : `I can't join right now: ${availability.reasonText}.`);
console.info(
'BotParty :: %s refused %s: %s distance=%s',
bot?.fetchName() || 'unknown',
@@ -184,7 +187,9 @@ const World = {
BotManager.botTell(
targetSession,
session,
- `I'm with you. Lead the way.`
+ availability.partyDecision
+ ? PersonaPartyDecisionPolicy.reply(availability.partyDecision)
+ : `I'm with you. Lead the way.`
);
}, 1000);
return true;
diff --git a/tests/test_bot_availability.js b/tests/test_bot_availability.js
index 9341cad3..651113f1 100644
--- a/tests/test_bot_availability.js
+++ b/tests/test_bot_availability.js
@@ -94,6 +94,25 @@ try {
assert.strictEqual(result.available, false, 'far cold bot should obey the same invite range as a hot bot');
assert.strictEqual(result.reason, 'too_far');
+ const socialBot = session(actor(2000012, 20), {
+ persona: { primaryDrive: 'social', traits: { sociability: 0.80, empathy: 0.80, commitment: 0.70 } }
+ });
+ result = BotAvailability.evaluate(lowPlayer, socialBot);
+ assert.strictEqual(result.available, true, 'a social persona should remain available after hard invite checks pass');
+
+ const soloBot = session(actor(2000013, 20), {
+ persona: { primaryDrive: 'wealth', traits: { sociability: 0.30, empathy: 0.35, commitment: 0.45 } }
+ });
+ result = BotAvailability.evaluate(lowPlayer, soloBot);
+ assert.strictEqual(result.available, false, 'a reserved persona may decline after all hard checks pass');
+ assert.strictEqual(result.reason, 'prefers_solo');
+
+ const farSocialBot = session(actor(2000014, 20, 0, { locX: 100000 }), {
+ persona: { primaryDrive: 'social', traits: { sociability: 0.80, empathy: 0.80, commitment: 0.70 } }
+ });
+ result = BotAvailability.evaluate(lowPlayer, farSocialBot);
+ assert.strictEqual(result.reason, 'too_far', 'persona must not override a hard invite gate');
+
console.log('Bot availability checks passed');
} finally {
BotSocialMemory.getSnapshot = originalGetSnapshot;
diff --git a/tests/test_bot_persona_party_decision.js b/tests/test_bot_persona_party_decision.js
new file mode 100644
index 00000000..0d06df73
--- /dev/null
+++ b/tests/test_bot_persona_party_decision.js
@@ -0,0 +1,34 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const Policy = invoke('GameServer/Bot/AI/PersonaPartyDecisionPolicy');
+
+function subject(persona) {
+ return { characterId: 1, persona };
+}
+
+const social = {
+ primaryDrive: 'social',
+ traits: { sociability: 0.80, empathy: 0.80, commitment: 0.70 }
+};
+const wealth = {
+ primaryDrive: 'wealth',
+ traits: { sociability: 0.30, empathy: 0.35, commitment: 0.45 }
+};
+
+const socialDecision = Policy.evaluate(subject(social), { trust: 0, familiarity: 0 });
+assert.strictEqual(socialDecision.accept, true, 'a social persona should welcome a first party invite');
+
+const soloDecision = Policy.evaluate(subject(wealth), { trust: 0, familiarity: 0 });
+assert.strictEqual(soloDecision.accept, false, 'a reserved wealth-focused stranger should be allowed to prefer a solo run');
+assert.strictEqual(soloDecision.reason, 'prefers_solo');
+
+const knownPartnerDecision = Policy.evaluate(subject(wealth), { trust: 3, familiarity: 0 });
+assert.strictEqual(knownPartnerDecision.accept, true, 'a known partner must override the solo preference');
+assert(Policy.reply(soloDecision).includes('get to know'), 'a refusal should explain how the player can improve the relationship');
+
+const hotFallback = Policy.evaluate({ actor: { fetchId: () => 42 } }, { trust: 0, familiarity: 0 });
+assert.strictEqual(hotFallback.persona.characterId, 42, 'a newly spawned hot bot must use its actor id before async persona loading finishes');
+
+console.log('Bot persona party decision checks passed');
From e41f0f878ec59604e1692d820f413d7e9a8feabd Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 07:29:16 -0400
Subject: [PATCH 06/17] Apply personas to remote bot chat
---
src/GameServer/Bot/AI/BotRemoteChat.js | 32 +++++++++++++++++++++++---
tests/test_bot_remote_chat_persona.js | 23 ++++++++++++++++++
2 files changed, 52 insertions(+), 3 deletions(-)
create mode 100644 tests/test_bot_remote_chat_persona.js
diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js
index d47c5c27..53d42675 100644
--- a/src/GameServer/Bot/AI/BotRemoteChat.js
+++ b/src/GameServer/Bot/AI/BotRemoteChat.js
@@ -1,6 +1,7 @@
const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability');
const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory');
const LifeEvents = invoke('GameServer/Bot/Population/BotLifeEvents');
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
const cooldowns = new Map();
@@ -62,7 +63,19 @@ function stateSummary(state) {
partyId: state.party?.partyId || null,
role: state.party?.role || state.stats?.role || 'dps',
lastReason: state.stats?.lastReason || null,
- newbieAnchor: !!state.stats?.newbieAnchor
+ newbieAnchor: !!state.stats?.newbieAnchor,
+ persona: personaForState(state)
+ };
+}
+
+function personaForState(state) {
+ const persona = BotPersona.generate(state);
+ if (!persona) return null;
+ return {
+ primaryDrive: persona.primaryDrive,
+ archetype: persona.archetype,
+ traits: { ...persona.traits },
+ textCard: persona.textCard
};
}
@@ -79,6 +92,7 @@ function fallbackReply(state, availability, text) {
const activity = state?.activity || 'hunting';
const hpPct = state?.vitals?.maxHp ? Math.round((state.vitals.hp / state.vitals.maxHp) * 100) : null;
const lower = String(text || '').toLowerCase();
+ const persona = personaForState(state);
if (availability?.reason === 'low_trust') {
return `I hear you, but I don't trust you enough yet.`;
@@ -86,6 +100,11 @@ function fallbackReply(state, availability, text) {
if (availability?.reason === 'recently_abandoned') {
return `Not now. Last party ended badly.`;
}
+ if (availability?.reason === 'prefers_solo') {
+ return persona?.primaryDrive === 'wealth'
+ ? `I'm keeping this run focused on work for now. Let us get to know each other first.`
+ : `I prefer a quiet solo run for now. Let us get to know each other first.`;
+ }
if (activity === 'dead' || availability?.reason === 'bot_dead') {
return `I died out here. Running back from town when I can.`;
}
@@ -93,6 +112,12 @@ function fallbackReply(state, availability, text) {
return `I'm recovering for a bit, HP is around ${hpPct ?? 'low'}%.`;
}
if (lower.includes('party') || lower.includes('пати') || lower.includes('invite')) {
+ if (availability?.available && persona?.primaryDrive === 'social') {
+ return `I am open to a steady party. Invite me by name near ${state?.homeRegion || 'my spot'}.`;
+ }
+ if (availability?.available && persona?.primaryDrive === 'wealth') {
+ return `If it is a practical run, invite me by name near ${state?.homeRegion || 'my spot'}.`;
+ }
return `Invite me by name if you want, I'm near ${state?.homeRegion || 'my spot'}.`;
}
if (lower.includes('where') || lower.includes('где')) {
@@ -132,7 +157,8 @@ function schema() {
function systemPrompt() {
return [
'You are replying as one Lineage 2 bot in private chat.',
- 'Use only the provided state, social memory, availability, and life events.',
+ 'Use only the provided state, persona, social memory, availability, and life events.',
+ 'The persona shapes tone and high-level preferences, never facts, safety, or available actions.',
'Do not invent items, rewards, locations, levels, party membership, or combat results.',
'Keep the reply short, grounded, and in character.',
'You may express a high-level intent, but server code decides all real actions.'
@@ -271,4 +297,4 @@ const BotRemoteChat = {
}
};
-module.exports = BotRemoteChat;
+module.exports = { ...BotRemoteChat, personaForState, fallbackReply };
diff --git a/tests/test_bot_remote_chat_persona.js b/tests/test_bot_remote_chat_persona.js
new file mode 100644
index 00000000..021b41d8
--- /dev/null
+++ b/tests/test_bot_remote_chat_persona.js
@@ -0,0 +1,23 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const BotRemoteChat = invoke('GameServer/Bot/AI/BotRemoteChat');
+
+const state = {
+ characterId: 7001,
+ name: 'RemotePersonaBot',
+ stats: { generatedIndex: 17 },
+ homeRegion: 'Talking Island',
+ vitals: { hp: 100, maxHp: 100 }
+};
+
+const first = BotRemoteChat.personaForState(state);
+const second = BotRemoteChat.personaForState(state);
+assert.deepStrictEqual(first, second, 'remote chat must use the same deterministic persona on every reply');
+assert(first?.primaryDrive && first?.archetype && first?.textCard, 'remote chat context must include a complete persona card');
+
+const soloReply = BotRemoteChat.fallbackReply(state, { available: false, reason: 'prefers_solo' }, 'party?');
+assert(soloReply.includes('get to know'), 'fallback refusal must explain the social path forward');
+
+console.log('Bot remote chat persona checks passed');
From e4a64490d9359f386d8b03bf485b9ed01d37cf8c Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 07:32:38 -0400
Subject: [PATCH 07/17] Let personas choose solo or party simulation
---
.../Bot/Population/PersonaPartyPolicy.js | 36 +++++++++++++++++--
.../Bot/Population/PopulationService.js | 11 +++---
tests/test_bot_persona_background_intent.js | 35 ++++++++++++++++++
3 files changed, 75 insertions(+), 7 deletions(-)
create mode 100644 tests/test_bot_persona_background_intent.js
diff --git a/src/GameServer/Bot/Population/PersonaPartyPolicy.js b/src/GameServer/Bot/Population/PersonaPartyPolicy.js
index 7243cee5..554ac77f 100644
--- a/src/GameServer/Bot/Population/PersonaPartyPolicy.js
+++ b/src/GameServer/Bot/Population/PersonaPartyPolicy.js
@@ -6,7 +6,35 @@ function supportCount(coverage = {}) {
}
function profileFor(state) {
- return BotPersona.generate(state);
+ return state?.persona?.traits ? state.persona : BotPersona.generate(state);
+}
+
+function backgroundIntent(state = {}) {
+ const persona = profileFor(state);
+ if (!persona) return { accept: true, reason: 'no_persona', score: null, persona: null };
+ if (state.activity === 'party_wait') {
+ return { accept: true, reason: 'goal_requires_party', score: 100, persona };
+ }
+
+ const traits = persona.traits;
+ const establishedBond = Object.values(state.stats?.partyHistory || {})
+ .some((entry) => Number(entry?.runs || 0) >= 3);
+ const driveBonus = persona.primaryDrive === 'social' ? 18
+ : persona.primaryDrive === 'progression' ? 4 : -8;
+ const score = Math.round(
+ traits.sociability * 55 +
+ traits.commitment * 25 +
+ traits.empathy * 10 +
+ driveBonus
+ );
+ const accept = establishedBond || score >= 45;
+ return {
+ accept,
+ reason: establishedBond ? 'established_party_bonds'
+ : accept ? 'open_to_party' : 'prefers_solo',
+ score,
+ persona
+ };
}
function preference(state, peers = [], coverage = {}) {
@@ -33,12 +61,14 @@ function preference(state, peers = [], coverage = {}) {
function explain(state, peers = [], coverage = {}) {
const result = preference(state, peers, coverage);
+ const intent = backgroundIntent(state);
return {
score: result.score,
reasons: result.reasons,
primaryDrive: result.persona?.primaryDrive || null,
- archetype: result.persona?.archetype || null
+ archetype: result.persona?.archetype || null,
+ partyIntent: intent.reason
};
}
-module.exports = { preference, explain };
+module.exports = { backgroundIntent, preference, explain };
diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js
index 519f998c..25d3c62c 100644
--- a/src/GameServer/Bot/Population/PopulationService.js
+++ b/src/GameServer/Bot/Population/PopulationService.js
@@ -669,11 +669,14 @@ const PopulationService = {
? { states: partyWaitStates, partyWaitBacklog: true }
: LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit)
.then((states) => ({ states, partyWaitBacklog: false }))))
- .then(({ states, partyWaitBacklog }) => this.reclaimBackgroundPartyCapacity(partyWaitBacklog ? states : [])
- .then(() => this.recruitBackgroundMembers(states)).then((recruitedIds) => ({
- states: states.filter((state) => !recruitedIds.has(Number(state.characterId))),
+ .then(({ states, partyWaitBacklog }) => {
+ const willingStates = states.filter((state) => PersonaPartyPolicy.backgroundIntent(state).accept);
+ return this.reclaimBackgroundPartyCapacity(partyWaitBacklog ? willingStates : [])
+ .then(() => this.recruitBackgroundMembers(willingStates)).then((recruitedIds) => ({
+ states: willingStates.filter((state) => !recruitedIds.has(Number(state.characterId))),
partyWaitBacklog
- })))
+ }));
+ })
.then(({ states, partyWaitBacklog }) => {
const activeParties = BackgroundPartyState.counts().active || 0;
const slots = Math.max(0, Config.maxBackgroundParties - activeParties);
diff --git a/tests/test_bot_persona_background_intent.js b/tests/test_bot_persona_background_intent.js
new file mode 100644
index 00000000..80642f09
--- /dev/null
+++ b/tests/test_bot_persona_background_intent.js
@@ -0,0 +1,35 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const PersonaPartyPolicy = invoke('GameServer/Bot/Population/PersonaPartyPolicy');
+
+function state(persona, extras = {}) {
+ return { characterId: 1, persona, stats: {}, ...extras };
+}
+
+const social = {
+ primaryDrive: 'social',
+ archetype: 'party_regular',
+ traits: { sociability: 0.80, commitment: 0.70, empathy: 0.70 }
+};
+const soloWealth = {
+ primaryDrive: 'wealth',
+ archetype: 'pragmatic_earner',
+ traits: { sociability: 0.30, commitment: 0.40, empathy: 0.35 }
+};
+
+assert.strictEqual(PersonaPartyPolicy.backgroundIntent(state(social)).accept, true, 'a social bot should volunteer for background parties');
+assert.strictEqual(PersonaPartyPolicy.backgroundIntent(state(soloWealth)).accept, false, 'a reserved wealth bot should stay solo by default');
+assert.strictEqual(
+ PersonaPartyPolicy.backgroundIntent(state(soloWealth, { activity: 'party_wait' })).reason,
+ 'goal_requires_party',
+ 'a goal that requires a party must override the solo preference'
+);
+assert.strictEqual(
+ PersonaPartyPolicy.backgroundIntent(state(soloWealth, { stats: { partyHistory: { 2: { runs: 3 } } } })).reason,
+ 'established_party_bonds',
+ 'proven background bonds must override the solo preference'
+);
+
+console.log('Bot persona background intent checks passed');
From 9982e87e5d9f085cf9e98d24f028e20089901639 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 07:40:21 -0400
Subject: [PATCH 08/17] Use wealth personas for market goals
---
src/GameServer/Bot/BotManager.js | 5 ++-
.../Bot/Economy/PersonaEconomicPolicy.js | 42 +++++++++++++++++++
src/GameServer/Bot/Goals/NeedsEvaluator.js | 20 +++++++--
tests/test_bot_goal_planner.js | 12 ++++++
tests/test_bot_persona_economic_policy.js | 27 ++++++++++++
5 files changed, 101 insertions(+), 5 deletions(-)
create mode 100644 src/GameServer/Bot/Economy/PersonaEconomicPolicy.js
create mode 100644 tests/test_bot_persona_economic_policy.js
diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js
index 91250e66..02bac5fa 100644
--- a/src/GameServer/Bot/BotManager.js
+++ b/src/GameServer/Bot/BotManager.js
@@ -211,10 +211,13 @@ const BotManager = {
const history = Object.values(state.stats?.partyHistory || {});
const persona = BotPersona.generate(state);
const traits = personaTraits(persona);
+ const goalLabel = !goal ? 'none' : goal.plan?.personaDrive === 'wealth'
+ ? `${goal.type}: wealth / ${goal.target?.focusItem?.itemName || 'best surplus'}`
+ : `${goal.type}: ${goal.plan?.expectedBenefit || 'active'}`;
const body = `${Html.font(state.name, Html.COLOR.title)}
` + Html.statusTable([
['Phase', 'cold'], ['Activity', safe(state.activity)], ['Level', safe(String(state.level))],
['Role', safe(state.party?.role || state.stats?.role || 'dps')], ['Region / Spot', safe(`${state.currentRegion || 'unknown'} / ${state.spotId || 'none'}`)],
- ['Party', safe(state.party?.partyId || 'none')], ['Goal', safe(goal ? `${goal.type}: ${goal.plan?.expectedBenefit || 'active'}` : 'none')],
+ ['Party', safe(state.party?.partyId || 'none')], ['Goal', safe(goalLabel)],
['Travel', safe(travel ? `${travel.reason} -> ${travel.townName || 'field'}` : 'none')],
['Market Lead', safe(lead ? `${lead.itemName} in ${lead.town} for ${lead.price}` : 'none')],
['WTB', safe(wanted ? wanted.itemName || `Item ${wanted.itemId}` : 'none')],
diff --git a/src/GameServer/Bot/Economy/PersonaEconomicPolicy.js b/src/GameServer/Bot/Economy/PersonaEconomicPolicy.js
new file mode 100644
index 00000000..4f693460
--- /dev/null
+++ b/src/GameServer/Bot/Economy/PersonaEconomicPolicy.js
@@ -0,0 +1,42 @@
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
+
+const EARLY_SALE_ITEM_COUNT = 2;
+const EARLY_SALE_VALUE = 600;
+const WEALTH_SALE_PRIORITY_BONUS = 12;
+
+function personaFor(state = {}) {
+ return state?.persona?.traits ? state.persona : BotPersona.generate(state);
+}
+
+function wealthSaleOpportunity(state = {}, sale = {}) {
+ const persona = personaFor(state);
+ if (persona?.primaryDrive !== 'wealth') return null;
+
+ const focus = sale.items?.[0] || null;
+ if (!focus) return null;
+ const focusValue = Number(focus.count || 0) * Number(focus.price || 0);
+ const qualifying = Number(sale.itemCount || 0) >= EARLY_SALE_ITEM_COUNT
+ || Number(sale.marketValue || 0) >= EARLY_SALE_VALUE
+ || focusValue >= EARLY_SALE_VALUE;
+ if (!qualifying) return null;
+
+ return {
+ priorityBonus: WEALTH_SALE_PRIORITY_BONUS,
+ reason: 'liquidate_best_surplus',
+ focus: {
+ itemId: Number(focus.selfId || 0),
+ itemName: focus.name || `Item ${focus.selfId}`,
+ count: Number(focus.count || 0),
+ unitPrice: Number(focus.price || 0),
+ value: focusValue
+ },
+ persona
+ };
+}
+
+module.exports = {
+ EARLY_SALE_ITEM_COUNT,
+ EARLY_SALE_VALUE,
+ WEALTH_SALE_PRIORITY_BONUS,
+ wealthSaleOpportunity
+};
diff --git a/src/GameServer/Bot/Goals/NeedsEvaluator.js b/src/GameServer/Bot/Goals/NeedsEvaluator.js
index 47065078..e9c7cf20 100644
--- a/src/GameServer/Bot/Goals/NeedsEvaluator.js
+++ b/src/GameServer/Bot/Goals/NeedsEvaluator.js
@@ -2,6 +2,7 @@ const BotGear = invoke('GameServer/Bot/AI/BotGear');
const DataCache = invoke('GameServer/DataCache');
const ItemDisposition = invoke('GameServer/Bot/Economy/ItemDisposition');
const GearLifecycle = invoke('GameServer/Bot/AI/GearLifecycle');
+const PersonaEconomicPolicy = invoke('GameServer/Bot/Economy/PersonaEconomicPolicy');
const RANK_ORDER = ['none', 'd', 'c', 'b', 'a', 's'];
// Weapons make the largest immediate difference, then core armour. The two
@@ -186,16 +187,27 @@ function evaluate(state = {}, options = {}) {
}
const sale = ItemDisposition.saleSummary(state);
- if (sale.itemCount >= 3 || sale.marketValue >= 1000) {
+ const wealthSale = PersonaEconomicPolicy.wealthSaleOpportunity(state, sale);
+ if (sale.itemCount >= 3 || sale.marketValue >= 1000 || wealthSale) {
candidates.push({
type: 'sell_inventory',
// A full bag is capital, not a reason to keep grinding with no
// adena. Recovery and death still win, but an equipped bot with
// useful surplus should reach the market before another generic
// earn-adena / upgrade-funding loop.
- priority: 74,
- target: { itemCount: sale.itemCount, marketValue: sale.marketValue },
- plan: { kind: 'market_sell', expectedBenefit: 'market_sale_inventory', risk: 0 },
+ priority: 74 + Number(wealthSale?.priorityBonus || 0),
+ target: {
+ itemCount: sale.itemCount,
+ marketValue: sale.marketValue,
+ focusItem: wealthSale?.focus || null
+ },
+ plan: {
+ kind: 'market_sell',
+ expectedBenefit: 'market_sale_inventory',
+ risk: 0,
+ personaDrive: wealthSale ? 'wealth' : null,
+ personaReason: wealthSale?.reason || null
+ },
blockers: [],
nextReviewAt: timestamp + 10 * 60 * 1000
});
diff --git a/tests/test_bot_goal_planner.js b/tests/test_bot_goal_planner.js
index 601be4af..0dec8597 100644
--- a/tests/test_bot_goal_planner.js
+++ b/tests/test_bot_goal_planner.js
@@ -112,6 +112,18 @@ const saleGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({
assert.strictEqual(saleGoal.type, 'sell_inventory');
assert.strictEqual(saleGoal.plan.expectedBenefit, 'market_sale_inventory');
+const wealthSaleGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({
+ ...base,
+ persona: { primaryDrive: 'wealth', traits: {} },
+ inventory: {
+ 1864: { selfId: 1864, name: 'Stem', amount: 12, kind: 'Other.Material' }
+ }
+}, { spot, now: timestamp }), timestamp);
+assert.strictEqual(wealthSaleGoal.type, 'sell_inventory');
+assert.strictEqual(wealthSaleGoal.priority, 86, 'wealth drive should prioritize a real market opportunity');
+assert.strictEqual(wealthSaleGoal.plan.personaDrive, 'wealth');
+assert.strictEqual(wealthSaleGoal.target.focusItem.itemId, 1864);
+
const poorSellerGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({
...base,
adena: 50,
diff --git a/tests/test_bot_persona_economic_policy.js b/tests/test_bot_persona_economic_policy.js
new file mode 100644
index 00000000..e7bdec9c
--- /dev/null
+++ b/tests/test_bot_persona_economic_policy.js
@@ -0,0 +1,27 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const Policy = invoke('GameServer/Bot/Economy/PersonaEconomicPolicy');
+
+const wealthState = {
+ characterId: 1,
+ persona: { primaryDrive: 'wealth', traits: {} }
+};
+const sale = {
+ itemCount: 2,
+ marketValue: 550,
+ items: [{ selfId: 1864, name: 'Stem', count: 2, price: 275 }]
+};
+const opportunity = Policy.wealthSaleOpportunity(wealthState, sale);
+assert(opportunity, 'a wealth bot should notice a small but concrete sell opportunity');
+assert.strictEqual(opportunity.priorityBonus, Policy.WEALTH_SALE_PRIORITY_BONUS);
+assert.deepStrictEqual(opportunity.focus, { itemId: 1864, itemName: 'Stem', count: 2, unitPrice: 275, value: 550 });
+
+assert.strictEqual(
+ Policy.wealthSaleOpportunity({ characterId: 2, persona: { primaryDrive: 'progression', traits: {} } }, sale),
+ null,
+ 'non-wealth bots must keep the normal sale threshold'
+);
+
+console.log('Bot persona economic policy checks passed');
From fdeec39ec7a22e9c7e451d4804b44accd126243e Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 09:13:50 -0400
Subject: [PATCH 09/17] Refine wealth bot sale goals
---
scripts/run-tests.js | 4 +++
.../Bot/Economy/PersonaEconomicPolicy.js | 11 +++++--
tests/test_bot_persona_economic_policy.js | 30 +++++++++++++++++--
3 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/scripts/run-tests.js b/scripts/run-tests.js
index f1617912..32bbb7fc 100644
--- a/scripts/run-tests.js
+++ b/scripts/run-tests.js
@@ -24,6 +24,10 @@ const tests = [
'tests/test_population_starter_party_grouping.js',
'tests/test_bot_goal_state.js',
'tests/test_bot_persona.js',
+ 'tests/test_bot_persona_background_intent.js',
+ 'tests/test_bot_persona_economic_policy.js',
+ 'tests/test_bot_persona_party_decision.js',
+ 'tests/test_bot_remote_chat_persona.js',
'tests/test_bot_goal_planner.js',
'tests/test_bot_goal_market_priority.js',
'tests/test_bot_market_goal_reconcile.js',
diff --git a/src/GameServer/Bot/Economy/PersonaEconomicPolicy.js b/src/GameServer/Bot/Economy/PersonaEconomicPolicy.js
index 4f693460..f82fa4e4 100644
--- a/src/GameServer/Bot/Economy/PersonaEconomicPolicy.js
+++ b/src/GameServer/Bot/Economy/PersonaEconomicPolicy.js
@@ -12,12 +12,17 @@ function wealthSaleOpportunity(state = {}, sale = {}) {
const persona = personaFor(state);
if (persona?.primaryDrive !== 'wealth') return null;
- const focus = sale.items?.[0] || null;
+ const focus = (sale.items || []).reduce((best, item) => {
+ const value = Number(item?.count || 0) * Number(item?.price || 0);
+ const bestValue = Number(best?.count || 0) * Number(best?.price || 0);
+ return value > bestValue || (value === bestValue && Number(item?.selfId || 0) < Number(best?.selfId || 0))
+ ? item
+ : best;
+ }, null);
if (!focus) return null;
const focusValue = Number(focus.count || 0) * Number(focus.price || 0);
const qualifying = Number(sale.itemCount || 0) >= EARLY_SALE_ITEM_COUNT
- || Number(sale.marketValue || 0) >= EARLY_SALE_VALUE
- || focusValue >= EARLY_SALE_VALUE;
+ && Number(sale.marketValue || 0) >= EARLY_SALE_VALUE;
if (!qualifying) return null;
return {
diff --git a/tests/test_bot_persona_economic_policy.js b/tests/test_bot_persona_economic_policy.js
index e7bdec9c..4a51c6f2 100644
--- a/tests/test_bot_persona_economic_policy.js
+++ b/tests/test_bot_persona_economic_policy.js
@@ -10,14 +10,40 @@ const wealthState = {
};
const sale = {
itemCount: 2,
- marketValue: 550,
- items: [{ selfId: 1864, name: 'Stem', count: 2, price: 275 }]
+ marketValue: 650,
+ items: [
+ { selfId: 1864, name: 'Stem', count: 2, price: 275 },
+ { selfId: 1865, name: 'Animal Bone', count: 10, price: 10 }
+ ]
};
const opportunity = Policy.wealthSaleOpportunity(wealthState, sale);
assert(opportunity, 'a wealth bot should notice a small but concrete sell opportunity');
assert.strictEqual(opportunity.priorityBonus, Policy.WEALTH_SALE_PRIORITY_BONUS);
assert.deepStrictEqual(opportunity.focus, { itemId: 1864, itemName: 'Stem', count: 2, unitPrice: 275, value: 550 });
+assert.strictEqual(
+ Policy.wealthSaleOpportunity(wealthState, {
+ itemCount: 2,
+ marketValue: 20,
+ items: [{ selfId: 1865, name: 'Animal Bone', count: 2, price: 10 }]
+ }),
+ null,
+ 'two trivial items must not trigger a market trip'
+);
+
+assert.deepStrictEqual(
+ Policy.wealthSaleOpportunity(wealthState, {
+ itemCount: 3,
+ marketValue: 700,
+ items: [
+ { selfId: 100, name: 'Rare Fragment', count: 1, price: 300 },
+ { selfId: 101, name: 'Material Stack', count: 4, price: 100 }
+ ]
+ }).focus,
+ { itemId: 101, itemName: 'Material Stack', count: 4, unitPrice: 100, value: 400 },
+ 'the focus must be the highest total-value item, not merely the first candidate'
+);
+
assert.strictEqual(
Policy.wealthSaleOpportunity({ characterId: 2, persona: { primaryDrive: 'progression', traits: {} } }, sale),
null,
From e6388f172fe9909cf4cc26ee00ef7c041430e860 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 09:31:38 -0400
Subject: [PATCH 10/17] Add bot friendship and const party roster
---
database/sql/sqlite.sql | 18 +++++
src/GameServer/Bot/AI/BotAvailability.js | 8 +-
src/GameServer/Bot/AI/BotFriendship.js | 76 +++++++++++++++++++
src/GameServer/Bot/BotManager.js | 2 +
src/GameServer/Network/Request/Speak.js | 5 ++
.../World/Generics/NpcBypasses/BotFriends.js | 45 +++++++++++
src/GameServer/World/World.js | 23 ++++--
7 files changed, 167 insertions(+), 10 deletions(-)
create mode 100644 src/GameServer/Bot/AI/BotFriendship.js
create mode 100644 src/GameServer/World/Generics/NpcBypasses/BotFriends.js
diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql
index ca4d45dd..d756bbf4 100644
--- a/database/sql/sqlite.sql
+++ b/database/sql/sqlite.sql
@@ -220,6 +220,24 @@ CREATE TABLE IF NOT EXISTS bot_social_memory (
PRIMARY KEY(playerId, botId)
);
+CREATE TABLE IF NOT EXISTS bot_friendships (
+ playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
+ botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
+ status TEXT NOT NULL DEFAULT 'accepted',
+ createdAt INTEGER NOT NULL,
+ updatedAt INTEGER NOT NULL,
+ PRIMARY KEY (playerId, botId)
+);
+CREATE INDEX IF NOT EXISTS bot_friendships_player ON bot_friendships(playerId, status);
+
+CREATE TABLE IF NOT EXISTS bot_friend_roster (
+ playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
+ botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
+ selectedAt INTEGER NOT NULL,
+ PRIMARY KEY (playerId, botId)
+);
+CREATE INDEX IF NOT EXISTS bot_friend_roster_player ON bot_friend_roster(playerId, selectedAt);
+
CREATE TABLE IF NOT EXISTS bot_life_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
characterId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
diff --git a/src/GameServer/Bot/AI/BotAvailability.js b/src/GameServer/Bot/AI/BotAvailability.js
index fb8b5b78..cc1ca7fe 100644
--- a/src/GameServer/Bot/AI/BotAvailability.js
+++ b/src/GameServer/Bot/AI/BotAvailability.js
@@ -65,7 +65,7 @@ function emptyResult(playerSession, botSubject) {
const BotAvailability = {
inviteRange: Config.partyInviteRange,
- evaluate(playerSession, botSession) {
+ evaluate(playerSession, botSession, options = {}) {
const player = playerSession?.actor;
const bot = botSession?.actor;
const result = emptyResult(playerSession, botSession);
@@ -81,7 +81,7 @@ const BotAvailability = {
else if (bot.isDead && bot.isDead()) reason = 'bot_dead';
else if (botSession.plan === 'merchant') reason = 'merchant_duty';
else if (botSession.partyCompanion === true && botSession.followPlayerSession) reason = 'already_grouped';
- else if (result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
+ else if (!options.ignoreDistance && result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
else if (result.memory.trust <= -6) reason = 'low_trust';
else if (result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
else if (Math.abs(bot.fetchLevel() - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';
@@ -99,7 +99,7 @@ const BotAvailability = {
return result;
},
- evaluateState(playerSession, state) {
+ evaluateState(playerSession, state, options = {}) {
const player = playerSession?.actor;
const result = emptyResult(playerSession, state);
if (!player || !state) return result;
@@ -112,7 +112,7 @@ const BotAvailability = {
else if (player.isDead && player.isDead()) reason = 'player_dead';
else if (state.activity === 'dead' || Number(state.vitals?.hp || 1) <= 0) reason = 'bot_dead';
else if (state.activity === 'merchant' || state.activity === 'crafting') reason = 'merchant_duty';
- else if (result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
+ else if (!options.ignoreDistance && result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
else if (result.memory.trust <= -6) reason = 'low_trust';
else if (result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
else if (Math.abs(Number(state.level || 1) - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';
diff --git a/src/GameServer/Bot/AI/BotFriendship.js b/src/GameServer/Bot/AI/BotFriendship.js
new file mode 100644
index 00000000..8e2b96fd
--- /dev/null
+++ b/src/GameServer/Bot/AI/BotFriendship.js
@@ -0,0 +1,76 @@
+const Database = invoke('Database');
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
+
+const FRIEND_TRUST = 8;
+const MAX_CONST_MEMBERS = 8;
+const PAGE_SIZE = 12;
+
+function id(subject) { return Number(subject?.characterId || subject?.actor?.fetchId?.() || 0); }
+function page(value) { return Math.max(0, Number(value) || 0); }
+function normalize(row) {
+ let stats = {};
+ try { stats = JSON.parse(row.statsJson || '{}'); } catch {}
+ return { ...row, botId: Number(row.botId), trust: Number(row.trust || 0), familiarity: Number(row.familiarity || 0), selected: !!Number(row.selected), role: stats.role || 'dps' };
+}
+function list(playerId, where, currentPage) {
+ return Database.execute([`SELECT l.characterId AS botId, l.characterName AS name, l.level, l.activity, l.currentRegion, l.statsJson, s.trust, s.familiarity, f.status,
+ CASE WHEN r.botId IS NULL THEN 0 ELSE 1 END AS selected
+ FROM bot_social_memory s INNER JOIN bot_life_state l ON l.characterId = s.botId
+ LEFT JOIN bot_friendships f ON f.playerId = s.playerId AND f.botId = s.botId
+ LEFT JOIN bot_friend_roster r ON r.playerId = s.playerId AND r.botId = s.botId
+ WHERE s.playerId = ? AND ${where}
+ ORDER BY s.trust DESC, s.familiarity DESC, l.characterName COLLATE NOCASE LIMIT ? OFFSET ?`,
+ [playerId, PAGE_SIZE, page(currentPage) * PAGE_SIZE]
+ ]).then((rows) => rows.map(normalize));
+}
+
+const BotFriendship = {
+ FRIEND_TRUST, MAX_CONST_MEMBERS, PAGE_SIZE,
+ init() { return Database.execute(['SELECT 1 FROM bot_friendships LIMIT 1', []], 'schema:bot-friends').catch(() => null); },
+ listFriends(player, currentPage = 0) { const playerId = id(player); return playerId ? list(playerId, "f.status = 'accepted'", currentPage) : Promise.resolve([]); },
+ listCandidates(player, currentPage = 0) { const playerId = id(player); return playerId ? list(playerId, "s.trust > 0 AND (f.status IS NULL OR f.status <> 'accepted')", currentPage) : Promise.resolve([]); },
+ isFriend(player, botId) {
+ const playerId = id(player);
+ if (!playerId || !botId) return Promise.resolve(false);
+ return Database.execute(["SELECT 1 FROM bot_friendships WHERE playerId = ? AND botId = ? AND status = 'accepted'", [playerId, Number(botId)]]).then((rows) => !!rows[0]);
+ },
+ request(player, state) {
+ const playerId = id(player), botId = Number(state?.characterId || 0);
+ if (!playerId || !botId) return Promise.resolve({ ok: false, reason: 'missing_bot' });
+ return Database.execute(['SELECT * FROM bot_social_memory WHERE playerId = ? AND botId = ?', [playerId, botId]]).then((rows) => {
+ const social = rows[0] || {};
+ const accepted = Number(social.trust || 0) >= FRIEND_TRUST && Number(social.insults || 0) === 0 && !social.recentlyAbandonedAt;
+ const now = Date.now();
+ return Database.execute([`INSERT INTO bot_friendships (playerId, botId, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(playerId, botId) DO UPDATE SET status = excluded.status, updatedAt = excluded.updatedAt`, [playerId, botId, accepted ? 'accepted' : 'declined', now, now]])
+ .then(() => ({ ok: accepted, reason: accepted ? 'accepted' : 'trust_required', trust: Number(social.trust || 0), persona: BotPersona.generate(state) }));
+ });
+ },
+ toggleConst(player, botId) {
+ const playerId = id(player);
+ if (!playerId || !botId) return Promise.resolve({ ok: false, reason: 'missing_bot' });
+ return this.isFriend(player, botId).then((friend) => {
+ if (!friend) return { ok: false, reason: 'not_friend' };
+ return Database.execute(['SELECT 1 FROM bot_friend_roster WHERE playerId = ? AND botId = ?', [playerId, Number(botId)]]).then((rows) => {
+ if (rows[0]) return Database.execute(['DELETE FROM bot_friend_roster WHERE playerId = ? AND botId = ?', [playerId, Number(botId)]]).then(() => ({ ok: true, selected: false }));
+ return Database.execute(['SELECT COUNT(*) AS count FROM bot_friend_roster WHERE playerId = ?', [playerId]]).then((counts) => {
+ if (Number(counts[0]?.count || 0) >= MAX_CONST_MEMBERS) return { ok: false, reason: 'const_full' };
+ return Database.execute(['INSERT INTO bot_friend_roster (playerId, botId, selectedAt) VALUES (?, ?, ?)', [playerId, Number(botId), Date.now()]]).then(() => ({ ok: true, selected: true }));
+ });
+ });
+ });
+ },
+ selected(player) {
+ const playerId = id(player);
+ if (!playerId) return Promise.resolve([]);
+ return Database.execute([`SELECT l.* FROM bot_friend_roster r INNER JOIN bot_friendships f ON f.playerId = r.playerId AND f.botId = r.botId AND f.status = 'accepted'
+ INNER JOIN bot_life_state l ON l.characterId = r.botId WHERE r.playerId = ? ORDER BY r.selectedAt`, [playerId]]);
+ },
+ selectedCount(player) {
+ const playerId = id(player);
+ if (!playerId) return Promise.resolve(0);
+ return Database.execute(['SELECT COUNT(*) AS count FROM bot_friend_roster WHERE playerId = ?', [playerId]])
+ .then((rows) => Number(rows[0]?.count || 0));
+ }
+};
+module.exports = BotFriendship;
diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js
index 02bac5fa..bcb840f1 100644
--- a/src/GameServer/Bot/BotManager.js
+++ b/src/GameServer/Bot/BotManager.js
@@ -12,6 +12,7 @@ const TradeService = invoke('GameServer/Bot/TradeService');
const BotPopulation = invoke('GameServer/Bot/BotPopulation');
const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability');
const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory');
+const BotFriendship = invoke('GameServer/Bot/AI/BotFriendship');
const BotBuffs = invoke('GameServer/Bot/AI/BotBuffs');
const BotRoles = invoke('GameServer/Bot/AI/BotRoles');
const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities');
@@ -290,6 +291,7 @@ const BotManager = {
init() {
console.info("BotManager :: Initializing automated bots...");
BotSocialMemory.init();
+ BotFriendship.init();
PopulationService.init();
GoalService.init();
SimulationKernel.init({ population: PopulationService });
diff --git a/src/GameServer/Network/Request/Speak.js b/src/GameServer/Network/Request/Speak.js
index a1e41105..335d57e9 100644
--- a/src/GameServer/Network/Request/Speak.js
+++ b/src/GameServer/Network/Request/Speak.js
@@ -116,6 +116,11 @@ function consume(session, data) {
World.inviteBotByName(session, session.actor, name, undefined, 'chat_invite');
return;
}
+ if (data.text === '.botfriends' || data.text.startsWith('.botfriends ')) {
+ const BotFriends = invoke('GameServer/World/Generics/NpcBypasses/BotFriends');
+ BotFriends.render(session, data.text.includes(' add') ? 'add' : 'friends');
+ return;
+ }
if (/^(\/tell|\.tell|\/w|\.w)\s+/i.test(data.text)) {
const body = data.text.replace(/^(\/tell|\.tell|\/w|\.w)\s+/i, '').trim();
const match = body.match(/^(\S+)\s+(.+)$/);
diff --git a/src/GameServer/World/Generics/NpcBypasses/BotFriends.js b/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
new file mode 100644
index 00000000..4a5c0a22
--- /dev/null
+++ b/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
@@ -0,0 +1,45 @@
+const BotFriendship = invoke('GameServer/Bot/AI/BotFriendship');
+const LifeState = invoke('GameServer/Bot/Population/BotLifeState');
+const Html = invoke('GameServer/World/Generics/HtmlKit');
+const ServerResponse = invoke('GameServer/Network/Response');
+const World = invoke('GameServer/World/World');
+
+function render(session, mode = 'friends', currentPage = 0) {
+ const actor = session.actor;
+ if (!actor) return;
+ const isAdd = mode === 'add';
+ const loader = isAdd ? BotFriendship.listCandidates(session, currentPage) : BotFriendship.listFriends(session, currentPage);
+ Promise.all([loader, BotFriendship.selectedCount(session)]).then(([bots, selectedCount]) => {
+ let body = `${Html.font(isAdd ? 'Add Bot Friend' : 'Bot Friends', Html.COLOR.title)}`;
+ body += Html.font(isAdd ? 'Bots who know you, sorted by trust.' : 'Friends can be called from anywhere. Mark up to 8 for your const party.', Html.COLOR.muted) + '
';
+ bots.forEach((bot) => {
+ const action = isAdd
+ ? (bot.trust >= BotFriendship.FRIEND_TRUST ? Html.link('Add friend', `bot-friends request ${bot.name} ${currentPage}`, { color: Html.COLOR.ok }) : Html.font(`trust ${bot.trust}/${BotFriendship.FRIEND_TRUST}`, Html.COLOR.muted))
+ : Html.link(bot.selected ? 'Const: ON' : 'Const: OFF', `bot-friends const ${bot.botId} ${currentPage}`, { color: bot.selected ? Html.COLOR.ok : Html.COLOR.link });
+ body += Html.table([Html.row([
+ Html.cell(`${Html.font(bot.name, Html.COLOR.title)} Lv ${bot.level} ${bot.role}`, { width: 190 }),
+ Html.cell(action, { width: 95, align: 'right' })
+ ])]);
+ body += Html.font(`${bot.activity || 'hunting'} / ${bot.currentRegion || 'unknown'} / trust ${bot.trust} / familiarity ${bot.familiarity}`, Html.COLOR.muted);
+ body += '' + Html.line(Html.TEXTURE.blank, Html.WIDTH, 5);
+ });
+ if (!bots.length) body += Html.section('No Bots', Html.font(isAdd ? 'Run with bots to build trust first.' : 'No confirmed friends yet.', Html.COLOR.muted));
+ const nav = currentPage > 0 ? Html.link('Previous', `bot-friends ${mode} ${currentPage - 1}`, { color: Html.COLOR.link }) : Html.font('Previous', Html.COLOR.muted);
+ body += '
' + Html.columns([
+ Html.cell(isAdd ? Html.link('My friends', 'bot-friends friends 0', { color: Html.COLOR.link }) : Html.link('Add friend', 'bot-friends add 0', { color: Html.COLOR.link }), { align: 'center' }),
+ Html.cell(!isAdd && selectedCount > 0 ? Html.link('Form my party', 'bot-friends form', { color: Html.COLOR.ok }) : '', { align: 'center' }),
+ Html.cell(nav, { align: 'center' }), Html.cell(Html.link('Next', `bot-friends ${mode} ${currentPage + 1}`, { color: Html.COLOR.link }), { align: 'center' })
+ ]);
+ session.dataSendToMe(ServerResponse.npcHtml(actor.fetchId(), Html.page(body, { title: 'Bot Friends' })));
+ });
+}
+
+function handler(session, parts) {
+ const mode = parts[1] || 'friends';
+ if (mode === 'request' && parts[2]) return LifeState.findByName(parts[2]).then((state) => BotFriendship.request(session, state).then(() => render(session, 'add', parts[3])));
+ if (mode === 'const' && parts[2]) return BotFriendship.toggleConst(session, parts[2]).then(() => render(session, 'friends', parts[3]));
+ if (mode === 'form') return BotFriendship.selected(session).then((bots) => bots.reduce((chain, bot) => chain.then(() => World.inviteFriendByName(session, session.actor, bot.characterName || bot.name, undefined, 'friend_const')), Promise.resolve()).then(() => render(session)));
+ render(session, mode, parts[2]);
+}
+handler.render = render;
+module.exports = handler;
diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js
index 4edd1b17..b3c878bb 100644
--- a/src/GameServer/World/World.js
+++ b/src/GameServer/World/World.js
@@ -143,13 +143,13 @@ const World = {
});
},
- inviteBotCompanion(session, actor, targetSession, distribution, source = 'invite') {
+ inviteBotCompanion(session, actor, targetSession, distribution, source = 'invite', options = {}) {
const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability');
const BotManager = invoke('GameServer/Bot/BotManager');
const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory');
const PersonaPartyDecisionPolicy = invoke('GameServer/Bot/AI/PersonaPartyDecisionPolicy');
const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService');
- const availability = BotAvailability.evaluate(session, targetSession);
+ const availability = BotAvailability.evaluate(session, targetSession, options);
const bot = targetSession.actor;
BotSocialMemory.recordEvent(session, targetSession, 'invite_attempt', source);
@@ -195,7 +195,7 @@ const World = {
return true;
},
- inviteBotByName(session, actor, name, distribution, source = 'named_invite') {
+ inviteBotByName(session, actor, name, distribution, source = 'named_invite', options = {}) {
const lookup = String(name || '').trim();
if (!lookup) {
session.dataSendToMe(ServerResponse.actionFailed());
@@ -210,7 +210,7 @@ const World = {
const hotSession = BotManager.findSessionByName(lookup);
if (hotSession) {
- return Promise.resolve(this.inviteBotCompanion(session, actor, hotSession, distribution, source));
+ return Promise.resolve(this.inviteBotCompanion(session, actor, hotSession, distribution, source, options));
}
ConsoleText.transmit(session, ConsoleText.caption.waitForResponse);
@@ -220,7 +220,7 @@ const World = {
return false;
}
- const availability = BotAvailability.evaluateState(session, state);
+ const availability = BotAvailability.evaluateState(session, state, options);
if (!availability.available) {
BotSocialMemory.recordEvent(session, state, 'invite_attempt', source);
BotSocialMemory.recordEvent(session, state, 'party_refused', availability.reason);
@@ -254,7 +254,7 @@ const World = {
return false;
}
- return this.inviteBotCompanion(session, actor, targetSession, distribution, source);
+ return this.inviteBotCompanion(session, actor, targetSession, distribution, source, options);
});
});
}).catch((err) => {
@@ -341,6 +341,17 @@ const World = {
);
},
+ inviteFriendByName(session, actor, name, distribution, source = 'friend_invite') {
+ const BotFriendship = invoke('GameServer/Bot/AI/BotFriendship');
+ const LifeState = invoke('GameServer/Bot/Population/BotLifeState');
+ return LifeState.findByName(name).then((state) => {
+ if (!state) return false;
+ return BotFriendship.isFriend(session, state.characterId).then((friend) => friend
+ ? this.inviteBotByName(session, actor, name, distribution, source, { ignoreDistance: true })
+ : false);
+ });
+ },
+
oustPartyMember(session, actor, data) {
const BotManager = invoke('GameServer/Bot/BotManager');
const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService');
From 3a1223d6472f264bcbc432404524acf392b29527 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 09:33:08 -0400
Subject: [PATCH 11/17] Prioritize friend party invitations
---
src/GameServer/World/World.js | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js
index b3c878bb..a6301431 100644
--- a/src/GameServer/World/World.js
+++ b/src/GameServer/World/World.js
@@ -346,9 +346,13 @@ const World = {
const LifeState = invoke('GameServer/Bot/Population/BotLifeState');
return LifeState.findByName(name).then((state) => {
if (!state) return false;
- return BotFriendship.isFriend(session, state.characterId).then((friend) => friend
- ? this.inviteBotByName(session, actor, name, distribution, source, { ignoreDistance: true })
- : false);
+ return BotFriendship.isFriend(session, state.characterId).then((friend) => {
+ if (!friend) return false;
+ const leaveBackgroundParty = state.party?.partyId
+ ? LifeState.leaveParty(state, 'friend_priority')
+ : Promise.resolve(state);
+ return leaveBackgroundParty.then(() => this.inviteBotByName(session, actor, name, distribution, source, { ignoreDistance: true }));
+ });
});
},
From 1f478ef037d84eadfa119d6baff777cbaa5089a7 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 11:04:27 -0400
Subject: [PATCH 12/17] Improve bot market economy and wealth investments
---
scripts/run-tests.js | 4 +
src/GameServer/Bot/AI/BotFriendship.js | 10 ++-
.../Bot/Economy/ColdMarketListingService.js | 43 +++++++----
.../Bot/Economy/ColdMarketService.js | 11 ++-
src/GameServer/Bot/Economy/MarketTelemetry.js | 45 +++++++++++
.../Bot/Economy/MarketTownPolicy.js | 9 ++-
.../Bot/Economy/StaticBuyerService.js | 70 +++++++++++++++++
.../Bot/Economy/WealthInvestmentPolicy.js | 48 ++++++++++++
src/GameServer/Bot/Goals/NeedsEvaluator.js | 15 +++-
src/GameServer/Bot/Population/BotLifeState.js | 19 ++++-
.../Bot/Population/PopulationStatus.js | 5 +-
src/GameServer/World/World.js | 5 ++
tests/test_bot_cold_market_listing.js | 19 +++++
tests/test_bot_friendship.js | 35 +++++++++
tests/test_bot_goal_planner.js | 18 +++++
tests/test_bot_spot_risk_baseline.js | 75 +++++++++++++++++++
tests/test_bot_static_buyer_sale.js | 58 ++++++++++++++
tests/test_wealth_investment_policy.js | 28 +++++++
18 files changed, 493 insertions(+), 24 deletions(-)
create mode 100644 src/GameServer/Bot/Economy/MarketTelemetry.js
create mode 100644 src/GameServer/Bot/Economy/StaticBuyerService.js
create mode 100644 src/GameServer/Bot/Economy/WealthInvestmentPolicy.js
create mode 100644 tests/test_bot_friendship.js
create mode 100644 tests/test_bot_spot_risk_baseline.js
create mode 100644 tests/test_bot_static_buyer_sale.js
create mode 100644 tests/test_wealth_investment_policy.js
diff --git a/scripts/run-tests.js b/scripts/run-tests.js
index 32bbb7fc..878af3c9 100644
--- a/scripts/run-tests.js
+++ b/scripts/run-tests.js
@@ -26,8 +26,11 @@ const tests = [
'tests/test_bot_persona.js',
'tests/test_bot_persona_background_intent.js',
'tests/test_bot_persona_economic_policy.js',
+ 'tests/test_wealth_investment_policy.js',
+ 'tests/test_bot_spot_risk_baseline.js',
'tests/test_bot_persona_party_decision.js',
'tests/test_bot_remote_chat_persona.js',
+ 'tests/test_bot_friendship.js',
'tests/test_bot_goal_planner.js',
'tests/test_bot_goal_market_priority.js',
'tests/test_bot_market_goal_reconcile.js',
@@ -39,6 +42,7 @@ const tests = [
'tests/test_bot_craft_telemetry.js',
'tests/test_bot_cold_market_purchase.js',
'tests/test_bot_cold_market_listing.js',
+ 'tests/test_bot_static_buyer_sale.js',
'tests/test_bot_market_town_routing.js',
'tests/test_bot_craft_shop.js',
'tests/test_bot_warehouse.js',
diff --git a/src/GameServer/Bot/AI/BotFriendship.js b/src/GameServer/Bot/AI/BotFriendship.js
index 8e2b96fd..175da7e6 100644
--- a/src/GameServer/Bot/AI/BotFriendship.js
+++ b/src/GameServer/Bot/AI/BotFriendship.js
@@ -4,6 +4,7 @@ const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
const FRIEND_TRUST = 8;
const MAX_CONST_MEMBERS = 8;
const PAGE_SIZE = 12;
+const rosterWrites = new Map();
function id(subject) { return Number(subject?.characterId || subject?.actor?.fetchId?.() || 0); }
function page(value) { return Math.max(0, Number(value) || 0); }
@@ -49,7 +50,7 @@ const BotFriendship = {
toggleConst(player, botId) {
const playerId = id(player);
if (!playerId || !botId) return Promise.resolve({ ok: false, reason: 'missing_bot' });
- return this.isFriend(player, botId).then((friend) => {
+ const change = () => this.isFriend(player, botId).then((friend) => {
if (!friend) return { ok: false, reason: 'not_friend' };
return Database.execute(['SELECT 1 FROM bot_friend_roster WHERE playerId = ? AND botId = ?', [playerId, Number(botId)]]).then((rows) => {
if (rows[0]) return Database.execute(['DELETE FROM bot_friend_roster WHERE playerId = ? AND botId = ?', [playerId, Number(botId)]]).then(() => ({ ok: true, selected: false }));
@@ -59,6 +60,13 @@ const BotFriendship = {
});
});
});
+ const previous = rosterWrites.get(playerId) || Promise.resolve();
+ const next = previous.then(change, change);
+ const tracked = next.finally(() => {
+ if (rosterWrites.get(playerId) === tracked) rosterWrites.delete(playerId);
+ });
+ rosterWrites.set(playerId, tracked);
+ return next;
},
selected(player) {
const playerId = id(player);
diff --git a/src/GameServer/Bot/Economy/ColdMarketListingService.js b/src/GameServer/Bot/Economy/ColdMarketListingService.js
index 01dd3ebe..94eb6977 100644
--- a/src/GameServer/Bot/Economy/ColdMarketListingService.js
+++ b/src/GameServer/Bot/Economy/ColdMarketListingService.js
@@ -5,8 +5,10 @@ const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity');
const { marketStoreTitle } = invoke('GameServer/Bot/Economy/MarketStoreTitle');
const TownPathfinder = invoke('GameServer/Bot/AI/TownPathfinder');
const MarketTownPolicy = invoke('GameServer/Bot/Economy/MarketTownPolicy');
+const MarketTelemetry = invoke('GameServer/Bot/Economy/MarketTelemetry');
const MerchantStoreConfigs = invoke('GameServer/Bot/MerchantStoreConfigs');
const GeodataEngine = invoke('GameServer/Geodata/GeodataEngine');
+const StaticBuyerService = invoke('GameServer/Bot/Economy/StaticBuyerService');
const DEFAULT_LISTING_MS = 20 * 60 * 1000;
const SELL_RETRY_DELAY_MS = 30 * 60 * 1000;
@@ -502,31 +504,37 @@ function open(state, options = {}) {
if (!state || state.phase === 'hot' || state.activity !== 'shopping') {
return Promise.resolve({ state, listed: false, reason: 'not_shopping' });
}
- const items = ItemDisposition.saleCandidates(state, options);
- if (!items.length) return Promise.resolve({ state, listed: false, reason: 'nothing_to_sell' });
+ const initialItems = ItemDisposition.saleCandidates(state, options);
+ if (!initialItems.length) return Promise.resolve({ state, listed: false, reason: 'nothing_to_sell' });
const timestamp = Number(options.now) || Date.now();
- const town = marketTown(options.town || targetMarketTownName(state, items));
+ // GoalExecutor chooses the best buyer town before travel. At this stage
+ // the bot must trade only with the city it has actually reached.
+ const town = marketTown(options.town || state.currentRegion || targetMarketTownName(state, initialItems));
+ return StaticBuyerService.sell(state, town?.name).then((buyerSale) => {
+ const saleState = buyerSale.state || state;
+ const items = ItemDisposition.saleCandidates(saleState, options);
+ if (!items.length) return { state: saleState, listed: false, reason: buyerSale.sold ? 'sold_to_static_buyer' : 'nothing_to_sell', buyerSale };
const storeLoc = marketLocation(town, { ...options, state });
- if (!storeLoc) return Promise.resolve({ state, listed: false, reason: 'giran_plaza_full' });
+ if (!storeLoc) return { state: saleState, listed: false, reason: 'giran_plaza_full', buyerSale };
const nextState = {
- ...state,
+ ...saleState,
activity: 'merchant',
currentRegion: town?.name || state.currentRegion,
// A private store has a stall, not a roaming route. Persist the plaza
// coordinate so cold ticks and hot materialization use the same spot.
loc: storeLoc,
stats: {
- ...(state.stats || {}),
+ ...(saleState.stats || {}),
marketStore: {
id: `${state.characterId}:${timestamp}`,
storeType: 1,
- sellerCharacterId: Number(state.characterId),
- sellerName: state.name,
+ sellerCharacterId: Number(saleState.characterId),
+ sellerName: saleState.name,
title: options.title || marketStoreTitle(items),
autoTitle: !options.title,
marketTownRoutingVersion: MARKET_TOWN_ROUTING_VERSION,
- town: town?.name || options.town || state.currentRegion,
+ town: town?.name || options.town || saleState.currentRegion,
loc: storeLoc,
items,
openedAt: timestamp,
@@ -534,7 +542,7 @@ function open(state, options = {}) {
}
},
timing: {
- ...(state.timing || {}),
+ ...(saleState.timing || {}),
activityStartedAt: timestamp,
// Sales settle through the market event path. A listed store only
// needs a scheduled wake-up when its offer expires.
@@ -543,12 +551,15 @@ function open(state, options = {}) {
};
return LifeState.upsertState(nextState, 'cold_market_listing').then((saved) => {
if (saved) MarketOpportunity.indexColdStore(saved);
+ if (saved) MarketTelemetry.listingOpened();
return {
- state: saved || state,
+ state: saved || saleState,
listed: !!saved,
- itemCount: items.length
+ itemCount: items.length,
+ buyerSale
};
});
+ });
}
function resolve(state, timestamp = Date.now()) {
@@ -609,14 +620,18 @@ function resolve(state, timestamp = Date.now()) {
liquidated
}));
})
- .then(({ state: liquidatedState, warehouseCount, liquidated }) => LifeState.upsertState(liquidatedState, hasStock ? 'cold_market_expired' : 'cold_market_sold_out')
+ .then(({ state: liquidatedState, warehouseCount, liquidated }) => {
+ const reason = hasStock ? 'expired' : 'sold_out';
+ MarketTelemetry.closed(reason, liquidated.reduce((sum, item) => sum + Number(item.count || 0), 0));
+ return LifeState.upsertState(liquidatedState, hasStock ? 'cold_market_expired' : 'cold_market_sold_out')
.then((saved) => ({
state: saved || liquidatedState,
closed: true,
reason: hasStock ? 'expired' : 'sold_out',
warehouseCount,
liquidatedCount: liquidated.reduce((sum, item) => sum + Number(item.count || 0), 0)
- })));
+ }));
+ });
}
// Stores created before town-based routing all lived in Giran. Move that
diff --git a/src/GameServer/Bot/Economy/ColdMarketService.js b/src/GameServer/Bot/Economy/ColdMarketService.js
index 0204c31d..1967eead 100644
--- a/src/GameServer/Bot/Economy/ColdMarketService.js
+++ b/src/GameServer/Bot/Economy/ColdMarketService.js
@@ -4,10 +4,14 @@ const GoalState = invoke('GameServer/Bot/Goals/GoalState');
const ListingService = invoke('GameServer/Bot/Economy/ColdMarketListingService');
const TradeChat = invoke('GameServer/Bot/Economy/ColdMarketTradeChat');
const GoalExecutor = invoke('GameServer/Bot/Goals/GoalExecutor');
+const MarketTelemetry = invoke('GameServer/Bot/Economy/MarketTelemetry');
const RETRY_DELAY_MS = 15 * 60 * 1000;
function retryAfterFailedPurchase(state, goal, reason) {
+ if (reason === 'no_affordable_offer') MarketTelemetry.noOffer();
+ else if (reason === 'offer_changed') MarketTelemetry.offerChanged();
+ else if (reason === 'purchase_failed' || reason === 'persist_failed') MarketTelemetry.purchaseFailed();
const timestamp = Date.now();
const retryState = {
...state,
@@ -59,12 +63,15 @@ const ColdMarketService = {
return retryAfterFailedPurchase(state, goal, 'persist_failed');
}
const settlement = offer.sourceType === 'cold_store' ? ListingService.settle(offer, 1) : Promise.resolve(null);
- return settlement.then((sellerState) => GoalState.clear(state.characterId, 'completed').then(() => ({
+ return settlement.then((sellerState) => {
+ MarketTelemetry.purchase(offer, 1);
+ return GoalState.clear(state.characterId, 'completed').then(() => ({
state: updated,
purchased: true,
offer,
sellerState
- })));
+ }));
+ });
}).catch((err) => {
MarketOpportunity.release(offer, 1);
utils.infoWarn('BotMarket', 'cold purchase failed for %s: %s', state.name, err.message);
diff --git a/src/GameServer/Bot/Economy/MarketTelemetry.js b/src/GameServer/Bot/Economy/MarketTelemetry.js
new file mode 100644
index 00000000..e9d3e132
--- /dev/null
+++ b/src/GameServer/Bot/Economy/MarketTelemetry.js
@@ -0,0 +1,45 @@
+const counters = {
+ listingsOpened: 0,
+ purchases: 0,
+ itemsSold: 0,
+ adenaTraded: 0,
+ noOffer: 0,
+ offerChanged: 0,
+ purchaseFailed: 0,
+ soldOut: 0,
+ expired: 0,
+ expiredItems: 0,
+ staticBuyerSales: 0,
+ staticBuyerItems: 0,
+ staticBuyerAdena: 0
+};
+let previous = { ...counters };
+
+function add(key, amount = 1) { counters[key] = Number(counters[key] || 0) + Number(amount || 0); }
+
+module.exports = {
+ listingOpened() { add('listingsOpened'); },
+ purchase(offer, quantity = 1) {
+ const count = Math.max(1, Number(quantity) || 1);
+ add('purchases');
+ add('itemsSold', count);
+ add('adenaTraded', Math.max(0, Number(offer?.price || 0)) * count);
+ },
+ noOffer() { add('noOffer'); },
+ offerChanged() { add('offerChanged'); },
+ purchaseFailed() { add('purchaseFailed'); },
+ closed(reason, items = 0) {
+ if (reason === 'sold_out') add('soldOut');
+ if (reason === 'expired') { add('expired'); add('expiredItems', Math.max(0, Number(items) || 0)); }
+ },
+ staticBuyerSale(items = 0, adena = 0) {
+ add('staticBuyerSales');
+ add('staticBuyerItems', Math.max(0, Number(items) || 0));
+ add('staticBuyerAdena', Math.max(0, Number(adena) || 0));
+ },
+ snapshot() {
+ const delta = Object.fromEntries(Object.keys(counters).map((key) => [key, counters[key] - previous[key]]));
+ previous = { ...counters };
+ return { total: { ...counters }, delta };
+ }
+};
diff --git a/src/GameServer/Bot/Economy/MarketTownPolicy.js b/src/GameServer/Bot/Economy/MarketTownPolicy.js
index cfd365cd..b0e5a3fb 100644
--- a/src/GameServer/Bot/Economy/MarketTownPolicy.js
+++ b/src/GameServer/Bot/Economy/MarketTownPolicy.js
@@ -1,5 +1,6 @@
const ItemDisposition = invoke('GameServer/Bot/Economy/ItemDisposition');
const DataCache = invoke('GameServer/DataCache');
+const StaticBuyerService = invoke('GameServer/Bot/Economy/StaticBuyerService');
const GLUDIO_D_GRADE_SHARE_PERCENT = 15;
let rankIndexSource = null;
@@ -73,7 +74,13 @@ function targetTownForItems(state, items = []) {
}
function targetTownForSale(state) {
- return targetTownForItems(state, ItemDisposition.saleCandidates(state));
+ // Static buyer stores are the dependable Adena path for harvested
+ // resources. Prefer the city that is actually bidding on this inventory;
+ // equipment left after that sale may still open a normal private store
+ // there. Without this, no-grade local markets can strand materials in a
+ // town with no buyer.
+ const buyerTown = StaticBuyerService.bestTownFor(state)?.town;
+ return buyerTown || targetTownForItems(state, ItemDisposition.saleCandidates(state));
}
module.exports = {
diff --git a/src/GameServer/Bot/Economy/StaticBuyerService.js b/src/GameServer/Bot/Economy/StaticBuyerService.js
new file mode 100644
index 00000000..e0b31e39
--- /dev/null
+++ b/src/GameServer/Bot/Economy/StaticBuyerService.js
@@ -0,0 +1,70 @@
+const ItemDisposition = invoke('GameServer/Bot/Economy/ItemDisposition');
+const LifeState = invoke('GameServer/Bot/Population/BotLifeState');
+const MerchantStoreConfigs = invoke('GameServer/Bot/MerchantStoreConfigs');
+const TradeService = invoke('GameServer/Bot/TradeService');
+const MarketTelemetry = invoke('GameServer/Bot/Economy/MarketTelemetry');
+
+// Fixed buy stores are an intentionally unlimited Adena source for the
+// background economy. Only materials and drop resources they explicitly ask
+// for go through this path; equipment still has a chance to reach players via
+// a private store.
+function buyersInTown(town) {
+ return Object.entries(MerchantStoreConfigs)
+ .filter(([, store]) => store?.storeType === 3 && store.town === town)
+ .map(([name, store]) => ({ name, ...store }));
+}
+
+function candidatesFor(state, town) {
+ const buyers = buyersInTown(town);
+ if (!buyers.length) return [];
+ return ItemDisposition.saleCandidates(state, { limit: 20 }).flatMap((item) => {
+ if (!String(item.kind || '').startsWith('Other.Material')) return [];
+ const offer = buyers.reduce((best, buyer) => {
+ const line = (buyer.items || []).find((entry) => Number(entry.selfId) === Number(item.selfId));
+ if (!line) return best;
+ const price = TradeService.ratedPrice(item.selfId, line.priceRate ?? 1);
+ return !best || price > best.price ? { buyer, price } : best;
+ }, null);
+ if (!offer || offer.price <= 0) return [];
+ return [{
+ ...item,
+ count: Math.min(Number(item.count || 0), Number((offer.buyer.items || []).find((entry) => Number(entry.selfId) === Number(item.selfId))?.count || 0)),
+ npcPrice: offer.price,
+ buyerName: offer.buyer.name,
+ buyerTown: offer.buyer.town
+ }];
+ }).filter((item) => Number(item.count) > 0);
+}
+
+function bestTownFor(state) {
+ return [...new Set(Object.values(MerchantStoreConfigs)
+ .filter((store) => store?.storeType === 3 && store.town)
+ .map((store) => store.town))]
+ .map((town) => {
+ const candidates = candidatesFor(state, town);
+ return {
+ town,
+ candidates,
+ value: candidates.reduce((sum, item) => sum + Number(item.count || 0) * Number(item.npcPrice || 0), 0)
+ };
+ })
+ .filter((result) => result.value > 0)
+ .sort((left, right) => right.value - left.value || left.town.localeCompare(right.town))[0] || null;
+}
+
+function sell(state, town) {
+ const candidates = candidatesFor(state, town);
+ if (!candidates.length) return Promise.resolve({ state, sold: false, candidates: [] });
+ const itemCount = candidates.reduce((sum, item) => sum + Number(item.count || 0), 0);
+ const adena = candidates.reduce((sum, item) => sum + Number(item.count || 0) * Number(item.npcPrice || 0), 0);
+ return LifeState.applyNpcLiquidation(state, candidates, {
+ source: 'static_buyer',
+ town,
+ buyers: [...new Set(candidates.map((item) => item.buyerName))]
+ }).then((saved) => {
+ if (saved) MarketTelemetry.staticBuyerSale(itemCount, adena);
+ return { state: saved || state, sold: !!saved, candidates, itemCount, adena };
+ });
+}
+
+module.exports = { bestTownFor, buyersInTown, candidatesFor, sell };
diff --git a/src/GameServer/Bot/Economy/WealthInvestmentPolicy.js b/src/GameServer/Bot/Economy/WealthInvestmentPolicy.js
new file mode 100644
index 00000000..501efab8
--- /dev/null
+++ b/src/GameServer/Bot/Economy/WealthInvestmentPolicy.js
@@ -0,0 +1,48 @@
+const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
+
+const MIN_DEATHS_AT_NEW_SPOT = 2;
+const MIN_DEATH_RATE = 0.2;
+const MIN_ADENA_RESERVE = 500;
+const RESERVE_RATE = 0.2;
+
+function personaFor(state = {}) {
+ return state?.persona?.traits ? state.persona : BotPersona.generate(state);
+}
+
+// The baseline is stamped by BotLifeState when a resolver actually puts the
+// bot on another farming spot. This intentionally ignores historic deaths:
+// an old failure at a starter camp must not cause a purchase at every future
+// town visit.
+function spotDeathPressure(state = {}) {
+ const risk = state.stats?.spotRisk;
+ if (!risk || String(risk.spotId || '') !== String(state.spotId || '')) return null;
+ const deaths = Math.max(0, Number(state.stats?.deaths || 0) - Number(risk.deathsAtEntry || 0));
+ const fights = Math.max(0, Number(state.stats?.fightsResolved || 0) - Number(risk.fightsAtEntry || 0));
+ const deathRate = deaths / Math.max(1, fights);
+ if (deaths < MIN_DEATHS_AT_NEW_SPOT || deathRate < MIN_DEATH_RATE) return null;
+ return { spotId: risk.spotId, deaths, fights, deathRate };
+}
+
+function investmentOpportunity(state = {}, estimatedCost = 0) {
+ if (personaFor(state)?.primaryDrive !== 'wealth') return null;
+ const pressure = spotDeathPressure(state);
+ if (!pressure) return null;
+ const cost = Math.max(1, Number(estimatedCost) || 0);
+ const reserve = Math.max(MIN_ADENA_RESERVE, Math.ceil(cost * RESERVE_RATE));
+ const adena = Math.max(0, Number(state.adena || 0));
+ return {
+ pressure,
+ reserve,
+ affordable: adena >= cost + reserve,
+ reason: 'reduce_deaths_at_profitable_spot'
+ };
+}
+
+module.exports = {
+ MIN_DEATHS_AT_NEW_SPOT,
+ MIN_DEATH_RATE,
+ MIN_ADENA_RESERVE,
+ RESERVE_RATE,
+ investmentOpportunity,
+ spotDeathPressure
+};
diff --git a/src/GameServer/Bot/Goals/NeedsEvaluator.js b/src/GameServer/Bot/Goals/NeedsEvaluator.js
index e9c7cf20..bc7e5f7e 100644
--- a/src/GameServer/Bot/Goals/NeedsEvaluator.js
+++ b/src/GameServer/Bot/Goals/NeedsEvaluator.js
@@ -3,6 +3,7 @@ const DataCache = invoke('GameServer/DataCache');
const ItemDisposition = invoke('GameServer/Bot/Economy/ItemDisposition');
const GearLifecycle = invoke('GameServer/Bot/AI/GearLifecycle');
const PersonaEconomicPolicy = invoke('GameServer/Bot/Economy/PersonaEconomicPolicy');
+const WealthInvestmentPolicy = invoke('GameServer/Bot/Economy/WealthInvestmentPolicy');
const RANK_ORDER = ['none', 'd', 'c', 'b', 'a', 's'];
// Weapons make the largest immediate difference, then core armour. The two
@@ -119,9 +120,10 @@ function evaluate(state = {}, options = {}) {
if (gear) {
const requiredAdena = Math.max(0, gear.desiredItem.price - Number(state.adena || 0));
const weaponUpgrade = gear.slot === 7;
+ const wealthInvestment = WealthInvestmentPolicy.investmentOpportunity(state, gear.desiredItem.price);
candidates.push({
type: 'upgrade_gear',
- priority: requiredAdena > 0 ? 72 : 58,
+ priority: wealthInvestment?.affordable ? 81 : requiredAdena > 0 ? 72 : 58,
target: {
equipmentSlot: gear.slotName,
requiredRank: gear.desiredRank,
@@ -137,7 +139,16 @@ function evaluate(state = {}, options = {}) {
: weaponUpgrade ? 'market_search_for_weapon' : 'market_search_for_gear',
estimatedCost: gear.desiredItem.price,
requiredAdena,
- marketTown: gear.marketTown
+ marketTown: gear.marketTown,
+ ...(wealthInvestment ? {
+ personaDrive: 'wealth',
+ wealthInvestment: {
+ reason: wealthInvestment.reason,
+ affordable: wealthInvestment.affordable,
+ reserve: wealthInvestment.reserve,
+ spotRisk: wealthInvestment.pressure
+ }
+ } : {})
},
blockers: spot ? [] : ['missing_spot'],
nextReviewAt: timestamp + 10 * 60 * 1000
diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js
index f13d37c6..127cf705 100644
--- a/src/GameServer/Bot/Population/BotLifeState.js
+++ b/src/GameServer/Bot/Population/BotLifeState.js
@@ -1272,6 +1272,16 @@ const BotLifeState = {
.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const adena = Number(state.adena || 0) + Number(result.materialize?.adena || 0) + materializedAdenaItems;
const targetCombat = targetCombatTelemetry(state.stats?.targetCombat, result.debug, timestamp);
+ const nextSpotId = result.patch?.spotId || state.spotId;
+ const previousRisk = state.stats?.spotRisk;
+ const spotRisk = String(previousRisk?.spotId || '') === String(nextSpotId || '')
+ ? previousRisk
+ : {
+ spotId: nextSpotId || null,
+ enteredAt: timestamp,
+ deathsAtEntry: Number(state.stats?.deaths || 0),
+ fightsAtEntry: Number(state.stats?.fightsResolved || 0)
+ };
const stats = {
...(state.stats || {}),
fightsWon: Number(state.stats?.fightsWon || 0) + Number(result.debug?.wins || 0),
@@ -1330,7 +1340,7 @@ const BotLifeState = {
adena,
phase: 'cold',
activity: nextActivity,
- spotId: result.patch?.spotId || state.spotId,
+ spotId: nextSpotId,
currentRegion: result.patch?.currentRegion || state.currentRegion,
loc: {
...(state.loc || {}),
@@ -1351,6 +1361,9 @@ const BotLifeState = {
stats: {
...stats,
...(result.patch?.stats || {}),
+ // Resolver patches commonly start from the prior state. Keep
+ // the baseline stamped for this resolve's actual destination.
+ spotRisk,
// Party combat carries a projected combat snapshot in patch.stats.
// Keep lifecycle telemetry from this resolve authoritative over
// that snapshot, which still contains the previous tick's data.
@@ -1610,7 +1623,7 @@ const BotLifeState = {
});
},
- applyNpcLiquidation(state, candidates = []) {
+ applyNpcLiquidation(state, candidates = [], options = {}) {
if (!state || !Array.isArray(candidates) || !candidates.length) return Promise.resolve(state);
const inventory = { ...(state.inventory || {}) };
let payout = 0;
@@ -1639,7 +1652,7 @@ const BotLifeState = {
inventory,
stats: {
...(state.stats || {}),
- lastNpcLiquidation: { payout, sold, at: now() }
+ lastNpcLiquidation: { payout, sold, at: now(), ...options }
},
updatedAt: now()
};
diff --git a/src/GameServer/Bot/Population/PopulationStatus.js b/src/GameServer/Bot/Population/PopulationStatus.js
index 6578c6f5..6b03253b 100644
--- a/src/GameServer/Bot/Population/PopulationStatus.js
+++ b/src/GameServer/Bot/Population/PopulationStatus.js
@@ -1,4 +1,5 @@
const Metrics = invoke('GameServer/Bot/Population/PopulationMetrics');
+const MarketTelemetry = invoke('GameServer/Bot/Economy/MarketTelemetry');
const LifeState = invoke('GameServer/Bot/Population/BotLifeState');
const PartyState = invoke('GameServer/Bot/Population/BackgroundPartyState');
const Director = invoke('GameServer/Bot/Population/PopulationDirector');
@@ -38,12 +39,14 @@ const PopulationStatus = {
const resolve = metrics.resolve || {};
const scheduler = metrics.scheduler || {};
const schedulerSlice = metrics.schedulerSlice || {};
+ const market = MarketTelemetry.snapshot();
return {
...counts,
metrics,
director: Director.snapshot(),
- line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} combatActions=${metrics.delta.combatActions} skillUses=${metrics.delta.skillUses} heals=${metrics.delta.heals} skipped=${metrics.delta.skippedResolves} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms sliceP95=${schedulerSlice.p95Ms || 0}ms schedulerYields=${metrics.delta.schedulerYields || 0} schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}`
+ market,
+ line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} marketListings=${market.delta.listingsOpened} marketBuys=${market.delta.purchases} marketItems=${market.delta.itemsSold} marketAdena=${market.delta.adenaTraded} staticBuyerSales=${market.delta.staticBuyerSales} staticBuyerItems=${market.delta.staticBuyerItems} staticBuyerAdena=${market.delta.staticBuyerAdena} marketNoOffer=${market.delta.noOffer} marketSoldOut=${market.delta.soldOut} marketExpired=${market.delta.expired} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} combatActions=${metrics.delta.combatActions} skillUses=${metrics.delta.skillUses} heals=${metrics.delta.heals} skipped=${metrics.delta.skippedResolves} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms sliceP95=${schedulerSlice.p95Ms || 0}ms schedulerYields=${metrics.delta.schedulerYields || 0} schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}`
};
}
};
diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js
index a6301431..8b9158fb 100644
--- a/src/GameServer/World/World.js
+++ b/src/GameServer/World/World.js
@@ -344,6 +344,11 @@ const World = {
inviteFriendByName(session, actor, name, distribution, source = 'friend_invite') {
const BotFriendship = invoke('GameServer/Bot/AI/BotFriendship');
const LifeState = invoke('GameServer/Bot/Population/BotLifeState');
+ const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService');
+ if (!PartyCompanionService.hasCapacity(session)) {
+ session.dataSendToMe(ServerResponse.actionFailed());
+ return Promise.resolve(false);
+ }
return LifeState.findByName(name).then((state) => {
if (!state) return false;
return BotFriendship.isFriend(session, state.characterId).then((friend) => {
diff --git a/tests/test_bot_cold_market_listing.js b/tests/test_bot_cold_market_listing.js
index d037cccc..2dcefe8d 100644
--- a/tests/test_bot_cold_market_listing.js
+++ b/tests/test_bot_cold_market_listing.js
@@ -109,6 +109,25 @@ async function run() {
const dy = secondStall.locY - opened.state.loc.locY;
assert(Math.sqrt(dx * dx + dy * dy) >= ListingService.GIRAN_STALL_MIN_DISTANCE, 'stores must not overlap on the Giran plaza');
+ const buyerRoutedState = {
+ ...state,
+ characterId: 87,
+ name: 'BuyerRoutedSeller',
+ currentRegion: 'Talking Island',
+ inventory: {
+ 57: { selfId: 57, name: 'Adena', amount: 500 },
+ 1864: { selfId: 1864, name: 'Stem', amount: 10, kind: 'Other.Material' }
+ }
+ };
+ const buyerRouted = await ListingService.open(buyerRoutedState, { now: 1000, durationMs: 60000 });
+ assert.strictEqual(buyerRouted.listed, false, 'materials accepted by a static buyer must not create a dead private store');
+ assert.strictEqual(buyerRouted.reason, 'sold_to_static_buyer');
+ assert.strictEqual(buyerRouted.state.stats.lastNpcLiquidation.source, 'static_buyer');
+
+ const remoteBuyerState = { ...buyerRoutedState, characterId: 86, currentRegion: 'Giran' };
+ const remoteBuyer = await ListingService.open(remoteBuyerState, { now: 1000, durationMs: 60000 });
+ assert.strictEqual(remoteBuyer.state.stats.lastNpcLiquidation?.source, undefined, 'a bot must not sell to a buyer in another town before travelling there');
+
const ownOffer = MarketOpportunity.bestOffer(1, { town: 'Giran', buyerCharacterId: 88 });
assert(!ownOffer || ownOffer.sourceType !== 'cold_store', 'seller must not buy its own listing');
const offer = MarketOpportunity.bestOffer(1, { town: 'Giran', buyerCharacterId: 99 });
diff --git a/tests/test_bot_friendship.js b/tests/test_bot_friendship.js
new file mode 100644
index 00000000..510d4b9c
--- /dev/null
+++ b/tests/test_bot_friendship.js
@@ -0,0 +1,35 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const Database = invoke('Database');
+const BotFriendship = invoke('GameServer/Bot/AI/BotFriendship');
+const originalExecute = Database.execute;
+let rosterCount = 7;
+
+Database.execute = ([sql]) => {
+ const text = String(sql);
+ if (text.includes('FROM bot_friendships')) return Promise.resolve([{}]);
+ if (text.includes('FROM bot_friend_roster WHERE playerId') && text.includes('botId')) return Promise.resolve([]);
+ if (text.includes('COUNT(*) AS count')) return Promise.resolve([{ count: rosterCount }]);
+ if (text.includes('INSERT INTO bot_friend_roster')) {
+ rosterCount += 1;
+ return Promise.resolve([]);
+ }
+ throw new Error(`Unexpected SQL: ${text}`);
+};
+
+Promise.all([
+ BotFriendship.toggleConst({ characterId: 42 }, 100),
+ BotFriendship.toggleConst({ characterId: 42 }, 101)
+]).then(([first, second]) => {
+ assert.strictEqual(first.selected, true);
+ assert.strictEqual(second.reason, 'const_full', 'concurrent selections must not exceed eight const members');
+ assert.strictEqual(rosterCount, 8);
+ console.log('Bot friendship roster checks passed');
+}).catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+}).finally(() => {
+ Database.execute = originalExecute;
+});
diff --git a/tests/test_bot_goal_planner.js b/tests/test_bot_goal_planner.js
index 0dec8597..81c49cec 100644
--- a/tests/test_bot_goal_planner.js
+++ b/tests/test_bot_goal_planner.js
@@ -51,6 +51,24 @@ assert.strictEqual(equipmentGoal.type, 'upgrade_gear');
assert.strictEqual(equipmentGoal.target.itemId, expectedWeapon.selfId);
assert.strictEqual(equipmentGoal.plan.expectedBenefit, 'adena_for_weapon_upgrade');
+const wealthInvestmentGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({
+ ...base,
+ adena: 1000000000,
+ spotId: 'cruma',
+ persona: { primaryDrive: 'wealth', traits: {} },
+ stats: {
+ classId: 0,
+ build: { grade: 'c', classId: 0, level: 40 },
+ equipment: [{ selfId: 999, slot: 7, rank: 'd', name: 'Old Weapon' }],
+ deaths: 3,
+ fightsResolved: 12,
+ spotRisk: { spotId: 'cruma', deathsAtEntry: 1, fightsAtEntry: 2 }
+ }
+}, { spot, now: timestamp }), timestamp);
+assert.strictEqual(wealthInvestmentGoal.type, 'upgrade_gear');
+assert.strictEqual(wealthInvestmentGoal.priority, 81, 'repeated deaths at the current spot should elevate an affordable wealth investment');
+assert.strictEqual(wealthInvestmentGoal.plan.wealthInvestment.reason, 'reduce_deaths_at_profitable_spot');
+
const expectedChest = invoke('GameServer/Bot/AI/BotGear').planFor({ classId: 0, level: 40 }).items.find((item) => Number(item.slot) === 10);
const expectedChestPrice = Number((DataCache.items || []).find((item) => Number(item.selfId) === Number(expectedChest.selfId))?.template?.price || 0);
const armorGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({
diff --git a/tests/test_bot_spot_risk_baseline.js b/tests/test_bot_spot_risk_baseline.js
new file mode 100644
index 00000000..d3a2f06f
--- /dev/null
+++ b/tests/test_bot_spot_risk_baseline.js
@@ -0,0 +1,75 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const DataCache = invoke('GameServer/DataCache');
+const Database = invoke('Database');
+const LifeState = invoke('GameServer/Bot/Population/BotLifeState');
+
+DataCache.init();
+
+const originals = {
+ execute: Database.execute,
+ syncInventorySummary: Database.syncInventorySummary,
+ updateCharacterLocation: Database.updateCharacterLocation,
+ updateCharacterExperience: Database.updateCharacterExperience,
+ updateCharacterVitals: Database.updateCharacterVitals
+};
+
+async function run() {
+ Database.execute = () => Promise.resolve([]);
+ Database.syncInventorySummary = () => Promise.resolve();
+ Database.updateCharacterLocation = () => Promise.resolve();
+ Database.updateCharacterExperience = () => Promise.resolve();
+ Database.updateCharacterVitals = () => Promise.resolve();
+
+ const state = {
+ characterId: 992,
+ name: 'RiskBaselineProbe',
+ level: 20,
+ phase: 'cold',
+ activity: 'hunting',
+ spotId: 'old_spot',
+ adena: 0,
+ exp: 0,
+ sp: 0,
+ loc: {},
+ vitals: { hp: 100, maxHp: 100, mp: 50, maxMp: 50 },
+ timing: {},
+ inventory: {},
+ stats: {
+ deaths: 4,
+ fightsResolved: 20,
+ classId: 0,
+ classProgressionLevel: 20,
+ classProgressionClassId: 0,
+ spotRisk: { spotId: 'old_spot', deathsAtEntry: 1, fightsAtEntry: 2 }
+ }
+ };
+ const saved = await LifeState.applyResolve(state, {
+ patch: {
+ activity: 'hunting',
+ spotId: 'new_spot',
+ vitals: state.vitals,
+ // The resolver patch mirrors the prior state, including the old
+ // baseline. The lifecycle must replace it for the new spot.
+ stats: { ...state.stats, coldCombat: { cooldowns: {} } }
+ },
+ materialize: { exp: 0, sp: 0, adena: 0, items: [] },
+ nextResolveAt: 2000,
+ debug: { fights: 2, wins: 1 }
+ });
+
+ assert.strictEqual(saved.stats.spotRisk.spotId, 'new_spot');
+ assert.strictEqual(saved.stats.spotRisk.deathsAtEntry, 4);
+ assert.strictEqual(saved.stats.spotRisk.fightsAtEntry, 20);
+ console.log('Bot spot risk baseline checks passed');
+}
+
+run().catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+}).finally(() => {
+ Object.assign(Database, originals);
+ LifeState.reset?.();
+});
diff --git a/tests/test_bot_static_buyer_sale.js b/tests/test_bot_static_buyer_sale.js
new file mode 100644
index 00000000..b63a2be8
--- /dev/null
+++ b/tests/test_bot_static_buyer_sale.js
@@ -0,0 +1,58 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const DataCache = invoke('GameServer/DataCache');
+const Database = invoke('Database');
+const LifeState = invoke('GameServer/Bot/Population/BotLifeState');
+const StaticBuyerService = invoke('GameServer/Bot/Economy/StaticBuyerService');
+
+DataCache.init();
+
+const originals = {
+ execute: Database.execute,
+ syncInventorySummary: Database.syncInventorySummary
+};
+
+async function run() {
+ Database.execute = () => Promise.resolve([]);
+ Database.syncInventorySummary = () => Promise.resolve();
+
+ const state = {
+ characterId: 991,
+ name: 'MaterialSeller',
+ adena: 100,
+ phase: 'cold',
+ activity: 'shopping',
+ level: 10,
+ inventory: {
+ 57: { selfId: 57, name: 'Adena', amount: 100 },
+ 1864: { selfId: 1864, name: 'Stem', amount: 10, kind: 'Other.Material' },
+ 1: { selfId: 1, name: 'Short Sword', amount: 1, kind: 'Weapon.Sword', rank: 'c' }
+ },
+ stats: {}
+ };
+
+ const preview = StaticBuyerService.candidatesFor(state, 'Talking Island');
+ assert.strictEqual(preview.length, 1, 'the local buyer should accept listed materials');
+ assert.strictEqual(preview[0].selfId, 1864);
+ assert(preview[0].npcPrice > 0, 'the buyer price must use its configured rate');
+ assert.strictEqual(StaticBuyerService.bestTownFor(state).town, 'Talking Island', 'market travel should target a town that buys the held material');
+
+ const result = await StaticBuyerService.sell(state, 'Talking Island');
+ assert.strictEqual(result.sold, true);
+ assert.strictEqual(result.state.inventory['1864'].amount, 0, 'accepted materials are removed');
+ assert.strictEqual(result.state.inventory['1'].amount, 1, 'equipment remains available for the player market');
+ assert.strictEqual(result.state.adena, 100 + preview[0].npcPrice * 10);
+ assert.strictEqual(result.state.stats.lastNpcLiquidation.source, 'static_buyer');
+ console.log('Bot static buyer sale checks passed');
+}
+
+run().catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+}).finally(() => {
+ Database.execute = originals.execute;
+ Database.syncInventorySummary = originals.syncInventorySummary;
+ LifeState.reset?.();
+});
diff --git a/tests/test_wealth_investment_policy.js b/tests/test_wealth_investment_policy.js
new file mode 100644
index 00000000..e3901c31
--- /dev/null
+++ b/tests/test_wealth_investment_policy.js
@@ -0,0 +1,28 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const Policy = invoke('GameServer/Bot/Economy/WealthInvestmentPolicy');
+
+const state = {
+ persona: { primaryDrive: 'wealth', traits: {} },
+ adena: 12000,
+ spotId: 'dion_ruins',
+ stats: {
+ deaths: 3,
+ fightsResolved: 10,
+ spotRisk: { spotId: 'dion_ruins', deathsAtEntry: 1, fightsAtEntry: 2 }
+ }
+};
+
+const pressure = Policy.spotDeathPressure(state);
+assert.deepStrictEqual(pressure, { spotId: 'dion_ruins', deaths: 2, fights: 8, deathRate: 0.25 });
+
+const investment = Policy.investmentOpportunity(state, 9000);
+assert.strictEqual(investment.affordable, true, 'wealth bot with a reserve should invest to stop repeated deaths');
+assert.strictEqual(investment.reason, 'reduce_deaths_at_profitable_spot');
+assert.strictEqual(Policy.investmentOpportunity({ ...state, adena: 9000 }, 9000).affordable, false, 'the purchase must leave operating capital');
+assert.strictEqual(Policy.investmentOpportunity({ ...state, persona: { primaryDrive: 'progression', traits: {} } }, 9000), null, 'other drives retain normal gear priority');
+assert.strictEqual(Policy.spotDeathPressure({ ...state, spotId: 'other_spot' }), null, 'historic deaths cannot bleed into a new spot');
+
+console.log('Wealth investment policy checks passed');
From 651eed12b3b9620440c2ca6ab6c37d4e1dbe670f Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 15:05:10 -0400
Subject: [PATCH 13/17] Prioritize cold shopping transitions
---
src/GameServer/Bot/Population/BotLifeState.js | 9 +++++----
tests/test_bot_population_state.js | 6 +++---
2 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js
index 127cf705..c37115d6 100644
--- a/src/GameServer/Bot/Population/BotLifeState.js
+++ b/src/GameServer/Bot/Population/BotLifeState.js
@@ -972,14 +972,15 @@ const BotLifeState = {
nextResolveAt IS NULL OR nextResolveAt <= ?
OR (activity = 'hunting' AND (${staleRateModelPlan}))
)
- -- Travel and crafting are finite state transitions. They must
- -- outrank a large resting/hunting backlog, otherwise a bot can
- -- remain on its way to a station forever after a restart.
+ -- Travel, the arrived market action, and crafting are finite
+ -- state transitions. They must outrank a large resting/hunting
+ -- backlog, otherwise a bot can remain at a market or station
+ -- for minutes after it is already due.
ORDER BY CASE
-- Replan active combat before it can continue using a stale
-- target level or drop-rate estimate.
WHEN ${staleRateModelPlan} THEN 0
- WHEN activity IN ('traveling', 'crafting') THEN 1
+ WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1
-- Startup craft recovery is a one-shot replan. Serve it
-- before the normal hunting backlog so a repaired station
-- wait immediately selects its missing raw material.
diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js
index fd267b5c..d4712051 100644
--- a/tests/test_bot_population_state.js
+++ b/tests/test_bot_population_state.js
@@ -89,12 +89,12 @@ try {
return BotLifeState.dueCold(5, 1000);
});
}).then(() => {
- const due = statements.find((entry) => entry.sql.includes("WHEN activity IN ('traveling', 'crafting') THEN 1"));
+ const due = statements.find((entry) => entry.sql.includes("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1"));
assert(due.sql.includes('rateModelVersion'), 'due cold states must prioritize persisted plans from an older drop-rate model');
assert(due.sql.includes(`< ${GearPlanner.RATE_MODEL_VERSION}`), 'due cold states must prioritize plans from the current model rollout rather than a stale hard-coded version');
assert(due.sql.includes("OR (activity = 'hunting' AND (json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL"), 'a stale active combat plan must bypass its old next-resolve deadline for an immediate safety replan');
- assert(due.sql.indexOf("WHEN json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL") < due.sql.indexOf("WHEN activity IN ('traveling', 'crafting') THEN 1"), 'a stale active plan must outrank ordinary travel and crafting transitions');
- assert(due.sql.includes("WHEN activity IN ('traveling', 'crafting') THEN 1"), 'due cold states must promptly finish travel and crafting transitions after an urgent combat-safety replan');
+ assert(due.sql.indexOf("WHEN json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL") < due.sql.indexOf("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1"), 'a stale active plan must outrank ordinary market, travel, and crafting transitions');
+ assert(due.sql.includes("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1"), 'due cold states must promptly finish market, travel, and crafting transitions after an urgent combat-safety replan');
assert(due.sql.includes("startup_craft_wait_recovery"), 'startup craft recovery must immediately replan before the ordinary hunting backlog');
assert(due.sql.includes('COALESCE(nextResolveAt, 0) ASC'), 'due cold states must remain fair by schedule within each lifecycle bucket');
return BotLifeState.assignParty({
From fb527e91ea28820936d7d39b673e1da85f3aee35 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 15:20:21 -0400
Subject: [PATCH 14/17] Improve party wait capacity and recruitment fairness
---
src/GameServer/Bot/Population/BotLifeState.js | 55 ++++++++
.../Bot/Population/PopulationConfig.js | 10 ++
.../Bot/Population/PopulationService.js | 118 ++++++++++++++++--
.../test_bot_background_party_recruitment.js | 2 +
tests/test_bot_population_state.js | 71 ++++++-----
5 files changed, 212 insertions(+), 44 deletions(-)
diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js
index c37115d6..2170194a 100644
--- a/src/GameServer/Bot/Population/BotLifeState.js
+++ b/src/GameServer/Bot/Population/BotLifeState.js
@@ -1176,6 +1176,61 @@ const BotLifeState = {
});
},
+ coldPartyCandidateCount(partyRequiredOnly = false) {
+ if (!initialized) return Promise.resolve(0);
+ const activityClause = partyRequiredOnly
+ ? "activity = 'party_wait'"
+ : "activity IN ('hunting', 'resting', 'party_wait')";
+
+ return Database.execute([
+ `SELECT COUNT(*) AS candidateCount FROM ${TABLE}
+ WHERE phase = 'cold'
+ AND (partyId IS NULL OR partyId = '')
+ AND spotId IS NOT NULL
+ AND ${activityClause}`,
+ []
+ ]).then((rows) => Number(rows[0]?.candidateCount || 0)).catch((err) => {
+ utils.infoWarn('BotLife', 'failed to count party candidates: %s', err.message);
+ return 0;
+ });
+ },
+
+ coldPartyCandidatesForSpots(spotIds = [], limitPerSpot = 40, partyRequiredOnly = false) {
+ if (!initialized) return Promise.resolve([]);
+ const uniqueSpots = Array.from(new Set((spotIds || []).map((spotId) => String(spotId || '')).filter(Boolean)));
+ if (!uniqueSpots.length) return Promise.resolve([]);
+ const safeLimit = Math.max(1, Math.min(100, Number(limitPerSpot) || 40));
+ const placeholders = uniqueSpots.map(() => '?').join(', ');
+ const activityClause = partyRequiredOnly
+ ? "states.activity = 'party_wait'"
+ : "states.activity IN ('hunting', 'resting', 'party_wait')";
+
+ return Database.execute([
+ `SELECT * FROM (
+ SELECT states.*,
+ ROW_NUMBER() OVER (
+ PARTITION BY states.spotId
+ ORDER BY states.updatedAt ASC, states.level ASC, states.characterId ASC
+ ) AS candidateRank
+ FROM ${TABLE} states
+ WHERE states.phase = 'cold'
+ AND (states.partyId IS NULL OR states.partyId = '')
+ AND states.spotId IN (${placeholders})
+ AND ${activityClause}
+ ) ranked
+ WHERE candidateRank <= ${safeLimit}
+ ORDER BY spotId ASC, candidateRank ASC`,
+ uniqueSpots
+ ]).then((rows) => rows.map((row) => {
+ const state = normalize(row);
+ cache.set(state.characterId, state);
+ return state;
+ })).catch((err) => {
+ utils.infoWarn('BotLife', 'failed to fetch party candidates by spot: %s', err.message);
+ return [];
+ });
+ },
+
partyRequirementCounts(partyIds = []) {
if (!initialized) return Promise.resolve([]);
const ids = Array.from(new Set((partyIds || []).map((partyId) => String(partyId || '')).filter(Boolean)));
diff --git a/src/GameServer/Bot/Population/PopulationConfig.js b/src/GameServer/Bot/Population/PopulationConfig.js
index 8e2af627..a4c196ff 100644
--- a/src/GameServer/Bot/Population/PopulationConfig.js
+++ b/src/GameServer/Bot/Population/PopulationConfig.js
@@ -45,12 +45,22 @@ const DEFAULTS = {
// the three available slots reach distinct crowded grounds instead of
// letting the two largest queues consume the whole selection window.
partyFormationCandidateLimit: 250,
+ // Recruitment must get a fair sample from every active party ground; a
+ // global top-N window otherwise starves less crowded spots forever.
+ partyRecruitmentCandidateLimit: 40,
partyMinSize: 2,
partyMaxSize: 5,
// At roughly one party resolve per 90 seconds, forty parties consume
// about 27 of the 36 bounded resolves available each minute. This opens
// enough party-wait capacity without increasing work in a scheduler tick.
maxBackgroundParties: 40,
+ // A sustained party-wait queue can use the spare party-resolve headroom,
+ // but the expansion is deliberately capped so it cannot grow unbounded.
+ partyBacklogCapacityThreshold: 250,
+ partyBacklogCapacityStep: 3,
+ partyBacklogCapacityMaxExtra: 12,
+ partyRequirementRefreshMs: 5 * 60 * 1000,
+ partyRequirementRefreshBatchSize: 8,
cooldownGraceMs: 120000,
cooldownBatchSize: 20,
cooldownRadius: 11000,
diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js
index 25d3c62c..0a7400a0 100644
--- a/src/GameServer/Bot/Population/PopulationService.js
+++ b/src/GameServer/Bot/Population/PopulationService.js
@@ -82,6 +82,25 @@ function partySpotForLeader(leader) {
}, { mode: 'party', role: PartyComposition.roleForState(leader) }) || SpotProfiles.findById(leader.spotId);
}
+function maxBackgroundPartiesForBacklog(partyWaitCount = 0) {
+ const base = Math.max(0, Number(Config.maxBackgroundParties) || 0);
+ const threshold = Math.max(1, Number(Config.partyBacklogCapacityThreshold) || 250);
+ const step = Math.max(0, Number(Config.partyBacklogCapacityStep) || 0);
+ const maxExtra = Math.max(0, Number(Config.partyBacklogCapacityMaxExtra) || 0);
+ const extra = Math.min(maxExtra, Math.floor(Math.max(0, Number(partyWaitCount) || 0) / threshold) * step);
+ return base + extra;
+}
+
+function acquisitionRequirementKey(plan) {
+ return JSON.stringify({
+ status: plan?.status || null,
+ strategy: plan?.strategy || null,
+ requiresParty: Boolean(plan?.requiresParty),
+ target: Number(plan?.target?.selfId || 0),
+ nextSpot: plan?.next?.spotId || null
+ });
+}
+
function directDropTargetNpcId(...plans) {
for (const plan of plans) {
if (plan?.status !== 'active' || plan?.strategy !== 'direct_drop') continue;
@@ -664,22 +683,44 @@ const PopulationService = {
}
this.partyFormationRunning = true;
- return LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit, true)
- .then((partyWaitStates) => (partyWaitStates.length
- ? { states: partyWaitStates, partyWaitBacklog: true }
- : LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit)
- .then((states) => ({ states, partyWaitBacklog: false }))))
- .then(({ states, partyWaitBacklog }) => {
+ return LifeState.coldPartyCandidateCount(true)
+ .then((partyWaitCount) => LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit, true)
+ .then((partyWaitStates) => (partyWaitStates.length
+ ? { states: partyWaitStates, partyWaitBacklog: true }
+ : LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit)
+ .then((states) => ({ states, partyWaitBacklog: false }))))
+ .then(({ states, partyWaitBacklog }) => {
+ const activeParties = BackgroundPartyState.active();
+ const recruitSpots = activeParties
+ .filter((party) => (party.memberIds || []).length < Config.partyMaxSize)
+ .map((party) => party.spotId);
+ const fairCandidates = LifeState.coldPartyCandidatesForSpots(
+ recruitSpots,
+ Config.partyRecruitmentCandidateLimit,
+ partyWaitBacklog
+ );
+ return fairCandidates.then((spotCandidates) => {
+ const byId = new Map((states || []).map((state) => [Number(state.characterId), state]));
+ spotCandidates.forEach((state) => byId.set(Number(state.characterId), state));
+ return {
+ states: Array.from(byId.values()),
+ partyWaitBacklog,
+ partyWaitCount
+ };
+ });
+ }))
+ .then(({ states, partyWaitBacklog, partyWaitCount }) => {
const willingStates = states.filter((state) => PersonaPartyPolicy.backgroundIntent(state).accept);
- return this.reclaimBackgroundPartyCapacity(partyWaitBacklog ? willingStates : [])
+ return this.reclaimBackgroundPartyCapacity(partyWaitBacklog ? willingStates : [], partyWaitCount)
.then(() => this.recruitBackgroundMembers(willingStates)).then((recruitedIds) => ({
states: willingStates.filter((state) => !recruitedIds.has(Number(state.characterId))),
- partyWaitBacklog
+ partyWaitBacklog,
+ partyWaitCount
}));
})
- .then(({ states, partyWaitBacklog }) => {
+ .then(({ states, partyWaitBacklog, partyWaitCount }) => {
const activeParties = BackgroundPartyState.counts().active || 0;
- const slots = Math.max(0, Config.maxBackgroundParties - activeParties);
+ const slots = Math.max(0, maxBackgroundPartiesForBacklog(partyWaitCount) - activeParties);
if (slots <= 0) return [];
const maxNewParties = Math.min(slots, Config.partyFormationBatchSize);
const activePartiesBySpot = BackgroundPartyState.active().reduce((counts, party) => {
@@ -771,10 +812,60 @@ const PopulationService = {
return groupBySpot(states, options);
},
- reclaimBackgroundPartyCapacity(partyWaitStates = []) {
+ maxBackgroundPartiesForBacklog,
+
+ refreshBackgroundPartyRequirements(parties = []) {
+ const timestamp = Date.now();
+ const refreshMs = Math.max(1000, Number(Config.partyRequirementRefreshMs) || 5 * 60 * 1000);
+ const batchSize = Math.max(1, Number(Config.partyRequirementRefreshBatchSize) || 8);
+ const refreshable = (parties || [])
+ .filter((party) => timestamp - Number(party.stats?.lastRequirementRefreshAt || 0) >= refreshMs)
+ .sort((a, b) => Number(a.stats?.lastRequirementRefreshAt || 0) - Number(b.stats?.lastRequirementRefreshAt || 0))
+ .slice(0, batchSize);
+ if (!refreshable.length) return Promise.resolve([]);
+
+ let spots = [];
+ try {
+ spots = SpotProfiles.ensure();
+ } catch (err) {
+ // Unit/integration harnesses may not load the world spot index;
+ // keep the refresh best-effort and let the normal party resolver
+ // retry it on the next formation pass.
+ utils.infoWarn('BotPopulation', 'party requirement refresh spot index unavailable: %s', err.message);
+ return Promise.resolve([]);
+ }
+ return refreshable.reduce((chain, party) => chain.then(async (refreshed) => {
+ const members = await LifeState.statesForParty(party.partyId);
+ let changed = false;
+ for (const member of members) {
+ const previousPlan = member.stats?.equipmentPlan;
+ let nextPlan;
+ try {
+ nextPlan = GearAcquisitionPlanner.planFor(member, { spots });
+ } catch (err) {
+ utils.infoWarn('BotPopulation', 'party requirement refresh failed for %s: %s', member.name, err.message);
+ continue;
+ }
+ if (acquisitionRequirementKey(previousPlan) === acquisitionRequirementKey(nextPlan)) continue;
+ const nextState = {
+ ...member,
+ stats: { ...(member.stats || {}), equipmentPlan: nextPlan }
+ };
+ const saved = await LifeState.upsertState(nextState, 'party_requirement_refresh');
+ changed = changed || !!saved;
+ }
+ await BackgroundPartyState.createOrUpdate({
+ ...party,
+ stats: { ...(party.stats || {}), lastRequirementRefreshAt: timestamp }
+ });
+ return changed ? [...refreshed, party.partyId] : refreshed;
+ }), Promise.resolve([]));
+ },
+
+ reclaimBackgroundPartyCapacity(partyWaitStates = [], partyWaitCount = partyWaitStates.length) {
if (!partyWaitStates.length) return Promise.resolve([]);
const activeParties = BackgroundPartyState.active();
- const availableSlots = Math.max(0, Config.maxBackgroundParties - activeParties.length);
+ const availableSlots = Math.max(0, maxBackgroundPartiesForBacklog(partyWaitCount) - activeParties.length);
const wantedSlots = Math.min(
Config.partyFormationBatchSize,
Math.floor(partyWaitStates.length / Math.max(1, Config.partyMinSize))
@@ -782,7 +873,8 @@ const PopulationService = {
const reclaimCount = Math.max(0, wantedSlots - availableSlots);
if (!reclaimCount || !activeParties.length) return Promise.resolve([]);
- return LifeState.partyRequirementCounts(activeParties.map((party) => party.partyId))
+ return this.refreshBackgroundPartyRequirements(activeParties)
+ .then(() => LifeState.partyRequirementCounts(activeParties.map((party) => party.partyId)))
.then((counts) => {
const countByPartyId = new Map(counts.map((count) => [count.partyId, count]));
return activeParties
diff --git a/tests/test_bot_background_party_recruitment.js b/tests/test_bot_background_party_recruitment.js
index 08c041ba..35a0e356 100644
--- a/tests/test_bot_background_party_recruitment.js
+++ b/tests/test_bot_background_party_recruitment.js
@@ -96,6 +96,8 @@ async function run() {
]);
Config.maxBackgroundParties = 2;
Config.partyFormationBatchSize = 2;
+ assert.strictEqual(PopulationService.maxBackgroundPartiesForBacklog(0), 2, 'without a backlog the base party capacity must remain unchanged');
+ assert(PopulationService.maxBackgroundPartiesForBacklog(1000) > 2, 'a sustained party-wait backlog should open bounded spare party capacity');
const released = await PopulationService.reclaimBackgroundPartyCapacity([
{ characterId: 31 }, { characterId: 32 }, { characterId: 33 }, { characterId: 34 }
]);
diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js
index d4712051..8867854c 100644
--- a/tests/test_bot_population_state.js
+++ b/tests/test_bot_population_state.js
@@ -117,38 +117,47 @@ try {
}).then(() => {
const requiredCandidates = statements.find((entry) => entry.sql.includes("states.activity = 'party_wait'"));
assert(requiredCandidates, 'a real party-wait backlog must reserve formation capacity ahead of elective hunting parties');
- const member = {
- characterId: 44,
- name: 'PartyTelemetryProbe',
- level: 20,
- phase: 'cold',
- activity: 'grouped',
- party: { partyId: 'bgp_probe' },
- timing: { nextResolveAt: 9000 },
- vitals: { hp: 400, maxHp: 400, mp: 200, maxMp: 200 },
- stats: {
- lastResolveDebug: { targetNpcId: null },
- targetCombat: { targets: {}, populationTargets: {} }
- },
- inventory: {}
- };
- return BotLifeState.applyResolve(member, {
- patch: {
+ return BotLifeState.coldPartyCandidateCount(true).then(() => {
+ const count = statements.find((entry) => entry.sql.includes('COUNT(*) AS candidateCount'));
+ assert(count, 'party capacity planning must be able to measure the full wait backlog');
+ return BotLifeState.coldPartyCandidatesForSpots(['cruma', 'dion'], 3, true);
+ }).then(() => {
+ const fairCandidates = statements.find((entry) => entry.sql.includes('ROW_NUMBER() OVER') && entry.sql.includes('PARTITION BY states.spotId'));
+ assert(fairCandidates, 'party recruitment must load a bounded fair sample per active spot');
+ }).then(() => {
+ const member = {
+ characterId: 44,
+ name: 'PartyTelemetryProbe',
+ level: 20,
+ phase: 'cold',
activity: 'grouped',
- vitals: member.vitals,
- // This mirrors the projected snapshot that a party
- // resolver returns after a fight.
- stats: { ...member.stats, coldCombat: { cooldowns: {} } }
- },
- materialize: { exp: 0, sp: 0, adena: 0, items: [] },
- nextResolveAt: 10000,
- debug: {
- partyId: 'bgp_probe',
- aggregate: true,
- populationTelemetryOwner: true,
- targetNpcId: 93,
- defeatedNpcIds: [93]
- }
+ party: { partyId: 'bgp_probe' },
+ timing: { nextResolveAt: 9000 },
+ vitals: { hp: 400, maxHp: 400, mp: 200, maxMp: 200 },
+ stats: {
+ lastResolveDebug: { targetNpcId: null },
+ targetCombat: { targets: {}, populationTargets: {} }
+ },
+ inventory: {}
+ };
+ return BotLifeState.applyResolve(member, {
+ patch: {
+ activity: 'grouped',
+ vitals: member.vitals,
+ // This mirrors the projected snapshot that a party
+ // resolver returns after a fight.
+ stats: { ...member.stats, coldCombat: { cooldowns: {} } }
+ },
+ materialize: { exp: 0, sp: 0, adena: 0, items: [] },
+ nextResolveAt: 10000,
+ debug: {
+ partyId: 'bgp_probe',
+ aggregate: true,
+ populationTelemetryOwner: true,
+ targetNpcId: 93,
+ defeatedNpcIds: [93]
+ }
+ });
});
}).then(() => {
const partySave = statements.filter((entry) => entry.sql.includes('ON CONFLICT(characterId) DO UPDATE')).at(-1);
From 2004ac53a79d67fc18ff95189018272155c5cc62 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 15:31:52 -0400
Subject: [PATCH 15/17] Fix bot friendship request feedback
---
src/GameServer/Bot/AI/BotFriendship.js | 12 +++++++--
.../World/Generics/NpcBypasses/BotFriends.js | 25 +++++++++++++++++--
tests/test_bot_friendship.js | 14 ++++++++++-
3 files changed, 46 insertions(+), 5 deletions(-)
diff --git a/src/GameServer/Bot/AI/BotFriendship.js b/src/GameServer/Bot/AI/BotFriendship.js
index 175da7e6..fad43221 100644
--- a/src/GameServer/Bot/AI/BotFriendship.js
+++ b/src/GameServer/Bot/AI/BotFriendship.js
@@ -4,6 +4,7 @@ const BotPersona = invoke('GameServer/Bot/AI/BotPersona');
const FRIEND_TRUST = 8;
const MAX_CONST_MEMBERS = 8;
const PAGE_SIZE = 12;
+const RECENT_ABANDON_MS = 5 * 60 * 1000;
const rosterWrites = new Map();
function id(subject) { return Number(subject?.characterId || subject?.actor?.fetchId?.() || 0); }
@@ -40,11 +41,18 @@ const BotFriendship = {
if (!playerId || !botId) return Promise.resolve({ ok: false, reason: 'missing_bot' });
return Database.execute(['SELECT * FROM bot_social_memory WHERE playerId = ? AND botId = ?', [playerId, botId]]).then((rows) => {
const social = rows[0] || {};
- const accepted = Number(social.trust || 0) >= FRIEND_TRUST && Number(social.insults || 0) === 0 && !social.recentlyAbandonedAt;
const now = Date.now();
+ const trust = Number(social.trust || 0);
+ const insults = Number(social.insults || 0);
+ const recentlyAbandoned = Number(social.recentlyAbandonedAt || 0) > 0
+ && now - Number(social.recentlyAbandonedAt) < RECENT_ABANDON_MS;
+ const reason = trust < FRIEND_TRUST ? 'low_trust'
+ : insults > 0 ? 'insults'
+ : recentlyAbandoned ? 'recently_abandoned' : null;
+ const accepted = !reason;
return Database.execute([`INSERT INTO bot_friendships (playerId, botId, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(playerId, botId) DO UPDATE SET status = excluded.status, updatedAt = excluded.updatedAt`, [playerId, botId, accepted ? 'accepted' : 'declined', now, now]])
- .then(() => ({ ok: accepted, reason: accepted ? 'accepted' : 'trust_required', trust: Number(social.trust || 0), persona: BotPersona.generate(state) }));
+ .then(() => ({ ok: accepted, reason: accepted ? 'accepted' : reason, trust, persona: BotPersona.generate(state) }));
});
},
toggleConst(player, botId) {
diff --git a/src/GameServer/World/Generics/NpcBypasses/BotFriends.js b/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
index 4a5c0a22..af7cc75f 100644
--- a/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
+++ b/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
@@ -4,7 +4,16 @@ const Html = invoke('GameServer/World/Generics/HtmlKit');
const ServerResponse = invoke('GameServer/Network/Response');
const World = invoke('GameServer/World/World');
-function render(session, mode = 'friends', currentPage = 0) {
+function requestReasonText(reason) {
+ return {
+ low_trust: 'trust is still too low',
+ insults: 'the bot remembers an insult',
+ recently_abandoned: 'the bot needs a little time after the last abandonment',
+ missing_bot: 'the bot could not be found'
+ }[reason] || 'the request was declined';
+}
+
+function render(session, mode = 'friends', currentPage = 0, notice = null) {
const actor = session.actor;
if (!actor) return;
const isAdd = mode === 'add';
@@ -12,6 +21,10 @@ function render(session, mode = 'friends', currentPage = 0) {
Promise.all([loader, BotFriendship.selectedCount(session)]).then(([bots, selectedCount]) => {
let body = `${Html.font(isAdd ? 'Add Bot Friend' : 'Bot Friends', Html.COLOR.title)}`;
body += Html.font(isAdd ? 'Bots who know you, sorted by trust.' : 'Friends can be called from anywhere. Mark up to 8 for your const party.', Html.COLOR.muted) + '
';
+ if (notice?.message) {
+ body += Html.font(notice.message, notice.ok ? Html.COLOR.ok : Html.COLOR.warn) + '';
+ body += Html.line(Html.TEXTURE.blank, Html.WIDTH, 5);
+ }
bots.forEach((bot) => {
const action = isAdd
? (bot.trust >= BotFriendship.FRIEND_TRUST ? Html.link('Add friend', `bot-friends request ${bot.name} ${currentPage}`, { color: Html.COLOR.ok }) : Html.font(`trust ${bot.trust}/${BotFriendship.FRIEND_TRUST}`, Html.COLOR.muted))
@@ -36,7 +49,15 @@ function render(session, mode = 'friends', currentPage = 0) {
function handler(session, parts) {
const mode = parts[1] || 'friends';
- if (mode === 'request' && parts[2]) return LifeState.findByName(parts[2]).then((state) => BotFriendship.request(session, state).then(() => render(session, 'add', parts[3])));
+ if (mode === 'request' && parts[2]) {
+ return LifeState.findByName(parts[2]).then((state) => BotFriendship.request(session, state).then((result) => {
+ const name = state?.name || parts[2];
+ const message = result.ok
+ ? `${name} accepted your friend request.`
+ : `${name} declined the request: ${requestReasonText(result.reason)}.`;
+ return render(session, 'add', parts[3], { ok: result.ok, message });
+ }));
+ }
if (mode === 'const' && parts[2]) return BotFriendship.toggleConst(session, parts[2]).then(() => render(session, 'friends', parts[3]));
if (mode === 'form') return BotFriendship.selected(session).then((bots) => bots.reduce((chain, bot) => chain.then(() => World.inviteFriendByName(session, session.actor, bot.characterName || bot.name, undefined, 'friend_const')), Promise.resolve()).then(() => render(session)));
render(session, mode, parts[2]);
diff --git a/tests/test_bot_friendship.js b/tests/test_bot_friendship.js
index 510d4b9c..b0645e35 100644
--- a/tests/test_bot_friendship.js
+++ b/tests/test_bot_friendship.js
@@ -6,9 +6,16 @@ const Database = invoke('Database');
const BotFriendship = invoke('GameServer/Bot/AI/BotFriendship');
const originalExecute = Database.execute;
let rosterCount = 7;
+let requestSocial = {
+ trust: 20,
+ insults: 0,
+ recentlyAbandonedAt: Date.now() - 10 * 60 * 1000
+};
Database.execute = ([sql]) => {
const text = String(sql);
+ if (text.includes('FROM bot_social_memory')) return Promise.resolve([requestSocial]);
+ if (text.includes('INSERT INTO bot_friendships')) return Promise.resolve([]);
if (text.includes('FROM bot_friendships')) return Promise.resolve([{}]);
if (text.includes('FROM bot_friend_roster WHERE playerId') && text.includes('botId')) return Promise.resolve([]);
if (text.includes('COUNT(*) AS count')) return Promise.resolve([{ count: rosterCount }]);
@@ -22,10 +29,15 @@ Database.execute = ([sql]) => {
Promise.all([
BotFriendship.toggleConst({ characterId: 42 }, 100),
BotFriendship.toggleConst({ characterId: 42 }, 101)
-]).then(([first, second]) => {
+]).then(async ([first, second]) => {
assert.strictEqual(first.selected, true);
assert.strictEqual(second.reason, 'const_full', 'concurrent selections must not exceed eight const members');
assert.strictEqual(rosterCount, 8);
+ const accepted = await BotFriendship.request({ characterId: 42 }, { characterId: 100, name: 'OldFriend' });
+ assert.strictEqual(accepted.ok, true, 'an old abandonment cooldown must not block friendship forever');
+ requestSocial = { trust: 20, insults: 0, recentlyAbandonedAt: Date.now() - 1000 };
+ const coolingDown = await BotFriendship.request({ characterId: 42 }, { characterId: 101, name: 'CoolingFriend' });
+ assert.strictEqual(coolingDown.reason, 'recently_abandoned', 'a recent abandonment must still be respected');
console.log('Bot friendship roster checks passed');
}).catch((error) => {
console.error(error);
From 0f4422276b912cb3ea435206324b5ee0b0db48e5 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 15:44:27 -0400
Subject: [PATCH 16/17] Prioritize const friends and support removal
---
src/GameServer/Bot/AI/BotAvailability.js | 22 +++++++++----------
src/GameServer/Bot/AI/BotFriendship.js | 7 ++++++
.../World/Generics/NpcBypasses/BotFriends.js | 10 ++++++++-
src/GameServer/World/World.js | 12 +++++++++-
tests/test_bot_availability.js | 8 +++++++
tests/test_bot_friendship.js | 2 ++
6 files changed, 48 insertions(+), 13 deletions(-)
diff --git a/src/GameServer/Bot/AI/BotAvailability.js b/src/GameServer/Bot/AI/BotAvailability.js
index cc1ca7fe..8e453b4d 100644
--- a/src/GameServer/Bot/AI/BotAvailability.js
+++ b/src/GameServer/Bot/AI/BotAvailability.js
@@ -79,14 +79,14 @@ const BotAvailability = {
if (result.clanmate) reason = 'available';
else if (player.isDead && player.isDead()) reason = 'player_dead';
else if (bot.isDead && bot.isDead()) reason = 'bot_dead';
- else if (botSession.plan === 'merchant') reason = 'merchant_duty';
- else if (botSession.partyCompanion === true && botSession.followPlayerSession) reason = 'already_grouped';
+ else if (!options.forceFriend && botSession.plan === 'merchant') reason = 'merchant_duty';
+ else if (!options.forceFriend && botSession.partyCompanion === true && botSession.followPlayerSession) reason = 'already_grouped';
else if (!options.ignoreDistance && result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
- else if (result.memory.trust <= -6) reason = 'low_trust';
- else if (result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
- else if (Math.abs(bot.fetchLevel() - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';
+ else if (!options.forceFriend && result.memory.trust <= -6) reason = 'low_trust';
+ else if (!options.forceFriend && result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
+ else if (!options.forceFriend && Math.abs(bot.fetchLevel() - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';
- if (reason === 'available' && !result.clanmate) {
+ if (reason === 'available' && !result.clanmate && !options.forceFriend) {
result.partyDecision = PersonaPartyDecisionPolicy.evaluate(botSession, result.memory);
if (!result.partyDecision.accept) {
reason = result.partyDecision.reason;
@@ -111,13 +111,13 @@ const BotAvailability = {
if (result.clanmate) reason = 'available';
else if (player.isDead && player.isDead()) reason = 'player_dead';
else if (state.activity === 'dead' || Number(state.vitals?.hp || 1) <= 0) reason = 'bot_dead';
- else if (state.activity === 'merchant' || state.activity === 'crafting') reason = 'merchant_duty';
+ else if (!options.forceFriend && (state.activity === 'merchant' || state.activity === 'crafting')) reason = 'merchant_duty';
else if (!options.ignoreDistance && result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
- else if (result.memory.trust <= -6) reason = 'low_trust';
- else if (result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
- else if (Math.abs(Number(state.level || 1) - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';
+ else if (!options.forceFriend && result.memory.trust <= -6) reason = 'low_trust';
+ else if (!options.forceFriend && result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
+ else if (!options.forceFriend && Math.abs(Number(state.level || 1) - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';
- if (reason === 'available' && !result.clanmate) {
+ if (reason === 'available' && !result.clanmate && !options.forceFriend) {
result.partyDecision = PersonaPartyDecisionPolicy.evaluate(state, result.memory);
if (!result.partyDecision.accept) {
reason = result.partyDecision.reason;
diff --git a/src/GameServer/Bot/AI/BotFriendship.js b/src/GameServer/Bot/AI/BotFriendship.js
index fad43221..dcc6b317 100644
--- a/src/GameServer/Bot/AI/BotFriendship.js
+++ b/src/GameServer/Bot/AI/BotFriendship.js
@@ -36,6 +36,13 @@ const BotFriendship = {
if (!playerId || !botId) return Promise.resolve(false);
return Database.execute(["SELECT 1 FROM bot_friendships WHERE playerId = ? AND botId = ? AND status = 'accepted'", [playerId, Number(botId)]]).then((rows) => !!rows[0]);
},
+ remove(player, botId) {
+ const playerId = id(player);
+ if (!playerId || !botId) return Promise.resolve({ ok: false, reason: 'missing_bot' });
+ return Database.execute(['DELETE FROM bot_friend_roster WHERE playerId = ? AND botId = ?', [playerId, Number(botId)]])
+ .then(() => Database.execute(['DELETE FROM bot_friendships WHERE playerId = ? AND botId = ?', [playerId, Number(botId)]]))
+ .then(() => ({ ok: true }));
+ },
request(player, state) {
const playerId = id(player), botId = Number(state?.characterId || 0);
if (!playerId || !botId) return Promise.resolve({ ok: false, reason: 'missing_bot' });
diff --git a/src/GameServer/World/Generics/NpcBypasses/BotFriends.js b/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
index af7cc75f..4baebdc4 100644
--- a/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
+++ b/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
@@ -28,7 +28,9 @@ function render(session, mode = 'friends', currentPage = 0, notice = null) {
bots.forEach((bot) => {
const action = isAdd
? (bot.trust >= BotFriendship.FRIEND_TRUST ? Html.link('Add friend', `bot-friends request ${bot.name} ${currentPage}`, { color: Html.COLOR.ok }) : Html.font(`trust ${bot.trust}/${BotFriendship.FRIEND_TRUST}`, Html.COLOR.muted))
- : Html.link(bot.selected ? 'Const: ON' : 'Const: OFF', `bot-friends const ${bot.botId} ${currentPage}`, { color: bot.selected ? Html.COLOR.ok : Html.COLOR.link });
+ : Html.link(bot.selected ? 'Const: ON' : 'Const: OFF', `bot-friends const ${bot.botId} ${currentPage}`, { color: bot.selected ? Html.COLOR.ok : Html.COLOR.link })
+ + ''
+ + Html.link('Remove', `bot-friends remove ${bot.botId} ${currentPage}`, { color: Html.COLOR.danger });
body += Html.table([Html.row([
Html.cell(`${Html.font(bot.name, Html.COLOR.title)} Lv ${bot.level} ${bot.role}`, { width: 190 }),
Html.cell(action, { width: 95, align: 'right' })
@@ -58,6 +60,12 @@ function handler(session, parts) {
return render(session, 'add', parts[3], { ok: result.ok, message });
}));
}
+ if (mode === 'remove' && parts[2]) {
+ return BotFriendship.remove(session, parts[2]).then((result) => render(session, 'friends', parts[3], {
+ ok: result.ok,
+ message: result.ok ? 'Friend removed. Social memory was kept.' : 'The friend could not be removed.'
+ }));
+ }
if (mode === 'const' && parts[2]) return BotFriendship.toggleConst(session, parts[2]).then(() => render(session, 'friends', parts[3]));
if (mode === 'form') return BotFriendship.selected(session).then((bots) => bots.reduce((chain, bot) => chain.then(() => World.inviteFriendByName(session, session.actor, bot.characterName || bot.name, undefined, 'friend_const')), Promise.resolve()).then(() => render(session)));
render(session, mode, parts[2]);
diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js
index 8b9158fb..addacc90 100644
--- a/src/GameServer/World/World.js
+++ b/src/GameServer/World/World.js
@@ -343,6 +343,7 @@ const World = {
inviteFriendByName(session, actor, name, distribution, source = 'friend_invite') {
const BotFriendship = invoke('GameServer/Bot/AI/BotFriendship');
+ const BotManager = invoke('GameServer/Bot/BotManager');
const LifeState = invoke('GameServer/Bot/Population/BotLifeState');
const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService');
if (!PartyCompanionService.hasCapacity(session)) {
@@ -353,10 +354,19 @@ const World = {
if (!state) return false;
return BotFriendship.isFriend(session, state.characterId).then((friend) => {
if (!friend) return false;
+ const hotSession = BotManager.findSessionByName(name);
+ const previousLeader = hotSession?.partyCompanion === true ? hotSession.followPlayerSession : null;
+ const leaveActiveParty = previousLeader && previousLeader !== session
+ ? Promise.resolve(PartyCompanionService.detach(previousLeader, hotSession, { source: 'friend_priority' }))
+ : Promise.resolve(true);
const leaveBackgroundParty = state.party?.partyId
? LifeState.leaveParty(state, 'friend_priority')
: Promise.resolve(state);
- return leaveBackgroundParty.then(() => this.inviteBotByName(session, actor, name, distribution, source, { ignoreDistance: true }));
+ return Promise.all([leaveActiveParty, leaveBackgroundParty])
+ .then(() => this.inviteBotByName(session, actor, name, distribution, source, {
+ ignoreDistance: true,
+ forceFriend: true
+ }));
});
});
},
diff --git a/tests/test_bot_availability.js b/tests/test_bot_availability.js
index 651113f1..bd05bc93 100644
--- a/tests/test_bot_availability.js
+++ b/tests/test_bot_availability.js
@@ -106,6 +106,14 @@ try {
result = BotAvailability.evaluate(lowPlayer, soloBot);
assert.strictEqual(result.available, false, 'a reserved persona may decline after all hard checks pass');
assert.strictEqual(result.reason, 'prefers_solo');
+ result = BotAvailability.evaluate(lowPlayer, soloBot, { forceFriend: true, ignoreDistance: true });
+ assert.strictEqual(result.available, true, 'a const friend invite must override persona solo preference');
+
+ const farLowFriend = session(actor(2000015, 55, 0, { locX: 100000 }), {
+ persona: { primaryDrive: 'wealth', traits: { sociability: 0.30, empathy: 0.35, commitment: 0.45 } }
+ });
+ result = BotAvailability.evaluate(lowPlayer, farLowFriend, { forceFriend: true, ignoreDistance: true });
+ assert.strictEqual(result.available, true, 'a const friend invite must override distance and level soft gates');
const farSocialBot = session(actor(2000014, 20, 0, { locX: 100000 }), {
persona: { primaryDrive: 'social', traits: { sociability: 0.80, empathy: 0.80, commitment: 0.70 } }
diff --git a/tests/test_bot_friendship.js b/tests/test_bot_friendship.js
index b0645e35..33c5f5b7 100644
--- a/tests/test_bot_friendship.js
+++ b/tests/test_bot_friendship.js
@@ -38,6 +38,8 @@ Promise.all([
requestSocial = { trust: 20, insults: 0, recentlyAbandonedAt: Date.now() - 1000 };
const coolingDown = await BotFriendship.request({ characterId: 42 }, { characterId: 101, name: 'CoolingFriend' });
assert.strictEqual(coolingDown.reason, 'recently_abandoned', 'a recent abandonment must still be respected');
+ const removed = await BotFriendship.remove({ characterId: 42 }, 100);
+ assert.strictEqual(removed.ok, true, 'removing a friend should clear friendship and const membership');
console.log('Bot friendship roster checks passed');
}).catch((error) => {
console.error(error);
From 20805a466c50759b80774dd9bea8dfc24a71eb0b Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Fri, 31 Jul 2026 16:16:57 -0400
Subject: [PATCH 17/17] Guard static bots from friend recruitment
---
src/GameServer/Bot/AI/BotAvailability.js | 16 ++++++++++++++++
src/GameServer/Bot/AI/BotFriendship.js | 7 +++++++
.../World/Generics/NpcBypasses/BotFriends.js | 1 +
tests/test_bot_availability.js | 18 ++++++++++++++++++
tests/test_bot_friendship.js | 6 ++++++
tests/test_party_companion_rest_follow.js | 11 ++++++++++-
6 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/src/GameServer/Bot/AI/BotAvailability.js b/src/GameServer/Bot/AI/BotAvailability.js
index 8e453b4d..037a0e86 100644
--- a/src/GameServer/Bot/AI/BotAvailability.js
+++ b/src/GameServer/Bot/AI/BotAvailability.js
@@ -49,6 +49,18 @@ function sameClan(player, botSubject) {
return playerClanId === clanIdOf(botSubject);
}
+function isStaticService(subject = {}) {
+ const stats = subject?.stats || subject?.coldCraftState?.stats || {};
+ if (stats.craftStationId || stats.craftShop || subject?.manufactureShop) return true;
+
+ // Permanent private-store bots do not have a cold state. A market store
+ // or craft state marks an adventurer that may still be called by a friend.
+ return subject?.plan === 'merchant'
+ && !subject?.coldMarketState
+ && !subject?.coldCraftState
+ && !subject?.coldLifeState;
+}
+
function emptyResult(playerSession, botSubject) {
const memory = BotSocialMemory.getSnapshot(playerSession, botSubject);
return {
@@ -74,11 +86,13 @@ const BotAvailability = {
result.distance = distance(actorLocation(player), actorLocation(bot));
result.clanmate = sameClan(player, bot);
+ const staticService = isStaticService(botSession);
let reason = 'available';
if (result.clanmate) reason = 'available';
else if (player.isDead && player.isDead()) reason = 'player_dead';
else if (bot.isDead && bot.isDead()) reason = 'bot_dead';
+ else if (staticService) reason = 'merchant_duty';
else if (!options.forceFriend && botSession.plan === 'merchant') reason = 'merchant_duty';
else if (!options.forceFriend && botSession.partyCompanion === true && botSession.followPlayerSession) reason = 'already_grouped';
else if (!options.ignoreDistance && result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
@@ -106,11 +120,13 @@ const BotAvailability = {
result.distance = distance(actorLocation(player), state.loc);
result.clanmate = sameClan(player, state);
+ const staticService = isStaticService(state);
let reason = 'available';
if (result.clanmate) reason = 'available';
else if (player.isDead && player.isDead()) reason = 'player_dead';
else if (state.activity === 'dead' || Number(state.vitals?.hp || 1) <= 0) reason = 'bot_dead';
+ else if (staticService) reason = 'merchant_duty';
else if (!options.forceFriend && (state.activity === 'merchant' || state.activity === 'crafting')) reason = 'merchant_duty';
else if (!options.ignoreDistance && result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
else if (!options.forceFriend && result.memory.trust <= -6) reason = 'low_trust';
diff --git a/src/GameServer/Bot/AI/BotFriendship.js b/src/GameServer/Bot/AI/BotFriendship.js
index dcc6b317..42fffb5b 100644
--- a/src/GameServer/Bot/AI/BotFriendship.js
+++ b/src/GameServer/Bot/AI/BotFriendship.js
@@ -9,6 +9,10 @@ const rosterWrites = new Map();
function id(subject) { return Number(subject?.characterId || subject?.actor?.fetchId?.() || 0); }
function page(value) { return Math.max(0, Number(value) || 0); }
+function isStaticService(state = {}) {
+ const stats = state.stats || {};
+ return Boolean(stats.craftStationId || stats.craftShop);
+}
function normalize(row) {
let stats = {};
try { stats = JSON.parse(row.statsJson || '{}'); } catch {}
@@ -21,6 +25,8 @@ function list(playerId, where, currentPage) {
LEFT JOIN bot_friendships f ON f.playerId = s.playerId AND f.botId = s.botId
LEFT JOIN bot_friend_roster r ON r.playerId = s.playerId AND r.botId = s.botId
WHERE s.playerId = ? AND ${where}
+ AND json_extract(COALESCE(l.statsJson, '{}'), '$.craftStationId') IS NULL
+ AND json_extract(COALESCE(l.statsJson, '{}'), '$.craftShop') IS NULL
ORDER BY s.trust DESC, s.familiarity DESC, l.characterName COLLATE NOCASE LIMIT ? OFFSET ?`,
[playerId, PAGE_SIZE, page(currentPage) * PAGE_SIZE]
]).then((rows) => rows.map(normalize));
@@ -46,6 +52,7 @@ const BotFriendship = {
request(player, state) {
const playerId = id(player), botId = Number(state?.characterId || 0);
if (!playerId || !botId) return Promise.resolve({ ok: false, reason: 'missing_bot' });
+ if (isStaticService(state)) return Promise.resolve({ ok: false, reason: 'merchant_duty', trust: 0, persona: null });
return Database.execute(['SELECT * FROM bot_social_memory WHERE playerId = ? AND botId = ?', [playerId, botId]]).then((rows) => {
const social = rows[0] || {};
const now = Date.now();
diff --git a/src/GameServer/World/Generics/NpcBypasses/BotFriends.js b/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
index 4baebdc4..e2657937 100644
--- a/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
+++ b/src/GameServer/World/Generics/NpcBypasses/BotFriends.js
@@ -9,6 +9,7 @@ function requestReasonText(reason) {
low_trust: 'trust is still too low',
insults: 'the bot remembers an insult',
recently_abandoned: 'the bot needs a little time after the last abandonment',
+ merchant_duty: 'this is a fixed service bot',
missing_bot: 'the bot could not be found'
}[reason] || 'the request was declined';
}
diff --git a/tests/test_bot_availability.js b/tests/test_bot_availability.js
index bd05bc93..40206134 100644
--- a/tests/test_bot_availability.js
+++ b/tests/test_bot_availability.js
@@ -121,6 +121,24 @@ try {
result = BotAvailability.evaluate(lowPlayer, farSocialBot);
assert.strictEqual(result.reason, 'too_far', 'persona must not override a hard invite gate');
+ const staticCraftState = {
+ characterId: 2000016,
+ name: 'PublicCrafter',
+ level: 70,
+ activity: 'crafting',
+ loc: { locX: 0, locY: 0, locZ: 0 },
+ vitals: { hp: 100, maxHp: 100 },
+ stats: { craftStationId: 'giran_weapons', craftShop: { town: 'Giran' } }
+ };
+ result = BotAvailability.evaluateState(lowPlayer, staticCraftState, { forceFriend: true, ignoreDistance: true });
+ assert.strictEqual(result.available, false, 'const friend overrides must never recruit a public craft service');
+ assert.strictEqual(result.reason, 'merchant_duty');
+
+ const staticMerchant = session(actor(2000017, 20), { plan: 'merchant' });
+ result = BotAvailability.evaluate(lowPlayer, staticMerchant, { forceFriend: true, ignoreDistance: true });
+ assert.strictEqual(result.available, false, 'const friend overrides must never recruit a fixed merchant');
+ assert.strictEqual(result.reason, 'merchant_duty');
+
console.log('Bot availability checks passed');
} finally {
BotSocialMemory.getSnapshot = originalGetSnapshot;
diff --git a/tests/test_bot_friendship.js b/tests/test_bot_friendship.js
index 33c5f5b7..524a336f 100644
--- a/tests/test_bot_friendship.js
+++ b/tests/test_bot_friendship.js
@@ -35,6 +35,12 @@ Promise.all([
assert.strictEqual(rosterCount, 8);
const accepted = await BotFriendship.request({ characterId: 42 }, { characterId: 100, name: 'OldFriend' });
assert.strictEqual(accepted.ok, true, 'an old abandonment cooldown must not block friendship forever');
+ const staticService = await BotFriendship.request({ characterId: 42 }, {
+ characterId: 102,
+ name: 'PublicCrafter',
+ stats: { craftStationId: 'giran_weapons', craftShop: { town: 'Giran' } }
+ });
+ assert.strictEqual(staticService.reason, 'merchant_duty', 'fixed craft services must not be eligible for friendship');
requestSocial = { trust: 20, insults: 0, recentlyAbandonedAt: Date.now() - 1000 };
const coolingDown = await BotFriendship.request({ characterId: 42 }, { characterId: 101, name: 'CoolingFriend' });
assert.strictEqual(coolingDown.reason, 'recently_abandoned', 'a recent abandonment must still be respected');
diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js
index 6f4e3fa6..57505df6 100644
--- a/tests/test_party_companion_rest_follow.js
+++ b/tests/test_party_companion_rest_follow.js
@@ -301,6 +301,15 @@ try {
const nativeAnswerSession = fakeSession('bot_native_party_answer', nativeAnswerBot);
const nativeDeclineBot = fakeActor(2000046, { locX: 70, locY: 0 });
const nativeDeclineSession = fakeSession('bot_native_party_decline', nativeDeclineBot);
+ // This test exercises the accepted invite lifecycle. Make the
+ // persona choice explicit now that ordinary invites are persona-aware.
+ const socialPersona = {
+ primaryDrive: 'social',
+ archetype: 'party_regular',
+ traits: { sociability: 0.82, empathy: 0.66, commitment: 0.66 }
+ };
+ inviteBotSession.persona = socialPersona;
+ nativeAnswerSession.persona = socialPersona;
BotManager.sessions = [inviteBotSession, nativeAnswerSession, nativeDeclineSession];
assert.strictEqual(World.inviteBotCompanion(leaderSession, leader, inviteBotSession, 1, 'test_invite'), true, 'available resting bot should join the party');
@@ -336,7 +345,7 @@ try {
BotSocialMemory.getSnapshot = originalSocialSnapshot;
BotSocialMemory.recordEvent = originalSocialRecordEvent;
}
- assert.strictEqual(inviteTell, `I'm with you. Lead the way.`, 'a recovered invite acknowledgement should not promise another rest');
+ assert.strictEqual(inviteTell, 'Gladly. A steady party is better than going alone.', 'an accepted persona-aware invite should acknowledge the party without promising another rest');
assert.strictEqual(inviteBotSession.plan, 'following', 'attaching a resting bot should resume party follow after instant recovery');
inviteBot.level = 17;
inviteBot.hp = 40;