From 8b7eda1a2973bb0799147e0cae1276231b3341bb Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Wed, 22 Jul 2026 17:45:33 -0400
Subject: [PATCH 01/16] Fix gatekeeper quest and teleport menu
---
src/GameServer/Quest/QuestService.js | 9 ++++++
src/GameServer/World/C4GatekeeperTeleports.js | 10 +++++-
.../Generics/NpcBypasses/GatekeeperQuest.js | 20 ++++++++++++
.../NpcBypasses/GatekeeperTeleport.js | 8 +++++
src/GameServer/World/Generics/NpcTalk.js | 31 ++++++++++++++-----
tests/test_gatekeeper_teleports.js | 19 +++++++++++-
6 files changed, 87 insertions(+), 10 deletions(-)
create mode 100644 src/GameServer/World/Generics/NpcBypasses/GatekeeperQuest.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index bb1a48a7..f7459c91 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -145,6 +145,14 @@ function handlesNpc(npc) {
return quests.some((quest) => quest.npcs.includes(npcId));
}
+// Gatekeepers can offer both travel and quest progress. The caller needs to
+// decide whether to expose the quest branch without opening it (talking to a
+// quest NPC may itself advance a quest), so keep this check read-only.
+async function hasTalk(session, npc) {
+ await ensureLoaded(session);
+ return Boolean(questForNpc(npc, session));
+}
+
function render(session, npc, html) {
session.dataSendToMe(ServerResponse.npcHtml(npc.fetchId(), html));
session.dataSendToMe(ServerResponse.actionFailed());
@@ -334,6 +342,7 @@ module.exports = {
onEvent,
onKill,
handlesNpc,
+ hasTalk,
mutate,
stateFor,
active,
diff --git a/src/GameServer/World/C4GatekeeperTeleports.js b/src/GameServer/World/C4GatekeeperTeleports.js
index 2885bd91..0a7531f2 100644
--- a/src/GameServer/World/C4GatekeeperTeleports.js
+++ b/src/GameServer/World/C4GatekeeperTeleports.js
@@ -47,4 +47,12 @@ function html(npcId) {
return `
Region where teleporting is possible
${links.join('')}
`;
}
-module.exports = { destination, html, lists: LISTS };
+function menu(npcId, hasQuest) {
+ if (!LISTS[npcId]) return null;
+ const quest = hasQuest
+ ? 'Quest'
+ : '';
+ return `How can I help you?
Teleport${quest}`;
+}
+
+module.exports = { destination, html, menu, lists: LISTS };
diff --git a/src/GameServer/World/Generics/NpcBypasses/GatekeeperQuest.js b/src/GameServer/World/Generics/NpcBypasses/GatekeeperQuest.js
new file mode 100644
index 00000000..51d734fb
--- /dev/null
+++ b/src/GameServer/World/Generics/NpcBypasses/GatekeeperQuest.js
@@ -0,0 +1,20 @@
+const ServerResponse = invoke('GameServer/Network/Response');
+
+module.exports = function gatekeeperQuest(session) {
+ const active = session.activeNpcTalk;
+ if (!active) return;
+
+ const QuestService = invoke('GameServer/Quest/QuestService');
+ const npc = {
+ fetchSelfId: () => active.selfId,
+ fetchId: () => active.objectId
+ };
+ QuestService.onTalk(session, npc).then((handled) => {
+ if (handled) return;
+ session.dataSendToMe(ServerResponse.npcHtml(active.objectId, 'There are no quests available.'));
+ session.dataSendToMe(ServerResponse.actionFailed());
+ }).catch((error) => {
+ utils.infoWarn('Quest', 'failed to open gatekeeper quest dialog: %s', error.message);
+ session.dataSendToMe(ServerResponse.actionFailed());
+ });
+};
diff --git a/src/GameServer/World/Generics/NpcBypasses/GatekeeperTeleport.js b/src/GameServer/World/Generics/NpcBypasses/GatekeeperTeleport.js
index a4795b03..70de097e 100644
--- a/src/GameServer/World/Generics/NpcBypasses/GatekeeperTeleport.js
+++ b/src/GameServer/World/Generics/NpcBypasses/GatekeeperTeleport.js
@@ -2,6 +2,14 @@ const ServerResponse = invoke('GameServer/Network/Response');
const C4GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports');
module.exports = function gatekeeperTeleport(session, parts) {
+ if (!parts?.[1]) {
+ const html = C4GatekeeperTeleports.html(session?.activeNpcTalk?.selfId);
+ if (html) {
+ session.dataSendToMe(ServerResponse.npcHtml(session.activeNpcTalk.objectId, html));
+ session.dataSendToMe(ServerResponse.actionFailed());
+ }
+ return;
+ }
const actor = session?.actor;
const destination = C4GatekeeperTeleports.destination(session?.activeNpcTalk?.selfId, Number(parts?.[1]));
if (!actor || !destination) return session?.dataSendToMe?.(ServerResponse.actionFailed());
diff --git a/src/GameServer/World/Generics/NpcTalk.js b/src/GameServer/World/Generics/NpcTalk.js
index 6af9f864..85bf2ca4 100644
--- a/src/GameServer/World/Generics/NpcTalk.js
+++ b/src/GameServer/World/Generics/NpcTalk.js
@@ -12,6 +12,20 @@ function npcTalk(session, npc) {
title
};
+ const C4GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports');
+ if (C4GatekeeperTeleports.html(npc.fetchSelfId())) {
+ // A gatekeeper can simultaneously be a quest NPC. Do not let quest
+ // progress replace travel: offer the player both branches first.
+ const QuestService = invoke('GameServer/Quest/QuestService');
+ QuestService.hasTalk(session, npc).then((hasQuest) => {
+ showGatekeeperTalk(session, npc, hasQuest);
+ }).catch((error) => {
+ utils.infoWarn('Quest', 'failed to inspect gatekeeper quests: %s', error.message);
+ showGatekeeperTalk(session, npc, false);
+ });
+ return;
+ }
+
// Quest dialogue has priority over generic NPC HTML. The service loads
// persistent state before selecting a quest, so an unrelated NPC keeps its
// normal dialog while a quest NPC resumes exactly where the player stopped.
@@ -28,6 +42,15 @@ function npcTalk(session, npc) {
});
}
+function showGatekeeperTalk(session, npc, hasQuest) {
+ const C4GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports');
+ session.dataSendToMe(ServerResponse.npcHtml(
+ npc.fetchId(),
+ C4GatekeeperTeleports.menu(npc.fetchSelfId(), hasQuest)
+ ));
+ session.dataSendToMe(ServerResponse.actionFailed());
+}
+
function showDefaultTalk(session, npc) {
const path = 'data/Html/';
const filename = path + npc.fetchSelfId() + '.html';
@@ -43,14 +66,6 @@ function showDefaultTalk(session, npc) {
return;
}
- const C4GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports');
- const gatekeeperHtml = C4GatekeeperTeleports.html(npc.fetchSelfId());
- if (gatekeeperHtml) {
- session.dataSendToMe(ServerResponse.npcHtml(npc.fetchId(), gatekeeperHtml));
- session.dataSendToMe(ServerResponse.actionFailed());
- return;
- }
-
session.dataSendToMe(
ServerResponse.npcHtml(npc.fetchId(), utils.parseRawFile(
utils.fileExists(filename) ? filename : path + 'noquest.html'
diff --git a/tests/test_gatekeeper_teleports.js b/tests/test_gatekeeper_teleports.js
index 9ac865a5..1b56210e 100644
--- a/tests/test_gatekeeper_teleports.js
+++ b/tests/test_gatekeeper_teleports.js
@@ -4,6 +4,7 @@ require('../src/Global');
const GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports');
const LateTownGatekeepers = invoke('GameServer/World/C4LateTownGatekeepers');
+const QuestService = invoke('GameServer/Quest/QuestService');
const cityGatekeepers = [7006, 7059, 7080, 7134, 7146, 7162, 7177, 7233, 7256, 7320, 7540, 7576, 7848, 8275, 8320];
for (const npcId of cityGatekeepers) {
@@ -12,6 +13,10 @@ for (const npcId of cityGatekeepers) {
for (const [id] of GatekeeperTeleports.lists[npcId]) {
assert.ok(GatekeeperTeleports.destination(npcId, id), `gatekeeper ${npcId} destination ${id} must resolve`);
}
+ assert.match(GatekeeperTeleports.menu(npcId, false), /gatekeeper-teleport/, `gatekeeper ${npcId} must always offer teleport from its main dialog`);
+ assert.doesNotMatch(GatekeeperTeleports.menu(npcId, false), /gatekeeper-quest/, `gatekeeper ${npcId} must not offer an unavailable quest`);
+ assert.match(GatekeeperTeleports.menu(npcId, true), /gatekeeper-teleport/, `quest-capable gatekeeper ${npcId} must keep teleport in its main dialog`);
+ assert.match(GatekeeperTeleports.menu(npcId, true), /gatekeeper-quest/, `quest-capable gatekeeper ${npcId} must offer the quest branch`);
}
assert.strictEqual(GatekeeperTeleports.destination(7006, 18), null, 'a gatekeeper must not expose another city’s route by raw id');
@@ -25,4 +30,16 @@ for (const npcId of [8275, 8320]) {
assert.ok(LateTownGatekeepers.npcs.some((npc) => npc.selfId === npcId), `NPC template ${npcId} must be present`);
assert.ok(LateTownGatekeepers.spawns.some((group) => group.spawns.some((spawn) => spawn.selfId === npcId)), `NPC ${npcId} must spawn`);
}
-console.log('gatekeeper teleport checks passed');
+(async () => {
+ const session = {
+ actor: { fetchLevel: () => 10, fetchRace: () => 0 },
+ questStatesLoaded: true,
+ questStates: new Map()
+ };
+ assert.strictEqual(await QuestService.hasTalk(session, { fetchSelfId: () => 7006 }), true, 'Roxxy must expose the quest branch when Step into the Future can start');
+ assert.strictEqual(await QuestService.hasTalk(session, { fetchSelfId: () => 7059 }), false, 'a gatekeeper without a relevant quest must stay teleport-only');
+ console.log('gatekeeper teleport checks passed');
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
From beddfde63a92fa7e3d6bf2b88e9c4fb114d676b1 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:01:31 -0400
Subject: [PATCH 02/16] Add graded spiritshots to city merchants
---
src/GameServer/World/C4LateTownGatekeepers.js | 15 +++--
.../World/Generics/NpcShopBuyLists.js | 55 +++++++++++++++----
tests/test_gatekeeper_teleports.js | 4 ++
tests/test_npc_shop_stock.js | 28 ++++++++++
4 files changed, 87 insertions(+), 15 deletions(-)
diff --git a/src/GameServer/World/C4LateTownGatekeepers.js b/src/GameServer/World/C4LateTownGatekeepers.js
index 92ad89bb..10debd4b 100644
--- a/src/GameServer/World/C4LateTownGatekeepers.js
+++ b/src/GameServer/World/C4LateTownGatekeepers.js
@@ -1,7 +1,7 @@
-function gatekeeper(selfId, name) {
+function townNpc(selfId, name, title, kind = 'Teleporter') {
return {
selfId,
- template: { kind: 'Teleporter', name, title: 'Gatekeeper', level: 70, hostile: false },
+ template: { kind, name, title, level: 70, hostile: false },
base: { str: 40, dex: 30, con: 43, int: 21, wit: 20, men: 10 },
stats: { pAtk: 688.863725587608, pAtkRnd: 30, pDef: 295.91597408024, mAtk: 470.404627426724, mDef: 216.538467292763, accur: 4.75, atkSpd: 253, castSpd: 333, atkRadius: 40 },
speed: { walk: 80, run: 120 },
@@ -13,13 +13,20 @@ function gatekeeper(selfId, name) {
};
}
-const npcs = [gatekeeper(8275, 'Tatiana'), gatekeeper(8320, 'Ilyana')];
+const npcs = [
+ townNpc(8275, 'Tatiana', 'Gatekeeper'),
+ townNpc(8320, 'Ilyana', 'Gatekeeper'),
+ townNpc(8256, 'Leon', 'Trader', 'Merchant'),
+ townNpc(8300, 'Drumond', 'Trader', 'Merchant')
+];
const spawns = [{
selfId: 'c4_late_town_gatekeepers',
bounds: [{ locX: 43700, locY: -55300, minZ: -2800, maxZ: -700 }],
spawns: [
{ selfId: 8275, name: 'Tatiana', coords: [{ locX: 147966, locY: -55228, locZ: -2728, head: 48000 }], total: 1, respawn: 60, bias: 0 },
- { selfId: 8320, name: 'Ilyana', coords: [{ locX: 43824, locY: -47664, locZ: -792, head: 50000 }], total: 1, respawn: 60, bias: 0 }
+ { selfId: 8320, name: 'Ilyana', coords: [{ locX: 43824, locY: -47664, locZ: -792, head: 50000 }], total: 1, respawn: 60, bias: 0 },
+ { selfId: 8256, name: 'Leon', coords: [{ locX: 148832, locY: -58960, locZ: -2968, head: 22000 }], total: 1, respawn: 60, bias: 0 },
+ { selfId: 8300, name: 'Drumond', coords: [{ locX: 44692, locY: -47312, locZ: -792, head: 0 }], total: 1, respawn: 60, bias: 0 }
]
}];
diff --git a/src/GameServer/World/Generics/NpcShopBuyLists.js b/src/GameServer/World/Generics/NpcShopBuyLists.js
index d4d304ef..a9d903a8 100644
--- a/src/GameServer/World/Generics/NpcShopBuyLists.js
+++ b/src/GameServer/World/Generics/NpcShopBuyLists.js
@@ -9,6 +9,29 @@ function rangeEntries(start, end, basePrice) {
return Array.from({ length: end - start + 1 }, (_, index) => [start + index, basePrice]);
}
+const SPIRITSHOTS_BY_GRADE = [
+ [2509, 15],
+ [2510, 18],
+ [2511, 35],
+ [2512, 100],
+ [2513, 120],
+ [2514, 150]
+];
+const SHOT_GRADE_INDEX = { none: 0, d: 1, c: 2, b: 3, a: 4, s: 5 };
+
+function withSpiritshots(entries, grade) {
+ const maxIndex = SHOT_GRADE_INDEX[grade];
+ const existing = new Set(entries.map((entry) => Array.isArray(entry) ? entry[0] : entry.selfId));
+ const asObjects = entries.some((entry) => !Array.isArray(entry));
+ return [
+ ...entries,
+ ...SPIRITSHOTS_BY_GRADE
+ .slice(0, maxIndex + 1)
+ .filter(([selfId]) => !existing.has(selfId))
+ .map(([selfId, price]) => asObjects ? { selfId, price } : [selfId, price])
+ ];
+}
+
const ADVANCED_GROCER_BASE = [
[1835, 7],
[2509, 15],
@@ -128,6 +151,12 @@ const ADEN_GROCER_BASE = [
[5195, 400]
];
+const D_GROCER_BASE = withSpiritshots(ADVANCED_GROCER_BASE, 'd');
+const C_GROCER_BASE = withSpiritshots(ADVANCED_GROCER_BASE, 'c');
+const B_GROCER_BASE = withSpiritshots(ADVANCED_GROCER_BASE, 'b');
+const A_GROCER_BASE = withSpiritshots(ADEN_GROCER_BASE, 'a');
+const S_GROCER_BASE = withSpiritshots(ADVANCED_GROCER_BASE, 's');
+
const CEMA_GROCER_BASE = [
[1835, 7],
[2509, 15],
@@ -820,10 +849,10 @@ const LISTS = {
{ selfId: 4492, price: 14400 }
],
- gludioGrocer: withTax(ADVANCED_GROCER_BASE, 1.2),
- floranGrocer: withTax(ADVANCED_GROCER_BASE, 1.5),
- hunterGrocer: withTax(ADVANCED_GROCER_BASE, 1.3),
- dwarvenGrocer: withTax(DWARVEN_GROCER_BASE, 1.15),
+ gludioGrocer: withTax(D_GROCER_BASE, 1.2),
+ floranGrocer: withTax(D_GROCER_BASE, 1.5),
+ hunterGrocer: withTax(B_GROCER_BASE, 1.3),
+ dwarvenGrocer: withTax(withSpiritshots(DWARVEN_GROCER_BASE, 's'), 1.15),
dwarvenArmor: withTax(DWARVEN_ARMOR_BASE, 1.15),
hunterWeapons: withTax(STANDARD_PHYSICAL_WEAPON_BASE, 1.3),
hunterMysticWeapons: withTax(MYSTIC_WEAPON_BASE, 1.3),
@@ -839,20 +868,20 @@ const LISTS = {
giranBodyArmor: withTax(GIRAN_BODY_ARMOR_BASE, 1.1),
giranRobeAndAccessoryArmor: withTax(GIRAN_ROBE_AND_ACCESSORY_ARMOR_BASE, 1.1),
giranJewelry: withTax(GIRAN_JEWELRY_BASE, 1.1),
- giranGrocer: withTax(ADVANCED_GROCER_BASE, 1.1),
+ giranGrocer: withTax(C_GROCER_BASE, 1.1),
giranDyes: withTax(GIRAN_DYE_BASE, 1.1),
giranMagicBooks: withTax(GIRAN_MAGIC_BOOK_BASE, 1.1),
orenWeapons: withTax(STANDARD_PHYSICAL_WEAPON_BASE, 1.15),
orenMysticWeapons: withTax(MYSTIC_WEAPON_BASE, 1.15),
orenArmor: withTax(GLUDIO_ARMOR_BASE, 1.15),
- orenGrocer: withTax(ADVANCED_GROCER_BASE, 1.15),
+ orenGrocer: withTax(B_GROCER_BASE, 1.15),
orenDyes: withTax(BASIC_DYE_BASE, 1.15),
orenJewelry: withTax(ADVANCED_JEWELRY_BASE, 1.15),
orenMagicBooks: withTax(MAGIC_BOOK_BASE, 1.15),
adenWeapons: withTax(STANDARD_PHYSICAL_WEAPON_BASE, 1.2),
adenMysticWeapons: withTax(MYSTIC_WEAPON_BASE, 1.2),
adenArmor: withTax(GLUDIO_ARMOR_BASE, 1.2),
- adenGrocer: withTax(ADEN_GROCER_BASE, 1.2),
+ adenGrocer: withTax(A_GROCER_BASE, 1.2),
adenDyes: withTax(BASIC_DYE_BASE, 1.2),
adenJewelry: withTax(ADVANCED_JEWELRY_BASE, 1.2),
adenMagicBooks: withTax(MAGIC_BOOK_BASE, 1.2),
@@ -861,9 +890,11 @@ const LISTS = {
giranPetSupplies: withTax(GIRAN_PET_SUPPLY_BASE, 1.2),
cemaMysticWeapons: withTax(GIRAN_MYSTIC_WEAPON_BASE, 1.2),
cemaRobeAndAccessoryArmor: withTax(GIRAN_ROBE_AND_ACCESSORY_ARMOR_BASE, 1.2),
- cemaGrocer: withTax(CEMA_GROCER_BASE, 1.2),
+ cemaGrocer: withTax(withSpiritshots(CEMA_GROCER_BASE, 'b'), 1.2),
+ goddardGrocer: withTax(S_GROCER_BASE, 1.2),
+ runeGrocer: withTax(S_GROCER_BASE, 1.2),
- talkingIslandGrocer: [
+ talkingIslandGrocer: withSpiritshots([
{ selfId: 1835, price: 8 },
{ selfId: 2509, price: 17 },
{ selfId: 3947, price: 40 },
@@ -890,9 +921,9 @@ const LISTS = {
{ selfId: 4626, price: 575 },
{ selfId: 4627, price: 575 },
{ selfId: 4628, price: 575 }
- ],
+ ], 's'),
- grocery: [1060, 1061, 1831, 1833, 736, 737, 1835, 2509, 3947, 735, 1062, 1863, 17],
+ grocery: [...withSpiritshots([[1060], [1061], [1831], [1833], [736], [737], [1835], [3947], [735], [1062], [1863], [17]], 's')],
talkingIslandJewelry: [
{ selfId: 118, price: 76 },
@@ -1074,6 +1105,8 @@ const NPC_LISTS = {
7684: ['hunterWeapons', 'hunterMysticWeapons'],
7831: ['petSupplies'],
7834: ['cemaMysticWeapons', 'cemaRobeAndAccessoryArmor', 'cemaGrocer'],
+ 8256: ['goddardGrocer'],
+ 8300: ['runeGrocer'],
7253: ['gludinArmor'],
7254: ['gludioGrocer', 'gludinDyes'],
diff --git a/tests/test_gatekeeper_teleports.js b/tests/test_gatekeeper_teleports.js
index 1b56210e..dd695f8d 100644
--- a/tests/test_gatekeeper_teleports.js
+++ b/tests/test_gatekeeper_teleports.js
@@ -30,6 +30,10 @@ for (const npcId of [8275, 8320]) {
assert.ok(LateTownGatekeepers.npcs.some((npc) => npc.selfId === npcId), `NPC template ${npcId} must be present`);
assert.ok(LateTownGatekeepers.spawns.some((group) => group.spawns.some((spawn) => spawn.selfId === npcId)), `NPC ${npcId} must spawn`);
}
+for (const npcId of [8256, 8300]) {
+ assert.ok(LateTownGatekeepers.npcs.some((npc) => npc.selfId === npcId && npc.template.kind === 'Merchant'), `late-town merchant ${npcId} must be present`);
+ assert.ok(LateTownGatekeepers.spawns.some((group) => group.spawns.some((spawn) => spawn.selfId === npcId)), `late-town merchant ${npcId} must spawn`);
+}
(async () => {
const session = {
actor: { fetchLevel: () => 10, fetchRace: () => 0 },
diff --git a/tests/test_npc_shop_stock.js b/tests/test_npc_shop_stock.js
index cfd1bb4c..a6b79cbd 100644
--- a/tests/test_npc_shop_stock.js
+++ b/tests/test_npc_shop_stock.js
@@ -4,6 +4,7 @@ require('../src/Global');
const DataCache = invoke('GameServer/DataCache');
const BuyShop = invoke('GameServer/World/Generics/NpcBypasses/BuyShop');
+const NpcShopBuyLists = invoke('GameServer/World/Generics/NpcShopBuyLists');
DataCache.items = require('../data/Items/Others/others.json');
@@ -44,3 +45,30 @@ assert.strictEqual(rows.get(2509).amount, 0, 'NPC Spiritshot stock should be unl
assert.strictEqual(rows.get(17).amount, 0, 'NPC arrow stock should be unlimited in BuyList');
assert.strictEqual(rows.get(1060).amount, 0, 'NPC scroll stock should be unlimited in BuyList');
assert.strictEqual(rows.get(1835).price, 8, 'NPC shop should preserve audited per-NPC prices');
+
+const spiritshotsThrough = {
+ starter: [2509, 2510, 2511, 2512, 2513, 2514],
+ d: [2509, 2510],
+ c: [2509, 2510, 2511],
+ b: [2509, 2510, 2511, 2512],
+ a: [2509, 2510, 2511, 2512, 2513],
+ s: [2509, 2510, 2511, 2512, 2513, 2514]
+};
+const shopSpiritshots = (npcId) => NpcShopBuyLists.fetchForNpc(npcId)
+ .map((entry) => entry.selfId)
+ .filter((selfId) => selfId >= 2509 && selfId <= 2514);
+
+for (const npcId of [7004, 7137, 7150, 7519, 7561]) {
+ assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.starter, `starter merchant ${npcId} must stock every Spiritshot grade`);
+}
+for (const npcId of [7063, 7254, 7315]) {
+ assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.d, `D-grade city merchant ${npcId} must stock Spiritshot D`);
+}
+assert.deepStrictEqual(shopSpiritshots(7081), spiritshotsThrough.c, 'Giran must stock Spiritshot C');
+for (const npcId of [7180, 7301, 7834]) {
+ assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.b, `B-grade city merchant ${npcId} must stock Spiritshot B`);
+}
+assert.deepStrictEqual(shopSpiritshots(7839), spiritshotsThrough.a, 'Aden must stock Spiritshot A');
+for (const npcId of [8256, 8300]) {
+ assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.s, `late-town merchant ${npcId} must stock Spiritshot S`);
+}
From c8ba631f6862745809b012ee9873c98f92e4d8b6 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:25:06 -0400
Subject: [PATCH 03/16] Seed bot population in progression waves
---
config/default.ini | 5 +-
scripts/run-tests.js | 1 +
.../Bot/Population/GeneratedColdSeeder.js | 117 +++++++++++-------
.../Bot/Population/PopulationConfig.js | 16 ++-
.../Bot/Population/PopulationSeedPlanner.js | 99 +++++++++++++++
.../Bot/Population/PopulationService.js | 15 ++-
tests/test_population_seed_planner.js | 39 ++++++
7 files changed, 231 insertions(+), 61 deletions(-)
create mode 100644 src/GameServer/Bot/Population/PopulationSeedPlanner.js
create mode 100644 tests/test_population_seed_planner.js
diff --git a/config/default.ini b/config/default.ini
index 86bb1bcd..3e22d3b9 100644
--- a/config/default.ini
+++ b/config/default.ini
@@ -48,8 +48,9 @@ backgroundResolverEnabled = true
backgroundPartyEnabled = true
phasePolicyEnabled = true
directorEnabled = true
-generatedColdTarget = 100
-generatedColdBatchSize = 25
+maxPlayingPopulation = 1700
+initialStarterPopulation = 65
+generatedColdBatchSize = 50
generatedColdSeedDelayMs = 45000
activationRadius = 9000
activationLevelRange = 5
diff --git a/scripts/run-tests.js b/scripts/run-tests.js
index 8608396b..d5813073 100644
--- a/scripts/run-tests.js
+++ b/scripts/run-tests.js
@@ -18,6 +18,7 @@ const tests = [
'tests/test_bot_gear_skill_hints.js',
'tests/test_bot_class_progression.js',
'tests/test_generated_cold_skills.js',
+ 'tests/test_population_seed_planner.js',
'tests/test_bot_goal_state.js',
'tests/test_bot_goal_planner.js',
'tests/test_bot_goal_market_priority.js',
diff --git a/src/GameServer/Bot/Population/GeneratedColdSeeder.js b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
index ffcab3bc..8f7cee64 100644
--- a/src/GameServer/Bot/Population/GeneratedColdSeeder.js
+++ b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
@@ -9,6 +9,7 @@ const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints');
const ShotStock = invoke('GameServer/Inventory/ShotStock');
const BotClassProgression = invoke('GameServer/Bot/BotClassProgression');
const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService');
+const SeedPlanner = invoke('GameServer/Bot/Population/PopulationSeedPlanner');
const CLASS_POOL = [
{ race: 0, classId: 0, sex: 0, role: 'dps' },
@@ -54,10 +55,16 @@ function baseForIndex(index) {
return pick(index, CLASS_POOL);
}
-function profileForIndex(index, base = baseForIndex(index)) {
+function profileForIndex(index, base = baseForIndex(index), seedProfile = null) {
if (base.serviceCrafter) {
return { level: 70, band: 'craft_service' };
}
+ if (seedProfile?.level) {
+ return {
+ level: Math.max(1, Number(seedProfile.level)),
+ band: seedProfile.band || 'population_wave'
+ };
+ }
const roll = index % 20;
if (roll < 2) return { level: 2 + (index % 2), band: 'newbie' };
if (roll < 11) return { level: 4 + (index % 5), band: 'low' };
@@ -200,11 +207,11 @@ function ensureAccount(username) {
});
}
-function ensureCharacter(username, index, base = baseForIndex(index)) {
+function ensureCharacter(username, index, base = baseForIndex(index), seedProfile = null) {
return Database.fetchCharacters(username).then((characters) => {
if (characters[0]) {
const character = characters[0];
- const profile = profileForIndex(index, base);
+ const profile = profileForIndex(index, base, seedProfile);
const level = base.serviceCrafter ? profile.level : Number(character.level || profile.level);
const adena = Number(character.adena || Math.round(level * 85));
const classId = base.serviceCrafter ? base.classId : character.classId;
@@ -227,9 +234,9 @@ function ensureCharacter(username, index, base = baseForIndex(index)) {
}
const template = classInfo(base.classId);
- const levelProfile = profileForIndex(index, base);
+ const levelProfile = profileForIndex(index, base, seedProfile);
const level = levelProfile.level;
- const spot = targetSpot(level, index, base);
+ const spot = seedProfile?.spot || targetSpot(level, index, base);
const loc = randomNear(spot?.center || { locX: 0, locY: 0, locZ: 0 }, index);
const vitals = vitalsFor(template, level);
const charData = {
@@ -261,7 +268,8 @@ function ensureCharacter(username, index, base = baseForIndex(index)) {
function stateFor(character, index, seedMeta = {}) {
const base = seedMeta.base || baseForIndex(index);
const classId = Number(character.classId || base.classId);
- const level = Number(character.level || profileForIndex(index, base).level);
+ const levelProfile = seedMeta.levelProfile || profileForIndex(index, base, seedMeta.seedProfile);
+ const level = Number(character.level || levelProfile.level);
const spot = base.serviceCrafter ? null : seedMeta.spot || targetSpot(level, index, { ...base, classId });
const loc = seedMeta.loc || randomNear(spot?.center || {
locX: character.locX,
@@ -304,7 +312,8 @@ function stateFor(character, index, seedMeta = {}) {
classProgressionClassId: classId,
generatedCold: true,
generatedIndex: index,
- levelBand: profileForIndex(index, base).band
+ levelBand: levelProfile.band,
+ populationWave: seedMeta.populationWave || null
},
inventory: {
57: {
@@ -365,6 +374,9 @@ function sameRecipeEntries(left = [], right = []) {
const GeneratedColdSeeder = {
running: false,
+ // Millisecond-based slots keep generated accounts distinct across a
+ // restart; the base-36 form still fits the sixteen-character account name.
+ nextPopulationIndex: Date.now(),
awardProfileSkills,
craftServiceSeedState,
@@ -418,62 +430,71 @@ const GeneratedColdSeeder = {
return chain.then(() => ({ created, seeded }));
},
- seedToTarget(target = Config.generatedColdTarget) {
- const desired = Math.max(0, Number(target || 0));
- if (!desired || this.running) return Promise.resolve({ created: 0, desired, total: 0 });
+ seedPopulation() {
+ const limit = Math.max(0, Number(Config.maxPlayingPopulation || 0));
+ if (!limit || this.running) return Promise.resolve({ created: 0, seeded: 0, total: 0, limit });
this.running = true;
- return LifeState.levelHistogram().then((histogram) => {
- const total = Number(histogram.total || 0);
- const needed = Math.max(0, desired - total);
- const batch = Math.min(needed, Config.generatedColdBatchSize);
- if (batch <= 0) {
- return this.ensureCraftServices().then((services) => ({
- created: services.created,
- seeded: services.seeded,
- desired,
- total: total + services.seeded
- }));
- }
-
+ return Promise.resolve().then(() => {
+ const plan = SeedPlanner.plan(
+ SpotProfiles.ensure(),
+ LifeState.allStates(limit + 100),
+ limit,
+ Config.initialStarterPopulation
+ );
+ const batch = plan.missing.slice(0, SeedPlanner.seedBatchSize(plan, Config.generatedColdBatchSize));
let created = 0;
let seeded = 0;
let chain = Promise.resolve();
- const startIndex = total + 1;
-
- for (let offset = 0; offset < batch; offset++) {
- const index = startIndex + offset;
- chain = chain.then(() => {
- const username = usernameFor(index);
- return ensureAccount(username)
- .then(() => ensureCharacter(username, index))
- .then((result) => {
- const state = stateFor(result.character, index, result);
- const craftShop = state.stats?.craftShop;
- const recipesReady = craftShop
- ? CraftShopService.ensureRecipes(state.characterId, craftShop)
- : Promise.resolve(null);
- return recipesReady.then(() => LifeState.upsertState(state, 'generated_seed')).then((saved) => {
- if (saved && result.created) created += 1;
- if (saved) seeded += 1;
- return saved;
- });
+
+ batch.forEach((spot) => {
+ const index = this.nextPopulationIndex++;
+ const username = `bot_pop_${index.toString(36)}`.slice(0, 16);
+ const seedProfile = {
+ spot,
+ level: Math.max(1, Number(spot.minLevel || 1)),
+ band: `wave_${plan.maxMobLevel}`
+ };
+ chain = chain.then(() => ensureAccount(username)
+ .then(() => ensureCharacter(username, index, baseForIndex(index), seedProfile))
+ .then((result) => {
+ const state = stateFor(result.character, index, {
+ ...result,
+ seedProfile,
+ populationWave: plan.maxMobLevel,
+ spot,
+ loc: result.loc || randomNear(spot.center, index)
});
- });
- }
+ return LifeState.upsertState(state, 'population_wave_seed').then((saved) => {
+ if (saved && result.created) created += 1;
+ if (saved) seeded += 1;
+ return saved;
+ });
+ }));
+ });
return chain.then(() => this.ensureCraftServices()).then((services) => ({
created: created + services.created,
- seeded: seeded + services.seeded,
- desired,
- total: total + seeded + services.seeded
+ seeded,
+ total: plan.playing + seeded,
+ limit,
+ averageLevel: plan.averageLevel,
+ maxMobLevel: plan.maxMobLevel,
+ eligible: plan.eligible.length,
+ remaining: Math.max(0, plan.missing.length - seeded)
}));
}).catch((err) => {
utils.infoWarn('BotSeed', 'generated cold seed failed: %s', err.message);
- return { created: 0, seeded: 0, desired, total: 0, error: err.message };
+ return { created: 0, seeded: 0, total: 0, limit, error: err.message };
}).finally(() => {
this.running = false;
});
+ },
+
+ // Compatibility for callers outside PopulationService. The old target was
+ // a one-shot count; the server now always follows the staged population cap.
+ seedToTarget() {
+ return this.seedPopulation();
}
};
diff --git a/src/GameServer/Bot/Population/PopulationConfig.js b/src/GameServer/Bot/Population/PopulationConfig.js
index 04adff3f..23acade0 100644
--- a/src/GameServer/Bot/Population/PopulationConfig.js
+++ b/src/GameServer/Bot/Population/PopulationConfig.js
@@ -19,12 +19,15 @@ const DEFAULTS = {
partyFormationIntervalMs: 45000,
phasePolicyIntervalMs: 10000,
directorIntervalMs: 30000,
- generatedColdTarget: 100,
- generatedColdBatchSize: 25,
+ // Start with every level-one hunting sector, then grow in five-level
+ // waves. This is a cap for adventuring bots only; shop services are not
+ // part of the simulated player population.
+ maxPlayingPopulation: 1700,
+ initialStarterPopulation: 65,
+ generatedColdBatchSize: 50,
generatedColdSeedDelayMs: 45000,
- // The persistent world can contain substantially more than the initial
- // generated target after a restart. Twenty-five sequential resolves still
- // fit well inside the five-second scheduler interval.
+ // The persistent world is resolved in bounded batches so population
+ // expansion never becomes a database spike after a restart.
maxResolvesPerTick: 25,
maxPartyResolvesPerTick: 3,
maxMarketGoalReconcilesPerTick: 8,
@@ -77,7 +80,8 @@ const ENV_KEYS = {
backgroundPartyEnabled: 'BOT_BACKGROUND_PARTY_ENABLED',
phasePolicyEnabled: 'BOT_POPULATION_PHASE_POLICY_ENABLED',
directorEnabled: 'BOT_POPULATION_DIRECTOR_ENABLED',
- generatedColdTarget: 'BOT_POPULATION_TARGET',
+ maxPlayingPopulation: 'BOT_POPULATION_MAX_PLAYING',
+ initialStarterPopulation: 'BOT_POPULATION_INITIAL_STARTERS',
generatedColdBatchSize: 'BOT_POPULATION_BATCH_SIZE',
generatedColdSeedDelayMs: 'BOT_POPULATION_SEED_DELAY_MS',
cooldownGraceMs: 'BOT_COOLDOWN_GRACE_MS',
diff --git a/src/GameServer/Bot/Population/PopulationSeedPlanner.js b/src/GameServer/Bot/Population/PopulationSeedPlanner.js
new file mode 100644
index 00000000..97cdfddf
--- /dev/null
+++ b/src/GameServer/Bot/Population/PopulationSeedPlanner.js
@@ -0,0 +1,99 @@
+function number(value, fallback = 0) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function isPlaying(state = {}) {
+ return !['merchant', 'crafting'].includes(state.activity);
+}
+
+function snapshot(states = []) {
+ const playing = states.filter(isPlaying);
+ const levelTotal = playing.reduce((sum, state) => sum + Math.max(1, number(state.level, 1)), 0);
+ const spots = playing.reduce((counts, state) => {
+ if (!state.spotId) return counts;
+ counts[state.spotId] = number(counts[state.spotId]) + 1;
+ return counts;
+ }, {});
+
+ return {
+ playing: playing.length,
+ averageLevel: playing.length ? levelTotal / playing.length : 0,
+ spots,
+ hasPopulationSeed: playing.some((state) => !!state.stats?.populationWave)
+ };
+}
+
+function unlockedMobLevel(averageLevel) {
+ const average = Math.max(0, number(averageLevel));
+ // The first wave is deliberately only the genuine level-one grounds.
+ // Afterwards every five average levels opens the next five-level band.
+ return average < 5 ? 1 : (Math.floor(average / 5) * 5) + 1;
+}
+
+function eligibleSpots(profiles = [], maxMobLevel = 1) {
+ return profiles
+ .filter((spot) => number(spot.minLevel, 0) >= 1 && number(spot.minLevel, 0) <= maxMobLevel)
+ .sort((left, right) => number(left.minLevel, 0) - number(right.minLevel, 0)
+ || number(left.avgLevel, 0) - number(right.avgLevel, 0)
+ || String(left.id).localeCompare(String(right.id)));
+}
+
+function desiredSlots(spots = [], starterPopulation = 65) {
+ const starters = spots.filter((spot) => number(spot.minLevel, 0) === 1);
+ const starterTarget = Math.max(starters.length, number(starterPopulation, 65));
+ const starterCopies = starters.reduce((copies, spot, index) => {
+ const base = Math.floor(starterTarget / starters.length);
+ const extra = index < starterTarget % starters.length ? 1 : 0;
+ copies[spot.id] = base + extra;
+ return copies;
+ }, {});
+
+ return spots.flatMap((spot) => Array.from({ length: starterCopies[spot.id] || 1 }, () => spot));
+}
+
+function seedBatchSize(plan = {}, configuredBatch = 1) {
+ const normalBatch = Math.max(1, number(configuredBatch, 1));
+ // A fresh world must receive every starter slot together. Subsequent
+ // expansions remain bounded by the normal database-safe batch size.
+ return plan.hasPopulationSeed ? normalBatch : Math.max(normalBatch, Number(plan.missing?.length || 0));
+}
+
+function plan(profiles = [], states = [], maxPopulation = 1700, starterPopulation = 65) {
+ const current = snapshot(states);
+ const limit = Math.max(0, number(maxPopulation));
+ // Existing hand-authored or legacy bots must not make a brand-new world
+ // skip its starter wave. Once that wave exists, average level governs
+ // every following expansion.
+ const maxMobLevel = current.hasPopulationSeed ? unlockedMobLevel(current.averageLevel) : 1;
+ const eligible = eligibleSpots(profiles, maxMobLevel);
+ const available = Math.max(0, limit - current.playing);
+ const plannedSlots = desiredSlots(eligible, starterPopulation);
+ const occupied = { ...current.spots };
+ const missing = plannedSlots
+ .filter((spot) => {
+ const count = number(occupied[spot.id]);
+ occupied[spot.id] = count + 1;
+ return count < plannedSlots.filter((candidate) => candidate.id === spot.id).length;
+ })
+ .slice(0, available);
+
+ return {
+ ...current,
+ maxPopulation: limit,
+ maxMobLevel,
+ eligible,
+ plannedSlots,
+ missing
+ };
+}
+
+module.exports = {
+ isPlaying,
+ snapshot,
+ unlockedMobLevel,
+ eligibleSpots,
+ desiredSlots,
+ seedBatchSize,
+ plan
+};
diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js
index 3a7f2241..4c249d99 100644
--- a/src/GameServer/Bot/Population/PopulationService.js
+++ b/src/GameServer/Bot/Population/PopulationService.js
@@ -257,22 +257,27 @@ const PopulationService = {
},
scheduleGeneratedColdSeed(delayMs = Config.generatedColdSeedDelayMs) {
- if (Config.enabled === false || Config.generatedColdTarget <= 0 || this.seedTimer) return;
+ if (Config.enabled === false || Config.maxPlayingPopulation <= 0 || this.seedTimer) return;
this.seedTimer = setTimeout(() => {
this.seedTimer = null;
- GeneratedColdSeeder.seedToTarget(Config.generatedColdTarget).then((result) => {
+ GeneratedColdSeeder.seedPopulation().then((result) => {
if (result.seeded > 0) {
console.info(
- 'BotPopulation :: generated cold seed seeded=%d created=%d total=%d target=%d',
+ 'BotPopulation :: population wave seeded=%d created=%d total=%d/%d avgLevel=%s unlockedMobLevel=%d eligibleSpots=%d',
result.seeded,
result.created,
result.total,
- result.desired
+ result.limit,
+ Number(result.averageLevel || 0).toFixed(1),
+ result.maxMobLevel || 1,
+ result.eligible || 0
);
}
- if (this.started && result.desired > 0 && result.total < result.desired && !result.error) {
+ // Keep checking the next wave: once the mean bot level crosses
+ // another five-level threshold, newly opened grounds are filled.
+ if (this.started && result.limit > 0 && result.total < result.limit && !result.error) {
this.scheduleGeneratedColdSeed(Config.generatedColdSeedDelayMs);
}
});
diff --git a/tests/test_population_seed_planner.js b/tests/test_population_seed_planner.js
new file mode 100644
index 00000000..43e1e32c
--- /dev/null
+++ b/tests/test_population_seed_planner.js
@@ -0,0 +1,39 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const Planner = invoke('GameServer/Bot/Population/PopulationSeedPlanner');
+
+const profiles = [
+ { id: 'starter_a', minLevel: 1, avgLevel: 1 },
+ { id: 'starter_b', minLevel: 1, avgLevel: 1 },
+ { id: 'level_six', minLevel: 6, avgLevel: 6 },
+ { id: 'level_twelve', minLevel: 12, avgLevel: 12 }
+];
+
+const initial = Planner.plan(profiles, [], 1700, 5);
+assert.strictEqual(initial.averageLevel, 0);
+assert.strictEqual(initial.maxMobLevel, 1);
+assert.deepStrictEqual(initial.missing.map((spot) => spot.id), ['starter_a', 'starter_a', 'starter_a', 'starter_b', 'starter_b'],
+ 'first start must cover every level-one starter sector and reach the planned starter population');
+assert.strictEqual(Planner.seedBatchSize(initial, 2), 5,
+ 'the first wave must not be split by the normal seed batch limit');
+
+const progressed = Planner.plan(profiles, [
+ { characterId: 1, level: 5, spotId: 'moved_on', activity: 'hunting', stats: { populationWave: 1 } },
+ { characterId: 2, level: 70, spotId: null, activity: 'crafting' }
+], 1700, 5);
+assert.strictEqual(progressed.averageLevel, 5, 'craft services must not accelerate population waves');
+assert.strictEqual(progressed.maxMobLevel, 6);
+assert.deepStrictEqual(progressed.missing.map((spot) => spot.id), ['starter_a', 'starter_a', 'starter_a', 'starter_b', 'starter_b', 'level_six'],
+ 'at average level 5 the vacated starter grounds are refilled and the next band opens');
+assert.strictEqual(Planner.seedBatchSize(progressed, 2), 2,
+ 'later waves must still respect the normal seed batch limit');
+
+const capped = Planner.plan(profiles, [
+ { characterId: 1, level: 20, spotId: 'moved_a', activity: 'hunting', stats: { populationWave: 1 } },
+ { characterId: 2, level: 20, spotId: 'moved_b', activity: 'hunting', stats: { populationWave: 1 } }
+], 2, 5);
+assert.strictEqual(capped.missing.length, 0, 'the hard population cap must prevent any additional adventurer');
+
+console.log('Population seed planner checks passed');
From 1e8217e46b762756842e1d6e978b034817c4469d Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:50:47 -0400
Subject: [PATCH 04/16] Place grade spiritshots beside no-grade shots
---
.../World/Generics/NpcShopBuyLists.js | 20 ++++++++++++++-----
tests/test_npc_shop_stock.js | 3 +++
2 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/src/GameServer/World/Generics/NpcShopBuyLists.js b/src/GameServer/World/Generics/NpcShopBuyLists.js
index a9d903a8..44014a0f 100644
--- a/src/GameServer/World/Generics/NpcShopBuyLists.js
+++ b/src/GameServer/World/Generics/NpcShopBuyLists.js
@@ -23,12 +23,22 @@ function withSpiritshots(entries, grade) {
const maxIndex = SHOT_GRADE_INDEX[grade];
const existing = new Set(entries.map((entry) => Array.isArray(entry) ? entry[0] : entry.selfId));
const asObjects = entries.some((entry) => !Array.isArray(entry));
+ const additions = SPIRITSHOTS_BY_GRADE
+ .slice(0, maxIndex + 1)
+ .filter(([selfId]) => !existing.has(selfId))
+ .map(([selfId, price]) => asObjects ? { selfId, price } : [selfId, price]);
+ const noGradeShotIds = new Set([1835, 2509, 3947]);
+ const lastNoGradeShot = entries.reduce((last, entry, index) => (
+ noGradeShotIds.has(Array.isArray(entry) ? entry[0] : entry.selfId) ? index : last
+ ), -1);
+ const insertionIndex = lastNoGradeShot + 1;
+
+ // BuyList is a scrollable grid: appending shots after dyes makes the
+ // grade upgrade effectively invisible. Keep every grade beside no-grade.
return [
- ...entries,
- ...SPIRITSHOTS_BY_GRADE
- .slice(0, maxIndex + 1)
- .filter(([selfId]) => !existing.has(selfId))
- .map(([selfId, price]) => asObjects ? { selfId, price } : [selfId, price])
+ ...entries.slice(0, insertionIndex),
+ ...additions,
+ ...entries.slice(insertionIndex)
];
}
diff --git a/tests/test_npc_shop_stock.js b/tests/test_npc_shop_stock.js
index a6b79cbd..6f5a6ab4 100644
--- a/tests/test_npc_shop_stock.js
+++ b/tests/test_npc_shop_stock.js
@@ -64,6 +64,9 @@ for (const npcId of [7004, 7137, 7150, 7519, 7561]) {
for (const npcId of [7063, 7254, 7315]) {
assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.d, `D-grade city merchant ${npcId} must stock Spiritshot D`);
}
+const laraRows = NpcShopBuyLists.fetchForNpc(7063).map((entry) => entry.selfId);
+assert.strictEqual(laraRows.indexOf(2510), laraRows.indexOf(3947) + 1,
+ 'D Spiritshot must appear immediately after the no-grade shot rows, before ordinary supplies');
assert.deepStrictEqual(shopSpiritshots(7081), spiritshotsThrough.c, 'Giran must stock Spiritshot C');
for (const npcId of [7180, 7301, 7834]) {
assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.b, `B-grade city merchant ${npcId} must stock Spiritshot B`);
From 14e06a4c9032f47677ea34ef93533c8060c6481b Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:50:47 -0400
Subject: [PATCH 05/16] Seed starter cohorts by racial spawn
---
config/default.ini | 2 +-
.../Bot/Population/GeneratedColdSeeder.js | 19 ++--
.../Bot/Population/PopulationConfig.js | 4 +-
.../Bot/Population/PopulationSeedPlanner.js | 89 +++++++++++++------
.../Bot/Population/PopulationService.js | 5 +-
tests/test_population_seed_planner.js | 86 +++++++++++++-----
6 files changed, 143 insertions(+), 62 deletions(-)
diff --git a/config/default.ini b/config/default.ini
index 3e22d3b9..76e62889 100644
--- a/config/default.ini
+++ b/config/default.ini
@@ -49,7 +49,7 @@ backgroundPartyEnabled = true
phasePolicyEnabled = true
directorEnabled = true
maxPlayingPopulation = 1700
-initialStarterPopulation = 65
+starterBotsPerRace = 30
generatedColdBatchSize = 50
generatedColdSeedDelayMs = 45000
activationRadius = 9000
diff --git a/src/GameServer/Bot/Population/GeneratedColdSeeder.js b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
index 8f7cee64..0060630a 100644
--- a/src/GameServer/Bot/Population/GeneratedColdSeeder.js
+++ b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
@@ -126,7 +126,10 @@ function usernameFor(index) {
}
function nameFor(index) {
- return `${pick(index, NAME_STEMS)}${String(index).padStart(3, '0')}`.slice(0, 35);
+ // Character names are VARCHAR(16). Population-wave indexes are timestamps,
+ // so decimal formatting can no longer fit even though the old small index
+ // format did. Base-36 keeps the durable suffix unique and within the limit.
+ return `${pick(index, NAME_STEMS)}${Math.max(0, Number(index) || 0).toString(36).toUpperCase()}`.slice(0, 16);
}
function awardBaseGear(characterId, classId) {
@@ -313,7 +316,8 @@ function stateFor(character, index, seedMeta = {}) {
generatedCold: true,
generatedIndex: index,
levelBand: levelProfile.band,
- populationWave: seedMeta.populationWave || null
+ populationWave: seedMeta.populationWave || null,
+ starterRegion: seedMeta.starterRegion || null
},
inventory: {
57: {
@@ -380,6 +384,7 @@ const GeneratedColdSeeder = {
awardProfileSkills,
craftServiceSeedState,
+ nameFor,
ensureCraftServices() {
let created = 0;
@@ -440,7 +445,7 @@ const GeneratedColdSeeder = {
SpotProfiles.ensure(),
LifeState.allStates(limit + 100),
limit,
- Config.initialStarterPopulation
+ Config.starterBotsPerRace
);
const batch = plan.missing.slice(0, SeedPlanner.seedBatchSize(plan, Config.generatedColdBatchSize));
let created = 0;
@@ -453,7 +458,7 @@ const GeneratedColdSeeder = {
const seedProfile = {
spot,
level: Math.max(1, Number(spot.minLevel || 1)),
- band: `wave_${plan.maxMobLevel}`
+ band: `wave_${plan.wave}`
};
chain = chain.then(() => ensureAccount(username)
.then(() => ensureCharacter(username, index, baseForIndex(index), seedProfile))
@@ -461,7 +466,8 @@ const GeneratedColdSeeder = {
const state = stateFor(result.character, index, {
...result,
seedProfile,
- populationWave: plan.maxMobLevel,
+ populationWave: plan.wave,
+ starterRegion: spot.starterRegion,
spot,
loc: result.loc || randomNear(spot.center, index)
});
@@ -478,8 +484,9 @@ const GeneratedColdSeeder = {
seeded,
total: plan.playing + seeded,
limit,
+ targetPopulation: plan.targetPopulation,
averageLevel: plan.averageLevel,
- maxMobLevel: plan.maxMobLevel,
+ wave: plan.wave,
eligible: plan.eligible.length,
remaining: Math.max(0, plan.missing.length - seeded)
}));
diff --git a/src/GameServer/Bot/Population/PopulationConfig.js b/src/GameServer/Bot/Population/PopulationConfig.js
index 23acade0..4eeb0b83 100644
--- a/src/GameServer/Bot/Population/PopulationConfig.js
+++ b/src/GameServer/Bot/Population/PopulationConfig.js
@@ -23,7 +23,7 @@ const DEFAULTS = {
// waves. This is a cap for adventuring bots only; shop services are not
// part of the simulated player population.
maxPlayingPopulation: 1700,
- initialStarterPopulation: 65,
+ starterBotsPerRace: 30,
generatedColdBatchSize: 50,
generatedColdSeedDelayMs: 45000,
// The persistent world is resolved in bounded batches so population
@@ -81,7 +81,7 @@ const ENV_KEYS = {
phasePolicyEnabled: 'BOT_POPULATION_PHASE_POLICY_ENABLED',
directorEnabled: 'BOT_POPULATION_DIRECTOR_ENABLED',
maxPlayingPopulation: 'BOT_POPULATION_MAX_PLAYING',
- initialStarterPopulation: 'BOT_POPULATION_INITIAL_STARTERS',
+ starterBotsPerRace: 'BOT_POPULATION_STARTER_BOTS_PER_RACE',
generatedColdBatchSize: 'BOT_POPULATION_BATCH_SIZE',
generatedColdSeedDelayMs: 'BOT_POPULATION_SEED_DELAY_MS',
cooldownGraceMs: 'BOT_COOLDOWN_GRACE_MS',
diff --git a/src/GameServer/Bot/Population/PopulationSeedPlanner.js b/src/GameServer/Bot/Population/PopulationSeedPlanner.js
index 97cdfddf..b7be9b1c 100644
--- a/src/GameServer/Bot/Population/PopulationSeedPlanner.js
+++ b/src/GameServer/Bot/Population/PopulationSeedPlanner.js
@@ -3,12 +3,29 @@ function number(value, fallback = 0) {
return Number.isFinite(parsed) ? parsed : fallback;
}
+const STARTER_REGIONS = [
+ { id: 'human', center: { locX: -80000, locY: 250000 }, radius: 30000 },
+ { id: 'elf', center: { locX: 46000, locY: 40000 }, radius: 30000 },
+ { id: 'dark_elf', center: { locX: 27000, locY: 11000 }, radius: 30000 },
+ { id: 'orc', center: { locX: -57000, locY: -113000 }, radius: 30000 },
+ { id: 'dwarf', center: { locX: 108000, locY: -175000 }, radius: 30000 }
+];
+
+function distanceSquared(left = {}, right = {}) {
+ const dx = number(left.locX) - number(right.locX);
+ const dy = number(left.locY) - number(right.locY);
+ return (dx * dx) + (dy * dy);
+}
+
function isPlaying(state = {}) {
return !['merchant', 'crafting'].includes(state.activity);
}
function snapshot(states = []) {
const playing = states.filter(isPlaying);
+ const population = playing.filter((state) => Number(state.stats?.populationWave || 0) > 0);
+ const latestWave = population.reduce((highest, state) => Math.max(highest, Number(state.stats?.populationWave || 0)), 0);
+ const latestCohort = population.filter((state) => Number(state.stats?.populationWave || 0) === latestWave);
const levelTotal = playing.reduce((sum, state) => sum + Math.max(1, number(state.level, 1)), 0);
const spots = playing.reduce((counts, state) => {
if (!state.spotId) return counts;
@@ -19,16 +36,23 @@ function snapshot(states = []) {
return {
playing: playing.length,
averageLevel: playing.length ? levelTotal / playing.length : 0,
+ population: population.length,
+ latestWave,
+ latestCohortAverageLevel: latestCohort.length
+ ? latestCohort.reduce((sum, state) => sum + Math.max(1, number(state.level, 1)), 0) / latestCohort.length
+ : 0,
spots,
- hasPopulationSeed: playing.some((state) => !!state.stats?.populationWave)
+ hasPopulationSeed: population.length > 0
};
}
-function unlockedMobLevel(averageLevel) {
- const average = Math.max(0, number(averageLevel));
- // The first wave is deliberately only the genuine level-one grounds.
- // Afterwards every five average levels opens the next five-level band.
- return average < 5 ? 1 : (Math.floor(average / 5) * 5) + 1;
+function nextWave(snapshot = {}) {
+ if (!snapshot.hasPopulationSeed) return 1;
+ // Advance exactly one cohort at a time. Legacy/static bots must neither
+ // suppress the first wave nor jump several waves on server restart.
+ return snapshot.latestCohortAverageLevel >= 5
+ ? snapshot.latestWave + 1
+ : snapshot.latestWave;
}
function eligibleSpots(profiles = [], maxMobLevel = 1) {
@@ -39,36 +63,40 @@ function eligibleSpots(profiles = [], maxMobLevel = 1) {
|| String(left.id).localeCompare(String(right.id)));
}
-function desiredSlots(spots = [], starterPopulation = 65) {
+function starterSlots(spots = [], botsPerRace = 30, waves = 1) {
const starters = spots.filter((spot) => number(spot.minLevel, 0) === 1);
- const starterTarget = Math.max(starters.length, number(starterPopulation, 65));
- const starterCopies = starters.reduce((copies, spot, index) => {
- const base = Math.floor(starterTarget / starters.length);
- const extra = index < starterTarget % starters.length ? 1 : 0;
- copies[spot.id] = base + extra;
- return copies;
- }, {});
+ const slotsPerRace = Math.max(0, number(botsPerRace, 30)) * Math.max(1, number(waves, 1));
+ const fallback = starters.length ? starters : spots;
- return spots.flatMap((spot) => Array.from({ length: starterCopies[spot.id] || 1 }, () => spot));
+ return Array.from({ length: slotsPerRace }).flatMap((_, slot) => STARTER_REGIONS.flatMap((region) => {
+ const candidates = fallback
+ .map((spot) => ({ spot, distance: distanceSquared(spot.center, region.center) }))
+ .filter((candidate) => candidate.distance <= region.radius * region.radius)
+ .sort((left, right) => left.distance - right.distance || String(left.spot.id).localeCompare(String(right.spot.id)));
+ const selected = candidates[slot % Math.max(1, candidates.length)]?.spot;
+ return selected ? [{ ...selected, starterRegion: region.id }] : [];
+ }));
}
function seedBatchSize(plan = {}, configuredBatch = 1) {
const normalBatch = Math.max(1, number(configuredBatch, 1));
- // A fresh world must receive every starter slot together. Subsequent
- // expansions remain bounded by the normal database-safe batch size.
- return plan.hasPopulationSeed ? normalBatch : Math.max(normalBatch, Number(plan.missing?.length || 0));
+ // A cohort must land as one wave. Splitting it lets the new level-one bots
+ // lower the mean before the remaining members are created, which cancels
+ // the wave on the following check.
+ return Math.max(normalBatch, Number(plan.newBotsNeeded || plan.missing?.length || 0));
}
-function plan(profiles = [], states = [], maxPopulation = 1700, starterPopulation = 65) {
+function plan(profiles = [], states = [], maxPopulation = 1700, botsPerRace = 30) {
const current = snapshot(states);
const limit = Math.max(0, number(maxPopulation));
- // Existing hand-authored or legacy bots must not make a brand-new world
- // skip its starter wave. Once that wave exists, average level governs
- // every following expansion.
- const maxMobLevel = current.hasPopulationSeed ? unlockedMobLevel(current.averageLevel) : 1;
- const eligible = eligibleSpots(profiles, maxMobLevel);
+ const wave = nextWave(current);
+ const eligible = eligibleSpots(profiles, 1);
const available = Math.max(0, limit - current.playing);
- const plannedSlots = desiredSlots(eligible, starterPopulation);
+ const plannedSlots = starterSlots(eligible, botsPerRace, wave);
+ const targetPopulation = Math.min(limit, STARTER_REGIONS.length * Math.max(0, number(botsPerRace, 30)) * wave);
+ // The hard server cap includes everyone, but legacy/static bots do not
+ // replace the requested 30-per-race generated cohort.
+ const newBotsNeeded = Math.max(0, targetPopulation - current.population);
const occupied = { ...current.spots };
const missing = plannedSlots
.filter((spot) => {
@@ -76,12 +104,14 @@ function plan(profiles = [], states = [], maxPopulation = 1700, starterPopulatio
occupied[spot.id] = count + 1;
return count < plannedSlots.filter((candidate) => candidate.id === spot.id).length;
})
- .slice(0, available);
+ .slice(0, Math.min(available, newBotsNeeded));
return {
...current,
maxPopulation: limit,
- maxMobLevel,
+ wave,
+ targetPopulation,
+ newBotsNeeded,
eligible,
plannedSlots,
missing
@@ -91,9 +121,10 @@ function plan(profiles = [], states = [], maxPopulation = 1700, starterPopulatio
module.exports = {
isPlaying,
snapshot,
- unlockedMobLevel,
+ STARTER_REGIONS,
+ nextWave,
eligibleSpots,
- desiredSlots,
+ starterSlots,
seedBatchSize,
plan
};
diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js
index 4c249d99..135f3e7e 100644
--- a/src/GameServer/Bot/Population/PopulationService.js
+++ b/src/GameServer/Bot/Population/PopulationService.js
@@ -264,13 +264,14 @@ const PopulationService = {
GeneratedColdSeeder.seedPopulation().then((result) => {
if (result.seeded > 0) {
console.info(
- 'BotPopulation :: population wave seeded=%d created=%d total=%d/%d avgLevel=%s unlockedMobLevel=%d eligibleSpots=%d',
+ 'BotPopulation :: population wave=%d seeded=%d created=%d total=%d/%d target=%d avgLevel=%s starterSpots=%d',
+ result.wave || 1,
result.seeded,
result.created,
result.total,
result.limit,
+ result.targetPopulation || result.limit,
Number(result.averageLevel || 0).toFixed(1),
- result.maxMobLevel || 1,
result.eligible || 0
);
}
diff --git a/tests/test_population_seed_planner.js b/tests/test_population_seed_planner.js
index 43e1e32c..de36776d 100644
--- a/tests/test_population_seed_planner.js
+++ b/tests/test_population_seed_planner.js
@@ -3,37 +3,79 @@ const assert = require('assert');
require('../src/Global');
const Planner = invoke('GameServer/Bot/Population/PopulationSeedPlanner');
+const GeneratedColdSeeder = invoke('GameServer/Bot/Population/GeneratedColdSeeder');
-const profiles = [
- { id: 'starter_a', minLevel: 1, avgLevel: 1 },
- { id: 'starter_b', minLevel: 1, avgLevel: 1 },
- { id: 'level_six', minLevel: 6, avgLevel: 6 },
- { id: 'level_twelve', minLevel: 12, avgLevel: 12 }
-];
+const profiles = Planner.STARTER_REGIONS.map((region) => ({
+ id: `starter_${region.id}`,
+ minLevel: 1,
+ avgLevel: 1,
+ center: { ...region.center }
+}));
-const initial = Planner.plan(profiles, [], 1700, 5);
+const initial = Planner.plan(profiles, [], 1700, 30);
assert.strictEqual(initial.averageLevel, 0);
-assert.strictEqual(initial.maxMobLevel, 1);
-assert.deepStrictEqual(initial.missing.map((spot) => spot.id), ['starter_a', 'starter_a', 'starter_a', 'starter_b', 'starter_b'],
- 'first start must cover every level-one starter sector and reach the planned starter population');
-assert.strictEqual(Planner.seedBatchSize(initial, 2), 5,
+assert.strictEqual(initial.wave, 1);
+assert.strictEqual(initial.missing.length, 150, 'first start must create 30 bots at each of five racial spawns');
+assert.deepStrictEqual(
+ initial.missing.reduce((counts, spot) => ({ ...counts, [spot.starterRegion]: (counts[spot.starterRegion] || 0) + 1 }), {}),
+ { human: 30, elf: 30, dark_elf: 30, orc: 30, dwarf: 30 },
+ 'the first wave must be balanced between racial spawn regions'
+);
+assert.strictEqual(Planner.seedBatchSize(initial, 2), 150,
'the first wave must not be split by the normal seed batch limit');
+const legacyPopulation = Planner.plan(profiles, Array.from({ length: 132 }, (_, index) => ({
+ characterId: index + 1,
+ level: 16,
+ spotId: `legacy_${index}`,
+ activity: 'hunting',
+ stats: {}
+})), 1700, 30);
+assert.strictEqual(legacyPopulation.missing.length, 150,
+ 'legacy bots must not replace any member of the first 30-per-race cohort');
+
+const regionalSlots = Planner.starterSlots([
+ ...profiles,
+ { id: 'remote_starter', minLevel: 1, avgLevel: 1, center: { locX: 0, locY: 0 } }
+], 30, 1);
+regionalSlots.forEach((spot) => {
+ const region = Planner.STARTER_REGIONS.find((entry) => entry.id === spot.starterRegion);
+ const dx = spot.center.locX - region.center.locX;
+ const dy = spot.center.locY - region.center.locY;
+ assert.ok((dx * dx) + (dy * dy) <= region.radius * region.radius,
+ `${spot.starterRegion} slots must remain inside their racial starter region`);
+});
+
const progressed = Planner.plan(profiles, [
- { characterId: 1, level: 5, spotId: 'moved_on', activity: 'hunting', stats: { populationWave: 1 } },
+ ...initial.missing.map((spot, index) => ({
+ characterId: index + 1,
+ level: 5,
+ spotId: `moved_on_${index}`,
+ activity: 'hunting',
+ stats: { populationWave: 1 }
+ })),
{ characterId: 2, level: 70, spotId: null, activity: 'crafting' }
-], 1700, 5);
+], 1700, 30);
assert.strictEqual(progressed.averageLevel, 5, 'craft services must not accelerate population waves');
-assert.strictEqual(progressed.maxMobLevel, 6);
-assert.deepStrictEqual(progressed.missing.map((spot) => spot.id), ['starter_a', 'starter_a', 'starter_a', 'starter_b', 'starter_b', 'level_six'],
- 'at average level 5 the vacated starter grounds are refilled and the next band opens');
-assert.strictEqual(Planner.seedBatchSize(progressed, 2), 2,
- 'later waves must still respect the normal seed batch limit');
+assert.strictEqual(progressed.wave, 2);
+assert.strictEqual(progressed.targetPopulation, 300);
+assert.strictEqual(progressed.missing.length, 150,
+ 'at average level 5 exactly one additional 150-bot starter cohort opens');
+assert.strictEqual(Planner.seedBatchSize(progressed, 2), 150,
+ 'a later 150-bot cohort must not be split by the normal seed batch limit');
const capped = Planner.plan(profiles, [
- { characterId: 1, level: 20, spotId: 'moved_a', activity: 'hunting', stats: { populationWave: 1 } },
- { characterId: 2, level: 20, spotId: 'moved_b', activity: 'hunting', stats: { populationWave: 1 } }
-], 2, 5);
-assert.strictEqual(capped.missing.length, 0, 'the hard population cap must prevent any additional adventurer');
+ ...Array.from({ length: 1690 }, (_, index) => ({
+ characterId: index + 1,
+ level: 55,
+ spotId: `moved_${index}`,
+ activity: 'hunting',
+ stats: { populationWave: 11 }
+ }))
+], 1700, 30);
+assert.strictEqual(capped.missing.length, 10, 'the final partial wave must stop exactly at the hard population cap');
+
+const generatedName = GeneratedColdSeeder.nameFor(Date.now());
+assert.ok(generatedName.length <= 16, 'timestamp-based population slots must fit the character-name column');
console.log('Population seed planner checks passed');
From 745a43112126abeff05e26b8ef37c902bce105ab Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Wed, 22 Jul 2026 19:00:05 -0400
Subject: [PATCH 06/16] Add dedicated spiritshot merchants
---
src/GameServer/Bot/MerchantStoreConfigs.js | 111 ++++++++++++++++++
.../World/Generics/NpcShopBuyLists.js | 53 ++-------
tests/test_npc_shop_stock.js | 45 ++++---
3 files changed, 143 insertions(+), 66 deletions(-)
diff --git a/src/GameServer/Bot/MerchantStoreConfigs.js b/src/GameServer/Bot/MerchantStoreConfigs.js
index f3213003..e1f536ca 100644
--- a/src/GameServer/Bot/MerchantStoreConfigs.js
+++ b/src/GameServer/Bot/MerchantStoreConfigs.js
@@ -2,6 +2,10 @@ const BUY_CAP = 999999;
const s = (selfId, priceRate, count) => ({ selfId, priceRate, count });
const b = (selfId, priceRate, count = BUY_CAP) => ({ selfId, priceRate, count });
+const SPIRITSHOT_IDS = [2509, 2510, 2511, 2512, 2513, 2514];
+const spiritshotsThrough = (grade) => SPIRITSHOT_IDS
+ .slice(0, grade + 1)
+ .map((selfId) => s(selfId, 1, BUY_CAP));
module.exports = {
// Talking Island
@@ -232,5 +236,112 @@ module.exports = {
b(212, 0.56), b(284, 0.56), b(79, 0.56), b(97, 0.56),
b(856, 0.58), b(887, 0.58), b(918, 0.58)
]
+ },
+
+ // Spiritshots: dedicated private stores keep ordinary NPC grocery lists focused.
+ "Tia": {
+ title: "Spiritshots: all grades",
+ town: "Talking Island",
+ storeType: 1,
+ locX: -84250, locY: 244680, locZ: -3730,
+ items: spiritshotsThrough(5)
+ },
+ "Elya": {
+ title: "Spiritshots: all grades",
+ town: "Elven Village",
+ storeType: 1,
+ locX: 42700, locY: 50130, locZ: -2984,
+ items: spiritshotsThrough(5)
+ },
+ "Dena": {
+ title: "Spiritshots: all grades",
+ town: "Dark Elven Village",
+ storeType: 1,
+ locX: 12060, locY: 15740, locZ: -4554,
+ items: spiritshotsThrough(5)
+ },
+ "Orik": {
+ title: "Spiritshots: all grades",
+ town: "Orc Village",
+ storeType: 1,
+ locX: -44080, locY: -115380, locZ: -194,
+ items: spiritshotsThrough(5)
+ },
+ "Bran": {
+ title: "Spiritshots: all grades",
+ town: "Dwarven Village",
+ storeType: 1,
+ locX: 116360, locY: -177600, locZ: -914,
+ items: spiritshotsThrough(5)
+ },
+ "Rolf": {
+ title: "Spiritshots: D grade",
+ town: "Gludin",
+ storeType: 1,
+ locX: -79320, locY: 153900, locZ: -3160,
+ items: spiritshotsThrough(1)
+ },
+ "Sila": {
+ title: "Spiritshots: D grade",
+ town: "Gludio",
+ storeType: 1,
+ locX: -14480, locY: 123730, locZ: -3117,
+ items: spiritshotsThrough(1)
+ },
+ "Tara": {
+ title: "Spiritshots: D grade",
+ town: "Dion",
+ storeType: 1,
+ locX: 15910, locY: 143200, locZ: -2707,
+ items: spiritshotsThrough(1)
+ },
+ "Eris": {
+ title: "Spiritshots: C grade",
+ town: "Giran",
+ storeType: 1,
+ locX: 83600, locY: 148300, locZ: -3406,
+ items: spiritshotsThrough(2)
+ },
+ "Sera": {
+ title: "Spiritshots: B grade",
+ town: "Oren",
+ storeType: 1,
+ locX: 83200, locY: 53380, locZ: -1497,
+ items: spiritshotsThrough(3)
+ },
+ "Nora": {
+ title: "Spiritshots: B grade",
+ town: "Hunter's Village",
+ storeType: 1,
+ locX: 116760, locY: 74880, locZ: -2581,
+ items: spiritshotsThrough(3)
+ },
+ "Lina": {
+ title: "Spiritshots: B grade",
+ town: "Heine",
+ storeType: 1,
+ locX: 111500, locY: 219500, locZ: -3544,
+ items: spiritshotsThrough(3)
+ },
+ "Mila": {
+ title: "Spiritshots: A grade",
+ town: "Aden",
+ storeType: 1,
+ locX: 148980, locY: 28060, locZ: -2253,
+ items: spiritshotsThrough(4)
+ },
+ "Sven": {
+ title: "Spiritshots: S grade",
+ town: "Goddard",
+ storeType: 1,
+ locX: 148050, locY: -55340, locZ: -2728,
+ items: spiritshotsThrough(5)
+ },
+ "Runa": {
+ title: "Spiritshots: S grade",
+ town: "Rune",
+ storeType: 1,
+ locX: 43950, locY: -47720, locZ: -792,
+ items: spiritshotsThrough(5)
}
};
diff --git a/src/GameServer/World/Generics/NpcShopBuyLists.js b/src/GameServer/World/Generics/NpcShopBuyLists.js
index 44014a0f..97835e7c 100644
--- a/src/GameServer/World/Generics/NpcShopBuyLists.js
+++ b/src/GameServer/World/Generics/NpcShopBuyLists.js
@@ -9,39 +9,6 @@ function rangeEntries(start, end, basePrice) {
return Array.from({ length: end - start + 1 }, (_, index) => [start + index, basePrice]);
}
-const SPIRITSHOTS_BY_GRADE = [
- [2509, 15],
- [2510, 18],
- [2511, 35],
- [2512, 100],
- [2513, 120],
- [2514, 150]
-];
-const SHOT_GRADE_INDEX = { none: 0, d: 1, c: 2, b: 3, a: 4, s: 5 };
-
-function withSpiritshots(entries, grade) {
- const maxIndex = SHOT_GRADE_INDEX[grade];
- const existing = new Set(entries.map((entry) => Array.isArray(entry) ? entry[0] : entry.selfId));
- const asObjects = entries.some((entry) => !Array.isArray(entry));
- const additions = SPIRITSHOTS_BY_GRADE
- .slice(0, maxIndex + 1)
- .filter(([selfId]) => !existing.has(selfId))
- .map(([selfId, price]) => asObjects ? { selfId, price } : [selfId, price]);
- const noGradeShotIds = new Set([1835, 2509, 3947]);
- const lastNoGradeShot = entries.reduce((last, entry, index) => (
- noGradeShotIds.has(Array.isArray(entry) ? entry[0] : entry.selfId) ? index : last
- ), -1);
- const insertionIndex = lastNoGradeShot + 1;
-
- // BuyList is a scrollable grid: appending shots after dyes makes the
- // grade upgrade effectively invisible. Keep every grade beside no-grade.
- return [
- ...entries.slice(0, insertionIndex),
- ...additions,
- ...entries.slice(insertionIndex)
- ];
-}
-
const ADVANCED_GROCER_BASE = [
[1835, 7],
[2509, 15],
@@ -161,11 +128,11 @@ const ADEN_GROCER_BASE = [
[5195, 400]
];
-const D_GROCER_BASE = withSpiritshots(ADVANCED_GROCER_BASE, 'd');
-const C_GROCER_BASE = withSpiritshots(ADVANCED_GROCER_BASE, 'c');
-const B_GROCER_BASE = withSpiritshots(ADVANCED_GROCER_BASE, 'b');
-const A_GROCER_BASE = withSpiritshots(ADEN_GROCER_BASE, 'a');
-const S_GROCER_BASE = withSpiritshots(ADVANCED_GROCER_BASE, 's');
+const D_GROCER_BASE = ADVANCED_GROCER_BASE;
+const C_GROCER_BASE = ADVANCED_GROCER_BASE;
+const B_GROCER_BASE = ADVANCED_GROCER_BASE;
+const A_GROCER_BASE = ADEN_GROCER_BASE;
+const S_GROCER_BASE = ADVANCED_GROCER_BASE;
const CEMA_GROCER_BASE = [
[1835, 7],
@@ -862,7 +829,7 @@ const LISTS = {
gludioGrocer: withTax(D_GROCER_BASE, 1.2),
floranGrocer: withTax(D_GROCER_BASE, 1.5),
hunterGrocer: withTax(B_GROCER_BASE, 1.3),
- dwarvenGrocer: withTax(withSpiritshots(DWARVEN_GROCER_BASE, 's'), 1.15),
+ dwarvenGrocer: withTax(DWARVEN_GROCER_BASE, 1.15),
dwarvenArmor: withTax(DWARVEN_ARMOR_BASE, 1.15),
hunterWeapons: withTax(STANDARD_PHYSICAL_WEAPON_BASE, 1.3),
hunterMysticWeapons: withTax(MYSTIC_WEAPON_BASE, 1.3),
@@ -900,11 +867,11 @@ const LISTS = {
giranPetSupplies: withTax(GIRAN_PET_SUPPLY_BASE, 1.2),
cemaMysticWeapons: withTax(GIRAN_MYSTIC_WEAPON_BASE, 1.2),
cemaRobeAndAccessoryArmor: withTax(GIRAN_ROBE_AND_ACCESSORY_ARMOR_BASE, 1.2),
- cemaGrocer: withTax(withSpiritshots(CEMA_GROCER_BASE, 'b'), 1.2),
+ cemaGrocer: withTax(CEMA_GROCER_BASE, 1.2),
goddardGrocer: withTax(S_GROCER_BASE, 1.2),
runeGrocer: withTax(S_GROCER_BASE, 1.2),
- talkingIslandGrocer: withSpiritshots([
+ talkingIslandGrocer: [
{ selfId: 1835, price: 8 },
{ selfId: 2509, price: 17 },
{ selfId: 3947, price: 40 },
@@ -931,9 +898,9 @@ const LISTS = {
{ selfId: 4626, price: 575 },
{ selfId: 4627, price: 575 },
{ selfId: 4628, price: 575 }
- ], 's'),
+ ],
- grocery: [...withSpiritshots([[1060], [1061], [1831], [1833], [736], [737], [1835], [3947], [735], [1062], [1863], [17]], 's')],
+ grocery: [[1060], [1061], [1831], [1833], [736], [737], [1835], [3947], [735], [1062], [1863], [17]],
talkingIslandJewelry: [
{ selfId: 118, price: 76 },
diff --git a/tests/test_npc_shop_stock.js b/tests/test_npc_shop_stock.js
index 6f5a6ab4..1fc0fbbe 100644
--- a/tests/test_npc_shop_stock.js
+++ b/tests/test_npc_shop_stock.js
@@ -5,6 +5,7 @@ require('../src/Global');
const DataCache = invoke('GameServer/DataCache');
const BuyShop = invoke('GameServer/World/Generics/NpcBypasses/BuyShop');
const NpcShopBuyLists = invoke('GameServer/World/Generics/NpcShopBuyLists');
+const MerchantStoreConfigs = invoke('GameServer/Bot/MerchantStoreConfigs');
DataCache.items = require('../data/Items/Others/others.json');
@@ -46,32 +47,30 @@ assert.strictEqual(rows.get(17).amount, 0, 'NPC arrow stock should be unlimited
assert.strictEqual(rows.get(1060).amount, 0, 'NPC scroll stock should be unlimited in BuyList');
assert.strictEqual(rows.get(1835).price, 8, 'NPC shop should preserve audited per-NPC prices');
-const spiritshotsThrough = {
- starter: [2509, 2510, 2511, 2512, 2513, 2514],
- d: [2509, 2510],
- c: [2509, 2510, 2511],
- b: [2509, 2510, 2511, 2512],
- a: [2509, 2510, 2511, 2512, 2513],
- s: [2509, 2510, 2511, 2512, 2513, 2514]
-};
const shopSpiritshots = (npcId) => NpcShopBuyLists.fetchForNpc(npcId)
.map((entry) => entry.selfId)
.filter((selfId) => selfId >= 2509 && selfId <= 2514);
-for (const npcId of [7004, 7137, 7150, 7519, 7561]) {
- assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.starter, `starter merchant ${npcId} must stock every Spiritshot grade`);
-}
-for (const npcId of [7063, 7254, 7315]) {
- assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.d, `D-grade city merchant ${npcId} must stock Spiritshot D`);
-}
-const laraRows = NpcShopBuyLists.fetchForNpc(7063).map((entry) => entry.selfId);
-assert.strictEqual(laraRows.indexOf(2510), laraRows.indexOf(3947) + 1,
- 'D Spiritshot must appear immediately after the no-grade shot rows, before ordinary supplies');
-assert.deepStrictEqual(shopSpiritshots(7081), spiritshotsThrough.c, 'Giran must stock Spiritshot C');
-for (const npcId of [7180, 7301, 7834]) {
- assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.b, `B-grade city merchant ${npcId} must stock Spiritshot B`);
+for (const npcId of [7004, 7137, 7150, 7519, 7561, 7063, 7254, 7315, 7081, 7180, 7301, 7834, 7839, 8256, 8300]) {
+ assert.deepStrictEqual(shopSpiritshots(npcId), [2509], `ordinary NPC merchant ${npcId} must only retain its no-grade Spiritshot`);
}
-assert.deepStrictEqual(shopSpiritshots(7839), spiritshotsThrough.a, 'Aden must stock Spiritshot A');
-for (const npcId of [8256, 8300]) {
- assert.deepStrictEqual(shopSpiritshots(npcId), spiritshotsThrough.s, `late-town merchant ${npcId} must stock Spiritshot S`);
+
+const spiritshotStores = [
+ ['Tia', 'Talking Island', 5], ['Elya', 'Elven Village', 5], ['Dena', 'Dark Elven Village', 5],
+ ['Orik', 'Orc Village', 5], ['Bran', 'Dwarven Village', 5], ['Rolf', 'Gludin', 1],
+ ['Sila', 'Gludio', 1], ['Tara', 'Dion', 1], ['Eris', 'Giran', 2], ['Sera', 'Oren', 3],
+ ['Nora', "Hunter's Village", 3], ['Lina', 'Heine', 3], ['Mila', 'Aden', 4],
+ ['Sven', 'Goddard', 5], ['Runa', 'Rune', 5]
+];
+const spiritshotIds = [2509, 2510, 2511, 2512, 2513, 2514];
+for (const [name, town, grade] of spiritshotStores) {
+ const store = MerchantStoreConfigs[name];
+ assert.ok(store, `${town} must have a dedicated Spiritshot merchant`);
+ assert.strictEqual(store.storeType, 1, `${name} must be a selling private store`);
+ assert.strictEqual(store.town, town, `${name} must be placed in ${town}`);
+ assert.deepStrictEqual(store.items.map((item) => item.selfId), spiritshotIds.slice(0, grade + 1), `${name} must stock Spiritshots through its town grade`);
+ store.items.forEach((item) => {
+ assert.strictEqual(item.priceRate, 1, `${name} must use the standard Spiritshot price`);
+ assert.strictEqual(item.count, 999999, `${name} must have a practical unlimited Spiritshot stock`);
+ });
}
From d7058662c0d0feeb4d9450030f50d9828bd984e7 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 06:51:52 -0400
Subject: [PATCH 07/16] Fix stale NPC objects after respawn
---
scripts/run-tests.js | 1 +
src/GameServer/Bot/BotSession.js | 2 +
src/GameServer/Session.js | 3 ++
src/GameServer/World/Generics/RemoveNpc.js | 4 +-
src/GameServer/World/NpcVisibility.js | 58 ++++++++++++++++++++++
tests/test_npc_known_object_lifecycle.js | 56 +++++++++++++++++++++
6 files changed, 122 insertions(+), 2 deletions(-)
create mode 100644 src/GameServer/World/NpcVisibility.js
create mode 100644 tests/test_npc_known_object_lifecycle.js
diff --git a/scripts/run-tests.js b/scripts/run-tests.js
index d5813073..44429357 100644
--- a/scripts/run-tests.js
+++ b/scripts/run-tests.js
@@ -88,6 +88,7 @@ const tests = [
'tests/test_npc_sell_shop.js',
'tests/test_personal_warehouse.js',
'tests/test_npc_social_aggro.js',
+ 'tests/test_npc_known_object_lifecycle.js',
'tests/test_npc_respawn.js',
'tests/test_party_companion_rest_follow.js',
'tests/test_party_buff_targets.js',
diff --git a/src/GameServer/Bot/BotSession.js b/src/GameServer/Bot/BotSession.js
index eef4b83a..5c98eb73 100644
--- a/src/GameServer/Bot/BotSession.js
+++ b/src/GameServer/Bot/BotSession.js
@@ -1,5 +1,6 @@
const Actor = invoke('GameServer/Actor/Actor');
const World = invoke('GameServer/World/World');
+const NpcVisibility = invoke('GameServer/World/NpcVisibility');
class BotSession {
constructor(username) {
@@ -26,6 +27,7 @@ class BotSession {
const packet = this.packData(data);
World.fetchVisibleUsers(this, creature).forEach((user) => {
if (user.socket && typeof user.socket.write === 'function' && user.accountId !== this.accountId) {
+ NpcVisibility.trackNpcPacket(user, data);
if (user.recordOutboundPacket) {
user.recordOutboundPacket(data);
}
diff --git a/src/GameServer/Session.js b/src/GameServer/Session.js
index b14a4a9c..bd02c875 100644
--- a/src/GameServer/Session.js
+++ b/src/GameServer/Session.js
@@ -1,6 +1,7 @@
const Opcodes = invoke('GameServer/Network/Opcodes');
const Actor = invoke('GameServer/Actor/Actor');
const World = invoke('GameServer/World/World');
+const NpcVisibility = invoke('GameServer/World/NpcVisibility');
const TRACE_LIMIT = 40;
@@ -194,6 +195,7 @@ class Session {
}
dataSendToMe(data) {
+ NpcVisibility.trackNpcPacket(this, data);
this.recordOutboundPacket(data);
const packet = this.packData(data);
this.socket.write(packet);
@@ -202,6 +204,7 @@ class Session {
dataSendToOthers(data, creature) {
const packet = this.packData(data);
World.fetchVisibleUsers(this, creature).forEach((user) => {
+ NpcVisibility.trackNpcPacket(user, data);
if (user.recordOutboundPacket) {
user.recordOutboundPacket(data);
}
diff --git a/src/GameServer/World/Generics/RemoveNpc.js b/src/GameServer/World/Generics/RemoveNpc.js
index bcb9b563..897e6941 100644
--- a/src/GameServer/World/Generics/RemoveNpc.js
+++ b/src/GameServer/World/Generics/RemoveNpc.js
@@ -1,6 +1,6 @@
-const ServerResponse = invoke('GameServer/Network/Response');
const SpoilSweep = invoke('GameServer/Npc/SpoilSweep');
const SpawnNpcs = invoke('GameServer/World/Generics/SpawnNpcs');
+const NpcVisibility = invoke('GameServer/World/NpcVisibility');
function removeNpc(session, npc) {
const npcId = npc.fetchId();
@@ -19,7 +19,7 @@ function removeNpc(session, npc) {
// Delete NPC from world
setTimeout(() => {
- session.dataSendToMeAndOthers(ServerResponse.deleteOb(npcId), npc);
+ NpcVisibility.deleteKnownNpc(this, session, npcId);
this.npc.spawns = this.npc.spawns.filter(ob => ob.fetchId() !== npcId);
this.indexSpawnsInGrid();
}, SpoilSweep.corpseTime(npc));
diff --git a/src/GameServer/World/NpcVisibility.js b/src/GameServer/World/NpcVisibility.js
new file mode 100644
index 00000000..05fbb2fa
--- /dev/null
+++ b/src/GameServer/World/NpcVisibility.js
@@ -0,0 +1,58 @@
+const ServerResponse = invoke('GameServer/Network/Response');
+
+const NPC_INFO_OPCODE = 0x16;
+const DELETE_OBJECT_OPCODE = 0x12;
+
+function objectId(packet) {
+ if (!packet || packet.length < 5 || typeof packet.readInt32LE !== 'function') {
+ return null;
+ }
+
+ return packet.readInt32LE(1);
+}
+
+function trackNpcPacket(session, packet) {
+ const id = objectId(packet);
+ if (!session || id === null) return;
+
+ if (packet[0] === NPC_INFO_OPCODE) {
+ session.knownNpcIds ||= new Set();
+ session.knownNpcIds.add(id);
+ }
+ else if (packet[0] === DELETE_OBJECT_OPCODE) {
+ session.knownNpcIds?.delete(id);
+ }
+}
+
+function npcRemovalRecipients(world, sourceSession, npcId) {
+ const recipients = new Set();
+
+ if (typeof sourceSession?.dataSendToMe === 'function') {
+ recipients.add(sourceSession);
+ }
+
+ (world.user?.sessions || []).forEach((session) => {
+ if (
+ session?.actor?.fetchIsOnline?.() === true &&
+ typeof session.dataSendToMe === 'function' &&
+ session.knownNpcIds?.has(npcId)
+ ) {
+ recipients.add(session);
+ }
+ });
+
+ return recipients;
+}
+
+function deleteKnownNpc(world, sourceSession, npcId, response = ServerResponse) {
+ const packet = response.deleteOb(npcId);
+ const recipients = npcRemovalRecipients(world, sourceSession, npcId);
+ recipients.forEach((session) => session.dataSendToMe(packet));
+ return recipients.size;
+}
+
+module.exports = {
+ deleteKnownNpc,
+ npcRemovalRecipients,
+ trackNpcPacket
+};
diff --git a/tests/test_npc_known_object_lifecycle.js b/tests/test_npc_known_object_lifecycle.js
new file mode 100644
index 00000000..4563dd5c
--- /dev/null
+++ b/tests/test_npc_known_object_lifecycle.js
@@ -0,0 +1,56 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const NpcVisibility = invoke('GameServer/World/NpcVisibility');
+
+function npcInfo(id) {
+ const packet = Buffer.alloc(5);
+ packet[0] = 0x16;
+ packet.writeInt32LE(id, 1);
+ return packet;
+}
+
+function session(online = true) {
+ return {
+ actor: { fetchIsOnline: () => online },
+ sent: [],
+ dataSendToMe(packet) {
+ this.sent.push(packet);
+ NpcVisibility.trackNpcPacket(this, packet);
+ }
+ };
+}
+
+const oldNpcId = 1016465;
+const newNpcId = 1016466;
+const killer = session();
+const sawOldNpc = session();
+const neverSawNpc = session();
+const offlineViewer = session(false);
+
+NpcVisibility.trackNpcPacket(sawOldNpc, npcInfo(oldNpcId));
+NpcVisibility.trackNpcPacket(offlineViewer, npcInfo(oldNpcId));
+
+const delivered = NpcVisibility.deleteKnownNpc({
+ user: { sessions: [killer, sawOldNpc, neverSawNpc, offlineViewer] }
+}, killer, oldNpcId, {
+ deleteOb: (id) => {
+ const packet = Buffer.alloc(5);
+ packet[0] = 0x12;
+ packet.writeInt32LE(id, 1);
+ return packet;
+ }
+});
+
+assert.strictEqual(delivered, 2, 'the killer and every online viewer of the old object must receive DeleteObject');
+assert.strictEqual(killer.sent.length, 1, 'the killer must retain the previous direct cleanup behavior');
+assert.strictEqual(sawOldNpc.sent.length, 1, 'a viewer outside the corpse radius must still lose the stale NPC object');
+assert.strictEqual(neverSawNpc.sent.length, 0, 'clients that never received NpcInfo must not receive unrelated deletes');
+assert.strictEqual(offlineViewer.sent.length, 0, 'offline clients must not receive packets');
+assert.strictEqual(sawOldNpc.knownNpcIds.has(oldNpcId), false, 'DeleteObject must remove the stale id from the known-object set');
+
+NpcVisibility.trackNpcPacket(sawOldNpc, npcInfo(newNpcId));
+assert.strictEqual(sawOldNpc.knownNpcIds.has(newNpcId), true, 'the respawned object must be tracked under its new World ID');
+
+console.log('NPC known-object lifecycle regression checks passed');
From 43ba1df2ac378a8c96591c626620eca64a92e35a Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 07:12:37 -0400
Subject: [PATCH 08/16] Fix starter bot population seeding
---
scripts/run-tests.js | 1 +
src/GameServer/Bot/BotPopulation.js | 20 ++---
.../Bot/Population/BotNameGenerator.js | 56 ++++++++++++
.../Bot/Population/GeneratedColdSeeder.js | 86 +++++++++++--------
src/GameServer/Bot/Population/SpotProfiles.js | 17 ++++
tests/test_population_seed_planner.js | 30 ++++++-
tests/test_spot_profile_state_priority.js | 47 ++++++++++
7 files changed, 211 insertions(+), 46 deletions(-)
create mode 100644 src/GameServer/Bot/Population/BotNameGenerator.js
create mode 100644 tests/test_spot_profile_state_priority.js
diff --git a/scripts/run-tests.js b/scripts/run-tests.js
index 44429357..55020a1e 100644
--- a/scripts/run-tests.js
+++ b/scripts/run-tests.js
@@ -19,6 +19,7 @@ const tests = [
'tests/test_bot_class_progression.js',
'tests/test_generated_cold_skills.js',
'tests/test_population_seed_planner.js',
+ 'tests/test_spot_profile_state_priority.js',
'tests/test_bot_goal_state.js',
'tests/test_bot_goal_planner.js',
'tests/test_bot_goal_market_priority.js',
diff --git a/src/GameServer/Bot/BotPopulation.js b/src/GameServer/Bot/BotPopulation.js
index 7a07dcf4..eb5f7ad4 100644
--- a/src/GameServer/Bot/BotPopulation.js
+++ b/src/GameServer/Bot/BotPopulation.js
@@ -14,8 +14,8 @@ const STARTER_REGIONS = [
{ name: 'Serra', race: 0, classId: 10, sex: 1 }
],
visitors: [
- { name: 'Tovin', race: 4, classId: 53, sex: 0 },
- { name: 'Elandor', race: 1, classId: 18, sex: 0 }
+ { name: 'Tovin', race: 0, classId: 0, sex: 0 },
+ { name: 'Elandor', race: 0, classId: 10, sex: 0 }
],
apprentices: [
{ name: 'Nolan', race: 0, classId: 0, sex: 0 },
@@ -38,8 +38,8 @@ const STARTER_REGIONS = [
{ name: 'Velion', race: 1, classId: 18, sex: 0 }
],
visitors: [
- { name: 'Borik', race: 4, classId: 53, sex: 0 },
- { name: 'Rowan', race: 0, classId: 0, sex: 0 }
+ { name: 'Borik', race: 1, classId: 18, sex: 0 },
+ { name: 'Rowan', race: 1, classId: 25, sex: 0 }
],
apprentices: [
{ name: 'Eirlys', race: 1, classId: 18, sex: 1 },
@@ -62,8 +62,8 @@ const STARTER_REGIONS = [
{ name: 'Vorn', race: 2, classId: 31, sex: 0 }
],
visitors: [
- { name: 'Korrin', race: 4, classId: 53, sex: 0 },
- { name: 'Selwyn', race: 1, classId: 18, sex: 1 }
+ { name: 'Korrin', race: 2, classId: 31, sex: 0 },
+ { name: 'Selwyn', race: 2, classId: 38, sex: 1 }
],
apprentices: [
{ name: 'Velyra', race: 2, classId: 31, sex: 1 },
@@ -86,8 +86,8 @@ const STARTER_REGIONS = [
{ name: 'Urta', race: 3, classId: 49, sex: 0 }
],
visitors: [
- { name: 'Hedin', race: 4, classId: 53, sex: 0 },
- { name: 'Calder', race: 0, classId: 0, sex: 0 }
+ { name: 'Hedin', race: 3, classId: 44, sex: 0 },
+ { name: 'Calder', race: 3, classId: 49, sex: 0 }
],
apprentices: [
{ name: 'Rugor', race: 3, classId: 44, sex: 0 },
@@ -110,8 +110,8 @@ const STARTER_REGIONS = [
{ name: 'Toma', race: 4, classId: 53, sex: 0 }
],
visitors: [
- { name: 'Jalen', race: 0, classId: 0, sex: 0 },
- { name: 'Aerin', race: 1, classId: 25, sex: 1 }
+ { name: 'Jalen', race: 4, classId: 53, sex: 0 },
+ { name: 'Aerin', race: 4, classId: 53, sex: 1 }
],
apprentices: [
{ name: 'Berta', race: 4, classId: 53, sex: 1 },
diff --git a/src/GameServer/Bot/Population/BotNameGenerator.js b/src/GameServer/Bot/Population/BotNameGenerator.js
new file mode 100644
index 00000000..99a849b1
--- /dev/null
+++ b/src/GameServer/Bot/Population/BotNameGenerator.js
@@ -0,0 +1,56 @@
+// Generated population names intentionally come from a local, original corpus.
+// It captures the short fantasy and player-style nicknames common in MMORPGs
+// without distributing or impersonating names taken from player accounts.
+const STARTS = [
+ 'Ael', 'Aer', 'Ari', 'Ash', 'Astra', 'Bren', 'Cael', 'Cind', 'Cor', 'Dae',
+ 'Dra', 'Eli', 'Ery', 'Fen', 'Galen', 'Iri', 'Kael', 'Kira', 'Lio', 'Lun',
+ 'Mira', 'Ner', 'Nyx', 'Ori', 'Rae', 'Rav', 'Sera', 'Syl', 'Tae', 'Thorn',
+ 'Vale', 'Vex', 'Wyn', 'Xan', 'Yara', 'Zer'
+];
+
+const ENDS = [
+ 'a', 'ae', 'an', 'ar', 'ara', 'as', 'el', 'en', 'er', 'eth', 'ia', 'ian',
+ 'iel', 'in', 'ira', 'is', 'on', 'or', 'os', 'ra', 'ren', 'ric', 'ris', 'ros',
+ 'yn', 'ys'
+];
+
+const MIDDLES = [
+ 'a', 'ae', 'an', 'ar', 'ava', 'dra', 'el', 'en', 'eth', 'ia', 'iel', 'in',
+ 'ira', 'ka', 'or', 'ra', 'ren', 'ri', 'ryn', 'sa', 'sha', 'th', 'va', 'ver', 'wyn'
+];
+
+function mix(value) {
+ let hash = Number(value) >>> 0;
+ hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
+ hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
+ return (hash ^ (hash >>> 16)) >>> 0;
+}
+
+function normalize(name) {
+ return name.slice(0, 16);
+}
+
+function alphabeticToken(seed, length = 3) {
+ const modulus = 26 ** length;
+ // This is a permutation of the alphabetic token space, so nearby
+ // population slots do not collide while generated names remain digit-free.
+ let value = Number((BigInt(Math.trunc(seed)) * 7919n) % BigInt(modulus));
+ let token = '';
+ for (let index = 0; index < length; index++) {
+ token += String.fromCharCode(97 + (value % 26));
+ value = Math.floor(value / 26);
+ }
+ return token;
+}
+
+function nameFor(index) {
+ const seed = Math.max(0, Number(index) || 0);
+ const slot = mix(seed);
+ const nameSlot = slot;
+ const start = STARTS[nameSlot % STARTS.length];
+ const end = ENDS[Math.floor(nameSlot / STARTS.length) % ENDS.length];
+ const middle = MIDDLES[Math.floor(nameSlot / (STARTS.length * ENDS.length)) % MIDDLES.length];
+ return normalize(`${start}${middle}${end}${alphabeticToken(seed)}`);
+}
+
+module.exports = { nameFor };
diff --git a/src/GameServer/Bot/Population/GeneratedColdSeeder.js b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
index 0060630a..ac09111e 100644
--- a/src/GameServer/Bot/Population/GeneratedColdSeeder.js
+++ b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
@@ -10,6 +10,7 @@ const ShotStock = invoke('GameServer/Inventory/ShotStock');
const BotClassProgression = invoke('GameServer/Bot/BotClassProgression');
const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService');
const SeedPlanner = invoke('GameServer/Bot/Population/PopulationSeedPlanner');
+const BotNameGenerator = invoke('GameServer/Bot/Population/BotNameGenerator');
const CLASS_POOL = [
{ race: 0, classId: 0, sex: 0, role: 'dps' },
@@ -23,16 +24,18 @@ const CLASS_POOL = [
{ race: 4, classId: 53, sex: 0, role: 'dps' }
];
+const STARTER_REGION_RACES = Object.freeze({
+ human: 0,
+ elf: 1,
+ dark_elf: 2,
+ orc: 3,
+ dwarf: 4
+});
+
const CRAFT_SERVICE_PROFILE = { race: 4, classId: 57, sex: 0, role: 'crafter', serviceCrafter: true };
const CRAFT_SERVICE_COUNT = CraftShopService.CraftStations.length;
const CRAFT_SERVICE_INDEX_BASE = 10000;
-const NAME_STEMS = [
- 'Arin', 'Bren', 'Cail', 'Dorin', 'Elen', 'Faren', 'Garin', 'Halen',
- 'Irin', 'Joren', 'Kael', 'Lorin', 'Miren', 'Noren', 'Orin', 'Pavel',
- 'Quen', 'Ralen', 'Saren', 'Tarin', 'Ulric', 'Varen', 'Welyn', 'Yorin'
-];
-
function pick(index, list) {
return list[index % list.length];
}
@@ -51,8 +54,12 @@ function expForLevel(level) {
return Number(table[Math.max(0, Number(level || 1) - 1)] || 0);
}
-function baseForIndex(index) {
- return pick(index, CLASS_POOL);
+function baseForIndex(index, starterRegion = null) {
+ const race = STARTER_REGION_RACES[starterRegion];
+ const pool = Number.isInteger(race)
+ ? CLASS_POOL.filter((entry) => entry.race === race)
+ : CLASS_POOL;
+ return pick(index, pool);
}
function profileForIndex(index, base = baseForIndex(index), seedProfile = null) {
@@ -126,10 +133,17 @@ function usernameFor(index) {
}
function nameFor(index) {
- // Character names are VARCHAR(16). Population-wave indexes are timestamps,
- // so decimal formatting can no longer fit even though the old small index
- // format did. Base-36 keeps the durable suffix unique and within the limit.
- return `${pick(index, NAME_STEMS)}${Math.max(0, Number(index) || 0).toString(36).toUpperCase()}`.slice(0, 16);
+ return BotNameGenerator.nameFor(index);
+}
+
+function uniqueNameFor(index, attempt = 0) {
+ // Keep retry names natural too: a collision must not reintroduce a visible
+ // population counter on an otherwise player-like nickname.
+ const candidate = nameFor(Math.max(0, Number(index) || 0) + attempt * 2654435761);
+ return Database.fetchCharacterName(candidate).then((rows) => {
+ if (!rows[0]) return candidate;
+ return uniqueNameFor(index, attempt + 1);
+ });
}
function awardBaseGear(characterId, classId) {
@@ -242,28 +256,30 @@ function ensureCharacter(username, index, base = baseForIndex(index), seedProfil
const spot = seedProfile?.spot || targetSpot(level, index, base);
const loc = randomNear(spot?.center || { locX: 0, locY: 0, locZ: 0 }, index);
const vitals = vitalsFor(template, level);
- const charData = {
- name: nameFor(index),
- race: base.race,
- classId: base.classId,
- ...appearance(index, base.sex),
- ...vitals,
- ...loc
- };
-
- return Database.createCharacter(username, charData).then((packet) => {
- const character = {
- id: Number(packet.insertId),
- username,
- ...charData,
- level,
- exp: expForLevel(level),
- sp: Math.round(level * level * 3),
- adena: Math.round(level * 85)
+ return uniqueNameFor(index).then((name) => {
+ const charData = {
+ name,
+ race: base.race,
+ classId: base.classId,
+ ...appearance(index, base.sex),
+ ...vitals,
+ ...loc
};
- return Database.updateCharacterExperience(character.id, level, character.exp, character.sp)
- .then(() => ensureBaseLoadout(character.id, base.classId, character.adena, level))
- .then(() => ({ character, created: true, base, spot, levelProfile, vitals, loc }));
+
+ return Database.createCharacter(username, charData).then((packet) => {
+ const character = {
+ id: Number(packet.insertId),
+ username,
+ ...charData,
+ level,
+ exp: expForLevel(level),
+ sp: Math.round(level * level * 3),
+ adena: Math.round(level * 85)
+ };
+ return Database.updateCharacterExperience(character.id, level, character.exp, character.sp)
+ .then(() => ensureBaseLoadout(character.id, base.classId, character.adena, level))
+ .then(() => ({ character, created: true, base, spot, levelProfile, vitals, loc }));
+ });
});
});
}
@@ -384,6 +400,7 @@ const GeneratedColdSeeder = {
awardProfileSkills,
craftServiceSeedState,
+ baseForIndex,
nameFor,
ensureCraftServices() {
@@ -455,13 +472,14 @@ const GeneratedColdSeeder = {
batch.forEach((spot) => {
const index = this.nextPopulationIndex++;
const username = `bot_pop_${index.toString(36)}`.slice(0, 16);
+ const base = baseForIndex(index, spot.starterRegion);
const seedProfile = {
spot,
level: Math.max(1, Number(spot.minLevel || 1)),
band: `wave_${plan.wave}`
};
chain = chain.then(() => ensureAccount(username)
- .then(() => ensureCharacter(username, index, baseForIndex(index), seedProfile))
+ .then(() => ensureCharacter(username, index, base, seedProfile))
.then((result) => {
const state = stateFor(result.character, index, {
...result,
diff --git a/src/GameServer/Bot/Population/SpotProfiles.js b/src/GameServer/Bot/Population/SpotProfiles.js
index b21846eb..3ec6043f 100644
--- a/src/GameServer/Bot/Population/SpotProfiles.js
+++ b/src/GameServer/Bot/Population/SpotProfiles.js
@@ -61,6 +61,22 @@ const SpotProfiles = {
findForState(state, options = {}) {
const acquisitionPlan = state?.stats?.equipmentPlan;
+ const protectedStarterCohort = Number(state?.level || 1) < 5
+ && Number(state?.stats?.populationWave || 0) > 0
+ && !!state?.stats?.starterRegion;
+ const keepCurrentSpot = state?.spotId && (!acquisitionPlan || protectedStarterCohort);
+
+ // Fresh racial cohorts stay at their physical level-one spot until
+ // they advance. A gear plan otherwise remains the normal route choice
+ // for established bots.
+ if (keepCurrentSpot) {
+ const existing = this.findById(state.spotId);
+ if (existing) {
+ const match = LevelingRoutes.scoreSpot(existing, state, options);
+ return LevelingRoutes.decorateSpot(existing, match);
+ }
+ }
+
if (acquisitionPlan?.status === 'active') {
const planned = this.ensure()
.map((spot) => ({ spot, score: GearAcquisitionPlanner.scoreSpot(spot, acquisitionPlan) }))
@@ -68,6 +84,7 @@ const SpotProfiles = {
.sort((a, b) => b.score - a.score)[0];
if (planned) return planned.spot;
}
+
if (state?.spotId) {
const existing = this.findById(state.spotId);
if (existing) {
diff --git a/tests/test_population_seed_planner.js b/tests/test_population_seed_planner.js
index de36776d..0b9362fe 100644
--- a/tests/test_population_seed_planner.js
+++ b/tests/test_population_seed_planner.js
@@ -4,6 +4,7 @@ require('../src/Global');
const Planner = invoke('GameServer/Bot/Population/PopulationSeedPlanner');
const GeneratedColdSeeder = invoke('GameServer/Bot/Population/GeneratedColdSeeder');
+const BotPopulation = invoke('GameServer/Bot/BotPopulation');
const profiles = Planner.STARTER_REGIONS.map((region) => ({
id: `starter_${region.id}`,
@@ -24,6 +25,28 @@ assert.deepStrictEqual(
assert.strictEqual(Planner.seedBatchSize(initial, 2), 150,
'the first wave must not be split by the normal seed batch limit');
+const starterRaces = { human: 0, elf: 1, dark_elf: 2, orc: 3, dwarf: 4 };
+Object.entries(starterRaces).forEach(([starterRegion, race]) => {
+ Array.from({ length: 20 }, (_, index) => index).forEach((index) => {
+ assert.strictEqual(GeneratedColdSeeder.baseForIndex(index, starterRegion).race, race,
+ `${starterRegion} population slots must use only that race`);
+ });
+});
+
+const staticStarterRaces = {
+ 'Talking Island': 0,
+ 'Elven Village': 1,
+ 'Dark Elven Village': 2,
+ 'Orc Village': 3,
+ 'Dwarven Village': 4
+};
+BotPopulation.buildStarterBots()
+ .filter((bot) => Object.hasOwn(staticStarterRaces, bot.homeRegion))
+ .forEach((bot) => {
+ assert.strictEqual(bot.race, staticStarterRaces[bot.homeRegion],
+ `${bot.homeRegion} static starter cohort must use the local race`);
+ });
+
const legacyPopulation = Planner.plan(profiles, Array.from({ length: 132 }, (_, index) => ({
characterId: index + 1,
level: 16,
@@ -75,7 +98,10 @@ const capped = Planner.plan(profiles, [
], 1700, 30);
assert.strictEqual(capped.missing.length, 10, 'the final partial wave must stop exactly at the hard population cap');
-const generatedName = GeneratedColdSeeder.nameFor(Date.now());
-assert.ok(generatedName.length <= 16, 'timestamp-based population slots must fit the character-name column');
+const generatedNames = Array.from({ length: 5000 }, (_, index) => GeneratedColdSeeder.nameFor(Date.now() + index));
+assert.ok(generatedNames.every((name) => name.length >= 3 && name.length <= 16), 'generated names must fit the character-name column');
+assert.ok(generatedNames.every((name) => /^[A-Za-z]+$/.test(name)), 'generated names must remain client-safe alphabetic nicknames');
+assert.ok(new Set(generatedNames).size > 4500, 'the local nickname corpus must provide a varied population');
+assert.ok(generatedNames.every((name) => !/[0-9]/.test(name)), 'ordinary generated names must not expose population counters');
console.log('Population seed planner checks passed');
diff --git a/tests/test_spot_profile_state_priority.js b/tests/test_spot_profile_state_priority.js
new file mode 100644
index 00000000..1e4057f7
--- /dev/null
+++ b/tests/test_spot_profile_state_priority.js
@@ -0,0 +1,47 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const SpotProfiles = invoke('GameServer/Bot/Population/SpotProfiles');
+
+const originalCache = SpotProfiles.cache;
+SpotProfiles.cache = [
+ {
+ id: 'starter_human_01',
+ avgLevel: 1,
+ minLevel: 1,
+ maxLevel: 3,
+ density: 10,
+ center: { locX: -84000, locY: 245000, locZ: -3729 }
+ },
+ {
+ id: 'gear_source_01',
+ avgLevel: 8,
+ minLevel: 6,
+ maxLevel: 10,
+ density: 10,
+ center: { locX: -110000, locY: 76000, locZ: -2800 }
+ }
+];
+
+try {
+ const equipmentPlan = {
+ status: 'active',
+ next: { spotId: 'gear_source_01' }
+ };
+ const atStarter = SpotProfiles.findForState({
+ level: 1,
+ spotId: 'starter_human_01',
+ stats: { equipmentPlan, populationWave: 1, starterRegion: 'human' }
+ });
+ assert.strictEqual(atStarter.id, 'starter_human_01',
+ 'an active equipment plan must not replace a persisted physical starter spot');
+
+ const unplaced = SpotProfiles.findForState({ level: 1, stats: { equipmentPlan } });
+ assert.strictEqual(unplaced.id, 'gear_source_01',
+ 'a state without a physical spot may still select its equipment source');
+} finally {
+ SpotProfiles.cache = originalCache;
+}
+
+console.log('Spot profile state-priority checks passed');
From 412a6df2b6765eeecf03b66c436ac7023a85a5b3 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 07:16:19 -0400
Subject: [PATCH 09/16] Stop granting bots level-based gear
---
src/GameServer/Bot/AI/BotGear.js | 101 +------------------------------
src/GameServer/Bot/BotManager.js | 12 ++--
tests/test_bot_gear.js | 3 +
3 files changed, 8 insertions(+), 108 deletions(-)
diff --git a/src/GameServer/Bot/AI/BotGear.js b/src/GameServer/Bot/AI/BotGear.js
index 7cfea942..9a1b7437 100644
--- a/src/GameServer/Bot/AI/BotGear.js
+++ b/src/GameServer/Bot/AI/BotGear.js
@@ -1,4 +1,3 @@
-const Database = invoke('Database');
const DataCache = invoke('GameServer/DataCache');
const BotRoles = invoke('GameServer/Bot/AI/BotRoles');
const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints');
@@ -30,7 +29,6 @@ const GRADE_BANDS = [
];
const RANK_ORDER = ['none', 'd', 'c', 'b', 'a', 's'];
-const WEARABLE_SLOTS = new Set(Object.values(ARMOR_SLOTS));
const NO_GRADE_PRICE_CAPS = [
{ maxLevel: 5, price: 1000 },
{ maxLevel: 10, price: 12500 },
@@ -314,105 +312,8 @@ function planFor(character) {
};
}
-function templateFor(selfId) {
- return allItems().find((item) => Number(item.selfId) === Number(selfId)) || null;
-}
-
-function isWearableRow(row) {
- const template = templateFor(row.selfId);
- return !!template && WEARABLE_SLOTS.has(Number(row.slot || template.slot || 0));
-}
-
-function desiredKey(item, index) {
- return `${item.selfId}:${item.slot}:${index}`;
-}
-
-function assignExistingItems(existing, desired) {
- const available = new Map();
- existing.forEach((row) => {
- const selfId = Number(row.selfId || 0);
- if (!available.has(selfId)) available.set(selfId, []);
- available.get(selfId).push(row);
- });
-
- return desired.map((item, index) => {
- const rows = available.get(Number(item.selfId)) || [];
- const existingRow = rows.shift() || null;
- return {
- key: desiredKey(item, index),
- desired: item,
- existing: existingRow
- };
- });
-}
-
-function createMissingItem(characterId, desired) {
- return Database.setItem(characterId, {
- selfId: desired.selfId,
- name: desired.name,
- amount: desired.amount || 1,
- equipped: false,
- slot: desired.slot
- }).then((result) => ({
- id: Number(result.insertId),
- selfId: desired.selfId,
- slot: desired.slot,
- equipped: false
- }));
-}
-
-function syncAssignments(characterId, existing, assignments) {
- const assignedIds = new Map();
- let chain = Promise.resolve();
- let changed = false;
-
- assignments.forEach((assignment) => {
- chain = chain.then(() => {
- if (assignment.existing) return assignment.existing;
- changed = true;
- return createMissingItem(characterId, assignment.desired);
- }).then((row) => {
- assignedIds.set(Number(row.id), assignment.desired);
- if (Number(row.slot) !== Number(assignment.desired.slot) || Number(row.equipped) !== 1) {
- changed = true;
- return Database.updateItemEquipState(characterId, row.id, true, assignment.desired.slot);
- }
- return null;
- });
- });
-
- existing.filter(isWearableRow).forEach((row) => {
- chain = chain.then(() => {
- if (assignedIds.has(Number(row.id))) return null;
- if (Number(row.equipped) !== 1) return null;
- changed = true;
- return Database.updateItemEquipState(characterId, row.id, false, row.slot || 0);
- });
- });
-
- return chain.then(() => changed);
-}
-
const BotGear = {
- planFor,
-
- ensureCharacterGear(character, options = {}) {
- if (!character || !character.id || options.disabled === true) {
- return Promise.resolve({ changed: false, plan: null });
- }
-
- const plan = planFor(character);
- if (!plan.items.length) return Promise.resolve({ changed: false, plan });
-
- return Database.fetchItems(character.id).then((existing) => {
- const assignments = assignExistingItems(existing || [], plan.items);
- return syncAssignments(character.id, existing || [], assignments)
- .then((changed) => ({ changed, plan }));
- }).catch((err) => {
- utils.infoWarn('BotGear', 'failed to gear %s: %s', character.name || character.id, err.message);
- return { changed: false, plan, error: err.message };
- });
- }
+ planFor
};
module.exports = BotGear;
diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js
index 9f91d197..ca41d934 100644
--- a/src/GameServer/Bot/BotManager.js
+++ b/src/GameServer/Bot/BotManager.js
@@ -14,7 +14,6 @@ const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory');
const BotBuffs = invoke('GameServer/Bot/AI/BotBuffs');
const BotRoles = invoke('GameServer/Bot/AI/BotRoles');
const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities');
-const BotGear = invoke('GameServer/Bot/AI/BotGear');
const ShotStock = invoke('GameServer/Inventory/ShotStock');
const PopulationService = invoke('GameServer/Bot/Population/PopulationService');
const SimulationKernel = invoke('GameServer/Bot/Simulation/SimulationKernel');
@@ -509,21 +508,18 @@ const BotManager = {
classId: firstCharacter.classId,
level: firstCharacter.level
}, firstCharacter.classId);
- const gearReady = skillsReady.then(() => Shared.fetchCharacters(username))
+ const spawnReady = skillsReady.then(() => Shared.fetchCharacters(username))
.then((reconciledCharacters) => {
const reconciledCharacter = reconciledCharacters[0];
if (!reconciledCharacter) return null;
- return (firstStoreCfg
- ? Promise.resolve()
- : BotGear.ensureCharacterGear(reconciledCharacter, botData))
- .then(() => ShotStock.ensureCharacterStock(reconciledCharacter.id, {
+ return ShotStock.ensureCharacterStock(reconciledCharacter.id, {
classId: reconciledCharacter.classId,
targetAmount: ShotStock.DEFAULT_TARGET_AMOUNT
- }))
+ })
.then(() => Shared.fetchCharacters(username));
});
- gearReady.then((readyCharacters) => {
+ spawnReady.then((readyCharacters) => {
const character = readyCharacters[0];
if (!character) return;
const storeCfg = merchantConfigFor(botData, character.name);
diff --git a/tests/test_bot_gear.js b/tests/test_bot_gear.js
index ef0671d4..880a977b 100644
--- a/tests/test_bot_gear.js
+++ b/tests/test_bot_gear.js
@@ -9,6 +9,9 @@ const BotGear = invoke('GameServer/Bot/AI/BotGear');
const BotEquipmentUpgrade = invoke('GameServer/Bot/AI/BotEquipmentUpgrade');
const Item = invoke('GameServer/Item/Item');
+assert.strictEqual(BotGear.ensureCharacterGear, undefined,
+ 'level-based gear plans must guide acquisition, not create free equipment on spawn');
+
function bySlot(plan, slot) {
return plan.items.find((item) => Number(item.slot) === Number(slot));
}
From 8943f666714ce3a5c6f0432a7eb99122c197d016 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 07:55:07 -0400
Subject: [PATCH 10/16] Add bot gear acquisition lifecycle
---
scripts/run-tests.js | 1 +
.../Bot/AI/GearAcquisitionPlanner.js | 86 +++++++++++++++++--
src/GameServer/Bot/AI/GearLifecycle.js | 31 +++++++
src/GameServer/Bot/Goals/GoalExecutor.js | 4 +-
src/GameServer/Bot/Goals/NeedsEvaluator.js | 46 +++++++---
.../Bot/Population/PopulationService.js | 36 +++++---
src/GameServer/Bot/Population/SpotProfiles.js | 12 ++-
tests/test_bot_class_progression.js | 5 ++
tests/test_bot_gear_acquisition.js | 22 ++++-
tests/test_bot_goal_planner.js | 34 ++++++++
.../test_population_starter_party_grouping.js | 43 ++++++++++
11 files changed, 281 insertions(+), 39 deletions(-)
create mode 100644 src/GameServer/Bot/AI/GearLifecycle.js
create mode 100644 tests/test_population_starter_party_grouping.js
diff --git a/scripts/run-tests.js b/scripts/run-tests.js
index 55020a1e..57a5512e 100644
--- a/scripts/run-tests.js
+++ b/scripts/run-tests.js
@@ -20,6 +20,7 @@ const tests = [
'tests/test_generated_cold_skills.js',
'tests/test_population_seed_planner.js',
'tests/test_spot_profile_state_priority.js',
+ 'tests/test_population_starter_party_grouping.js',
'tests/test_bot_goal_state.js',
'tests/test_bot_goal_planner.js',
'tests/test_bot_goal_market_priority.js',
diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js
index d9a59f06..a10f150f 100644
--- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js
+++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js
@@ -4,6 +4,9 @@ const ProgressionRates = invoke('GameServer/ProgressionRates');
const BotRoles = invoke('GameServer/Bot/AI/BotRoles');
const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService');
const CraftSupplementMaterials = invoke('GameServer/Bot/Economy/CraftSupplementMaterials');
+const BotGear = invoke('GameServer/Bot/AI/BotGear');
+const GearLifecycle = invoke('GameServer/Bot/AI/GearLifecycle');
+const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity');
const RANKS = ['none', 'd', 'c', 'b', 'a', 's'];
const WEAPON_SLOTS = new Set([7, 14]);
@@ -153,6 +156,51 @@ function preferredDropTarget(state = {}) {
.sort((a, b) => itemScore(b, role) - itemScore(a, role) || Number(b.template?.price || 0) - Number(a.template?.price || 0))[0] || null;
}
+function preferredNoGradeTarget(state = {}) {
+ const role = roleFor(state);
+ const ownedItems = inventoryItems(state.inventory);
+ const classId = Number(state.stats?.classId || state.classId || 0);
+ const planned = BotGear.planFor({ classId, level: Math.max(GearLifecycle.GEAR_FOCUS_LEVEL, Number(state.level || 1)) });
+ const uniqueItems = new Set();
+
+ return planned.items
+ .map((desired) => (DataCache.items || []).find((item) => Number(item.selfId) === Number(desired.selfId)))
+ .filter(Boolean)
+ .filter((item) => {
+ if (uniqueItems.has(Number(item.selfId))) return false;
+ uniqueItems.add(Number(item.selfId));
+ return isSlotUpgrade(item, ownedItems, role);
+ })
+ .sort((a, b) => GearLifecycle.slotPriority(b.etc?.slot) - GearLifecycle.slotPriority(a.etc?.slot)
+ || Number(a.template?.price || 0) - Number(b.template?.price || 0))[0] || null;
+}
+
+function marketOfferForTarget(target, state = {}, options = {}) {
+ if (!target) return null;
+ if (typeof options.findMarketOffer === 'function') return options.findMarketOffer(target, state) || null;
+ const towns = [...new Set([
+ state.currentRegion,
+ ...Object.keys(MarketOpportunity.TOWN_NPC_SELLERS || {}),
+ 'Giran'
+ ].filter(Boolean))];
+ return towns
+ .map((town) => MarketOpportunity.bestOffer(target.selfId, {
+ town,
+ budget: Infinity,
+ buyerCharacterId: state.characterId
+ }))
+ .filter(Boolean)
+ .sort((left, right) => Number(left.price) - Number(right.price))[0] || null;
+}
+
+function expectedAdenaPerKill(state = {}) {
+ return Math.max(20, Number(state.level || 1) * 25);
+}
+
+function marketEffort(offer, state) {
+ return offer ? Number(offer.price || Infinity) / expectedAdenaPerKill(state) : Infinity;
+}
+
function itemDropChance(reward, itemId, kind = 'drop') {
return itemDropYield(reward, itemId, kind).chance;
}
@@ -257,10 +305,30 @@ function planFor(state = {}, options = {}) {
if (isCraftService(state)) {
return { status: 'service', strategy: 'none', recipeId: null, materials: [], next: null };
}
- if (gradeForLevel(state.level) === 'none' && !options.recipeId) {
- const target = preferredDropTarget(state);
+ if (!GearLifecycle.isGearFocusActive(state)) {
+ return {
+ status: 'deferred',
+ phase: GearLifecycle.phaseFor(state),
+ strategy: 'none',
+ recipeId: null,
+ materials: [],
+ next: null
+ };
+ }
+ if (!GearLifecycle.allowsCrafting(state) || gradeForLevel(state.level) === 'none') {
+ const target = preferredNoGradeTarget(state) || preferredDropTarget(state);
const source = target ? bestSourceForState(sourceForItem(target.selfId, options.spots || [], state), state) : null;
- return source ? {
+ const offer = marketOfferForTarget(target, state, options);
+ const directKills = source ? 1 / Math.max(source.expectedYield, 0.000001) : Infinity;
+ const buy = offer && marketEffort(offer, state) <= directKills;
+ return target && buy ? {
+ status: 'active', phase: GearLifecycle.phaseFor(state), grade: 'none', role: roleFor(state), strategy: 'market', soloSafe: true, requiresParty: false,
+ rateModelVersion: RATE_MODEL_VERSION,
+ expectedKills: Math.ceil(marketEffort(offer, state)),
+ target: { selfId: Number(target.selfId), name: target.template?.name || `Item ${target.selfId}`, slot: Number(target.etc?.slot || 0) },
+ market: { town: offer.town || 'Giran', price: Number(offer.price), sourceType: offer.sourceType },
+ recipeId: null, materials: [], next: null
+ } : source ? {
status: 'active', grade: 'none', role: roleFor(state), strategy: 'direct_drop', soloSafe: soloSafeForSource(state, source), requiresParty: !soloSafeForSource(state, source),
rateModelVersion: RATE_MODEL_VERSION,
expectedKills: Math.ceil(1 / Math.max(source.expectedYield, 0.000001)),
@@ -288,11 +356,13 @@ function planFor(state = {}, options = {}) {
))[0] || null;
const directKills = direct ? 1 / Math.max(direct.expectedYield, 0.000001) : Infinity;
const craftKills = missingMaterialPlans.reduce((sum, material) => sum + material.missing / Math.max(material.source?.expectedYield || 0.000001, 0.000001), 0);
+ const offer = marketOfferForTarget(target.item, state, options);
+ const buy = offer && marketEffort(offer, state) <= Math.min(directKills, craftKills);
const soloSafe = direct && soloSafeForSource(state, direct);
- const strategy = soloSafe && directKills <= craftKills * 0.8 ? 'direct_drop' : 'craft';
+ const strategy = buy ? 'market' : soloSafe && directKills <= craftKills * 0.8 ? 'direct_drop' : 'craft';
const next = strategy === 'direct_drop'
? direct && { ...direct, itemId: Number(target.item.selfId) }
- : nextMaterial?.source && { ...nextMaterial.source, itemId: Number(nextMaterial.selfId) };
+ : strategy === 'craft' ? nextMaterial?.source && { ...nextMaterial.source, itemId: Number(nextMaterial.selfId) } : null;
// Keep final-equipment readiness distinct from an available intermediate
// craft. Both routes go to a station, but reporting a ready Cokes batch
// as "can craft Atuba Mace" made the progression telemetry lie and hid
@@ -308,7 +378,8 @@ function planFor(state = {}, options = {}) {
&& Boolean(next && !soloSafeForSource(state, next));
return {
- status: readyToCraft ? 'ready_to_craft' : componentReady ? 'component_ready' : next ? 'active' : 'blocked',
+ status: readyToCraft ? 'ready_to_craft' : componentReady ? 'component_ready' : strategy === 'market' || next ? 'active' : 'blocked',
+ phase: GearLifecycle.phaseFor(state),
grade: String(target.item.etc?.rank || gradeForLevel(state.level)).toLowerCase(),
role: roleFor(state),
rateModelVersion: RATE_MODEL_VERSION,
@@ -318,6 +389,7 @@ function planFor(state = {}, options = {}) {
soloSafe,
requiresParty,
expectedKills: next ? Math.ceil(strategy === 'direct_drop' ? directKills : craftKills) : 0,
+ market: buy ? { town: offer.town || 'Giran', price: Number(offer.price), sourceType: offer.sourceType } : null,
materials: materialPlans.map(({ source, ...material }) => ({ ...material, sourceSpotId: source?.spotId || null })),
next: next ? { spotId: next.spotId, npcId: next.npcId, npcName: next.npcName, kind: next.kind, itemId: next.itemId } : null
};
@@ -344,4 +416,4 @@ function sameObjective(left, right) {
);
}
-module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, preferredTarget, preferredDropTarget, itemDropChance, itemDropYield, soloSafeForSource, bestSourceForState, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective };
+module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, preferredTarget, preferredDropTarget, preferredNoGradeTarget, marketOfferForTarget, itemDropChance, itemDropYield, soloSafeForSource, bestSourceForState, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective };
diff --git a/src/GameServer/Bot/AI/GearLifecycle.js b/src/GameServer/Bot/AI/GearLifecycle.js
new file mode 100644
index 00000000..e06e5a8a
--- /dev/null
+++ b/src/GameServer/Bot/AI/GearLifecycle.js
@@ -0,0 +1,31 @@
+const GEAR_FOCUS_LEVEL = 5;
+const D_GRADE_LEVEL = 20;
+
+function levelOf(state = {}) {
+ return Math.max(1, Number(state.level || 1));
+}
+
+function phaseFor(state = {}) {
+ const level = levelOf(state);
+ if (level < GEAR_FOCUS_LEVEL) return 'starter';
+ if (level < D_GRADE_LEVEL) return 'no_grade_focus';
+ return 'grade_progression';
+}
+
+function isGearFocusActive(state = {}) {
+ return levelOf(state) >= GEAR_FOCUS_LEVEL;
+}
+
+function allowsCrafting(state = {}) {
+ return levelOf(state) >= D_GRADE_LEVEL;
+}
+
+function slotPriority(slot) {
+ const value = Number(slot || 0);
+ if ([7, 14].includes(value)) return 3;
+ if ([6, 8, 9, 10, 11, 12, 15].includes(value)) return 2;
+ if ([1, 2, 3, 4, 5].includes(value)) return 1;
+ return 0;
+}
+
+module.exports = { GEAR_FOCUS_LEVEL, D_GRADE_LEVEL, phaseFor, isGearFocusActive, allowsCrafting, slotPriority };
diff --git a/src/GameServer/Bot/Goals/GoalExecutor.js b/src/GameServer/Bot/Goals/GoalExecutor.js
index ec1800c9..fc662924 100644
--- a/src/GameServer/Bot/Goals/GoalExecutor.js
+++ b/src/GameServer/Bot/Goals/GoalExecutor.js
@@ -22,7 +22,9 @@ function beginMarketTravel(state, goal, timestamp = Date.now()) {
if ((buyingGear || buyingMaterial) && Number(state.stats?.marketRetryAfter || 0) > timestamp) return null;
if (sellingInventory && Number(state.stats?.marketSellRetryAfter || 0) > timestamp) return null;
- const town = sellingInventory ? marketTown(MarketTownPolicy.targetTownForSale(state)) : marketTown('Giran');
+ const town = sellingInventory
+ ? marketTown(MarketTownPolicy.targetTownForSale(state))
+ : marketTown(goal.plan?.marketTown || 'Giran');
if (!town) return null;
const from = { ...state.loc };
const nearestTown = TownRespawn.getClosestTown(from.locX, from.locY);
diff --git a/src/GameServer/Bot/Goals/NeedsEvaluator.js b/src/GameServer/Bot/Goals/NeedsEvaluator.js
index 77400d52..47065078 100644
--- a/src/GameServer/Bot/Goals/NeedsEvaluator.js
+++ b/src/GameServer/Bot/Goals/NeedsEvaluator.js
@@ -1,6 +1,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 RANK_ORDER = ['none', 'd', 'c', 'b', 'a', 's'];
// Weapons make the largest immediate difference, then core armour. The two
@@ -31,6 +32,7 @@ function rankIndex(rank) {
}
function equipmentNeed(state) {
+ if (!GearLifecycle.isGearFocusActive(state)) return null;
const equipment = state.stats?.equipment;
if (!Array.isArray(equipment)) return null;
@@ -45,23 +47,40 @@ function equipmentNeed(state) {
const desiredRank = String(item.rank || build.grade || 'none').toLowerCase();
return !currentItem || rankIndex(currentItem.rank) < rankIndex(desiredRank);
}) || null;
- if (!desiredItem) return null;
-
- const currentItem = equipment.find((item) => Number(item.slot) === Number(desiredItem.slot)) || null;
- const desiredRank = String(desiredItem.rank || build.grade || 'none').toLowerCase();
-
- const template = (DataCache.items || []).find((item) => Number(item.selfId) === Number(desiredItem.selfId));
- const price = Math.max(1, Number(template?.template?.price || 0));
+ const acquisitionPlan = state.stats?.equipmentPlan;
+ if (['direct_drop', 'craft'].includes(acquisitionPlan?.strategy) && acquisitionPlan?.status === 'active') return null;
+ const plannedTarget = acquisitionPlan?.strategy === 'market' && acquisitionPlan?.target
+ ? (DataCache.items || []).find((item) => Number(item.selfId) === Number(acquisitionPlan.target.selfId))
+ : null;
+ const plannedSlot = Number(plannedTarget?.etc?.slot || 0);
+ const plannedAlreadyEquipped = plannedSlot > 0 && equipment.some((item) => (
+ Number(item.slot) === plannedSlot && Number(item.selfId) === Number(plannedTarget.selfId)
+ ));
+ const selectedItem = plannedTarget && !plannedAlreadyEquipped ? plannedTarget : desiredItem;
+ if (!selectedItem) return null;
+
+ const currentItem = equipment.find((item) => Number(item.slot) === Number(selectedItem.etc?.slot || selectedItem.slot)) || null;
+ const desiredRank = String(selectedItem.etc?.rank || selectedItem.rank || build.grade || 'none').toLowerCase();
+
+ // A just-completed market plan remains on the state until the next
+ // resolver pass. Once its target is equipped, the generic build may pick
+ // a different next slot; do not send that new purchase to the old offer's
+ // town or fund it with the old price.
+ const usingPlannedTarget = Number(selectedItem.selfId) === Number(plannedTarget?.selfId);
+ const template = (DataCache.items || []).find((item) => Number(item.selfId) === Number(selectedItem.selfId));
+ const plannedMarket = usingPlannedTarget ? acquisitionPlan?.market : null;
+ const price = Math.max(1, Number(plannedMarket?.price || template?.template?.price || 0));
return {
currentItem,
desiredRank,
- slot: Number(desiredItem.slot),
- slotName: EQUIPMENT_SLOT_NAMES[Number(desiredItem.slot)] || `slot_${desiredItem.slot}`,
+ slot: Number(selectedItem.etc?.slot || selectedItem.slot),
+ slotName: EQUIPMENT_SLOT_NAMES[Number(selectedItem.etc?.slot || selectedItem.slot)] || `slot_${selectedItem.etc?.slot || selectedItem.slot}`,
desiredItem: {
- selfId: Number(desiredItem.selfId),
- name: desiredItem.name || template?.template?.name || `Item ${desiredItem.selfId}`,
+ selfId: Number(selectedItem.selfId),
+ name: selectedItem.name || selectedItem.template?.name || template?.template?.name || `Item ${selectedItem.selfId}`,
price
- }
+ },
+ marketTown: plannedMarket?.town || null
};
}
@@ -116,7 +135,8 @@ function evaluate(state = {}, options = {}) {
? weaponUpgrade ? 'adena_for_weapon_upgrade' : 'adena_for_gear_upgrade'
: weaponUpgrade ? 'market_search_for_weapon' : 'market_search_for_gear',
estimatedCost: gear.desiredItem.price,
- requiredAdena
+ requiredAdena,
+ marketTown: gear.marketTown
},
blockers: spot ? [] : ['missing_spot'],
nextReviewAt: timestamp + 10 * 60 * 1000
diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js
index 135f3e7e..5db494ed 100644
--- a/src/GameServer/Bot/Population/PopulationService.js
+++ b/src/GameServer/Bot/Population/PopulationService.js
@@ -26,7 +26,8 @@ const CraftTelemetry = invoke('GameServer/Bot/Economy/CraftTelemetry');
function groupBySpot(states) {
const grouped = new Map();
states.forEach((state) => {
- const planSpotId = state.stats?.equipmentPlan?.status === 'active'
+ const planSpotId = !SpotProfiles.isProtectedStarterCohort(state)
+ && state.stats?.equipmentPlan?.status === 'active'
? state.stats.equipmentPlan.next?.spotId
: null;
const spotId = planSpotId || state.spotId;
@@ -44,6 +45,23 @@ function groupBySpot(states) {
});
}
+function partySpotForLeader(leader) {
+ const preserveStarterSpot = SpotProfiles.isProtectedStarterCohort(leader);
+ return SpotProfiles.findForState({
+ ...leader,
+ spotId: preserveStarterSpot ? leader.spotId : null,
+ party: {
+ ...(leader.party || {}),
+ partyId: 'forming',
+ role: PartyComposition.roleForState(leader)
+ },
+ stats: {
+ ...(leader.stats || {}),
+ routeMode: 'party'
+ }
+ }, { mode: 'party', role: PartyComposition.roleForState(leader) }) || SpotProfiles.findById(leader.spotId);
+}
+
function canTakePartyMarketBreak(party, members, member, timestamp = Date.now()) {
if (timestamp - Number(party.stats?.formedAt || party.startedAt || timestamp) < Config.partyMarketBreakMinSessionMs) return false;
if (Number(party.stats?.fightsResolved || 0) < Config.partyMarketBreakMinFights) return false;
@@ -100,6 +118,8 @@ function nearbyHotCount(sessions, player) {
}
const PopulationService = {
+ groupBySpot,
+ partySpotForLeader,
initialized: false,
started: false,
summaryTimer: null,
@@ -552,19 +572,7 @@ const PopulationService = {
if (members.length < Config.partyMinSize) return null;
const leader = PartyComposition.chooseLeader(members);
- const partySpot = SpotProfiles.findForState({
- ...leader,
- spotId: null,
- party: {
- ...(leader.party || {}),
- partyId: 'forming',
- role: PartyComposition.roleForState(leader)
- },
- stats: {
- ...(leader.stats || {}),
- routeMode: 'party'
- }
- }, { mode: 'party', role: PartyComposition.roleForState(leader) }) || SpotProfiles.findById(leader.spotId);
+ const partySpot = partySpotForLeader(leader);
const partyId = `bgp_${Date.now().toString(36)}_${leader.characterId}`;
const nextResolveAt = Date.now() + 45000 + Math.round(Math.random() * 90000);
const party = {
diff --git a/src/GameServer/Bot/Population/SpotProfiles.js b/src/GameServer/Bot/Population/SpotProfiles.js
index 3ec6043f..ab409427 100644
--- a/src/GameServer/Bot/Population/SpotProfiles.js
+++ b/src/GameServer/Bot/Population/SpotProfiles.js
@@ -42,9 +42,17 @@ function profileFromSpot(spot) {
};
}
+function isProtectedStarterCohort(state) {
+ return Number(state?.level || 1) < 5
+ && Number(state?.stats?.populationWave || 0) > 0
+ && !!state?.stats?.starterRegion;
+}
+
const SpotProfiles = {
cache: null,
+ isProtectedStarterCohort,
+
reset() {
this.cache = null;
},
@@ -61,9 +69,7 @@ const SpotProfiles = {
findForState(state, options = {}) {
const acquisitionPlan = state?.stats?.equipmentPlan;
- const protectedStarterCohort = Number(state?.level || 1) < 5
- && Number(state?.stats?.populationWave || 0) > 0
- && !!state?.stats?.starterRegion;
+ const protectedStarterCohort = isProtectedStarterCohort(state);
const keepCurrentSpot = state?.spotId && (!acquisitionPlan || protectedStarterCohort);
// Fresh racial cohorts stay at their physical level-one spot until
diff --git a/tests/test_bot_class_progression.js b/tests/test_bot_class_progression.js
index 7d8cf859..d866fd0a 100644
--- a/tests/test_bot_class_progression.js
+++ b/tests/test_bot_class_progression.js
@@ -9,6 +9,11 @@ const BotRoles = invoke('GameServer/Bot/AI/BotRoles');
DataCache.init();
+const firstProfessionChoices = new Set(Array.from({ length: 30 }, (_, index) => (
+ BotClassProgression.nextClass(0, 20, `starter_${index}`)
+)));
+assert(firstProfessionChoices.size > 1, 'first-profession choices must vary across a generated fighter cohort');
+
const original = {
fetchSkill: Database.fetchSkill,
fetchSkills: Database.fetchSkills,
diff --git a/tests/test_bot_gear_acquisition.js b/tests/test_bot_gear_acquisition.js
index 26e2d6a4..41cc2c9e 100644
--- a/tests/test_bot_gear_acquisition.js
+++ b/tests/test_bot_gear_acquisition.js
@@ -44,10 +44,24 @@ if (previousProgressionRate === undefined) delete process.env.L2NODE_PROGRESSION
else process.env.L2NODE_PROGRESSION_RATE = previousProgressionRate;
const noGradePlan = GearAcquisitionPlanner.planFor({ level: 10, stats: { classId: 0, role: 'dps' }, inventory: {} }, { spots: [stoneGolemSpot] });
-assert.strictEqual(noGradePlan.strategy, 'direct_drop', 'no-grade bots must use drop goals rather than recipes');
+assert(['direct_drop', 'market'].includes(noGradePlan.strategy), 'no-grade bots must choose a drop or market route, never recipes');
assert.strictEqual(noGradePlan.recipeId, null, 'no-grade bots must never receive a crafting recipe');
assert.strictEqual(noGradePlan.rateModelVersion, GearAcquisitionPlanner.RATE_MODEL_VERSION, 'all acquisition plans must persist the drop-rate model used for their estimates');
+const preFocusPlan = GearAcquisitionPlanner.planFor({ level: 4, stats: { classId: 0, role: 'dps' }, inventory: {} }, { spots: [stoneGolemSpot] });
+assert.strictEqual(preFocusPlan.status, 'deferred', 'starter bots must level naturally before gear acquisition begins');
+assert.strictEqual(preFocusPlan.strategy, 'none');
+
+const forcedRecipeBeforeTwenty = GearAcquisitionPlanner.planFor({ level: 19, stats: { classId: 0, role: 'dps' }, inventory: {} }, { spots: [stoneGolemSpot], recipeId: 189 });
+assert.notStrictEqual(forcedRecipeBeforeTwenty.strategy, 'craft', 'no-grade bots must never enter a craft route before level twenty');
+
+const marketNoGradePlan = GearAcquisitionPlanner.planFor({ level: 5, stats: { classId: 0, role: 'dps' }, inventory: {} }, {
+ spots: [],
+ findMarketOffer: (item) => ({ selfId: item.selfId, price: 1, town: 'Giran', sourceType: 'npc' })
+});
+assert.strictEqual(marketNoGradePlan.strategy, 'market', 'an affordable no-grade market offer must beat an unavailable drop route');
+assert.strictEqual(marketNoGradePlan.recipeId, null, 'no-grade market purchases must never request crafting');
+
const serviceCrafter = {
level: 70,
activity: 'crafting',
@@ -61,6 +75,12 @@ const mage = { level: 40, stats: { classId: 10, role: 'mage' }, inventory: {} };
const target = GearAcquisitionPlanner.preferredTarget(mage);
assert(target, 'a C-grade mage without gear must receive a craftable target');
assert(['Weapon.Sword', 'Weapon.Blunt'].includes(target.item.template.kind), 'mage target must use a caster weapon family');
+
+const dMarketPlan = GearAcquisitionPlanner.planFor({ ...mage, level: 20 }, {
+ spots: [],
+ findMarketOffer: (item) => ({ selfId: item.selfId, price: 1, town: 'Giran', sourceType: 'npc' })
+});
+assert.strictEqual(dMarketPlan.strategy, 'market', 'D-grade bots must compare a ready market offer with crafting and drops');
assert(Number(target.item.template.price) <= 2290000, 'a new C-grade bot must begin with an entry-tier weapon target');
const station = ColdCraftingService.stationForRecipe(target.recipe.recipeId);
assert(station, 'a selected equipment recipe must be published by a Giran crafting station');
diff --git a/tests/test_bot_goal_planner.js b/tests/test_bot_goal_planner.js
index e3fd1df1..601be4af 100644
--- a/tests/test_bot_goal_planner.js
+++ b/tests/test_bot_goal_planner.js
@@ -52,6 +52,7 @@ assert.strictEqual(equipmentGoal.target.itemId, expectedWeapon.selfId);
assert.strictEqual(equipmentGoal.plan.expectedBenefit, 'adena_for_weapon_upgrade');
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({
...base,
adena: 1000000,
@@ -66,9 +67,42 @@ assert.strictEqual(armorGoal.target.equipmentSlot, 'chest');
assert.strictEqual(armorGoal.target.itemId, expectedChest.selfId);
assert.strictEqual(armorGoal.plan.expectedBenefit, 'market_search_for_gear');
+const staleMarketPlanGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({
+ ...base,
+ adena: 1000000,
+ stats: {
+ classId: 0,
+ build: { grade: 'c', classId: 0, level: 40 },
+ // The weapon was just purchased. The resolver has not rebuilt the
+ // equipment plan yet, so the next goal must use the chest's own
+ // template data instead of the completed weapon offer.
+ equipment: [{ selfId: expectedWeapon.selfId, slot: 7, rank: 'c', name: expectedWeapon.name }],
+ equipmentPlan: {
+ status: 'active',
+ strategy: 'market',
+ target: { selfId: expectedWeapon.selfId },
+ market: { town: 'Dion', price: 7 }
+ }
+ }
+}, { spot, now: timestamp }), timestamp);
+assert.strictEqual(staleMarketPlanGoal.target.itemId, expectedChest.selfId, 'the next build slot must replace a completed market target');
+assert.strictEqual(staleMarketPlanGoal.target.adena, expectedChestPrice, 'the next item must use its own price rather than the completed offer');
+assert.strictEqual(staleMarketPlanGoal.plan.marketTown, null, 'the next item must be replanned before choosing a market town');
+
const noSnapshot = GoalPlanner.plan(NeedsEvaluator.evaluate({ ...base, stats: { classId: 0, build: { grade: 'c' } } }, { spot, now: timestamp }), timestamp);
assert.notStrictEqual(noSnapshot.type, 'upgrade_gear', 'missing equipment data must not invent a gear deficit');
+const preFocusGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({
+ ...base,
+ level: 4,
+ stats: {
+ classId: 0,
+ build: { grade: 'none', classId: 0, level: 4 },
+ equipment: []
+ }
+}, { spot, now: timestamp }), timestamp);
+assert.strictEqual(preFocusGoal.type, 'progress_level', 'bots below level five must not abandon starter leveling for equipment goals');
+
const saleGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({
...base,
inventory: {
diff --git a/tests/test_population_starter_party_grouping.js b/tests/test_population_starter_party_grouping.js
new file mode 100644
index 00000000..b0807727
--- /dev/null
+++ b/tests/test_population_starter_party_grouping.js
@@ -0,0 +1,43 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const PopulationService = invoke('GameServer/Bot/Population/PopulationService');
+const SpotProfiles = invoke('GameServer/Bot/Population/SpotProfiles');
+
+function starter(characterId, starterRegion, spotId) {
+ return {
+ characterId,
+ level: 1,
+ spotId,
+ party: { role: 'dps' },
+ stats: {
+ populationWave: 1,
+ starterRegion,
+ equipmentPlan: { status: 'active', next: { spotId: '7_40' } }
+ }
+ };
+}
+
+const human = starter(1, 'human', 'starter_human');
+const elf = starter(2, 'elf', 'starter_elf');
+const groups = PopulationService.groupBySpot([human, elf]);
+assert.deepStrictEqual(groups.map((group) => group.map((state) => state.characterId)), [[1], [2]],
+ 'starter cohorts with the same gear target must remain grouped by their physical spot');
+
+const originalFindForState = SpotProfiles.findForState;
+let partyLeader = null;
+SpotProfiles.findForState = (state) => {
+ partyLeader = state;
+ return { id: state.spotId };
+};
+try {
+ const partySpot = PopulationService.partySpotForLeader(human);
+ assert.strictEqual(partyLeader.spotId, 'starter_human',
+ 'starter party formation must retain the leader physical spot');
+ assert.strictEqual(partySpot.id, 'starter_human');
+} finally {
+ SpotProfiles.findForState = originalFindForState;
+}
+
+console.log('Starter party grouping checks passed');
From 8249952abca90e3720a9650170b3d3beae30887d Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 09:26:15 -0400
Subject: [PATCH 11/16] Refine staged bot population seeding
---
src/GameServer/Bot/BotManager.js | 16 +++--
src/GameServer/Bot/Economy/ItemDisposition.js | 29 +++++++-
.../Bot/Population/BackgroundDropResolver.js | 12 +++-
src/GameServer/Bot/Population/BotLifeState.js | 32 +++++++--
.../Bot/Population/GeneratedColdSeeder.js | 4 +-
.../Bot/Population/PopulationConfig.js | 6 +-
.../Bot/Population/PopulationSeedPlanner.js | 67 ++++++++++++++++---
tests/test_bot_background_drops.js | 1 +
tests/test_bot_cold_market_listing.js | 24 +++++++
tests/test_population_seed_planner.js | 53 ++++++++++++++-
10 files changed, 213 insertions(+), 31 deletions(-)
diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js
index ca41d934..2f651f7d 100644
--- a/src/GameServer/Bot/BotManager.js
+++ b/src/GameServer/Bot/BotManager.js
@@ -275,17 +275,23 @@ const BotManager = {
register: (registry) => registry.statusProvider(() => ({ initialized: GoalService.initialized }))
});
- const bots = [...BOTS_TO_SPAWN.filter((bot) => bot.plan !== 'pk_hunting'), ...MERCHANT_BOTS];
- console.info("BotManager :: Starter population: %s", BotPopulation.summarize(BOTS_TO_SPAWN));
+ // Adventurers now belong exclusively to the persistent population
+ // seeder. Keeping the old static starters here doubled the fresh-world
+ // population before wave one had even begun.
+ const bots = [...MERCHANT_BOTS];
+ console.info('BotManager :: Static services: merchants=%d; adventure population is seeder-managed', bots.length);
// Wait 5 seconds after startup to let world finish loading
setTimeout(() => {
bots.forEach((botData, idx) => {
this.provisionAndSpawn(botData, idx);
});
- this.pkEncounterBots = BotPopulation.pkEncounters();
- this.hydratePkEncounterAnchors().finally(() => {
- this.startPkEncounterMonitor();
+ // PK encounter profiles were part of the old static population as
+ // well. Do not introduce high-level hunters into a newly seeded
+ // level-one world; they can be reintroduced as a later population
+ // phase with an explicit lifecycle rule.
+ this.pkEncounterBots = [];
+ Promise.resolve().finally(() => {
this.startDynamicScalingMonitor();
this.startStatusLogMonitor();
PopulationService.start();
diff --git a/src/GameServer/Bot/Economy/ItemDisposition.js b/src/GameServer/Bot/Economy/ItemDisposition.js
index bee1f5ab..2ed8478f 100644
--- a/src/GameServer/Bot/Economy/ItemDisposition.js
+++ b/src/GameServer/Bot/Economy/ItemDisposition.js
@@ -4,6 +4,7 @@ const BotEconomyPricing = invoke('GameServer/Bot/Economy/BotEconomyPricing');
const SELLABLE_KINDS = ['Weapon.', 'Armor.', 'Other.Material'];
const NPC_LIQUIDATION_MAX_UNIT_PRICE = 1000;
const WAREHOUSE_GEAR_MIN_BASE_PRICE = 1000;
+const TRADE_MIN_LEVEL = 10;
function templateFor(selfId) {
return (DataCache.items || []).find((item) => Number(item.selfId) === Number(selfId)) || null;
@@ -33,7 +34,26 @@ function reservedCraftAmounts(state) {
}, {});
}
+function isTradeEligible(state = {}) {
+ // Purpose-built static merchant/craft services are not adventurers and
+ // retain their normal storefronts. Generated characters start selling
+ // only once their first leveling/gear loop has had time to produce useful
+ // surplus.
+ if (!state.stats?.generatedCold) return true;
+ return Number(state.level || 1) >= TRADE_MIN_LEVEL;
+}
+
+function protectedStarterLootAmount(item, kind) {
+ // Low-level resources remain sellable once the character reaches the
+ // trading phase: they are a legitimate early Adena source. Ordinary gear
+ // and drops from level 1-5 mobs are retained instead of becoming instant
+ // private-store/NPC-liquidation stock.
+ if (String(kind || '').startsWith('Other.Material')) return 0;
+ return Math.max(0, Math.min(Number(item?.amount || 0), Number(item?.starterMobLootAmount || 0)));
+}
+
function saleCandidates(state, options = {}) {
+ if (!isTradeEligible(state)) return [];
const limit = Math.max(1, Math.min(20, Number(options.limit) || 8));
const reserved = { ...reservedCraftAmounts(state), ...(options.reserved || {}) };
return Object.values(state?.inventory || {}).flatMap((item) => {
@@ -46,15 +66,17 @@ function saleCandidates(state, options = {}) {
const kind = item.kind || template?.template?.kind || '';
if (!SELLABLE_KINDS.some((prefix) => kind.startsWith(prefix))) return [];
+ const protectedAmount = protectedStarterLootAmount(item, kind);
+ const sellableCount = Math.max(0, sellableAmount - protectedAmount);
const base = basePrice(item, template);
const price = priceFor(state, item, template);
- if (price <= 0) return [];
+ if (price <= 0 || sellableCount <= 0) return [];
return [{
selfId,
name: item.name || template?.template?.name || `Item ${selfId}`,
kind,
rank: item.rank || template?.etc?.rank || 'none',
- count: sellableAmount,
+ count: sellableCount,
price,
basePrice: base
}];
@@ -97,11 +119,14 @@ function saleSummary(state, options = {}) {
module.exports = {
NPC_LIQUIDATION_MAX_UNIT_PRICE,
+ TRADE_MIN_LEVEL,
WAREHOUSE_GEAR_MIN_BASE_PRICE,
basePrice,
+ isTradeEligible,
isWarehouseCandidate,
npcLiquidationCandidates,
priceFor,
+ protectedStarterLootAmount,
reservedCraftAmounts,
saleCandidates,
saleSummary,
diff --git a/src/GameServer/Bot/Population/BackgroundDropResolver.js b/src/GameServer/Bot/Population/BackgroundDropResolver.js
index 3148d65d..7e604111 100644
--- a/src/GameServer/Bot/Population/BackgroundDropResolver.js
+++ b/src/GameServer/Bot/Population/BackgroundDropResolver.js
@@ -45,7 +45,7 @@ function selectItem(items, rng) {
return null;
}
-function itemSnapshot(item, amount) {
+function itemSnapshot(item, amount, sourceMobLevel = 0) {
const template = (DataCache.items || []).find((entry) => Number(entry.selfId) === Number(item.selfId));
if (!template || template.template?.kind === 'Other.Quest') return null;
return {
@@ -53,10 +53,16 @@ function itemSnapshot(item, amount) {
name: item.name || template.template?.name || `Item ${item.selfId}`,
amount,
kind: template.template?.kind || '',
- rank: template.etc?.rank || 'none'
+ rank: template.etc?.rank || 'none',
+ sourceMobLevel: Math.max(0, Number(sourceMobLevel) || 0)
};
}
+function sourceMobLevel(rewardData, spot) {
+ const npc = (DataCache.npcs || []).find((entry) => Number(entry.selfId) === Number(rewardData?.selfId));
+ return Math.max(0, Number(npc?.template?.level || spot?.avgLevel || 0));
+}
+
function rollForFight({ spot, killerLevel, rng = Math.random, maxItems = 1 } = {}) {
const rewardData = rewardDataForSpot(spot, rng);
if (!rewardData) return [];
@@ -75,7 +81,7 @@ function rollForFight({ spot, killerLevel, rng = Math.random, maxItems = 1 } = {
const item = selectItem(group.items, rng);
if (!item || Number(item.selfId) === 57) continue;
const amount = ProgressionRates.scaleAmount(randInt(rng, item.min, item.max), groupRoll.amountMultiplier, rng);
- const snapshot = itemSnapshot(item, amount);
+ const snapshot = itemSnapshot(item, amount, sourceMobLevel(rewardData, spot));
if (snapshot) drops.push(snapshot);
}
return drops;
diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js
index daeabb06..d7e27388 100644
--- a/src/GameServer/Bot/Population/BotLifeState.js
+++ b/src/GameServer/Bot/Population/BotLifeState.js
@@ -412,6 +412,17 @@ function hydrateCache() {
});
}
+function preserveStarterLootProvenance(previousInventory = {}, observedInventory = {}) {
+ return Object.entries(observedInventory).reduce((inventory, [key, item]) => {
+ const protectedAmount = Math.min(
+ Number(item?.amount || 0),
+ Math.max(0, Number(previousInventory?.[key]?.starterMobLootAmount || 0))
+ );
+ inventory[key] = protectedAmount > 0 ? { ...item, starterMobLootAmount: protectedAmount } : item;
+ return inventory;
+ }, {});
+}
+
function classProgressionNeeded(state, classId, level) {
const knownLevel = Number(state.stats?.classProgressionLevel || 0);
const knownClassId = Number(state.stats?.classProgressionClassId ?? state.stats?.classId);
@@ -507,7 +518,7 @@ function mergeSessionIntoLifeState(session, state, phase, reason = '', options =
lastHotAt: phase === 'hot' ? timestamp : state.timing?.lastHotAt || null
},
stats: { ...(state.stats || {}), ...observedStats, lastReason: reason },
- inventory: observedInventory
+ inventory: preserveStarterLootProvenance(state.inventory, observedInventory)
};
}
@@ -1124,12 +1135,25 @@ const BotLifeState = {
const inventory = { ...(state.inventory || {}) };
materializedItems.filter((item) => Number(item.selfId) !== 57).forEach((item) => {
const key = String(item.selfId);
+ const amount = Number(item.amount || 0);
+ const kind = item.kind || inventory[key]?.kind || itemTemplate(item.selfId)?.template?.kind || '';
+ const protectedStarterLoot = Number(item.sourceMobLevel || 0) > 0
+ && Number(item.sourceMobLevel) <= 5
+ && !String(kind).startsWith('Other.Material')
+ ? amount
+ : 0;
+ const nextAmount = Number(inventory[key]?.amount || 0) + amount;
+ const starterMobLootAmount = Math.min(
+ nextAmount,
+ Number(inventory[key]?.starterMobLootAmount || 0) + protectedStarterLoot
+ );
inventory[key] = {
selfId: item.selfId,
name: item.name || inventory[key]?.name || itemName(item.selfId),
- amount: Number(inventory[key]?.amount || 0) + Number(item.amount || 0),
- kind: item.kind || inventory[key]?.kind || itemTemplate(item.selfId)?.template?.kind || '',
- rank: item.rank || inventory[key]?.rank || itemTemplate(item.selfId)?.etc?.rank || 'none'
+ amount: nextAmount,
+ kind,
+ rank: item.rank || inventory[key]?.rank || itemTemplate(item.selfId)?.etc?.rank || 'none',
+ ...(starterMobLootAmount > 0 ? { starterMobLootAmount } : {})
};
});
if (adena > 0) {
diff --git a/src/GameServer/Bot/Population/GeneratedColdSeeder.js b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
index ac09111e..7c81047a 100644
--- a/src/GameServer/Bot/Population/GeneratedColdSeeder.js
+++ b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
@@ -500,7 +500,9 @@ const GeneratedColdSeeder = {
return chain.then(() => this.ensureCraftServices()).then((services) => ({
created: created + services.created,
seeded,
- total: plan.playing + seeded,
+ // `population` includes generated merchants, unlike the
+ // hunting-only count used to pace and backfill each wave.
+ total: plan.population + seeded,
limit,
targetPopulation: plan.targetPopulation,
averageLevel: plan.averageLevel,
diff --git a/src/GameServer/Bot/Population/PopulationConfig.js b/src/GameServer/Bot/Population/PopulationConfig.js
index 4eeb0b83..7b4d0448 100644
--- a/src/GameServer/Bot/Population/PopulationConfig.js
+++ b/src/GameServer/Bot/Population/PopulationConfig.js
@@ -19,9 +19,9 @@ const DEFAULTS = {
partyFormationIntervalMs: 45000,
phasePolicyIntervalMs: 10000,
directorIntervalMs: 30000,
- // Start with every level-one hunting sector, then grow in five-level
- // waves. This is a cap for adventuring bots only; shop services are not
- // part of the simulated player population.
+ // Start with every level-one hunting sector. Waves open every five levels
+ // at x1-x10, or every ten levels at x50 and above. This is a cap for
+ // generated adventurers only; shop services are not part of it.
maxPlayingPopulation: 1700,
starterBotsPerRace: 30,
generatedColdBatchSize: 50,
diff --git a/src/GameServer/Bot/Population/PopulationSeedPlanner.js b/src/GameServer/Bot/Population/PopulationSeedPlanner.js
index b7be9b1c..789cbc6a 100644
--- a/src/GameServer/Bot/Population/PopulationSeedPlanner.js
+++ b/src/GameServer/Bot/Population/PopulationSeedPlanner.js
@@ -1,3 +1,5 @@
+const ProgressionRates = invoke('GameServer/ProgressionRates');
+
function number(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
@@ -11,6 +13,15 @@ const STARTER_REGIONS = [
{ id: 'dwarf', center: { locX: 108000, locY: -175000 }, radius: 30000 }
];
+function regionalTargetCounts(targetPopulation) {
+ const base = Math.floor(Math.max(0, number(targetPopulation)) / STARTER_REGIONS.length);
+ const remainder = Math.max(0, number(targetPopulation)) % STARTER_REGIONS.length;
+ return STARTER_REGIONS.reduce((counts, region, index) => ({
+ ...counts,
+ [region.id]: base + (index < remainder ? 1 : 0)
+ }), {});
+}
+
function distanceSquared(left = {}, right = {}) {
const dx = number(left.locX) - number(right.locX);
const dy = number(left.locY) - number(right.locY);
@@ -23,9 +34,21 @@ function isPlaying(state = {}) {
function snapshot(states = []) {
const playing = states.filter(isPlaying);
- const population = playing.filter((state) => Number(state.stats?.populationWave || 0) > 0);
+ // Wave pacing deliberately follows hunting bots: a bot that opens a
+ // private store has left its farming spot and should be backfilled there.
+ // The hard cap, however, covers every generated character, including the
+ // ones temporarily selling in town.
+ const population = states.filter((state) => Number(state.stats?.populationWave || 0) > 0);
+ const playingPopulation = playing.filter((state) => Number(state.stats?.populationWave || 0) > 0);
+ const populationByStarterRegion = playingPopulation.reduce((counts, state) => {
+ const region = String(state.stats?.starterRegion || '');
+ if (STARTER_REGIONS.some((entry) => entry.id === region)) {
+ counts[region] = number(counts[region]) + 1;
+ }
+ return counts;
+ }, {});
const latestWave = population.reduce((highest, state) => Math.max(highest, Number(state.stats?.populationWave || 0)), 0);
- const latestCohort = population.filter((state) => Number(state.stats?.populationWave || 0) === latestWave);
+ const latestCohort = playingPopulation.filter((state) => Number(state.stats?.populationWave || 0) === latestWave);
const levelTotal = playing.reduce((sum, state) => sum + Math.max(1, number(state.level, 1)), 0);
const spots = playing.reduce((counts, state) => {
if (!state.spotId) return counts;
@@ -35,22 +58,29 @@ function snapshot(states = []) {
return {
playing: playing.length,
+ playingPopulation: playingPopulation.length,
averageLevel: playing.length ? levelTotal / playing.length : 0,
population: population.length,
latestWave,
latestCohortAverageLevel: latestCohort.length
? latestCohort.reduce((sum, state) => sum + Math.max(1, number(state.level, 1)), 0) / latestCohort.length
: 0,
+ populationByStarterRegion,
+ hasRegionalPopulation: Object.keys(populationByStarterRegion).length > 0,
spots,
hasPopulationSeed: population.length > 0
};
}
-function nextWave(snapshot = {}) {
+function waveLevelThreshold(multiplier = ProgressionRates.profile().multiplier) {
+ return number(multiplier, 1) >= 50 ? 10 : 5;
+}
+
+function nextWave(snapshot = {}, levelThreshold = waveLevelThreshold()) {
if (!snapshot.hasPopulationSeed) return 1;
// Advance exactly one cohort at a time. Legacy/static bots must neither
// suppress the first wave nor jump several waves on server restart.
- return snapshot.latestCohortAverageLevel >= 5
+ return snapshot.latestCohortAverageLevel >= levelThreshold
? snapshot.latestWave + 1
: snapshot.latestWave;
}
@@ -86,32 +116,46 @@ function seedBatchSize(plan = {}, configuredBatch = 1) {
return Math.max(normalBatch, Number(plan.newBotsNeeded || plan.missing?.length || 0));
}
-function plan(profiles = [], states = [], maxPopulation = 1700, botsPerRace = 30) {
+function plan(profiles = [], states = [], maxPopulation = 1700, botsPerRace = 30, options = {}) {
const current = snapshot(states);
const limit = Math.max(0, number(maxPopulation));
- const wave = nextWave(current);
+ const levelThreshold = waveLevelThreshold(options.progressionMultiplier);
+ const wave = nextWave(current, levelThreshold);
const eligible = eligibleSpots(profiles, 1);
- const available = Math.max(0, limit - current.playing);
+ const available = Math.max(0, limit - current.population);
const plannedSlots = starterSlots(eligible, botsPerRace, wave);
const targetPopulation = Math.min(limit, STARTER_REGIONS.length * Math.max(0, number(botsPerRace, 30)) * wave);
- // The hard server cap includes everyone, but legacy/static bots do not
- // replace the requested 30-per-race generated cohort.
- const newBotsNeeded = Math.max(0, targetPopulation - current.population);
+ // Static merchant and craft services are outside the generated-population
+ // cap, and do not replace the requested 30-per-race starter cohort.
+ const regionalTargets = regionalTargetCounts(targetPopulation);
+ const regionalMissing = STARTER_REGIONS.reduce((counts, region) => ({
+ ...counts,
+ [region.id]: Math.max(0, number(regionalTargets[region.id]) - number(current.populationByStarterRegion[region.id]))
+ }), {});
+ const newBotsNeeded = current.hasRegionalPopulation
+ ? Object.values(regionalMissing).reduce((sum, count) => sum + number(count), 0)
+ : Math.max(0, targetPopulation - current.population);
const occupied = { ...current.spots };
const missing = plannedSlots
.filter((spot) => {
+ if (current.hasRegionalPopulation && number(regionalMissing[spot.starterRegion]) <= 0) return false;
const count = number(occupied[spot.id]);
occupied[spot.id] = count + 1;
- return count < plannedSlots.filter((candidate) => candidate.id === spot.id).length;
+ const available = count < plannedSlots.filter((candidate) => candidate.id === spot.id).length;
+ if (available && current.hasRegionalPopulation) regionalMissing[spot.starterRegion] -= 1;
+ return available;
})
.slice(0, Math.min(available, newBotsNeeded));
return {
...current,
maxPopulation: limit,
+ levelThreshold,
wave,
targetPopulation,
newBotsNeeded,
+ regionalTargets,
+ regionalMissing,
eligible,
plannedSlots,
missing
@@ -122,6 +166,7 @@ module.exports = {
isPlaying,
snapshot,
STARTER_REGIONS,
+ waveLevelThreshold,
nextWave,
eligibleSpots,
starterSlots,
diff --git a/tests/test_bot_background_drops.js b/tests/test_bot_background_drops.js
index 06de316f..d0175f02 100644
--- a/tests/test_bot_background_drops.js
+++ b/tests/test_bot_background_drops.js
@@ -23,6 +23,7 @@ const direct = BackgroundDropResolver.rollForFight({ spot, killerLevel: 1, rng:
assert.strictEqual(direct.length, 1);
assert.strictEqual(direct[0].selfId, 1121, 'the selected item must come from the real Gremlin rewards');
assert.strictEqual(direct[0].kind, 'Armor.Wear');
+assert.strictEqual(direct[0].sourceMobLevel, 1, 'background loot must retain the source-mob level for sale policy');
const nameOnly = BackgroundDropResolver.rollForFight({
spot: { ...spot, npcSelfIds: [], npcNames: ['Gremlin'] },
diff --git a/tests/test_bot_cold_market_listing.js b/tests/test_bot_cold_market_listing.js
index efae00ee..283f86ad 100644
--- a/tests/test_bot_cold_market_listing.js
+++ b/tests/test_bot_cold_market_listing.js
@@ -65,6 +65,30 @@ async function run() {
const candidates = ItemDisposition.saleCandidates(state);
assert.deepStrictEqual(candidates.map((item) => item.selfId), [1], 'equipped gear must never be listed');
+ const preTradeState = {
+ ...state,
+ level: 9,
+ stats: { ...state.stats, generatedCold: true }
+ };
+ assert.deepStrictEqual(ItemDisposition.saleCandidates(preTradeState), [], 'generated bots must not sell before level ten');
+ const preTradeListing = await ListingService.open(preTradeState, { now: 1000 });
+ assert.strictEqual(preTradeListing.reason, 'nothing_to_sell', 'pre-ten generated bots must never open a private store');
+
+ const starterMobLootState = {
+ ...state,
+ stats: { ...state.stats, generatedCold: true },
+ inventory: {
+ 57: { selfId: 57, name: 'Adena', amount: 500 },
+ 1: { selfId: 1, name: 'Short Sword', amount: 1, equipped: false, slot: 7, kind: 'Weapon.Sword', starterMobLootAmount: 1 },
+ 1864: { selfId: 1864, name: 'Stem', amount: 4, kind: 'Other.Material', starterMobLootAmount: 4 }
+ }
+ };
+ assert.deepStrictEqual(
+ ItemDisposition.saleCandidates(starterMobLootState).map((item) => item.selfId),
+ [1864],
+ 'ordinary level-one-to-five loot must stay out of sales while materials remain sellable'
+ );
+
const opened = await ListingService.open(state, { now: 1000, durationMs: 60000, random: () => 0.1 });
assert.strictEqual(opened.listed, true);
assert.strictEqual(opened.state.activity, 'merchant');
diff --git a/tests/test_population_seed_planner.js b/tests/test_population_seed_planner.js
index 0b9362fe..b6a094c9 100644
--- a/tests/test_population_seed_planner.js
+++ b/tests/test_population_seed_planner.js
@@ -24,6 +24,10 @@ assert.deepStrictEqual(
);
assert.strictEqual(Planner.seedBatchSize(initial, 2), 150,
'the first wave must not be split by the normal seed batch limit');
+assert.strictEqual(Planner.waveLevelThreshold(1), 5, 'x1 must open the next wave at level 5');
+assert.strictEqual(Planner.waveLevelThreshold(10), 5, 'x10 must retain the level-5 wave threshold');
+assert.strictEqual(Planner.waveLevelThreshold(50), 10, 'x50 must defer the next wave to level 10');
+assert.strictEqual(Planner.waveLevelThreshold(100), 10, 'rates above x50 must retain the level-10 wave threshold');
const starterRaces = { human: 0, elf: 1, dark_elf: 2, orc: 3, dwarf: 4 };
Object.entries(starterRaces).forEach(([starterRegion, race]) => {
@@ -75,10 +79,10 @@ const progressed = Planner.plan(profiles, [
level: 5,
spotId: `moved_on_${index}`,
activity: 'hunting',
- stats: { populationWave: 1 }
+ stats: { populationWave: 1, starterRegion: spot.starterRegion }
})),
{ characterId: 2, level: 70, spotId: null, activity: 'crafting' }
-], 1700, 30);
+], 1700, 30, { progressionMultiplier: 1 });
assert.strictEqual(progressed.averageLevel, 5, 'craft services must not accelerate population waves');
assert.strictEqual(progressed.wave, 2);
assert.strictEqual(progressed.targetPopulation, 300);
@@ -87,6 +91,51 @@ assert.strictEqual(progressed.missing.length, 150,
assert.strictEqual(Planner.seedBatchSize(progressed, 2), 150,
'a later 150-bot cohort must not be split by the normal seed batch limit');
+const highRateNotReady = Planner.plan(profiles, initial.missing.map((spot, index) => ({
+ characterId: index + 1,
+ level: 5,
+ spotId: `high_rate_${index}`,
+ activity: 'hunting',
+ stats: { populationWave: 1, starterRegion: spot.starterRegion }
+})), 1700, 30, { progressionMultiplier: 50 });
+assert.strictEqual(highRateNotReady.levelThreshold, 10);
+assert.strictEqual(highRateNotReady.wave, 1, 'x50 must not open the second wave at level 5');
+assert.strictEqual(highRateNotReady.missing.length, 0, 'x50 must wait for the first cohort to reach level 10');
+
+const highRateProgressed = Planner.plan(profiles, initial.missing.map((spot, index) => ({
+ characterId: index + 1,
+ level: 10,
+ spotId: `high_rate_${index}`,
+ activity: 'hunting',
+ stats: { populationWave: 1, starterRegion: spot.starterRegion }
+})), 1700, 30, { progressionMultiplier: 50 });
+assert.strictEqual(highRateProgressed.wave, 2, 'x50 must open the second wave at level 10');
+assert.strictEqual(highRateProgressed.missing.length, 150, 'x50 level-10 progress must add one full cohort');
+
+const merchantBackfill = Planner.plan(profiles, initial.missing.map((spot, index) => ({
+ characterId: index + 1,
+ level: 2,
+ spotId: spot.id,
+ activity: spot.starterRegion === 'human' && index < 10 ? 'merchant' : 'hunting',
+ stats: { populationWave: 1, starterRegion: spot.starterRegion }
+})), 1700, 30);
+assert.strictEqual(merchantBackfill.missing.length, 2, 'merchant departures must only backfill their own racial cohort');
+assert(merchantBackfill.missing.every((spot) => spot.starterRegion === 'human'), 'racial backfill must not drift into another starter region');
+
+const merchantCapped = Planner.plan(profiles, Array.from({ length: 1700 }, (_, index) => ({
+ characterId: index + 1,
+ level: 12,
+ spotId: `merchant_cap_${index}`,
+ activity: index < 150 ? 'merchant' : 'hunting',
+ stats: { populationWave: 1, starterRegion: index < 150 ? 'human' : 'elf' }
+})), 1700, 30);
+assert.strictEqual(merchantCapped.playingPopulation, 1550,
+ 'merchant states must remain outside the hunting population used for wave pacing');
+assert.strictEqual(merchantCapped.population, 1700,
+ 'the global cap must include generated merchants');
+assert.strictEqual(merchantCapped.missing.length, 0,
+ 'no replacement may be created once all generated bot slots are occupied by hunters or merchants');
+
const capped = Planner.plan(profiles, [
...Array.from({ length: 1690 }, (_, index) => ({
characterId: index + 1,
From 4974b7cf49064d7d6fc064fcac7a10a5dcdb8017 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 10:22:42 -0400
Subject: [PATCH 12/16] Fix merchant stall routing expectations
---
tests/test_bot_market_town_routing.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/test_bot_market_town_routing.js b/tests/test_bot_market_town_routing.js
index 4fcdd325..0b984c99 100644
--- a/tests/test_bot_market_town_routing.js
+++ b/tests/test_bot_market_town_routing.js
@@ -90,7 +90,7 @@ const dionStall = ListingService.chooseDionDMarketStall(() => 0.5, []);
assert(ListingService.isDionDMarketStallLocation(dionStall), 'Dion D-grade listings must remain inside the captured trading square');
const gludioStaticStalls = ListingService.staticMerchantStalls('Gludio', ListingService.isGludioDMarketStallLocation);
-assert.strictEqual(gludioStaticStalls.length, 4, 'all fixed Gludio merchants must reserve their market stalls');
+assert.strictEqual(gludioStaticStalls.length, 5, 'all fixed Gludio merchants must reserve their market stalls');
const gludioCandidateNearLysa = ListingService.chooseGludioDMarketStall(
(() => {
const values = [60 / 390, 970 / 1080];
@@ -142,7 +142,7 @@ const talkingIslandStall = ListingService.chooseTalkingIslandNoGradeStall(() =>
assert(ListingService.isTalkingIslandNoGradeStallLocation(talkingIslandStall), 'Talking Island no-grade listings must remain inside the captured trading square');
assert.strictEqual(
ListingService.staticMerchantStalls('Talking Island', ListingService.isTalkingIslandNoGradeStallLocation).length,
- 4,
+ 5,
'fixed Talking Island merchants must reserve their market stalls'
);
From fcd64fa6fb7879834c1fe9906f83eb62be2eb38e Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 10:30:28 -0400
Subject: [PATCH 13/16] Generate readable population bot names
---
src/Database.js | 8 ++
.../Bot/Population/BotNameGenerator.js | 79 ++++++++-----------
.../Bot/Population/GeneratedColdSeeder.js | 37 +++++++--
tests/test_population_seed_planner.js | 2 +
4 files changed, 74 insertions(+), 52 deletions(-)
diff --git a/src/Database.js b/src/Database.js
index 1f42d3c1..977a55cc 100644
--- a/src/Database.js
+++ b/src/Database.js
@@ -748,6 +748,14 @@ const Database = {
);
},
+ updateCharacterName(id, name) {
+ return Database.execute(
+ builder.update('characters', {
+ name: name
+ }, 'id = ? LIMIT 1', id)
+ );
+ },
+
updateCharacterExperience(id, level, exp, sp) {
return Database.execute(
builder.update('characters', {
diff --git a/src/GameServer/Bot/Population/BotNameGenerator.js b/src/GameServer/Bot/Population/BotNameGenerator.js
index 99a849b1..6104fdd6 100644
--- a/src/GameServer/Bot/Population/BotNameGenerator.js
+++ b/src/GameServer/Bot/Population/BotNameGenerator.js
@@ -1,56 +1,45 @@
-// Generated population names intentionally come from a local, original corpus.
-// It captures the short fantasy and player-style nicknames common in MMORPGs
-// without distributing or impersonating names taken from player accounts.
-const STARTS = [
- 'Ael', 'Aer', 'Ari', 'Ash', 'Astra', 'Bren', 'Cael', 'Cind', 'Cor', 'Dae',
- 'Dra', 'Eli', 'Ery', 'Fen', 'Galen', 'Iri', 'Kael', 'Kira', 'Lio', 'Lun',
- 'Mira', 'Ner', 'Nyx', 'Ori', 'Rae', 'Rav', 'Sera', 'Syl', 'Tae', 'Thorn',
- 'Vale', 'Vex', 'Wyn', 'Xan', 'Yara', 'Zer'
+// Generated population names use readable CamelCase pairs. The account id
+// remains the durable technical identity; display names should look like
+// player nicknames rather than a syllable hash with a collision suffix.
+const GIVEN_NAMES = [
+ 'Aelina', 'Aerin', 'Alira', 'Amara', 'Arlen', 'Arwyn', 'Asher', 'Astrid',
+ 'Brenna', 'Brina', 'Caelan', 'Carys', 'Cedric', 'Celine', 'Corin', 'Cyra',
+ 'Daria', 'Dorian', 'Eira', 'Elara', 'Elian', 'Elora', 'Emrys', 'Eryn',
+ 'Faris', 'Fenna', 'Galen', 'Garen', 'Halen', 'Ilyra', 'Irena', 'Isolde',
+ 'Jaren', 'Kaela', 'Kieran', 'Liora', 'Lucan', 'Lyra', 'Maelin', 'Mara',
+ 'Nadia', 'Naren', 'Neris', 'Orin', 'Raina', 'Riven', 'Rowan', 'Sable',
+ 'Seren', 'Silas', 'Sylva', 'Talia', 'Taren', 'Thalia', 'Torin', 'Vaela',
+ 'Valen', 'Varyn', 'Vela', 'Wren', 'Xara', 'Yara', 'Zorin'
];
-const ENDS = [
- 'a', 'ae', 'an', 'ar', 'ara', 'as', 'el', 'en', 'er', 'eth', 'ia', 'ian',
- 'iel', 'in', 'ira', 'is', 'on', 'or', 'os', 'ra', 'ren', 'ric', 'ris', 'ros',
- 'yn', 'ys'
+const BYNAMES = [
+ 'Amber', 'Arbor', 'Ash', 'Birch', 'Bloom', 'Bramble', 'Bright', 'Brook',
+ 'Cedar', 'Cinder', 'Cloud', 'Clover', 'Coast', 'Crest', 'Dawn', 'Drift',
+ 'Dusk', 'Echo', 'Ember', 'Falcon', 'Fern', 'Field', 'Flame', 'Frost',
+ 'Gale', 'Glimmer', 'Grove', 'Harbor', 'Haven', 'Hearth', 'Hill', 'Ivy',
+ 'Juniper', 'Lake', 'Lantern', 'Lark', 'Light', 'Linden', 'Maple', 'Marsh',
+ 'Meadow', 'Mist', 'Moon', 'Moss', 'Night', 'North', 'Oak', 'Onyx', 'Pearl',
+ 'Quartz', 'Rain', 'Raven', 'Reed', 'Ridge', 'River', 'Rose', 'Rowan',
+ 'Rune', 'Saffron', 'Sage', 'Sand', 'Shore', 'Silver', 'Sky', 'Snow', 'Sol',
+ 'Sparrow', 'Spring', 'Star', 'Stone', 'Storm', 'Summer', 'Thorn', 'Tide',
+ 'Umber', 'Vale', 'Velvet', 'Vesper', 'Wave', 'West', 'Wild', 'Willow',
+ 'Wind', 'Winter', 'Wisp', 'Wolf', 'Wood'
];
-const MIDDLES = [
- 'a', 'ae', 'an', 'ar', 'ava', 'dra', 'el', 'en', 'eth', 'ia', 'iel', 'in',
- 'ira', 'ka', 'or', 'ra', 'ren', 'ri', 'ryn', 'sa', 'sha', 'th', 'va', 'ver', 'wyn'
-];
-
-function mix(value) {
- let hash = Number(value) >>> 0;
- hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
- hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
- return (hash ^ (hash >>> 16)) >>> 0;
-}
-
-function normalize(name) {
- return name.slice(0, 16);
-}
+const NAME_SPACE = GIVEN_NAMES.length * BYNAMES.length;
-function alphabeticToken(seed, length = 3) {
- const modulus = 26 ** length;
- // This is a permutation of the alphabetic token space, so nearby
- // population slots do not collide while generated names remain digit-free.
- let value = Number((BigInt(Math.trunc(seed)) * 7919n) % BigInt(modulus));
- let token = '';
- for (let index = 0; index < length; index++) {
- token += String.fromCharCode(97 + (value % 26));
- value = Math.floor(value / 26);
- }
- return token;
+function normalizedIndex(value) {
+ const parsed = Math.trunc(Number(value) || 0);
+ // 7919 is coprime with the 5,481 available pairs. This keeps the mapping
+ // one-to-one while spreading adjacent population slots across surnames.
+ return Number((BigInt(Math.abs(parsed)) * 7919n) % BigInt(NAME_SPACE));
}
function nameFor(index) {
- const seed = Math.max(0, Number(index) || 0);
- const slot = mix(seed);
- const nameSlot = slot;
- const start = STARTS[nameSlot % STARTS.length];
- const end = ENDS[Math.floor(nameSlot / STARTS.length) % ENDS.length];
- const middle = MIDDLES[Math.floor(nameSlot / (STARTS.length * ENDS.length)) % MIDDLES.length];
- return normalize(`${start}${middle}${end}${alphabeticToken(seed)}`);
+ const slot = normalizedIndex(index);
+ const given = GIVEN_NAMES[slot % GIVEN_NAMES.length];
+ const byname = BYNAMES[Math.floor(slot / GIVEN_NAMES.length)];
+ return `${given}${byname}`;
}
module.exports = { nameFor };
diff --git a/src/GameServer/Bot/Population/GeneratedColdSeeder.js b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
index 7c81047a..ea85a0b2 100644
--- a/src/GameServer/Bot/Population/GeneratedColdSeeder.js
+++ b/src/GameServer/Bot/Population/GeneratedColdSeeder.js
@@ -12,6 +12,8 @@ const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService');
const SeedPlanner = invoke('GameServer/Bot/Population/PopulationSeedPlanner');
const BotNameGenerator = invoke('GameServer/Bot/Population/BotNameGenerator');
+const NAME_GENERATOR_VERSION = 2;
+
const CLASS_POOL = [
{ race: 0, classId: 0, sex: 0, role: 'dps' },
{ race: 0, classId: 10, sex: 1, role: 'mage' },
@@ -136,16 +138,36 @@ function nameFor(index) {
return BotNameGenerator.nameFor(index);
}
-function uniqueNameFor(index, attempt = 0) {
- // Keep retry names natural too: a collision must not reintroduce a visible
- // population counter on an otherwise player-like nickname.
- const candidate = nameFor(Math.max(0, Number(index) || 0) + attempt * 2654435761);
+function uniqueNameFor(index, attempt = 0, characterId = null) {
+ // A collision advances to the next readable pair instead of appending a
+ // technical suffix to the visible character name.
+ const candidate = nameFor(Math.max(0, Number(index) || 0) + attempt);
return Database.fetchCharacterName(candidate).then((rows) => {
- if (!rows[0]) return candidate;
- return uniqueNameFor(index, attempt + 1);
+ if (!rows[0] || Number(rows[0].id) === Number(characterId)) return candidate;
+ return uniqueNameFor(index, attempt + 1, characterId);
});
}
+function migratePopulationNames(states = []) {
+ const candidates = states.filter((state) => state.accountName?.startsWith('bot_pop_')
+ && state.stats?.generatedCold
+ && Number(state.stats?.nameGeneratorVersion || 0) < NAME_GENERATOR_VERSION
+ && Number.isFinite(Number(state.stats?.generatedIndex)));
+ return candidates.reduce((chain, state) => chain.then(() => (
+ uniqueNameFor(state.stats.generatedIndex, 0, state.characterId).then((name) => {
+ const nextState = {
+ ...state,
+ name,
+ stats: { ...(state.stats || {}), nameGeneratorVersion: NAME_GENERATOR_VERSION }
+ };
+ const rename = name === state.name
+ ? Promise.resolve()
+ : Database.updateCharacterName(state.characterId, name);
+ return rename.then(() => LifeState.upsertState(nextState, 'generated_name_migration'));
+ })
+ )), Promise.resolve()).then(() => candidates.length);
+}
+
function awardBaseGear(characterId, classId) {
const items = DataCache.newbieItems.find((row) => row.classId === classId)?.items || [];
return Database.fetchItems(characterId).then((existing) => {
@@ -331,6 +353,7 @@ function stateFor(character, index, seedMeta = {}) {
classProgressionClassId: classId,
generatedCold: true,
generatedIndex: index,
+ nameGeneratorVersion: NAME_GENERATOR_VERSION,
levelBand: levelProfile.band,
populationWave: seedMeta.populationWave || null,
starterRegion: seedMeta.starterRegion || null
@@ -457,7 +480,7 @@ const GeneratedColdSeeder = {
if (!limit || this.running) return Promise.resolve({ created: 0, seeded: 0, total: 0, limit });
this.running = true;
- return Promise.resolve().then(() => {
+ return Promise.resolve().then(() => migratePopulationNames(LifeState.allStates(limit + 100))).then(() => {
const plan = SeedPlanner.plan(
SpotProfiles.ensure(),
LifeState.allStates(limit + 100),
diff --git a/tests/test_population_seed_planner.js b/tests/test_population_seed_planner.js
index b6a094c9..4e090503 100644
--- a/tests/test_population_seed_planner.js
+++ b/tests/test_population_seed_planner.js
@@ -152,5 +152,7 @@ assert.ok(generatedNames.every((name) => name.length >= 3 && name.length <= 16),
assert.ok(generatedNames.every((name) => /^[A-Za-z]+$/.test(name)), 'generated names must remain client-safe alphabetic nicknames');
assert.ok(new Set(generatedNames).size > 4500, 'the local nickname corpus must provide a varied population');
assert.ok(generatedNames.every((name) => !/[0-9]/.test(name)), 'ordinary generated names must not expose population counters');
+assert.ok(generatedNames.every((name) => /^[A-Z][a-z]+[A-Z][a-z]+$/.test(name)), 'generated names must remain readable CamelCase name pairs');
+assert.strictEqual(new Set(generatedNames).size, generatedNames.length, 'readable names must remain unique across a full population sample');
console.log('Population seed planner checks passed');
From 820e025e82564fe7866860cc29b5e2cb121c78cb Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 10:34:16 -0400
Subject: [PATCH 14/16] Fix town shot merchant assortments
---
src/GameServer/Bot/MerchantStoreConfigs.js | 77 ++++++++++++----------
tests/test_npc_shop_stock.js | 24 ++++---
2 files changed, 57 insertions(+), 44 deletions(-)
diff --git a/src/GameServer/Bot/MerchantStoreConfigs.js b/src/GameServer/Bot/MerchantStoreConfigs.js
index e1f536ca..14043c8c 100644
--- a/src/GameServer/Bot/MerchantStoreConfigs.js
+++ b/src/GameServer/Bot/MerchantStoreConfigs.js
@@ -2,9 +2,15 @@ const BUY_CAP = 999999;
const s = (selfId, priceRate, count) => ({ selfId, priceRate, count });
const b = (selfId, priceRate, count = BUY_CAP) => ({ selfId, priceRate, count });
-const SPIRITSHOT_IDS = [2509, 2510, 2511, 2512, 2513, 2514];
-const spiritshotsThrough = (grade) => SPIRITSHOT_IDS
- .slice(0, grade + 1)
+const SHOT_IDS_BY_GRADE = [
+ [1835, 2509, 3947], // No Grade: Soulshot, Spiritshot, Blessed Spiritshot
+ [1463, 2510, 3948], // D
+ [1464, 2511, 3949], // C
+ [1465, 2512, 3950], // B
+ [1466, 2513, 3951], // A
+ [1467, 2514, 3952] // S
+];
+const shotsForGrade = (grade) => (SHOT_IDS_BY_GRADE[grade] || SHOT_IDS_BY_GRADE[0])
.map((selfId) => s(selfId, 1, BUY_CAP));
module.exports = {
@@ -238,110 +244,111 @@ module.exports = {
]
},
- // Spiritshots: dedicated private stores keep ordinary NPC grocery lists focused.
+ // Dedicated shot stores sell every player shot type at the town's exact
+ // progression grade. They deliberately do not carry lower grades.
"Tia": {
- title: "Spiritshots: all grades",
+ title: "Shots: No Grade",
town: "Talking Island",
storeType: 1,
locX: -84250, locY: 244680, locZ: -3730,
- items: spiritshotsThrough(5)
+ items: shotsForGrade(0)
},
"Elya": {
- title: "Spiritshots: all grades",
+ title: "Shots: No Grade",
town: "Elven Village",
storeType: 1,
locX: 42700, locY: 50130, locZ: -2984,
- items: spiritshotsThrough(5)
+ items: shotsForGrade(0)
},
"Dena": {
- title: "Spiritshots: all grades",
+ title: "Shots: No Grade",
town: "Dark Elven Village",
storeType: 1,
locX: 12060, locY: 15740, locZ: -4554,
- items: spiritshotsThrough(5)
+ items: shotsForGrade(0)
},
"Orik": {
- title: "Spiritshots: all grades",
+ title: "Shots: No Grade",
town: "Orc Village",
storeType: 1,
locX: -44080, locY: -115380, locZ: -194,
- items: spiritshotsThrough(5)
+ items: shotsForGrade(0)
},
"Bran": {
- title: "Spiritshots: all grades",
+ title: "Shots: No Grade",
town: "Dwarven Village",
storeType: 1,
locX: 116360, locY: -177600, locZ: -914,
- items: spiritshotsThrough(5)
+ items: shotsForGrade(0)
},
"Rolf": {
- title: "Spiritshots: D grade",
+ title: "Shots: D Grade",
town: "Gludin",
storeType: 1,
- locX: -79320, locY: 153900, locZ: -3160,
- items: spiritshotsThrough(1)
+ locX: -80620, locY: 150020, locZ: -3040,
+ items: shotsForGrade(1)
},
"Sila": {
- title: "Spiritshots: D grade",
+ title: "Shots: D Grade",
town: "Gludio",
storeType: 1,
locX: -14480, locY: 123730, locZ: -3117,
- items: spiritshotsThrough(1)
+ items: shotsForGrade(1)
},
"Tara": {
- title: "Spiritshots: D grade",
+ title: "Shots: D Grade",
town: "Dion",
storeType: 1,
locX: 15910, locY: 143200, locZ: -2707,
- items: spiritshotsThrough(1)
+ items: shotsForGrade(1)
},
"Eris": {
- title: "Spiritshots: C grade",
+ title: "Shots: C Grade",
town: "Giran",
storeType: 1,
locX: 83600, locY: 148300, locZ: -3406,
- items: spiritshotsThrough(2)
+ items: shotsForGrade(2)
},
"Sera": {
- title: "Spiritshots: B grade",
+ title: "Shots: B Grade",
town: "Oren",
storeType: 1,
locX: 83200, locY: 53380, locZ: -1497,
- items: spiritshotsThrough(3)
+ items: shotsForGrade(3)
},
"Nora": {
- title: "Spiritshots: B grade",
+ title: "Shots: B Grade",
town: "Hunter's Village",
storeType: 1,
locX: 116760, locY: 74880, locZ: -2581,
- items: spiritshotsThrough(3)
+ items: shotsForGrade(3)
},
"Lina": {
- title: "Spiritshots: B grade",
+ title: "Shots: B Grade",
town: "Heine",
storeType: 1,
locX: 111500, locY: 219500, locZ: -3544,
- items: spiritshotsThrough(3)
+ items: shotsForGrade(3)
},
"Mila": {
- title: "Spiritshots: A grade",
+ title: "Shots: A Grade",
town: "Aden",
storeType: 1,
locX: 148980, locY: 28060, locZ: -2253,
- items: spiritshotsThrough(4)
+ items: shotsForGrade(4)
},
"Sven": {
- title: "Spiritshots: S grade",
+ title: "Shots: S Grade",
town: "Goddard",
storeType: 1,
locX: 148050, locY: -55340, locZ: -2728,
- items: spiritshotsThrough(5)
+ items: shotsForGrade(5)
},
"Runa": {
- title: "Spiritshots: S grade",
+ title: "Shots: S Grade",
town: "Rune",
storeType: 1,
locX: 43950, locY: -47720, locZ: -792,
- items: spiritshotsThrough(5)
+ items: shotsForGrade(5)
}
};
diff --git a/tests/test_npc_shop_stock.js b/tests/test_npc_shop_stock.js
index 1fc0fbbe..65ac4069 100644
--- a/tests/test_npc_shop_stock.js
+++ b/tests/test_npc_shop_stock.js
@@ -55,22 +55,28 @@ for (const npcId of [7004, 7137, 7150, 7519, 7561, 7063, 7254, 7315, 7081, 7180,
assert.deepStrictEqual(shopSpiritshots(npcId), [2509], `ordinary NPC merchant ${npcId} must only retain its no-grade Spiritshot`);
}
-const spiritshotStores = [
- ['Tia', 'Talking Island', 5], ['Elya', 'Elven Village', 5], ['Dena', 'Dark Elven Village', 5],
- ['Orik', 'Orc Village', 5], ['Bran', 'Dwarven Village', 5], ['Rolf', 'Gludin', 1],
+const shotStores = [
+ ['Tia', 'Talking Island', 0], ['Elya', 'Elven Village', 0], ['Dena', 'Dark Elven Village', 0],
+ ['Orik', 'Orc Village', 0], ['Bran', 'Dwarven Village', 0], ['Rolf', 'Gludin', 1],
['Sila', 'Gludio', 1], ['Tara', 'Dion', 1], ['Eris', 'Giran', 2], ['Sera', 'Oren', 3],
['Nora', "Hunter's Village", 3], ['Lina', 'Heine', 3], ['Mila', 'Aden', 4],
['Sven', 'Goddard', 5], ['Runa', 'Rune', 5]
];
-const spiritshotIds = [2509, 2510, 2511, 2512, 2513, 2514];
-for (const [name, town, grade] of spiritshotStores) {
+const shotIdsByGrade = [
+ [1835, 2509, 3947], [1463, 2510, 3948], [1464, 2511, 3949],
+ [1465, 2512, 3950], [1466, 2513, 3951], [1467, 2514, 3952]
+];
+for (const [name, town, grade] of shotStores) {
const store = MerchantStoreConfigs[name];
- assert.ok(store, `${town} must have a dedicated Spiritshot merchant`);
+ assert.ok(store, `${town} must have a dedicated shot merchant`);
assert.strictEqual(store.storeType, 1, `${name} must be a selling private store`);
assert.strictEqual(store.town, town, `${name} must be placed in ${town}`);
- assert.deepStrictEqual(store.items.map((item) => item.selfId), spiritshotIds.slice(0, grade + 1), `${name} must stock Spiritshots through its town grade`);
+ assert.deepStrictEqual(store.items.map((item) => item.selfId), shotIdsByGrade[grade], `${name} must stock every shot type at its town grade only`);
store.items.forEach((item) => {
- assert.strictEqual(item.priceRate, 1, `${name} must use the standard Spiritshot price`);
- assert.strictEqual(item.count, 999999, `${name} must have a practical unlimited Spiritshot stock`);
+ assert.strictEqual(item.priceRate, 1, `${name} must use the standard shot price`);
+ assert.strictEqual(item.count, 999999, `${name} must have a practical unlimited shot stock`);
});
}
+
+assert(Math.hypot(MerchantStoreConfigs.Rolf.locX + 80826, MerchantStoreConfigs.Rolf.locY - 149775) < 1000,
+ 'Gludin shot merchant must be placed inside the town square');
From 6877ce423816bda9a1299d21931616183a9c8a57 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 10:40:06 -0400
Subject: [PATCH 15/16] Route no-grade bot stores to starter markets
---
.../Bot/Economy/ColdMarketListingService.js | 2 +-
src/GameServer/Bot/Economy/MarketTownPolicy.js | 18 +++++++++---------
tests/test_bot_cold_market_listing.js | 5 ++++-
tests/test_bot_market_town_routing.js | 4 ++--
4 files changed, 16 insertions(+), 13 deletions(-)
diff --git a/src/GameServer/Bot/Economy/ColdMarketListingService.js b/src/GameServer/Bot/Economy/ColdMarketListingService.js
index c5b0074d..17590caa 100644
--- a/src/GameServer/Bot/Economy/ColdMarketListingService.js
+++ b/src/GameServer/Bot/Economy/ColdMarketListingService.js
@@ -10,7 +10,7 @@ const GeodataEngine = invoke('GameServer/Geodata/GeodataEngine');
const DEFAULT_LISTING_MS = 20 * 60 * 1000;
const SELL_RETRY_DELAY_MS = 30 * 60 * 1000;
-const MARKET_TOWN_ROUTING_VERSION = 4;
+const MARKET_TOWN_ROUTING_VERSION = 5;
// Captured in-game from the Giran trading square. The inner rectangle is the
// central column: it is walkable around, but a private store cannot sit there.
const GIRAN_MARKET_PLAZA = Object.freeze({
diff --git a/src/GameServer/Bot/Economy/MarketTownPolicy.js b/src/GameServer/Bot/Economy/MarketTownPolicy.js
index ab9fe3e8..cfd365cd 100644
--- a/src/GameServer/Bot/Economy/MarketTownPolicy.js
+++ b/src/GameServer/Bot/Economy/MarketTownPolicy.js
@@ -6,9 +6,6 @@ let rankIndexSource = null;
let rankIndexSize = -1;
let rankBySelfId = new Map();
-// Add a town here only after its sellable no-grade plaza has been captured.
-// This prevents cheap local loot from silently falling back to the Giran hub.
-const NO_GRADE_MARKET_TOWNS = new Set(['Talking Island', 'Elven Village', 'Dark Elven Village', 'Orc Village', 'Dwarven Village']);
const NO_GRADE_MARKETS = Object.freeze([
{ name: 'Talking Island', locX: -84700, locY: 244200, radius: 12000 },
{ name: 'Elven Village', locX: 46600, locY: 49700, radius: 12000 },
@@ -28,12 +25,12 @@ function marketTown(name) {
};
}
-function nearbyNoGradeMarket(loc = {}) {
+function nearestNoGradeMarket(loc = {}) {
const x = Number(loc.locX || 0);
const y = Number(loc.locY || 0);
+ if (!Number.isFinite(x) || !Number.isFinite(y) || (x === 0 && y === 0)) return null;
return NO_GRADE_MARKETS
.map((market) => ({ ...market, distance: Math.hypot(x - market.locX, y - market.locY) }))
- .filter((market) => market.distance <= market.radius)
.sort((a, b) => a.distance - b.distance)[0] || null;
}
@@ -64,9 +61,13 @@ function targetTownForItems(state, items = []) {
// A listed bot now stands at the market, so use its saved departure point
// to preserve local no-grade routing during legacy-store migrations.
const saleOrigin = state?.stats?.marketReturn?.loc || state?.loc;
- const localTown = nearbyNoGradeMarket(saleOrigin)?.name || null;
+ const localTown = nearestNoGradeMarket(saleOrigin)?.name || null;
- if (onlyNoGrade) return NO_GRADE_MARKET_TOWNS.has(localTown) ? localTown : 'Giran';
+ // No-grade stock belongs to the starter village nearest the bot's actual
+ // farming location. Early hunting routes legitimately extend beyond a
+ // village's immediate square, so a small-radius check funnels Elven,
+ // Dark Elven, and Talking Island sellers into Giran incorrectly.
+ if (onlyNoGrade) return localTown || 'Giran';
if (!hasHigherGrade && hasDGrade) return dGradeMarketFor(state);
return 'Giran';
}
@@ -77,11 +78,10 @@ function targetTownForSale(state) {
module.exports = {
GLUDIO_D_GRADE_SHARE_PERCENT,
- NO_GRADE_MARKET_TOWNS,
NO_GRADE_MARKETS,
dGradeMarketFor,
marketTown,
- nearbyNoGradeMarket,
+ nearestNoGradeMarket,
targetTownForItems,
targetTownForSale
};
diff --git a/tests/test_bot_cold_market_listing.js b/tests/test_bot_cold_market_listing.js
index 283f86ad..d1c821aa 100644
--- a/tests/test_bot_cold_market_listing.js
+++ b/tests/test_bot_cold_market_listing.js
@@ -53,7 +53,10 @@ async function run() {
timing: {},
inventory: {
57: { selfId: 57, name: 'Adena', amount: 500 },
- 1: { selfId: 1, name: 'Short Sword', amount: 1, equipped: false, slot: 7, kind: 'Weapon.Sword' },
+ // Keep this as a C-grade listing: the fixture exercises the Giran
+ // plaza, while no-grade stock is deliberately routed to the
+ // nearest starter village.
+ 1: { selfId: 1, name: 'Short Sword', amount: 1, equipped: false, slot: 7, kind: 'Weapon.Sword', rank: 'c' },
2: { selfId: 2, name: 'Long Sword', amount: 1, equipped: true, slot: 7, kind: 'Weapon.Sword' }
},
stats: {
diff --git a/tests/test_bot_market_town_routing.js b/tests/test_bot_market_town_routing.js
index 0b984c99..12b74cca 100644
--- a/tests/test_bot_market_town_routing.js
+++ b/tests/test_bot_market_town_routing.js
@@ -122,8 +122,8 @@ const noGradeOverflowState = {
};
assert.strictEqual(
ListingService.targetMarketTownName(noGradeOverflowState, [{ rank: 'none' }]),
- 'Giran',
- 'a level-appropriate no-grade-only listing must not be mistaken for D-grade overflow'
+ 'Elven Village',
+ 'a no-grade-only listing must use the nearest starter market even when its farming spot is outside the village radius'
);
assert.strictEqual(
ListingService.targetMarketTownName({
From b7a754672a6f4571da5f8b80b9bc1c4dd854d8ed Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 11:01:27 -0400
Subject: [PATCH 16/16] Place town merchants on accessible stalls
---
src/GameServer/Bot/MerchantStoreConfigs.js | 22 +++++++++++-----------
tests/test_npc_shop_stock.js | 13 +++++++++++++
2 files changed, 24 insertions(+), 11 deletions(-)
diff --git a/src/GameServer/Bot/MerchantStoreConfigs.js b/src/GameServer/Bot/MerchantStoreConfigs.js
index 14043c8c..e7de2f17 100644
--- a/src/GameServer/Bot/MerchantStoreConfigs.js
+++ b/src/GameServer/Bot/MerchantStoreConfigs.js
@@ -203,7 +203,7 @@ module.exports = {
title: "B/A materials",
town: "Oren",
storeType: 1,
- locX: 83110, locY: 53327, locZ: -1497,
+ locX: 82600, locY: 53400, locZ: -1488,
items: [
s(1885, 0.66, 2500), s(1886, 0.62, 600), s(1887, 0.62, 1200),
s(1888, 0.62, 1200), s(1889, 0.64, 2200), s(1890, 0.60, 700),
@@ -214,7 +214,7 @@ module.exports = {
title: "Oren gear",
town: "Oren",
storeType: 1,
- locX: 83020, locY: 53245, locZ: -1497,
+ locX: 82700, locY: 53400, locZ: -1488,
items: [
s(79, 0.60, 3), s(97, 0.60, 3), s(98, 0.60, 2),
s(442, 0.61, 3), s(473, 0.61, 3), s(603, 0.62, 5),
@@ -225,7 +225,7 @@ module.exports = {
title: "Buy Oren mats",
town: "Oren",
storeType: 3,
- locX: 83150, locY: 53300, locZ: -1497,
+ locX: 82800, locY: 53400, locZ: -1488,
items: [
b(1885, 0.62), b(1886, 0.58), b(1887, 0.60), b(1888, 0.60),
b(1889, 0.62), b(1890, 0.58), b(1893, 0.56), b(1894, 0.60),
@@ -236,7 +236,7 @@ module.exports = {
title: "Buy Oren drops",
town: "Oren",
storeType: 3,
- locX: 83245, locY: 53270, locZ: -1497,
+ locX: 82900, locY: 53400, locZ: -1488,
items: [
b(1830, 0.60), b(1343, 0.55), b(1539, 0.62), b(91, 0.56),
b(212, 0.56), b(284, 0.56), b(79, 0.56), b(97, 0.56),
@@ -257,28 +257,28 @@ module.exports = {
title: "Shots: No Grade",
town: "Elven Village",
storeType: 1,
- locX: 42700, locY: 50130, locZ: -2984,
+ locX: 47166, locY: 51511, locZ: -2992,
items: shotsForGrade(0)
},
"Dena": {
title: "Shots: No Grade",
town: "Dark Elven Village",
storeType: 1,
- locX: 12060, locY: 15740, locZ: -4554,
+ locX: 9550, locY: 15717, locZ: -4568,
items: shotsForGrade(0)
},
"Orik": {
title: "Shots: No Grade",
town: "Orc Village",
storeType: 1,
- locX: -44080, locY: -115380, locZ: -194,
+ locX: -45264, locY: -112292, locZ: -240,
items: shotsForGrade(0)
},
"Bran": {
title: "Shots: No Grade",
town: "Dwarven Village",
storeType: 1,
- locX: 116360, locY: -177600, locZ: -914,
+ locX: 115072, locY: -177956, locZ: -880,
items: shotsForGrade(0)
},
"Rolf": {
@@ -313,14 +313,14 @@ module.exports = {
title: "Shots: B Grade",
town: "Oren",
storeType: 1,
- locX: 83200, locY: 53380, locZ: -1497,
+ locX: 83000, locY: 53400, locZ: -1488,
items: shotsForGrade(3)
},
"Nora": {
title: "Shots: B Grade",
town: "Hunter's Village",
storeType: 1,
- locX: 116760, locY: 74880, locZ: -2581,
+ locX: 117129, locY: 77137, locZ: -2688,
items: shotsForGrade(3)
},
"Lina": {
@@ -334,7 +334,7 @@ module.exports = {
title: "Shots: A Grade",
town: "Aden",
storeType: 1,
- locX: 148980, locY: 28060, locZ: -2253,
+ locX: 146497, locY: 25807, locZ: -2008,
items: shotsForGrade(4)
},
"Sven": {
diff --git a/tests/test_npc_shop_stock.js b/tests/test_npc_shop_stock.js
index 65ac4069..39ae98f4 100644
--- a/tests/test_npc_shop_stock.js
+++ b/tests/test_npc_shop_stock.js
@@ -6,6 +6,7 @@ const DataCache = invoke('GameServer/DataCache');
const BuyShop = invoke('GameServer/World/Generics/NpcBypasses/BuyShop');
const NpcShopBuyLists = invoke('GameServer/World/Generics/NpcShopBuyLists');
const MerchantStoreConfigs = invoke('GameServer/Bot/MerchantStoreConfigs');
+const GeodataEngine = invoke('GameServer/Geodata/GeodataEngine');
DataCache.items = require('../data/Items/Others/others.json');
@@ -80,3 +81,15 @@ for (const [name, town, grade] of shotStores) {
assert(Math.hypot(MerchantStoreConfigs.Rolf.locX + 80826, MerchantStoreConfigs.Rolf.locY - 149775) < 1000,
'Gludin shot merchant must be placed inside the town square');
+
+// These stalls were captured beside each town's gatekeeper and checked against
+// the loaded geodata. Keeping the Z value on the actual floor prevents private
+// stores from being hidden in a building or on another vertical layer.
+const accessibleStalls = [
+ 'Elya', 'Dena', 'Orik', 'Bran', 'Iris', 'Helga', 'Oskar', 'Selin', 'Sera', 'Nora', 'Mila'
+];
+for (const name of accessibleStalls) {
+ const store = MerchantStoreConfigs[name];
+ const ground = GeodataEngine.getHeight(store.locX, store.locY, store.locZ);
+ assert.strictEqual(store.locZ, ground, `${name} must stand on the visible geodata floor`);
+}