diff --git a/src/main/java/com/projectswg/holocore/intents/gameplay/world/PetIntents.kt b/src/main/java/com/projectswg/holocore/intents/gameplay/world/PetIntents.kt
index 75887c7f2..caf286b17 100644
--- a/src/main/java/com/projectswg/holocore/intents/gameplay/world/PetIntents.kt
+++ b/src/main/java/com/projectswg/holocore/intents/gameplay/world/PetIntents.kt
@@ -35,5 +35,6 @@ data class DismountIntent(val creature: CreatureObject, val pet: CreatureObject)
data class MountIntent(val creature: CreatureObject, val pet: CreatureObject) : Intent()
data class PetDeviceCallIntent(val creature: CreatureObject, val controlDevice: IntangibleObject) : Intent()
data class PetDeviceStoreIntent(val creature: CreatureObject, val controlDevice: IntangibleObject) : Intent()
+data class RepairVehicleIntent(val creature: CreatureObject, val pet: CreatureObject) : Intent()
data class StoreMountIntent(val creature: CreatureObject, val pet: CreatureObject) : Intent()
data class VehicleDeedGenerateIntent(val creature: CreatureObject, val deed: SWGObject) : Intent()
diff --git a/src/main/java/com/projectswg/holocore/resources/gameplay/structures/VehicleGarages.kt b/src/main/java/com/projectswg/holocore/resources/gameplay/structures/VehicleGarages.kt
new file mode 100644
index 000000000..b1d09913a
--- /dev/null
+++ b/src/main/java/com/projectswg/holocore/resources/gameplay/structures/VehicleGarages.kt
@@ -0,0 +1,55 @@
+/***********************************************************************************
+ * 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.gameplay.structures
+
+import com.projectswg.holocore.resources.support.objects.swg.building.BuildingObject
+import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject
+
+object VehicleGarages {
+
+ private const val RANGE = 64.0
+
+ private val TEMPLATES = setOf(
+ "object/building/base/shared_base_parking_garage.iff",
+ "object/building/tatooine/shared_parking_garage_tatooine_style_1.iff",
+ "object/building/naboo/shared_parking_garage_naboo_style_1.iff",
+ "object/building/general/shared_parking_garage_general.iff",
+ "object/building/corellia/shared_garage_corellia.iff",
+ "object/building/player/shared_player_garage_tatooine_style_01.iff",
+ "object/building/player/shared_player_garage_naboo_style_01.iff",
+ "object/building/player/shared_player_garage_corellia_style_01.iff",
+ )
+
+ @JvmStatic
+ fun nearestGarage(player: CreatureObject): BuildingObject? {
+ val playerLocation = player.worldLocation
+ return player.aware.asSequence()
+ .filterIsInstance()
+ .filter { it.template in TEMPLATES }
+ .minByOrNull { it.worldLocation.flatDistanceTo(playerLocation) }
+ ?.takeIf { it.worldLocation.flatDistanceTo(playerLocation) <= RANGE }
+ }
+}
diff --git a/src/main/java/com/projectswg/holocore/resources/support/objects/radial/pet/VehicleMountRadial.java b/src/main/java/com/projectswg/holocore/resources/support/objects/radial/pet/VehicleMountRadial.kt
similarity index 59%
rename from src/main/java/com/projectswg/holocore/resources/support/objects/radial/pet/VehicleMountRadial.java
rename to src/main/java/com/projectswg/holocore/resources/support/objects/radial/pet/VehicleMountRadial.kt
index c20e17336..befb45c09 100644
--- a/src/main/java/com/projectswg/holocore/resources/support/objects/radial/pet/VehicleMountRadial.java
+++ b/src/main/java/com/projectswg/holocore/resources/support/objects/radial/pet/VehicleMountRadial.kt
@@ -24,85 +24,69 @@
* 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.pet
+
+import com.projectswg.common.data.radial.RadialItem
+import com.projectswg.common.data.radial.RadialOption
+import com.projectswg.holocore.intents.gameplay.world.DismountIntent
+import com.projectswg.holocore.intents.gameplay.world.MountIntent
+import com.projectswg.holocore.intents.gameplay.world.RepairVehicleIntent
+import com.projectswg.holocore.intents.gameplay.world.StoreMountIntent
+import com.projectswg.holocore.resources.gameplay.structures.VehicleGarages
+import com.projectswg.holocore.resources.support.global.player.Player
+import com.projectswg.holocore.resources.support.objects.radial.RadialHandlerInterface
+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.creature.CreatureState
+import com.projectswg.holocore.resources.support.objects.swg.group.GroupObject
+import com.projectswg.holocore.services.support.objects.ObjectStorageService.ObjectLookup
+
+class VehicleMountRadial : RadialHandlerInterface {
+
+ override fun getOptions(options: MutableCollection, player: Player, target: SWGObject) {
+ val creature = player.creatureObject
+ if (target !is CreatureObject || !isValidTarget(creature, target)) return
+
+ options.add(RadialOption.create(RadialItem.EXAMINE))
+ if (creature.isStatesBitmask(CreatureState.RIDING_MOUNT) && creature.parent === target) {
+ options.add(RadialOption.create(RadialItem.ITEM_USE, "@cmd_n:dismount"))
+ } else {
+ options.add(RadialOption.create(RadialItem.ITEM_USE, "@cmd_n:mount"))
+ }
-package com.projectswg.holocore.resources.support.objects.radial.pet;
-
-import com.projectswg.common.data.radial.RadialItem;
-import com.projectswg.common.data.radial.RadialOption;
-import com.projectswg.holocore.intents.gameplay.world.DismountIntent;
-import com.projectswg.holocore.intents.gameplay.world.MountIntent;
-import com.projectswg.holocore.intents.gameplay.world.StoreMountIntent;
-import com.projectswg.holocore.resources.support.global.player.Player;
-import com.projectswg.holocore.resources.support.objects.radial.RadialHandlerInterface;
-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.creature.CreatureState;
-import com.projectswg.holocore.resources.support.objects.swg.group.GroupObject;
-import com.projectswg.holocore.services.support.objects.ObjectStorageService.ObjectLookup;
-import org.jetbrains.annotations.NotNull;
-
-import java.util.Collection;
-
-public class VehicleMountRadial implements RadialHandlerInterface {
-
- public VehicleMountRadial() {
-
- }
-
- @Override
- public void getOptions(@NotNull Collection options, @NotNull Player player, @NotNull SWGObject target) {
- CreatureObject creature = player.getCreatureObject();
- if (!(target instanceof CreatureObject mount) || !isValidTarget(creature, (CreatureObject) target))
- return;
-
- options.add(RadialOption.create(RadialItem.EXAMINE));
- if (creature.isStatesBitmask(CreatureState.RIDING_MOUNT) && creature.getParent() == target)
- options.add(RadialOption.create(RadialItem.ITEM_USE, "@cmd_n:dismount"));
- else
- options.add(RadialOption.create(RadialItem.ITEM_USE, "@cmd_n:mount"));
+ if (creature.objectId == target.ownerId) {
+ options.add(RadialOption.create(RadialItem.PET_STORE))
- if (creature.getObjectId() == mount.getOwnerId())
- options.add(RadialOption.create(RadialItem.PET_STORE));
+ if (VehicleGarages.nearestGarage(creature) != null) {
+ options.add(RadialOption.create(RadialItem.SERVER_MENU1, "@pet/pet_menu:menu_repair_vehicle"))
+ }
+ }
}
- @Override
- public void handleSelection(@NotNull Player player, @NotNull SWGObject target, @NotNull RadialItem selection) {
- CreatureObject creature = player.getCreatureObject();
- if (!(target instanceof CreatureObject) || !isValidTarget(creature, (CreatureObject) target))
- return;
+ override fun handleSelection(player: Player, target: SWGObject, selection: RadialItem) {
+ val creature = player.creatureObject
+ if (target !is CreatureObject || !isValidTarget(creature, target)) return
- switch (selection) {
- case ITEM_USE:
- if (player.getCreatureObject().getParent() == target)
- new DismountIntent(creature, (CreatureObject) target).broadcast();
- else
- new MountIntent(creature, (CreatureObject) target).broadcast();
- break;
- case PET_STORE:
- new StoreMountIntent(creature, (CreatureObject) target).broadcast();
- break;
- default:
- break;
+ when (selection) {
+ RadialItem.ITEM_USE -> if (creature.parent === target) DismountIntent(creature, target).broadcast() else MountIntent(creature, target).broadcast()
+ RadialItem.PET_STORE -> StoreMountIntent(creature, target).broadcast()
+ RadialItem.SERVER_MENU1 -> RepairVehicleIntent(creature, target).broadcast()
+ else -> {}
}
}
- private static boolean isValidTarget(CreatureObject creature, CreatureObject mount) {
+ private fun isValidTarget(creature: CreatureObject, mount: CreatureObject): Boolean {
// Owner of the vehicle
- if (creature.getObjectId() == mount.getOwnerId())
- return true;
+ if (creature.objectId == mount.ownerId) return true
// Already mounted
- if (creature.getParent() == mount && mount.isStatesBitmask(CreatureState.MOUNTED_CREATURE) && creature.isStatesBitmask(CreatureState.RIDING_MOUNT))
- return true;
+ if (creature.parent === mount && mount.isStatesBitmask(CreatureState.MOUNTED_CREATURE) && creature.isStatesBitmask(CreatureState.RIDING_MOUNT)) return true
// Within the same group
- GroupObject group = (GroupObject) ObjectLookup.getObjectById(creature.getGroupId());
- if (group == null || !group.isInGroup(mount.getOwnerId())) {
- return false;
- }
+ val group = ObjectLookup.getObjectById(creature.groupId) as GroupObject?
+ if (group == null || !group.isInGroup(mount.ownerId)) return false
// Owner is already mounted
- return mount.getSlottedObject("rider") != null;
+ return mount.getSlottedObject("rider") != null
}
-
}
diff --git a/src/main/java/com/projectswg/holocore/resources/support/objects/swg/SWGObject.java b/src/main/java/com/projectswg/holocore/resources/support/objects/swg/SWGObject.java
index 61c81352a..bfa305395 100644
--- a/src/main/java/com/projectswg/holocore/resources/support/objects/swg/SWGObject.java
+++ b/src/main/java/com/projectswg/holocore/resources/support/objects/swg/SWGObject.java
@@ -664,7 +664,14 @@ public boolean removeFromBankAndCash(long amount) {
}
return true;
}
-
+
+ public boolean removeFromBank(long amount) {
+ if (bankBalance < amount)
+ return false;
+ setBankBalance(bankBalance - amount);
+ return true;
+ }
+
/**
* Adds amount to cash balance.
* @param amount the amount to add
diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/structures/StructuresManager.kt b/src/main/java/com/projectswg/holocore/services/gameplay/structures/StructuresManager.kt
index 28ba6be8a..706bbd5a9 100644
--- a/src/main/java/com/projectswg/holocore/services/gameplay/structures/StructuresManager.kt
+++ b/src/main/java/com/projectswg/holocore/services/gameplay/structures/StructuresManager.kt
@@ -9,6 +9,7 @@ import me.joshlarson.jlcommon.control.ManagerStructure
HousingManager::class,
StructureService::class,
ElevatorService::class,
- MedicalCenterService::class
+ MedicalCenterService::class,
+ VehicleGarageService::class
])
class StructuresManager : Manager()
diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/structures/VehicleGarageService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/structures/VehicleGarageService.kt
new file mode 100644
index 000000000..1962d89e5
--- /dev/null
+++ b/src/main/java/com/projectswg/holocore/services/gameplay/structures/VehicleGarageService.kt
@@ -0,0 +1,162 @@
+/***********************************************************************************
+ * 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.structures
+
+import com.projectswg.common.data.encodables.oob.ProsePackage
+import com.projectswg.common.data.encodables.oob.StringId
+import com.projectswg.holocore.intents.gameplay.world.RepairVehicleIntent
+import com.projectswg.holocore.intents.gameplay.world.StoreMountIntent
+import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent
+import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent.Companion.broadcastPersonal
+import com.projectswg.holocore.intents.support.global.zone.PlayerEventIntent
+import com.projectswg.holocore.intents.support.global.zone.PlayerTransformedIntent
+import com.projectswg.holocore.resources.gameplay.structures.VehicleGarages.nearestGarage
+import com.projectswg.holocore.resources.support.data.server_info.StandardLog
+import com.projectswg.holocore.resources.support.data.server_info.loader.DataLoader.Companion.vehicles
+import com.projectswg.holocore.resources.support.data.server_info.loader.VehicleLoader.VehicleInfo
+import com.projectswg.holocore.resources.support.global.player.PlayerEvent
+import com.projectswg.holocore.resources.support.global.zone.sui.SuiButtons
+import com.projectswg.holocore.resources.support.global.zone.sui.SuiListBox
+import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject
+import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureState
+import me.joshlarson.jlcommon.control.IntentHandler
+import me.joshlarson.jlcommon.control.Service
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.math.roundToInt
+import kotlin.math.roundToLong
+
+class VehicleGarageService : Service() {
+
+ private val playersNotifiedOfGarage: MutableSet = ConcurrentHashMap.newKeySet()
+
+ @IntentHandler
+ private fun handlePlayerTransformedIntent(pti: PlayerTransformedIntent) {
+ val player = pti.player
+ if (!player.isStatesBitmask(CreatureState.RIDING_MOUNT)) return
+ val owner = player.owner ?: return
+
+ if (nearestGarage(player) != null) {
+ if (playersNotifiedOfGarage.add(player)) broadcastPersonal(owner, "@pet/pet_menu:garage_proximity")
+ } else {
+ playersNotifiedOfGarage.remove(player)
+ }
+ }
+
+ @IntentHandler
+ private fun handleStoreMountIntent(smi: StoreMountIntent) {
+ playersNotifiedOfGarage.remove(smi.creature)
+ }
+
+ @IntentHandler
+ private fun handlePlayerEventIntent(pei: PlayerEventIntent) {
+ if (pei.event == PlayerEvent.PE_DISAPPEAR) playersNotifiedOfGarage.remove(pei.player.creatureObject)
+ }
+
+ @IntentHandler
+ private fun handleRepairVehicleIntent(rvi: RepairVehicleIntent) {
+ val creature = rvi.creature
+ val mount = rvi.pet
+ val owner = creature.owner ?: return
+
+ if (creature.objectId != mount.ownerId) return
+ if (nearestGarage(creature) == null) {
+ broadcastPersonal(owner, "@pet/pet_menu:repair_unrecognized_garages")
+ return
+ }
+
+ val damage = mount.conditionDamage
+ if (damage <= 0) {
+ broadcastPersonal(owner, "@pet/pet_menu:undamaged_vehicle")
+ return
+ }
+
+ val vehicleInfo = vehicles().getVehicleFromIff(mount.template)
+ if (vehicleInfo == null || vehicleInfo.repairRate <= 0) {
+ StandardLog.onPlayerError(this, creature, "unable to determine repair rate for vehicle %s", mount)
+ broadcastPersonal(owner, "@pet/pet_menu:repair_error")
+ return
+ }
+
+ if (damage >= mount.maxHitPoints && !vehicleInfo.isCanRepairDisabled) {
+ broadcastPersonal(owner, "@pet/pet_menu:cannot_repair_disabled")
+ return
+ }
+
+ val cost = repairCost(damage, vehicleInfo.repairRate)
+ val funds = creature.bankBalance
+ if (funds < cost) {
+ broadcastPersonal(owner, lackingFunds(cost - funds))
+ return
+ }
+
+ SuiListBox().run {
+ title = "@pet/pet_menu:confirm_repairs_t"
+ prompt = "@pet/pet_menu:vehicle_repair_d"
+ buttons = SuiButtons.OK_CANCEL
+ addListItem("@pet/pet_menu:vehicle_prompt ${mount.stringId}")
+ addListItem("@pet/pet_menu:repair_cost_prompt $cost")
+ addListItem("@pet/pet_menu:total_funds_prompt $funds")
+ addOkButtonCallback("repair") { _, _ -> repair(creature, mount, cost, vehicleInfo) }
+ display(owner)
+ }
+ }
+
+ private fun repair(creature: CreatureObject, mount: CreatureObject, cost: Long, vehicleInfo: VehicleInfo) {
+ val owner = creature.owner ?: return
+
+ if (nearestGarage(creature) == null) {
+ broadcastPersonal(owner, "@pet/pet_menu:garage_out_of_range")
+ return
+ }
+
+ val funds = creature.bankBalance
+ if (!creature.removeFromBank(cost)) {
+ broadcastPersonal(owner, lackingFunds(cost - funds))
+ return
+ }
+
+ val damage = mount.conditionDamage
+ val paidForPoints = (cost / vehicleInfo.repairRate).roundToInt()
+ val repairedPoints = paidForPoints.coerceAtMost(damage)
+ var paid = cost
+
+ // Condition may have improved between opening the window and confirming - only charge for what's actually repaired
+ if (repairedPoints < paidForPoints) {
+ paid = repairCost(repairedPoints, vehicleInfo.repairRate)
+ creature.addToBank(cost - paid)
+ SystemMessageIntent.broadcastPersonal(owner, ProsePackage(StringId("pet/pet_menu", "repair_condition_changed"), "DI", paid.toInt()))
+ }
+
+ SystemMessageIntent.broadcastPersonal(owner, ProsePackage(StringId("base_player", "prose_pay_success_no_target"), "DI", paid.toInt()))
+
+ mount.conditionDamage = damage - repairedPoints
+ StandardLog.onPlayerEvent(this, creature, "repaired mount %s for %d credits, condition is now %d/%d", mount, paid, mount.maxHitPoints - mount.conditionDamage, mount.maxHitPoints)
+ }
+
+ private fun repairCost(points: Int, repairRate: Double): Long = (points * repairRate).roundToLong()
+
+ private fun lackingFunds(missing: Long): String = "@pet/pet_menu:lacking_funds_prefix $missing @pet/pet_menu:lacking_funds_suffix"
+}