From 3d61555f8488919f3a174f66130dd296a3f12148 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 13:53:45 -0400
Subject: [PATCH 01/23] Audit available C4 quest routes
---
data/Items/Others/others.json | 8 ++++
src/GameServer/Quest/QuestService.js | 27 ++++---------
tests/test_quest_availability.js | 46 ++++++++++++++++++++++
tests/test_quest_packets.js | 3 +-
tests/test_quest_runtime.js | 58 ++++++++++++++++++++++++++++
5 files changed, 121 insertions(+), 21 deletions(-)
create mode 100644 tests/test_quest_availability.js
diff --git a/data/Items/Others/others.json b/data/Items/Others/others.json
index 03d493e5..f3aa3dfa 100644
--- a/data/Items/Others/others.json
+++ b/data/Items/Others/others.json
@@ -14058,6 +14058,14 @@
"selfId": 5554,
"template": { "kind": "Other.Material", "name": "Warsmith's Holder", "class1": 4, "class2": 5, "mass": 2, "price": 884000 },
"etc": { "stackable": true, "consumable": false }
+},{
+ "selfId": 5789,
+ "template": { "kind": "Other.Shot", "name": "Soulshot: No Grade for Beginners", "class1": 4, "class2": 5, "mass": 1, "price": 0 },
+ "etc": { "stackable": true, "consumable": false }
+},{
+ "selfId": 5790,
+ "template": { "kind": "Other.Shot", "name": "Spiritshot: No Grade for Beginners", "class1": 4, "class2": 5, "mass": 1, "price": 0 },
+ "etc": { "stackable": true, "consumable": false }
},{
"selfId": 5262,
"template": { "kind": "Other.Shot", "name": "Greater Compressed Package of Blessed Spiritshots: No-grade", "class1": 4, "class2": 0, "mass": 750, "price": 38500 },
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index f7459c91..6d2b814c 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -17,27 +17,10 @@ const quests = [
require("./quests/Q008_AnAdventureBegins"),
require("./quests/Q009_IntoTheCityOfHumans"),
require("./quests/Q010_IntoTheWorld"),
- require("./quests/Q011_SecretMeetingWithKetraOrcs"),
- require("./quests/Q012_SecretMeetingWithVarkaSilenos"),
- require("./quests/Q013_ParcelDelivery"),
- require("./quests/Q014_WhereaboutsOfTheArchaeologist"),
- require("./quests/Q015_SweetWhispers"),
- require("./quests/Q016_TheComingDarkness"),
- require("./quests/Q017_LightAndDarkness"),
- require("./quests/Q018_MeetingWithTheGoldenRam"),
- require("./quests/Q019_GoToThePastureland"),
- require("./quests/Q031_SecretBuriedInTheSwamp"),
- require("./quests/Q032_AnObviousLie"),
- require("./quests/Q033_MakeAPairOfDressShoes"),
require("./quests/Q034_InSearchOfCloth"),
- require("./quests/Q035_FindGlitteringJewelry"),
require("./quests/Q036_MakeASewingKit"),
- require("./quests/Q037_MakeFormalWear"),
- require("./quests/Q038_DragonFangs"),
- require("./quests/Q039_RedEyedInvaders"),
require("./quests/Q042_HelpTheUncle"),
require("./quests/Q043_HelpTheSister"),
- require("./quests/Q044_HelpTheSon"),
require("./quests/Q045_ToTalkingIsland"),
require("./quests/Q046_OnceMoreInTheArmsOfTheMotherTree"),
require("./quests/Q047_IntoTheDarkForest"),
@@ -260,8 +243,14 @@ async function giveItem(session, selfId, amount) {
async function takeItem(session, selfId, amount = 1) {
const item = session.actor.backpack.fetchItemFromSelfId(selfId);
- if (!item || item.fetchAmount() < amount) return false;
- const remaining = item.fetchAmount() - amount;
+ if (!item) return false;
+ // L2J's QuestState.takeItems(id, -1) consumes the entire stack. Several
+ // source-backed quest hand-ins use that sentinel; subtracting -1 would
+ // silently duplicate a quest item instead of clearing it.
+ const requested = Number(amount) === -1 ? item.fetchAmount() : Number(amount);
+ if (!Number.isFinite(requested) || requested <= 0 || item.fetchAmount() < requested)
+ return false;
+ const remaining = item.fetchAmount() - requested;
if (remaining > 0) {
await Database.updateItemAmount(
session.actor.fetchId(),
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
new file mode 100644
index 00000000..ac5c7896
--- /dev/null
+++ b/tests/test_quest_availability.js
@@ -0,0 +1,46 @@
+const assert = require("assert");
+const fs = require("fs");
+
+require("../src/Global");
+
+const QuestService = invoke("GameServer/Quest/QuestService");
+const npcTemplates = JSON.parse(fs.readFileSync("data/Npcs/npcs.json", "utf8"));
+const otherItems = JSON.parse(
+ fs.readFileSync("data/Items/Others/others.json", "utf8"),
+);
+const spawnGroups = JSON.parse(
+ fs.readFileSync("data/Npcs/Spawns/spawns.json", "utf8"),
+);
+
+const templateIds = new Set(npcTemplates.map((npc) => Number(npc.selfId)));
+const itemIds = new Set(otherItems.map((item) => Number(item.selfId)));
+const spawnedIds = new Set();
+function collectSpawnIds(value) {
+ if (Array.isArray(value)) return value.forEach(collectSpawnIds);
+ if (!value || typeof value !== "object") return;
+ if (Number.isInteger(value.selfId)) spawnedIds.add(value.selfId);
+ Object.values(value).forEach(collectSpawnIds);
+}
+collectSpawnIds(spawnGroups);
+
+for (const quest of QuestService.quests()) {
+ for (const npcId of [...(quest.npcs || []), ...(quest.killNpcs || [])]) {
+ assert(
+ templateIds.has(npcId),
+ `Q${quest.id} references NPC ${npcId}, but its template is absent`,
+ );
+ assert(
+ spawnedIds.has(npcId),
+ `Q${quest.id} references NPC ${npcId}, but it has no world spawn`,
+ );
+ }
+}
+
+for (const itemId of [5789, 5790]) {
+ assert(
+ itemIds.has(itemId),
+ `source-backed starter quest reward ${itemId} is missing from the item datapack`,
+ );
+}
+
+console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_packets.js b/tests/test_quest_packets.js
index 4b663c54..f59f31f7 100644
--- a/tests/test_quest_packets.js
+++ b/tests/test_quest_packets.js
@@ -8,8 +8,7 @@ const QuestService = invoke("GameServer/Quest/QuestService");
assert.deepStrictEqual(
QuestService.quests().map((quest) => quest.id),
[
- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 31, 32,
- 33, 34, 35, 36, 37, 38, 39, 42, 43, 44, 45, 46, 47, 48, 49, 101, 102, 103,
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 34, 36, 42, 43, 45, 46, 47, 48, 49, 101, 102, 103,
104, 105, 106, 107, 108, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160,
161, 162, 163, 164, 165, 166, 167, 168, 169, 170,
],
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index d2151526..da1e01d5 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -187,6 +187,34 @@ async function main() {
calls.push(["give", itemId, amount]);
QuestService.rewardAdena = async (_, amount) => calls.push(["adena", amount]);
try {
+ let q002Condition = 1;
+ const q002State = {
+ session: { actor: { fetchLevel: () => 2, fetchRace: () => 1 } },
+ isCompleted: () => false,
+ isStarted: () => true,
+ getInt: () => q002Condition,
+ set: async (key, value) => {
+ assert.strictEqual(key, "cond");
+ q002Condition = Number(value);
+ },
+ playSound: (sound) => calls.push(["sound", sound]),
+ };
+ await Q002.onTalk(q002State, { fetchSelfId: () => 7146 });
+ await Q002.onTalk(q002State, { fetchSelfId: () => 7150 });
+ assert.deepStrictEqual(
+ calls,
+ [
+ ["take", 1092],
+ ["give", 1093, 1],
+ ["sound", "ItemSound.quest_middle"],
+ ["take", 1093],
+ ["give", 1094, 1],
+ ["sound", "ItemSound.quest_middle"],
+ ],
+ "Q002 must replace the gatekeeper letter and issue Herbiel's church letter",
+ );
+ calls.length = 0;
+
let completed = false;
await Q001.onTalk(
{
@@ -215,6 +243,36 @@ async function main() {
QuestService.giveItem = originalGive;
QuestService.rewardAdena = originalRewardAdena;
}
+
+ const deleted = [];
+ const allStackItem = {
+ fetchId: () => 41,
+ fetchAmount: () => 7,
+ };
+ const allStackSession = {
+ actor: {
+ fetchId: () => 8,
+ backpack: {
+ items: [allStackItem],
+ fetchItemFromSelfId: () => allStackItem,
+ fetchItems: () => [],
+ },
+ },
+ dataSendToMe: () => {},
+ };
+ const originalDeleteItem = invoke("Database").deleteItem;
+ invoke("Database").deleteItem = async (...args) => deleted.push(args);
+ try {
+ assert.strictEqual(
+ await QuestService.takeItem(allStackSession, 1094, -1),
+ true,
+ "takeItem(-1) must consume an existing quest-item stack",
+ );
+ assert.deepStrictEqual(deleted, [[8, 41]]);
+ assert.deepStrictEqual(allStackSession.actor.backpack.items, []);
+ } finally {
+ invoke("Database").deleteItem = originalDeleteItem;
+ }
}
main()
From ce88134c42c176469fa283467f3ce0a35788a3ba Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 14:21:56 -0400
Subject: [PATCH 02/23] Add quest spawn and radar foundations
---
data/Npcs/Spawns/spawns.json | 4 +
.../Network/Response/RadarControl.js | 18 +++++
src/GameServer/Network/Response/index.js | 1 +
src/GameServer/Quest/QuestService.js | 61 ++++++++++++++
src/GameServer/Quest/QuestState.js | 9 +++
src/GameServer/World/Generics/RemoveNpc.js | 1 +
src/GameServer/World/Generics/SpawnNpcs.js | 61 ++++++++++++++
src/GameServer/World/World.js | 6 ++
tests/test_quest_spawn_radar.js | 80 +++++++++++++++++++
9 files changed, 241 insertions(+)
create mode 100644 src/GameServer/Network/Response/RadarControl.js
create mode 100644 tests/test_quest_spawn_radar.js
diff --git a/data/Npcs/Spawns/spawns.json b/data/Npcs/Spawns/spawns.json
index faa42250..aa7517af 100644
--- a/data/Npcs/Spawns/spawns.json
+++ b/data/Npcs/Spawns/spawns.json
@@ -15730,6 +15730,10 @@
"selfId": "party_20_21_dg_02f_015",
"bounds": [{ "locX": 21332, "locY": 109751, "minZ": -9076, "maxZ": -8876 }, { "locX": 21752, "locY": 109751, "minZ": -9076, "maxZ": -8876 }, { "locX": 21752, "locY": 111591, "minZ": -9076, "maxZ": -8876 }, { "locX": 21332, "locY": 111591, "minZ": -9076, "maxZ": -8876 }],
"spawns": [{ "selfId": 753, "name": "Dark Lord", "coords": [{ "locX": 21567, "locY": 110335, "locZ": -9047, "head": 0 }, { "locX": 21560, "locY": 111024, "locZ": -9047, "head": 0 }], "total": 1, "respawn": 900, "bias": 240 }]
+},{
+ "selfId": "gludio01_qm1822_01",
+ "bounds": [{ "locX": -49201, "locY": 143118, "minZ": -3032, "maxZ": -2792 }, { "locX": -45905, "locY": 143118, "minZ": -3032, "maxZ": -2792 }, { "locX": -45905, "locY": 147781, "minZ": -3032, "maxZ": -2792 }, { "locX": -49201, "locY": 147781, "minZ": -3032, "maxZ": -2792 }],
+ "spawns": [{ "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -46027, "locY": 145033, "locZ": -3032, "head": 11372 }], "total": 1, "respawn": 180, "bias": 0 }, { "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -45905, "locY": 146758, "locZ": -2968, "head": 17318 }], "total": 1, "respawn": 180, "bias": 0 }, { "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -48062, "locY": 143345, "locZ": -2944, "head": 51023 }], "total": 1, "respawn": 180, "bias": 0 }, { "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -46190, "locY": 143296, "locZ": -2912, "head": 0 }], "total": 1, "respawn": 180, "bias": 0 }, { "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -46975, "locY": 144858, "locZ": -2936, "head": 29016 }], "total": 1, "respawn": 180, "bias": 0 }, { "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -46602, "locY": 144609, "locZ": -2960, "head": 17668 }], "total": 1, "respawn": 180, "bias": 0 }, { "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -46895, "locY": 143118, "locZ": -2864, "head": 0 }], "total": 1, "respawn": 180, "bias": 0 }, { "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -48801, "locY": 145604, "locZ": -2824, "head": 25118 }], "total": 1, "respawn": 180, "bias": 0 }, { "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -47803, "locY": 145100, "locZ": -2848, "head": 21385 }], "total": 1, "respawn": 180, "bias": 0 }, { "selfId": 5038, "name": "Cat's Eye Bandit", "coords": [{ "locX": -49201, "locY": 147781, "locZ": -2792, "head": 14037 }], "total": 1, "respawn": 180, "bias": 0 }]
},{
"selfId": "party_20_21_dg_02f_017",
"bounds": [{ "locX": 19883, "locY": 108294, "minZ": -9080, "maxZ": -8880 }, { "locX": 20739, "locY": 108294, "minZ": -9080, "maxZ": -8880 }, { "locX": 20739, "locY": 110069, "minZ": -9080, "maxZ": -8880 }, { "locX": 19883, "locY": 110069, "minZ": -9080, "maxZ": -8880 }],
diff --git a/src/GameServer/Network/Response/RadarControl.js b/src/GameServer/Network/Response/RadarControl.js
new file mode 100644
index 00000000..755f71e5
--- /dev/null
+++ b/src/GameServer/Network/Response/RadarControl.js
@@ -0,0 +1,18 @@
+const SendPacket = invoke('Packet/Send');
+
+// C4 RadarControl (0xEB): the client expects the radar to be armed before
+// receiving the visible waypoint marker.
+function radarControl(showRadar, type, locX, locY, locZ) {
+ const packet = new SendPacket(0xeb);
+
+ packet
+ .writeD(showRadar)
+ .writeD(type)
+ .writeD(locX)
+ .writeD(locY)
+ .writeD(locZ);
+
+ return packet.fetchBuffer();
+}
+
+module.exports = radarControl;
diff --git a/src/GameServer/Network/Response/index.js b/src/GameServer/Network/Response/index.js
index 485ed16e..bd31f4f8 100644
--- a/src/GameServer/Network/Response/index.js
+++ b/src/GameServer/Network/Response/index.js
@@ -31,6 +31,7 @@ module.exports = {
moveToPawn: require('./MoveToPawn'),
npcHtml: require('./NpcHtml'),
npcInfo: require('./NpcInfo'),
+ radarControl: require('./RadarControl'),
petStatusShow: require('./PetStatusShow'),
petInfo: require('./PetInfo'),
petStatusUpdate: require('./PetStatusUpdate'),
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 6d2b814c..d2ca469c 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -5,6 +5,7 @@ const QuestState = invoke("GameServer/Quest/QuestState");
const ProgressionRates = invoke("GameServer/ProgressionRates");
const ExperienceReward = invoke("GameServer/Actor/Generics/ExperienceReward");
const ConsoleText = invoke("GameServer/ConsoleText");
+const World = invoke("GameServer/World/World");
const quests = [
require("./quests/Q001_LettersOfLove"),
@@ -289,6 +290,58 @@ function questDropAmount(amount, needed, current) {
return Math.min(scaled, needed - current);
}
+function waypointKey(locX, locY, locZ) {
+ return `${Number(locX)}:${Number(locY)}:${Number(locZ)}`;
+}
+
+// Source QuestState.addRadar sends an arming packet followed by a visible
+// waypoint marker. Keep the marker session-local; it is client UI state, not
+// durable quest progress.
+function addRadar(session, locX, locY, locZ) {
+ const coords = [locX, locY, locZ].map(Number);
+ if (!session?.dataSendToMe || !coords.every(Number.isFinite)) return false;
+ const key = waypointKey(...coords);
+ session.questWaypoints ||= new Map();
+ session.questWaypoints.set(key, coords);
+ session.dataSendToMe(ServerResponse.radarControl(2, 2, ...coords));
+ session.dataSendToMe(ServerResponse.radarControl(0, 1, ...coords));
+ return true;
+}
+
+function removeRadar(session, locX, locY, locZ) {
+ const coords = [locX, locY, locZ].map(Number);
+ if (!session?.dataSendToMe || !coords.every(Number.isFinite)) return false;
+ session.questWaypoints?.delete(waypointKey(...coords));
+ session.dataSendToMe(ServerResponse.radarControl(1, 1, ...coords));
+ return true;
+}
+
+function clearRadars(session) {
+ const waypoints = [...(session?.questWaypoints?.values() || [])];
+ waypoints.forEach((coords) => removeRadar(session, ...coords));
+ return waypoints.length;
+}
+
+// Mirrors the source QuestState.addSpawn default: spawn at the player's
+// location, with no automatic despawn unless a quest explicitly requests it.
+function spawnQuestNpc(state, selfId, options = {}) {
+ const actor = state?.session?.actor;
+ if (!actor?.fetchId) return null;
+ const locX = options.locX ?? actor.fetchLocX?.();
+ const locY = options.locY ?? actor.fetchLocY?.();
+ const locZ = options.locZ ?? actor.fetchLocZ?.();
+ return World.spawnQuestNpc({
+ selfId,
+ locX,
+ locY,
+ locZ,
+ head: options.head ?? actor.fetchHead?.() ?? 0,
+ ownerId: actor.fetchId(),
+ questId: state.quest?.id,
+ despawnDelay: options.despawnDelay ?? 0,
+ });
+}
+
function rewardExpSp(session, exp, sp) {
const rates = questRates();
// ExperienceReward owns UI, persistence, and level-up. Counter its normal
@@ -304,9 +357,13 @@ function rewardExpSp(session, exp, sp) {
async function onKill(session, npc) {
return mutate(session, async () => {
await ensureLoaded(session);
+ const ownerId = Number(npc.questSpawn?.ownerId) || 0;
+ if (ownerId && ownerId !== Number(session.actor.fetchId())) return;
+ const spawnedQuestId = Number(npc.questSpawn?.questId) || 0;
const before = activeQuestSnapshot(session);
const npcId = Number(npc.fetchSelfId());
for (const quest of quests) {
+ if (spawnedQuestId && spawnedQuestId !== quest.id) continue;
if (!quest.killNpcs?.includes(npcId)) continue;
const state = states(session).get(quest.id);
if (state?.isStarted()) await quest.onKill(state, npc);
@@ -342,6 +399,10 @@ module.exports = {
rewardAdena,
rewardExpSp,
questDropAmount,
+ addRadar,
+ removeRadar,
+ clearRadars,
+ spawnQuestNpc,
questRates,
quests: () => quests,
};
diff --git a/src/GameServer/Quest/QuestState.js b/src/GameServer/Quest/QuestState.js
index ab07d0db..b44889be 100644
--- a/src/GameServer/Quest/QuestState.js
+++ b/src/GameServer/Quest/QuestState.js
@@ -43,6 +43,15 @@ class QuestState {
playSound(sound) {
this.session.dataSendToMe(ServerResponse.playSound(sound));
}
+ addRadar(locX, locY, locZ) {
+ return invoke("GameServer/Quest/QuestService").addRadar(this.session, locX, locY, locZ);
+ }
+ removeRadar(locX, locY, locZ) {
+ return invoke("GameServer/Quest/QuestService").removeRadar(this.session, locX, locY, locZ);
+ }
+ addSpawn(selfId, options = {}) {
+ return invoke("GameServer/Quest/QuestService").spawnQuestNpc(this, selfId, options);
+ }
save() {
return Database.setCharacterQuest(
diff --git a/src/GameServer/World/Generics/RemoveNpc.js b/src/GameServer/World/Generics/RemoveNpc.js
index 897e6941..23636330 100644
--- a/src/GameServer/World/Generics/RemoveNpc.js
+++ b/src/GameServer/World/Generics/RemoveNpc.js
@@ -4,6 +4,7 @@ const NpcVisibility = invoke('GameServer/World/NpcVisibility');
function removeNpc(session, npc) {
const npcId = npc.fetchId();
+ SpawnNpcs.clearQuestSpawn(npc);
this.npcRewards(session, npc);
// Datapack respawn is measured from the death event, independently of
diff --git a/src/GameServer/World/Generics/SpawnNpcs.js b/src/GameServer/World/Generics/SpawnNpcs.js
index 90d6bede..71f0e74f 100644
--- a/src/GameServer/World/Generics/SpawnNpcs.js
+++ b/src/GameServer/World/Generics/SpawnNpcs.js
@@ -1,6 +1,7 @@
const Npc = invoke('GameServer/Npc/Npc');
const DataCache = invoke('GameServer/DataCache');
const ServerResponse = invoke('GameServer/Network/Response');
+const NpcVisibility = invoke('GameServer/World/NpcVisibility');
const VISIBILITY_RADIUS = 6000;
@@ -61,6 +62,63 @@ function spawnNpc(world, definition) {
return npc;
}
+function templateFor(selfId) {
+ const id = Number(selfId);
+ const template = DataCache.npcs?.find((npc) => Number(npc.selfId) === id);
+ return template ? structuredClone(template) : null;
+}
+
+// Dynamic quest NPCs intentionally have no spawnDefinition: they never enter
+// the ordinary respawn loop. Ownership is checked by QuestService on kill so
+// one player's personal objective cannot advance another player's quest.
+function spawnQuestNpc(world, {
+ selfId,
+ locX,
+ locY,
+ locZ,
+ head = 0,
+ ownerId = 0,
+ questId = 0,
+ despawnDelay = 0
+} = {}) {
+ if (!world?.npc?.spawns || !Number.isFinite(Number(world.npc.nextId))) return null;
+ const template = templateFor(selfId);
+ const coords = { locX: Number(locX), locY: Number(locY), locZ: Number(locZ), head: Number(head) || 0 };
+ if (!template || !Object.values(coords).slice(0, 3).every(Number.isFinite)) return null;
+
+ const npc = createNpc(world, template, coords);
+ npc.questSpawn = {
+ ownerId: Number(ownerId) || 0,
+ questId: Number(questId) || 0,
+ timer: undefined
+ };
+ if (Number(despawnDelay) > 0) {
+ npc.questSpawn.timer = setTimeout(() => despawnQuestNpc(world, npc), Number(despawnDelay));
+ }
+ world.indexSpawnsInGrid?.();
+ notifyNearby(world, npc);
+ return npc;
+}
+
+function clearQuestSpawn(npc) {
+ if (!npc?.questSpawn) return;
+ clearTimeout(npc.questSpawn.timer);
+ npc.questSpawn.timer = undefined;
+}
+
+function despawnQuestNpc(world, npc, sourceSession = null) {
+ if (!world?.npc?.spawns || !npc) return false;
+ const objectId = npc.fetchId?.();
+ if (!world.npc.spawns.some((entry) => entry.fetchId?.() === objectId)) return false;
+
+ clearQuestSpawn(npc);
+ npc.destructor?.(sourceSession || { dataSendToMeAndOthers: () => {}, dataSendToMe: () => {} });
+ NpcVisibility.deleteKnownNpc(world, sourceSession, objectId);
+ world.npc.spawns = world.npc.spawns.filter((entry) => entry.fetchId?.() !== objectId);
+ world.indexSpawnsInGrid?.();
+ return true;
+}
+
function spawnNpcs() {
DataCache.npcSpawns.forEach((item) => {
const bounds = item.bounds;
@@ -95,6 +153,9 @@ function spawnNpcs() {
module.exports = spawnNpcs;
module.exports.spawnNpc = spawnNpc;
+module.exports.spawnQuestNpc = spawnQuestNpc;
+module.exports.despawnQuestNpc = despawnQuestNpc;
+module.exports.clearQuestSpawn = clearQuestSpawn;
module.exports.notifyNearby = notifyNearby;
module.exports.shouldRespawn = function shouldRespawn(spawn) {
return Number(spawn?.respawn) > 0;
diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js
index 3eab688e..c56c43de 100644
--- a/src/GameServer/World/World.js
+++ b/src/GameServer/World/World.js
@@ -373,6 +373,12 @@ const World = {
fetchNpc : invoke(path.world + 'FetchNpc'),
spawnNpcs : invoke(path.world + 'SpawnNpcs'),
spawnNpc : invoke(path.world + 'SpawnNpcs').spawnNpc,
+ spawnQuestNpc(options) {
+ return invoke(path.world + 'SpawnNpcs').spawnQuestNpc(this, options);
+ },
+ despawnQuestNpc(npc, sourceSession = null) {
+ return invoke(path.world + 'SpawnNpcs').despawnQuestNpc(this, npc, sourceSession);
+ },
removeNpc : invoke(path.world + 'RemoveNpc'),
npcRewards : invoke(path.world + 'NpcRewards'),
npcTalk : invoke(path.world + 'NpcTalk'),
diff --git a/tests/test_quest_spawn_radar.js b/tests/test_quest_spawn_radar.js
new file mode 100644
index 00000000..3d43e1f4
--- /dev/null
+++ b/tests/test_quest_spawn_radar.js
@@ -0,0 +1,80 @@
+const assert = require('assert');
+
+require('../src/Global');
+
+const DataCache = invoke('GameServer/DataCache');
+const QuestService = invoke('GameServer/Quest/QuestService');
+const NpcVisibility = invoke('GameServer/World/NpcVisibility');
+const SpawnNpcs = invoke('GameServer/World/Generics/SpawnNpcs');
+
+DataCache.init();
+
+function viewer(locX, locY) {
+ return {
+ actor: {
+ fetchIsOnline: () => true,
+ fetchLocX: () => locX,
+ fetchLocY: () => locY
+ },
+ sent: [],
+ dataSendToMe(packet) {
+ this.sent.push(packet);
+ NpcVisibility.trackNpcPacket(this, packet);
+ }
+ };
+}
+
+const nearby = viewer(1000, 2000);
+const distant = viewer(10000, 2000);
+const world = {
+ npc: { nextId: 4000000, spawns: [], grid: {} },
+ user: { sessions: [nearby, distant] },
+ indexSpawnsInGrid() {
+ this.npc.grid = {};
+ this.npc.spawns.forEach((npc) => {
+ const key = `${Math.floor(npc.fetchLocX() / 6000)}_${Math.floor(npc.fetchLocY() / 6000)}`;
+ (this.npc.grid[key] ||= []).push(npc);
+ });
+ }
+};
+
+const questNpc = SpawnNpcs.spawnQuestNpc(world, {
+ selfId: 5032,
+ locX: 1000,
+ locY: 2000,
+ locZ: -3000,
+ ownerId: 77,
+ questId: 409
+});
+assert.ok(questNpc, 'a valid quest NPC template must spawn');
+assert.strictEqual(questNpc.spawnDefinition, null, 'quest NPCs must not enter static respawn');
+assert.deepStrictEqual(
+ { ownerId: questNpc.questSpawn.ownerId, questId: questNpc.questSpawn.questId },
+ { ownerId: 77, questId: 409 },
+ 'quest spawn ownership must stay attached to the NPC'
+);
+assert.strictEqual(world.npc.spawns.length, 1);
+assert.strictEqual(nearby.sent[0][0], 0x16, 'nearby clients must receive NpcInfo immediately');
+assert.strictEqual(distant.sent.length, 0, 'distant clients must not receive unrelated quest NPCs');
+assert.ok(Object.values(world.npc.grid).flat().includes(questNpc), 'quest NPC must enter the spatial grid');
+assert.strictEqual(SpawnNpcs.despawnQuestNpc(world, questNpc), true);
+assert.strictEqual(world.npc.spawns.length, 0, 'despawn must remove the temporary NPC');
+assert.strictEqual(nearby.sent.at(-1)[0], 0x12, 'despawn must clean the object from known clients');
+
+const radarSession = { sent: [], dataSendToMe(packet) { this.sent.push(packet); } };
+assert.strictEqual(QuestService.addRadar(radarSession, -16760, 78268, -3480), true);
+assert.strictEqual(radarSession.sent.length, 2, 'a waypoint requires the C4 arm and marker packets');
+assert.strictEqual(radarSession.sent[0][0], 0xeb);
+assert.strictEqual(radarSession.sent[0].readInt32LE(1), 2);
+assert.strictEqual(radarSession.sent[0].readInt32LE(5), 2);
+assert.strictEqual(radarSession.sent[1].readInt32LE(1), 0);
+assert.strictEqual(radarSession.sent[1].readInt32LE(5), 1);
+assert.strictEqual(QuestService.removeRadar(radarSession, -16760, 78268, -3480), true);
+assert.strictEqual(radarSession.sent.at(-1).readInt32LE(1), 1, 'removal must send RadarControl delete');
+
+const bandits = DataCache.npcSpawns.find((entry) => entry.selfId === 'gludio01_qm1822_01');
+assert.strictEqual(bandits.spawns.length, 10, 'Cat’s Eye Bandit must have all ten source spawns');
+assert.deepStrictEqual(bandits.spawns[0].coords[0], { locX: -46027, locY: 145033, locZ: -3032, head: 11372 });
+assert.deepStrictEqual(bandits.spawns.at(-1).coords[0], { locX: -49201, locY: 147781, locZ: -2792, head: 14037 });
+
+console.log('quest spawn and radar checks passed');
From b07e48d977c04af4f2ccbda60a34a6a55b1ea380 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 14:31:29 -0400
Subject: [PATCH 03/23] Allow profession quests to award classes
---
src/GameServer/ClassTransfer.js | 79 ++++++++++++
src/GameServer/Quest/QuestService.js | 11 ++
.../World/Generics/NpcBypasses/ChangeClass.js | 113 +++++-------------
tests/test_change_class.js | 33 +++++
4 files changed, 153 insertions(+), 83 deletions(-)
create mode 100644 src/GameServer/ClassTransfer.js
diff --git a/src/GameServer/ClassTransfer.js b/src/GameServer/ClassTransfer.js
new file mode 100644
index 00000000..dd2479ed
--- /dev/null
+++ b/src/GameServer/ClassTransfer.js
@@ -0,0 +1,79 @@
+const Database = invoke('Database');
+const CalculateStats = invoke('GameServer/Actor/Generics/CalculateStats');
+const ServerResponse = invoke('GameServer/Network/Response');
+const ClassProgression = invoke('GameServer/ClassProgression');
+
+function statusParams(actor) {
+ const d = (value) => Math.round(Number(value) || 0);
+
+ return [
+ { id: 0x01, value: d(actor.fetchLevel()) },
+ { id: 0x09, value: d(actor.fetchHp()) },
+ { id: 0x0a, value: d(actor.fetchMaxHp()) },
+ { id: 0x0b, value: d(actor.fetchMp()) },
+ { id: 0x0c, value: d(actor.fetchMaxMp()) },
+ { id: 0x11, value: d(actor.fetchCollectivePAtk()) },
+ { id: 0x12, value: d(actor.fetchCollectiveAtkSpd()) },
+ { id: 0x13, value: d(actor.fetchCollectivePDef()) },
+ { id: 0x14, value: d(actor.fetchCollectiveEvasion()) },
+ { id: 0x15, value: d(actor.fetchCollectiveAccur()) },
+ { id: 0x16, value: d(actor.fetchCollectiveCritical()) },
+ { id: 0x17, value: d(actor.fetchCollectiveMAtk()) },
+ { id: 0x18, value: d(actor.fetchCollectiveCastSpd()) },
+ { id: 0x19, value: d(actor.fetchCollectiveMDef()) }
+ ];
+}
+
+function eligibility(actor, targetClassId, { firstProfessionOnly = false } = {}) {
+ if (!actor || actor.isDead?.()) return { ok: false, reason: 'unavailable' };
+ const currentClassId = Number(actor.fetchClassId());
+ const target = Number(targetClassId);
+ const { firstProfMap, secondProfMap } = ClassProgression;
+
+ if (firstProfMap[currentClassId]?.includes(target)) {
+ return { ok: true, requiredLevel: 20, currentClassId, targetClassId: target };
+ }
+ if (firstProfessionOnly) return { ok: false, reason: 'wrong_profession' };
+ if (secondProfMap[currentClassId]?.includes(target)) {
+ return { ok: true, requiredLevel: 40, currentClassId, targetClassId: target };
+ }
+ if (ClassProgression.getThirdClass(target)?.parentClassId === currentClassId) {
+ return { ok: true, requiredLevel: 76, currentClassId, targetClassId: target };
+ }
+ return { ok: false, reason: 'wrong_profession' };
+}
+
+// The transferable unit is shared by the legacy Sylvain bypass and quest
+// endings. It persists first, then refreshes skills, stats and every client
+// view; callers therefore never leave a completed quest with a stale class.
+async function transfer(session, targetClassId, options = {}) {
+ const actor = session?.actor;
+ const check = eligibility(actor, targetClassId, options);
+ if (!check.ok) return check;
+ const currentLevel = Number(actor.fetchLevel());
+ if (currentLevel < check.requiredLevel) {
+ return { ok: false, reason: 'level', requiredLevel: check.requiredLevel };
+ }
+
+ actor.setClassId(check.targetClassId);
+ try {
+ await Database.updateCharacterClassId(actor.fetchId(), check.targetClassId);
+ await actor.skillset.awardSkills(actor.fetchId(), check.targetClassId, currentLevel);
+ CalculateStats(session, actor);
+ actor.fillupVitals();
+
+ session.dataSendToMeAndOthers?.(ServerResponse.socialAction(actor.fetchId(), 15), actor);
+ session.dataSendToMe?.(ServerResponse.skillsList(actor.skillset.fetchSkills()));
+ session.dataSendToMe?.(ServerResponse.userInfo(actor));
+ session.dataSendToMe?.(ServerResponse.statusUpdate(actor.fetchId(), statusParams(actor)));
+ session.dataSendToOthers?.(ServerResponse.charInfo(actor), actor);
+ return { ok: true, targetClassId: check.targetClassId, requiredLevel: check.requiredLevel };
+ } catch (error) {
+ actor.setClassId(check.currentClassId);
+ await Database.updateCharacterClassId(actor.fetchId(), check.currentClassId).catch(() => {});
+ utils.infoWarn('Character', 'class change failed for %s: %s', actor.fetchName(), error.message);
+ return { ok: false, reason: 'persistence', error };
+ }
+}
+
+module.exports = { eligibility, transfer, statusParams };
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index d2ca469c..c0208e19 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -6,6 +6,7 @@ const ProgressionRates = invoke("GameServer/ProgressionRates");
const ExperienceReward = invoke("GameServer/Actor/Generics/ExperienceReward");
const ConsoleText = invoke("GameServer/ConsoleText");
const World = invoke("GameServer/World/World");
+const ClassTransfer = invoke("GameServer/ClassTransfer");
const quests = [
require("./quests/Q001_LettersOfLove"),
@@ -342,6 +343,15 @@ function spawnQuestNpc(state, selfId, options = {}) {
});
}
+// Profession quests call this only from their verified final hand-in. The
+// shared transfer commits classId and target skills before the quest can mark
+// itself completed, so a database failure cannot consume the final objective.
+function awardFirstProfession(state, targetClassId) {
+ return ClassTransfer.transfer(state?.session, targetClassId, {
+ firstProfessionOnly: true,
+ });
+}
+
function rewardExpSp(session, exp, sp) {
const rates = questRates();
// ExperienceReward owns UI, persistence, and level-up. Counter its normal
@@ -403,6 +413,7 @@ module.exports = {
removeRadar,
clearRadars,
spawnQuestNpc,
+ awardFirstProfession,
questRates,
quests: () => quests,
};
diff --git a/src/GameServer/World/Generics/NpcBypasses/ChangeClass.js b/src/GameServer/World/Generics/NpcBypasses/ChangeClass.js
index 3ca5f686..6b6710e3 100644
--- a/src/GameServer/World/Generics/NpcBypasses/ChangeClass.js
+++ b/src/GameServer/World/Generics/NpcBypasses/ChangeClass.js
@@ -1,97 +1,44 @@
-const Database = invoke('Database');
-const CalculateStats = invoke('GameServer/Actor/Generics/CalculateStats');
const ServerResponse = invoke('GameServer/Network/Response');
-const ClassProgression = invoke('GameServer/ClassProgression');
+const ClassTransfer = invoke('GameServer/ClassTransfer');
function html(session, body) {
session.dataSendToMe(ServerResponse.npcHtml(7070, body));
}
-function statusParams(actor) {
- const d = (value) => Math.round(Number(value) || 0);
-
- return [
- { id: 0x01, value: d(actor.fetchLevel()) },
- { id: 0x09, value: d(actor.fetchHp()) },
- { id: 0x0a, value: d(actor.fetchMaxHp()) },
- { id: 0x0b, value: d(actor.fetchMp()) },
- { id: 0x0c, value: d(actor.fetchMaxMp()) },
- { id: 0x11, value: d(actor.fetchCollectivePAtk()) },
- { id: 0x12, value: d(actor.fetchCollectiveAtkSpd()) },
- { id: 0x13, value: d(actor.fetchCollectivePDef()) },
- { id: 0x14, value: d(actor.fetchCollectiveEvasion()) },
- { id: 0x15, value: d(actor.fetchCollectiveAccur()) },
- { id: 0x16, value: d(actor.fetchCollectiveCritical()) },
- { id: 0x17, value: d(actor.fetchCollectiveMAtk()) },
- { id: 0x18, value: d(actor.fetchCollectiveCastSpd()) },
- { id: 0x19, value: d(actor.fetchCollectiveMDef()) }
- ];
-}
-
-module.exports = async function(session, parts) {
- const actor = session.actor;
- if (!actor || actor.isDead()) return;
-
+module.exports = async function changeClass(session, parts) {
const targetClassId = Number(parts[1]);
- if (isNaN(targetClassId)) return;
-
- const currentClassId = actor.fetchClassId();
- const currentLevel = actor.fetchLevel();
-
- const { firstProfMap, secondProfMap } = ClassProgression;
-
- const thirdClass = ClassProgression.getThirdClass(targetClassId);
-
- let isAllowed = false;
- let requiredLevel = 20;
-
- if (firstProfMap[currentClassId] && firstProfMap[currentClassId].includes(targetClassId)) {
- isAllowed = true;
- requiredLevel = 20;
- } else if (secondProfMap[currentClassId] && secondProfMap[currentClassId].includes(targetClassId)) {
- isAllowed = true;
- requiredLevel = 40;
- } else if (thirdClass?.parentClassId === currentClassId) {
- isAllowed = true;
- requiredLevel = 76;
+ if (!Number.isFinite(targetClassId)) return;
+
+ // Keep ordinary bypass rejections immediate. NpcTalkResponse deliberately
+ // does not await handlers, while the actual persisted transfer remains
+ // asynchronous below.
+ const preflight = ClassTransfer.eligibility(session?.actor, targetClassId);
+ if (!preflight.ok) {
+ if (preflight.reason === 'wrong_profession') {
+ html(session, '
Gatekeeper Sylvain:
This class transfer is not available for your current profession.');
+ }
+ return preflight;
}
-
- if (!isAllowed) {
- html(session, `Gatekeeper Sylvain:
This class transfer is not available for your current profession.`);
- return;
+ if (Number(session.actor.fetchLevel()) < preflight.requiredLevel) {
+ html(session, `Gatekeeper Sylvain:
You must be at least level ${preflight.requiredLevel} to perform this class transfer.`);
+ return { ok: false, reason: 'level', requiredLevel: preflight.requiredLevel };
}
- if (currentLevel < requiredLevel) {
- html(session, `Gatekeeper Sylvain:
You must be at least level ${requiredLevel} to perform this class transfer. You are currently level ${currentLevel}.`);
- return;
+ const result = await ClassTransfer.transfer(session, targetClassId);
+ if (!result.ok) {
+ if (result.reason === 'level') {
+ html(session, `Gatekeeper Sylvain:
You must be at least level ${result.requiredLevel} to perform this class transfer.`);
+ } else if (result.reason === 'wrong_profession') {
+ html(session, 'Gatekeeper Sylvain:
This class transfer is not available for your current profession.');
+ } else if (result.reason === 'persistence') {
+ html(session, 'Gatekeeper Sylvain:
The class transfer could not be completed. Your previous profession was restored.');
+ }
+ return result;
}
- // Execute class change
- actor.setClassId(targetClassId);
-
- try {
- await Database.updateCharacterClassId(actor.fetchId(), targetClassId);
- await actor.skillset.awardSkills(actor.fetchId(), targetClassId, currentLevel);
- CalculateStats(session, actor);
- actor.fillupVitals();
-
- // Send social effect (Social ID 15 is Level Up, very shiny and appropriate)
- session.dataSendToMeAndOthers(ServerResponse.socialAction(actor.fetchId(), 15), actor);
- session.dataSendToMe(ServerResponse.skillsList(actor.skillset.fetchSkills()));
- session.dataSendToMe(ServerResponse.userInfo(actor));
- session.dataSendToMe(ServerResponse.statusUpdate(actor.fetchId(), statusParams(actor)));
- session.dataSendToOthers(ServerResponse.charInfo(actor), actor);
-
- // Notify
- const className = parts.slice(2).join(' ') || 'new profession';
- const html = `Gatekeeper Sylvain:
Congratulations! You have successfully advanced your path and became a ${className}!
Return`;
- session.dataSendToMe(ServerResponse.npcHtml(7070, html));
- } catch (err) {
- actor.setClassId(currentClassId);
- await Database.updateCharacterClassId(actor.fetchId(), currentClassId).catch(() => {});
- utils.infoWarn('Character', 'class change failed for %s: %s', actor.fetchName(), err.message);
- html(session, 'Gatekeeper Sylvain:
The class transfer could not be completed. Your previous profession was restored.');
- }
+ const className = parts.slice(2).join(' ') || 'new profession';
+ html(session, `Gatekeeper Sylvain:
Congratulations! You have successfully advanced your path and became a ${className}!
Return`);
+ return result;
};
-module.exports.statusParams = statusParams;
+module.exports.statusParams = ClassTransfer.statusParams;
diff --git a/tests/test_change_class.js b/tests/test_change_class.js
index f5ca449e..dad15a86 100644
--- a/tests/test_change_class.js
+++ b/tests/test_change_class.js
@@ -6,6 +6,7 @@ const Actor = invoke('GameServer/Actor/Actor');
const DataCache = invoke('GameServer/DataCache');
const Database = invoke('Database');
const ChangeClass = invoke('GameServer/World/Generics/NpcBypasses/ChangeClass');
+const ClassTransfer = invoke('GameServer/ClassTransfer');
DataCache.init();
@@ -14,6 +15,22 @@ assert.strictEqual(thirdClassTrees.length, 31, 'all C4 third-class skill trees m
assert.strictEqual(DataCache.classTemplates.filter((template) => template.classId >= 88 && template.classId <= 118).length, 31, 'all C4 third classes must have login templates');
assert.ok(thirdClassTrees.every((tree) => tree.skills.every((skill) => DataCache.skills.some((definition) => definition.selfId === skill.selfId))), 'every third-class tree skill must have a loaded definition');
+const firstProfessionRoutes = [
+ [0, 1], [0, 4], [0, 7], [10, 11], [10, 15],
+ [18, 19], [18, 22], [25, 26], [25, 29],
+ [31, 32], [31, 35], [38, 39], [38, 42],
+ [44, 45], [44, 47], [49, 50], [53, 54], [53, 56]
+];
+firstProfessionRoutes.forEach(([from, target]) => {
+ assert.ok(DataCache.classTemplates.some((template) => template.classId === target), `class ${target} needs a login template`);
+ assert.ok(DataCache.skillTree.some((tree) => tree.classId === target), `class ${target} needs a skill tree`);
+ assert.deepStrictEqual(
+ ClassTransfer.eligibility({ fetchClassId: () => from, isDead: () => false }, target, { firstProfessionOnly: true }).ok,
+ true,
+ `${from} -> ${target} must remain a valid first-profession quest result`
+ );
+});
+
let storedSkills = [{ selfId: 194, level: 1, passive: true }];
Database.updateCharacterClassId = (id, classId) => {
Database.lastClassUpdate = { id, classId };
@@ -95,6 +112,22 @@ function createSession({ level = 20, classId = 0 } = {}) {
assert.ok(session.packets.some((packet) => packet[0] === 0x03), 'class change should refresh the character class for nearby clients');
assert.strictEqual(session.packets.at(-1)[0], 0x0f, 'class change success should render NPC HTML without htmlPacket');
+ const questFinishSession = createSession({ level: 20, classId: 0 });
+ const profession = await ClassTransfer.transfer(questFinishSession, 1, { firstProfessionOnly: true });
+ assert.deepStrictEqual(
+ { ok: profession.ok, targetClassId: profession.targetClassId },
+ { ok: true, targetClassId: 1 },
+ 'a profession quest final must use the same persisted and client-refreshed class transfer'
+ );
+ assert.strictEqual(questFinishSession.actor.fetchClassId(), 1);
+ assert.ok(questFinishSession.packets.some((packet) => packet[0] === 0x58), 'quest profession transfer must send SkillsList');
+ assert.ok(questFinishSession.packets.some((packet) => packet[0] === 0x04), 'quest profession transfer must send UserInfo');
+ assert.strictEqual(
+ (await ClassTransfer.transfer(createSession({ level: 20, classId: 0 }), 2, { firstProfessionOnly: true })).reason,
+ 'wrong_profession',
+ 'a quest cannot award a class outside its first-profession branch'
+ );
+
const incompleteDataSession = createSession({ level: 20, classId: 18 });
await ChangeClass(incompleteDataSession, ['change-class', '22', 'Elven', 'Scout']);
From e0661c2af439829210fdd29cada50650e31d29da Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 14:39:25 -0400
Subject: [PATCH 04/23] Implement Path to Warrior quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q401_PathToWarrior.js | 154 ++++++++++++++++++
tests/test_quest_availability.js | 9 +-
tests/test_quest_runtime.js | 74 +++++++++
4 files changed, 237 insertions(+), 1 deletion(-)
create mode 100644 src/GameServer/Quest/quests/Q401_PathToWarrior.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index c0208e19..000d44d2 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -56,6 +56,7 @@ const quests = [
require("./quests/Q168_DeliverSupplies"),
require("./quests/Q169_OffspringOfNightmares"),
require("./quests/Q170_DangerousSeduction"),
+ require("./quests/Q401_PathToWarrior"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q401_PathToWarrior.js b/src/GameServer/Quest/quests/Q401_PathToWarrior.js
new file mode 100644
index 00000000..b0878b96
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q401_PathToWarrior.js
@@ -0,0 +1,154 @@
+const AURON = 7010;
+const SIMPLON = 7253;
+const TRACKER_SKELETON = 35;
+const TRACKER_SKELETON_LEADER = 42;
+const POISON_SPIDER = 38;
+const ARACHNID_SPIDER = 43;
+
+const AURONS_LETTER = 1138;
+const WARRIOR_GUILD_MARK = 1139;
+const RUSTED_SWORD_1 = 1140;
+const RUSTED_SWORD_2 = 1141;
+const RUSTED_SWORD_3 = 1142;
+const SIMPLONS_LETTER = 1143;
+const POISON_SPIDER_LEG = 1144;
+const MEDALLION_OF_WARRIOR = 1145;
+
+const ACCEPT = 'ItemSound.quest_accept';
+const ITEM = 'ItemSound.quest_itemget';
+const MIDDLE = 'ItemSound.quest_middle';
+const FINISH = 'ItemSound.quest_finish';
+
+function service() {
+ return invoke('GameServer/Quest/QuestService');
+}
+
+function page(title, text, action = '') {
+ return `${title}:
${text}
${action}`;
+}
+
+function count(state, selfId) {
+ return state.session.actor.backpack.fetchItemFromSelfId(selfId)?.fetchAmount() || 0;
+}
+
+function equippedWeapon(state) {
+ const backpack = state.session.actor.backpack;
+ return Number(backpack.fetchPaperdollSelfId?.(7)) || 0;
+}
+
+async function collect(state, selfId, needed, chance = 1) {
+ const current = count(state, selfId);
+ if (current >= needed || Math.random() >= chance) return false;
+ const amount = service().questDropAmount(1, needed, current);
+ if (!amount) return false;
+ await service().giveItem(state.session, selfId, amount);
+ return current + amount >= needed;
+}
+
+module.exports = {
+ id: 401,
+ name: 'Path to Warrior',
+ npcs: [AURON, SIMPLON],
+ startNpcs: [AURON],
+ killNpcs: [TRACKER_SKELETON, TRACKER_SKELETON_LEADER, POISON_SPIDER, ARACHNID_SPIDER],
+ eventNpc: (event) => ({ start: AURON, guild: SIMPLON, forge: AURON })[event] ?? null,
+
+ async onEvent(state, event) {
+ const quest = service();
+ const actor = state.session.actor;
+ if (event === 'start' && !state.isStarted() && !state.isCompleted()) {
+ if (Number(actor.fetchClassId()) !== 0 || Number(actor.fetchLevel()) < 19) return null;
+ await state.setState('started');
+ await state.set('cond', 1);
+ await quest.giveItem(state.session, AURONS_LETTER, 1);
+ state.playSound(ACCEPT);
+ return page('Auron', 'Take my letter to Simplon in the Warrior Guild.');
+ }
+ if (event === 'guild' && state.getInt('cond') === 1) {
+ if (!(await quest.takeItem(state.session, AURONS_LETTER))) return null;
+ await quest.giveItem(state.session, WARRIOR_GUILD_MARK, 1);
+ await state.set('cond', 2);
+ state.playSound(MIDDLE);
+ return page('Simplon', 'Bring me ten pieces of rusted bronze sword.');
+ }
+ if (event === 'forge' && state.getInt('cond') === 4) {
+ if (!(await quest.takeItem(state.session, SIMPLONS_LETTER))) return null;
+ if (!(await quest.takeItem(state.session, RUSTED_SWORD_2))) return null;
+ await quest.giveItem(state.session, RUSTED_SWORD_3, 1);
+ await state.set('cond', 5);
+ state.playSound(MIDDLE);
+ return page('Auron', 'Equip the Rusted Bronze Sword and hunt Poison Spiders.');
+ }
+ return null;
+ },
+
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ const actor = state.session.actor;
+ const quest = service();
+ const cond = state.getInt('cond');
+
+ if (state.isCompleted()) return page('Auron', 'You have already completed the Path to Warrior.');
+ if (!state.isStarted()) {
+ if (npcId !== AURON || Number(actor.fetchClassId()) !== 0) return page('Quest', 'This path is not for your current class.');
+ return Number(actor.fetchLevel()) < 19
+ ? page('Auron', 'Come back after reaching level 19.')
+ : page('Auron', 'Do you seek the path of a Warrior?', 'Accept the trial.');
+ }
+
+ if (npcId === SIMPLON) {
+ if (cond === 1 && count(state, AURONS_LETTER)) {
+ return page('Simplon', 'Auron sent you?', 'Present Auron’s letter.');
+ }
+ if (cond === 2 && count(state, WARRIOR_GUILD_MARK)) return page('Simplon', `Rusted bronze swords: ${count(state, RUSTED_SWORD_1)}/10.`);
+ if (cond === 3 && count(state, WARRIOR_GUILD_MARK) && count(state, RUSTED_SWORD_1) >= 10) {
+ await quest.takeItem(state.session, WARRIOR_GUILD_MARK);
+ await quest.takeItem(state.session, RUSTED_SWORD_1, -1);
+ await quest.giveItem(state.session, RUSTED_SWORD_2, 1);
+ await quest.giveItem(state.session, SIMPLONS_LETTER, 1);
+ await state.set('cond', 4);
+ state.playSound(MIDDLE);
+ return page('Simplon', 'Take this sword and my letter back to Auron.');
+ }
+ return page('Simplon', 'Continue your trial with Auron.');
+ }
+
+ if (cond === 1 && count(state, AURONS_LETTER)) return page('Auron', 'Take my letter to Simplon.');
+ if (cond === 4 && count(state, SIMPLONS_LETTER)) {
+ return page('Auron', 'Simplon has sent the sword.', 'Repair the Rusted Bronze Sword.');
+ }
+ if (cond >= 5 && count(state, RUSTED_SWORD_3)) {
+ if (count(state, POISON_SPIDER_LEG) < 20) {
+ return page('Auron', `Poison Spider’s Legs: ${count(state, POISON_SPIDER_LEG)}/20. Equip the Rusted Bronze Sword first.`);
+ }
+ const profession = await quest.awardFirstProfession(state, 1);
+ if (!profession.ok) {
+ return page('Auron', profession.reason === 'level' ? `Reach level ${profession.requiredLevel} to become a Warrior.` : 'Your profession could not be granted. Keep your quest items and try again.');
+ }
+ await quest.takeItem(state.session, POISON_SPIDER_LEG, -1);
+ await quest.takeItem(state.session, RUSTED_SWORD_3);
+ await quest.giveItem(state.session, MEDALLION_OF_WARRIOR, 1);
+ state.playSound(FINISH);
+ await state.exit(false);
+ return page('Auron', 'You have completed the Path to Warrior and become a Warrior.');
+ }
+ return page('Auron', 'Continue your trial.');
+ },
+
+ async onKill(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ if ([TRACKER_SKELETON, TRACKER_SKELETON_LEADER].includes(npcId) && state.getInt('cond') === 2) {
+ if (await collect(state, RUSTED_SWORD_1, 10, 0.7)) {
+ await state.set('cond', 3);
+ state.playSound(MIDDLE);
+ } else if (count(state, RUSTED_SWORD_1)) state.playSound(ITEM);
+ return;
+ }
+ if (![POISON_SPIDER, ARACHNID_SPIDER].includes(npcId) || state.getInt('cond') !== 5) return;
+ if (equippedWeapon(state) !== RUSTED_SWORD_3) return;
+ if (await collect(state, POISON_SPIDER_LEG, 20)) {
+ await state.set('cond', 6);
+ state.playSound(MIDDLE);
+ } else if (count(state, POISON_SPIDER_LEG)) state.playSound(ITEM);
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index ac5c7896..6de4453c 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -8,12 +8,15 @@ const npcTemplates = JSON.parse(fs.readFileSync("data/Npcs/npcs.json", "utf8"));
const otherItems = JSON.parse(
fs.readFileSync("data/Items/Others/others.json", "utf8"),
);
+const weapons = JSON.parse(
+ fs.readFileSync("data/Items/Weapons/weapons.json", "utf8"),
+);
const spawnGroups = JSON.parse(
fs.readFileSync("data/Npcs/Spawns/spawns.json", "utf8"),
);
const templateIds = new Set(npcTemplates.map((npc) => Number(npc.selfId)));
-const itemIds = new Set(otherItems.map((item) => Number(item.selfId)));
+const itemIds = new Set([...otherItems, ...weapons].map((item) => Number(item.selfId)));
const spawnedIds = new Set();
function collectSpawnIds(value) {
if (Array.isArray(value)) return value.forEach(collectSpawnIds);
@@ -43,4 +46,8 @@ for (const itemId of [5789, 5790]) {
);
}
+for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145]) {
+ assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
+}
+
console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index da1e01d5..bed868b2 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -47,6 +47,7 @@ const Q152 = require("../src/GameServer/Quest/quests/Q152_ShardsOfGolem");
const Q159 = require("../src/GameServer/Quest/quests/Q159_ProtectTheWaterSource");
const Q162 = require("../src/GameServer/Quest/quests/Q162_CurseOfTheUndergroundFortress");
const Q163 = require("../src/GameServer/Quest/quests/Q163_LegacyOfThePoet");
+const Q401 = require("../src/GameServer/Quest/quests/Q401_PathToWarrior");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -100,6 +101,9 @@ async function main() {
assert.strictEqual(Q104.eventNpc("start"), 7017);
assert.strictEqual(Q157.eventNpc("start"), 7005);
assert.strictEqual(Q160.eventNpc("start"), 7370);
+ assert.strictEqual(Q401.eventNpc("start"), 7010);
+ assert.strictEqual(Q401.eventNpc("guild"), 7253);
+ assert.strictEqual(Q401.eventNpc("forge"), 7010);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -181,6 +185,7 @@ async function main() {
const originalTake = QuestService.takeItem;
const originalGive = QuestService.giveItem;
const originalRewardAdena = QuestService.rewardAdena;
+ const originalAwardFirstProfession = QuestService.awardFirstProfession;
const calls = [];
QuestService.takeItem = async (_, itemId) => calls.push(["take", itemId]);
QuestService.giveItem = async (_, itemId, amount) =>
@@ -238,10 +243,79 @@ async function main() {
],
"Q001 must grant one unscaled Necklace of Knowledge and the completion sound",
);
+
+ calls.length = 0;
+ const items = new Map();
+ let equippedWeapon = 0;
+ const setItem = (id, amount) => items.set(id, Math.max(0, amount));
+ const questState = {
+ session: {
+ actor: {
+ fetchClassId: () => 0,
+ fetchLevel: () => 20,
+ backpack: {
+ fetchItemFromSelfId: (id) => {
+ const amount = items.get(id) || 0;
+ return amount ? { fetchAmount: () => amount } : null;
+ },
+ fetchPaperdollSelfId: () => equippedWeapon,
+ },
+ },
+ },
+ isStarted: () => questState.started,
+ isCompleted: () => questState.completed,
+ started: false,
+ completed: false,
+ cond: 0,
+ getInt: () => questState.cond,
+ setState: async () => { questState.started = true; },
+ set: async (key, value) => { if (key === "cond") questState.cond = Number(value); },
+ exit: async () => { questState.completed = true; },
+ playSound: (sound) => calls.push(["sound", sound]),
+ };
+ QuestService.giveItem = async (_, id, amount) => setItem(id, (items.get(id) || 0) + amount);
+ QuestService.takeItem = async (_, id, amount = 1) => {
+ const current = items.get(id) || 0;
+ const remove = amount === -1 ? current : amount;
+ if (current < remove) return false;
+ setItem(id, current - remove);
+ return true;
+ };
+ QuestService.awardFirstProfession = async () => ({ ok: true, targetClassId: 1 });
+ await Q401.onEvent(questState, "start");
+ assert.strictEqual(items.get(1138), 1, "Q401 must issue Auron's Letter");
+ await Q401.onEvent(questState, "guild");
+ assert.strictEqual(items.get(1139), 1, "Q401 must issue the Warrior Guild Mark");
+ setItem(1140, 9);
+ const originalRandom = Math.random;
+ Math.random = () => 0;
+ try {
+ await Q401.onKill(questState, { fetchSelfId: () => 35 });
+ } finally {
+ Math.random = originalRandom;
+ }
+ assert.strictEqual(items.get(1140), 10, "Q401 must drop the tenth rusted sword from a Tracker Skeleton");
+ assert.strictEqual(questState.cond, 3, "Q401 must advance when all ten rusted swords are collected");
+ await Q401.onTalk(questState, { fetchSelfId: () => 7253 });
+ assert.strictEqual(items.get(1143), 1, "Q401 must issue Simplon's Letter after the sword hand-in");
+ await Q401.onEvent(questState, "forge");
+ assert.strictEqual(items.get(1142), 1, "Q401 must issue the equipped Rusted Bronze Sword");
+ setItem(1144, 19);
+ await Q401.onKill(questState, { fetchSelfId: () => 38 });
+ assert.strictEqual(items.get(1144), 19, "Q401 must not drop spider legs while the Rusted Bronze Sword is unequipped");
+ equippedWeapon = 1142;
+ await Q401.onKill(questState, { fetchSelfId: () => 38 });
+ assert.strictEqual(items.get(1144), 20, "Q401 must drop spider legs with the Rusted Bronze Sword equipped");
+ await Q401.onTalk(questState, { fetchSelfId: () => 7010 });
+ assert.strictEqual(questState.completed, true, "Q401 must complete after the final spider-leg hand-in");
+ assert.strictEqual(items.get(1145), 1, "Q401 must retain the source Medallion of Warrior reward");
+ assert.strictEqual(items.get(1144), 0, "Q401 must consume all collected Poison Spider's Legs");
+ assert.strictEqual(items.get(1142), 0, "Q401 must consume the Rusted Bronze Sword");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
QuestService.rewardAdena = originalRewardAdena;
+ QuestService.awardFirstProfession = originalAwardFirstProfession;
}
const deleted = [];
From b1ccdfb66f9ef6f8769871efc4ac5007499a25a2 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 14:45:56 -0400
Subject: [PATCH 05/23] Implement Path to Knight quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q402_PathToKnight.js | 151 ++++++++++++++++++
tests/test_quest_availability.js | 2 +-
tests/test_quest_runtime.js | 54 +++++++
4 files changed, 207 insertions(+), 1 deletion(-)
create mode 100644 src/GameServer/Quest/quests/Q402_PathToKnight.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 000d44d2..3d2b80de 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -57,6 +57,7 @@ const quests = [
require("./quests/Q169_OffspringOfNightmares"),
require("./quests/Q170_DangerousSeduction"),
require("./quests/Q401_PathToWarrior"),
+ require("./quests/Q402_PathToKnight"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q402_PathToKnight.js b/src/GameServer/Quest/quests/Q402_PathToKnight.js
new file mode 100644
index 00000000..28592eb1
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q402_PathToKnight.js
@@ -0,0 +1,151 @@
+const SIR_KLAUS = 7417;
+const SIR_ARON = 7332;
+const SIR_COLLIN = 7289;
+const SIR_KYLE = 7379;
+const SIR_DRYSTAN = 7037;
+const SIR_JEREMY = 7039;
+const SIR_HEROD = 7031;
+const HINEN = 7311;
+const ROSHEEK = 7653;
+
+const MARK_OF_ESQUIRE = 1271;
+const SWORD_OF_RITUAL = 1161;
+const COINS = [1162, 1163, 1164, 1165, 1166, 1167];
+
+const assignments = [
+ { event: 'aron', npc: SIR_ARON, mark: 1168, item: 1169, coin: 1162, needed: 10, mobs: [775], chance: 1, title: 'Sir Aron', itemName: 'Bugbear Necklaces' },
+ { event: 'collin', npc: SIR_COLLIN, mark: 1170, item: 1171, coin: 1163, needed: 12, mobs: [5024], chance: 1, title: 'Sir Collin', itemName: 'Einhasad Crucifixes' },
+ { event: 'kyle', npc: SIR_KYLE, mark: 1172, item: 1173, coin: 1164, needed: 20, mobs: [38, 43, 50], chance: 1, title: 'Sir Kyle', itemName: 'Poison Spider Legs' },
+ { event: 'drystan', npc: SIR_DRYSTAN, mark: 1174, item: 1175, coin: 1165, needed: 20, mobs: [24, 27, 30], chance: 0.5, title: 'Sir Drystan', itemName: 'Lizardman Totems' },
+ { event: 'jeremy', npc: SIR_JEREMY, mark: 1176, item: 1177, coin: 1166, needed: 20, mobs: [103, 106, 108], chance: 0.4, title: 'Sir Jeremy', itemName: 'Giant Spider Husks' },
+ { event: 'herod', npc: SIR_HEROD, mark: 1178, item: 1179, coin: 1167, needed: 10, mobs: [404], chance: 1, title: 'Sir Herod', itemName: 'Horrible Skulls' },
+];
+
+const ACCEPT = 'ItemSound.quest_accept';
+const ITEM = 'ItemSound.quest_itemget';
+const MIDDLE = 'ItemSound.quest_middle';
+const FINISH = 'ItemSound.quest_finish';
+
+function service() {
+ return invoke('GameServer/Quest/QuestService');
+}
+
+function page(title, text, action = '') {
+ return `${title}:
${text}
${action}`;
+}
+
+function count(state, selfId) {
+ return state.session.actor.backpack.fetchItemFromSelfId(selfId)?.fetchAmount() || 0;
+}
+
+function assignmentForNpc(npcId) {
+ return assignments.find((assignment) => assignment.npc === npcId);
+}
+
+function assignmentForMob(npcId) {
+ return assignments.find((assignment) => assignment.mobs.includes(npcId));
+}
+
+function coinsCollected(state) {
+ return COINS.reduce((total, itemId) => total + count(state, itemId), 0);
+}
+
+async function collect(state, assignment) {
+ const current = count(state, assignment.item);
+ if (current >= assignment.needed || Math.random() >= assignment.chance) return false;
+ const amount = service().questDropAmount(1, assignment.needed, current);
+ if (!amount) return false;
+ await service().giveItem(state.session, assignment.item, amount);
+ return current + amount >= assignment.needed;
+}
+
+async function consume(quest, state, itemIds) {
+ for (const itemId of itemIds) await quest.takeItem(state.session, itemId, -1);
+}
+
+module.exports = {
+ id: 402,
+ name: 'Path to Knight',
+ npcs: [SIR_KLAUS, SIR_ARON, SIR_COLLIN, SIR_KYLE, SIR_DRYSTAN, SIR_JEREMY, SIR_HEROD, HINEN, ROSHEEK],
+ startNpcs: [SIR_KLAUS],
+ killNpcs: assignments.flatMap((assignment) => assignment.mobs),
+ eventNpc: (event) => ({ start: SIR_KLAUS, ...Object.fromEntries(assignments.map((assignment) => [assignment.event, assignment.npc])) })[event] ?? null,
+
+ async onEvent(state, event) {
+ const quest = service();
+ const actor = state.session.actor;
+ if (event === 'start' && !state.isStarted() && !state.isCompleted()) {
+ if (Number(actor.fetchClassId()) !== 0 || Number(actor.fetchLevel()) < 19) return null;
+ await state.setState('started');
+ await state.set('cond', 1);
+ await quest.giveItem(state.session, MARK_OF_ESQUIRE, 1);
+ state.playSound(ACCEPT);
+ return page('Sir Klaus Vasper', 'Seek six coins from the Lords of Gludio.');
+ }
+
+ const assignment = assignments.find((entry) => entry.event === event);
+ if (assignment && state.getInt('cond') === 1 && count(state, MARK_OF_ESQUIRE) && !count(state, assignment.mark) && !count(state, assignment.coin)) {
+ await quest.giveItem(state.session, assignment.mark, 1);
+ state.playSound(MIDDLE);
+ return page(assignment.title, `Bring me ${assignment.needed} ${assignment.itemName}.`);
+ }
+ return null;
+ },
+
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ const cond = state.getInt('cond');
+
+ if (state.isCompleted()) return page('Sir Klaus Vasper', 'You have already completed the Path to Knight.');
+ if (!state.isStarted()) {
+ const actor = state.session.actor;
+ if (npcId !== SIR_KLAUS || Number(actor.fetchClassId()) !== 0) return page('Quest', 'This path is not for your current class.');
+ return Number(actor.fetchLevel()) < 19
+ ? page('Sir Klaus Vasper', 'Come back after reaching level 19.')
+ : page('Sir Klaus Vasper', 'Do you seek the path of a Knight?', 'Accept the trial.');
+ }
+ if (cond !== 1) return page('Quest', 'Continue your trial.');
+
+ if (npcId === SIR_KLAUS) {
+ const coins = coinsCollected(state);
+ const hasAllCoins = COINS.every((coin) => count(state, coin) >= 1);
+ if (!count(state, MARK_OF_ESQUIRE) || !hasAllCoins) return page('Sir Klaus Vasper', `Coins of Lords: ${coins}/6. Complete the Lords’ requests in any order.`);
+ const profession = await quest.awardFirstProfession(state, 4);
+ if (!profession.ok) {
+ return page('Sir Klaus Vasper', profession.reason === 'level' ? `Reach level ${profession.requiredLevel} to become a Human Knight.` : 'Your profession could not be granted. Keep your quest items and try again.');
+ }
+ await consume(quest, state, [MARK_OF_ESQUIRE, ...COINS, ...assignments.flatMap((assignment) => [assignment.mark, assignment.item])]);
+ await quest.giveItem(state.session, SWORD_OF_RITUAL, 1);
+ state.playSound(FINISH);
+ await state.exit(false);
+ return page('Sir Klaus Vasper', 'You have completed the Path to Knight and become a Human Knight.');
+ }
+
+ const assignment = assignmentForNpc(npcId);
+ if (assignment) {
+ if (count(state, assignment.coin)) return page(assignment.title, 'You have already earned my Coin of Lords.');
+ if (!count(state, assignment.mark)) {
+ return page(assignment.title, `I can help only after you present Sir Klaus’s mark. Accept my request.`);
+ }
+ if (count(state, assignment.item) < assignment.needed) {
+ return page(assignment.title, `${assignment.itemName}: ${count(state, assignment.item)}/${assignment.needed}.`);
+ }
+ await quest.takeItem(state.session, assignment.item, -1);
+ await quest.takeItem(state.session, assignment.mark);
+ await quest.giveItem(state.session, assignment.coin, 1);
+ state.playSound(MIDDLE);
+ return page(assignment.title, 'Take this Coin of Lords to Sir Klaus.');
+ }
+ if ([HINEN, ROSHEEK].includes(npcId)) return page('Quest', 'The Knights of Gludio need the six Coins of Lords.');
+ return page('Quest', 'Continue your trial.');
+ },
+
+ async onKill(state, npc) {
+ if (state.getInt('cond') !== 1) return;
+ const assignment = assignmentForMob(Number(npc.fetchSelfId()));
+ if (!assignment || !count(state, assignment.mark) || count(state, assignment.coin)) return;
+ if (await collect(state, assignment)) state.playSound(MIDDLE);
+ else if (count(state, assignment.item)) state.playSound(ITEM);
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 6de4453c..267bb548 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -46,7 +46,7 @@ for (const itemId of [5789, 5790]) {
);
}
-for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145]) {
+for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1271]) {
assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
}
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index bed868b2..b5ce04d9 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -48,6 +48,7 @@ const Q159 = require("../src/GameServer/Quest/quests/Q159_ProtectTheWaterSource"
const Q162 = require("../src/GameServer/Quest/quests/Q162_CurseOfTheUndergroundFortress");
const Q163 = require("../src/GameServer/Quest/quests/Q163_LegacyOfThePoet");
const Q401 = require("../src/GameServer/Quest/quests/Q401_PathToWarrior");
+const Q402 = require("../src/GameServer/Quest/quests/Q402_PathToKnight");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -104,6 +105,9 @@ async function main() {
assert.strictEqual(Q401.eventNpc("start"), 7010);
assert.strictEqual(Q401.eventNpc("guild"), 7253);
assert.strictEqual(Q401.eventNpc("forge"), 7010);
+ assert.strictEqual(Q402.eventNpc("start"), 7417);
+ assert.strictEqual(Q402.eventNpc("aron"), 7332);
+ assert.strictEqual(Q402.eventNpc("herod"), 7031);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -311,6 +315,56 @@ async function main() {
assert.strictEqual(items.get(1145), 1, "Q401 must retain the source Medallion of Warrior reward");
assert.strictEqual(items.get(1144), 0, "Q401 must consume all collected Poison Spider's Legs");
assert.strictEqual(items.get(1142), 0, "Q401 must consume the Rusted Bronze Sword");
+
+ items.clear();
+ questState.started = false;
+ questState.completed = false;
+ questState.cond = 0;
+ QuestService.awardFirstProfession = async () => ({ ok: true, targetClassId: 4 });
+ await Q402.onEvent(questState, "start");
+ assert.strictEqual(items.get(1271), 1, "Q402 must issue the Mark of Esquire");
+ const knightAssignments = [
+ ["aron", 7332, 1169, 10, 1162],
+ ["collin", 7289, 1171, 12, 1163],
+ ["kyle", 7379, 1173, 20, 1164],
+ ["drystan", 7037, 1175, 20, 1165],
+ ["jeremy", 7039, 1177, 20, 1166],
+ ["herod", 7031, 1179, 10, 1167],
+ ];
+ for (const [event, npcId, trophy, needed, coin] of knightAssignments) {
+ await Q402.onEvent(questState, event);
+ if (event === "drystan") {
+ setItem(trophy, needed - 1);
+ const originalRandom = Math.random;
+ Math.random = () => 0;
+ try {
+ await Q402.onKill(questState, { fetchSelfId: () => 24 });
+ } finally {
+ Math.random = originalRandom;
+ }
+ assert.strictEqual(items.get(trophy), needed, "Q402 must drop the final Lizardman Totem from its source mob");
+ } else if (event === "jeremy") {
+ setItem(trophy, needed - 1);
+ const originalRandom = Math.random;
+ Math.random = () => 0.5;
+ try {
+ await Q402.onKill(questState, { fetchSelfId: () => 103 });
+ assert.strictEqual(items.get(trophy), needed - 1, "Q402 must preserve the source 40% Giant Spider Husk drop chance");
+ Math.random = () => 0;
+ await Q402.onKill(questState, { fetchSelfId: () => 103 });
+ } finally {
+ Math.random = originalRandom;
+ }
+ assert.strictEqual(items.get(trophy), needed, "Q402 must collect the final Giant Spider Husk on a successful roll");
+ } else setItem(trophy, needed);
+ await Q402.onTalk(questState, { fetchSelfId: () => npcId });
+ assert.strictEqual(items.get(coin), 1, `Q402 must exchange ${event}'s trophies for its Coin of Lords`);
+ }
+ await Q402.onTalk(questState, { fetchSelfId: () => 7417 });
+ assert.strictEqual(questState.completed, true, "Q402 must complete after all six Coins of Lords are returned");
+ assert.strictEqual(items.get(1161), 1, "Q402 must retain the source Sword of Ritual reward");
+ assert.strictEqual(items.get(1271), 0, "Q402 must consume the Mark of Esquire at completion");
+ assert.deepStrictEqual([1162, 1163, 1164, 1165, 1166, 1167].map((id) => items.get(id) || 0), [0, 0, 0, 0, 0, 0], "Q402 must consume every Coin of Lords");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 45104d6f937f6fdc98625316b7b42376ab5f28e5 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 14:56:01 -0400
Subject: [PATCH 06/23] Implement Path to Rogue quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q403_PathToRogue.js | 157 ++++++++++++++++++
tests/test_quest_availability.js | 2 +-
tests/test_quest_runtime.js | 45 +++++
4 files changed, 204 insertions(+), 1 deletion(-)
create mode 100644 src/GameServer/Quest/quests/Q403_PathToRogue.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 3d2b80de..1c53d75a 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -58,6 +58,7 @@ const quests = [
require("./quests/Q170_DangerousSeduction"),
require("./quests/Q401_PathToWarrior"),
require("./quests/Q402_PathToKnight"),
+ require("./quests/Q403_PathToRogue"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q403_PathToRogue.js b/src/GameServer/Quest/quests/Q403_PathToRogue.js
new file mode 100644
index 00000000..2031d61c
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q403_PathToRogue.js
@@ -0,0 +1,157 @@
+const BEZIQUE = 7379;
+const NETI = 7425;
+const CATS_EYE_BANDIT = 5038;
+
+const BEZIQUES_LETTER = 1180;
+const NETIS_BOW = 1181;
+const NETIS_DAGGER = 1182;
+const SPARTOI_BONES = 1183;
+const HORSESHOE_OF_LIGHT = 1184;
+const WANTED_BILL = 1185;
+const STOLEN_ITEMS = [1186, 1187, 1188, 1189];
+const BEZIQUES_RECOMMENDATION = 1190;
+
+const BONE_CHANCE = new Map([
+ [35, 0.2], [42, 0.3], [45, 0.2], [51, 0.2], [54, 0.8], [60, 0.8],
+]);
+
+const ACCEPT = 'ItemSound.quest_accept';
+const ITEM = 'ItemSound.quest_itemget';
+const MIDDLE = 'ItemSound.quest_middle';
+const FINISH = 'ItemSound.quest_finish';
+
+function service() {
+ return invoke('GameServer/Quest/QuestService');
+}
+
+function page(title, text, action = '') {
+ return `${title}:
${text}
${action}`;
+}
+
+function count(state, selfId) {
+ return state.session.actor.backpack.fetchItemFromSelfId(selfId)?.fetchAmount() || 0;
+}
+
+function equippedWeapon(state) {
+ return Number(state.session.actor.backpack.fetchPaperdollSelfId?.(7)) || 0;
+}
+
+function hasNetiWeapon(state) {
+ return [NETIS_BOW, NETIS_DAGGER].includes(equippedWeapon(state));
+}
+
+function hasAllStolenItems(state) {
+ return STOLEN_ITEMS.every((item) => count(state, item) > 0);
+}
+
+async function collectBones(state, chance) {
+ const current = count(state, SPARTOI_BONES);
+ if (current >= 10 || Math.random() >= chance) return false;
+ const amount = service().questDropAmount(1, 10, current);
+ if (!amount) return false;
+ await service().giveItem(state.session, SPARTOI_BONES, amount);
+ return current + amount >= 10;
+}
+
+module.exports = {
+ id: 403,
+ name: 'Path to Rogue',
+ npcs: [BEZIQUE, NETI],
+ startNpcs: [BEZIQUE],
+ killNpcs: [CATS_EYE_BANDIT, ...BONE_CHANCE.keys()],
+ eventNpc: (event) => ({ start: BEZIQUE, neti: NETI })[event] ?? null,
+
+ async onEvent(state, event) {
+ const quest = service();
+ const actor = state.session.actor;
+ if (event === 'start' && !state.isStarted() && !state.isCompleted()) {
+ if (Number(actor.fetchClassId()) !== 0 || Number(actor.fetchLevel()) < 19) return null;
+ await state.setState('started');
+ await state.set('cond', 1);
+ await quest.giveItem(state.session, BEZIQUES_LETTER, 1);
+ state.playSound(ACCEPT);
+ return page('Bezique', 'Take this letter to Neti.');
+ }
+ if (event === 'neti' && state.getInt('cond') === 1) {
+ if (!(await quest.takeItem(state.session, BEZIQUES_LETTER))) return null;
+ if (!count(state, NETIS_BOW)) await quest.giveItem(state.session, NETIS_BOW, 1);
+ if (!count(state, NETIS_DAGGER)) await quest.giveItem(state.session, NETIS_DAGGER, 1);
+ await state.set('cond', 2);
+ state.playSound(MIDDLE);
+ return page('Neti', 'Equip either weapon and bring me ten Spartoi bones.');
+ }
+ return null;
+ },
+
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ const cond = state.getInt('cond');
+
+ if (state.isCompleted()) return page('Bezique', 'You have already completed the Path to Rogue.');
+ if (!state.isStarted()) {
+ const actor = state.session.actor;
+ if (npcId !== BEZIQUE || Number(actor.fetchClassId()) !== 0) return page('Quest', 'This path is not for your current class.');
+ return Number(actor.fetchLevel()) < 19
+ ? page('Bezique', 'Come back after reaching level 19.')
+ : page('Bezique', 'Do you seek the path of a Rogue?', 'Accept the trial.');
+ }
+
+ if (npcId === NETI) {
+ if (cond === 1 && count(state, BEZIQUES_LETTER)) return page('Neti', 'Bezique sent you?', 'Present Bezique’s letter.');
+ if (count(state, WANTED_BILL)) return page('Neti', 'Use the wanted bill to recover the stolen items from Cat’s Eye Bandits.');
+ if (count(state, SPARTOI_BONES) < 10) return page('Neti', `Spartoi Bones: ${count(state, SPARTOI_BONES)}/10. Equip Neti’s weapon first.`);
+ if (!count(state, HORSESHOE_OF_LIGHT)) {
+ await quest.takeItem(state.session, SPARTOI_BONES, -1);
+ await quest.giveItem(state.session, HORSESHOE_OF_LIGHT, 1);
+ await state.set('cond', 4);
+ state.playSound(MIDDLE);
+ return page('Neti', 'Take the Horseshoe of Light back to Bezique.');
+ }
+ return page('Neti', 'Return to Bezique.');
+ }
+
+ if (hasAllStolenItems(state) && !count(state, HORSESHOE_OF_LIGHT)) {
+ const profession = await quest.awardFirstProfession(state, 7);
+ if (!profession.ok) {
+ return page('Bezique', profession.reason === 'level' ? `Reach level ${profession.requiredLevel} to become a Rogue.` : 'Your profession could not be granted. Keep your quest items and try again.');
+ }
+ for (const item of [NETIS_BOW, NETIS_DAGGER, WANTED_BILL, ...STOLEN_ITEMS]) await quest.takeItem(state.session, item, -1);
+ await quest.giveItem(state.session, BEZIQUES_RECOMMENDATION, 1);
+ state.playSound(FINISH);
+ await state.exit(false);
+ return page('Bezique', 'You have completed the Path to Rogue and become a Rogue.');
+ }
+ if (count(state, BEZIQUES_LETTER)) return page('Bezique', 'Take my letter to Neti.');
+ if (count(state, HORSESHOE_OF_LIGHT)) {
+ await quest.takeItem(state.session, HORSESHOE_OF_LIGHT);
+ await quest.giveItem(state.session, WANTED_BILL, 1);
+ await state.set('cond', 5);
+ state.playSound(MIDDLE);
+ return page('Bezique', 'Hunt Cat’s Eye Bandits for the stolen items.');
+ }
+ if (count(state, WANTED_BILL)) return page('Bezique', 'Recover every stolen item from Cat’s Eye Bandits.');
+ return page('Bezique', 'Equip Neti’s weapon and complete her request.');
+ },
+
+ async onKill(state, npc) {
+ if (!state.isStarted() || !hasNetiWeapon(state)) return;
+ const npcId = Number(npc.fetchSelfId());
+ const chance = BONE_CHANCE.get(npcId);
+ if (chance !== undefined && state.getInt('cond') > 0) {
+ if (await collectBones(state, chance)) {
+ await state.set('cond', 3);
+ state.playSound(MIDDLE);
+ } else if (count(state, SPARTOI_BONES)) state.playSound(ITEM);
+ return;
+ }
+ if (npcId !== CATS_EYE_BANDIT || !count(state, WANTED_BILL)) return;
+ const item = STOLEN_ITEMS[Math.floor(Math.random() * STOLEN_ITEMS.length)];
+ if (count(state, item)) return;
+ await service().giveItem(state.session, item, 1);
+ if (hasAllStolenItems(state)) {
+ await state.set('cond', 6);
+ state.playSound(MIDDLE);
+ } else state.playSound(ITEM);
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 267bb548..3e928177 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -46,7 +46,7 @@ for (const itemId of [5789, 5790]) {
);
}
-for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1271]) {
+for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1271]) {
assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
}
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index b5ce04d9..3e3e9b06 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -49,6 +49,7 @@ const Q162 = require("../src/GameServer/Quest/quests/Q162_CurseOfTheUndergroundF
const Q163 = require("../src/GameServer/Quest/quests/Q163_LegacyOfThePoet");
const Q401 = require("../src/GameServer/Quest/quests/Q401_PathToWarrior");
const Q402 = require("../src/GameServer/Quest/quests/Q402_PathToKnight");
+const Q403 = require("../src/GameServer/Quest/quests/Q403_PathToRogue");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -108,6 +109,8 @@ async function main() {
assert.strictEqual(Q402.eventNpc("start"), 7417);
assert.strictEqual(Q402.eventNpc("aron"), 7332);
assert.strictEqual(Q402.eventNpc("herod"), 7031);
+ assert.strictEqual(Q403.eventNpc("start"), 7379);
+ assert.strictEqual(Q403.eventNpc("neti"), 7425);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -365,6 +368,48 @@ async function main() {
assert.strictEqual(items.get(1161), 1, "Q402 must retain the source Sword of Ritual reward");
assert.strictEqual(items.get(1271), 0, "Q402 must consume the Mark of Esquire at completion");
assert.deepStrictEqual([1162, 1163, 1164, 1165, 1166, 1167].map((id) => items.get(id) || 0), [0, 0, 0, 0, 0, 0], "Q402 must consume every Coin of Lords");
+
+ items.clear();
+ questState.started = false;
+ questState.completed = false;
+ questState.cond = 0;
+ equippedWeapon = 0;
+ QuestService.awardFirstProfession = async () => ({ ok: true, targetClassId: 7 });
+ await Q403.onEvent(questState, "start");
+ assert.strictEqual(items.get(1180), 1, "Q403 must issue Bezique's Letter");
+ await Q403.onEvent(questState, "neti");
+ assert.strictEqual(items.get(1181), 1, "Q403 must issue Neti's Bow");
+ assert.strictEqual(items.get(1182), 1, "Q403 must issue Neti's Dagger");
+ setItem(1183, 9);
+ equippedWeapon = 1181;
+ const originalRandomForRogue = Math.random;
+ Math.random = () => 0;
+ try {
+ await Q403.onKill(questState, { fetchSelfId: () => 54 });
+ } finally {
+ Math.random = originalRandomForRogue;
+ }
+ assert.strictEqual(items.get(1183), 10, "Q403 must drop the tenth Spartoi Bone with Neti's weapon equipped");
+ assert.strictEqual(questState.cond, 3, "Q403 must advance after all Spartoi Bones are collected");
+ await Q403.onTalk(questState, { fetchSelfId: () => 7425 });
+ assert.strictEqual(items.get(1184), 1, "Q403 must exchange bones for the Horseshoe of Light");
+ await Q403.onTalk(questState, { fetchSelfId: () => 7379 });
+ assert.strictEqual(items.get(1185), 1, "Q403 must issue the Wanted Bill after the Horseshoe hand-in");
+ const stolenRolls = [0, 0, 0.25, 0.5, 0.75];
+ for (const roll of stolenRolls) {
+ const originalRandom = Math.random;
+ Math.random = () => roll;
+ try {
+ await Q403.onKill(questState, { fetchSelfId: () => 5038 });
+ } finally {
+ Math.random = originalRandom;
+ }
+ }
+ assert.deepStrictEqual([1186, 1187, 1188, 1189].map((id) => items.get(id) || 0), [1, 1, 1, 1], "Q403 must award each stolen item only when Cat's Eye Bandit's source roll selects it");
+ await Q403.onTalk(questState, { fetchSelfId: () => 7379 });
+ assert.strictEqual(questState.completed, true, "Q403 must complete after all stolen items are returned");
+ assert.strictEqual(items.get(1190), 1, "Q403 must retain Bezique's Recommendation as the source reward");
+ assert.strictEqual(items.get(1185), 0, "Q403 must consume the Wanted Bill at completion");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 6156e463e237f13f8f69cb801e2bd359c034518c Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:05:08 -0400
Subject: [PATCH 07/23] Implement Path to Wizard quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q404_PathToWizard.js | 198 ++++++++++++++++++
tests/test_quest_availability.js | 2 +-
tests/test_quest_runtime.js | 43 +++-
4 files changed, 242 insertions(+), 2 deletions(-)
create mode 100644 src/GameServer/Quest/quests/Q404_PathToWizard.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 1c53d75a..a88f9747 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -59,6 +59,7 @@ const quests = [
require("./quests/Q401_PathToWarrior"),
require("./quests/Q402_PathToKnight"),
require("./quests/Q403_PathToRogue"),
+ require("./quests/Q404_PathToWizard"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q404_PathToWizard.js b/src/GameServer/Quest/quests/Q404_PathToWizard.js
new file mode 100644
index 00000000..a8e89538
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q404_PathToWizard.js
@@ -0,0 +1,198 @@
+const GALLINT = 7391;
+const EARTH_SNAKE = 7409;
+const WASTELAND_LIZARDMAN = 7410;
+const FLAME_SALAMANDER = 7411;
+const WIND_SYLPH = 7412;
+const WATER_UNDINE = 7413;
+
+const RED_BEAR = 21;
+const RATMAN_WARRIOR = 359;
+const WATER_SEER = 5030;
+
+const MAP_OF_LUSTER = 1280;
+const KEY_OF_FLAME = 1281;
+const FLAME_EARRING = 1282;
+const BROKEN_BRONZE_MIRROR = 1283;
+const WIND_FEATHER = 1284;
+const WIND_BANGLE = 1285;
+const RAMAS_DIARY = 1286;
+const SPARKLE_PEBBLE = 1287;
+const WATER_NECKLACE = 1288;
+const RUST_GOLD_COIN = 1289;
+const RED_SOIL = 1290;
+const EARTH_RING = 1291;
+const BEAD_OF_SEASON = 1292;
+
+const ACCEPT = "ItemSound.quest_accept";
+const ITEM = "ItemSound.quest_itemget";
+const MIDDLE = "ItemSound.quest_middle";
+const FINISH = "ItemSound.quest_finish";
+
+function service() {
+ return invoke("GameServer/Quest/QuestService");
+}
+
+function page(title, text, action = "") {
+ return `${title}:
${text}
${action}`;
+}
+
+function count(state, selfId) {
+ return state.session.actor.backpack.fetchItemFromSelfId(selfId)?.fetchAmount() || 0;
+}
+
+async function exchange(state, remove, give, condition) {
+ const quest = service();
+ for (const item of remove) {
+ if (!(await quest.takeItem(state.session, item))) return false;
+ }
+ await quest.giveItem(state.session, give, 1);
+ await state.set("cond", condition);
+ state.playSound(MIDDLE);
+ return true;
+}
+
+module.exports = {
+ id: 404,
+ name: "Path to Wizard",
+ npcs: [GALLINT, EARTH_SNAKE, WASTELAND_LIZARDMAN, FLAME_SALAMANDER, WIND_SYLPH, WATER_UNDINE],
+ startNpcs: [GALLINT],
+ killNpcs: [RED_BEAR, RATMAN_WARRIOR, WATER_SEER],
+ eventNpc: (event) => ({ start: GALLINT, feather: WASTELAND_LIZARDMAN })[event] ?? null,
+
+ async onEvent(state, event) {
+ const quest = service();
+ const actor = state.session.actor;
+ if (event === "start" && !state.isStarted() && !state.isCompleted()) {
+ if (Number(actor.fetchClassId()) !== 10 || Number(actor.fetchLevel()) < 19) return null;
+ await state.setState("started");
+ await state.set("cond", 1);
+ state.playSound(ACCEPT);
+ return page("Gallint", "Seek the four elemental signs to prove your mastery.");
+ }
+ if (event === "feather" && state.getInt("cond") === 5 && count(state, BROKEN_BRONZE_MIRROR) && !count(state, WIND_FEATHER)) {
+ await quest.giveItem(state.session, WIND_FEATHER, 1);
+ await state.set("cond", 6);
+ state.playSound(MIDDLE);
+ return page("Wasteland Lizardman", "The Wind Feather has revealed itself in the broken mirror.");
+ }
+ return null;
+ },
+
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ const cond = state.getInt("cond");
+
+ if (state.isCompleted()) return page("Gallint", "You have already completed the Path to Wizard.");
+ if (!state.isStarted()) {
+ const actor = state.session.actor;
+ if (npcId !== GALLINT || Number(actor.fetchClassId()) !== 10) return page("Quest", "This path is not for your current class.");
+ return Number(actor.fetchLevel()) < 19
+ ? page("Gallint", "Come back after reaching level 19.")
+ : page("Gallint", "Do you seek the path of a Wizard?", 'Accept the trial.');
+ }
+
+ if (npcId === FLAME_SALAMANDER) {
+ if (cond === 1 && !count(state, MAP_OF_LUSTER) && !count(state, FLAME_EARRING)) {
+ await quest.giveItem(state.session, MAP_OF_LUSTER, 1);
+ await state.set("cond", 2);
+ state.playSound(MIDDLE);
+ return page("Flame Salamander", "Find the Key of Flame from Ratman Warriors.");
+ }
+ if (cond === 3 && count(state, MAP_OF_LUSTER) && count(state, KEY_OF_FLAME)) {
+ await exchange(state, [MAP_OF_LUSTER, KEY_OF_FLAME], FLAME_EARRING, 4);
+ return page("Flame Salamander", "Receive the Flame Earring.");
+ }
+ return page("Flame Salamander", "Bring me the Map of Luster and the Key of Flame.");
+ }
+
+ if (npcId === WIND_SYLPH) {
+ if (cond === 4 && count(state, FLAME_EARRING) && !count(state, BROKEN_BRONZE_MIRROR) && !count(state, WIND_BANGLE)) {
+ await quest.giveItem(state.session, BROKEN_BRONZE_MIRROR, 1);
+ await state.set("cond", 5);
+ state.playSound(MIDDLE);
+ return page("Wind Sylph", "Show the broken bronze mirror to a Wasteland Lizardman.");
+ }
+ if (cond === 6 && count(state, BROKEN_BRONZE_MIRROR) && count(state, WIND_FEATHER)) {
+ await exchange(state, [BROKEN_BRONZE_MIRROR, WIND_FEATHER], WIND_BANGLE, 7);
+ return page("Wind Sylph", "Receive the Wind Bangle.");
+ }
+ return page("Wind Sylph", "Return when you have learned the sign of wind.");
+ }
+
+ if (npcId === WASTELAND_LIZARDMAN) {
+ if (cond === 5 && count(state, BROKEN_BRONZE_MIRROR) && !count(state, WIND_FEATHER)) {
+ return page("Wasteland Lizardman", "The mirror reflects a hidden feather.", 'Examine the reflection.');
+ }
+ return page("Wasteland Lizardman", "The wind has nothing more to show you.");
+ }
+
+ if (npcId === WATER_UNDINE) {
+ if (cond === 7 && count(state, WIND_BANGLE) && !count(state, RAMAS_DIARY) && !count(state, WATER_NECKLACE)) {
+ await quest.giveItem(state.session, RAMAS_DIARY, 1);
+ await state.set("cond", 8);
+ state.playSound(MIDDLE);
+ return page("Water Undine", "Collect two Sparkle Pebbles from Water Seers.");
+ }
+ if (cond === 9 && count(state, RAMAS_DIARY) && count(state, SPARKLE_PEBBLE) >= 2) {
+ await exchange(state, [RAMAS_DIARY, SPARKLE_PEBBLE, SPARKLE_PEBBLE], WATER_NECKLACE, 10);
+ return page("Water Undine", "Receive the Water Necklace.");
+ }
+ return page("Water Undine", `Sparkle Pebbles: ${count(state, SPARKLE_PEBBLE)}/2.`);
+ }
+
+ if (npcId === EARTH_SNAKE) {
+ if (cond === 10 && count(state, WATER_NECKLACE) && !count(state, RUST_GOLD_COIN) && !count(state, EARTH_RING)) {
+ await quest.giveItem(state.session, RUST_GOLD_COIN, 1);
+ await state.set("cond", 11);
+ state.playSound(MIDDLE);
+ return page("Earth Snake", "Bring me Red Soil from a Red Bear.");
+ }
+ if (cond === 12 && count(state, RUST_GOLD_COIN) && count(state, RED_SOIL)) {
+ await exchange(state, [RUST_GOLD_COIN, RED_SOIL], EARTH_RING, 13);
+ return page("Earth Snake", "Receive the Earth Ring.");
+ }
+ return page("Earth Snake", "Bring me the Rust Gold Coin and Red Soil.");
+ }
+
+ if (npcId === GALLINT) {
+ const signs = [FLAME_EARRING, WIND_BANGLE, WATER_NECKLACE, EARTH_RING];
+ if (signs.every((item) => count(state, item))) {
+ const profession = await quest.awardFirstProfession(state, 11);
+ if (!profession.ok) {
+ return page("Gallint", profession.reason === "level" ? `Reach level ${profession.requiredLevel} to become a Wizard.` : "Your profession could not be granted. Keep your elemental signs and try again.");
+ }
+ for (const item of signs) await quest.takeItem(state.session, item);
+ if (!count(state, BEAD_OF_SEASON)) await quest.giveItem(state.session, BEAD_OF_SEASON, 1);
+ state.playSound(FINISH);
+ await state.exit(false);
+ return page("Gallint", "You have completed the Path to Wizard and become a Wizard.");
+ }
+ return page("Gallint", "Bring me the four elemental signs.");
+ }
+
+ return null;
+ },
+
+ async onKill(state, npc) {
+ if (!state.isStarted()) return;
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ const cond = state.getInt("cond");
+ if (npcId === RATMAN_WARRIOR && cond === 2 && !count(state, KEY_OF_FLAME)) {
+ await quest.giveItem(state.session, KEY_OF_FLAME, 1);
+ await state.set("cond", 3);
+ state.playSound(MIDDLE);
+ } else if (npcId === WATER_SEER && cond === 8 && count(state, SPARKLE_PEBBLE) < 2) {
+ await quest.giveItem(state.session, SPARKLE_PEBBLE, 1);
+ if (count(state, SPARKLE_PEBBLE) >= 2) {
+ await state.set("cond", 9);
+ state.playSound(MIDDLE);
+ } else state.playSound(ITEM);
+ } else if (npcId === RED_BEAR && cond === 11 && !count(state, RED_SOIL)) {
+ await quest.giveItem(state.session, RED_SOIL, 1);
+ await state.set("cond", 12);
+ state.playSound(MIDDLE);
+ }
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 3e928177..b8c8a588 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -46,7 +46,7 @@ for (const itemId of [5789, 5790]) {
);
}
-for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1271]) {
+for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1271, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292]) {
assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
}
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 3e3e9b06..8f839c95 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -50,6 +50,7 @@ const Q163 = require("../src/GameServer/Quest/quests/Q163_LegacyOfThePoet");
const Q401 = require("../src/GameServer/Quest/quests/Q401_PathToWarrior");
const Q402 = require("../src/GameServer/Quest/quests/Q402_PathToKnight");
const Q403 = require("../src/GameServer/Quest/quests/Q403_PathToRogue");
+const Q404 = require("../src/GameServer/Quest/quests/Q404_PathToWizard");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -111,6 +112,8 @@ async function main() {
assert.strictEqual(Q402.eventNpc("herod"), 7031);
assert.strictEqual(Q403.eventNpc("start"), 7379);
assert.strictEqual(Q403.eventNpc("neti"), 7425);
+ assert.strictEqual(Q404.eventNpc("start"), 7391);
+ assert.strictEqual(Q404.eventNpc("feather"), 7410);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -254,11 +257,12 @@ async function main() {
calls.length = 0;
const items = new Map();
let equippedWeapon = 0;
+ let classId = 0;
const setItem = (id, amount) => items.set(id, Math.max(0, amount));
const questState = {
session: {
actor: {
- fetchClassId: () => 0,
+ fetchClassId: () => classId,
fetchLevel: () => 20,
backpack: {
fetchItemFromSelfId: (id) => {
@@ -410,6 +414,43 @@ async function main() {
assert.strictEqual(questState.completed, true, "Q403 must complete after all stolen items are returned");
assert.strictEqual(items.get(1190), 1, "Q403 must retain Bezique's Recommendation as the source reward");
assert.strictEqual(items.get(1185), 0, "Q403 must consume the Wanted Bill at completion");
+
+ items.clear();
+ questState.started = false;
+ questState.completed = false;
+ questState.cond = 0;
+ classId = 10;
+ QuestService.awardFirstProfession = async () => ({ ok: true, targetClassId: 11 });
+ await Q404.onEvent(questState, "start");
+ await Q404.onTalk(questState, { fetchSelfId: () => 7411 });
+ assert.strictEqual(items.get(1280), 1, "Q404 must issue the Map of Luster");
+ await Q404.onKill(questState, { fetchSelfId: () => 359 });
+ assert.strictEqual(items.get(1281), 1, "Q404 must drop the Key of Flame from Ratman Warriors");
+ await Q404.onTalk(questState, { fetchSelfId: () => 7411 });
+ assert.strictEqual(items.get(1282), 1, "Q404 must exchange the fire proof for the Flame Earring");
+ await Q404.onTalk(questState, { fetchSelfId: () => 7412 });
+ assert.strictEqual(items.get(1283), 1, "Q404 must issue the Broken Bronze Mirror");
+ await Q404.onEvent(questState, "feather");
+ assert.strictEqual(items.get(1284), 1, "Q404 must issue the Wind Feather after the mirror dialogue");
+ await Q404.onTalk(questState, { fetchSelfId: () => 7412 });
+ assert.strictEqual(items.get(1285), 1, "Q404 must exchange the wind proof for the Wind Bangle");
+ await Q404.onTalk(questState, { fetchSelfId: () => 7413 });
+ assert.strictEqual(items.get(1286), 1, "Q404 must issue Rama's Diary");
+ await Q404.onKill(questState, { fetchSelfId: () => 5030 });
+ await Q404.onKill(questState, { fetchSelfId: () => 5030 });
+ assert.strictEqual(items.get(1287), 2, "Q404 must collect two Sparkle Pebbles from Water Seers");
+ await Q404.onTalk(questState, { fetchSelfId: () => 7413 });
+ assert.strictEqual(items.get(1288), 1, "Q404 must exchange the water proof for the Water Necklace");
+ await Q404.onTalk(questState, { fetchSelfId: () => 7409 });
+ assert.strictEqual(items.get(1289), 1, "Q404 must issue the Rust Gold Coin");
+ await Q404.onKill(questState, { fetchSelfId: () => 21 });
+ assert.strictEqual(items.get(1290), 1, "Q404 must drop Red Soil from Red Bears");
+ await Q404.onTalk(questState, { fetchSelfId: () => 7409 });
+ assert.strictEqual(items.get(1291), 1, "Q404 must exchange the earth proof for the Earth Ring");
+ await Q404.onTalk(questState, { fetchSelfId: () => 7391 });
+ assert.strictEqual(questState.completed, true, "Q404 must complete after all four elemental signs are returned");
+ assert.strictEqual(items.get(1292), 1, "Q404 must retain the source Bead of Season reward");
+ assert.deepStrictEqual([1282, 1285, 1288, 1291].map((id) => items.get(id) || 0), [0, 0, 0, 0], "Q404 must consume every elemental sign at completion");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 0a6f44537cebd7bce83846b7e93fdb106c5a130d Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:11:45 -0400
Subject: [PATCH 08/23] Implement Path to Cleric quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q405_PathToCleric.js | 172 ++++++++++++++++++
tests/test_quest_availability.js | 2 +-
tests/test_quest_runtime.js | 39 ++++
4 files changed, 213 insertions(+), 1 deletion(-)
create mode 100644 src/GameServer/Quest/quests/Q405_PathToCleric.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index a88f9747..0c594b4e 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -60,6 +60,7 @@ const quests = [
require("./quests/Q402_PathToKnight"),
require("./quests/Q403_PathToRogue"),
require("./quests/Q404_PathToWizard"),
+ require("./quests/Q405_PathToCleric"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q405_PathToCleric.js b/src/GameServer/Quest/quests/Q405_PathToCleric.js
new file mode 100644
index 00000000..529cb0e3
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q405_PathToCleric.js
@@ -0,0 +1,172 @@
+const ZIGAUNT = 7022;
+const GALLINT = 7017;
+const VIVYAN = 7030;
+const SIMPLON = 7253;
+const PRAGA = 7333;
+const LIONEL = 7408;
+
+const RUIN_ZOMBIE = 26;
+const RUIN_ZOMBIE_LEADER = 29;
+
+const LETTER_OF_ORDER_1 = 1191;
+const LETTER_OF_ORDER_2 = 1192;
+const BOOK_OF_LIONEL = 1193;
+const BOOK_OF_VIVYAN = 1194;
+const BOOK_OF_SIMPLON = 1195;
+const BOOK_OF_PRAGA = 1196;
+const CERTIFICATE_OF_GALLINT = 1197;
+const PENDANT_OF_MOTHER = 1198;
+const NECKLACE_OF_MOTHER = 1199;
+const LIONEL_COVENANT = 1200;
+const MARK_OF_FAITH = 1201;
+
+const ACCEPT = "ItemSound.quest_accept";
+const MIDDLE = "ItemSound.quest_middle";
+const FINISH = "ItemSound.quest_finish";
+
+function service() {
+ return invoke("GameServer/Quest/QuestService");
+}
+
+function page(title, text, action = "") {
+ return `${title}:
${text}
${action}`;
+}
+
+function count(state, selfId) {
+ return state.session.actor.backpack.fetchItemFromSelfId(selfId)?.fetchAmount() || 0;
+}
+
+async function replace(state, remove, give, condition) {
+ const quest = service();
+ for (const item of remove) {
+ if (!(await quest.takeItem(state.session, item))) return false;
+ }
+ await quest.giveItem(state.session, give, 1);
+ await state.set("cond", condition);
+ state.playSound(MIDDLE);
+ return true;
+}
+
+module.exports = {
+ id: 405,
+ name: "Path to Cleric",
+ npcs: [ZIGAUNT, GALLINT, VIVYAN, SIMPLON, PRAGA, LIONEL],
+ startNpcs: [ZIGAUNT],
+ killNpcs: [RUIN_ZOMBIE, RUIN_ZOMBIE_LEADER],
+ eventNpc: (event) => (event === "start" ? ZIGAUNT : null),
+
+ async onEvent(state, event) {
+ const actor = state.session.actor;
+ if (event !== "start" || state.isStarted() || state.isCompleted()) return null;
+ if (Number(actor.fetchClassId()) !== 10 || Number(actor.fetchLevel()) < 19 || count(state, MARK_OF_FAITH)) return null;
+ await state.setState("started");
+ await state.set("cond", 1);
+ await service().giveItem(state.session, LETTER_OF_ORDER_1, 1);
+ state.playSound(ACCEPT);
+ return page("Zigaunt", "Bring books from Vivyan, Simplon, and Praga.");
+ },
+
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ const cond = state.getInt("cond");
+
+ if (state.isCompleted()) return page("Zigaunt", "You have already completed the Path to Cleric.");
+ if (!state.isStarted()) {
+ const actor = state.session.actor;
+ if (npcId !== ZIGAUNT || Number(actor.fetchClassId()) !== 10) return page("Quest", "This path is not for your current class.");
+ if (count(state, MARK_OF_FAITH)) return page("Zigaunt", "You have already earned the Mark of Faith.");
+ return Number(actor.fetchLevel()) < 19
+ ? page("Zigaunt", "Come back after reaching level 19.")
+ : page("Zigaunt", "Do you seek the path of a Cleric?", 'Accept the trial.');
+ }
+
+ if (npcId === ZIGAUNT) {
+ if (count(state, LETTER_OF_ORDER_2) && !count(state, LIONEL_COVENANT)) return page("Zigaunt", "Take the second Letter of Order to Lionel.");
+ if (count(state, LETTER_OF_ORDER_1)) {
+ const allBooks = count(state, BOOK_OF_VIVYAN) && count(state, BOOK_OF_SIMPLON) >= 3 && count(state, BOOK_OF_PRAGA);
+ if (!allBooks) return page("Zigaunt", "Bring me the books of Vivyan, Simplon, and Praga.");
+ await replace(state, [LETTER_OF_ORDER_1, BOOK_OF_VIVYAN, BOOK_OF_SIMPLON, BOOK_OF_SIMPLON, BOOK_OF_SIMPLON, BOOK_OF_PRAGA], LETTER_OF_ORDER_2, 3);
+ return page("Zigaunt", "Take this second Letter of Order to Lionel.");
+ }
+ if (count(state, LETTER_OF_ORDER_2) && count(state, LIONEL_COVENANT)) {
+ const profession = await quest.awardFirstProfession(state, 15);
+ if (!profession.ok) {
+ return page("Zigaunt", profession.reason === "level" ? `Reach level ${profession.requiredLevel} to become a Cleric.` : "Your profession could not be granted. Keep Lionel's Covenant and try again.");
+ }
+ await quest.takeItem(state.session, LETTER_OF_ORDER_2);
+ await quest.takeItem(state.session, LIONEL_COVENANT);
+ await quest.giveItem(state.session, MARK_OF_FAITH, 1);
+ state.playSound(FINISH);
+ await state.exit(false);
+ return page("Zigaunt", "You have completed the Path to Cleric and become a Cleric.");
+ }
+ return page("Zigaunt", "Continue your trial.");
+ }
+
+ if (npcId === SIMPLON && cond > 0 && count(state, LETTER_OF_ORDER_1)) {
+ if (!count(state, BOOK_OF_SIMPLON)) {
+ await quest.giveItem(state.session, BOOK_OF_SIMPLON, 3);
+ state.playSound(MIDDLE);
+ return page("Simplon", "Take these books to Zigaunt.");
+ }
+ return page("Simplon", "You already have my books.");
+ }
+
+ if (npcId === VIVYAN && cond > 0 && count(state, LETTER_OF_ORDER_1)) {
+ if (!count(state, BOOK_OF_VIVYAN)) {
+ await quest.giveItem(state.session, BOOK_OF_VIVYAN, 1);
+ state.playSound(MIDDLE);
+ return page("Vivyan", "Take my book to Zigaunt.");
+ }
+ return page("Vivyan", "You already have my book.");
+ }
+
+ if (npcId === PRAGA && cond > 0 && count(state, LETTER_OF_ORDER_1)) {
+ if (!count(state, BOOK_OF_PRAGA) && !count(state, NECKLACE_OF_MOTHER)) {
+ await quest.giveItem(state.session, NECKLACE_OF_MOTHER, 1);
+ state.playSound(MIDDLE);
+ return page("Praga", "Find the Pendant of Mother from Ruin Zombies.");
+ }
+ if (!count(state, BOOK_OF_PRAGA) && count(state, NECKLACE_OF_MOTHER) && !count(state, PENDANT_OF_MOTHER)) return page("Praga", "Bring me the Pendant of Mother.");
+ if (!count(state, BOOK_OF_PRAGA) && count(state, NECKLACE_OF_MOTHER) && count(state, PENDANT_OF_MOTHER)) {
+ await replace(state, [NECKLACE_OF_MOTHER, PENDANT_OF_MOTHER], BOOK_OF_PRAGA, 2);
+ return page("Praga", "Take my book to Zigaunt.");
+ }
+ return page("Praga", "You already have my book.");
+ }
+
+ if (npcId === LIONEL && cond > 0) {
+ if (!count(state, LETTER_OF_ORDER_2)) return page("Lionel", "Return after receiving Zigaunt's second Letter of Order.");
+ if (!count(state, BOOK_OF_LIONEL) && !count(state, LIONEL_COVENANT) && !count(state, CERTIFICATE_OF_GALLINT)) {
+ await quest.giveItem(state.session, BOOK_OF_LIONEL, 1);
+ await state.set("cond", 4);
+ state.playSound(MIDDLE);
+ return page("Lionel", "Bring this book to Gallint.");
+ }
+ if (count(state, BOOK_OF_LIONEL)) return page("Lionel", "Take my book to Gallint.");
+ if (!count(state, LIONEL_COVENANT) && count(state, CERTIFICATE_OF_GALLINT)) {
+ await replace(state, [CERTIFICATE_OF_GALLINT], LIONEL_COVENANT, 6);
+ return page("Lionel", "Take my covenant to Zigaunt.");
+ }
+ return page("Lionel", "Take my covenant to Zigaunt.");
+ }
+
+ if (npcId === GALLINT && cond > 0 && count(state, LETTER_OF_ORDER_2) && !count(state, LIONEL_COVENANT)) {
+ if (count(state, BOOK_OF_LIONEL) && !count(state, CERTIFICATE_OF_GALLINT)) {
+ await replace(state, [BOOK_OF_LIONEL], CERTIFICATE_OF_GALLINT, 5);
+ return page("Gallint", "Take this certificate back to Lionel.");
+ }
+ if (count(state, CERTIFICATE_OF_GALLINT)) return page("Gallint", "Take the certificate back to Lionel.");
+ }
+
+ return page("Quest", "Continue your trial.");
+ },
+
+ async onKill(state, npc) {
+ if (!state.isStarted() || state.getInt("cond") <= 0 || count(state, PENDANT_OF_MOTHER)) return;
+ if (![RUIN_ZOMBIE, RUIN_ZOMBIE_LEADER].includes(Number(npc.fetchSelfId()))) return;
+ await service().giveItem(state.session, PENDANT_OF_MOTHER, 1);
+ state.playSound(MIDDLE);
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index b8c8a588..d3be1ebe 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -46,7 +46,7 @@ for (const itemId of [5789, 5790]) {
);
}
-for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1271, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292]) {
+for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1271, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292]) {
assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
}
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 8f839c95..2d678d5d 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -51,6 +51,7 @@ const Q401 = require("../src/GameServer/Quest/quests/Q401_PathToWarrior");
const Q402 = require("../src/GameServer/Quest/quests/Q402_PathToKnight");
const Q403 = require("../src/GameServer/Quest/quests/Q403_PathToRogue");
const Q404 = require("../src/GameServer/Quest/quests/Q404_PathToWizard");
+const Q405 = require("../src/GameServer/Quest/quests/Q405_PathToCleric");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -114,6 +115,7 @@ async function main() {
assert.strictEqual(Q403.eventNpc("neti"), 7425);
assert.strictEqual(Q404.eventNpc("start"), 7391);
assert.strictEqual(Q404.eventNpc("feather"), 7410);
+ assert.strictEqual(Q405.eventNpc("start"), 7022);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -451,6 +453,43 @@ async function main() {
assert.strictEqual(questState.completed, true, "Q404 must complete after all four elemental signs are returned");
assert.strictEqual(items.get(1292), 1, "Q404 must retain the source Bead of Season reward");
assert.deepStrictEqual([1282, 1285, 1288, 1291].map((id) => items.get(id) || 0), [0, 0, 0, 0], "Q404 must consume every elemental sign at completion");
+
+ items.clear();
+ questState.started = false;
+ questState.completed = false;
+ questState.cond = 0;
+ classId = 10;
+ let awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => {
+ awardedClassId = targetClassId;
+ return { ok: true, targetClassId };
+ };
+ await Q405.onEvent(questState, "start");
+ assert.strictEqual(items.get(1191), 1, "Q405 must issue the first Letter of Order");
+ await Q405.onTalk(questState, { fetchSelfId: () => 7253 });
+ assert.strictEqual(items.get(1195), 3, "Q405 must issue all three Books of Simplon");
+ await Q405.onTalk(questState, { fetchSelfId: () => 7030 });
+ assert.strictEqual(items.get(1194), 1, "Q405 must issue the Book of Vivyan");
+ await Q405.onTalk(questState, { fetchSelfId: () => 7333 });
+ assert.strictEqual(items.get(1199), 1, "Q405 must issue the Necklace of Mother");
+ await Q405.onKill(questState, { fetchSelfId: () => 26 });
+ assert.strictEqual(items.get(1198), 1, "Q405 must drop the Pendant of Mother from Ruin Zombies");
+ await Q405.onTalk(questState, { fetchSelfId: () => 7333 });
+ assert.strictEqual(items.get(1196), 1, "Q405 must exchange Praga's pendant and necklace for her book");
+ await Q405.onTalk(questState, { fetchSelfId: () => 7022 });
+ assert.strictEqual(items.get(1192), 1, "Q405 must exchange the three books for the second Letter of Order");
+ await Q405.onTalk(questState, { fetchSelfId: () => 7408 });
+ assert.strictEqual(items.get(1193), 1, "Q405 must issue Lionel's Book");
+ await Q405.onTalk(questState, { fetchSelfId: () => 7017 });
+ assert.strictEqual(items.get(1197), 1, "Q405 must exchange Lionel's Book for Gallint's certificate");
+ await Q405.onTalk(questState, { fetchSelfId: () => 7408 });
+ assert.strictEqual(items.get(1200), 1, "Q405 must exchange Gallint's certificate for Lionel's Covenant");
+ await Q405.onTalk(questState, { fetchSelfId: () => 7022 });
+ assert.strictEqual(awardedClassId, 15, "Q405 must award the Human Cleric class");
+ assert.strictEqual(questState.completed, true, "Q405 must complete after Lionel's Covenant is returned");
+ assert.strictEqual(items.get(1201), 1, "Q405 must retain the source Mark of Faith reward");
+ assert.strictEqual(items.get(1192), 0, "Q405 must consume the second Letter of Order at completion");
+ assert.strictEqual(items.get(1200), 0, "Q405 must consume Lionel's Covenant at completion");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From b8b28991d63d771dd226cb61d5a326e06cb128dc Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:16:17 -0400
Subject: [PATCH 09/23] Implement Path to Elven Knight quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q406_PathToElvenKnight.js | 139 ++++++++++++++++++
tests/test_quest_availability.js | 2 +-
tests/test_quest_runtime.js | 44 ++++++
4 files changed, 185 insertions(+), 1 deletion(-)
create mode 100644 src/GameServer/Quest/quests/Q406_PathToElvenKnight.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 0c594b4e..8ba71fff 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -61,6 +61,7 @@ const quests = [
require("./quests/Q403_PathToRogue"),
require("./quests/Q404_PathToWizard"),
require("./quests/Q405_PathToCleric"),
+ require("./quests/Q406_PathToElvenKnight"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q406_PathToElvenKnight.js b/src/GameServer/Quest/quests/Q406_PathToElvenKnight.js
new file mode 100644
index 00000000..79782c27
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q406_PathToElvenKnight.js
@@ -0,0 +1,139 @@
+const SORIUS = 7327;
+const KLUTO = 7317;
+
+const TOPAZ_MOBS = [35, 42, 45, 51, 54, 60];
+const OL_MAHUM_NOVICE = 782;
+
+const SORIUS_LETTER = 1202;
+const KLUTO_BOX = 1203;
+const ELVEN_KNIGHT_BROOCH = 1204;
+const TOPAZ_PIECE = 1205;
+const EMERALD_PIECE = 1206;
+const KLUTO_MEMO = 1276;
+
+const ACCEPT = "ItemSound.quest_accept";
+const ITEM = "ItemSound.quest_itemget";
+const MIDDLE = "ItemSound.quest_middle";
+const FINISH = "ItemSound.quest_finish";
+
+function service() {
+ return invoke("GameServer/Quest/QuestService");
+}
+
+function page(title, text, action = "") {
+ return `${title}:
${text}
${action}`;
+}
+
+function count(state, selfId) {
+ return state.session.actor.backpack.fetchItemFromSelfId(selfId)?.fetchAmount() || 0;
+}
+
+async function collect(state, selfId, needed, chance) {
+ const current = count(state, selfId);
+ if (current >= needed || Math.random() >= chance) return false;
+ const amount = service().questDropAmount(1, needed, current);
+ if (!amount) return false;
+ await service().giveItem(state.session, selfId, amount);
+ return current + amount >= needed;
+}
+
+module.exports = {
+ id: 406,
+ name: "Path to Elven Knight",
+ npcs: [SORIUS, KLUTO],
+ startNpcs: [SORIUS],
+ killNpcs: [...TOPAZ_MOBS, OL_MAHUM_NOVICE],
+ eventNpc: (event) => ({ start: SORIUS, kluto: KLUTO })[event] ?? null,
+
+ async onEvent(state, event) {
+ const quest = service();
+ const actor = state.session.actor;
+ if (event === "start" && !state.isStarted() && !state.isCompleted()) {
+ if (Number(actor.fetchClassId()) !== 18 || Number(actor.fetchLevel()) < 19 || count(state, ELVEN_KNIGHT_BROOCH)) return null;
+ await state.setState("started");
+ await state.set("cond", 1);
+ state.playSound(ACCEPT);
+ return page("Sorius", "Hunt skeletons and Spartoi in the Ruins of Agony for twenty Topaz Pieces.");
+ }
+ if (event === "kluto" && state.getInt("cond") === 3 && count(state, SORIUS_LETTER) && !count(state, KLUTO_MEMO)) {
+ if (!(await quest.takeItem(state.session, SORIUS_LETTER))) return null;
+ await quest.giveItem(state.session, KLUTO_MEMO, 1);
+ await state.set("cond", 4);
+ state.playSound(MIDDLE);
+ return page("Kluto", "Bring me twenty Emerald Pieces from Ol Mahum Novices.");
+ }
+ return null;
+ },
+
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ const cond = state.getInt("cond");
+
+ if (state.isCompleted()) return page("Sorius", "You have already completed the Path to Elven Knight.");
+ if (!state.isStarted()) {
+ const actor = state.session.actor;
+ if (npcId !== SORIUS || Number(actor.fetchClassId()) !== 18) return page("Quest", "This path is not for your current class.");
+ if (count(state, ELVEN_KNIGHT_BROOCH)) return page("Sorius", "You have already earned the Elven Knight Brooch.");
+ return Number(actor.fetchLevel()) < 19
+ ? page("Sorius", "Come back after reaching level 19.")
+ : page("Sorius", "Do you seek the path of an Elven Knight?", 'Challenge the test.');
+ }
+
+ if (npcId === SORIUS) {
+ if (cond === 1) return page("Sorius", `Topaz Pieces: ${count(state, TOPAZ_PIECE)}/20.`);
+ if (cond === 2) {
+ if (!count(state, SORIUS_LETTER)) await quest.giveItem(state.session, SORIUS_LETTER, 1);
+ await state.set("cond", 3);
+ state.playSound(MIDDLE);
+ return page("Sorius", "Take my letter to Blacksmith Kluto.");
+ }
+ if ([3, 4, 5].includes(cond)) return page("Sorius", "Complete Kluto's request.");
+ if (cond === 6 && count(state, KLUTO_BOX)) {
+ const profession = await quest.awardFirstProfession(state, 19);
+ if (!profession.ok) {
+ return page("Sorius", profession.reason === "level" ? `Reach level ${profession.requiredLevel} to become an Elven Knight.` : "Your profession could not be granted. Keep Kluto's Box and try again.");
+ }
+ await quest.takeItem(state.session, KLUTO_BOX, -1);
+ if (!count(state, ELVEN_KNIGHT_BROOCH)) await quest.giveItem(state.session, ELVEN_KNIGHT_BROOCH, 1);
+ state.playSound(FINISH);
+ await state.exit(false);
+ return page("Sorius", "You have completed the Path to Elven Knight and become an Elven Knight.");
+ }
+ return page("Sorius", "Continue your trial.");
+ }
+
+ if (npcId === KLUTO) {
+ if (cond === 3 && count(state, SORIUS_LETTER)) return page("Kluto", "Will you accept my request?", 'Ask about the favor.');
+ if (cond === 4) return page("Kluto", `Emerald Pieces: ${count(state, EMERALD_PIECE)}/20.`);
+ if (cond === 5 && count(state, EMERALD_PIECE) >= 20 && count(state, TOPAZ_PIECE) >= 20 && count(state, KLUTO_MEMO)) {
+ await quest.takeItem(state.session, EMERALD_PIECE, -1);
+ await quest.takeItem(state.session, TOPAZ_PIECE, -1);
+ await quest.takeItem(state.session, KLUTO_MEMO, -1);
+ if (!count(state, KLUTO_BOX)) await quest.giveItem(state.session, KLUTO_BOX, 1);
+ await state.set("cond", 6);
+ state.playSound(MIDDLE);
+ return page("Kluto", "Take this box to Sorius.");
+ }
+ if (cond === 6) return page("Kluto", "Take the box to Sorius.");
+ }
+
+ return page("Quest", "Continue your trial.");
+ },
+
+ async onKill(state, npc) {
+ if (!state.isStarted()) return;
+ const npcId = Number(npc.fetchSelfId());
+ if (TOPAZ_MOBS.includes(npcId) && state.getInt("cond") === 1) {
+ if (await collect(state, TOPAZ_PIECE, 20, 0.7)) {
+ await state.set("cond", 2);
+ state.playSound(MIDDLE);
+ } else if (count(state, TOPAZ_PIECE)) state.playSound(ITEM);
+ } else if (npcId === OL_MAHUM_NOVICE && state.getInt("cond") === 4) {
+ if (await collect(state, EMERALD_PIECE, 20, 0.5)) {
+ await state.set("cond", 5);
+ state.playSound(MIDDLE);
+ } else if (count(state, EMERALD_PIECE)) state.playSound(ITEM);
+ }
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index d3be1ebe..00ad1095 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -46,7 +46,7 @@ for (const itemId of [5789, 5790]) {
);
}
-for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1271, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292]) {
+for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1271, 1276, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292]) {
assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
}
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 2d678d5d..2f3c2ea5 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -52,6 +52,7 @@ const Q402 = require("../src/GameServer/Quest/quests/Q402_PathToKnight");
const Q403 = require("../src/GameServer/Quest/quests/Q403_PathToRogue");
const Q404 = require("../src/GameServer/Quest/quests/Q404_PathToWizard");
const Q405 = require("../src/GameServer/Quest/quests/Q405_PathToCleric");
+const Q406 = require("../src/GameServer/Quest/quests/Q406_PathToElvenKnight");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -116,6 +117,8 @@ async function main() {
assert.strictEqual(Q404.eventNpc("start"), 7391);
assert.strictEqual(Q404.eventNpc("feather"), 7410);
assert.strictEqual(Q405.eventNpc("start"), 7022);
+ assert.strictEqual(Q406.eventNpc("start"), 7327);
+ assert.strictEqual(Q406.eventNpc("kluto"), 7317);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -490,6 +493,47 @@ async function main() {
assert.strictEqual(items.get(1201), 1, "Q405 must retain the source Mark of Faith reward");
assert.strictEqual(items.get(1192), 0, "Q405 must consume the second Letter of Order at completion");
assert.strictEqual(items.get(1200), 0, "Q405 must consume Lionel's Covenant at completion");
+
+ items.clear();
+ questState.started = false;
+ questState.completed = false;
+ questState.cond = 0;
+ classId = 18;
+ awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => {
+ awardedClassId = targetClassId;
+ return { ok: true, targetClassId };
+ };
+ await Q406.onEvent(questState, "start");
+ setItem(1205, 19);
+ const originalRandomForKnight = Math.random;
+ Math.random = () => 0;
+ try {
+ await Q406.onKill(questState, { fetchSelfId: () => 35 });
+ } finally {
+ Math.random = originalRandomForKnight;
+ }
+ assert.strictEqual(items.get(1205), 20, "Q406 must drop the final Topaz Piece from the source skeletons");
+ await Q406.onTalk(questState, { fetchSelfId: () => 7327 });
+ assert.strictEqual(items.get(1202), 1, "Q406 must issue Sorius's Letter after the topaz hand-in");
+ await Q406.onEvent(questState, "kluto");
+ assert.strictEqual(items.get(1276), 1, "Q406 must issue Kluto's Memo after accepting his request");
+ setItem(1206, 19);
+ const originalRandomForEmerald = Math.random;
+ Math.random = () => 0;
+ try {
+ await Q406.onKill(questState, { fetchSelfId: () => 782 });
+ } finally {
+ Math.random = originalRandomForEmerald;
+ }
+ assert.strictEqual(items.get(1206), 20, "Q406 must drop the final Emerald Piece from Ol Mahum Novices");
+ await Q406.onTalk(questState, { fetchSelfId: () => 7317 });
+ assert.strictEqual(items.get(1203), 1, "Q406 must exchange both gem sets for Kluto's Box");
+ await Q406.onTalk(questState, { fetchSelfId: () => 7327 });
+ assert.strictEqual(awardedClassId, 19, "Q406 must award the Elven Knight class");
+ assert.strictEqual(questState.completed, true, "Q406 must complete after Kluto's Box is returned");
+ assert.strictEqual(items.get(1204), 1, "Q406 must retain the source Elven Knight Brooch reward");
+ assert.strictEqual(items.get(1203), 0, "Q406 must consume Kluto's Box at completion");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 288e911e6e0bc4d4296b28abe95ac0c32b5c0457 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:18:54 -0400
Subject: [PATCH 10/23] Implement Path to Elven Scout quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q407_PathToElvenScout.js | 160 ++++++++++++++++++
tests/test_quest_availability.js | 2 +-
tests/test_quest_runtime.js | 43 +++++
4 files changed, 205 insertions(+), 1 deletion(-)
create mode 100644 src/GameServer/Quest/quests/Q407_PathToElvenScout.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 8ba71fff..7b52a63b 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -62,6 +62,7 @@ const quests = [
require("./quests/Q404_PathToWizard"),
require("./quests/Q405_PathToCleric"),
require("./quests/Q406_PathToElvenKnight"),
+ require("./quests/Q407_PathToElvenScout"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q407_PathToElvenScout.js b/src/GameServer/Quest/quests/Q407_PathToElvenScout.js
new file mode 100644
index 00000000..04566a08
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q407_PathToElvenScout.js
@@ -0,0 +1,160 @@
+const REISA = 7328;
+const BABENCO = 7334;
+const MORETTI = 7337;
+const PRIAS = 7426;
+
+const OL_MAHUM_PATROL = 53;
+const OL_MAHUM_SENTRY = 5031;
+
+const REISAS_LETTER = 1207;
+const TORN_LETTERS = [1208, 1209, 1210, 1211];
+const MORETTIS_HERB = 1212;
+const MORETTIS_LETTER = 1214;
+const PRIAS_LETTER = 1215;
+const HONORARY_GUARD = 1216;
+const REISAS_RECOMMENDATION = 1217;
+const RUSTED_KEY = 1293;
+
+const ACCEPT = "ItemSound.quest_accept";
+const ITEM = "ItemSound.quest_itemget";
+const MIDDLE = "ItemSound.quest_middle";
+const FINISH = "ItemSound.quest_finish";
+
+function service() {
+ return invoke("GameServer/Quest/QuestService");
+}
+
+function page(title, text, action = "") {
+ return `${title}:
${text}
${action}`;
+}
+
+function count(state, selfId) {
+ return state.session.actor.backpack.fetchItemFromSelfId(selfId)?.fetchAmount() || 0;
+}
+
+function tornLetterCount(state) {
+ return TORN_LETTERS.reduce((total, item) => total + Number(count(state, item) > 0), 0);
+}
+
+module.exports = {
+ id: 407,
+ name: "Path to Elven Scout",
+ npcs: [REISA, BABENCO, MORETTI, PRIAS],
+ startNpcs: [REISA],
+ killNpcs: [OL_MAHUM_PATROL, OL_MAHUM_SENTRY],
+ eventNpc: (event) => ({ start: REISA, moretti: MORETTI })[event] ?? null,
+
+ async onEvent(state, event) {
+ const quest = service();
+ const actor = state.session.actor;
+ if (event === "start" && !state.isStarted() && !state.isCompleted()) {
+ if (Number(actor.fetchClassId()) !== 18 || Number(actor.fetchLevel()) < 19 || count(state, REISAS_RECOMMENDATION)) return null;
+ await state.setState("started");
+ await state.set("cond", 1);
+ await quest.giveItem(state.session, REISAS_LETTER, 1);
+ state.playSound(ACCEPT);
+ return page("Reisa", "Take my letter to Moretti.");
+ }
+ if (event === "moretti" && state.getInt("cond") === 1 && count(state, REISAS_LETTER) && tornLetterCount(state) === 0) {
+ if (!(await quest.takeItem(state.session, REISAS_LETTER))) return null;
+ await state.set("cond", 2);
+ return page("Moretti", "Recover my four torn letters from Ol Mahum Patrols.");
+ }
+ return null;
+ },
+
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ const cond = state.getInt("cond");
+
+ if (state.isCompleted()) return page("Reisa", "You have already completed the Path to Elven Scout.");
+ if (!state.isStarted()) {
+ const actor = state.session.actor;
+ if (npcId !== REISA || Number(actor.fetchClassId()) !== 18) return page("Quest", "This path is not for your current class.");
+ if (count(state, REISAS_RECOMMENDATION)) return page("Reisa", "You have already earned my recommendation.");
+ return Number(actor.fetchLevel()) < 19
+ ? page("Reisa", "Come back after reaching level 19.")
+ : page("Reisa", "Do you seek the path of an Elven Scout?", 'Accept the trial.');
+ }
+
+ if (npcId === REISA) {
+ if (count(state, REISAS_LETTER)) return page("Reisa", "Take my letter to Moretti.");
+ if (count(state, HONORARY_GUARD)) {
+ const profession = await quest.awardFirstProfession(state, 22);
+ if (!profession.ok) {
+ return page("Reisa", profession.reason === "level" ? `Reach level ${profession.requiredLevel} to become an Elven Scout.` : "Your profession could not be granted. Keep the Honorary Guard and try again.");
+ }
+ await quest.takeItem(state.session, HONORARY_GUARD);
+ await quest.giveItem(state.session, REISAS_RECOMMENDATION, 1);
+ state.playSound(FINISH);
+ await state.exit(false);
+ return page("Reisa", "You have completed the Path to Elven Scout and become an Elven Scout.");
+ }
+ return page("Reisa", "Continue Moretti's trial.");
+ }
+
+ if (npcId === MORETTI) {
+ if (count(state, REISAS_LETTER) && tornLetterCount(state) === 0) return page("Moretti", "Reisa sent you?", 'Accept Moretti’s request.');
+ if (!count(state, MORETTIS_LETTER) && !count(state, PRIAS_LETTER) && !count(state, HONORARY_GUARD)) {
+ const letters = tornLetterCount(state);
+ if (!letters) return page("Moretti", "Recover my four torn letters from Ol Mahum Patrols.");
+ if (letters < 4) return page("Moretti", `Torn letters: ${letters}/4.`);
+ for (const item of TORN_LETTERS) await quest.takeItem(state.session, item);
+ await quest.giveItem(state.session, MORETTIS_HERB, 1);
+ await quest.giveItem(state.session, MORETTIS_LETTER, 1);
+ await state.set("cond", 4);
+ state.playSound(MIDDLE);
+ return page("Moretti", "Take my herb and letter to Prias.");
+ }
+ if (count(state, PRIAS_LETTER)) {
+ if (count(state, MORETTIS_HERB)) return page("Moretti", "Take the herb to Prias before returning.");
+ await quest.takeItem(state.session, PRIAS_LETTER);
+ await quest.giveItem(state.session, HONORARY_GUARD, 1);
+ await state.set("cond", 8);
+ state.playSound(MIDDLE);
+ return page("Moretti", "Take this Honorary Guard to Reisa.");
+ }
+ if (count(state, HONORARY_GUARD)) return page("Moretti", "Take the Honorary Guard to Reisa.");
+ return page("Moretti", "Continue your trial.");
+ }
+
+ if (npcId === BABENCO) return page("Babenco", "Ol Mahum Patrols roam east of Gludio.");
+
+ if (npcId === PRIAS && count(state, MORETTIS_LETTER) && count(state, MORETTIS_HERB)) {
+ if (!count(state, RUSTED_KEY)) {
+ await state.set("cond", 5);
+ return page("Prias", "Find the Rusted Key from an Ol Mahum Sentry.");
+ }
+ await quest.takeItem(state.session, RUSTED_KEY);
+ await quest.takeItem(state.session, MORETTIS_HERB);
+ await quest.takeItem(state.session, MORETTIS_LETTER);
+ await quest.giveItem(state.session, PRIAS_LETTER, 1);
+ await state.set("cond", 7);
+ state.playSound(MIDDLE);
+ return page("Prias", "Take my letter to Moretti.");
+ }
+ if (npcId === PRIAS && count(state, PRIAS_LETTER)) return page("Prias", "Take my letter to Moretti.");
+
+ return page("Quest", "Continue your trial.");
+ },
+
+ async onKill(state, npc) {
+ if (!state.isStarted() || state.getInt("cond") <= 0) return;
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ if (npcId === OL_MAHUM_PATROL && tornLetterCount(state) < 4) {
+ const letter = TORN_LETTERS.find((item) => !count(state, item));
+ if (!letter) return;
+ await quest.giveItem(state.session, letter, 1);
+ if (tornLetterCount(state) === 4) {
+ await state.set("cond", 3);
+ state.playSound(MIDDLE);
+ } else state.playSound(ITEM);
+ } else if (npcId === OL_MAHUM_SENTRY && count(state, MORETTIS_HERB) && count(state, MORETTIS_LETTER) && !count(state, RUSTED_KEY) && Math.random() < 0.6) {
+ await quest.giveItem(state.session, RUSTED_KEY, 1);
+ await state.set("cond", 6);
+ state.playSound(MIDDLE);
+ }
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 00ad1095..bb8171c5 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -46,7 +46,7 @@ for (const itemId of [5789, 5790]) {
);
}
-for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1271, 1276, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292]) {
+for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1214, 1215, 1216, 1217, 1271, 1276, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293]) {
assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
}
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 2f3c2ea5..fa731366 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -53,6 +53,7 @@ const Q403 = require("../src/GameServer/Quest/quests/Q403_PathToRogue");
const Q404 = require("../src/GameServer/Quest/quests/Q404_PathToWizard");
const Q405 = require("../src/GameServer/Quest/quests/Q405_PathToCleric");
const Q406 = require("../src/GameServer/Quest/quests/Q406_PathToElvenKnight");
+const Q407 = require("../src/GameServer/Quest/quests/Q407_PathToElvenScout");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -119,6 +120,8 @@ async function main() {
assert.strictEqual(Q405.eventNpc("start"), 7022);
assert.strictEqual(Q406.eventNpc("start"), 7327);
assert.strictEqual(Q406.eventNpc("kluto"), 7317);
+ assert.strictEqual(Q407.eventNpc("start"), 7328);
+ assert.strictEqual(Q407.eventNpc("moretti"), 7337);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -534,6 +537,46 @@ async function main() {
assert.strictEqual(questState.completed, true, "Q406 must complete after Kluto's Box is returned");
assert.strictEqual(items.get(1204), 1, "Q406 must retain the source Elven Knight Brooch reward");
assert.strictEqual(items.get(1203), 0, "Q406 must consume Kluto's Box at completion");
+
+ items.clear();
+ questState.started = false;
+ questState.completed = false;
+ questState.cond = 0;
+ classId = 18;
+ awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => {
+ awardedClassId = targetClassId;
+ return { ok: true, targetClassId };
+ };
+ await Q407.onEvent(questState, "start");
+ assert.strictEqual(items.get(1207), 1, "Q407 must issue Reisa's Letter");
+ await Q407.onEvent(questState, "moretti");
+ for (let i = 0; i < 4; i += 1) await Q407.onKill(questState, { fetchSelfId: () => 53 });
+ assert.deepStrictEqual([1208, 1209, 1210, 1211].map((id) => items.get(id) || 0), [1, 1, 1, 1], "Q407 must recover each distinct torn letter from Ol Mahum Patrols");
+ await Q407.onTalk(questState, { fetchSelfId: () => 7337 });
+ assert.strictEqual(items.get(1212), 1, "Q407 must exchange the torn letters for Moretti's Herb");
+ assert.strictEqual(items.get(1214), 1, "Q407 must issue Moretti's Letter");
+ await Q407.onTalk(questState, { fetchSelfId: () => 7426 });
+ const originalRandomForScout = Math.random;
+ Math.random = () => 0.9;
+ try {
+ await Q407.onKill(questState, { fetchSelfId: () => 5031 });
+ assert.strictEqual(items.get(1293) || 0, 0, "Q407 must preserve the source 60% Rusted Key drop chance");
+ Math.random = () => 0;
+ await Q407.onKill(questState, { fetchSelfId: () => 5031 });
+ } finally {
+ Math.random = originalRandomForScout;
+ }
+ assert.strictEqual(items.get(1293), 1, "Q407 must drop the Rusted Key from Ol Mahum Sentries");
+ await Q407.onTalk(questState, { fetchSelfId: () => 7426 });
+ assert.strictEqual(items.get(1215), 1, "Q407 must exchange the Rusted Key for Prias's Letter");
+ await Q407.onTalk(questState, { fetchSelfId: () => 7337 });
+ assert.strictEqual(items.get(1216), 1, "Q407 must exchange Prias's Letter for the Honorary Guard");
+ await Q407.onTalk(questState, { fetchSelfId: () => 7328 });
+ assert.strictEqual(awardedClassId, 22, "Q407 must award the Elven Scout class");
+ assert.strictEqual(questState.completed, true, "Q407 must complete after the Honorary Guard is returned");
+ assert.strictEqual(items.get(1217), 1, "Q407 must retain Reisa's Recommendation as the source reward");
+ assert.strictEqual(items.get(1216), 0, "Q407 must consume the Honorary Guard at completion");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From bcda19279022cb8ac934066a5af301843560aed9 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:21:54 -0400
Subject: [PATCH 11/23] Implement Path to Elven Wizard quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q408_PathToElvenWizard.js | 206 ++++++++++++++++++
tests/test_quest_availability.js | 2 +-
tests/test_quest_runtime.js | 58 +++++
4 files changed, 266 insertions(+), 1 deletion(-)
create mode 100644 src/GameServer/Quest/quests/Q408_PathToElvenWizard.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 7b52a63b..c2b60c18 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -63,6 +63,7 @@ const quests = [
require("./quests/Q405_PathToCleric"),
require("./quests/Q406_PathToElvenKnight"),
require("./quests/Q407_PathToElvenScout"),
+ require("./quests/Q408_PathToElvenWizard"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q408_PathToElvenWizard.js b/src/GameServer/Quest/quests/Q408_PathToElvenWizard.js
new file mode 100644
index 00000000..0bb285ad
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q408_PathToElvenWizard.js
@@ -0,0 +1,206 @@
+const ROSELLA = 7414;
+const GREENIS = 7157;
+const THALIA = 7371;
+const NORTHWIND = 7423;
+
+const PINCER_SPIDER = 466;
+const DRYAD_ELDER = 19;
+const SUKAR_WERERAT_LEADER = 47;
+
+const ROSELLAS_LETTER = 1218;
+const RED_DOWN = 1219;
+const MAGICAL_POWERS_RUBY = 1220;
+const PURE_AQUAMARINE = 1221;
+const APPETIZING_APPLE = 1222;
+const GOLD_LEAVES = 1223;
+const IMMORTAL_LOVE = 1224;
+const AMETHYST = 1225;
+const NOBILITY_AMETHYST = 1226;
+const FERTILITY_PERIDOT = 1229;
+const ETERNITY_DIAMOND = 1230;
+const CHARM_OF_GRAIN = 1272;
+const SAP_OF_MOTHER_TREE = 1273;
+const LUCKY_POTPOURI = 1274;
+
+const ACCEPT = "ItemSound.quest_accept";
+const ITEM = "ItemSound.quest_itemget";
+const MIDDLE = "ItemSound.quest_middle";
+const FINISH = "ItemSound.quest_finish";
+
+function service() {
+ return invoke("GameServer/Quest/QuestService");
+}
+
+function page(title, text, action = "") {
+ return `${title}:
${text}
${action}`;
+}
+
+function count(state, selfId) {
+ return state.session.actor.backpack.fetchItemFromSelfId(selfId)?.fetchAmount() || 0;
+}
+
+async function collect(state, selfId, needed, chance) {
+ const current = count(state, selfId);
+ if (current >= needed || Math.random() >= chance) return false;
+ const amount = service().questDropAmount(1, needed, current);
+ if (!amount) return false;
+ await service().giveItem(state.session, selfId, amount);
+ return current + amount >= needed;
+}
+
+async function takeAll(state, selfId) {
+ return service().takeItem(state.session, selfId, -1);
+}
+
+function hasAllGems(state) {
+ return [MAGICAL_POWERS_RUBY, PURE_AQUAMARINE, NOBILITY_AMETHYST].every((item) => count(state, item));
+}
+
+module.exports = {
+ id: 408,
+ name: "Path to Elven Wizard",
+ npcs: [ROSELLA, GREENIS, THALIA, NORTHWIND],
+ startNpcs: [ROSELLA],
+ killNpcs: [PINCER_SPIDER, DRYAD_ELDER, SUKAR_WERERAT_LEADER],
+ eventNpc: (event) => ({
+ start: ROSELLA,
+ ruby: ROSELLA,
+ aquamarine: ROSELLA,
+ amethyst: ROSELLA,
+ grain: GREENIS,
+ sap: THALIA,
+ })[event] ?? null,
+
+ async onEvent(state, event) {
+ const quest = service();
+ const actor = state.session.actor;
+ if (event === "start" && !state.isStarted() && !state.isCompleted()) {
+ if (Number(actor.fetchClassId()) !== 25 || Number(actor.fetchLevel()) < 19 || count(state, ETERNITY_DIAMOND)) return null;
+ await state.setState("started");
+ await state.set("cond", 1);
+ if (!count(state, FERTILITY_PERIDOT)) await quest.giveItem(state.session, FERTILITY_PERIDOT, 1);
+ state.playSound(ACCEPT);
+ return page("Rosella", "Recover the Ruby, Aquamarine, and Nobility Amethyst.");
+ }
+ if (!state.isStarted() || !count(state, FERTILITY_PERIDOT)) return null;
+ if (event === "ruby" && !count(state, MAGICAL_POWERS_RUBY) && !count(state, ROSELLAS_LETTER)) {
+ await quest.giveItem(state.session, ROSELLAS_LETTER, 1);
+ await state.set("cond", 2);
+ return page("Rosella", "Take my letter to Greenis.");
+ }
+ if (event === "aquamarine" && !count(state, PURE_AQUAMARINE) && !count(state, APPETIZING_APPLE)) {
+ await quest.giveItem(state.session, APPETIZING_APPLE, 1);
+ return page("Rosella", "Take this apple to Thalia.");
+ }
+ if (event === "amethyst" && !count(state, NOBILITY_AMETHYST) && !count(state, IMMORTAL_LOVE)) {
+ await quest.giveItem(state.session, IMMORTAL_LOVE, 1);
+ return page("Rosella", "Take Immortal Love to Northwind.");
+ }
+ if (event === "grain" && count(state, ROSELLAS_LETTER)) {
+ await takeAll(state, ROSELLAS_LETTER);
+ if (!count(state, CHARM_OF_GRAIN)) await quest.giveItem(state.session, CHARM_OF_GRAIN, 1);
+ return page("Greenis", "Collect five Red Down from Pincer Spiders.");
+ }
+ if (event === "sap" && count(state, APPETIZING_APPLE)) {
+ await takeAll(state, APPETIZING_APPLE);
+ if (!count(state, SAP_OF_MOTHER_TREE)) await quest.giveItem(state.session, SAP_OF_MOTHER_TREE, 1);
+ return page("Thalia", "Collect five Gold Leaves from Dryad Elders.");
+ }
+ return null;
+ },
+
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+
+ if (state.isCompleted()) return page("Rosella", "You have already completed the Path to Elven Wizard.");
+ if (!state.isStarted()) {
+ const actor = state.session.actor;
+ if (npcId !== ROSELLA || Number(actor.fetchClassId()) !== 25) return page("Quest", "This path is not for your current class.");
+ if (count(state, ETERNITY_DIAMOND)) return page("Rosella", "You have already earned the Eternity Diamond.");
+ return Number(actor.fetchLevel()) < 19
+ ? page("Rosella", "Come back after reaching level 19.")
+ : page("Rosella", "Do you seek the path of an Elven Wizard?", 'Accept the trial.');
+ }
+
+ if (npcId === ROSELLA) {
+ if (hasAllGems(state) && count(state, FERTILITY_PERIDOT)) {
+ const profession = await quest.awardFirstProfession(state, 26);
+ if (!profession.ok) return page("Rosella", profession.reason === "level" ? `Reach level ${profession.requiredLevel} to become an Elven Wizard.` : "Your profession could not be granted. Keep your gems and try again.");
+ for (const item of [MAGICAL_POWERS_RUBY, PURE_AQUAMARINE, NOBILITY_AMETHYST, FERTILITY_PERIDOT]) await takeAll(state, item);
+ if (!count(state, ETERNITY_DIAMOND)) await quest.giveItem(state.session, ETERNITY_DIAMOND, 1);
+ state.playSound(FINISH);
+ await state.exit(false);
+ return page("Rosella", "You have completed the Path to Elven Wizard and become an Elven Wizard.");
+ }
+ if (count(state, ROSELLAS_LETTER)) return page("Rosella", "Take my letter to Greenis.");
+ if (count(state, CHARM_OF_GRAIN)) return page("Rosella", `Red Down: ${count(state, RED_DOWN)}/5. Return to Greenis when finished.`);
+ if (count(state, APPETIZING_APPLE)) return page("Rosella", "Take the apple to Thalia.");
+ if (count(state, SAP_OF_MOTHER_TREE)) return page("Rosella", `Gold Leaves: ${count(state, GOLD_LEAVES)}/5. Return to Thalia when finished.`);
+ if (count(state, IMMORTAL_LOVE)) return page("Rosella", "Take Immortal Love to Northwind.");
+ if (count(state, LUCKY_POTPOURI)) return page("Rosella", `Amethysts: ${count(state, AMETHYST)}/2. Return to Northwind when finished.`);
+ const choices = [];
+ if (!count(state, MAGICAL_POWERS_RUBY)) choices.push('Seek the Ruby.');
+ if (!count(state, PURE_AQUAMARINE)) choices.push('Seek the Aquamarine.');
+ if (!count(state, NOBILITY_AMETHYST)) choices.push('Seek the Nobility Amethyst.');
+ return page("Rosella", "Choose an elemental trial.", choices.join("
"));
+ }
+
+ if (npcId === GREENIS) {
+ if (count(state, ROSELLAS_LETTER)) return page("Greenis", "Rosella sent you?", 'Accept Greenis’s request.');
+ if (count(state, CHARM_OF_GRAIN) && count(state, RED_DOWN) < 5) return page("Greenis", `Red Down: ${count(state, RED_DOWN)}/5.`);
+ if (count(state, CHARM_OF_GRAIN) && count(state, RED_DOWN) >= 5) {
+ await takeAll(state, RED_DOWN);
+ await takeAll(state, CHARM_OF_GRAIN);
+ if (!count(state, MAGICAL_POWERS_RUBY)) await quest.giveItem(state.session, MAGICAL_POWERS_RUBY, 1);
+ state.playSound(MIDDLE);
+ return page("Greenis", "Receive the Magical Powers Ruby.");
+ }
+ }
+
+ if (npcId === THALIA) {
+ if (count(state, APPETIZING_APPLE)) return page("Thalia", "Bring me this apple?", 'Offer the apple.');
+ if (count(state, SAP_OF_MOTHER_TREE) && count(state, GOLD_LEAVES) < 5) return page("Thalia", `Gold Leaves: ${count(state, GOLD_LEAVES)}/5.`);
+ if (count(state, SAP_OF_MOTHER_TREE) && count(state, GOLD_LEAVES) >= 5) {
+ await takeAll(state, GOLD_LEAVES);
+ await takeAll(state, SAP_OF_MOTHER_TREE);
+ if (!count(state, PURE_AQUAMARINE)) await quest.giveItem(state.session, PURE_AQUAMARINE, 1);
+ state.playSound(MIDDLE);
+ return page("Thalia", "Receive the Pure Aquamarine.");
+ }
+ }
+
+ if (npcId === NORTHWIND) {
+ if (count(state, IMMORTAL_LOVE)) {
+ await takeAll(state, IMMORTAL_LOVE);
+ if (!count(state, LUCKY_POTPOURI)) await quest.giveItem(state.session, LUCKY_POTPOURI, 1);
+ return page("Northwind", "Collect two Amethysts from Sukar Wererat Leaders.");
+ }
+ if (count(state, LUCKY_POTPOURI) && count(state, AMETHYST) < 2) return page("Northwind", `Amethysts: ${count(state, AMETHYST)}/2.`);
+ if (count(state, LUCKY_POTPOURI) && count(state, AMETHYST) >= 2) {
+ await takeAll(state, AMETHYST);
+ await takeAll(state, LUCKY_POTPOURI);
+ if (!count(state, NOBILITY_AMETHYST)) await quest.giveItem(state.session, NOBILITY_AMETHYST, 1);
+ state.playSound(MIDDLE);
+ return page("Northwind", "Receive the Nobility Amethyst.");
+ }
+ }
+
+ return page("Quest", "Continue your trial.");
+ },
+
+ async onKill(state, npc) {
+ if (!state.isStarted()) return;
+ const npcId = Number(npc.fetchSelfId());
+ if (npcId === PINCER_SPIDER && count(state, CHARM_OF_GRAIN)) {
+ if (await collect(state, RED_DOWN, 5, 0.7)) state.playSound(MIDDLE);
+ else if (count(state, RED_DOWN)) state.playSound(ITEM);
+ } else if (npcId === DRYAD_ELDER && count(state, SAP_OF_MOTHER_TREE)) {
+ if (await collect(state, GOLD_LEAVES, 5, 0.4)) state.playSound(MIDDLE);
+ else if (count(state, GOLD_LEAVES)) state.playSound(ITEM);
+ } else if (npcId === SUKAR_WERERAT_LEADER && count(state, LUCKY_POTPOURI)) {
+ if (await collect(state, AMETHYST, 2, 0.4)) state.playSound(MIDDLE);
+ else if (count(state, AMETHYST)) state.playSound(ITEM);
+ }
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index bb8171c5..03b312fb 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -46,7 +46,7 @@ for (const itemId of [5789, 5790]) {
);
}
-for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1214, 1215, 1216, 1217, 1271, 1276, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293]) {
+for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1222, 1223, 1224, 1225, 1226, 1229, 1230, 1271, 1272, 1273, 1274, 1276, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293]) {
assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
}
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index fa731366..56bd4259 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -54,6 +54,7 @@ const Q404 = require("../src/GameServer/Quest/quests/Q404_PathToWizard");
const Q405 = require("../src/GameServer/Quest/quests/Q405_PathToCleric");
const Q406 = require("../src/GameServer/Quest/quests/Q406_PathToElvenKnight");
const Q407 = require("../src/GameServer/Quest/quests/Q407_PathToElvenScout");
+const Q408 = require("../src/GameServer/Quest/quests/Q408_PathToElvenWizard");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -122,6 +123,9 @@ async function main() {
assert.strictEqual(Q406.eventNpc("kluto"), 7317);
assert.strictEqual(Q407.eventNpc("start"), 7328);
assert.strictEqual(Q407.eventNpc("moretti"), 7337);
+ assert.strictEqual(Q408.eventNpc("start"), 7414);
+ assert.strictEqual(Q408.eventNpc("grain"), 7157);
+ assert.strictEqual(Q408.eventNpc("sap"), 7371);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -577,6 +581,60 @@ async function main() {
assert.strictEqual(questState.completed, true, "Q407 must complete after the Honorary Guard is returned");
assert.strictEqual(items.get(1217), 1, "Q407 must retain Reisa's Recommendation as the source reward");
assert.strictEqual(items.get(1216), 0, "Q407 must consume the Honorary Guard at completion");
+
+ items.clear();
+ questState.started = false;
+ questState.completed = false;
+ questState.cond = 0;
+ classId = 25;
+ awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => {
+ awardedClassId = targetClassId;
+ return { ok: true, targetClassId };
+ };
+ await Q408.onEvent(questState, "start");
+ assert.strictEqual(items.get(1229), 1, "Q408 must issue the Fertility Peridot");
+ await Q408.onEvent(questState, "ruby");
+ await Q408.onEvent(questState, "grain");
+ setItem(1219, 4);
+ const originalRandomForRuby = Math.random;
+ Math.random = () => 0;
+ try {
+ await Q408.onKill(questState, { fetchSelfId: () => 466 });
+ } finally {
+ Math.random = originalRandomForRuby;
+ }
+ await Q408.onTalk(questState, { fetchSelfId: () => 7157 });
+ assert.strictEqual(items.get(1220), 1, "Q408 must exchange Red Down for the Magical Powers Ruby");
+ await Q408.onEvent(questState, "aquamarine");
+ await Q408.onEvent(questState, "sap");
+ setItem(1223, 4);
+ const originalRandomForAquamarine = Math.random;
+ Math.random = () => 0;
+ try {
+ await Q408.onKill(questState, { fetchSelfId: () => 19 });
+ } finally {
+ Math.random = originalRandomForAquamarine;
+ }
+ await Q408.onTalk(questState, { fetchSelfId: () => 7371 });
+ assert.strictEqual(items.get(1221), 1, "Q408 must exchange Gold Leaves for the Pure Aquamarine");
+ await Q408.onEvent(questState, "amethyst");
+ await Q408.onTalk(questState, { fetchSelfId: () => 7423 });
+ setItem(1225, 1);
+ const originalRandomForAmethyst = Math.random;
+ Math.random = () => 0;
+ try {
+ await Q408.onKill(questState, { fetchSelfId: () => 47 });
+ } finally {
+ Math.random = originalRandomForAmethyst;
+ }
+ await Q408.onTalk(questState, { fetchSelfId: () => 7423 });
+ assert.strictEqual(items.get(1226), 1, "Q408 must exchange Amethysts for the Nobility Amethyst");
+ await Q408.onTalk(questState, { fetchSelfId: () => 7414 });
+ assert.strictEqual(awardedClassId, 26, "Q408 must award the Elven Wizard class");
+ assert.strictEqual(questState.completed, true, "Q408 must complete after all three gems are returned");
+ assert.strictEqual(items.get(1230), 1, "Q408 must retain the source Eternity Diamond reward");
+ assert.deepStrictEqual([1220, 1221, 1226, 1229].map((id) => items.get(id) || 0), [0, 0, 0, 0], "Q408 must consume every required gem and the Fertility Peridot");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 0afb3287f55edeab21f3303d4d5b4386815a8966 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:25:57 -0400
Subject: [PATCH 12/23] Implement Path to Elven Oracle quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q409_PathToElvenOracle.js | 145 ++++++++++++++++++
tests/test_quest_availability.js | 2 +-
tests/test_quest_runtime.js | 38 +++++
4 files changed, 185 insertions(+), 1 deletion(-)
create mode 100644 src/GameServer/Quest/quests/Q409_PathToElvenOracle.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index c2b60c18..c84f28a6 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -64,6 +64,7 @@ const quests = [
require("./quests/Q406_PathToElvenKnight"),
require("./quests/Q407_PathToElvenScout"),
require("./quests/Q408_PathToElvenWizard"),
+ require("./quests/Q409_PathToElvenOracle"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q409_PathToElvenOracle.js b/src/GameServer/Quest/quests/Q409_PathToElvenOracle.js
new file mode 100644
index 00000000..cd8a578c
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q409_PathToElvenOracle.js
@@ -0,0 +1,145 @@
+const MANUEL = 7293;
+const ALLANA = 7424;
+const PERRIN = 7428;
+
+const LIZARDMAN_WARRIOR = 5032;
+const LIZARDMAN_SCOUT = 5033;
+const LIZARDMAN = 5034;
+const TAMATO = 5035;
+
+const CRYSTAL_MEDALLION = 1231;
+const MONEY_OF_SWINDLER = 1232;
+const DIARY_OF_ALLANA = 1233;
+const LIZARD_CAPTAIN_ORDER = 1234;
+const LEAF_OF_ORACLE = 1235;
+const HALF_OF_DIARY = 1236;
+const TAMATOS_NECKLACE = 1275;
+
+const ACCEPT = "ItemSound.quest_accept";
+const MIDDLE = "ItemSound.quest_middle";
+const FINISH = "ItemSound.quest_finish";
+
+function service() {
+ return invoke("GameServer/Quest/QuestService");
+}
+
+function page(title, text, action = "") {
+ return `${title}:
${text}
${action}`;
+}
+
+function count(state, selfId) {
+ return state.session.actor.backpack.fetchItemFromSelfId(selfId)?.fetchAmount() || 0;
+}
+
+module.exports = {
+ id: 409,
+ name: "Path to Elven Oracle",
+ npcs: [MANUEL, ALLANA, PERRIN],
+ startNpcs: [MANUEL],
+ killNpcs: [LIZARDMAN_WARRIOR, LIZARDMAN_SCOUT, LIZARDMAN, TAMATO],
+ questSpawns: [LIZARDMAN_WARRIOR, LIZARDMAN_SCOUT, LIZARDMAN, TAMATO],
+ eventNpc: (event) => ({ start: MANUEL, lizardmen: ALLANA, tamato: PERRIN })[event] ?? null,
+
+ async onEvent(state, event) {
+ const quest = service();
+ const actor = state.session.actor;
+ if (event === "start" && !state.isStarted() && !state.isCompleted()) {
+ if (Number(actor.fetchClassId()) !== 25 || Number(actor.fetchLevel()) < 19 || count(state, LEAF_OF_ORACLE)) return null;
+ await state.setState("started");
+ await state.set("cond", 1);
+ await quest.giveItem(state.session, CRYSTAL_MEDALLION, 1);
+ state.playSound(ACCEPT);
+ return page("Manuel", "Investigate the false prophet Allana.");
+ }
+ if (event === "lizardmen" && state.getInt("cond") === 1 && count(state, CRYSTAL_MEDALLION)) {
+ for (const selfId of [LIZARDMAN_WARRIOR, LIZARDMAN_SCOUT, LIZARDMAN]) state.addSpawn(selfId);
+ await state.set("cond", 2);
+ return page("Allana", "The lizardmen have appeared. Defend Allana.");
+ }
+ if (event === "tamato" && state.getInt("cond") >= 4 && count(state, LIZARD_CAPTAIN_ORDER) && !count(state, TAMATOS_NECKLACE)) {
+ state.addSpawn(TAMATO);
+ return page("Perrin", "Tamato is coming to defend Perrin.");
+ }
+ return null;
+ },
+
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ const cond = state.getInt("cond");
+
+ if (state.isCompleted()) return page("Manuel", "You have already completed the Path to Elven Oracle.");
+ if (!state.isStarted()) {
+ const actor = state.session.actor;
+ if (npcId !== MANUEL || Number(actor.fetchClassId()) !== 25) return page("Quest", "This path is not for your current class.");
+ if (count(state, LEAF_OF_ORACLE)) return page("Manuel", "You have already earned the Leaf of Oracle.");
+ return Number(actor.fetchLevel()) < 19
+ ? page("Manuel", "Come back after reaching level 19.")
+ : page("Manuel", "Do you seek the path of an Elven Oracle?", 'Accept the trial.');
+ }
+
+ if (npcId === MANUEL) {
+ if (count(state, MONEY_OF_SWINDLER) && count(state, DIARY_OF_ALLANA) && count(state, LIZARD_CAPTAIN_ORDER) && !count(state, HALF_OF_DIARY)) {
+ const profession = await quest.awardFirstProfession(state, 29);
+ if (!profession.ok) return page("Manuel", profession.reason === "level" ? `Reach level ${profession.requiredLevel} to become an Elven Oracle.` : "Your profession could not be granted. Keep the evidence and try again.");
+ for (const item of [MONEY_OF_SWINDLER, DIARY_OF_ALLANA, LIZARD_CAPTAIN_ORDER, CRYSTAL_MEDALLION]) await quest.takeItem(state.session, item);
+ await quest.giveItem(state.session, LEAF_OF_ORACLE, 1);
+ state.playSound(FINISH);
+ await state.exit(false);
+ return page("Manuel", "You have completed the Path to Elven Oracle and become an Elven Oracle.");
+ }
+ return page("Manuel", "Bring me Allana's diary, Perrin's money, and the Lizard Captain's Order.");
+ }
+
+ if (npcId === ALLANA && count(state, CRYSTAL_MEDALLION)) {
+ if (!count(state, LIZARD_CAPTAIN_ORDER) && !count(state, HALF_OF_DIARY)) {
+ if (cond > 2) return page("Allana", "You have driven the lizardmen away.");
+ return page("Allana", "The lizardmen are threatening me.", 'Defend Allana.');
+ }
+ if (count(state, LIZARD_CAPTAIN_ORDER) && !count(state, HALF_OF_DIARY)) {
+ await quest.giveItem(state.session, HALF_OF_DIARY, 1);
+ await state.set("cond", 4);
+ state.playSound(MIDDLE);
+ return page("Allana", "Take this half of my diary and confront Perrin.");
+ }
+ if (count(state, MONEY_OF_SWINDLER) && count(state, LIZARD_CAPTAIN_ORDER) && count(state, HALF_OF_DIARY) && !count(state, DIARY_OF_ALLANA)) {
+ await quest.takeItem(state.session, HALF_OF_DIARY);
+ await quest.giveItem(state.session, DIARY_OF_ALLANA, 1);
+ await state.set("cond", 7);
+ state.playSound(MIDDLE);
+ return page("Allana", "Take my complete diary to Manuel.");
+ }
+ if (count(state, LIZARD_CAPTAIN_ORDER) && count(state, HALF_OF_DIARY) && !count(state, TAMATOS_NECKLACE)) return page("Allana", "Perrin owes me money. Please find him.");
+ return page("Allana", "Continue your investigation.");
+ }
+
+ if (npcId === PERRIN && count(state, CRYSTAL_MEDALLION) && count(state, LIZARD_CAPTAIN_ORDER)) {
+ if (count(state, TAMATOS_NECKLACE)) {
+ await quest.giveItem(state.session, MONEY_OF_SWINDLER, 1);
+ await quest.takeItem(state.session, TAMATOS_NECKLACE);
+ await state.set("cond", 6);
+ state.playSound(MIDDLE);
+ return page("Perrin", "Take the money to Allana.");
+ }
+ if (count(state, MONEY_OF_SWINDLER)) return page("Perrin", "I have already paid Allana.");
+ return page("Perrin", "You will not get Allana's money.", 'Challenge Tamato.');
+ }
+
+ return page("Quest", "Continue your trial.");
+ },
+
+ async onKill(state, npc) {
+ if (!state.isStarted()) return;
+ const npcId = Number(npc.fetchSelfId());
+ const quest = service();
+ if (npcId === LIZARDMAN_WARRIOR && !count(state, LIZARD_CAPTAIN_ORDER)) {
+ await quest.giveItem(state.session, LIZARD_CAPTAIN_ORDER, 1);
+ await state.set("cond", 3);
+ state.playSound(MIDDLE);
+ } else if (npcId === TAMATO && !count(state, TAMATOS_NECKLACE)) {
+ await quest.giveItem(state.session, TAMATOS_NECKLACE, 1);
+ await state.set("cond", 5);
+ state.playSound(MIDDLE);
+ }
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 03b312fb..7f4145bd 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -33,7 +33,7 @@ for (const quest of QuestService.quests()) {
`Q${quest.id} references NPC ${npcId}, but its template is absent`,
);
assert(
- spawnedIds.has(npcId),
+ spawnedIds.has(npcId) || quest.questSpawns?.includes(npcId),
`Q${quest.id} references NPC ${npcId}, but it has no world spawn`,
);
}
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 56bd4259..f875cdde 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -55,6 +55,7 @@ const Q405 = require("../src/GameServer/Quest/quests/Q405_PathToCleric");
const Q406 = require("../src/GameServer/Quest/quests/Q406_PathToElvenKnight");
const Q407 = require("../src/GameServer/Quest/quests/Q407_PathToElvenScout");
const Q408 = require("../src/GameServer/Quest/quests/Q408_PathToElvenWizard");
+const Q409 = require("../src/GameServer/Quest/quests/Q409_PathToElvenOracle");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -126,6 +127,9 @@ async function main() {
assert.strictEqual(Q408.eventNpc("start"), 7414);
assert.strictEqual(Q408.eventNpc("grain"), 7157);
assert.strictEqual(Q408.eventNpc("sap"), 7371);
+ assert.strictEqual(Q409.eventNpc("start"), 7293);
+ assert.strictEqual(Q409.eventNpc("lizardmen"), 7424);
+ assert.strictEqual(Q409.eventNpc("tamato"), 7428);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -635,6 +639,40 @@ async function main() {
assert.strictEqual(questState.completed, true, "Q408 must complete after all three gems are returned");
assert.strictEqual(items.get(1230), 1, "Q408 must retain the source Eternity Diamond reward");
assert.deepStrictEqual([1220, 1221, 1226, 1229].map((id) => items.get(id) || 0), [0, 0, 0, 0], "Q408 must consume every required gem and the Fertility Peridot");
+
+ items.clear();
+ questState.started = false;
+ questState.completed = false;
+ questState.cond = 0;
+ classId = 25;
+ const spawnedQuestNpcs = [];
+ questState.addSpawn = (selfId) => spawnedQuestNpcs.push(selfId);
+ awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => {
+ awardedClassId = targetClassId;
+ return { ok: true, targetClassId };
+ };
+ await Q409.onEvent(questState, "start");
+ assert.strictEqual(items.get(1231), 1, "Q409 must issue the Crystal Medallion");
+ await Q409.onEvent(questState, "lizardmen");
+ assert.deepStrictEqual(spawnedQuestNpcs, [5032, 5033, 5034], "Q409 must spawn Allana's personal lizardman encounter");
+ await Q409.onKill(questState, { fetchSelfId: () => 5032 });
+ assert.strictEqual(items.get(1234), 1, "Q409 must drop the Lizard Captain Order from the spawned warrior");
+ await Q409.onTalk(questState, { fetchSelfId: () => 7424 });
+ assert.strictEqual(items.get(1236), 1, "Q409 must issue Half of Diary after the lizard encounter");
+ await Q409.onEvent(questState, "tamato");
+ assert.deepStrictEqual(spawnedQuestNpcs, [5032, 5033, 5034, 5035], "Q409 must spawn Tamato as a personal quest encounter");
+ await Q409.onKill(questState, { fetchSelfId: () => 5035 });
+ assert.strictEqual(items.get(1275), 1, "Q409 must drop Tamato's Necklace from the spawned Tamato");
+ await Q409.onTalk(questState, { fetchSelfId: () => 7428 });
+ assert.strictEqual(items.get(1232), 1, "Q409 must exchange Tamato's Necklace for Perrin's money");
+ await Q409.onTalk(questState, { fetchSelfId: () => 7424 });
+ assert.strictEqual(items.get(1233), 1, "Q409 must exchange Half of Diary for Allana's Diary");
+ await Q409.onTalk(questState, { fetchSelfId: () => 7293 });
+ assert.strictEqual(awardedClassId, 29, "Q409 must award the Elven Oracle class");
+ assert.strictEqual(questState.completed, true, "Q409 must complete after the evidence is returned to Manuel");
+ assert.strictEqual(items.get(1235), 1, "Q409 must retain the source Leaf of Oracle reward");
+ assert.deepStrictEqual([1231, 1232, 1233, 1234].map((id) => items.get(id) || 0), [0, 0, 0, 0], "Q409 must consume every final hand-in item");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 1214e54d71b4d9eb472adfc15e70836ed2ad009e Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:51:52 -0400
Subject: [PATCH 13/23] Implement Path to Palus Knight quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q410_PathToPalusKnight.js | 55 +++++++++++++++++++
tests/test_quest_availability.js | 2 +-
tests/test_quest_runtime.js | 30 ++++++++++
4 files changed, 87 insertions(+), 1 deletion(-)
create mode 100644 src/GameServer/Quest/quests/Q410_PathToPalusKnight.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index c84f28a6..3001a3d9 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -65,6 +65,7 @@ const quests = [
require("./quests/Q407_PathToElvenScout"),
require("./quests/Q408_PathToElvenWizard"),
require("./quests/Q409_PathToElvenOracle"),
+ require("./quests/Q410_PathToPalusKnight"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q410_PathToPalusKnight.js b/src/GameServer/Quest/quests/Q410_PathToPalusKnight.js
new file mode 100644
index 00000000..077dd3c9
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q410_PathToPalusKnight.js
@@ -0,0 +1,55 @@
+const VIRGIL = 7329, KALINTA = 7422;
+const LYCANTHROPE = 49, POISON_SPIDER = 38, ARACHNID_TRACKER = 43;
+const PALUS_TALISMAN = 1237, LYCANTHROPE_SKULL = 1238, VIRGILS_LETTER = 1239, MORTE_TALISMAN = 1240, PREDATOR_CARAPACE = 1241, TRIMDEN_SILK = 1242, COFFIN_ETERNAL_REST = 1243, GAZE_OF_ABYSS = 1244;
+const ACCEPT = "ItemSound.quest_accept", ITEM = "ItemSound.quest_itemget", MIDDLE = "ItemSound.quest_middle", FINISH = "ItemSound.quest_finish";
+const service = () => invoke("GameServer/Quest/QuestService");
+const page = (title, text, action = "") => `${title}:
${text}
${action}`;
+const count = (state, id) => state.session.actor.backpack.fetchItemFromSelfId(id)?.fetchAmount() || 0;
+
+module.exports = {
+ id: 410, name: "Path to Palus Knight", npcs: [VIRGIL, KALINTA], startNpcs: [VIRGIL], killNpcs: [LYCANTHROPE, POISON_SPIDER, ARACHNID_TRACKER],
+ eventNpc: (event) => ({ start: VIRGIL, skulls: VIRGIL, morte: KALINTA, coffin: KALINTA })[event] ?? null,
+ async onEvent(state, event) {
+ const quest = service(), actor = state.session.actor;
+ if (event === "start" && !state.isStarted() && !state.isCompleted()) {
+ if (Number(actor.fetchClassId()) !== 31 || Number(actor.fetchLevel()) < 19 || count(state, GAZE_OF_ABYSS)) return null;
+ await state.setState("started"); await state.set("cond", 1); await quest.giveItem(state.session, PALUS_TALISMAN, 1); state.playSound(ACCEPT);
+ return page("Virgil", "Bring me thirteen Lycanthrope Skulls.");
+ }
+ if (event === "skulls" && state.getInt("cond") === 2 && count(state, PALUS_TALISMAN) && count(state, LYCANTHROPE_SKULL) >= 13) {
+ await quest.takeItem(state.session, PALUS_TALISMAN); await quest.takeItem(state.session, LYCANTHROPE_SKULL, -1); await quest.giveItem(state.session, VIRGILS_LETTER, 1); await state.set("cond", 3); state.playSound(MIDDLE);
+ return page("Virgil", "Take my letter to Kalinta.");
+ }
+ if (event === "morte" && state.getInt("cond") === 3 && count(state, VIRGILS_LETTER)) {
+ await quest.takeItem(state.session, VIRGILS_LETTER); await quest.giveItem(state.session, MORTE_TALISMAN, 1); await state.set("cond", 4); state.playSound(MIDDLE);
+ return page("Kalinta", "Bring five Arachnid Tracker Silks and a Predator's Carapace.");
+ }
+ if (event === "coffin" && state.getInt("cond") === 5 && count(state, MORTE_TALISMAN) && count(state, TRIMDEN_SILK) >= 5 && count(state, PREDATOR_CARAPACE)) {
+ for (const id of [MORTE_TALISMAN, TRIMDEN_SILK, PREDATOR_CARAPACE]) await quest.takeItem(state.session, id, -1);
+ await quest.giveItem(state.session, COFFIN_ETERNAL_REST, 1); await state.set("cond", 6); state.playSound(MIDDLE);
+ return page("Kalinta", "Take the Coffin of Eternal Rest to Virgil.");
+ }
+ return null;
+ },
+ async onTalk(state, npc) {
+ const npcId = Number(npc.fetchSelfId()), quest = service(), cond = state.getInt("cond");
+ if (state.isCompleted()) return page("Virgil", "You have already completed the Path to Palus Knight.");
+ if (!state.isStarted()) { const actor = state.session.actor; if (npcId !== VIRGIL || Number(actor.fetchClassId()) !== 31) return page("Quest", "This path is not for your current class."); return Number(actor.fetchLevel()) < 19 ? page("Virgil", "Come back after reaching level 19.") : page("Virgil", "Do you seek the path of a Palus Knight?", 'Accept the trial.'); }
+ if (npcId === VIRGIL) {
+ if (count(state, PALUS_TALISMAN)) return count(state, LYCANTHROPE_SKULL) >= 13 ? page("Virgil", "You have all the skulls.", 'Present the skulls.') : page("Virgil", `Lycanthrope Skulls: ${count(state, LYCANTHROPE_SKULL)}/13.`);
+ if (count(state, COFFIN_ETERNAL_REST)) { const result = await quest.awardFirstProfession(state, 32); if (!result.ok) return page("Virgil", "Your profession could not be granted. Keep the coffin and try again."); await quest.takeItem(state.session, COFFIN_ETERNAL_REST); await quest.giveItem(state.session, GAZE_OF_ABYSS, 1); state.playSound(FINISH); await state.exit(false); return page("Virgil", "You have completed the Path to Palus Knight and become a Palus Knight."); }
+ return page("Virgil", "Complete Kalinta's request.");
+ }
+ if (npcId === KALINTA) {
+ if (count(state, VIRGILS_LETTER)) return page("Kalinta", "Virgil sent you?", 'Accept Kalinta’s request.');
+ if (count(state, MORTE_TALISMAN)) return count(state, TRIMDEN_SILK) >= 5 && count(state, PREDATOR_CARAPACE) ? page("Kalinta", "You have the required trophies.", 'Receive the coffin.') : page("Kalinta", `Arachnid Tracker Silk: ${count(state, TRIMDEN_SILK)}/5; Predator's Carapace: ${count(state, PREDATOR_CARAPACE)}/1.`);
+ }
+ return page("Quest", "Continue your trial.");
+ },
+ async onKill(state, npc) {
+ if (!state.isStarted()) return; const id = Number(npc.fetchSelfId()), quest = service();
+ if (id === LYCANTHROPE && count(state, PALUS_TALISMAN) && count(state, LYCANTHROPE_SKULL) < 13) { await quest.giveItem(state.session, LYCANTHROPE_SKULL, 1); if (count(state, LYCANTHROPE_SKULL) === 13) { await state.set("cond", 2); state.playSound(MIDDLE); } else state.playSound(ITEM); }
+ if (id === POISON_SPIDER && count(state, MORTE_TALISMAN) && !count(state, PREDATOR_CARAPACE)) { await quest.giveItem(state.session, PREDATOR_CARAPACE, 1); if (count(state, TRIMDEN_SILK) >= 5) await state.set("cond", 5); state.playSound(MIDDLE); }
+ if (id === ARACHNID_TRACKER && count(state, MORTE_TALISMAN) && count(state, TRIMDEN_SILK) < 5) { await quest.giveItem(state.session, TRIMDEN_SILK, 1); if (count(state, TRIMDEN_SILK) === 5) { if (count(state, PREDATOR_CARAPACE)) await state.set("cond", 5); state.playSound(MIDDLE); } else state.playSound(ITEM); }
+ },
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 7f4145bd..e617db02 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -46,7 +46,7 @@ for (const itemId of [5789, 5790]) {
);
}
-for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1222, 1223, 1224, 1225, 1226, 1229, 1230, 1271, 1272, 1273, 1274, 1276, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293]) {
+for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1222, 1223, 1224, 1225, 1226, 1229, 1230, 1237, 1238, 1239, 1240, 1241, 1242, 1243, 1244, 1271, 1272, 1273, 1274, 1276, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293]) {
assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
}
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index f875cdde..5481ae76 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -56,6 +56,7 @@ const Q406 = require("../src/GameServer/Quest/quests/Q406_PathToElvenKnight");
const Q407 = require("../src/GameServer/Quest/quests/Q407_PathToElvenScout");
const Q408 = require("../src/GameServer/Quest/quests/Q408_PathToElvenWizard");
const Q409 = require("../src/GameServer/Quest/quests/Q409_PathToElvenOracle");
+const Q410 = require("../src/GameServer/Quest/quests/Q410_PathToPalusKnight");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -130,6 +131,8 @@ async function main() {
assert.strictEqual(Q409.eventNpc("start"), 7293);
assert.strictEqual(Q409.eventNpc("lizardmen"), 7424);
assert.strictEqual(Q409.eventNpc("tamato"), 7428);
+ assert.strictEqual(Q410.eventNpc("start"), 7329);
+ assert.strictEqual(Q410.eventNpc("morte"), 7422);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -673,6 +676,33 @@ async function main() {
assert.strictEqual(questState.completed, true, "Q409 must complete after the evidence is returned to Manuel");
assert.strictEqual(items.get(1235), 1, "Q409 must retain the source Leaf of Oracle reward");
assert.deepStrictEqual([1231, 1232, 1233, 1234].map((id) => items.get(id) || 0), [0, 0, 0, 0], "Q409 must consume every final hand-in item");
+
+ items.clear();
+ questState.started = false;
+ questState.completed = false;
+ questState.cond = 0;
+ classId = 31;
+ awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => {
+ awardedClassId = targetClassId;
+ return { ok: true, targetClassId };
+ };
+ await Q410.onEvent(questState, "start");
+ setItem(1238, 12);
+ await Q410.onKill(questState, { fetchSelfId: () => 49 });
+ assert.strictEqual(items.get(1238), 13, "Q410 must collect the thirteenth Lycanthrope Skull");
+ await Q410.onEvent(questState, "skulls");
+ await Q410.onEvent(questState, "morte");
+ setItem(1242, 4);
+ await Q410.onKill(questState, { fetchSelfId: () => 43 });
+ await Q410.onKill(questState, { fetchSelfId: () => 38 });
+ assert.deepStrictEqual([items.get(1242), items.get(1241)], [5, 1], "Q410 must collect both Kalinta trophies");
+ await Q410.onEvent(questState, "coffin");
+ assert.strictEqual(items.get(1243), 1, "Q410 must exchange the trophies for the Coffin of Eternal Rest");
+ await Q410.onTalk(questState, { fetchSelfId: () => 7329 });
+ assert.strictEqual(awardedClassId, 32, "Q410 must award the Palus Knight class");
+ assert.strictEqual(questState.completed, true, "Q410 must complete after the coffin hand-in");
+ assert.strictEqual(items.get(1244), 1, "Q410 must retain the source Gaze of Abyss reward");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 2688fc73da8ce0092ef213ca423cf3f1a9cc9622 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:54:10 -0400
Subject: [PATCH 14/23] Implement Path to Assassin quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q411_PathToAssassin.js | 20 +++++++++++++++++++
tests/test_quest_availability.js | 4 ++++
tests/test_quest_runtime.js | 17 ++++++++++++++++
4 files changed, 42 insertions(+)
create mode 100644 src/GameServer/Quest/quests/Q411_PathToAssassin.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 3001a3d9..808fb626 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -66,6 +66,7 @@ const quests = [
require("./quests/Q408_PathToElvenWizard"),
require("./quests/Q409_PathToElvenOracle"),
require("./quests/Q410_PathToPalusKnight"),
+ require("./quests/Q411_PathToAssassin"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q411_PathToAssassin.js b/src/GameServer/Quest/quests/Q411_PathToAssassin.js
new file mode 100644
index 00000000..959f6ccd
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q411_PathToAssassin.js
@@ -0,0 +1,20 @@
+const TRISKEL=7416, ARKENIA=7419, LEIKAN=7382, MOONSTONE_BEAST=369, CALPICO=5036;
+const SHILENS_CALL=1245, ARKENIAS_LETTER=1246, LEIKANS_NOTE=1247, ONYX_BEASTS_MOLAR=1248, SHILENS_TEARS=1250, ARKENIA_RECOMMEND=1251, IRON_HEART=1252;
+const ACCEPT="ItemSound.quest_accept", ITEM="ItemSound.quest_itemget", MIDDLE="ItemSound.quest_middle", FINISH="ItemSound.quest_finish";
+const service=()=>invoke("GameServer/Quest/QuestService");
+const page=(title,text,action="")=>`${title}:
${text}
${action}`;
+const count=(state,id)=>state.session.actor.backpack.fetchItemFromSelfId(id)?.fetchAmount()||0;
+module.exports={
+ id:411,name:"Path to Assassin",npcs:[TRISKEL,ARKENIA,LEIKAN],startNpcs:[TRISKEL],killNpcs:[MOONSTONE_BEAST,CALPICO],
+ eventNpc:(event)=>({start:TRISKEL,arkenia:ARKENIA,leikan:LEIKAN})[event]??null,
+ async onEvent(state,event){const quest=service(),actor=state.session.actor;
+ if(event==="start"&&!state.isStarted()&&!state.isCompleted()){if(Number(actor.fetchClassId())!==31||Number(actor.fetchLevel())<19||count(state,IRON_HEART))return null;await state.setState("started");await state.set("cond",1);await quest.giveItem(state.session,SHILENS_CALL,1);state.playSound(ACCEPT);return page("Triskel","Take Shilen's Call to Arkenia.");}
+ if(event==="arkenia"&&count(state,SHILENS_CALL)){await quest.takeItem(state.session,SHILENS_CALL);await quest.giveItem(state.session,ARKENIAS_LETTER,1);await state.set("cond",2);state.playSound(MIDDLE);return page("Arkenia","Take my letter to Leikan.");}
+ if(event==="leikan"&&count(state,ARKENIAS_LETTER)){await quest.takeItem(state.session,ARKENIAS_LETTER);await quest.giveItem(state.session,LEIKANS_NOTE,1);await state.set("cond",3);state.playSound(MIDDLE);return page("Leikan","Bring ten Moonstone Beast Molars.");}return null;},
+ async onTalk(state,npc){const id=Number(npc.fetchSelfId()),quest=service();if(state.isCompleted())return page("Triskel","You have already completed the Path to Assassin.");if(!state.isStarted()){const a=state.session.actor;if(id!==TRISKEL||Number(a.fetchClassId())!==31)return page("Quest","This path is not for your current class.");return Number(a.fetchLevel())<19?page("Triskel","Come back after reaching level 19."):page("Triskel","Do you seek the path of an Assassin?",'Accept the trial.');}
+ if(id===TRISKEL){if(count(state,ARKENIA_RECOMMEND)){const result=await quest.awardFirstProfession(state,35);if(!result.ok)return page("Triskel","Your profession could not be granted. Keep the recommendation and try again.");await quest.takeItem(state.session,ARKENIA_RECOMMEND);await quest.giveItem(state.session,IRON_HEART,1);state.playSound(FINISH);await state.exit(false);return page("Triskel","You have completed the Path to Assassin and become an Assassin.");}return page("Triskel","Complete Arkenia's request.");}
+ if(id===ARKENIA){if(count(state,SHILENS_CALL))return page("Arkenia","Triskel sent you?",'Present Shilen’s Call.');if(count(state,SHILENS_TEARS)){await quest.takeItem(state.session,SHILENS_TEARS);await quest.giveItem(state.session,ARKENIA_RECOMMEND,1);await state.set("cond",7);state.playSound(MIDDLE);return page("Arkenia","Take my recommendation to Triskel.");}return page("Arkenia","Continue Leikan's trial.");}
+ if(id===LEIKAN){if(count(state,ARKENIAS_LETTER))return page("Leikan","Arkenia sent you?",'Accept Leikan’s request.');if(count(state,LEIKANS_NOTE)&&count(state,ONYX_BEASTS_MOLAR)>=10){await quest.takeItem(state.session,ONYX_BEASTS_MOLAR,10);await quest.takeItem(state.session,LEIKANS_NOTE);await state.set("cond",5);state.playSound(MIDDLE);return page("Leikan","Now hunt Calpico for Shilen's Tears.");}if(count(state,LEIKANS_NOTE))return page("Leikan",`Moonstone Beast Molars: ${count(state,ONYX_BEASTS_MOLAR)}/10.`);return page("Leikan","Hunt Calpico for Shilen's Tears.");}
+ return page("Quest","Continue your trial.");},
+ async onKill(state,npc){if(!state.isStarted())return;const id=Number(npc.fetchSelfId()),quest=service();if(id===MOONSTONE_BEAST&&count(state,LEIKANS_NOTE)&&count(state,ONYX_BEASTS_MOLAR)<10){await quest.giveItem(state.session,ONYX_BEASTS_MOLAR,1);if(count(state,ONYX_BEASTS_MOLAR)===10){await state.set("cond",4);state.playSound(MIDDLE);}else state.playSound(ITEM);}else if(id===CALPICO&&!count(state,SHILENS_TEARS)){await quest.giveItem(state.session,SHILENS_TEARS,1);await state.set("cond",6);state.playSound(MIDDLE);}}
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index e617db02..1b619b93 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -50,4 +50,8 @@ for (const itemId of [1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1161, 1162
assert(itemIds.has(itemId), `Q401 requires missing quest item ${itemId}`);
}
+for (const itemId of [1245, 1246, 1247, 1248, 1250, 1251, 1252]) {
+ assert(itemIds.has(itemId), `Q411 requires missing quest item ${itemId}`);
+}
+
console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 5481ae76..914622bc 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -57,6 +57,7 @@ const Q407 = require("../src/GameServer/Quest/quests/Q407_PathToElvenScout");
const Q408 = require("../src/GameServer/Quest/quests/Q408_PathToElvenWizard");
const Q409 = require("../src/GameServer/Quest/quests/Q409_PathToElvenOracle");
const Q410 = require("../src/GameServer/Quest/quests/Q410_PathToPalusKnight");
+const Q411 = require("../src/GameServer/Quest/quests/Q411_PathToAssassin");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -133,6 +134,8 @@ async function main() {
assert.strictEqual(Q409.eventNpc("tamato"), 7428);
assert.strictEqual(Q410.eventNpc("start"), 7329);
assert.strictEqual(Q410.eventNpc("morte"), 7422);
+ assert.strictEqual(Q411.eventNpc("start"), 7416);
+ assert.strictEqual(Q411.eventNpc("arkenia"), 7419);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -703,6 +706,20 @@ async function main() {
assert.strictEqual(awardedClassId, 32, "Q410 must award the Palus Knight class");
assert.strictEqual(questState.completed, true, "Q410 must complete after the coffin hand-in");
assert.strictEqual(items.get(1244), 1, "Q410 must retain the source Gaze of Abyss reward");
+
+ items.clear(); questState.started = false; questState.completed = false; questState.cond = 0; classId = 31; awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => { awardedClassId = targetClassId; return { ok: true, targetClassId }; };
+ await Q411.onEvent(questState, "start");
+ await Q411.onEvent(questState, "arkenia");
+ await Q411.onEvent(questState, "leikan");
+ setItem(1248, 9); await Q411.onKill(questState, { fetchSelfId: () => 369 });
+ await Q411.onTalk(questState, { fetchSelfId: () => 7382 });
+ await Q411.onKill(questState, { fetchSelfId: () => 5036 });
+ await Q411.onTalk(questState, { fetchSelfId: () => 7419 });
+ await Q411.onTalk(questState, { fetchSelfId: () => 7416 });
+ assert.strictEqual(awardedClassId, 35, "Q411 must award the Assassin class");
+ assert.strictEqual(questState.completed, true, "Q411 must complete after Arkenia's recommendation is returned");
+ assert.strictEqual(items.get(1252), 1, "Q411 must retain the source Iron Heart reward");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 4f63984452a889e80149b70801a57e433314c5d4 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:56:43 -0400
Subject: [PATCH 15/23] Implement Path to Dark Wizard quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q412_PathToDarkWizard.js | 11 +++++++++++
tests/test_quest_availability.js | 4 ++++
tests/test_quest_runtime.js | 14 ++++++++++++++
4 files changed, 30 insertions(+)
create mode 100644 src/GameServer/Quest/quests/Q412_PathToDarkWizard.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 808fb626..a699dba9 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -67,6 +67,7 @@ const quests = [
require("./quests/Q409_PathToElvenOracle"),
require("./quests/Q410_PathToPalusKnight"),
require("./quests/Q411_PathToAssassin"),
+ require("./quests/Q412_PathToDarkWizard"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q412_PathToDarkWizard.js b/src/GameServer/Quest/quests/Q412_PathToDarkWizard.js
new file mode 100644
index 00000000..66fd7424
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q412_PathToDarkWizard.js
@@ -0,0 +1,11 @@
+const VARIKA=7421, CHARKEREN=7415, ANNIKA=7418, ARKENIA=7419;
+const MARSH_ZOMBIE=15, KNEE_MOBS=[22,517,518], SKELETON_SCOUT=45;
+const SEED_OF_ANGER=1253, SEED_OF_DESPAIR=1254, SEED_OF_HORROR=1255, SEED_OF_LUNACY=1256, FAMILYS_ASHES=1257, KNEE_BONE=1259, HEART_OF_LUNACY=1260, JEWEL_OF_DARKNESS=1261, LUCKY_KEY=1277, CANDLE=1278, HUB_SCENT=1279;
+const ACCEPT="ItemSound.quest_accept",ITEM="ItemSound.quest_itemget",MIDDLE="ItemSound.quest_middle",FINISH="ItemSound.quest_finish";
+const service=()=>invoke("GameServer/Quest/QuestService"),page=(t,x,a="")=>`${t}:
${x}
${a}`,count=(s,i)=>s.session.actor.backpack.fetchItemFromSelfId(i)?.fetchAmount()||0;
+async function collect(s,id,n){if(count(s,id)>=n||Math.random()>=.5)return false;await service().giveItem(s.session,id,1);return count(s,id)>=n;}
+module.exports={id:412,name:"Path to Dark Wizard",npcs:[VARIKA,CHARKEREN,ANNIKA,ARKENIA],startNpcs:[VARIKA],killNpcs:[MARSH_ZOMBIE,...KNEE_MOBS,SKELETON_SCOUT],eventNpc:e=>({start:VARIKA,anger:VARIKA,horror:VARIKA,lunacy:VARIKA,key:CHARKEREN,candle:ANNIKA})[e]??null,
+async onEvent(s,e){const q=service(),a=s.session.actor;if(e==="start"&&!s.isStarted()&&!s.isCompleted()){if(Number(a.fetchClassId())!==38||Number(a.fetchLevel())<19||count(s,JEWEL_OF_DARKNESS))return null;await s.setState("started");await s.set("cond",1);await q.giveItem(s.session,SEED_OF_DESPAIR,1);s.playSound(ACCEPT);return page("Varika","Recover the three remaining Seeds.");}if(!s.isStarted())return null;if(e==="lunacy"&&!count(s,SEED_OF_LUNACY)&&!count(s,HUB_SCENT)){await q.giveItem(s.session,HUB_SCENT,1);return page("Varika","Collect three Hearts of Lunacy from Skeleton Scouts.");}if(e==="key"&&!count(s,SEED_OF_ANGER)&&!count(s,LUCKY_KEY)){await q.giveItem(s.session,LUCKY_KEY,1);return page("Charkeren","Collect three Family's Ashes from Marsh Zombies.");}if(e==="candle"&&!count(s,SEED_OF_HORROR)&&!count(s,CANDLE)){await q.giveItem(s.session,CANDLE,1);return page("Annika","Collect two Knee Bones from skeletons.");}return null;},
+async onTalk(s,n){const id=Number(n.fetchSelfId()),q=service();if(s.isCompleted())return page("Varika","You have already completed the Path to Dark Wizard.");if(!s.isStarted()){const a=s.session.actor;if(id!==VARIKA||Number(a.fetchClassId())!==38)return page("Quest","This path is not for your current class.");return Number(a.fetchLevel())<19?page("Varika","Come back after reaching level 19."):page("Varika","Do you seek the path of a Dark Wizard?",'Accept the trial.');}if(id===VARIKA){if([SEED_OF_DESPAIR,SEED_OF_ANGER,SEED_OF_HORROR,SEED_OF_LUNACY].every(x=>count(s,x))){const r=await q.awardFirstProfession(s,39);if(!r.ok)return page("Varika","Your profession could not be granted. Keep the Seeds and try again.");for(const x of [SEED_OF_DESPAIR,SEED_OF_ANGER,SEED_OF_HORROR,SEED_OF_LUNACY])await q.takeItem(s.session,x);await q.giveItem(s.session,JEWEL_OF_DARKNESS,1);s.playSound(FINISH);await s.exit(false);return page("Varika","You have completed the Path to Dark Wizard and become a Dark Wizard.");}const links=[];if(!count(s,SEED_OF_ANGER))links.push('Seek the Seed of Anger.');if(!count(s,SEED_OF_HORROR))links.push('Seek the Seed of Horror.');if(!count(s,SEED_OF_LUNACY))links.push('Seek the Seed of Lunacy.');return page("Varika","Choose a trial.",links.join("
"));}if(id===CHARKEREN){if(!count(s,LUCKY_KEY)&&!count(s,SEED_OF_ANGER))return page("Charkeren","Accept my request.",'Receive the Lucky Key.');if(count(s,LUCKY_KEY)&&count(s,FAMILYS_ASHES)>=3){await q.takeItem(s.session,LUCKY_KEY);await q.takeItem(s.session,FAMILYS_ASHES,-1);await q.giveItem(s.session,SEED_OF_ANGER,1);s.playSound(MIDDLE);return page("Charkeren","Receive the Seed of Anger.");}return page("Charkeren",`Family's Ashes: ${count(s,FAMILYS_ASHES)}/3.`);}if(id===ANNIKA){if(!count(s,CANDLE)&&!count(s,SEED_OF_HORROR))return page("Annika","Accept my request.",'Receive the Candle.');if(count(s,CANDLE)&&count(s,KNEE_BONE)>=2){await q.takeItem(s.session,CANDLE);await q.takeItem(s.session,KNEE_BONE,-1);await q.giveItem(s.session,SEED_OF_HORROR,1);s.playSound(MIDDLE);return page("Annika","Receive the Seed of Horror.");}return page("Annika",`Knee Bones: ${count(s,KNEE_BONE)}/2.`);}if(id===ARKENIA){if(!count(s,HUB_SCENT)&&!count(s,SEED_OF_LUNACY)){await q.giveItem(s.session,HUB_SCENT,1);return page("Arkenia","Collect three Hearts of Lunacy.");}if(count(s,HUB_SCENT)&&count(s,HEART_OF_LUNACY)>=3){await q.takeItem(s.session,HUB_SCENT);await q.takeItem(s.session,HEART_OF_LUNACY,3);await q.giveItem(s.session,SEED_OF_LUNACY,1);s.playSound(MIDDLE);return page("Arkenia","Receive the Seed of Lunacy.");}return page("Arkenia",`Hearts of Lunacy: ${count(s,HEART_OF_LUNACY)}/3.`);}return page("Quest","Continue your trial.");},
+async onKill(s,n){if(!s.isStarted())return;const id=Number(n.fetchSelfId());if(id===MARSH_ZOMBIE&&count(s,LUCKY_KEY)){if(await collect(s,FAMILYS_ASHES,3))s.playSound(MIDDLE);else if(count(s,FAMILYS_ASHES))s.playSound(ITEM);}else if(KNEE_MOBS.includes(id)&&count(s,CANDLE)){if(await collect(s,KNEE_BONE,2))s.playSound(MIDDLE);else if(count(s,KNEE_BONE))s.playSound(ITEM);}else if(id===SKELETON_SCOUT&&count(s,HUB_SCENT)){if(await collect(s,HEART_OF_LUNACY,3))s.playSound(MIDDLE);else if(count(s,HEART_OF_LUNACY))s.playSound(ITEM);}}
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 1b619b93..68c371e1 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -54,4 +54,8 @@ for (const itemId of [1245, 1246, 1247, 1248, 1250, 1251, 1252]) {
assert(itemIds.has(itemId), `Q411 requires missing quest item ${itemId}`);
}
+for (const itemId of [1253, 1254, 1255, 1256, 1257, 1259, 1260, 1261, 1277, 1278, 1279]) {
+ assert(itemIds.has(itemId), `Q412 requires missing quest item ${itemId}`);
+}
+
console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 914622bc..c41641a9 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -58,6 +58,7 @@ const Q408 = require("../src/GameServer/Quest/quests/Q408_PathToElvenWizard");
const Q409 = require("../src/GameServer/Quest/quests/Q409_PathToElvenOracle");
const Q410 = require("../src/GameServer/Quest/quests/Q410_PathToPalusKnight");
const Q411 = require("../src/GameServer/Quest/quests/Q411_PathToAssassin");
+const Q412 = require("../src/GameServer/Quest/quests/Q412_PathToDarkWizard");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -136,6 +137,8 @@ async function main() {
assert.strictEqual(Q410.eventNpc("morte"), 7422);
assert.strictEqual(Q411.eventNpc("start"), 7416);
assert.strictEqual(Q411.eventNpc("arkenia"), 7419);
+ assert.strictEqual(Q412.eventNpc("start"), 7421);
+ assert.strictEqual(Q412.eventNpc("key"), 7415);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -720,6 +723,17 @@ async function main() {
assert.strictEqual(awardedClassId, 35, "Q411 must award the Assassin class");
assert.strictEqual(questState.completed, true, "Q411 must complete after Arkenia's recommendation is returned");
assert.strictEqual(items.get(1252), 1, "Q411 must retain the source Iron Heart reward");
+
+ items.clear(); questState.started = false; questState.completed = false; questState.cond = 0; classId = 38; awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => { awardedClassId = targetClassId; return { ok: true, targetClassId }; };
+ await Q412.onEvent(questState, "start");
+ await Q412.onEvent(questState, "key"); setItem(1257, 3); await Q412.onTalk(questState, { fetchSelfId: () => 7415 });
+ await Q412.onEvent(questState, "candle"); setItem(1259, 2); await Q412.onTalk(questState, { fetchSelfId: () => 7418 });
+ await Q412.onEvent(questState, "lunacy"); setItem(1260, 3); await Q412.onTalk(questState, { fetchSelfId: () => 7419 });
+ await Q412.onTalk(questState, { fetchSelfId: () => 7421 });
+ assert.strictEqual(awardedClassId, 39, "Q412 must award the Dark Wizard class");
+ assert.strictEqual(questState.completed, true, "Q412 must complete after all four Seeds are returned");
+ assert.strictEqual(items.get(1261), 1, "Q412 must retain the source Jewel of Darkness reward");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From b1a21a080a6000a9c8da1148976e504299b672d1 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:58:43 -0400
Subject: [PATCH 16/23] Implement Path to Shillien Oracle quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q413_PathToShillienOracle.js | 9 +++++++++
tests/test_quest_availability.js | 4 ++++
tests/test_quest_runtime.js | 17 +++++++++++++++++
4 files changed, 31 insertions(+)
create mode 100644 src/GameServer/Quest/quests/Q413_PathToShillienOracle.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index a699dba9..0c1bee55 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -68,6 +68,7 @@ const quests = [
require("./quests/Q410_PathToPalusKnight"),
require("./quests/Q411_PathToAssassin"),
require("./quests/Q412_PathToDarkWizard"),
+ require("./quests/Q413_PathToShillienOracle"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q413_PathToShillienOracle.js b/src/GameServer/Quest/quests/Q413_PathToShillienOracle.js
new file mode 100644
index 00000000..bfe204c4
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q413_PathToShillienOracle.js
@@ -0,0 +1,9 @@
+const SIDRA=7330,TALBOT=7377,ADONIUS=7375,DARK_SUCCUBUS=776,ASHEN_BONE_MOBS=[457,458,514,515];
+const SIDRAS_LETTER=1262,BLANK_SHEET=1263,BLOODY_RUNE=1264,GARMIEL_BOOK=1265,PRAYER_OF_ADON=1266,PENITENTS_MARK=1267,ASHEN_BONES=1268,ANDARIEL_BOOK=1269,ORB_OF_ABYSS=1270;
+const ACCEPT="ItemSound.quest_accept",ITEM="ItemSound.quest_itemget",MIDDLE="ItemSound.quest_middle",FINISH="ItemSound.quest_finish";
+const service=()=>invoke("GameServer/Quest/QuestService"),page=(t,x,a="")=>`${t}:
${x}
${a}`,count=(s,i)=>s.session.actor.backpack.fetchItemFromSelfId(i)?.fetchAmount()||0;
+module.exports={id:413,name:"Path to Shillien Oracle",npcs:[SIDRA,TALBOT,ADONIUS],startNpcs:[SIDRA],killNpcs:[DARK_SUCCUBUS,...ASHEN_BONE_MOBS],eventNpc:e=>({start:SIDRA,sheets:TALBOT,mark:ADONIUS})[e]??null,
+async onEvent(s,e){const q=service(),a=s.session.actor;if(e==="start"&&!s.isStarted()&&!s.isCompleted()){if(Number(a.fetchClassId())!==38||Number(a.fetchLevel())<19||count(s,ORB_OF_ABYSS))return null;await s.setState("started");await s.set("cond",1);await q.giveItem(s.session,SIDRAS_LETTER,1);s.playSound(ACCEPT);return page("Sidra","Take my letter to Talbot.");}if(e==="sheets"&&count(s,SIDRAS_LETTER)){await q.takeItem(s.session,SIDRAS_LETTER);await q.giveItem(s.session,BLANK_SHEET,5);await s.set("cond",2);s.playSound(MIDDLE);return page("Talbot","Collect five Bloody Runes from Dark Succubi.");}if(e==="mark"&&count(s,PRAYER_OF_ADON)){await q.takeItem(s.session,PRAYER_OF_ADON);await q.giveItem(s.session,PENITENTS_MARK,1);await s.set("cond",5);s.playSound(MIDDLE);return page("Adonius","Collect ten Ashen Bones.");}return null;},
+async onTalk(s,n){const id=Number(n.fetchSelfId()),q=service();if(s.isCompleted())return page("Sidra","You have already completed the Path to Shillien Oracle.");if(!s.isStarted()){const a=s.session.actor;if(id!==SIDRA||Number(a.fetchClassId())!==38)return page("Quest","This path is not for your current class.");return Number(a.fetchLevel())<19?page("Sidra","Come back after reaching level 19."):page("Sidra","Do you seek the path of a Shillien Oracle?",'Accept the trial.');}if(id===SIDRA){if(count(s,ANDARIEL_BOOK)&&count(s,GARMIEL_BOOK)){const r=await q.awardFirstProfession(s,42);if(!r.ok)return page("Sidra","Your profession could not be granted. Keep the books and try again.");await q.takeItem(s.session,ANDARIEL_BOOK);await q.takeItem(s.session,GARMIEL_BOOK);await q.giveItem(s.session,ORB_OF_ABYSS,1);s.playSound(FINISH);await s.exit(false);return page("Sidra","You have completed the Path to Shillien Oracle and become a Shillien Oracle.");}return page("Sidra","Complete Talbot and Adonius's trials.");}if(id===TALBOT){if(count(s,SIDRAS_LETTER))return page("Talbot","Sidra sent you?",'Receive the blank sheets.');if(count(s,BLANK_SHEET)>0)return page("Talbot",`Bloody Runes: ${count(s,BLOODY_RUNE)}/5.`);if(count(s,BLOODY_RUNE)>=5){await q.takeItem(s.session,BLOODY_RUNE,-1);await q.giveItem(s.session,GARMIEL_BOOK,1);await q.giveItem(s.session,PRAYER_OF_ADON,1);await s.set("cond",4);s.playSound(MIDDLE);return page("Talbot","Take the Prayer of Adonius to Adonius.");}return page("Talbot","Continue Adonius's trial.");}if(id===ADONIUS){if(count(s,PRAYER_OF_ADON))return page("Adonius","Present the prayer.",'Receive the Penitent’s Mark.');if(count(s,PENITENTS_MARK)&&count(s,ASHEN_BONES)>=10){await q.takeItem(s.session,PENITENTS_MARK);await q.takeItem(s.session,ASHEN_BONES,-1);await q.giveItem(s.session,ANDARIEL_BOOK,1);await s.set("cond",7);s.playSound(MIDDLE);return page("Adonius","Take Andariel's Book to Sidra.");}if(count(s,PENITENTS_MARK))return page("Adonius",`Ashen Bones: ${count(s,ASHEN_BONES)}/10.`);return page("Adonius","Continue Talbot's trial.");}return page("Quest","Continue your trial.");},
+async onKill(s,n){if(!s.isStarted())return;const id=Number(n.fetchSelfId()),q=service();if(id===DARK_SUCCUBUS&&count(s,BLANK_SHEET)>0){await q.takeItem(s.session,BLANK_SHEET);await q.giveItem(s.session,BLOODY_RUNE,1);if(!count(s,BLANK_SHEET)){await s.set("cond",3);s.playSound(MIDDLE);}else s.playSound(ITEM);}else if(ASHEN_BONE_MOBS.includes(id)&&count(s,PENITENTS_MARK)&&count(s,ASHEN_BONES)<10){await q.giveItem(s.session,ASHEN_BONES,1);if(count(s,ASHEN_BONES)===10){await s.set("cond",6);s.playSound(MIDDLE);}else s.playSound(ITEM);}}
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 68c371e1..b02df16b 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -58,4 +58,8 @@ for (const itemId of [1253, 1254, 1255, 1256, 1257, 1259, 1260, 1261, 1277, 1278
assert(itemIds.has(itemId), `Q412 requires missing quest item ${itemId}`);
}
+for (const itemId of [1262, 1263, 1264, 1265, 1266, 1267, 1268, 1269, 1270]) {
+ assert(itemIds.has(itemId), `Q413 requires missing quest item ${itemId}`);
+}
+
console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index c41641a9..e4577821 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -59,6 +59,7 @@ const Q409 = require("../src/GameServer/Quest/quests/Q409_PathToElvenOracle");
const Q410 = require("../src/GameServer/Quest/quests/Q410_PathToPalusKnight");
const Q411 = require("../src/GameServer/Quest/quests/Q411_PathToAssassin");
const Q412 = require("../src/GameServer/Quest/quests/Q412_PathToDarkWizard");
+const Q413 = require("../src/GameServer/Quest/quests/Q413_PathToShillienOracle");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -139,6 +140,8 @@ async function main() {
assert.strictEqual(Q411.eventNpc("arkenia"), 7419);
assert.strictEqual(Q412.eventNpc("start"), 7421);
assert.strictEqual(Q412.eventNpc("key"), 7415);
+ assert.strictEqual(Q413.eventNpc("start"), 7330);
+ assert.strictEqual(Q413.eventNpc("sheets"), 7377);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -734,6 +737,20 @@ async function main() {
assert.strictEqual(awardedClassId, 39, "Q412 must award the Dark Wizard class");
assert.strictEqual(questState.completed, true, "Q412 must complete after all four Seeds are returned");
assert.strictEqual(items.get(1261), 1, "Q412 must retain the source Jewel of Darkness reward");
+
+ items.clear(); questState.started = false; questState.completed = false; questState.cond = 0; classId = 38; awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => { awardedClassId = targetClassId; return { ok: true, targetClassId }; };
+ await Q413.onEvent(questState, "start");
+ await Q413.onEvent(questState, "sheets");
+ setItem(1263, 1); setItem(1264, 4); await Q413.onKill(questState, { fetchSelfId: () => 776 });
+ await Q413.onTalk(questState, { fetchSelfId: () => 7377 });
+ await Q413.onEvent(questState, "mark");
+ setItem(1268, 9); await Q413.onKill(questState, { fetchSelfId: () => 514 });
+ await Q413.onTalk(questState, { fetchSelfId: () => 7375 });
+ await Q413.onTalk(questState, { fetchSelfId: () => 7330 });
+ assert.strictEqual(awardedClassId, 42, "Q413 must award the Shillien Oracle class");
+ assert.strictEqual(questState.completed, true, "Q413 must complete after both books are returned");
+ assert.strictEqual(items.get(1270), 1, "Q413 must retain the source Orb of Abyss reward");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From dd70981b1c1b004f080e1a7597cd8d05818c2765 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:04:39 -0400
Subject: [PATCH 17/23] Implement Path to Orc Raider quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q414_PathToOrcRaider.js | 9 ++++++++
tests/test_quest_availability.js | 4 ++++
tests/test_quest_runtime.js | 22 +++++++++++++++++++
4 files changed, 36 insertions(+)
create mode 100644 src/GameServer/Quest/quests/Q414_PathToOrcRaider.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 0c1bee55..2d56df9d 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -69,6 +69,7 @@ const quests = [
require("./quests/Q411_PathToAssassin"),
require("./quests/Q412_PathToDarkWizard"),
require("./quests/Q413_PathToShillienOracle"),
+ require("./quests/Q414_PathToOrcRaider"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q414_PathToOrcRaider.js b/src/GameServer/Quest/quests/Q414_PathToOrcRaider.js
new file mode 100644
index 00000000..0a5eed44
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q414_PathToOrcRaider.js
@@ -0,0 +1,9 @@
+const KARUKIA=7570,KASMAN=7501,GOBLIN_TOMB_RAIDER_LEADER=320,KURUKA_RATMAN_LEADER=5045,UMBAR_ORC=5054;
+const GREEN_BLOOD=1578,GOBLIN_DWELLING_MAP=1579,KURUKA_RATMAN_TOOTH=1580,BETRAYER_UMBAR_REPORT=1589,HEAD_OF_BETRAYER=1591,MARK_OF_RAIDER=1592;
+const UMBAR_LOC=[-16760,78268,-3480],ACCEPT="ItemSound.quest_accept",ITEM="ItemSound.quest_itemget",MIDDLE="ItemSound.quest_middle",FINISH="ItemSound.quest_finish";
+const service=()=>invoke("GameServer/Quest/QuestService"),page=(t,x,a="")=>`${t}:
${x}
${a}`,count=(s,i)=>s.session.actor.backpack.fetchItemFromSelfId(i)?.fetchAmount()||0;
+module.exports={id:414,name:"Path to Orc Raider",npcs:[KARUKIA,KASMAN],startNpcs:[KARUKIA],killNpcs:[GOBLIN_TOMB_RAIDER_LEADER,KURUKA_RATMAN_LEADER,UMBAR_ORC],questSpawns:[KURUKA_RATMAN_LEADER],eventNpc:e=>(e==="start"?KARUKIA:null),
+async onEvent(s,e){const q=service(),a=s.session.actor;if(e!=="start"||s.isStarted()||s.isCompleted())return null;if(Number(a.fetchClassId())!==44||Number(a.fetchLevel())<19||count(s,MARK_OF_RAIDER))return null;await s.setState("started");await s.set("cond",1);await q.giveItem(s.session,GOBLIN_DWELLING_MAP,1);s.playSound(ACCEPT);return page("Karukia","Bring ten Kuruka Ratman Teeth.");},
+async onTalk(s,n){const id=Number(n.fetchSelfId()),q=service();if(s.isCompleted())return page("Karukia","You have already completed the Path to Orc Raider.");if(!s.isStarted()){const a=s.session.actor;if(id!==KARUKIA||Number(a.fetchClassId())!==44)return page("Quest","This path is not for your current class.");return Number(a.fetchLevel())<19?page("Karukia","Come back after reaching level 19."):page("Karukia","Do you seek the path of an Orc Raider?",'Accept the trial.');}if(id===KARUKIA){if(count(s,GOBLIN_DWELLING_MAP)&&count(s,KURUKA_RATMAN_TOOTH)>=10){await q.takeItem(s.session,GOBLIN_DWELLING_MAP);await q.takeItem(s.session,KURUKA_RATMAN_TOOTH,-1);await q.giveItem(s.session,BETRAYER_UMBAR_REPORT,1);await s.addRadar(...UMBAR_LOC);await s.set("cond",3);s.playSound(MIDDLE);return page("Karukia","Find and punish the Umbar traitors.");}return page("Karukia",`Kuruka Ratman Teeth: ${count(s,KURUKA_RATMAN_TOOTH)}/10.`);}if(id===KASMAN&&count(s,BETRAYER_UMBAR_REPORT)&&count(s,HEAD_OF_BETRAYER)>=2){const r=await q.awardFirstProfession(s,45);if(!r.ok)return page("Kasman","Your profession could not be granted. Keep the heads and try again.");await q.takeItem(s.session,HEAD_OF_BETRAYER,-1);await q.takeItem(s.session,BETRAYER_UMBAR_REPORT);await s.removeRadar(...UMBAR_LOC);await q.giveItem(s.session,MARK_OF_RAIDER,1);s.playSound(FINISH);await s.exit(false);return page("Kasman","You have completed the Path to Orc Raider and become an Orc Raider.");}if(id===KASMAN)return page("Kasman",`Heads of Betrayers: ${count(s,HEAD_OF_BETRAYER)}/2.`);return page("Quest","Continue your trial.");},
+async onKill(s,n){if(!s.isStarted())return;const id=Number(n.fetchSelfId()),q=service();if(id===GOBLIN_TOMB_RAIDER_LEADER&&count(s,GOBLIN_DWELLING_MAP)&&count(s,KURUKA_RATMAN_TOOTH)<10&&count(s,GREEN_BLOOD)<40){const blood=count(s,GREEN_BLOOD);if(blood>20&&Math.random()<((blood-20)*.05)){await q.takeItem(s.session,GREEN_BLOOD,-1);s.addSpawn(KURUKA_RATMAN_LEADER);}else{await q.giveItem(s.session,GREEN_BLOOD,1);s.playSound(ITEM);}}else if(id===KURUKA_RATMAN_LEADER&&count(s,GOBLIN_DWELLING_MAP)&&count(s,KURUKA_RATMAN_TOOTH)<10){await q.takeItem(s.session,GREEN_BLOOD,-1);await q.giveItem(s.session,KURUKA_RATMAN_TOOTH,1);if(count(s,KURUKA_RATMAN_TOOTH)===10){await s.set("cond",2);s.playSound(MIDDLE);}else s.playSound(ITEM);}else if(id===UMBAR_ORC&&count(s,BETRAYER_UMBAR_REPORT)&&count(s,HEAD_OF_BETRAYER)<2){await q.giveItem(s.session,HEAD_OF_BETRAYER,1);if(count(s,HEAD_OF_BETRAYER)===2){await s.set("cond",4);s.playSound(MIDDLE);}else s.playSound(ITEM);}}
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index b02df16b..1e756687 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -62,4 +62,8 @@ for (const itemId of [1262, 1263, 1264, 1265, 1266, 1267, 1268, 1269, 1270]) {
assert(itemIds.has(itemId), `Q413 requires missing quest item ${itemId}`);
}
+for (const itemId of [1578, 1579, 1580, 1589, 1591, 1592]) {
+ assert(itemIds.has(itemId), `Q414 requires missing quest item ${itemId}`);
+}
+
console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index e4577821..0e784f75 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -60,6 +60,7 @@ const Q410 = require("../src/GameServer/Quest/quests/Q410_PathToPalusKnight");
const Q411 = require("../src/GameServer/Quest/quests/Q411_PathToAssassin");
const Q412 = require("../src/GameServer/Quest/quests/Q412_PathToDarkWizard");
const Q413 = require("../src/GameServer/Quest/quests/Q413_PathToShillienOracle");
+const Q414 = require("../src/GameServer/Quest/quests/Q414_PathToOrcRaider");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -142,6 +143,7 @@ async function main() {
assert.strictEqual(Q412.eventNpc("key"), 7415);
assert.strictEqual(Q413.eventNpc("start"), 7330);
assert.strictEqual(Q413.eventNpc("sheets"), 7377);
+ assert.strictEqual(Q414.eventNpc("start"), 7570);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -751,6 +753,26 @@ async function main() {
assert.strictEqual(awardedClassId, 42, "Q413 must award the Shillien Oracle class");
assert.strictEqual(questState.completed, true, "Q413 must complete after both books are returned");
assert.strictEqual(items.get(1270), 1, "Q413 must retain the source Orb of Abyss reward");
+
+ items.clear(); questState.started = false; questState.completed = false; questState.cond = 0; classId = 44; awardedClassId = null;
+ const raiderSpawns = [], raiderRadars = [];
+ questState.addSpawn = (selfId) => raiderSpawns.push(selfId);
+ questState.addRadar = (...coords) => raiderRadars.push(["add", ...coords]);
+ questState.removeRadar = (...coords) => raiderRadars.push(["remove", ...coords]);
+ QuestService.awardFirstProfession = async (_, targetClassId) => { awardedClassId = targetClassId; return { ok: true, targetClassId }; };
+ await Q414.onEvent(questState, "start");
+ setItem(1578, 21);
+ const originalRandomForRaider = Math.random; Math.random = () => 0;
+ try { await Q414.onKill(questState, { fetchSelfId: () => 320 }); } finally { Math.random = originalRandomForRaider; }
+ assert.deepStrictEqual(raiderSpawns, [5045], "Q414 must spawn the personal Kuruka Ratman Leader after the source Green Blood roll");
+ setItem(1580, 9); await Q414.onKill(questState, { fetchSelfId: () => 5045 });
+ await Q414.onTalk(questState, { fetchSelfId: () => 7570 });
+ assert.deepStrictEqual(raiderRadars[0], ["add", -16760, 78268, -3480], "Q414 must mark the source Umbar location");
+ await Q414.onKill(questState, { fetchSelfId: () => 5054 }); await Q414.onKill(questState, { fetchSelfId: () => 5054 });
+ await Q414.onTalk(questState, { fetchSelfId: () => 7501 });
+ assert.strictEqual(awardedClassId, 45, "Q414 must award the Orc Raider class");
+ assert.strictEqual(questState.completed, true, "Q414 must complete after both Umbar heads are returned");
+ assert.strictEqual(items.get(1592), 1, "Q414 must retain the source Mark of Raider reward");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 652af2f1b17dde235bef6845c2dc75759d69bde7 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:06:57 -0400
Subject: [PATCH 18/23] Implement Path to Monk quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q415_PathToOrcMonk.js | 16 ++++++++++++++++
tests/test_quest_availability.js | 4 ++++
tests/test_quest_runtime.js | 15 +++++++++++++++
4 files changed, 36 insertions(+)
create mode 100644 src/GameServer/Quest/quests/Q415_PathToOrcMonk.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 2d56df9d..84795de6 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -70,6 +70,7 @@ const quests = [
require("./quests/Q412_PathToDarkWizard"),
require("./quests/Q413_PathToShillienOracle"),
require("./quests/Q414_PathToOrcRaider"),
+ require("./quests/Q415_PathToOrcMonk"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q415_PathToOrcMonk.js b/src/GameServer/Quest/quests/Q415_PathToOrcMonk.js
new file mode 100644
index 00000000..6a7219eb
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q415_PathToOrcMonk.js
@@ -0,0 +1,16 @@
+const GANTAKI=7587,ROSHEEK=7590,KASMAN=7501,TORUKU=7591;
+const BEAR=479,SPIDER=478,SALAMANDER=415,FINAL_MOBS=new Map([[17,1609],[359,1610],[24,1611],[14,1612]]);
+const POMEGRANATE=1593,P1=1594,P2=1595,P3=1596,P1F=1597,P2F=1598,P3F=1599,BEAR_CLAW=1600,SPIDER_TALON=1601,SALAMANDER_SCALE=1602,FIERY=1603,ROSHEEK_LETTER=1604,GANTAKI_LETTER=1605,FIG=1606,P4=1607,P4F=1608,IRON=1613,TORUKU_LETTER=1614,TOTEM=1615;
+const ACCEPT="ItemSound.quest_accept",ITEM="ItemSound.quest_itemget",MIDDLE="ItemSound.quest_middle",FINISH="ItemSound.quest_finish";
+const service=()=>invoke("GameServer/Quest/QuestService"),page=(t,x,a="")=>`${t}:
${x}
${a}`,count=(s,i)=>s.session.actor.backpack.fetchItemFromSelfId(i)?.fetchAmount()||0;
+const finalTotal=s=>[1609,1610,1611,1612].reduce((n,i)=>n+count(s,i),0);
+async function fill(s,pouch,full,trophy,need){const q=service();if(count(s,trophy)===need-1){await q.takeItem(s.session,trophy,-1);await q.takeItem(s.session,pouch);await q.giveItem(s.session,full,1);s.playSound(MIDDLE);return true;}await q.giveItem(s.session,trophy,1);s.playSound(ITEM);return false;}
+module.exports={id:415,name:"Path to Monk",npcs:[GANTAKI,ROSHEEK,KASMAN,TORUKU],startNpcs:[GANTAKI],killNpcs:[BEAR,SPIDER,SALAMANDER,...FINAL_MOBS.keys()],eventNpc:e=>(e==="start"?GANTAKI:null),
+async onEvent(s,e){const q=service(),a=s.session.actor;if(e!=="start"||s.isStarted()||s.isCompleted())return null;if(Number(a.fetchClassId())!==44||Number(a.fetchLevel())<19||count(s,TOTEM))return null;await s.setState("started");await s.set("cond",1);await q.giveItem(s.session,POMEGRANATE,1);s.playSound(ACCEPT);return page("Gantaki Zu Urutu","Take this Pomegranate to Rosheek.");},
+async onTalk(s,n){const id=Number(n.fetchSelfId()),q=service();if(s.isCompleted())return page("Gantaki Zu Urutu","You have already completed the Path to Monk.");if(!s.isStarted()){const a=s.session.actor;if(id!==GANTAKI||Number(a.fetchClassId())!==44)return page("Quest","This path is not for your current class.");return Number(a.fetchLevel())<19?page("Gantaki Zu Urutu","Come back after reaching level 19."):page("Gantaki Zu Urutu","Do you seek the path of a Monk?",'Accept the trial.');}
+if(id===ROSHEEK){if(count(s,POMEGRANATE)){await q.takeItem(s.session,POMEGRANATE);await q.giveItem(s.session,P1,1);await s.set("cond",2);return page("Rosheek","Fill the first pouch with Kasha Bear Claws.");}if(count(s,P1F)){await q.takeItem(s.session,P1F);await q.giveItem(s.session,P2,1);await s.set("cond",4);return page("Rosheek","Fill the second pouch with Kasha Blade Spider Talons.");}if(count(s,P2F)){await q.takeItem(s.session,P2F);await q.giveItem(s.session,P3,1);await s.set("cond",6);return page("Rosheek","Fill the third pouch with Scarlet Salamander Scales.");}if(count(s,P3F)){await q.takeItem(s.session,P3F);await q.giveItem(s.session,FIERY,1);await q.giveItem(s.session,ROSHEEK_LETTER,1);await s.set("cond",8);return page("Rosheek","Return to Gantaki.");}return page("Rosheek","Continue filling your pouch.");}
+if(id===GANTAKI){if(count(s,FIERY)&&count(s,ROSHEEK_LETTER)){await q.takeItem(s.session,ROSHEEK_LETTER);await q.giveItem(s.session,GANTAKI_LETTER,1);await s.set("cond",9);return page("Gantaki Zu Urutu","Take my letter to Kasman.");}return page("Gantaki Zu Urutu","Complete Rosheek's trials.");}
+if(id===KASMAN){if(count(s,GANTAKI_LETTER)){await q.takeItem(s.session,GANTAKI_LETTER);await q.giveItem(s.session,FIG,1);await s.set("cond",10);return page("Kasman","Take this fig to Toruku.");}if(count(s,IRON)&&count(s,FIERY)&&count(s,TORUKU_LETTER)){const r=await q.awardFirstProfession(s,47);if(!r.ok)return page("Kasman","Your profession could not be granted. Keep the scrolls and try again.");for(const x of [IRON,FIERY,TORUKU_LETTER])await q.takeItem(s.session,x);await q.giveItem(s.session,TOTEM,1);s.playSound(FINISH);await s.exit(false);return page("Kasman","You have completed the Path to Monk and become a Monk.");}return page("Kasman","Complete Toruku's trial.");}
+if(id===TORUKU){if(count(s,FIG)){await q.takeItem(s.session,FIG);await q.giveItem(s.session,P4,1);await s.set("cond",11);return page("Toruku","Fill the fourth pouch with the four kinds of trophies.");}if(count(s,P4F)){await q.takeItem(s.session,P4F);await q.giveItem(s.session,IRON,1);await q.giveItem(s.session,TORUKU_LETTER,1);await s.set("cond",13);return page("Toruku","Take my letter to Kasman.");}return page("Toruku",`Fourth pouch trophies: ${finalTotal(s)}/12.`);}return page("Quest","Continue your trial.");},
+async onKill(s,n){if(!s.isStarted())return;const id=Number(n.fetchSelfId()),q=service();if(id===BEAR&&count(s,P1))await fill(s,P1,P1F,BEAR_CLAW,5);else if(id===SPIDER&&count(s,P2))await fill(s,P2,P2F,SPIDER_TALON,5);else if(id===SALAMANDER&&count(s,P3))await fill(s,P3,P3F,SALAMANDER_SCALE,5);else if(FINAL_MOBS.has(id)&&count(s,P4)){if(finalTotal(s)>=11){for(const x of [1609,1610,1611,1612])await q.takeItem(s.session,x,-1);await q.takeItem(s.session,P4);await q.giveItem(s.session,P4F,1);s.playSound(MIDDLE);}else{await q.giveItem(s.session,FINAL_MOBS.get(id),1);s.playSound(ITEM);}}}
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 1e756687..0a0a198a 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -66,4 +66,8 @@ for (const itemId of [1578, 1579, 1580, 1589, 1591, 1592]) {
assert(itemIds.has(itemId), `Q414 requires missing quest item ${itemId}`);
}
+for (const itemId of Array.from({ length: 23 }, (_, index) => 1593 + index)) {
+ assert(itemIds.has(itemId), `Q415 requires missing quest item ${itemId}`);
+}
+
console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 0e784f75..239ab8d0 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -61,6 +61,7 @@ const Q411 = require("../src/GameServer/Quest/quests/Q411_PathToAssassin");
const Q412 = require("../src/GameServer/Quest/quests/Q412_PathToDarkWizard");
const Q413 = require("../src/GameServer/Quest/quests/Q413_PathToShillienOracle");
const Q414 = require("../src/GameServer/Quest/quests/Q414_PathToOrcRaider");
+const Q415 = require("../src/GameServer/Quest/quests/Q415_PathToOrcMonk");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -144,6 +145,7 @@ async function main() {
assert.strictEqual(Q413.eventNpc("start"), 7330);
assert.strictEqual(Q413.eventNpc("sheets"), 7377);
assert.strictEqual(Q414.eventNpc("start"), 7570);
+ assert.strictEqual(Q415.eventNpc("start"), 7587);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -773,6 +775,19 @@ async function main() {
assert.strictEqual(awardedClassId, 45, "Q414 must award the Orc Raider class");
assert.strictEqual(questState.completed, true, "Q414 must complete after both Umbar heads are returned");
assert.strictEqual(items.get(1592), 1, "Q414 must retain the source Mark of Raider reward");
+
+ items.clear(); questState.started = false; questState.completed = false; questState.cond = 0; classId = 44; awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => { awardedClassId = targetClassId; return { ok: true, targetClassId }; };
+ await Q415.onEvent(questState, "start"); await Q415.onTalk(questState, { fetchSelfId: () => 7590 });
+ setItem(1600, 4); await Q415.onKill(questState, { fetchSelfId: () => 479 }); await Q415.onTalk(questState, { fetchSelfId: () => 7590 });
+ setItem(1601, 4); await Q415.onKill(questState, { fetchSelfId: () => 478 }); await Q415.onTalk(questState, { fetchSelfId: () => 7590 });
+ setItem(1602, 4); await Q415.onKill(questState, { fetchSelfId: () => 415 }); await Q415.onTalk(questState, { fetchSelfId: () => 7590 });
+ await Q415.onTalk(questState, { fetchSelfId: () => 7587 }); await Q415.onTalk(questState, { fetchSelfId: () => 7501 }); await Q415.onTalk(questState, { fetchSelfId: () => 7591 });
+ setItem(1609, 3); setItem(1610, 3); setItem(1611, 3); setItem(1612, 2); await Q415.onKill(questState, { fetchSelfId: () => 14 });
+ await Q415.onTalk(questState, { fetchSelfId: () => 7591 }); await Q415.onTalk(questState, { fetchSelfId: () => 7501 });
+ assert.strictEqual(awardedClassId, 47, "Q415 must award the Monk class");
+ assert.strictEqual(questState.completed, true, "Q415 must complete after Kasman receives both scrolls and Toruku's Letter");
+ assert.strictEqual(items.get(1615), 1, "Q415 must retain the source Khavatari Totem reward");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From aa62845a27fcbaa330b29ea47d7dea4a974b4676 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:09:15 -0400
Subject: [PATCH 19/23] Implement Path to Orc Shaman quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q416_PathToOrcShaman.js | 9 +++++++++
tests/test_quest_availability.js | 4 ++++
tests/test_quest_runtime.js | 16 ++++++++++++++++
4 files changed, 30 insertions(+)
create mode 100644 src/GameServer/Quest/quests/Q416_PathToOrcShaman.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 84795de6..948c84cf 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -71,6 +71,7 @@ const quests = [
require("./quests/Q413_PathToShillienOracle"),
require("./quests/Q414_PathToOrcRaider"),
require("./quests/Q415_PathToOrcMonk"),
+ require("./quests/Q416_PathToOrcShaman"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q416_PathToOrcShaman.js b/src/GameServer/Quest/quests/Q416_PathToOrcShaman.js
new file mode 100644
index 00000000..324a8370
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q416_PathToOrcShaman.js
@@ -0,0 +1,9 @@
+const TATARU=7585,HESTUI=7592,UMOS=7502,DUDA=7593,BEAR=479,SPIDER=478,SALAMANDER=415,GRIZZLY=335,PARASITE_MOBS=[38,43],DURKA=5056;
+const FIRE=1616,PELT=1617,HUSK=1618,EGG1=1619,MASK=1620,EGG2=1621,CLAW=1622,LETTER=1623,FLAME=1624,BLOOD=1625,CAULDRON=1626,NET=1627,BOUND=1628,PARASITES=1629,TOTEM_BLOOD=1630,MEDIUM=1631;
+const ACCEPT="ItemSound.quest_accept",ITEM="ItemSound.quest_itemget",MIDDLE="ItemSound.quest_middle",FINISH="ItemSound.quest_finish";
+const service=()=>invoke("GameServer/Quest/QuestService"),page=(t,x,a="")=>`${t}:
${x}
${a}`,count=(s,i)=>s.session.actor.backpack.fetchItemFromSelfId(i)?.fetchAmount()||0;
+module.exports={id:416,name:"Path to Orc Shaman",npcs:[TATARU,HESTUI,UMOS,DUDA],startNpcs:[TATARU],killNpcs:[BEAR,SPIDER,SALAMANDER,GRIZZLY,...PARASITE_MOBS,DURKA],questSpawns:[DURKA],eventNpc:e=>({start:TATARU,claw:HESTUI,letter:TATARU,net:DUDA})[e]??null,
+async onEvent(s,e){const q=service(),a=s.session.actor;if(e==="start"&&!s.isStarted()&&!s.isCompleted()){if(Number(a.fetchClassId())!==49||Number(a.fetchLevel())<19||count(s,MEDIUM))return null;await s.setState("started");await s.set("cond",1);await q.giveItem(s.session,FIRE,1);s.playSound(ACCEPT);return page("Tataru Zu Hestui","Bring the three fiery offerings.");}if(e==="claw"&&count(s,MASK)&&count(s,EGG2)){await q.takeItem(s.session,MASK);await q.takeItem(s.session,EGG2);await q.giveItem(s.session,CLAW,1);await s.set("cond",4);return page("Hestui Totem Spirit","Take the claw to Tataru.");}if(e==="letter"&&count(s,CLAW)){await q.takeItem(s.session,CLAW);await q.giveItem(s.session,LETTER,1);await s.set("cond",5);return page("Tataru Zu Hestui","Take my letter to Umos.");}if(e==="net"&&count(s,CAULDRON)){await q.takeItem(s.session,CAULDRON);await q.giveItem(s.session,NET,1);await s.set("cond",9);return page("Duda-Mara Totem Spirit","Capture the Durka Spirit.");}return null;},
+async onTalk(s,n){const id=Number(n.fetchSelfId()),q=service();if(s.isCompleted())return page("Tataru Zu Hestui","You have already completed the Path to Orc Shaman.");if(!s.isStarted()){const a=s.session.actor;if(id!==TATARU||Number(a.fetchClassId())!==49)return page("Quest","This path is not for your current class.");return Number(a.fetchLevel())<19?page("Tataru Zu Hestui","Come back after reaching level 19."):page("Tataru Zu Hestui","Do you seek the path of an Orc Shaman?",'Accept the trial.');}if(id===TATARU){if(count(s,FIRE)&&[PELT,HUSK,EGG1].every(x=>count(s,x))){for(const x of [FIRE,PELT,HUSK,EGG1])await q.takeItem(s.session,x);await q.giveItem(s.session,MASK,1);await q.giveItem(s.session,EGG2,1);await s.set("cond",3);return page("Tataru Zu Hestui","Take the mask and egg to the Hestui spirit.");}if(count(s,CLAW))return page("Tataru Zu Hestui","Present the claw.",'Receive Tataru’s Letter.');return page("Tataru Zu Hestui","Continue the fiery offering.");}if(id===HESTUI){if(count(s,MASK)&&count(s,EGG2))return page("Hestui Totem Spirit","Offer the mask and egg.",'Receive the Totem Spirit Claw.');return page("Hestui Totem Spirit","Continue Umos's trial.");}if(id===UMOS){if(count(s,LETTER)){await q.takeItem(s.session,LETTER);await q.giveItem(s.session,FLAME,1);await s.set("cond",6);return page("Umos","Bring three Grizzly Blood.");}if(count(s,FLAME)&&count(s,BLOOD)>=3){await q.takeItem(s.session,FLAME);await q.takeItem(s.session,BLOOD,-1);await q.giveItem(s.session,CAULDRON,1);await s.set("cond",8);return page("Umos","Take the Blood Cauldron to Duda-Mara.");}if(count(s,TOTEM_BLOOD)){const r=await q.awardFirstProfession(s,50);if(!r.ok)return page("Umos","Your profession could not be granted. Keep the Totem Spirit Blood and try again.");await q.takeItem(s.session,TOTEM_BLOOD,-1);await q.giveItem(s.session,MEDIUM,1);s.playSound(FINISH);await s.exit(false);return page("Umos","You have completed the Path to Orc Shaman and become an Orc Shaman.");}return page("Umos",`Grizzly Blood: ${count(s,BLOOD)}/3.`);}if(id===DUDA){if(count(s,CAULDRON))return page("Duda-Mara Totem Spirit","Offer the Blood Cauldron.",'Receive the Spirit Net.');if(count(s,NET))return page("Duda-Mara Totem Spirit",`Durka Parasites: ${count(s,PARASITES)}/8.`);if(count(s,BOUND)){await q.takeItem(s.session,BOUND);await q.giveItem(s.session,TOTEM_BLOOD,1);await s.set("cond",11);return page("Duda-Mara Totem Spirit","Take the Totem Spirit Blood to Umos.");}return page("Duda-Mara Totem Spirit","Continue the trial.");}return page("Quest","Continue your trial.");},
+async onKill(s,n){if(!s.isStarted())return;const id=Number(n.fetchSelfId()),q=service();if(id===BEAR&&count(s,FIRE)&&!count(s,PELT)){await q.giveItem(s.session,PELT,1);s.playSound(ITEM);}else if(id===SPIDER&&count(s,FIRE)&&!count(s,HUSK)){await q.giveItem(s.session,HUSK,1);s.playSound(ITEM);}else if(id===SALAMANDER&&count(s,FIRE)&&!count(s,EGG1)){await q.giveItem(s.session,EGG1,1);s.playSound(MIDDLE);}else if(id===GRIZZLY&&count(s,FLAME)&&count(s,BLOOD)<3){await q.giveItem(s.session,BLOOD,1);s.playSound(count(s,BLOOD)===3?MIDDLE:ITEM);}else if(PARASITE_MOBS.includes(id)&&count(s,NET)&&!count(s,BOUND)){const p=count(s,PARASITES),roll=Math.random();if(p>=7||(p===5&&roll<.1)||(p===6&&roll<.2)){await q.takeItem(s.session,PARASITES,-1);s.addSpawn(DURKA);s.playSound(ITEM);}else{await q.giveItem(s.session,PARASITES,1);s.playSound(ITEM);}}else if(id===DURKA&&count(s,NET)&&!count(s,BOUND)){await q.takeItem(s.session,NET);await q.takeItem(s.session,PARASITES,-1);await q.giveItem(s.session,BOUND,1);await s.set("cond",10);s.playSound(MIDDLE);}}
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 0a0a198a..595ac320 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -70,4 +70,8 @@ for (const itemId of Array.from({ length: 23 }, (_, index) => 1593 + index)) {
assert(itemIds.has(itemId), `Q415 requires missing quest item ${itemId}`);
}
+for (const itemId of Array.from({ length: 16 }, (_, index) => 1616 + index)) {
+ assert(itemIds.has(itemId), `Q416 requires missing quest item ${itemId}`);
+}
+
console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 239ab8d0..968c7a1f 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -62,6 +62,7 @@ const Q412 = require("../src/GameServer/Quest/quests/Q412_PathToDarkWizard");
const Q413 = require("../src/GameServer/Quest/quests/Q413_PathToShillienOracle");
const Q414 = require("../src/GameServer/Quest/quests/Q414_PathToOrcRaider");
const Q415 = require("../src/GameServer/Quest/quests/Q415_PathToOrcMonk");
+const Q416 = require("../src/GameServer/Quest/quests/Q416_PathToOrcShaman");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -146,6 +147,8 @@ async function main() {
assert.strictEqual(Q413.eventNpc("sheets"), 7377);
assert.strictEqual(Q414.eventNpc("start"), 7570);
assert.strictEqual(Q415.eventNpc("start"), 7587);
+ assert.strictEqual(Q416.eventNpc("start"), 7585);
+ assert.strictEqual(Q416.eventNpc("net"), 7593);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -788,6 +791,19 @@ async function main() {
assert.strictEqual(awardedClassId, 47, "Q415 must award the Monk class");
assert.strictEqual(questState.completed, true, "Q415 must complete after Kasman receives both scrolls and Toruku's Letter");
assert.strictEqual(items.get(1615), 1, "Q415 must retain the source Khavatari Totem reward");
+
+ items.clear(); questState.started = false; questState.completed = false; questState.cond = 0; classId = 49; awardedClassId = null;
+ const shamanSpawns = []; questState.addSpawn = (selfId) => shamanSpawns.push(selfId);
+ QuestService.awardFirstProfession = async (_, targetClassId) => { awardedClassId = targetClassId; return { ok: true, targetClassId }; };
+ await Q416.onEvent(questState, "start"); await Q416.onKill(questState, { fetchSelfId: () => 479 }); await Q416.onKill(questState, { fetchSelfId: () => 478 }); await Q416.onKill(questState, { fetchSelfId: () => 415 });
+ await Q416.onTalk(questState, { fetchSelfId: () => 7585 }); await Q416.onEvent(questState, "claw"); await Q416.onEvent(questState, "letter");
+ await Q416.onTalk(questState, { fetchSelfId: () => 7502 }); setItem(1625, 2); await Q416.onKill(questState, { fetchSelfId: () => 335 }); await Q416.onTalk(questState, { fetchSelfId: () => 7502 });
+ await Q416.onEvent(questState, "net"); setItem(1629, 7); await Q416.onKill(questState, { fetchSelfId: () => 38 });
+ assert.deepStrictEqual(shamanSpawns, [5056], "Q416 must spawn the personal Durka Spirit after the source parasite threshold");
+ await Q416.onKill(questState, { fetchSelfId: () => 5056 }); await Q416.onTalk(questState, { fetchSelfId: () => 7593 }); await Q416.onTalk(questState, { fetchSelfId: () => 7502 });
+ assert.strictEqual(awardedClassId, 50, "Q416 must award the Orc Shaman class");
+ assert.strictEqual(questState.completed, true, "Q416 must complete after Totem Spirit Blood is returned to Umos");
+ assert.strictEqual(items.get(1631), 1, "Q416 must retain the source Mask of Medium reward");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 26cc0493ce5368c8e32dd6f9cd99c3a981e86d1c Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:37:13 -0400
Subject: [PATCH 20/23] Implement Path to Scavenger quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q417_PathToScavenger.js | 12 ++++++++++
tests/test_quest_availability.js | 4 ++++
tests/test_quest_runtime.js | 23 +++++++++++++++++--
4 files changed, 38 insertions(+), 2 deletions(-)
create mode 100644 src/GameServer/Quest/quests/Q417_PathToScavenger.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index 948c84cf..e0ca5688 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -72,6 +72,7 @@ const quests = [
require("./quests/Q414_PathToOrcRaider"),
require("./quests/Q415_PathToOrcMonk"),
require("./quests/Q416_PathToOrcShaman"),
+ require("./quests/Q417_PathToScavenger"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q417_PathToScavenger.js b/src/GameServer/Quest/quests/Q417_PathToScavenger.js
new file mode 100644
index 00000000..acd1e5da
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q417_PathToScavenger.js
@@ -0,0 +1,12 @@
+const PIPPI=7524,MION=7519,SHARI=7517,BRONK=7525,ZIMENF=7538,TOMA=7556,RAUT=7316,TORAI=7557,HUNTER_BEAR=777,HONEY_BEAR=5058,HUNTER_TARANTULA=403,PLUNDER_TARANTULA=508;
+const RING=1642,PIPPI_LETTER=1643,ROUT_SCROLL=1644,UNDIES=1645,MION_LETTER=1646,INGOT=1647,AXE=1648,POTION=1649,BRONK_PAY=1650,SHARI_PAY=1651,ZIMENF_PAY=1652,BEAR_PIC=1653,TARANTULA_PIC=1654,HONEY=1655,BEAD=1656,PARCEL=1657;
+const ACCEPT="ItemSound.quest_accept",ITEM="ItemSound.quest_itemget",MIDDLE="ItemSound.quest_middle",FINISH="ItemSound.quest_finish";
+const service=()=>invoke("GameServer/Quest/QuestService"),page=(t,x,a="")=>`${t}:
${x}
${a}`,count=(s,i)=>s.session.actor.backpack.fetchItemFromSelfId(i)?.fetchAmount()||0;
+const jobs=[{item:INGOT,pay:BRONK_PAY,npc:BRONK,name:"Bronk"},{item:AXE,pay:SHARI_PAY,npc:SHARI,name:"Shari"},{item:POTION,pay:ZIMENF_PAY,npc:ZIMENF,name:"Zimenf"}];
+const currentJob=s=>jobs.find(j=>count(s,j.item)||count(s,j.pay));
+async function assign(s){const j=jobs[Math.floor(Math.random()*jobs.length)];await service().giveItem(s.session,j.item,1);return j;}
+module.exports={id:417,name:"Path to Scavenger",npcs:[PIPPI,MION,SHARI,BRONK,ZIMENF,TOMA,RAUT,TORAI],startNpcs:[PIPPI],killNpcs:[HUNTER_BEAR,HONEY_BEAR,HUNTER_TARANTULA,PLUNDER_TARANTULA],questSpawns:[HONEY_BEAR],eventNpc:e=>({start:PIPPI,mion:MION,raut:RAUT,torai:TORAI})[e]??null,
+async onEvent(s,e){const q=service(),a=s.session.actor;if(e==="start"&&!s.isStarted()&&!s.isCompleted()){if(Number(a.fetchClassId())!==53||Number(a.fetchLevel())<19||count(s,RING))return null;await s.setState("started");await s.set("cond",1);await q.giveItem(s.session,PIPPI_LETTER,1);s.playSound(ACCEPT);return page("Pippi","Take my letter to Mion.");}if(e==="mion"&&count(s,PIPPI_LETTER)){await q.takeItem(s.session,PIPPI_LETTER);const j=await assign(s);await s.set("cond",2);return page("Mion",`Take this errand to ${j.name}.`);}if(e==="raut"&&count(s,PARCEL)){await q.takeItem(s.session,PARCEL);await q.giveItem(s.session,ROUT_SCROLL,1);await s.set("cond",10);return page("Raut","Take the teleport scroll to Torai.");}if(e==="torai"&&count(s,ROUT_SCROLL)){await q.takeItem(s.session,ROUT_SCROLL);await q.giveItem(s.session,UNDIES,1);await s.set("cond",11);return page("Torai","Take the Succubus Undies to Raut.");}return null;},
+async onTalk(s,n){const id=Number(n.fetchSelfId()),q=service();if(s.isCompleted())return page("Pippi","You have already completed the Path to Scavenger.");if(!s.isStarted()){const a=s.session.actor;if(id!==PIPPI||Number(a.fetchClassId())!==53)return page("Quest","This path is not for your current class.");return Number(a.fetchLevel())<19?page("Pippi","Come back after reaching level 19."):page("Pippi","Do you seek the path of a Scavenger?",'Accept the trial.');}if(id===MION){if(count(s,PIPPI_LETTER))return page("Mion","Pippi sent you?",'Accept Mion’s errand.');const j=currentJob(s);if(j&&count(s,j.item))return page("Mion",`Take the errand to ${j.name}.`);if(j&&count(s,j.pay)){await q.takeItem(s.session,j.pay);const done=s.getInt("mionJobs")+1;await s.set("mionJobs",done);if(done>=3){await q.giveItem(s.session,MION_LETTER,1);await s.set("cond",4);return page("Mion","Take my letter to Toma.");}const next=await assign(s);return page("Mion",`Another errand: visit ${next.name}.`);}return page("Mion","Complete an errand.");}const j=jobs.find(x=>x.npc===id);if(j&&count(s,j.item)){await q.takeItem(s.session,j.item);await q.giveItem(s.session,j.pay,1);return page(j.name,"Take this payment back to Mion.");}if(id===TOMA){if(count(s,MION_LETTER)){await q.takeItem(s.session,MION_LETTER);await q.giveItem(s.session,BEAR_PIC,1);await s.set("cond",5);return page("Toma","Spoil Honey Bears for five Honey Jars.");}if(count(s,BEAR_PIC)&&count(s,HONEY)>=5){await q.takeItem(s.session,BEAR_PIC);await q.takeItem(s.session,HONEY,-1);await q.giveItem(s.session,TARANTULA_PIC,1);await s.set("cond",7);return page("Toma","Spoil tarantulas for twenty Beads.");}if(count(s,TARANTULA_PIC)&&count(s,BEAD)>=20){await q.takeItem(s.session,TARANTULA_PIC);await q.takeItem(s.session,BEAD,-1);await q.giveItem(s.session,PARCEL,1);await s.set("cond",9);return page("Toma","Take the Bead Parcel to Raut.");}return page("Toma",count(s,BEAR_PIC)?`Honey Jars: ${count(s,HONEY)}/5.`:`Beads: ${count(s,BEAD)}/20.`);}if(id===RAUT&&count(s,UNDIES)){const r=await q.awardFirstProfession(s,54);if(!r.ok)return page("Raut","Your profession could not be granted. Keep the Undies and try again.");await q.takeItem(s.session,UNDIES);await q.giveItem(s.session,RING,1);s.playSound(FINISH);await s.exit(false);return page("Raut","You have completed the Path to Scavenger and become a Scavenger.");}if(id===RAUT&&count(s,PARCEL))return page("Raut","Present the Bead Parcel.",'Receive the teleport scroll.');if(id===TORAI&&count(s,ROUT_SCROLL))return page("Torai","Present the teleport scroll.",'Receive the Succubus Undies.');return page("Quest","Continue your trial.");},
+async onKill(s,n){if(!s.isStarted())return;const id=Number(n.fetchSelfId()),q=service();if(id===HUNTER_BEAR&&count(s,BEAR_PIC)&&count(s,HONEY)<5){const attempts=s.getInt("bearSearch");if(attempts>20&&Math.random()<=((attempts-20)*.1)){await s.set("bearSearch",0);s.addSpawn(HONEY_BEAR);}else await s.set("bearSearch",attempts+1);}else if(id===HONEY_BEAR&&count(s,BEAR_PIC)&&n.isSpoil?.()){await q.giveItem(s.session,HONEY,1);s.playSound(count(s,HONEY)===5?MIDDLE:ITEM);}else if([HUNTER_TARANTULA,PLUNDER_TARANTULA].includes(id)&&count(s,TARANTULA_PIC)&&n.isSpoil?.()&&count(s,BEAD)<20){const chance=(id===HUNTER_TARANTULA)?0.5:0.6;if(Math.random() 1616 + index)) {
assert(itemIds.has(itemId), `Q416 requires missing quest item ${itemId}`);
}
+for (const itemId of Array.from({ length: 16 }, (_, index) => 1642 + index)) {
+ assert(itemIds.has(itemId), `Q417 requires missing quest item ${itemId}`);
+}
+
console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 968c7a1f..3f94965a 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -63,6 +63,7 @@ const Q413 = require("../src/GameServer/Quest/quests/Q413_PathToShillienOracle")
const Q414 = require("../src/GameServer/Quest/quests/Q414_PathToOrcRaider");
const Q415 = require("../src/GameServer/Quest/quests/Q415_PathToOrcMonk");
const Q416 = require("../src/GameServer/Quest/quests/Q416_PathToOrcShaman");
+const Q417 = require("../src/GameServer/Quest/quests/Q417_PathToScavenger");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -149,6 +150,8 @@ async function main() {
assert.strictEqual(Q415.eventNpc("start"), 7587);
assert.strictEqual(Q416.eventNpc("start"), 7585);
assert.strictEqual(Q416.eventNpc("net"), 7593);
+ assert.strictEqual(Q417.eventNpc("start"), 7524);
+ assert.strictEqual(Q417.eventNpc("mion"), 7519);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -313,9 +316,10 @@ async function main() {
started: false,
completed: false,
cond: 0,
- getInt: () => questState.cond,
+ values: {},
+ getInt: (key) => key === "cond" ? questState.cond : Number(questState.values[key]) || 0,
setState: async () => { questState.started = true; },
- set: async (key, value) => { if (key === "cond") questState.cond = Number(value); },
+ set: async (key, value) => { if (key === "cond") questState.cond = Number(value); else questState.values[key] = value; },
exit: async () => { questState.completed = true; },
playSound: (sound) => calls.push(["sound", sound]),
};
@@ -804,6 +808,21 @@ async function main() {
assert.strictEqual(awardedClassId, 50, "Q416 must award the Orc Shaman class");
assert.strictEqual(questState.completed, true, "Q416 must complete after Totem Spirit Blood is returned to Umos");
assert.strictEqual(items.get(1631), 1, "Q416 must retain the source Mask of Medium reward");
+
+ items.clear(); questState.started = false; questState.completed = false; questState.cond = 0; questState.values = {}; classId = 53; awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => { awardedClassId = targetClassId; return { ok: true, targetClassId }; };
+ const originalRandomForScavenger = Math.random; Math.random = () => 0;
+ try {
+ await Q417.onEvent(questState, "start");
+ for (let i = 0; i < 3; i += 1) { await Q417.onEvent(questState, "mion"); await Q417.onTalk(questState, { fetchSelfId: () => 7525 }); await Q417.onTalk(questState, { fetchSelfId: () => 7519 }); }
+ await Q417.onTalk(questState, { fetchSelfId: () => 7556 });
+ setItem(1655, 4); await Q417.onKill(questState, { fetchSelfId: () => 5058, isSpoil: () => true }); await Q417.onTalk(questState, { fetchSelfId: () => 7556 });
+ setItem(1656, 19); await Q417.onKill(questState, { fetchSelfId: () => 403, isSpoil: () => true }); await Q417.onTalk(questState, { fetchSelfId: () => 7556 });
+ await Q417.onEvent(questState, "raut"); await Q417.onEvent(questState, "torai"); await Q417.onTalk(questState, { fetchSelfId: () => 7316 });
+ } finally { Math.random = originalRandomForScavenger; }
+ assert.strictEqual(awardedClassId, 54, "Q417 must award the Scavenger class");
+ assert.strictEqual(questState.completed, true, "Q417 must complete after the Succubus Undies are returned");
+ assert.strictEqual(items.get(1642), 1, "Q417 must retain the source Ring of Raven reward");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 324d45558e82d90ee84d65832e4d833481cde986 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:40:28 -0400
Subject: [PATCH 21/23] Implement Path to Artisan quest
---
src/GameServer/Quest/QuestService.js | 1 +
.../Quest/quests/Q418_PathToArtisan.js | 9 +++++++++
tests/test_quest_availability.js | 4 ++++
tests/test_quest_runtime.js | 16 ++++++++++++++++
4 files changed, 30 insertions(+)
create mode 100644 src/GameServer/Quest/quests/Q418_PathToArtisan.js
diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js
index e0ca5688..bc4133c6 100644
--- a/src/GameServer/Quest/QuestService.js
+++ b/src/GameServer/Quest/QuestService.js
@@ -73,6 +73,7 @@ const quests = [
require("./quests/Q415_PathToOrcMonk"),
require("./quests/Q416_PathToOrcShaman"),
require("./quests/Q417_PathToScavenger"),
+ require("./quests/Q418_PathToArtisan"),
];
const byId = new Map(quests.map((quest) => [quest.id, quest]));
diff --git a/src/GameServer/Quest/quests/Q418_PathToArtisan.js b/src/GameServer/Quest/quests/Q418_PathToArtisan.js
new file mode 100644
index 00000000..0dcd8b95
--- /dev/null
+++ b/src/GameServer/Quest/quests/Q418_PathToArtisan.js
@@ -0,0 +1,9 @@
+const SILVERA=7527,KLUTO=7317,PINTER=7298,RATMAN=389,BIG_RATMAN=390,VUKU=17;
+const RING=1632,PASS1=1633,PASS2=1634,PASS_FINAL=1635,TOOTH=1636,BIG_TOOTH=1637,KLUTO_LETTER=1638,FOOTPRINT=1639,BOX1=1640,BOX2=1641;
+const ACCEPT="ItemSound.quest_accept",ITEM="ItemSound.quest_itemget",MIDDLE="ItemSound.quest_middle",FINISH="ItemSound.quest_finish";
+const service=()=>invoke("GameServer/Quest/QuestService"),page=(t,x,a="")=>`${t}:
${x}
${a}`,count=(s,i)=>s.session.actor.backpack.fetchItemFromSelfId(i)?.fetchAmount()||0;
+module.exports={id:418,name:"Path to Artisan",npcs:[SILVERA,KLUTO,PINTER],startNpcs:[SILVERA],killNpcs:[RATMAN,BIG_RATMAN,VUKU],eventNpc:e=>({start:SILVERA,letter:KLUTO,footprint:PINTER,box:PINTER})[e]??null,
+async onEvent(s,e){const q=service(),a=s.session.actor;if(e==="start"&&!s.isStarted()&&!s.isCompleted()){if(Number(a.fetchClassId())!==53||Number(a.fetchLevel())<19||count(s,PASS_FINAL))return null;await s.setState("started");await s.set("cond",1);await q.giveItem(s.session,RING,1);s.playSound(ACCEPT);return page("Silvera","Bring ten Boogle Ratman Teeth and two Leader Teeth.");}if(e==="letter"&&count(s,PASS1)&&!count(s,KLUTO_LETTER)){await q.giveItem(s.session,KLUTO_LETTER,1);await s.set("cond",4);return page("Kluto","Take my letter to Pinter.");}if(e==="footprint"&&count(s,KLUTO_LETTER)){await q.takeItem(s.session,KLUTO_LETTER);await q.giveItem(s.session,FOOTPRINT,1);await s.set("cond",5);return page("Pinter","Recover the Stolen Secret Box from Vuku Orc Fighters.");}if(e==="box"&&count(s,FOOTPRINT)&&count(s,BOX1)){await q.takeItem(s.session,FOOTPRINT);await q.takeItem(s.session,BOX1);await q.giveItem(s.session,BOX2,1);await q.giveItem(s.session,PASS2,1);await s.set("cond",7);return page("Pinter","Return to Kluto.");}return null;},
+async onTalk(s,n){const id=Number(n.fetchSelfId()),q=service();if(s.isCompleted())return page("Silvera","You have already completed the Path to Artisan.");if(!s.isStarted()){const a=s.session.actor;if(id!==SILVERA||Number(a.fetchClassId())!==53)return page("Quest","This path is not for your current class.");return Number(a.fetchLevel())<19?page("Silvera","Come back after reaching level 19."):page("Silvera","Do you seek the path of an Artisan?",'Accept the trial.');}if(id===SILVERA){if(count(s,RING)&&count(s,TOOTH)>=10&&count(s,BIG_TOOTH)>=2){for(const x of [RING,TOOTH,BIG_TOOTH])await q.takeItem(s.session,x,-1);await q.giveItem(s.session,PASS1,1);await s.set("cond",3);return page("Silvera","Take the first pass to Kluto.");}return page("Silvera",`Boogle Ratman Teeth: ${count(s,TOOTH)}/10; Leader Teeth: ${count(s,BIG_TOOTH)}/2.`);}if(id===KLUTO){if(count(s,PASS1)&&count(s,PASS2)&&count(s,BOX2)){const r=await q.awardFirstProfession(s,56);if(!r.ok)return page("Kluto","Your profession could not be granted. Keep the passes and try again.");for(const x of [PASS1,PASS2,BOX2])await q.takeItem(s.session,x);await q.giveItem(s.session,PASS_FINAL,1);s.playSound(FINISH);await s.exit(false);return page("Kluto","You have completed the Path to Artisan and become an Artisan.");}if(count(s,PASS1)&&!count(s,KLUTO_LETTER))return page("Kluto","Accept my task.",'Receive Kluto’s Letter.');return page("Kluto","Complete Pinter's task.");}if(id===PINTER){if(count(s,KLUTO_LETTER))return page("Pinter","Present Kluto's Letter.",'Receive the Footprint.');if(count(s,FOOTPRINT)&&count(s,BOX1))return page("Pinter","Present the Stolen Secret Box.",'Receive the second pass.');return page("Pinter","Recover the Stolen Secret Box from Vuku Orc Fighters.");}return page("Quest","Continue your trial.");},
+async onKill(s,n){if(!s.isStarted())return;const id=Number(n.fetchSelfId()),q=service();if(id===RATMAN&&count(s,RING)&&count(s,TOOTH)<10&&Math.random()<.7){await q.giveItem(s.session,TOOTH,1);s.playSound(count(s,TOOTH)===10&&count(s,BIG_TOOTH)>=2?MIDDLE:ITEM);}else if(id===BIG_RATMAN&&count(s,RING)&&count(s,BIG_TOOTH)<2&&Math.random()<.5){await q.giveItem(s.session,BIG_TOOTH,1);s.playSound(count(s,BIG_TOOTH)===2&&count(s,TOOTH)>=10?MIDDLE:ITEM);}else if(id===VUKU&&count(s,FOOTPRINT)&&!count(s,BOX1)&&Math.random()<.2){await q.giveItem(s.session,BOX1,1);await s.set("cond",6);s.playSound(MIDDLE);}}
+};
diff --git a/tests/test_quest_availability.js b/tests/test_quest_availability.js
index 13ff732f..a5123bc4 100644
--- a/tests/test_quest_availability.js
+++ b/tests/test_quest_availability.js
@@ -78,4 +78,8 @@ for (const itemId of Array.from({ length: 16 }, (_, index) => 1642 + index)) {
assert(itemIds.has(itemId), `Q417 requires missing quest item ${itemId}`);
}
+for (const itemId of [1622, 1623, 1632, 1633, 1634, 1635, 1636, 1637, 1638, 1639, 1640, 1641]) {
+ assert(itemIds.has(itemId), `Q418 requires missing quest item ${itemId}`);
+}
+
console.log("registered quest NPCs and kill targets are available");
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index 3f94965a..bbef1e0c 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -64,6 +64,7 @@ const Q414 = require("../src/GameServer/Quest/quests/Q414_PathToOrcRaider");
const Q415 = require("../src/GameServer/Quest/quests/Q415_PathToOrcMonk");
const Q416 = require("../src/GameServer/Quest/quests/Q416_PathToOrcShaman");
const Q417 = require("../src/GameServer/Quest/quests/Q417_PathToScavenger");
+const Q418 = require("../src/GameServer/Quest/quests/Q418_PathToArtisan");
async function main() {
assert.strictEqual(Q001.eventNpc("start"), 7048);
@@ -152,6 +153,8 @@ async function main() {
assert.strictEqual(Q416.eventNpc("net"), 7593);
assert.strictEqual(Q417.eventNpc("start"), 7524);
assert.strictEqual(Q417.eventNpc("mion"), 7519);
+ assert.strictEqual(Q418.eventNpc("start"), 7527);
+ assert.strictEqual(Q418.eventNpc("letter"), 7317);
assert.strictEqual(Q002.eventNpc("unknown"), null);
assert.strictEqual(Q004.eventNpc("unknown"), null);
assert.strictEqual(Q005.eventNpc("unknown"), null);
@@ -823,6 +826,19 @@ async function main() {
assert.strictEqual(awardedClassId, 54, "Q417 must award the Scavenger class");
assert.strictEqual(questState.completed, true, "Q417 must complete after the Succubus Undies are returned");
assert.strictEqual(items.get(1642), 1, "Q417 must retain the source Ring of Raven reward");
+
+ items.clear(); questState.started = false; questState.completed = false; questState.cond = 0; questState.values = {}; classId = 53; awardedClassId = null;
+ QuestService.awardFirstProfession = async (_, targetClassId) => { awardedClassId = targetClassId; return { ok: true, targetClassId }; };
+ await Q418.onEvent(questState, "start");
+ setItem(1636, 9); setItem(1637, 2); const originalRandomForArtisan = Math.random; Math.random = () => 0;
+ try { await Q418.onKill(questState, { fetchSelfId: () => 389 }); } finally { Math.random = originalRandomForArtisan; }
+ await Q418.onTalk(questState, { fetchSelfId: () => 7527 }); await Q418.onEvent(questState, "letter"); await Q418.onEvent(questState, "footprint");
+ const originalRandomForBox = Math.random; Math.random = () => 0;
+ try { await Q418.onKill(questState, { fetchSelfId: () => 17 }); } finally { Math.random = originalRandomForBox; }
+ await Q418.onEvent(questState, "box"); await Q418.onTalk(questState, { fetchSelfId: () => 7317 });
+ assert.strictEqual(awardedClassId, 56, "Q418 must award the Artisan class");
+ assert.strictEqual(questState.completed, true, "Q418 must complete after both pass certificates and Secret Box are returned");
+ assert.strictEqual(items.get(1635), 1, "Q418 must retain the source Final Pass Certificate reward");
} finally {
QuestService.takeItem = originalTake;
QuestService.giveItem = originalGive;
From 1b11b40b39ed4ca2cc97c32318f7c814e01a1a5e Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:52:47 -0400
Subject: [PATCH 22/23] Fix profession quest edge cases
---
src/GameServer/Quest/quests/Q409_PathToElvenOracle.js | 6 +++++-
src/GameServer/Quest/quests/Q415_PathToOrcMonk.js | 2 +-
tests/test_quest_runtime.js | 7 ++++++-
3 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/src/GameServer/Quest/quests/Q409_PathToElvenOracle.js b/src/GameServer/Quest/quests/Q409_PathToElvenOracle.js
index cd8a578c..682d5402 100644
--- a/src/GameServer/Quest/quests/Q409_PathToElvenOracle.js
+++ b/src/GameServer/Quest/quests/Q409_PathToElvenOracle.js
@@ -51,7 +51,11 @@ module.exports = {
state.playSound(ACCEPT);
return page("Manuel", "Investigate the false prophet Allana.");
}
- if (event === "lizardmen" && state.getInt("cond") === 1 && count(state, CRYSTAL_MEDALLION)) {
+ // The spawned captain can be killed by another player before its owner
+ // reaches it. Keep the encounter retryable until the captain's order is
+ // actually obtained (as in the source quest), rather than stranding the
+ // owner at condition 2.
+ if (event === "lizardmen" && state.isStarted() && count(state, CRYSTAL_MEDALLION) && !count(state, LIZARD_CAPTAIN_ORDER)) {
for (const selfId of [LIZARDMAN_WARRIOR, LIZARDMAN_SCOUT, LIZARDMAN]) state.addSpawn(selfId);
await state.set("cond", 2);
return page("Allana", "The lizardmen have appeared. Defend Allana.");
diff --git a/src/GameServer/Quest/quests/Q415_PathToOrcMonk.js b/src/GameServer/Quest/quests/Q415_PathToOrcMonk.js
index 6a7219eb..e8a063be 100644
--- a/src/GameServer/Quest/quests/Q415_PathToOrcMonk.js
+++ b/src/GameServer/Quest/quests/Q415_PathToOrcMonk.js
@@ -12,5 +12,5 @@ if(id===ROSHEEK){if(count(s,POMEGRANATE)){await q.takeItem(s.session,POMEGRANATE
if(id===GANTAKI){if(count(s,FIERY)&&count(s,ROSHEEK_LETTER)){await q.takeItem(s.session,ROSHEEK_LETTER);await q.giveItem(s.session,GANTAKI_LETTER,1);await s.set("cond",9);return page("Gantaki Zu Urutu","Take my letter to Kasman.");}return page("Gantaki Zu Urutu","Complete Rosheek's trials.");}
if(id===KASMAN){if(count(s,GANTAKI_LETTER)){await q.takeItem(s.session,GANTAKI_LETTER);await q.giveItem(s.session,FIG,1);await s.set("cond",10);return page("Kasman","Take this fig to Toruku.");}if(count(s,IRON)&&count(s,FIERY)&&count(s,TORUKU_LETTER)){const r=await q.awardFirstProfession(s,47);if(!r.ok)return page("Kasman","Your profession could not be granted. Keep the scrolls and try again.");for(const x of [IRON,FIERY,TORUKU_LETTER])await q.takeItem(s.session,x);await q.giveItem(s.session,TOTEM,1);s.playSound(FINISH);await s.exit(false);return page("Kasman","You have completed the Path to Monk and become a Monk.");}return page("Kasman","Complete Toruku's trial.");}
if(id===TORUKU){if(count(s,FIG)){await q.takeItem(s.session,FIG);await q.giveItem(s.session,P4,1);await s.set("cond",11);return page("Toruku","Fill the fourth pouch with the four kinds of trophies.");}if(count(s,P4F)){await q.takeItem(s.session,P4F);await q.giveItem(s.session,IRON,1);await q.giveItem(s.session,TORUKU_LETTER,1);await s.set("cond",13);return page("Toruku","Take my letter to Kasman.");}return page("Toruku",`Fourth pouch trophies: ${finalTotal(s)}/12.`);}return page("Quest","Continue your trial.");},
-async onKill(s,n){if(!s.isStarted())return;const id=Number(n.fetchSelfId()),q=service();if(id===BEAR&&count(s,P1))await fill(s,P1,P1F,BEAR_CLAW,5);else if(id===SPIDER&&count(s,P2))await fill(s,P2,P2F,SPIDER_TALON,5);else if(id===SALAMANDER&&count(s,P3))await fill(s,P3,P3F,SALAMANDER_SCALE,5);else if(FINAL_MOBS.has(id)&&count(s,P4)){if(finalTotal(s)>=11){for(const x of [1609,1610,1611,1612])await q.takeItem(s.session,x,-1);await q.takeItem(s.session,P4);await q.giveItem(s.session,P4F,1);s.playSound(MIDDLE);}else{await q.giveItem(s.session,FINAL_MOBS.get(id),1);s.playSound(ITEM);}}}
+async onKill(s,n){if(!s.isStarted())return;const id=Number(n.fetchSelfId()),q=service();if(id===BEAR&&count(s,P1))await fill(s,P1,P1F,BEAR_CLAW,5);else if(id===SPIDER&&count(s,P2))await fill(s,P2,P2F,SPIDER_TALON,5);else if(id===SALAMANDER&&count(s,P3))await fill(s,P3,P3F,SALAMANDER_SCALE,5);else if(FINAL_MOBS.has(id)&&count(s,P4)){const trophy=FINAL_MOBS.get(id);if(count(s,trophy)>=3)return;if(finalTotal(s)>=11){for(const x of [1609,1610,1611,1612])await q.takeItem(s.session,x,-1);await q.takeItem(s.session,P4);await q.giveItem(s.session,P4F,1);s.playSound(MIDDLE);}else{await q.giveItem(s.session,trophy,1);s.playSound(ITEM);}}}
};
diff --git a/tests/test_quest_runtime.js b/tests/test_quest_runtime.js
index bbef1e0c..005bc8a4 100644
--- a/tests/test_quest_runtime.js
+++ b/tests/test_quest_runtime.js
@@ -682,12 +682,14 @@ async function main() {
assert.strictEqual(items.get(1231), 1, "Q409 must issue the Crystal Medallion");
await Q409.onEvent(questState, "lizardmen");
assert.deepStrictEqual(spawnedQuestNpcs, [5032, 5033, 5034], "Q409 must spawn Allana's personal lizardman encounter");
+ await Q409.onEvent(questState, "lizardmen");
+ assert.deepStrictEqual(spawnedQuestNpcs, [5032, 5033, 5034, 5032, 5033, 5034], "Q409 must allow the personal lizardman encounter to be retried until the captain order is obtained");
await Q409.onKill(questState, { fetchSelfId: () => 5032 });
assert.strictEqual(items.get(1234), 1, "Q409 must drop the Lizard Captain Order from the spawned warrior");
await Q409.onTalk(questState, { fetchSelfId: () => 7424 });
assert.strictEqual(items.get(1236), 1, "Q409 must issue Half of Diary after the lizard encounter");
await Q409.onEvent(questState, "tamato");
- assert.deepStrictEqual(spawnedQuestNpcs, [5032, 5033, 5034, 5035], "Q409 must spawn Tamato as a personal quest encounter");
+ assert.deepStrictEqual(spawnedQuestNpcs, [5032, 5033, 5034, 5032, 5033, 5034, 5035], "Q409 must spawn Tamato as a personal quest encounter");
await Q409.onKill(questState, { fetchSelfId: () => 5035 });
assert.strictEqual(items.get(1275), 1, "Q409 must drop Tamato's Necklace from the spawned Tamato");
await Q409.onTalk(questState, { fetchSelfId: () => 7428 });
@@ -793,6 +795,9 @@ async function main() {
setItem(1601, 4); await Q415.onKill(questState, { fetchSelfId: () => 478 }); await Q415.onTalk(questState, { fetchSelfId: () => 7590 });
setItem(1602, 4); await Q415.onKill(questState, { fetchSelfId: () => 415 }); await Q415.onTalk(questState, { fetchSelfId: () => 7590 });
await Q415.onTalk(questState, { fetchSelfId: () => 7587 }); await Q415.onTalk(questState, { fetchSelfId: () => 7501 }); await Q415.onTalk(questState, { fetchSelfId: () => 7591 });
+ for (let i = 0; i < 12; i += 1) await Q415.onKill(questState, { fetchSelfId: () => 14 });
+ assert.strictEqual(items.get(1612), 3, "Q415 must cap each fourth-pouch trophy at three");
+ assert.strictEqual(items.get(1608) || 0, 0, "Q415 must require trophies from all four fourth-pouch targets");
setItem(1609, 3); setItem(1610, 3); setItem(1611, 3); setItem(1612, 2); await Q415.onKill(questState, { fetchSelfId: () => 14 });
await Q415.onTalk(questState, { fetchSelfId: () => 7591 }); await Q415.onTalk(questState, { fetchSelfId: () => 7501 });
assert.strictEqual(awardedClassId, 47, "Q415 must award the Monk class");
From 26fc1c5d9b6483ae2b0bd58218b24920e47e3584 Mon Sep 17 00:00:00 2001
From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com>
Date: Thu, 23 Jul 2026 17:06:51 -0400
Subject: [PATCH 23/23] Update quest registry packet test
---
tests/test_quest_packets.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/test_quest_packets.js b/tests/test_quest_packets.js
index f59f31f7..f4765ff9 100644
--- a/tests/test_quest_packets.js
+++ b/tests/test_quest_packets.js
@@ -11,6 +11,8 @@ assert.deepStrictEqual(
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 34, 36, 42, 43, 45, 46, 47, 48, 49, 101, 102, 103,
104, 105, 106, 107, 108, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160,
161, 162, 163, 164, 165, 166, 167, 168, 169, 170,
+ 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415,
+ 416, 417, 418,
],
"early C4 quests register in deterministic order",
);