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", );