diff --git a/src/main/java/com/projectswg/holocore/intents/gameplay/items/BioLinkIntents.kt b/src/main/java/com/projectswg/holocore/intents/gameplay/items/BioLinkIntents.kt
new file mode 100644
index 000000000..d7558c001
--- /dev/null
+++ b/src/main/java/com/projectswg/holocore/intents/gameplay/items/BioLinkIntents.kt
@@ -0,0 +1,33 @@
+/***********************************************************************************
+ * 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.intents.gameplay.items
+
+import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject
+import com.projectswg.holocore.resources.support.objects.swg.tangible.TangibleObject
+import me.joshlarson.jlcommon.control.Intent
+
+data class RequestBioLinkIntent(val linker: CreatureObject, val item: TangibleObject) : Intent()
+data class BioLinkNowIntent(val linker: CreatureObject, val item: TangibleObject) : Intent()
diff --git a/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/TransferItemCallback.kt b/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/TransferItemCallback.kt
index 222f5b229..c380be544 100644
--- a/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/TransferItemCallback.kt
+++ b/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/TransferItemCallback.kt
@@ -129,6 +129,22 @@ class TransferItemCallback : ICmdCallback {
}
if (target is TangibleObject) {
+ if (target.isBioLinkRequired) {
+ val bioLinkedTo = target.bioLinkedTo
+
+ if (bioLinkedTo == null) {
+ SystemMessageIntent(player, "@base_player:not_linked").broadcast()
+ player.sendPacket(PlayMusicMessage(0, "sound/ui_dialog_warning.snd", 1, false))
+ return
+ }
+
+ if (bioLinkedTo != actor.objectId) {
+ SystemMessageIntent(player, "@container_error_message:container31").broadcast()
+ player.sendPacket(PlayMusicMessage(0, "sound/ui_dialog_warning.snd", 1, false))
+ return
+ }
+ }
+
val armorCategory: ArmorCategory? = target.armorCategory
if (armorCategory != null) {
diff --git a/src/main/java/com/projectswg/holocore/resources/support/objects/StaticItemCreator.kt b/src/main/java/com/projectswg/holocore/resources/support/objects/StaticItemCreator.kt
index da42de775..ed207f9f4 100644
--- a/src/main/java/com/projectswg/holocore/resources/support/objects/StaticItemCreator.kt
+++ b/src/main/java/com/projectswg/holocore/resources/support/objects/StaticItemCreator.kt
@@ -88,6 +88,7 @@ object StaticItemCreator {
}
}
obj.armorCategory = info.armorCategory
+ obj.isBioLinkRequired = info.isBioLink
val protection = Protection(
kineticMax,
@@ -112,6 +113,7 @@ object StaticItemCreator {
applySkillMods(obj, info.skillMods)
obj.requiredCombatLevel = info.requiredLevel
obj.requiredFaction = ServerData.factions.getFaction(info.requiredFaction)
+ obj.isBioLinkRequired = info.isBioLink
applyColors(obj, info.color)
applyItemValue(info.value, obj)
}
@@ -120,6 +122,7 @@ object StaticItemCreator {
if (info == null)
return
obj.requiredCombatLevel = info.requiredLevel
+ obj.isBioLinkRequired = info.isBioLink
val weapon = obj as WeaponObject
weapon.type = info.weaponType
@@ -151,6 +154,7 @@ object StaticItemCreator {
obj.counter = info.charges
}
+ obj.isBioLinkRequired = info.bioLink
applyItemValue(info.value, obj)
}
diff --git a/src/main/java/com/projectswg/holocore/resources/support/objects/radial/RadialHandler.java b/src/main/java/com/projectswg/holocore/resources/support/objects/radial/RadialHandler.java
index 5ffef1207..32ecdf70d 100644
--- a/src/main/java/com/projectswg/holocore/resources/support/objects/radial/RadialHandler.java
+++ b/src/main/java/com/projectswg/holocore/resources/support/objects/radial/RadialHandler.java
@@ -56,7 +56,7 @@ public enum RadialHandler {
private final Map handlers = new HashMap<>();
private final Map gotHandlers = new EnumMap<>(GameObjectType.class);
private final Map, RadialHandlerInterface> classHandlers = new HashMap<>();
-
+
RadialHandler() {
initializeTerminalRadials();
initializeSurveyRadials();
@@ -66,7 +66,8 @@ public enum RadialHandler {
initializeSpecialEditionGoggleRadials();
initializeMeleeWeaponRadials();
initializeDeedRadials();
-
+ initializeBioLinkRadials();
+
RadialHandlerInterface aiHandler = new AIObjectRadial();
classHandlers.put(AIObject.class, aiHandler);
@@ -139,6 +140,13 @@ private void initializePetRadials() {
registerHandler(GameObjectType.GOT_VEHICLE_HOVER, new VehicleMountRadial());
}
+ private void initializeBioLinkRadials() {
+ RadialHandlerInterface bioLinkHandler = new BioLinkRadial();
+
+ registerHandler(GameObjectType.GOT_ARMOR, bioLinkHandler);
+ registerHandler(GameObjectType.GOT_WEAPON, bioLinkHandler);
+ }
+
private void initializeMiscRadials() {
registerHandler(GameObjectType.GOT_COMPONENT_SABER_CRYSTAL, new TuneCrystalRadial());
registerHandler("object/tangible/spawning/shared_spawn_egg.iff", new SpawnerRadial());
diff --git a/src/main/java/com/projectswg/holocore/resources/support/objects/radial/object/BioLinkRadial.kt b/src/main/java/com/projectswg/holocore/resources/support/objects/radial/object/BioLinkRadial.kt
new file mode 100644
index 000000000..72f17b1d4
--- /dev/null
+++ b/src/main/java/com/projectswg/holocore/resources/support/objects/radial/object/BioLinkRadial.kt
@@ -0,0 +1,53 @@
+/***********************************************************************************
+ * 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.objects.radial.`object`
+
+import com.projectswg.common.data.radial.RadialItem
+import com.projectswg.common.data.radial.RadialOption
+import com.projectswg.holocore.intents.gameplay.items.RequestBioLinkIntent
+import com.projectswg.holocore.resources.support.global.player.Player
+import com.projectswg.holocore.resources.support.objects.swg.SWGObject
+import com.projectswg.holocore.resources.support.objects.swg.tangible.TangibleObject
+
+class BioLinkRadial : SWGObjectRadial() {
+
+ override fun getOptions(options: MutableCollection, player: Player, target: SWGObject) {
+ if (isLinkable(target)) {
+ options.add(RadialOption.create(RadialItem.BIO_LINK, "@ui_radial:bio_link"))
+ }
+ }
+
+ override fun handleSelection(player: Player, target: SWGObject, selection: RadialItem) {
+ if (selection != RadialItem.BIO_LINK) return
+ if (!isLinkable(target)) return
+
+ RequestBioLinkIntent(player.creatureObject, target as TangibleObject).broadcast() // Shows the confirmation window
+ }
+
+ private fun isLinkable(target: SWGObject): Boolean {
+ return target is TangibleObject && target.isBioLinkRequired && target.bioLinkedTo == null
+ }
+}
diff --git a/src/main/java/com/projectswg/holocore/resources/support/objects/swg/tangible/TangibleObject.java b/src/main/java/com/projectswg/holocore/resources/support/objects/swg/tangible/TangibleObject.java
index 3558acecd..166c1033b 100644
--- a/src/main/java/com/projectswg/holocore/resources/support/objects/swg/tangible/TangibleObject.java
+++ b/src/main/java/com/projectswg/holocore/resources/support/objects/swg/tangible/TangibleObject.java
@@ -86,6 +86,7 @@ public class TangibleObject extends SWGObject {
private String requiredSkill;
private DamageType lightsaberColorCrystalElementalType;
private int lightsaberColorCrystalDamagePercent;
+ private boolean bioLinkRequired;
private Map skillMods = new LinkedHashMap<>();
private long lastCombat = 0;
@@ -376,6 +377,31 @@ public void setRequiredCombatLevel(int requiredCombatLevel) {
this.requiredCombatLevel = requiredCombatLevel;
}
+ /**
+ * @return {@code true} if this item must be bio-linked before it can be equipped or used
+ */
+ public boolean isBioLinkRequired() {
+ return bioLinkRequired;
+ }
+
+ public void setBioLinkRequired(boolean bioLinkRequired) {
+ this.bioLinkRequired = bioLinkRequired;
+ }
+
+ /**
+ * @return object ID of the character this item is bio-linked to, or {@code null} if it isn't linked yet
+ */
+ @Nullable
+ public Long getBioLinkedTo() {
+ if (!bioLinkRequired)
+ return null;
+ return (Long) getServerAttribute(ServerAttribute.LINK_OBJECT_ID);
+ }
+
+ public void setBioLinkedTo(long bioLinkedTo) {
+ setServerAttribute(ServerAttribute.LINK_OBJECT_ID, bioLinkedTo);
+ }
+
public Faction getRequiredFaction() {
return requiredFaction;
}
@@ -467,8 +493,10 @@ public AttributeList getAttributeList(CreatureObject viewer) {
attributeList.putNumber("cat_skill_mod_bonus.@stat_n:" + skillMod, value);
}
- // TODO bio-link would go here, if this item is bio-link
-
+ if (bioLinkRequired) {
+ attributeList.putText("bio_link", getBioLinkAttributeValue());
+ }
+
if (getGameObjectType() == GameObjectType.GOT_COMPONENT_SABER_CRYSTAL) {
displayLightsaberCrystalAttributes(attributeList);
}
@@ -511,6 +539,22 @@ public AttributeList getAttributeList(CreatureObject viewer) {
return attributeList;
}
+ private String getBioLinkAttributeValue() {
+ Long bioLinkedTo = getBioLinkedTo();
+
+ if (bioLinkedTo == null) {
+ return "@obj_attr_n:bio_link_pending";
+ }
+
+ SWGObject owner = ObjectStorageService.ObjectLookup.getObjectById(bioLinkedTo);
+
+ if (owner == null) {
+ return "@obj_attr_n:bio_link_unknown";
+ }
+
+ return owner.getObjectName();
+ }
+
private boolean isOnlyWearableBySome(Set speciesRestrictions) {
return speciesRestrictions.size() != Race.values().length;
}
@@ -656,6 +700,7 @@ public void saveMongo(MongoData data) {
data.putString("lightsaberColorCrystalElementalType", lightsaberColorCrystalElementalType.name());
}
data.putInteger("lightsaberColorCrystalDamagePercent", lightsaberColorCrystalDamagePercent);
+ data.putBoolean("bioLinkRequired", bioLinkRequired);
}
@Override
@@ -694,6 +739,7 @@ public void readMongo(MongoData data) {
lightsaberColorCrystalElementalType = DamageType.valueOf(data.getString("lightsaberColorCrystalElementalType"));
}
lightsaberColorCrystalDamagePercent = data.getInteger("lightsaberColorCrystalDamagePercent", 0);
+ bioLinkRequired = data.getBoolean("bioLinkRequired", false);
}
/**
diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/GameplayManager.kt b/src/main/java/com/projectswg/holocore/services/gameplay/GameplayManager.kt
index d5dd0e4f9..fb8268f94 100644
--- a/src/main/java/com/projectswg/holocore/services/gameplay/GameplayManager.kt
+++ b/src/main/java/com/projectswg/holocore/services/gameplay/GameplayManager.kt
@@ -33,6 +33,7 @@ import com.projectswg.holocore.services.gameplay.conversation.ConversationServic
import com.projectswg.holocore.services.gameplay.crafting.CraftingManager
import com.projectswg.holocore.services.gameplay.entertainment.EntertainmentManager
import com.projectswg.holocore.services.gameplay.faction.FactionManager
+import com.projectswg.holocore.services.gameplay.items.BioLinkService
import com.projectswg.holocore.services.gameplay.jedi.JediManager
import com.projectswg.holocore.services.gameplay.junkdealer.JunkDealerService
import com.projectswg.holocore.services.gameplay.missions.DestroyMissionService
@@ -43,5 +44,5 @@ import com.projectswg.holocore.services.gameplay.world.WorldManager
import me.joshlarson.jlcommon.control.Manager
import me.joshlarson.jlcommon.control.ManagerStructure
-@ManagerStructure(children = [BazaarService::class, CombatManager::class, ConversationService::class, CraftingManager::class, DeathCyberneticService::class, DestroyMissionService::class, EntertainmentManager::class, FactionManager::class, JediManager::class, JunkDealerService::class, PlayerManager::class, StructuresManager::class, TradeService::class, WorldManager::class])
+@ManagerStructure(children = [BazaarService::class, BioLinkService::class, CombatManager::class, ConversationService::class, CraftingManager::class, DeathCyberneticService::class, DestroyMissionService::class, EntertainmentManager::class, FactionManager::class, JediManager::class, JunkDealerService::class, PlayerManager::class, StructuresManager::class, TradeService::class, WorldManager::class])
class GameplayManager : Manager()
diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/commodities/BazaarService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/commodities/BazaarService.kt
index e47459be1..a203bcccc 100644
--- a/src/main/java/com/projectswg/holocore/services/gameplay/commodities/BazaarService.kt
+++ b/src/main/java/com/projectswg/holocore/services/gameplay/commodities/BazaarService.kt
@@ -254,6 +254,16 @@ class BazaarService : Service() {
return
}
+ if (objectById is TangibleObject && objectById.bioLinkedTo != null) {
+ player.sendPacket(
+ CreateAuctionResponseMessage(
+ objectId = objectId, vendorId = packet.vendorId, status = 26
+ )
+ )
+
+ return
+ }
+
if (objectById.owner != player) {
player.sendPacket(
CreateAuctionResponseMessage(
diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/items/BioLinkService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/items/BioLinkService.kt
new file mode 100644
index 000000000..6e32275f6
--- /dev/null
+++ b/src/main/java/com/projectswg/holocore/services/gameplay/items/BioLinkService.kt
@@ -0,0 +1,75 @@
+/***********************************************************************************
+ * 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.items
+
+import com.projectswg.common.data.sui.SuiEvent
+import com.projectswg.holocore.intents.gameplay.items.BioLinkNowIntent
+import com.projectswg.holocore.intents.gameplay.items.RequestBioLinkIntent
+import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent
+import com.projectswg.holocore.resources.support.global.zone.sui.SuiButtons
+import com.projectswg.holocore.resources.support.global.zone.sui.SuiMessageBox
+import com.projectswg.holocore.resources.support.objects.swg.tangible.TangibleObject
+import me.joshlarson.jlcommon.control.IntentHandler
+import me.joshlarson.jlcommon.control.Service
+
+class BioLinkService : Service() {
+
+ @IntentHandler
+ private fun handleRequestBioLinkIntent(intent: RequestBioLinkIntent) {
+ val linker = intent.linker
+ val item = intent.item
+ if (!isLinkable(item)) return
+ val owner = linker.owner ?: return
+
+ if (item.parent !== linker.inventory) {
+ SystemMessageIntent.broadcastPersonal(owner, "@base_player:must_bio_link_from_inventory")
+ return
+ }
+
+ SuiMessageBox().run {
+ title = "@sui:bio_link_item_title"
+ prompt = "@sui:bio_link_item_prompt"
+ buttons = SuiButtons.YES_NO
+ addOkButtonCallback("biolink") { _: SuiEvent, _: Map -> BioLinkNowIntent(linker, item).broadcast() }
+ display(owner)
+ }
+ }
+
+ @IntentHandler
+ private fun handleBioLinkNowIntent(intent: BioLinkNowIntent) {
+ val linker = intent.linker
+ val item = intent.item
+ if (!isLinkable(item)) return
+
+ item.setBioLinkedTo(linker.objectId) // In case the name of the character ever changes
+ val owner = linker.owner ?: return
+ SystemMessageIntent.broadcastPersonal(owner, "@base_player:item_bio_linked")
+ }
+
+ private fun isLinkable(item: TangibleObject): Boolean {
+ return item.isBioLinkRequired && item.bioLinkedTo == null
+ }
+}
diff --git a/src/test/java/com/projectswg/holocore/resources/support/objects/radial/object/TestBioLinkRadial.java b/src/test/java/com/projectswg/holocore/resources/support/objects/radial/object/TestBioLinkRadial.java
new file mode 100644
index 000000000..bacc945a3
--- /dev/null
+++ b/src/test/java/com/projectswg/holocore/resources/support/objects/radial/object/TestBioLinkRadial.java
@@ -0,0 +1,63 @@
+package com.projectswg.holocore.resources.support.objects.radial.object;
+
+import com.projectswg.common.data.radial.RadialOption;
+import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject;
+import com.projectswg.holocore.resources.support.objects.swg.tangible.TangibleObject;
+import com.projectswg.holocore.test.resources.GenericCreatureObject;
+import com.projectswg.holocore.test.resources.GenericPlayer;
+import com.projectswg.holocore.test.resources.GenericTangibleObject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class TestBioLinkRadial {
+
+ private BioLinkRadial radial;
+ private GenericPlayer player;
+ private TangibleObject item;
+
+ @BeforeEach
+ public void setup() {
+ radial = new BioLinkRadial();
+ player = new GenericPlayer();
+ CreatureObject creatureObject = new GenericCreatureObject(1, "Some Player", true);
+ player.setCreatureObject(creatureObject);
+
+ item = new GenericTangibleObject(3);
+ }
+
+ @Test
+ public void itemsWithoutBioLinkShouldHaveNoOptions() {
+ List options = new ArrayList<>();
+
+ radial.getOptions(options, player, item);
+
+ assertTrue(options.isEmpty());
+ }
+
+ @Test
+ public void unlinkedItemsShouldHaveTheBioLinkOption() {
+ item.setBioLinkRequired(true);
+ List options = new ArrayList<>();
+
+ radial.getOptions(options, player, item);
+
+ assertEquals(1, options.size());
+ assertEquals("@ui_radial:bio_link", options.get(0).getLabel());
+ }
+
+ @Test
+ public void linkedItemsShouldHaveNoOptions() {
+ item.setBioLinkRequired(true);
+ item.setBioLinkedTo(12345678L);
+ List options = new ArrayList<>();
+
+ radial.getOptions(options, player, item);
+
+ assertTrue(options.isEmpty());
+ }
+}