Restructured loot generation and added a StaticItemCreator to streamline creating static items

This commit is contained in:
Obique
2019-08-15 02:20:39 -04:00
parent 23cc77696a
commit 2ff6719c10
9 changed files with 567 additions and 506 deletions
@@ -0,0 +1,251 @@
/***********************************************************************************
* Copyright (c) 2019 /// 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 <http://www.gnu.org/licenses/>. *
***********************************************************************************/
package com.projectswg.holocore.resources.support.objects
import com.projectswg.holocore.resources.support.data.server_info.loader.DataLoader
import com.projectswg.holocore.resources.support.data.server_info.loader.StaticItemLoader
import com.projectswg.holocore.resources.support.objects.swg.SWGObject
import com.projectswg.holocore.resources.support.objects.swg.tangible.TangibleObject
import com.projectswg.holocore.resources.support.objects.swg.weapon.WeaponObject
import java.util.*
import kotlin.math.floor
object StaticItemCreator {
fun createItem(itemName: String): SWGObject? {
val info = DataLoader.staticItems().getItemByName(itemName) ?: return null
val swgObject = ObjectCreator.createObjectFromTemplate(info.iffTemplate) as? TangibleObject ?: return null
applyAttributes(swgObject, info)
return swgObject
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.StaticItemInfo) {
obj.addAttribute("condition", String.format("%d/%d", info.hitPoints, info.hitPoints))
obj.addAttribute("volume", info.volume.toString())
obj.objectName = info.stringName
applyAttributes(obj, info.armorInfo)
applyAttributes(obj, info.wearableInfo)
applyAttributes(obj, info.weaponInfo)
applyAttributes(obj, info.collectionInfo)
applyAttributes(obj, info.costumeInfo)
applyAttributes(obj, info.dnaInfo)
applyAttributes(obj, info.grantInfo)
applyAttributes(obj, info.genericInfo)
applyAttributes(obj, info.objectInfo)
applyAttributes(obj, info.schematicInfo)
applyAttributes(obj, info.storytellerInfo)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.ArmorItemInfo?) {
if (info == null)
return
if (info.requiredProfession.isNotEmpty())
obj.addAttribute("class_required", "@ui_roadmap:title_" + info.requiredProfession)
obj.addAttribute("required_combat_level", info.requiredLevel.toString())
val kineticMax: Int
val energyMax: Int
when (info.armorType) {
StaticItemLoader.ArmorItemInfo.ArmorType.ASSAULT -> {
kineticMax = 7000
energyMax = 5000
obj.addAttribute("armor_category", "@obj_attr_n:armor_assault")
}
StaticItemLoader.ArmorItemInfo.ArmorType.BATTLE -> {
kineticMax = 6000
energyMax = 6000
obj.addAttribute("armor_category", "@obj_attr_n:armor_battle")
}
StaticItemLoader.ArmorItemInfo.ArmorType.RECON -> {
kineticMax = 5000
energyMax = 7000
obj.addAttribute("armor_category", "@obj_attr_n:armor_reconnaissance")
}
}
obj.addAttribute("cat_armor_standard_protection.kinetic", calculateProtection(kineticMax, info.protection))
obj.addAttribute("cat_armor_standard_protection.energy", calculateProtection(energyMax, info.protection))
obj.addAttribute("cat_armor_special_protection.special_protection_type_heat", calculateProtection(6000, info.protection))
obj.addAttribute("cat_armor_special_protection.special_protection_type_cold", calculateProtection(6000, info.protection))
obj.addAttribute("cat_armor_special_protection.special_protection_type_acid", calculateProtection(6000, info.protection))
obj.addAttribute("cat_armor_special_protection.special_protection_type_electricity", calculateProtection(6000, info.protection))
applySkillMods(obj, info.skillMods)
applyColors(obj, info.color)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.WearableItemInfo?) {
if (info == null)
return
if (info.requiredProfession.isNotEmpty())
obj.addAttribute("class_required", "@ui_roadmap:title_" + info.requiredProfession)
obj.addAttribute("required_combat_level", info.requiredLevel.toString())
if (info.requiredFaction.isNotEmpty())
obj.addAttribute("faction_restriction", "@pvp_factions:" + info.requiredFaction)
// Apply the mods!
applySkillMods(obj, info.skillMods)
applyColors(obj, info.color)
// Add the race restrictions only if there are any
val raceRestriction = buildRaceRestrictionString(info)
if (raceRestriction.isNotEmpty())
obj.addAttribute("species_restrictions.species_name", raceRestriction)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.WeaponItemInfo?) {
if (info == null)
return
if (info.requiredProfession.isNotEmpty())
obj.addAttribute("class_required", "@ui_roadmap:title_" + info.requiredProfession)
obj.addAttribute("required_combat_level", info.requiredLevel.toString())
obj.addAttribute("cat_wpn_damage.wpn_damage_type", "@obj_attr_n:${info.damageType.name.toLowerCase(Locale.US)}")
obj.addAttribute("cat_wpn_damage.wpn_category", "@obj_attr_n:wpn_category_" + info.weaponType.num)
obj.addAttribute("cat_wpn_damage.wpn_attack_speed", (info.attackSpeed / 100).toString())
obj.addAttribute("cat_wpn_damage.damage", "${info.minDamage}-${info.maxDamage}")
if (info.elementalType != null) { // Not all weapons have elemental damage.
obj.addAttribute("cat_wpn_damage.wpn_elemental_type", "@obj_attr_n:${info.elementalType.name.toLowerCase(Locale.US)}")
obj.addAttribute("cat_wpn_damage.wpn_elemental_value", info.elementalDamage.toString())
}
obj.addAttribute("cat_wpn_damage.weapon_dps", info.actualDps.toString())
if (info.procEffect.isNotEmpty())
// Not all weapons have a proc effect
obj.addAttribute("proc_name", info.procEffect)
// TODO set DPS
obj.addAttribute("cat_wpn_other.wpn_range", String.format("%d-%dm", info.minRange, info.maxRange))
// Ziggy: Special Action Cost would go under cat_wpn_other as well, but it's a pre-NGE artifact.
val weapon = obj as WeaponObject
weapon.type = info.weaponType
weapon.attackSpeed = info.attackSpeed.toFloat()
weapon.minRange = info.minRange.toFloat()
weapon.maxRange = info.maxRange.toFloat()
weapon.damageType = info.damageType
weapon.elementalType = info.elementalType
weapon.minDamage = info.minDamage
weapon.maxDamage = info.maxDamage
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.CollectionItemInfo?) {
if (info == null)
return
obj.addAttribute("collection_name", info.slotName)
applyColors(obj, info.color)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.CostumeItemInfo?) {
// if (info == null)
// return;
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.DnaItemInfo?) {
// if (info == null)
// return;
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.GrantItemInfo?) {
// if (info == null)
// return;
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.GenericItemInfo?) {
if (info == null)
return
if (info.value != 0)
obj.addAttribute("charges", info.value.toString())
applyColors(obj, info.color)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.ObjectItemInfo?) {
if (info == null)
return
if (info.value != 0)
obj.addAttribute("charges", info.value.toString())
applyColors(obj, info.color)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.SchematicItemInfo?) {
// if (info == null)
// return;
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.StorytellerItemInfo?) {
// if (info == null)
// return;
}
private fun applySkillMods(obj: TangibleObject, skillMods: Map<String, Int>) {
for ((key, value) in skillMods)
obj.addAttribute(key, value.toString())
}
private fun applyColors(obj: TangibleObject, colors: IntArray) {
for (i in 0..3) {
if (colors[i] >= 0) {
obj.putCustomization("/private/index_color_$i", colors[i])
}
}
}
private fun buildRaceRestrictionString(info: StaticItemLoader.WearableItemInfo): String {
var races = ""
if (info.isRaceWookie)
races += "Wookiee "
if (info.isRaceIthorian)
races += "Ithorian "
if (info.isRaceRodian)
races += "Rodian "
if (info.isRaceTrandoshan)
races += "Trandoshan "
if (info.isRaceRest)
races += "MonCal Human Zabrak Bothan Sullustan Twi'lek "
return if (races.isEmpty()) "" else races.substring(0, races.length - 1)
}
private fun calculateProtection(max: Int, protection: Double): String {
return floor(max * protection).toInt().toString()
}
}
@@ -256,6 +256,8 @@ public final class GrantLootService extends Service {
switch (item.moveToContainer(looter, looter.getInventory())) {
case SUCCESS: {
String itemName = item.getObjectName();
if (itemName.isEmpty())
itemName = item.getStringId().toString();
if (item instanceof CreditObject) {
onLootedCredits(looter, ((CreditObject) item).getAmount());
@@ -1,35 +1,40 @@
package com.projectswg.holocore.services.gameplay.combat.loot
import com.projectswg.common.data.location.Location
import com.projectswg.common.network.packets.swg.zone.PlayClientEffectObjectMessage
import com.projectswg.common.network.packets.swg.zone.PlayClientEffectObjectTransformMessage
import com.projectswg.common.network.packets.swg.zone.PlayMusicMessage
import com.projectswg.common.network.packets.swg.zone.object_controller.ShowLootBox
import com.projectswg.holocore.intents.gameplay.combat.CreatureKilledIntent
import com.projectswg.holocore.intents.gameplay.combat.loot.CorpseLootedIntent
import com.projectswg.holocore.intents.gameplay.combat.loot.LootGeneratedIntent
import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent
import com.projectswg.holocore.intents.support.objects.items.CreateStaticItemIntent
import com.projectswg.holocore.intents.support.objects.swg.ObjectCreatedIntent
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.data.server_info.mongodb.PswgDatabase
import com.projectswg.holocore.resources.support.objects.ObjectCreator
import com.projectswg.holocore.resources.support.objects.swg.SWGObject
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureDifficulty
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject
import com.projectswg.holocore.resources.support.objects.swg.custom.AIObject
import com.projectswg.holocore.resources.support.objects.swg.group.GroupObject
import com.projectswg.holocore.resources.support.objects.swg.tangible.CreditObject
import com.projectswg.holocore.services.gameplay.combat.loot.generation.CreditLootGenerator
import com.projectswg.holocore.services.gameplay.combat.loot.generation.ItemLootGenerator
import com.projectswg.holocore.services.gameplay.combat.loot.generation.NPCLootTable
import com.projectswg.holocore.services.gameplay.combat.loot.generation.RareItemLootGenerator
import com.projectswg.holocore.services.support.objects.ObjectStorageService.ObjectLookup
import com.projectswg.holocore.services.support.objects.items.StaticItemService
import me.joshlarson.jlcommon.control.IntentChain
import kotlinx.coroutines.*
import me.joshlarson.jlcommon.control.IntentHandler
import me.joshlarson.jlcommon.control.Service
import me.joshlarson.jlcommon.log.Log
import java.util.*
import java.util.concurrent.ThreadLocalRandom
class LootGenerationService : Service() {
private val npcLoot: MutableMap<String, NPCLoot> = HashMap() // K: npc_id, V: possible loot
private val creditGenerator = CreditLootGenerator()
private val itemGenerator = ItemLootGenerator()
private val rareItemLootGenerator = RareItemLootGenerator()
private val scope = CoroutineScope(Dispatchers.Default)
override fun initialize(): Boolean {
loadNPCLoot()
@@ -37,36 +42,58 @@ class LootGenerationService : Service() {
return true
}
override fun stop(): Boolean {
scope.coroutineContext.cancel()
return true
}
@IntentHandler
private fun handleCreatureKilled(cki: CreatureKilledIntent) {
val corpse = cki.corpse as? AIObject ?: return
val creatureId = corpse.creatureId
val loot = npcLoot[creatureId]
val lootRecord = npcLoot[creatureId]
if (loot == null) {
if (lootRecord == null) {
Log.w("No NPCLoot associated with NPC ID: $creatureId")
return
}
val loot = ArrayList<SWGObject>()
val killer = cki.killer
if (!killer.isPlayer)
return
// Various kinds of loot
if (lootRecord.isDropCredits && PswgDatabase.config.getBoolean(this, "cashLoot", true))
creditGenerator.generate(corpse, loot)
if (PswgDatabase.config.getBoolean(this, "itemLoot", true))
itemGenerator.generate(corpse, killer, loot, lootRecord.npcTables)
rareItemLootGenerator.generate(corpse = corpse, killer = killer, loot = loot)
if (loot.isEmpty()) {
CorpseLootedIntent(corpse).broadcast()
return
}
val lootInventory = ObjectCreator.createObjectFromTemplate("object/tangible/inventory/shared_creature_inventory.iff")
loot.forEach { obj ->
obj.moveToContainer(lootInventory)
ObjectCreatedIntent.broadcast(obj)
scope.launch {
delay(60)
when (obj.template) {
RareLootService.RARE_CHEST,
RareLootService.LEGENDARY_CHEST,
RareLootService.EXCEPTIONAL_CHEST -> sendRareLootPackets(obj, corpse, killer)
}
}
}
lootInventory.moveToContainer(corpse, corpse.location)
ObjectCreatedIntent.broadcast(lootInventory)
val killer = cki.killer
var cashGenerated = false
var lootGenerated = false
if (PswgDatabase.config.getBoolean(this, "cashLoot", true))
cashGenerated = generateCreditChip(loot, lootInventory, corpse.difficulty, corpse.level.toInt())
if (PswgDatabase.config.getBoolean(this, "itemLoot", true))
lootGenerated = generateLoot(loot, killer, lootInventory, corpse.difficulty, corpse.level.toInt())
if (!cashGenerated && !lootGenerated)
CorpseLootedIntent(corpse).broadcast()
else
showLootDisc(killer, corpse)
showLootDisc(killer, corpse)
}
/**
@@ -100,7 +127,7 @@ class LootGenerationService : Service() {
if (chance <= 0)
return
loot.npcTables.add(NPCTable(chance, table))
loot.npcTables.add(NPCLootTable(chance, table))
}
private fun showLootDisc(requester: CreatureObject, corpse: AIObject) {
@@ -122,125 +149,14 @@ class LootGenerationService : Service() {
LootGeneratedIntent.broadcast(corpse)
}
private fun generateCreditChip(loot: NPCLoot, lootInventory: SWGObject, difficulty: CreatureDifficulty, combatLevel: Int): Boolean {
if (!loot.isDropCredits)
return false
val random = ThreadLocalRandom.current()
val config = PswgDatabase.config
val range = config.getDouble(this, "lootCashHumanRange", 0.05)
val cashLootRoll = random.nextDouble()
val credits = combatLevel * (1 + random.nextDouble(0.0, range)).toInt() * when (difficulty) {
CreatureDifficulty.NORMAL -> {
if (cashLootRoll > config.getDouble(this, "lootCashHumanNormalChance", 0.60))
return false
config.getInt(this, "lootCashHumanNormal", 2)
}
CreatureDifficulty.ELITE -> {
if (cashLootRoll > config.getDouble(this, "lootCashHumanElitechance", 0.80))
return false
config.getInt(this, "lootCashHumanElite", 5)
}
CreatureDifficulty.BOSS -> // bosses always drop cash loot, so no need to check
config.getInt(this, "lootCashHumanBoss", 9)
}
// TODO scale with group size?
val cashObject = ObjectCreator.createObjectFromTemplate("object/tangible/item/shared_loot_cash.iff", CreditObject::class.java)
cashObject.amount = credits.toLong()
cashObject.moveToContainer(lootInventory)
ObjectCreatedIntent.broadcast(cashObject)
return true
private fun sendRareLootPackets(chest: SWGObject, corpse: CreatureObject, killer: CreatureObject) {
val effect = PlayClientEffectObjectMessage("appearance/pt_rare_chest.prt", "", corpse.objectId, "")
val sound = PlayMusicMessage(0, "sound/rare_loot_chest.snd", 1, false)
val box = ShowLootBox(killer.objectId, longArrayOf(chest.objectId))
killer.owner?.sendPacket(effect, sound, box)
}
/**
* Generates loot and places it in the inventory of the corpse.
*
* @param loot the loot info of the creature killed
* @param killer the person that killed the NPC
* @param lootInventory the inventory the loot will be placed in (corpse inventory)
* @return whether loot was generated or not
*/
private fun generateLoot(loot: NPCLoot, killer: CreatureObject, lootInventory: SWGObject, difficulty: CreatureDifficulty, level: Int): Boolean {
val random = ThreadLocalRandom.current()
var lootGenerated = false
val tableRoll = random.nextInt(100) + 1
// Admin Variables
val admin = killer.hasCommand("admin")
val adminOutput1 = StringBuilder("$tableRoll //")
val adminOutput2 = StringBuilder()
val lootTables = ServerData.lootTables
for ((tableId, npcTable) in loot.npcTables.withIndex()) {
val tableChance = npcTable.chance
if (tableRoll > tableChance) {
// Skip ahead if there's no drop chance
adminOutput1.append("/ \\#FF0000 loot_table").append(tableId+1)
break
}
adminOutput1.append("/ \\#00FF00 loot_table").append(tableId+1)
val groupRoll = random.nextInt(100) + 1
val lootTableItem = lootTables.getLootTableItem(npcTable.lootTable, difficulty.name, level) ?: continue
for ((chance, items) in lootTableItem.groups) { // group of items to be granted
if (groupRoll > chance)
continue
val itemName = items[random.nextInt(items.size)]
adminOutput2.append(itemName).append('\n')
when {
itemName.startsWith("dynamic_") -> { // TODO dynamic item handling
SystemMessageIntent(killer.owner!!, "We don't support this loot item yet: $itemName").broadcast()
}
itemName.endsWith(".iff") -> {
val obj = ObjectCreator.createObjectFromTemplate(itemName)
obj.moveToContainer(lootInventory)
ObjectCreatedIntent.broadcast(obj)
lootGenerated = true
}
else -> {
CreateStaticItemIntent(killer, lootInventory, CreateStaticItemCallback(), itemName).broadcast()
lootGenerated = true
}
}
break
}
}
if (admin) {
IntentChain.broadcastChain(
SystemMessageIntent(killer.owner!!, adminOutput1.toString()),
SystemMessageIntent(killer.owner!!, adminOutput2.toString())
)
}
return lootGenerated
}
private class NPCLoot(val isDropCredits: Boolean, val npcTables: MutableCollection<NPCTable> = ArrayList())
private class NPCTable(val chance: Int, val lootTable: String)
private class CreateStaticItemCallback : StaticItemService.ObjectCreationHandler {
override val isIgnoreVolume = true
override fun success(createdObjects: List<SWGObject>) {
// do nothing - loot disc is created on the return of the generateLoot method
}
}
private class NPCLoot(val isDropCredits: Boolean, val npcTables: MutableList<NPCLootTable> = ArrayList())
}
@@ -26,22 +26,14 @@
***********************************************************************************/
package com.projectswg.holocore.services.gameplay.combat.loot;
import com.projectswg.common.data.encodables.oob.StringId;
import com.projectswg.common.network.packets.swg.zone.PlayClientEffectObjectMessage;
import com.projectswg.common.network.packets.swg.zone.PlayMusicMessage;
import com.projectswg.common.network.packets.swg.zone.object_controller.ShowLootBox;
import com.projectswg.holocore.intents.gameplay.combat.CreatureKilledIntent;
import com.projectswg.holocore.intents.gameplay.combat.loot.OpenRareChestIntent;
import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent;
import com.projectswg.holocore.intents.support.objects.items.CreateStaticItemIntent;
import com.projectswg.holocore.intents.support.objects.swg.DestroyObjectIntent;
import com.projectswg.holocore.intents.support.objects.swg.ObjectCreatedIntent;
import com.projectswg.holocore.resources.support.data.server_info.SdbLoader;
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.objects.ObjectCreator;
import com.projectswg.holocore.resources.support.objects.swg.SWGObject;
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureDifficulty;
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject;
import com.projectswg.holocore.resources.support.objects.swg.tangible.TangibleObject;
import com.projectswg.holocore.services.support.objects.items.StaticItemService;
@@ -54,7 +46,6 @@ import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
public class RareLootService extends Service {
@@ -63,18 +54,11 @@ public class RareLootService extends Service {
public static final String EXCEPTIONAL_CHEST = "object/tangible/item/shared_rare_loot_chest_2.iff";
public static final String LEGENDARY_CHEST = "object/tangible/item/shared_rare_loot_chest_3.iff";
// TODO these two could be config options
private static final short MAX_LEVEL_DIFFERENCE = 6; // +-6 difference is allowed between killer and corpse
private static final int DROP_CHANCE = 1; // One in a hundred eligible kills will drop a chest
private final Random random;
private final List<String> rareItems;
private final List<String> exceptionalItems;
private final List<String> legendaryItems;
public RareLootService() {
random = new Random();
rareItems = new ArrayList<>();
exceptionalItems = new ArrayList<>();
legendaryItems = new ArrayList<>();
@@ -112,38 +96,6 @@ public class RareLootService extends Service {
return super.initialize();
}
@IntentHandler
private void handleCreatureKilledIntent(CreatureKilledIntent cki) {
// TODO only the player that delivered the killing blow is considered for this
CreatureObject corpse = cki.getCorpse();
CreatureObject killer = cki.getKiller();
if (!isPlayerEligible(killer.isPlayer(), corpse.isPlayer())) {
return;
}
if (!isLevelEligible(corpse.getLevel(), killer.getLevel())) {
return;
}
int roll = random.nextInt(100) + 1; // Rolls from 0 to 99, then we add 1 and it becomes 1 to 100
if (!isDrop(roll)) {
Log.d("No RLS drop from %s with roll %d", corpse, roll);
return;
}
String template = templateForDifficulty(corpse.getDifficulty());
SWGObject chest = ObjectCreator.createObjectFromTemplate(template);
SWGObject inventory = killer.getSlottedObject("inventory");
chest.setStf("loot_n", chestIdForTemplate(template) + "_n");
chest.setDetailStf(new StringId("loot_n", chestIdForTemplate(template) + "_d")); // Not located in loot_d, for whatever reason...
chest.moveToContainer(inventory);
sendSuccessPackets(chest, corpse, killer);
}
@IntentHandler
private void handleOpenRareChestIntent(OpenRareChestIntent intent) {
CreatureObject actor = intent.getActor();
@@ -183,50 +135,6 @@ public class RareLootService extends Service {
}
boolean isPlayerEligible(boolean killerPlayer, boolean corpsePlayer) {
return killerPlayer && !corpsePlayer;
}
boolean isLevelEligible(int corpseLevel, int killerLevel) {
// Ensure a positive levelDifference
int highestLevel = Math.max(corpseLevel, killerLevel);
int lowestLevel = Math.min(corpseLevel, killerLevel);
int levelDifference = highestLevel - lowestLevel;
return levelDifference <= MAX_LEVEL_DIFFERENCE;
}
boolean isDrop(int roll) {
return roll <= DROP_CHANCE;
}
String templateForDifficulty(CreatureDifficulty difficulty) {
switch (difficulty) {
case NORMAL:
return RARE_CHEST;
case ELITE:
return EXCEPTIONAL_CHEST;
case BOSS:
return LEGENDARY_CHEST;
default:
throw new IllegalArgumentException("Unknown CreatureDifficulty: " + difficulty);
}
}
String chestIdForTemplate(String template) {
return template.replace("object/tangible/item/shared_", "").replace(".iff", "");
}
private void sendSuccessPackets(SWGObject chest, CreatureObject corpse, CreatureObject killer) {
ObjectCreatedIntent.broadcast(chest);
PlayClientEffectObjectMessage effect = new PlayClientEffectObjectMessage("appearance/pt_rare_chest.prt", "", corpse.getObjectId(), "");
PlayMusicMessage sound = new PlayMusicMessage(0, "sound/rare_loot_chest.snd", 1, false);
ShowLootBox box = new ShowLootBox(killer.getObjectId(), new long[] { chest.getObjectId() });
killer.getOwner().sendPacket(effect, sound, box);
}
private String randomItem(List<String> items) {
int availableItems = items.size();
int randomIndex = ThreadLocalRandom.current().nextInt(availableItems);
@@ -0,0 +1,70 @@
/***********************************************************************************
* Copyright (c) 2019 /// 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 <http://www.gnu.org/licenses/>. *
***********************************************************************************/
package com.projectswg.holocore.services.gameplay.combat.loot.generation
import com.projectswg.holocore.resources.support.data.server_info.mongodb.PswgDatabase
import com.projectswg.holocore.resources.support.objects.ObjectCreator
import com.projectswg.holocore.resources.support.objects.swg.SWGObject
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureDifficulty
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject
import com.projectswg.holocore.resources.support.objects.swg.tangible.CreditObject
import java.util.concurrent.ThreadLocalRandom
class CreditLootGenerator() {
fun generate(corpse: CreatureObject, loot: MutableList<SWGObject>) {
val random = ThreadLocalRandom.current()
val config = PswgDatabase.config
val range = config.getDouble(this, "lootCashHumanRange", 0.05)
val cashLootRoll = random.nextDouble()
val credits = corpse.level.toInt() * (1 + random.nextDouble(0.0, range)).toInt() * when (corpse.difficulty) {
CreatureDifficulty.ELITE -> {
if (cashLootRoll > config.getDouble(this, "lootCashHumanElitechance", 0.80))
return
config.getInt(this, "lootCashHumanElite", 5)
}
CreatureDifficulty.BOSS -> // bosses always drop cash loot, so no need to check
config.getInt(this, "lootCashHumanBoss", 9)
else -> {
if (cashLootRoll > config.getDouble(this, "lootCashHumanNormalChance", 0.60))
return
config.getInt(this, "lootCashHumanNormal", 2)
}
}
// TODO scale with group size?
val cashObject = ObjectCreator.createObjectFromTemplate("object/tangible/item/shared_loot_cash.iff", CreditObject::class.java)
cashObject.amount = credits.toLong()
loot.add(cashObject)
}
}
@@ -0,0 +1,97 @@
/***********************************************************************************
* Copyright (c) 2019 /// 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 <http://www.gnu.org/licenses/>. *
***********************************************************************************/
package com.projectswg.holocore.services.gameplay.combat.loot.generation
import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent
import com.projectswg.holocore.resources.support.data.server_info.loader.ServerData
import com.projectswg.holocore.resources.support.objects.ObjectCreator
import com.projectswg.holocore.resources.support.objects.StaticItemCreator
import com.projectswg.holocore.resources.support.objects.swg.SWGObject
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject
import me.joshlarson.jlcommon.control.IntentChain
import java.util.concurrent.ThreadLocalRandom
class ItemLootGenerator {
fun generate(corpse: CreatureObject, killer: CreatureObject, loot: MutableList<SWGObject>, lootTables: List<NPCLootTable>) {
val random = ThreadLocalRandom.current()
val tableRoll = random.nextInt(100) + 1
// Admin Variables
val admin = killer.hasCommand("admin")
val adminOutput1 = StringBuilder("$tableRoll //")
val adminOutput2 = StringBuilder()
val lootTableLookup = ServerData.lootTables
for ((tableId, npcTable) in lootTables.withIndex()) {
val tableChance = npcTable.chance
if (tableRoll > tableChance) {
// Skip ahead if there's no drop chance
adminOutput1.append("/ \\#FF0000 loot_table").append(tableId+1)
break
}
adminOutput1.append("/ \\#00FF00 loot_table").append(tableId+1)
val groupRoll = random.nextInt(100) + 1
val lootTableItem = lootTableLookup.getLootTableItem(npcTable.lootTable, corpse.difficulty.name, corpse.level.toInt()) ?: continue
for ((chance, items) in lootTableItem.groups) { // group of items to be granted
if (groupRoll > chance)
continue
val itemName = items[random.nextInt(items.size)]
adminOutput2.append(itemName).append('\n')
when {
itemName.startsWith("dynamic_") -> { // TODO dynamic item handling
SystemMessageIntent(killer.owner!!, "We don't support this loot item yet: $itemName").broadcast()
}
itemName.endsWith(".iff") -> {
loot.add(ObjectCreator.createObjectFromTemplate(itemName))
}
else -> {
val obj = StaticItemCreator.createItem(itemName)
if (obj != null)
loot.add(obj)
}
}
break
}
}
if (admin) {
IntentChain.broadcastChain(
SystemMessageIntent(killer.owner!!, adminOutput1.toString()),
SystemMessageIntent(killer.owner!!, adminOutput2.toString())
)
}
}
}
@@ -1,5 +1,5 @@
/***********************************************************************************
* Copyright (c) 2018 /// Project SWG /// www.projectswg.com *
* Copyright (c) 2019 /// 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. *
@@ -24,50 +24,7 @@
* You should have received a copy of the GNU Affero General Public License *
* along with Holocore. If not, see <http://www.gnu.org/licenses/>. *
***********************************************************************************/
package com.projectswg.holocore.services.gameplay.combat.loot;
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureDifficulty;
import com.projectswg.holocore.test.runners.TestRunnerNoIntents;
import org.junit.Assert;
import org.junit.Test;
package com.projectswg.holocore.services.gameplay.combat.loot.generation
public class TestRareLootService extends TestRunnerNoIntents {
private final RareLootService rls;
public TestRareLootService() {
rls = new RareLootService();
}
@Test
public void testIsPlayerEligible() {
Assert.assertTrue(rls.isPlayerEligible(true, false));
Assert.assertFalse(rls.isPlayerEligible(false, true));
Assert.assertFalse(rls.isPlayerEligible(false, false));
}
@Test
public void testIsLevelEligible() {
Assert.assertTrue(rls.isLevelEligible(90, 84));
Assert.assertFalse(rls.isLevelEligible(90, 83));
}
@Test
public void testIsDrop() {
Assert.assertTrue(rls.isDrop(1));
Assert.assertFalse(rls.isDrop(2));
}
@Test
public void testTemplateForDifficulty() {
Assert.assertEquals("object/tangible/item/shared_rare_loot_chest_1.iff", rls.templateForDifficulty(CreatureDifficulty.NORMAL));
Assert.assertEquals("object/tangible/item/shared_rare_loot_chest_2.iff", rls.templateForDifficulty(CreatureDifficulty.ELITE));
Assert.assertEquals("object/tangible/item/shared_rare_loot_chest_3.iff", rls.templateForDifficulty(CreatureDifficulty.BOSS));
}
@Test
public void testChestIdForTemplate() {
Assert.assertEquals("rare_loot_chest_1", rls.chestIdForTemplate("object/tangible/item/shared_rare_loot_chest_1.iff"));
}
}
data class NPCLootTable(val chance: Int, val lootTable: String)
@@ -0,0 +1,80 @@
/***********************************************************************************
* Copyright (c) 2019 /// 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 <http://www.gnu.org/licenses/>. *
***********************************************************************************/
package com.projectswg.holocore.services.gameplay.combat.loot.generation
import com.projectswg.common.data.encodables.oob.StringId
import com.projectswg.holocore.resources.support.objects.ObjectCreator
import com.projectswg.holocore.resources.support.objects.swg.SWGObject
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureDifficulty
import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject
import com.projectswg.holocore.services.gameplay.combat.loot.RareLootService.*
import me.joshlarson.jlcommon.log.Log
import java.util.concurrent.ThreadLocalRandom
import kotlin.math.abs
class RareItemLootGenerator {
fun generate(corpse: CreatureObject, killer: CreatureObject, loot: MutableList<SWGObject>) {
if (abs(corpse.level.toInt() - killer.level.toInt()) > MAX_LEVEL_DIFFERENCE) // Levels can't be too far apart
return
val roll = 0 //ThreadLocalRandom.current().nextInt(100) + 1 // Rolls from 0 to 99, then we add 1 and it becomes 1 to 100
if (roll >= DROP_CHANCE) {
Log.d("No RLS drop from %s with roll %d", corpse, roll)
return
}
val template = templateForDifficulty(corpse.difficulty)
val chest = ObjectCreator.createObjectFromTemplate(template)
chest.setStf("loot_n", chestIdForTemplate(template) + "_n")
chest.setDetailStf(StringId("loot_n", chestIdForTemplate(template) + "_d")) // Not located in loot_d, for whatever reason...
loot.add(chest)
}
private fun chestIdForTemplate(template: String): String {
return template.replace("object/tangible/item/shared_", "").replace(".iff", "")
}
private fun templateForDifficulty(difficulty: CreatureDifficulty): String {
return when (difficulty) {
CreatureDifficulty.NORMAL -> RARE_CHEST
CreatureDifficulty.ELITE -> EXCEPTIONAL_CHEST
CreatureDifficulty.BOSS -> LEGENDARY_CHEST
}
}
companion object {
// TODO these two could be config options
private const val MAX_LEVEL_DIFFERENCE: Short = 6 // +-6 difference is allowed between killer and corpse
private const val DROP_CHANCE = 1 // One in a hundred eligible kills will drop a chest
}
}
@@ -30,14 +30,9 @@ import com.projectswg.common.network.packets.swg.zone.object_controller.ShowLoot
import com.projectswg.holocore.intents.support.global.chat.SystemMessageIntent
import com.projectswg.holocore.intents.support.objects.items.CreateStaticItemIntent
import com.projectswg.holocore.intents.support.objects.swg.ObjectCreatedIntent
import com.projectswg.holocore.resources.support.data.server_info.loader.DataLoader
import com.projectswg.holocore.resources.support.data.server_info.loader.StaticItemLoader
import com.projectswg.holocore.resources.support.data.server_info.loader.StaticItemLoader.StaticItemInfo
import com.projectswg.holocore.resources.support.objects.ObjectCreator
import com.projectswg.holocore.resources.support.objects.StaticItemCreator
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.tangible.TangibleObject
import com.projectswg.holocore.resources.support.objects.swg.weapon.WeaponObject
import kotlinx.coroutines.*
import me.joshlarson.jlcommon.control.Intent
import me.joshlarson.jlcommon.control.IntentChain
@@ -45,7 +40,6 @@ import me.joshlarson.jlcommon.control.IntentHandler
import me.joshlarson.jlcommon.control.Service
import me.joshlarson.jlcommon.log.Log
import java.util.*
import kotlin.math.floor
class StaticItemService : Service() {
@@ -63,7 +57,7 @@ class StaticItemService : Service() {
val objectCreationHandler = csii.objectCreationHandler
// If adding these items to the container would exceed the max capacity...
if (!objectCreationHandler.isIgnoreVolume && container.volume + itemNames.size > container.maxContainerSize) {
if (!objectCreationHandler.isIgnoreVolume && container.containedObjects.size + itemNames.size > container.maxContainerSize) {
objectCreationHandler.containerFull()
return
}
@@ -71,9 +65,11 @@ class StaticItemService : Service() {
val intentChain = IntentChain()
val objects = ArrayList<SWGObject>()
for (itemName in itemNames) {
val obj = createItem(intentChain, itemName, container)
val obj = StaticItemCreator.createItem(itemName)
if (obj != null) {
objects.add(obj)
obj.moveToContainer(container)
intentChain.broadcastAfter(ObjectCreatedIntent(obj))
} else {
Log.d("%s could not be spawned because the item name is unknown", itemName)
val requesterOwner = csii.requester.owner
@@ -93,227 +89,11 @@ class StaticItemService : Service() {
}
}
private fun createItem(intentChain: IntentChain, itemName: String, container: SWGObject): SWGObject? {
val info = DataLoader.staticItems().getItemByName(itemName) ?: return null
val swgObject = ObjectCreator.createObjectFromTemplate(info.iffTemplate) as? TangibleObject ?: return null
applyAttributes(swgObject, info)
intentChain.broadcastAfter(ObjectCreatedIntent(swgObject))
swgObject.moveToContainer(container)
Log.d("Successfully moved %s into container %s", itemName, container)
return swgObject
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemInfo) {
obj.addAttribute("condition", String.format("%d/%d", info.hitPoints, info.hitPoints))
obj.addAttribute("volume", info.volume.toString())
obj.objectName = info.stringName
applyAttributes(obj, info.armorInfo)
applyAttributes(obj, info.wearableInfo)
applyAttributes(obj, info.weaponInfo)
applyAttributes(obj, info.collectionInfo)
applyAttributes(obj, info.costumeInfo)
applyAttributes(obj, info.dnaInfo)
applyAttributes(obj, info.grantInfo)
applyAttributes(obj, info.genericInfo)
applyAttributes(obj, info.objectInfo)
applyAttributes(obj, info.schematicInfo)
applyAttributes(obj, info.storytellerInfo)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.ArmorItemInfo?) {
if (info == null)
return
if (info.requiredProfession.isNotEmpty())
obj.addAttribute("class_required", "@ui_roadmap:title_" + info.requiredProfession)
obj.addAttribute("required_combat_level", info.requiredLevel.toString())
val kineticMax: Int
val energyMax: Int
when (info.armorType) {
StaticItemLoader.ArmorItemInfo.ArmorType.ASSAULT -> {
kineticMax = 7000
energyMax = 5000
obj.addAttribute("armor_category", "@obj_attr_n:armor_assault")
}
StaticItemLoader.ArmorItemInfo.ArmorType.BATTLE -> {
kineticMax = 6000
energyMax = 6000
obj.addAttribute("armor_category", "@obj_attr_n:armor_battle")
}
StaticItemLoader.ArmorItemInfo.ArmorType.RECON -> {
kineticMax = 5000
energyMax = 7000
obj.addAttribute("armor_category", "@obj_attr_n:armor_reconnaissance")
}
}
obj.addAttribute("cat_armor_standard_protection.kinetic", calculateProtection(kineticMax, info.protection))
obj.addAttribute("cat_armor_standard_protection.energy", calculateProtection(energyMax, info.protection))
obj.addAttribute("cat_armor_special_protection.special_protection_type_heat", calculateProtection(6000, info.protection))
obj.addAttribute("cat_armor_special_protection.special_protection_type_cold", calculateProtection(6000, info.protection))
obj.addAttribute("cat_armor_special_protection.special_protection_type_acid", calculateProtection(6000, info.protection))
obj.addAttribute("cat_armor_special_protection.special_protection_type_electricity", calculateProtection(6000, info.protection))
applySkillMods(obj, info.skillMods)
applyColors(obj, info.color)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.WearableItemInfo?) {
if (info == null)
return
if (info.requiredProfession.isNotEmpty())
obj.addAttribute("class_required", "@ui_roadmap:title_" + info.requiredProfession)
obj.addAttribute("required_combat_level", info.requiredLevel.toString())
if (info.requiredFaction.isNotEmpty())
obj.addAttribute("faction_restriction", "@pvp_factions:" + info.requiredFaction)
// Apply the mods!
applySkillMods(obj, info.skillMods)
applyColors(obj, info.color)
// Add the race restrictions only if there are any
val raceRestriction = buildRaceRestrictionString(info)
if (raceRestriction.isNotEmpty())
obj.addAttribute("species_restrictions.species_name", raceRestriction)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.WeaponItemInfo?) {
if (info == null)
return
if (info.requiredProfession.isNotEmpty())
obj.addAttribute("class_required", "@ui_roadmap:title_" + info.requiredProfession)
obj.addAttribute("required_combat_level", info.requiredLevel.toString())
obj.addAttribute("cat_wpn_damage.wpn_damage_type", "@obj_attr_n:${info.damageType.name.toLowerCase(Locale.US)}")
obj.addAttribute("cat_wpn_damage.wpn_category", "@obj_attr_n:wpn_category_" + info.weaponType.num)
obj.addAttribute("cat_wpn_damage.wpn_attack_speed", (info.attackSpeed / 100).toString())
obj.addAttribute("cat_wpn_damage.damage", "${info.minDamage}-${info.maxDamage}")
if (info.elementalType != null) { // Not all weapons have elemental damage.
obj.addAttribute("cat_wpn_damage.wpn_elemental_type", "@obj_attr_n:${info.elementalType.name.toLowerCase(Locale.US)}")
obj.addAttribute("cat_wpn_damage.wpn_elemental_value", info.elementalDamage.toString())
}
obj.addAttribute("cat_wpn_damage.weapon_dps", info.actualDps.toString())
if (info.procEffect.isNotEmpty())
// Not all weapons have a proc effect
obj.addAttribute("proc_name", info.procEffect)
// TODO set DPS
obj.addAttribute("cat_wpn_other.wpn_range", String.format("%d-%dm", info.minRange, info.maxRange))
// Ziggy: Special Action Cost would go under cat_wpn_other as well, but it's a pre-NGE artifact.
val weapon = obj as WeaponObject
weapon.type = info.weaponType
weapon.attackSpeed = info.attackSpeed.toFloat()
weapon.minRange = info.minRange.toFloat()
weapon.maxRange = info.maxRange.toFloat()
weapon.damageType = info.damageType
weapon.elementalType = info.elementalType
weapon.minDamage = info.minDamage
weapon.maxDamage = info.maxDamage
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.CollectionItemInfo?) {
if (info == null)
return
obj.addAttribute("collection_name", info.slotName)
applyColors(obj, info.color)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.CostumeItemInfo?) {
// if (info == null)
// return;
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.DnaItemInfo?) {
// if (info == null)
// return;
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.GrantItemInfo?) {
// if (info == null)
// return;
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.GenericItemInfo?) {
if (info == null)
return
if (info.value != 0)
obj.addAttribute("charges", info.value.toString())
applyColors(obj, info.color)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.ObjectItemInfo?) {
if (info == null)
return
if (info.value != 0)
obj.addAttribute("charges", info.value.toString())
applyColors(obj, info.color)
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.SchematicItemInfo?) {
// if (info == null)
// return;
}
private fun applyAttributes(obj: TangibleObject, info: StaticItemLoader.StorytellerItemInfo?) {
// if (info == null)
// return;
}
private fun applySkillMods(obj: TangibleObject, skillMods: Map<String, Int>) {
for ((key, value) in skillMods)
obj.addAttribute(key, value.toString())
}
private fun applyColors(obj: TangibleObject, colors: IntArray) {
for (i in 0..3) {
if (colors[i] >= 0) {
obj.putCustomization("/private/index_color_$i", colors[i])
}
}
}
private fun buildRaceRestrictionString(info: StaticItemLoader.WearableItemInfo): String {
var races = ""
if (info.isRaceWookie)
races += "Wookiee "
if (info.isRaceIthorian)
races += "Ithorian "
if (info.isRaceRodian)
races += "Rodian "
if (info.isRaceTrandoshan)
races += "Trandoshan "
if (info.isRaceRest)
races += "MonCal Human Zabrak Bothan Sullustan Twi'lek "
return if (races.isEmpty()) "" else races.substring(0, races.length - 1)
}
private fun calculateProtection(max: Int, protection: Double): String {
return floor(max * protection).toInt().toString()
}
interface ObjectCreationHandler {
val isIgnoreVolume: Boolean
fun success(createdObjects: List<SWGObject>)
open fun containerFull() {
fun containerFull() {
}
}