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()); + } +}