From e33276673a8fd97426d85a3b46c9285d1a40fd4f Mon Sep 17 00:00:00 2001 From: Mads Boddum Date: Thu, 3 Sep 2026 20:52:46 +0200 Subject: [PATCH] Added creature milking Living creatures with milk offer Collect Milk on their radial menu. Milking runs in three ticks over 20-30 seconds, requires the player to stay within 5 metres and grants the planet's wild or domesticated milk resource. A creature can only be milked once and owned creatures cannot be milked. The mask scent requirement is left out, as mask scent is not implemented. --- .../objects/radial/object/AIObjectRadial.kt | 43 +++- .../support/objects/swg/custom/AIObject.kt | 3 +- .../gameplay/crafting/CraftingManager.kt | 5 +- .../resource/CreatureHarvestingIntents.kt | 3 +- .../resource/CreatureMilkingService.kt | 170 ++++++++++++++++ .../crafting/resource/CreatureMilkingTest.kt | 185 ++++++++++++++++++ 6 files changed, 398 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureMilkingService.kt create mode 100644 src/test/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureMilkingTest.kt diff --git a/src/main/java/com/projectswg/holocore/resources/support/objects/radial/object/AIObjectRadial.kt b/src/main/java/com/projectswg/holocore/resources/support/objects/radial/object/AIObjectRadial.kt index d1262a5f6..b5fbd2487 100644 --- a/src/main/java/com/projectswg/holocore/resources/support/objects/radial/object/AIObjectRadial.kt +++ b/src/main/java/com/projectswg/holocore/resources/support/objects/radial/object/AIObjectRadial.kt @@ -1,5 +1,5 @@ /*********************************************************************************** - * Copyright (c) 2024 /// Project SWG /// www.projectswg.com * + * 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. * @@ -42,17 +42,23 @@ import com.projectswg.holocore.resources.support.objects.swg.custom.AIObject import com.projectswg.holocore.services.gameplay.crafting.resource.HarvestBoneIntent import com.projectswg.holocore.services.gameplay.crafting.resource.HarvestHideIntent import com.projectswg.holocore.services.gameplay.crafting.resource.HarvestMeatIntent +import com.projectswg.holocore.services.gameplay.crafting.resource.MilkCreatureIntent class AIObjectRadial : RadialHandlerInterface { override fun getOptions(options: MutableCollection, player: Player, target: SWGObject) { val ai = target as AIObject - if (ai.posture != Posture.DEAD) { - return - } val creatureObject = player.creatureObject if (Locomotion.DEAD.isActive(creatureObject) || Locomotion.INCAPACITATED.isActive(creatureObject)) { return } + if (ai.posture == Posture.DEAD) { + appendDeadCreatureOptions(ai, creatureObject, options) + } else { + appendLivingCreatureOptions(ai, options) + } + } + + private fun appendDeadCreatureOptions(ai: AIObject, creatureObject: CreatureObject, options: MutableCollection) { options.add(RadialOption.create(RadialItem.LOOT_ALL, RadialOption.create(RadialItem.LOOT))) if (isScout(creatureObject)) { @@ -60,6 +66,17 @@ class AIObjectRadial : RadialHandlerInterface { } } + private fun appendLivingCreatureOptions(ai: AIObject, options: MutableCollection) { + if (ai.isMilked || ai.ownerId > 0) { + return + } + val npcInfo = ServerData.npcs[ai.creatureId ?: return] ?: return + + if (npcInfo.milkResourceInfo.amount > 0) { + options.add(RadialOption.create(RadialItem.SERVER_MENU4, "@pet/pet_menu:milk_me")) + } + } + private fun appendScoutOptions(ai: AIObject, options: MutableCollection) { val npcInfo = ServerData.npcs[ai.creatureId ?: return] ?: return val harvestCreatureOptions = harvestCreatureOptions(npcInfo) @@ -94,13 +111,18 @@ class AIObjectRadial : RadialHandlerInterface { override fun handleSelection(player: Player, target: SWGObject, selection: RadialItem) { val ai = target as AIObject - if (ai.posture != Posture.DEAD) { - return - } val creatureObject = player.creatureObject if (Locomotion.DEAD.isActive(creatureObject) || Locomotion.INCAPACITATED.isActive(creatureObject)) { return } + if (ai.posture == Posture.DEAD) { + handleDeadCreatureSelection(player, ai, selection) + } else { + handleLivingCreatureSelection(player, ai, selection) + } + } + + private fun handleDeadCreatureSelection(player: Player, ai: AIObject, selection: RadialItem) { when (selection) { RadialItem.LOOT -> LootRequestIntent(player, ai, LootType.LOOT).broadcast() RadialItem.LOOT_ALL -> LootRequestIntent(player, ai, LootType.LOOT_ALL).broadcast() @@ -110,4 +132,11 @@ class AIObjectRadial : RadialHandlerInterface { else -> StandardLog.onPlayerError(this, player, "radial option without handling selected: %s", selection) } } + + private fun handleLivingCreatureSelection(player: Player, ai: AIObject, selection: RadialItem) { + when (selection) { + RadialItem.SERVER_MENU4 -> MilkCreatureIntent(player, ai).broadcast() + else -> StandardLog.onPlayerError(this, player, "radial option without handling selected: %s", selection) + } + } } diff --git a/src/main/java/com/projectswg/holocore/resources/support/objects/swg/custom/AIObject.kt b/src/main/java/com/projectswg/holocore/resources/support/objects/swg/custom/AIObject.kt index 5be59913b..42ef7a6a2 100644 --- a/src/main/java/com/projectswg/holocore/resources/support/objects/swg/custom/AIObject.kt +++ b/src/main/java/com/projectswg/holocore/resources/support/objects/swg/custom/AIObject.kt @@ -1,5 +1,5 @@ /*********************************************************************************** - * Copyright (c) 2025 /// Project SWG /// www.projectswg.com * + * 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. * @@ -77,6 +77,7 @@ class AIObject(objectId: Long) : CreatureObject(objectId) { var spawner: Spawner? = null var creatureId: String? = null var isHarvested: Boolean = false + var isMilked: Boolean = false override fun onObjectEnteredAware(aware: SWGObject) { val player = getCreatureOrRider(aware) ?: return diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/crafting/CraftingManager.kt b/src/main/java/com/projectswg/holocore/services/gameplay/crafting/CraftingManager.kt index 617d6d881..d164821e7 100644 --- a/src/main/java/com/projectswg/holocore/services/gameplay/crafting/CraftingManager.kt +++ b/src/main/java/com/projectswg/holocore/services/gameplay/crafting/CraftingManager.kt @@ -1,5 +1,5 @@ /*********************************************************************************** - * Copyright (c) 2023 /// Project SWG /// www.projectswg.com * + * Copyright (c) 2026 /// 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. * @@ -27,10 +27,11 @@ package com.projectswg.holocore.services.gameplay.crafting import com.projectswg.holocore.services.gameplay.crafting.resource.CreatureHarvestingService +import com.projectswg.holocore.services.gameplay.crafting.resource.CreatureMilkingService import com.projectswg.holocore.services.gameplay.crafting.resource.ResourceService import com.projectswg.holocore.services.gameplay.crafting.survey.SurveyToolService import me.joshlarson.jlcommon.control.Manager import me.joshlarson.jlcommon.control.ManagerStructure -@ManagerStructure(children = [CreatureHarvestingService::class, ResourceService::class, SurveyToolService::class]) +@ManagerStructure(children = [CreatureHarvestingService::class, CreatureMilkingService::class, ResourceService::class, SurveyToolService::class]) class CraftingManager : Manager() diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureHarvestingIntents.kt b/src/main/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureHarvestingIntents.kt index 42613dd3b..ae49b18fa 100644 --- a/src/main/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureHarvestingIntents.kt +++ b/src/main/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureHarvestingIntents.kt @@ -1,5 +1,5 @@ /*********************************************************************************** - * Copyright (c) 2023 /// Project SWG /// www.projectswg.com * + * Copyright (c) 2026 /// 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. * @@ -33,3 +33,4 @@ import me.joshlarson.jlcommon.control.Intent data class HarvestBoneIntent(val player: Player, val target: AIObject): Intent() data class HarvestHideIntent(val player: Player, val target: AIObject): Intent() data class HarvestMeatIntent(val player: Player, val target: AIObject): Intent() +data class MilkCreatureIntent(val player: Player, val target: AIObject): Intent() diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureMilkingService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureMilkingService.kt new file mode 100644 index 000000000..074510e5c --- /dev/null +++ b/src/main/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureMilkingService.kt @@ -0,0 +1,170 @@ +/*********************************************************************************** + * 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.crafting.resource + +import com.projectswg.common.data.encodables.tangible.Posture +import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent +import com.projectswg.holocore.resources.gameplay.crafting.resource.galactic.GalacticResource +import com.projectswg.holocore.resources.gameplay.crafting.resource.galactic.storage.GalacticResourceContainer.getRawResource +import com.projectswg.holocore.resources.gameplay.crafting.resource.galactic.storage.GalacticResourceContainer.getSpawnedResources +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.global.player.Player +import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureState +import com.projectswg.holocore.resources.support.objects.swg.custom.AIObject +import com.projectswg.holocore.utilities.HolocoreCoroutine +import com.projectswg.holocore.utilities.cancelAndWait +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.joshlarson.jlcommon.control.IntentHandler +import me.joshlarson.jlcommon.control.Service +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ThreadLocalRandom + +class CreatureMilkingService(private val milkingDurationMs: LongRange) : Service() { + + constructor() : this(MILKING_DURATION_MS) + + private val milkingScope = HolocoreCoroutine.childScope() + private val creaturesBeingMilked: MutableSet = ConcurrentHashMap.newKeySet() + + override fun stop(): Boolean { + milkingScope.cancelAndWait() + return super.stop() + } + + @IntentHandler + private fun handleMilkCreatureIntent(intent: MilkCreatureIntent) { + val ai = intent.target + val player = intent.player + val creature = player.creatureObject + + val npcInfo = ServerData.npcs[ai.creatureId ?: return] ?: return + val milkResourceInfo = npcInfo.milkResourceInfo + if (milkResourceInfo.amount <= 0) return + if (ai.ownerId > 0) return // Tamed creatures can not be milked + + if (creature.isStatesBitmask(CreatureState.RIDING_MOUNT)) { + SystemMessageIntent.broadcastPersonal(player, "@skl_use:skl_use") + return + } + if (ai.isMilked) { + SystemMessageIntent.broadcastPersonal(player, "@skl_use:milk_cant") + return + } + if (!isAbleToContinueMilking(player, ai)) { + SystemMessageIntent.broadcastPersonal(player, "@skl_use:milk_too_far") + return + } + if (!creaturesBeingMilked.add(ai)) { + SystemMessageIntent.broadcastPersonal(player, "@skl_use:being_milked") + return + } + + val spawnedResource = getSpawnedResource(player, milkResourceInfo.type) + if (spawnedResource == null) { + creaturesBeingMilked.remove(ai) + StandardLog.onPlayerError(this, player, "unable to find a creature resource of type %s for %s", milkResourceInfo.type, ai) + return + } + + val amount = milkResourceInfo.amount + milkingScope.launch { + try { + milk(player, ai, spawnedResource, amount) + } finally { + creaturesBeingMilked.remove(ai) + } + } + } + + private suspend fun milk(player: Player, ai: AIObject, resource: GalacticResource, amount: Int) { + val tickDelay = ThreadLocalRandom.current().nextLong(milkingDurationMs.first, milkingDurationMs.last + 1) / TICKS + SystemMessageIntent.broadcastPersonal(player, "@skl_use:milk_begin") + StandardLog.onPlayerTrace(this, player, "started milking %d %s from %s with a tick delay of %d ms", amount, resource.name, ai, tickDelay) + + for (tick in 1..TICKS) { + delay(tickDelay) + + if (!isAbleToContinueMilking(player, ai)) { + SystemMessageIntent.broadcastPersonal(player, "@skl_use:milk_too_far") + StandardLog.onPlayerTrace(this, player, "aborted milking %s on tick %d of %d", ai, tick, TICKS) + return + } + + if (tick < TICKS) { + SystemMessageIntent.broadcastPersonal(player, "@skl_use:milk_continue") + } + } + + val service = this + val eventHandler = object : ResourceContainerEventHandler { + override fun onUnknownError() { + + } + + override fun onInventoryFull() { + SystemMessageIntent.broadcastPersonal(player, "@container_error_message:container03") + } + + override fun onSuccess() { + ai.isMilked = true + StandardLog.onPlayerEvent(service, player, "milked %d %s from %s", amount, resource.name, ai) + SystemMessageIntent.broadcastPersonal(player, "@skl_use:milk_success") + } + } + ResourceContainerHelper.giveResourcesToPlayer(amount, resource, player, eventHandler) + } + + private fun isAbleToContinueMilking(player: Player, ai: AIObject): Boolean { + val creature = player.creatureObject + + if (ai.posture == Posture.DEAD || creature.posture == Posture.DEAD || creature.posture == Posture.INCAPACITATED) return false + if (creature.isInCombat || ai.isInCombat) return false + if (creature.terrain != ai.terrain) return false + + return creature.worldLocation.distanceTo(ai.worldLocation) <= MILKING_RANGE + } + + private fun getSpawnedResource(player: Player, requestedCreatureResourceType: String): GalacticResource? { + val spawnedResources = getSpawnedResources(player.creatureObject.terrain) + for (spawnedResource in spawnedResources) { + val rawResource = getRawResource(spawnedResource.rawResourceId) ?: continue + + if (rawResource.name.startsWith("${requestedCreatureResourceType}_")) { + return spawnedResource + } + } + + return null + } + + companion object { + private const val MILKING_RANGE = 5.0 + private const val TICKS = 3 + private val MILKING_DURATION_MS = 20_000L..30_000L + } +} diff --git a/src/test/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureMilkingTest.kt b/src/test/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureMilkingTest.kt new file mode 100644 index 000000000..6cce1e4d4 --- /dev/null +++ b/src/test/java/com/projectswg/holocore/services/gameplay/crafting/resource/CreatureMilkingTest.kt @@ -0,0 +1,185 @@ +/*********************************************************************************** + * 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.crafting.resource + +import com.projectswg.common.data.location.Location +import com.projectswg.common.data.location.Terrain +import com.projectswg.common.network.packets.swg.zone.chat.ChatSystemMessage +import com.projectswg.holocore.intents.support.objects.ObjectCreatedIntent +import com.projectswg.holocore.resources.support.global.player.Player +import com.projectswg.holocore.resources.support.npc.spawn.NPCCreator +import com.projectswg.holocore.resources.support.npc.spawn.SimpleSpawnInfo +import com.projectswg.holocore.resources.support.npc.spawn.Spawner +import com.projectswg.holocore.resources.support.objects.ObjectCreator +import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureDifficulty +import com.projectswg.holocore.resources.support.objects.swg.custom.AIObject +import com.projectswg.holocore.resources.support.objects.swg.weapon.DefaultWeaponFactory +import com.projectswg.holocore.services.support.global.chat.ChatSystemService +import com.projectswg.holocore.test.resources.GenericCreatureObject +import com.projectswg.holocore.test.resources.GenericPlayer +import com.projectswg.holocore.test.runners.TestRunnerSynchronousIntents +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.concurrent.TimeUnit + +class CreatureMilkingTest : TestRunnerSynchronousIntents() { + + @BeforeEach + fun setup() { + registerService(CreatureMilkingService(1L..1L)) + registerService(ResourceService()) + registerService(ChatSystemService()) + } + + @Test + fun `milk is placed in the inventory of the player`() { + val player = createPlayer() + val previousInventorySize = inventorySize(player) + val bantha = createNpc("creature_bantha") + + broadcastAndWait(MilkCreatureIntent(player, bantha)) + + awaitSystemMessage(player, MILK_SUCCESS) + assertTrue(inventorySize(player) > previousInventorySize, "Inventory contents should have increased") + assertTrue(bantha.isMilked, "The creature should be dry after being milked") + } + + @Test + fun `a creature can only be milked once`() { + val player = createPlayer() + val bantha = createNpc("creature_bantha") + + broadcastAndWait(MilkCreatureIntent(player, bantha)) + awaitSystemMessage(player, MILK_SUCCESS) + + val inventorySizeAfterFirstMilking = inventorySize(player) + broadcastAndWait(MilkCreatureIntent(player, bantha)) + + awaitSystemMessage(player, MILK_CANT) + assertEquals(inventorySizeAfterFirstMilking, inventorySize(player), "A dry creature should not give milk") + } + + @Test + fun `creatures without milk can not be milked`() { + val player = createPlayer() + val dewback = createNpc("creature_dewback") + + broadcastAndWait(MilkCreatureIntent(player, dewback)) + + assertNoSystemMessage(player, MILK_BEGIN, "A creature without milk should not be milkable") + } + + @Test + fun `owned creatures can not be milked`() { + val player = createPlayer() + val bantha = createNpc("creature_bantha") + bantha.ownerId = player.creatureObject.objectId + + broadcastAndWait(MilkCreatureIntent(player, bantha)) + + assertNoSystemMessage(player, MILK_BEGIN, "A tamed creature should not be milkable") + } + + @Test + fun `milking stops when the creature is out of range`() { + val player = createPlayer() + val previousInventorySize = inventorySize(player) + val bantha = createNpc("creature_bantha", locationFarAwayFromPlayer()) + + broadcastAndWait(MilkCreatureIntent(player, bantha)) + + awaitSystemMessage(player, MILK_TOO_FAR) + assertEquals(previousInventorySize, inventorySize(player), "A creature out of range should not give milk") + assertFalse(bantha.isMilked, "The creature should not be dry after a failed milking") + } + + private fun awaitSystemMessage(player: Player, message: String) { + val packet = nextSystemMessage(player, message, 5, TimeUnit.SECONDS) + assertNotNull(packet, "Expected to receive the system message $message") + } + + private fun assertNoSystemMessage(player: Player, message: String, reason: String) { + val packet = nextSystemMessage(player, message, 100, TimeUnit.MILLISECONDS) + assertNull(packet, reason) + } + + private fun nextSystemMessage(player: Player, message: String, timeout: Long, unit: TimeUnit): ChatSystemMessage? { + val genericPlayer = player as GenericPlayer + return genericPlayer.waitForNextPacket(ChatSystemMessage::class.java, timeout, unit) { it.message == message } + } + + private fun inventorySize(player: Player): Int { + return player.creatureObject.inventory.childObjects.size + } + + private fun createPlayer(): Player { + val creatureObject = GenericCreatureObject(ObjectCreator.getNextObjectId()) + creatureObject.location = inFrontOfMosEisleyStarport() + ObjectCreatedIntent(creatureObject).broadcast() + val defaultWeapon = DefaultWeaponFactory.createDefaultWeapon() + defaultWeapon.moveToContainer(creatureObject) + creatureObject.equippedWeapon = defaultWeapon + return creatureObject.owner ?: throw RuntimeException("Unable to access player") + } + + private fun createNpc(npcId: String, location: Location = inFrontOfMosEisleyStarport()): AIObject { + val egg = ObjectCreator.createObjectFromTemplate("object/tangible/ground_spawning/shared_patrol_spawner.iff") + egg.moveToContainer(null, location) + + val spawnInfo = SimpleSpawnInfo.builder() + .withNpcId(npcId) + .withDifficulty(CreatureDifficulty.ELITE) + .withMinLevel(19) + .withMaxLevel(19) + .withLocation(location) + .build() + + return NPCCreator.createAllNPCs(Spawner(spawnInfo, egg)).first() + } + + private fun inFrontOfMosEisleyStarport(): Location { + return Location.builder() + .setTerrain(Terrain.TATOOINE) + .setX(-3521.0) + .setY(5.0) + .setZ(-4807.0) + .build() + } + + private fun locationFarAwayFromPlayer(): Location { + return Location.builder(inFrontOfMosEisleyStarport()) + .setX(-3400.0) + .build() + } + + companion object { + private const val MILK_BEGIN = "@skl_use:milk_begin" + private const val MILK_SUCCESS = "@skl_use:milk_success" + private const val MILK_CANT = "@skl_use:milk_cant" + private const val MILK_TOO_FAR = "@skl_use:milk_too_far" + } +}