Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions data/Items/Others/others.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
4 changes: 4 additions & 0 deletions data/Npcs/Spawns/spawns.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
79 changes: 79 additions & 0 deletions src/GameServer/ClassTransfer.js
Original file line number Diff line number Diff line change
@@ -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 };
18 changes: 18 additions & 0 deletions src/GameServer/Network/Response/RadarControl.js
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src/GameServer/Network/Response/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
117 changes: 98 additions & 19 deletions src/GameServer/Quest/QuestService.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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 ClassTransfer = invoke("GameServer/ClassTransfer");

const quests = [
require("./quests/Q001_LettersOfLove"),
Expand All @@ -17,27 +19,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"),
Expand Down Expand Up @@ -71,6 +56,24 @@ const quests = [
require("./quests/Q168_DeliverSupplies"),
require("./quests/Q169_OffspringOfNightmares"),
require("./quests/Q170_DangerousSeduction"),
require("./quests/Q401_PathToWarrior"),
require("./quests/Q402_PathToKnight"),
require("./quests/Q403_PathToRogue"),
require("./quests/Q404_PathToWizard"),
require("./quests/Q405_PathToCleric"),
require("./quests/Q406_PathToElvenKnight"),
require("./quests/Q407_PathToElvenScout"),
require("./quests/Q408_PathToElvenWizard"),
require("./quests/Q409_PathToElvenOracle"),
require("./quests/Q410_PathToPalusKnight"),
require("./quests/Q411_PathToAssassin"),
require("./quests/Q412_PathToDarkWizard"),
require("./quests/Q413_PathToShillienOracle"),
require("./quests/Q414_PathToOrcRaider"),
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]));

Expand Down Expand Up @@ -260,8 +263,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(),
Expand Down Expand Up @@ -300,6 +309,67 @@ 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,
});
}

// 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
Expand All @@ -315,9 +385,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);
Expand Down Expand Up @@ -353,6 +427,11 @@ module.exports = {
rewardAdena,
rewardExpSp,
questDropAmount,
addRadar,
removeRadar,
clearRadars,
spawnQuestNpc,
awardFirstProfession,
questRates,
quests: () => quests,
};
9 changes: 9 additions & 0 deletions src/GameServer/Quest/QuestState.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading