diff --git a/serverdata/skill/xp_limits.sdb b/serverdata/skill/xp_limits.sdb
new file mode 100644
index 000000000..e868f7e21
--- /dev/null
+++ b/serverdata/skill/xp_limits.sdb
@@ -0,0 +1,51 @@
+name limit
+
+apprenticeship 1240
+crafting_bio_engineer_creature 20000
+bio_engineer_dna_harvesting 20000
+bountyhunter 10000
+camp 2000
+combat_general 170000
+combat_meleespecialize_onehandlightsaber 3000
+combat_meleespecialize_twohandlightsaber 200000
+combat_meleespecialize_polearmlightsaber 200000
+jedi_general 90000
+combat_meleespecialize_onehand 10000
+combat_meleespecialize_polearm 10000
+combat_meleespecialize_twohand 10000
+combat_meleespecialize_unarmed 10000
+combat_rangedspecialize_carbine 200000
+combat_rangedspecialize_pistol 200000
+combat_rangedspecialize_rifle 200000
+crafting_clothing_armor 56000
+crafting_clothing_general 56000
+crafting_droid_general 56000
+crafting_food_general 56000
+crafting_general 1000
+crafting_medicine_general 1000
+crafting_spice 45000
+crafting_structure_general 140000
+crafting_weapons_general 56000
+creaturehandler 250000
+dance 2000
+entertainer_healing 2000
+entertainer 2000
+imagedesigner 2000
+jedi 2000
+medical 2000
+merchant 10000
+music 2000
+resource_harvesting_inorganic 1000
+scout 2000
+slicing 30000
+squadleader 500000
+trapping 2000
+combat_rangedspecialize_heavy 300000
+political 20000
+force_rank_xp 625000
+fs_combat 5100000
+fs_crafting 5100000
+fs_senses 5100000
+fs_reflex 5100000
+shipwright 60000
+space_combat_general 10000
diff --git a/src/main/java/com/projectswg/holocore/resources/support/data/server_info/loader/ServerData.kt b/src/main/java/com/projectswg/holocore/resources/support/data/server_info/loader/ServerData.kt
index 2421c9ae6..df2188381 100644
--- a/src/main/java/com/projectswg/holocore/resources/support/data/server_info/loader/ServerData.kt
+++ b/src/main/java/com/projectswg/holocore/resources/support/data/server_info/loader/ServerData.kt
@@ -52,6 +52,7 @@ object ServerData {
* Skill / Collection
*/
val skills by SoftDataLoaderDelegate(::SkillLoader)
+ val xpLimits by SoftDataLoaderDelegate(::XpLimitLoader)
/*
* Player
diff --git a/src/main/java/com/projectswg/holocore/resources/support/data/server_info/loader/XpLimitLoader.kt b/src/main/java/com/projectswg/holocore/resources/support/data/server_info/loader/XpLimitLoader.kt
new file mode 100644
index 000000000..535441dc0
--- /dev/null
+++ b/src/main/java/com/projectswg/holocore/resources/support/data/server_info/loader/XpLimitLoader.kt
@@ -0,0 +1,45 @@
+/***********************************************************************************
+ * Copyright (c) 2026 /// Project SWG /// www.projectswg.com *
+ * *
+ * ProjectSWG is an emulation project for Star Wars Galaxies founded on *
+ * July 7th, 2011 after SOE announced the official shutdown of Star Wars Galaxies. *
+ * Our goal is to create one or more emulators which will provide servers for *
+ * players to continue playing a game similar to the one they used to play. *
+ * *
+ * This file is part of Holocore. *
+ * *
+ * --------------------------------------------------------------------------------*
+ * *
+ * Holocore is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU Affero General Public License as *
+ * published by the Free Software Foundation, either version 3 of the *
+ * License, or (at your option) any later version. *
+ * *
+ * Holocore is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * GNU Affero General Public License for more details. *
+ * *
+ * You should have received a copy of the GNU Affero General Public License *
+ * along with Holocore. If not, see . *
+ ***********************************************************************************/
+package com.projectswg.holocore.resources.support.data.server_info.loader
+
+import com.projectswg.holocore.resources.support.data.server_info.SdbLoader
+import java.io.File
+import java.io.IOException
+
+class XpLimitLoader internal constructor() : DataLoader() {
+ private val limits: MutableMap = HashMap()
+
+ fun getLimit(xpType: String): Int? = limits[xpType]
+
+ @Throws(IOException::class)
+ override fun load() {
+ SdbLoader.load(File("serverdata/skill/xp_limits.sdb")).use { set ->
+ while (set.next()) {
+ limits[set.getText("name")] = set.getInt("limit").toInt()
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/player/experience/ExperiencePointService.java b/src/main/java/com/projectswg/holocore/services/gameplay/player/experience/ExperiencePointService.java
deleted file mode 100644
index 2d98b07f4..000000000
--- a/src/main/java/com/projectswg/holocore/services/gameplay/player/experience/ExperiencePointService.java
+++ /dev/null
@@ -1,101 +0,0 @@
-/***********************************************************************************
- * Copyright (c) 2024 /// Project SWG /// www.projectswg.com *
- * *
- * ProjectSWG is the first NGE emulator for Star Wars Galaxies founded on *
- * July 7th, 2011 after SOE announced the official shutdown of Star Wars Galaxies. *
- * Our goal is to create an emulator which will provide a server for players to *
- * continue playing a game similar to the one they used to play. We are basing *
- * it on the final publish of the game prior to end-game events. *
- * *
- * This file is part of Holocore. *
- * *
- * --------------------------------------------------------------------------------*
- * *
- * Holocore is free software: you can redistribute it and/or modify *
- * it under the terms of the GNU Affero General Public License as *
- * published by the Free Software Foundation, either version 3 of the *
- * License, or (at your option) any later version. *
- * *
- * Holocore is distributed in the hope that it will be useful, *
- * but WITHOUT ANY WARRANTY; without even the implied warranty of *
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
- * GNU Affero General Public License for more details. *
- * *
- * You should have received a copy of the GNU Affero General Public License *
- * along with Holocore. If not, see . *
- ***********************************************************************************/
-package com.projectswg.holocore.services.gameplay.player.experience;
-
-import com.projectswg.common.data.encodables.oob.OutOfBandPackage;
-import com.projectswg.common.data.encodables.oob.ProsePackage;
-import com.projectswg.common.data.encodables.oob.StringId;
-import com.projectswg.common.network.packets.swg.zone.chat.ChatSystemMessage;
-import com.projectswg.common.network.packets.swg.zone.object_controller.ShowFlyText;
-import com.projectswg.common.network.packets.swg.zone.object_controller.ShowFlyText.Scale;
-import com.projectswg.holocore.intents.gameplay.player.experience.ExperienceIntent;
-import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent;
-import com.projectswg.holocore.resources.support.color.SWGColor;
-import com.projectswg.holocore.resources.support.data.server_info.StandardLog;
-import com.projectswg.holocore.resources.support.data.server_info.mongodb.PswgDatabase;
-import com.projectswg.holocore.resources.support.objects.swg.SWGObject;
-import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject;
-import com.projectswg.holocore.resources.support.objects.swg.player.PlayerObject;
-import me.joshlarson.jlcommon.control.IntentHandler;
-import me.joshlarson.jlcommon.control.Service;
-
-import java.util.Objects;
-
-public class ExperiencePointService extends Service {
-
- private final double xpMultiplier;
-
- public ExperiencePointService() {
- xpMultiplier = PswgDatabase.INSTANCE.getConfig().getDouble(this, "xpMultiplier", 1);
- }
-
- @IntentHandler
- private void handleExperienceIntent(ExperienceIntent ei) {
- CreatureObject creatureObject = ei.getCreatureObject();
- PlayerObject playerObject = creatureObject.getPlayerObject();
-
- if (playerObject != null) {
- String xpType = ei.getXpType();
- int experienceGained = ei.getExperienceGained();
- SWGObject flytextTarget = ei.getFlytextTarget();
- boolean xpMultiplied = ei.isXpMultiplied();
-
- if (xpMultiplied) {
- experienceGained *= xpMultiplier;
- }
-
- awardExperience(creatureObject, flytextTarget, playerObject, xpType, experienceGained);
- }
- }
-
- private void awardExperience(CreatureObject creatureObject, SWGObject flytextTarget, PlayerObject playerObject, String xpType, int xpGained) {
- int currentXp = playerObject.getExperiencePoints(xpType);
- int newXpTotal = currentXp + xpGained;
-
- playerObject.setExperiencePoints(xpType, newXpTotal);
- StandardLog.onPlayerTrace(this, creatureObject, "gained %d %s XP", xpGained, xpType);
-
- if (!Objects.equals("combat_general", xpType)) {
- showFlytext(creatureObject, flytextTarget, xpGained);
- showSystemMessage(creatureObject, xpType);
- }
- }
-
- private void showSystemMessage(CreatureObject creatureObject, String xpType) {
- // TODO display different messages with inspiration bonus and/or group bonus
- StringId xpTypeDisplayName = new StringId("exp_n", xpType);
- ProsePackage message = new ProsePackage(new StringId("base_player", "prose_grant_xp"), "TO", xpTypeDisplayName);
- SystemMessageIntent.Companion.broadcastPersonal(creatureObject.getOwner(), message, ChatSystemMessage.SystemChatType.CHAT_BOX);
- }
-
- private void showFlytext(CreatureObject creatureObject, SWGObject flytextTarget, int xpGained) {
- OutOfBandPackage message = new OutOfBandPackage(new ProsePackage(new StringId("base_player", "prose_flytext_xp"), "DI", xpGained));
- ShowFlyText packet = new ShowFlyText(flytextTarget.getObjectId(), message, Scale.MEDIUM, SWGColor.Violets.INSTANCE.getMagenta());
- creatureObject.sendSelf(packet);
- }
-
-}
diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/player/experience/ExperiencePointService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/player/experience/ExperiencePointService.kt
new file mode 100644
index 000000000..2d847b1c1
--- /dev/null
+++ b/src/main/java/com/projectswg/holocore/services/gameplay/player/experience/ExperiencePointService.kt
@@ -0,0 +1,123 @@
+/***********************************************************************************
+ * Copyright (c) 2024 /// Project SWG /// www.projectswg.com *
+ * *
+ * ProjectSWG is the first NGE emulator for Star Wars Galaxies founded on *
+ * July 7th, 2011 after SOE announced the official shutdown of Star Wars Galaxies. *
+ * Our goal is to create an emulator which will provide a server for players to *
+ * continue playing a game similar to the one they used to play. We are basing *
+ * it on the final publish of the game prior to end-game events. *
+ * *
+ * This file is part of Holocore. *
+ * *
+ * --------------------------------------------------------------------------------*
+ * *
+ * Holocore is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU Affero General Public License as *
+ * published by the Free Software Foundation, either version 3 of the *
+ * License, or (at your option) any later version. *
+ * *
+ * Holocore is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * GNU Affero General Public License for more details. *
+ * *
+ * You should have received a copy of the GNU Affero General Public License *
+ * along with Holocore. If not, see . *
+ ***********************************************************************************/
+package com.projectswg.holocore.services.gameplay.player.experience
+
+import com.projectswg.common.data.encodables.oob.OutOfBandPackage
+import com.projectswg.common.data.encodables.oob.ProsePackage
+import com.projectswg.common.data.encodables.oob.StringId
+import com.projectswg.common.network.packets.swg.zone.chat.ChatSystemMessage
+import com.projectswg.common.network.packets.swg.zone.object_controller.ShowFlyText
+import com.projectswg.holocore.intents.gameplay.player.experience.ExperienceIntent
+import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent
+import com.projectswg.holocore.resources.support.color.SWGColor
+import com.projectswg.holocore.resources.support.data.server_info.StandardLog
+import com.projectswg.holocore.resources.support.data.server_info.loader.ServerData
+import com.projectswg.holocore.resources.support.data.server_info.mongodb.PswgDatabase
+import com.projectswg.holocore.resources.support.objects.swg.SWGObject
+import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject
+import com.projectswg.holocore.resources.support.objects.swg.player.PlayerObject
+import me.joshlarson.jlcommon.control.IntentHandler
+import me.joshlarson.jlcommon.control.Service
+
+class ExperiencePointService : Service() {
+
+ private val xpMultiplier = PswgDatabase.config.getDouble(this, "xpMultiplier", 1.0)
+
+ @IntentHandler
+ private fun handleExperienceIntent(ei: ExperienceIntent) {
+ val creatureObject = ei.creatureObject ?: return
+ val playerObject = creatureObject.playerObject ?: return
+ val xpType = ei.xpType ?: return
+ var experienceGained = ei.experienceGained
+
+ if (ei.isXpMultiplied) {
+ experienceGained = (experienceGained * xpMultiplier).toInt()
+ }
+
+ awardExperience(creatureObject, ei.flytextTarget, playerObject, xpType, experienceGained)
+ }
+
+ private fun awardExperience(creatureObject: CreatureObject, flytextTarget: SWGObject?, playerObject: PlayerObject, xpType: String, xpGained: Int) {
+ val currentXp = playerObject.getExperiencePoints(xpType)
+ val xpLimit = getXpLimit(creatureObject, xpType)
+
+ if (xpGained > 0 && currentXp >= xpLimit) {
+ StandardLog.onPlayerTrace(this, creatureObject, "gained no %s XP, already at the limit of %d", xpType, xpLimit)
+ return
+ }
+
+ val newXpTotal = minOf(currentXp + xpGained, xpLimit)
+ val actualXpGained = newXpTotal - currentXp
+
+ playerObject.setExperiencePoints(xpType, newXpTotal)
+ StandardLog.onPlayerTrace(this, creatureObject, "gained %d %s XP", actualXpGained, xpType)
+
+ if (newXpTotal == xpLimit && xpGained > 0) {
+ StandardLog.onPlayerTrace(this, creatureObject, "reached the %s XP limit of %d", xpType, xpLimit)
+ }
+
+ if (xpType != "combat_general") {
+ showFlytext(creatureObject, flytextTarget, actualXpGained)
+ showSystemMessage(creatureObject, xpType)
+ }
+ }
+
+ /**
+ * The limit is the largest cap among the skills the player has for this XP type. Players without any such skill are
+ * held to the default limit from the client datatable, or [UNKNOWN_XP_TYPE_LIMIT] if the XP type has no default.
+ */
+ private fun getXpLimit(creatureObject: CreatureObject, xpType: String): Int {
+ val skillLimit = creatureObject.skills
+ .mapNotNull { ServerData.skills.getSkillByName(it) }
+ .filter { it.xpType == xpType && it.xpCap > 0 }
+ .maxOfOrNull { it.xpCap }
+
+ return skillLimit ?: ServerData.xpLimits.getLimit(xpType) ?: UNKNOWN_XP_TYPE_LIMIT
+ }
+
+ private fun showSystemMessage(creatureObject: CreatureObject, xpType: String) {
+ // TODO display different messages with inspiration bonus and/or group bonus
+ val owner = creatureObject.owner ?: return
+ val xpTypeDisplayName = StringId("exp_n", xpType)
+ val message = ProsePackage(StringId("base_player", "prose_grant_xp"), "TO", xpTypeDisplayName)
+ SystemMessageIntent.broadcastPersonal(owner, message, ChatSystemMessage.SystemChatType.CHAT_BOX)
+ }
+
+ private fun showFlytext(creatureObject: CreatureObject, flytextTarget: SWGObject?, xpGained: Int) {
+ if (flytextTarget == null) {
+ return
+ }
+
+ val message = OutOfBandPackage(ProsePackage(StringId("base_player", "prose_flytext_xp"), "DI", xpGained))
+ val packet = ShowFlyText(flytextTarget.objectId, message, ShowFlyText.Scale.MEDIUM, SWGColor.Violets.magenta)
+ creatureObject.sendSelf(packet)
+ }
+
+ private companion object {
+ private const val UNKNOWN_XP_TYPE_LIMIT = 2000
+ }
+}
diff --git a/src/test/java/com/projectswg/holocore/headless/admin.kt b/src/test/java/com/projectswg/holocore/headless/admin.kt
index bdc70cf4f..02e29c937 100644
--- a/src/test/java/com/projectswg/holocore/headless/admin.kt
+++ b/src/test/java/com/projectswg/holocore/headless/admin.kt
@@ -51,6 +51,15 @@ fun ZonedInCharacter.adminGrantSkill(skill: String) {
player.waitForNextObjectDelta(player.creatureObject.objectId, 4, 14, 50, TimeUnit.MILLISECONDS) ?: java.lang.IllegalStateException("No known packet received")
}
+/**
+ * Admin command /setExperience
+ * @param xpType the type of XP to award
+ * @param xpGained the amount of XP to award
+ */
+fun ZonedInCharacter.adminSetExperience(xpType: String, xpGained: Int) {
+ sendCommand("setExperience", player.creatureObject, "$xpType $xpGained")
+}
+
/**
* Admin command /teleport
* @param target The object to teleport. If null, the player executing the command will be teleported.
diff --git a/src/test/java/com/projectswg/holocore/services/gameplay/player/experience/XpLimitTest.kt b/src/test/java/com/projectswg/holocore/services/gameplay/player/experience/XpLimitTest.kt
new file mode 100644
index 000000000..c016e7c3e
--- /dev/null
+++ b/src/test/java/com/projectswg/holocore/services/gameplay/player/experience/XpLimitTest.kt
@@ -0,0 +1,72 @@
+/***********************************************************************************
+ * Copyright (c) 2026 /// Project SWG /// www.projectswg.com *
+ * *
+ * ProjectSWG is an emulation project for Star Wars Galaxies founded on *
+ * July 7th, 2011 after SOE announced the official shutdown of Star Wars Galaxies. *
+ * Our goal is to create one or more emulators which will provide servers for *
+ * players to continue playing a game similar to the one they used to play. *
+ * *
+ * This file is part of Holocore. *
+ * *
+ * --------------------------------------------------------------------------------*
+ * *
+ * Holocore is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU Affero General Public License as *
+ * published by the Free Software Foundation, either version 3 of the *
+ * License, or (at your option) any later version. *
+ * *
+ * Holocore is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * GNU Affero General Public License for more details. *
+ * *
+ * You should have received a copy of the GNU Affero General Public License *
+ * along with Holocore. If not, see . *
+ ***********************************************************************************/
+package com.projectswg.holocore.services.gameplay.player.experience
+
+import com.projectswg.holocore.headless.*
+import com.projectswg.holocore.resources.support.global.player.AccessLevel
+import com.projectswg.holocore.test.runners.AcceptanceTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class XpLimitTest : AcceptanceTest() {
+
+ @Test
+ fun defaultLimitCapsXp() {
+ val character = createCharacter()
+
+ character.adminSetExperience("trapping", 5000)
+
+ character.waitForExperiencePoints("trapping")
+ assertEquals(2000, character.getXP("trapping"))
+ }
+
+ @Test
+ fun skillBoxRaisesLimit() {
+ val character = createCharacter()
+ character.adminGrantSkill("outdoors_scout_tools_01")
+
+ character.adminSetExperience("trapping", 5000)
+
+ character.waitForExperiencePoints("trapping")
+ assertEquals(5000, character.getXP("trapping"))
+ }
+
+ @Test
+ fun noXpGainedWhenAtLimit() {
+ val character = createCharacter()
+ character.adminSetExperience("trapping", 2000)
+ character.waitForExperiencePoints("trapping")
+
+ character.adminSetExperience("trapping", 1000)
+
+ assertEquals(2000, character.getXP("trapping"))
+ }
+
+ private fun createCharacter(): ZonedInCharacter {
+ val user = generateUser(accessLevel = AccessLevel.DEV)
+ return HeadlessSWGClient.createZonedInCharacter(user.username, user.password, "adminchar")
+ }
+}
diff --git a/src/utility/java/com/projectswg/utility/ClientdataConvertAll.java b/src/utility/java/com/projectswg/utility/ClientdataConvertAll.java
index 6ccc78872..b032ee741 100644
--- a/src/utility/java/com/projectswg/utility/ClientdataConvertAll.java
+++ b/src/utility/java/com/projectswg/utility/ClientdataConvertAll.java
@@ -29,6 +29,7 @@ public static void main(String [] args) throws IOException {
Converters.QUESTTASK.load();
Converters.SCHEMATIC_GROUP.load();
Converters.FACTION_RANK.load();
+ Converters.XP_LIMITS.load();
}
}
diff --git a/src/utility/java/com/projectswg/utility/clientdata/Converters.java b/src/utility/java/com/projectswg/utility/clientdata/Converters.java
index 933f9f864..c7b889cc1 100644
--- a/src/utility/java/com/projectswg/utility/clientdata/Converters.java
+++ b/src/utility/java/com/projectswg/utility/clientdata/Converters.java
@@ -23,6 +23,7 @@ public enum Converters {
QUESTTASK (ConvertQuestTasks::new),
SCHEMATIC_GROUP (() -> new ConvertDatatable("datatables/crafting/schematic_group.iff", "serverdata/crafting/schematic_group.sdb", true)),
FACTION_RANK (() -> new ConvertDatatable("datatables/faction/rank.iff", "serverdata/faction/rank.sdb", true)),
+ XP_LIMITS (() -> new ConvertDatatable("datatables/skill/xp_limits.iff", "serverdata/skill/xp_limits.sdb", true)),
STRINGS (ConvertStrings::new),
TERRAINS (ConvertTerrain::new);