diff --git a/src/main/java/com/projectswg/holocore/intents/gameplay/player/character/TippingIntents.kt b/src/main/java/com/projectswg/holocore/intents/gameplay/player/character/TippingIntents.kt new file mode 100644 index 000000000..020f9917c --- /dev/null +++ b/src/main/java/com/projectswg/holocore/intents/gameplay/player/character/TippingIntents.kt @@ -0,0 +1,43 @@ +/*********************************************************************************** + * Copyright (c) 2023 /// 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 . * + ***********************************************************************************/ +@file:Suppress("NOTHING_TO_INLINE") +package com.projectswg.holocore.intents.gameplay.player.character + +import com.projectswg.holocore.resources.support.global.player.Player +import me.joshlarson.jlcommon.control.Intent + +data class CashTipIntent(val sender: Player, val receiver: Player, val amount: Int): Intent() { + companion object { + @JvmStatic inline fun broadcast(sender: Player, receiver: Player, amount: Int) = CashTipIntent(sender, receiver, amount).broadcast() + } +} + +data class BankTipIntent(val sender: Player, val receiver: Player, val amount: Int): Intent() { + companion object { + @JvmStatic inline fun broadcast(sender: Player, receiver: Player, amount: Int) = BankTipIntent(sender, receiver, amount).broadcast() + } +} diff --git a/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/CmdTip.kt b/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/CmdTip.kt new file mode 100644 index 000000000..a8333e982 --- /dev/null +++ b/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/CmdTip.kt @@ -0,0 +1,90 @@ +/*********************************************************************************** + * Copyright (c) 2023 /// 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.resources.support.global.commands.callbacks + +import com.projectswg.common.data.encodables.oob.ProsePackage +import com.projectswg.common.data.encodables.oob.StringId +import com.projectswg.holocore.intents.gameplay.player.character.BankTipIntent +import com.projectswg.holocore.intents.gameplay.player.character.CashTipIntent +import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent +import com.projectswg.holocore.resources.support.global.commands.ICmdCallback +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.creature.CreatureObject + +private enum class TipType { + CASH, + BANK +} + +class CmdTip : ICmdCallback { + override fun execute(player: Player, target: SWGObject?, args: String) { + val tipType = tipType(args) + val amount = args.replace(" bank", "").toIntOrNull() + + if (amount == null) { + SystemMessageIntent.broadcastPersonal(player, ProsePackage(StringId("base_player", "tip_syntax"))) + return + } + + if (amount <= 0) { + SystemMessageIntent.broadcastPersonal(player, ProsePackage(StringId("base_player", "prose_tip_invalid_amt"), "DI", amount)) + return + } + + if (target !is CreatureObject) { + SystemMessageIntent.broadcastPersonal(player, "You cannot tip a non-player object") + return + } + + val targetPlayer = target.owner + + if (targetPlayer == null) { + SystemMessageIntent.broadcastPersonal(player, "You cannot tip a non-player object") + return + } + + if (target == player.creatureObject) { + SystemMessageIntent.broadcastPersonal(player, "You cannot tip yourself") + return + } + + if (tipType == TipType.CASH) { + CashTipIntent.broadcast(player, targetPlayer, amount) + } else if (tipType == TipType.BANK) { + BankTipIntent.broadcast(player, targetPlayer, amount) + } + } + + private fun tipType(args: String): TipType { + return if (args.endsWith(" bank")) { + TipType.BANK + } else { + TipType.CASH + } + } +} diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/player/PlayerManager.kt b/src/main/java/com/projectswg/holocore/services/gameplay/player/PlayerManager.kt index 2214f07d9..69a033944 100644 --- a/src/main/java/com/projectswg/holocore/services/gameplay/player/PlayerManager.kt +++ b/src/main/java/com/projectswg/holocore/services/gameplay/player/PlayerManager.kt @@ -28,6 +28,7 @@ package com.projectswg.holocore.services.gameplay.player import com.projectswg.holocore.services.gameplay.player.badge.BadgeManager import com.projectswg.holocore.services.gameplay.player.character.PlayerCharacterSheetService +import com.projectswg.holocore.services.gameplay.player.character.TippingService import com.projectswg.holocore.services.gameplay.player.experience.ExperienceManager import com.projectswg.holocore.services.gameplay.player.group.GroupManager import com.projectswg.holocore.services.gameplay.player.guild.GuildService @@ -35,5 +36,5 @@ import com.projectswg.holocore.services.gameplay.player.quest.QuestService import me.joshlarson.jlcommon.control.Manager import me.joshlarson.jlcommon.control.ManagerStructure -@ManagerStructure(children = [BadgeManager::class, ExperienceManager::class, GroupManager::class, GuildService::class, QuestService::class, PlayerCharacterSheetService::class]) +@ManagerStructure(children = [BadgeManager::class, ExperienceManager::class, GroupManager::class, GuildService::class, QuestService::class, PlayerCharacterSheetService::class, TippingService::class]) class PlayerManager : Manager() diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/player/character/TippingService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/player/character/TippingService.kt new file mode 100644 index 000000000..94a77394a --- /dev/null +++ b/src/main/java/com/projectswg/holocore/services/gameplay/player/character/TippingService.kt @@ -0,0 +1,127 @@ +/*********************************************************************************** + * Copyright (c) 2023 /// 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.character + +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.data.encodables.player.Mail +import com.projectswg.holocore.ProjectSWG +import com.projectswg.holocore.intents.gameplay.player.character.BankTipIntent +import com.projectswg.holocore.intents.gameplay.player.character.CashTipIntent +import com.projectswg.holocore.intents.support.global.chat.PersistentMessageIntent +import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent +import com.projectswg.holocore.resources.support.data.server_info.StandardLog +import com.projectswg.holocore.resources.support.global.player.Player +import com.projectswg.holocore.resources.support.global.zone.sui.SuiMessageBox +import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject +import me.joshlarson.jlcommon.control.IntentHandler +import me.joshlarson.jlcommon.control.Service + +class TippingService : Service() { + + @IntentHandler + private fun handleCashTip(intent: CashTipIntent) { + val amount = intent.amount + val player = intent.sender + val targetPlayer = intent.receiver + val target = targetPlayer.creatureObject + + if (amount > player.creatureObject.cashBalance) { + SystemMessageIntent.broadcastPersonal(player, ProsePackage(StringId("base_player", "prose_tip_nsf_cash"), "DI", amount, "TT", target.objectName)) + return + } + + transferCash(player, amount, targetPlayer) + } + + @IntentHandler + private fun handleBankTip(intent: BankTipIntent) { + val amount = intent.amount + val player = intent.sender + val targetPlayer = intent.receiver + val target = targetPlayer.creatureObject + + if (amount > player.creatureObject.bankBalance) { + SystemMessageIntent.broadcastPersonal(player, ProsePackage(StringId("base_player", "prose_tip_nsf_bank"), "DI", amount, "TT", target.objectName)) + return + } + + val amountWithSurcharge = (amount * 1.05).toInt() // System applies a 5% surcharge to the bank tip + + if (amountWithSurcharge > player.creatureObject.bankBalance) { + SystemMessageIntent.broadcastPersonal(player, ProsePackage(StringId("base_player", "prose_tip_nsf_wire"), "DI", amountWithSurcharge, "TT", target.objectName)) + return + } + + val suiMessageBox = SuiMessageBox("@base_player:tip_wire_title", "@base_player:tip_wire_prompt") + suiMessageBox.addOkButtonCallback("tip") { _, _ -> + wire(player, target, amount, amountWithSurcharge) + } + suiMessageBox.display(player) + } + + private fun transferCash(player: Player, amount: Int, targetPlayer: Player) { + val target = targetPlayer.creatureObject + player.creatureObject.setCashBalance((player.creatureObject.cashBalance - amount).toLong()) + target.setCashBalance((target.cashBalance + amount).toLong()) + StandardLog.onPlayerEvent(this, player, "tipped %d credits to %s using cash", amount, target) + + sendSystemMessages(player, amount, target, targetPlayer) + } + + private fun sendSystemMessages(player: Player, amount: Int, target: CreatureObject, targetPlayer: Player) { + SystemMessageIntent.broadcastPersonal( + player, ProsePackage(StringId("base_player", "prose_tip_pass_self"), "DI", amount, "TT", target.objectName) + ) + SystemMessageIntent.broadcastPersonal( + targetPlayer, ProsePackage(StringId("base_player", "prose_tip_pass_target"), "TT", player.creatureObject.objectName, "DI", amount) + ) + } + + private fun wire(player: Player, target: CreatureObject, amount: Int, amountWithSurcharge: Int) { + player.creatureObject.setBankBalance((player.creatureObject.bankBalance - amountWithSurcharge).toLong()) + target.setBankBalance((target.bankBalance + amount).toLong()) + StandardLog.onPlayerEvent(this, player, "tipped %d credits to %s using wire transfer", amount, target) + + sendMails(target, amount, player) + } + + private fun sendMails(target: CreatureObject, amount: Int, player: Player) { + val targetMail = Mail("@money/acct_n:bank", "@base_player:wire_mail_subject", "", target.objectId) + targetMail.outOfBandPackage = OutOfBandPackage( + ProsePackage(StringId("base_player", "prose_wire_mail_target"), "DI", amount, "TO", player.creatureObject.objectName), + ) + PersistentMessageIntent(target, targetMail, ProjectSWG.galaxy.name).broadcast() + + val selfMail = Mail(target.objectName, "@base_player:wire_mail_subject", "", player.creatureObject.objectId) + selfMail.outOfBandPackage = OutOfBandPackage( + ProsePackage(StringId("base_player", "prose_wire_mail_self"), "TO", target.objectName, "DI", amount), + ) + PersistentMessageIntent(player.creatureObject, selfMail, ProjectSWG.galaxy.name).broadcast() + } +} \ No newline at end of file diff --git a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandExecutionService.java b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandExecutionService.java index b5bc1dbc2..6fc3416ff 100644 --- a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandExecutionService.java +++ b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandExecutionService.java @@ -123,6 +123,7 @@ public class CommandExecutionService extends Service { registerScriptCallback("knockdownRecovery", KnockdownRecoveryCmdCallback::new); registerScriptCallback("burstRun", BurstRunCmdCallback::new); registerScriptCallback("cmdMeditate", CmdMeditate::new); + registerScriptCallback("cmdTip", CmdTip::new); addAdminScripts(); addChatScripts(); diff --git a/src/test/java/com/projectswg/holocore/services/gameplay/player/TipCreditsTest.kt b/src/test/java/com/projectswg/holocore/services/gameplay/player/TipCreditsTest.kt new file mode 100644 index 000000000..621792a19 --- /dev/null +++ b/src/test/java/com/projectswg/holocore/services/gameplay/player/TipCreditsTest.kt @@ -0,0 +1,218 @@ +/*********************************************************************************** + * Copyright (c) 2023 /// 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 + +import com.projectswg.common.data.location.Location +import com.projectswg.holocore.headless.* +import com.projectswg.holocore.resources.support.data.server_info.loader.ServerData +import com.projectswg.holocore.resources.support.data.server_info.loader.npc.NpcStaticSpawnLoader +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.services.gameplay.player.character.TippingService +import com.projectswg.holocore.services.gameplay.player.experience.skills.SkillService +import com.projectswg.holocore.services.support.global.chat.ChatMailService +import com.projectswg.holocore.services.support.global.chat.ChatSystemService +import com.projectswg.holocore.services.support.global.commands.CommandExecutionService +import com.projectswg.holocore.services.support.global.commands.CommandQueueService +import com.projectswg.holocore.services.support.global.zone.LoginService +import com.projectswg.holocore.services.support.global.zone.ZoneService +import com.projectswg.holocore.services.support.global.zone.creation.CharacterCreationService +import com.projectswg.holocore.services.support.global.zone.sui.SuiService +import com.projectswg.holocore.test.runners.TestRunnerSimulatedWorld +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class TipCreditsTest : TestRunnerSimulatedWorld() { + + private val memoryUserDatabase = MemoryUserDatabase() + + @BeforeEach + fun setUp() { + registerService(LoginService(memoryUserDatabase)) + registerService(ZoneService()) + registerService(CharacterCreationService()) + registerService(SkillService()) + registerService(CommandQueueService(5)) + registerService(CommandExecutionService()) + registerService(ChatSystemService()) + registerService(ChatMailService()) + registerService(SuiService()) + registerService(TippingService()) + } + + @AfterEach + fun tearDown() { + memoryUserDatabase.clear() + } + + @Test + fun negativeAmount() { + val zonedInCharacter1 = createZonedInCharacter("Playerone", "Charone") + val zonedInCharacter2 = createZonedInCharacter("Playertwo", "Chartwo") + val tipAmount = -50 + + assertThrows(TipException::class.java) { + zonedInCharacter1.tipCash(zonedInCharacter2.player.creatureObject, tipAmount) + } + } + + @Test + fun notEnoughMoneyForSurcharge() { + val zonedInCharacter1 = createZonedInCharacter("Playerone", "Charone") + val zonedInCharacter2 = createZonedInCharacter("Playertwo", "Chartwo") + val tipAmount = zonedInCharacter1.player.creatureObject.bankBalance // Sending all bank balance should never be possible, because of the 5% surcharge + + assertThrows(TipException::class.java) { + zonedInCharacter1.tipBank(zonedInCharacter2.player.creatureObject, tipAmount) + } + } + + @Test + fun npc() { + val zonedInCharacter1 = createZonedInCharacter("Playerone", "Charone") + val womprat = spawnNPCs("creature_womprat", zonedInCharacter1.player.creatureObject.location, 1).first() + + assertThrows(TipException::class.java) { + zonedInCharacter1.tipCash(womprat, 1) + } + } + + @Test + fun self() { + val zonedInCharacter1 = createZonedInCharacter("Playerone", "Charone") + + assertThrows(TipException::class.java) { + zonedInCharacter1.tipCash(zonedInCharacter1.player.creatureObject, 1) + } + } + + @Test + fun sufficientCash() { + val zonedInCharacter1 = createZonedInCharacter("Playerone", "Charone") + val zonedInCharacter2 = createZonedInCharacter("Playertwo", "Chartwo") + val char1Before = CreditsSnapshot(zonedInCharacter1) + val char2Before = CreditsSnapshot(zonedInCharacter2) + val tipAmount = 1 + + zonedInCharacter1.tipCash(zonedInCharacter2.player.creatureObject, tipAmount) + + val char1After = CreditsSnapshot(zonedInCharacter1) + val char2After = CreditsSnapshot(zonedInCharacter2) + assertAll( + { assertEquals(char1Before.cash - tipAmount, char1After.cash) }, + { assertEquals(char2Before.cash + tipAmount, char2After.cash) }, + ) + } + + @Test + fun insufficientCash() { + val zonedInCharacter1 = createZonedInCharacter("Playerone", "Charone") + val zonedInCharacter2 = createZonedInCharacter("Playertwo", "Chartwo") + val tipAmount = zonedInCharacter1.player.creatureObject.cashBalance * 2 + + assertThrows(TipException::class.java) { + zonedInCharacter1.tipCash(zonedInCharacter2.player.creatureObject, tipAmount) + } + } + + @Test + fun sufficientBank() { + val zonedInCharacter1 = createZonedInCharacter("Playerone", "Charone") + val zonedInCharacter2 = createZonedInCharacter("Playertwo", "Chartwo") + val char1Before = CreditsSnapshot(zonedInCharacter1) + val char2Before = CreditsSnapshot(zonedInCharacter2) + val tipAmount = 100 + + val suiWindow = zonedInCharacter1.tipBank(zonedInCharacter2.player.creatureObject, tipAmount) + suiWindow.clickOk() + zonedInCharacter1.waitForMail() + zonedInCharacter2.waitForMail() + + val char1After = CreditsSnapshot(zonedInCharacter1) + val char2After = CreditsSnapshot(zonedInCharacter2) + assertAll( + { assertEquals(char1Before.bank - (tipAmount * 1.05).toInt(), char1After.bank) }, + { assertEquals(char2Before.bank + tipAmount, char2After.bank) }, + ) + } + + @Test + fun insufficientBank() { + val zonedInCharacter1 = createZonedInCharacter("Playerone", "Charone") + val zonedInCharacter2 = createZonedInCharacter("Playertwo", "Chartwo") + val char1Before = CreditsSnapshot(zonedInCharacter1) + val tipAmount = char1Before.bank * 2 + + assertThrows(TipException::class.java) { + zonedInCharacter1.tipBank(zonedInCharacter2.player.creatureObject, tipAmount) + } + } + + private fun createZonedInCharacter(username: String, characterName: String): ZonedInCharacter { + val password = "password" + memoryUserDatabase.addUser(username, password) + return HeadlessSWGClient.createZonedInCharacter(username, password, characterName) + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll() { + ServerData.terrains // Eagerly load, so even the very first command is fast in the 'swimming' state check + } + } + + private fun spawnNPCs(npcId: String, location: Location, amount: Int): Collection { + val egg = ObjectCreator.createObjectFromTemplate("object/tangible/ground_spawning/shared_patrol_spawner.iff") + egg.moveToContainer(null, location) + + val spawnInfo = SimpleSpawnInfo.builder() + .withNpcId(npcId) + .withDifficulty(CreatureDifficulty.NORMAL) + .withMinLevel(1) + .withMaxLevel(1) + .withLocation(location) + .withAmount(amount) + .withSpawnerFlag(NpcStaticSpawnLoader.SpawnerFlag.ATTACKABLE) + .build() + + return NPCCreator.createAllNPCs(Spawner(spawnInfo, egg)) + } + +} + +private data class CreditsSnapshot(private val character: ZonedInCharacter) { + val cash = character.player.creatureObject.cashBalance + val bank = character.player.creatureObject.bankBalance +}