/************************************************************************************ * Copyright (c) 2015 /// 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 services.combat; import java.awt.Color; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import network.packets.swg.zone.object_controller.ShowFlyText; import network.packets.swg.zone.object_controller.ShowFlyText.Scale; import network.packets.swg.zone.object_controller.combat.CombatAction; import intents.chat.ChatCommandIntent; import intents.combat.CreatureKilledIntent; import intents.combat.DeathblowIntent; import java.util.Iterator; import java.util.Random; import java.util.concurrent.Future; import resources.Posture; import resources.PvpFaction; import resources.PvpFlag; import resources.combat.AttackInfoLight; import resources.combat.AttackType; import resources.combat.CombatStatus; import resources.combat.HitLocation; import resources.combat.TrailLocation; import resources.commands.CombatCommand; import resources.common.CRC; import resources.common.RGB; import resources.control.Intent; import resources.control.Manager; import resources.objects.SWGObject; import resources.objects.creature.CreatureObject; import resources.objects.creature.CreatureState; import resources.objects.tangible.TangibleObject; import resources.objects.weapon.WeaponObject; import resources.server_info.Log; import utilities.Scripts; import utilities.ThreadUtilities; public class CombatManager extends Manager { private final Map inCombat; private final Set regeneratingHealthCreatures; // Only allowed outside of combat private final Set regeneratingActionCreatures; // Always allowed private final Map> incapacitatedCreatures; private final Random random; private final CorpseService corpseService; private final CombatXpService combatXpService; private ScheduledExecutorService executor; public CombatManager() { registerForIntent(DeathblowIntent.TYPE); inCombat = new HashMap<>(); regeneratingHealthCreatures = new HashSet<>(); regeneratingActionCreatures = new HashSet<>(); incapacitatedCreatures = new HashMap<>(); random = new Random(); corpseService = new CorpseService(); combatXpService = new CombatXpService(); addChildService(corpseService); addChildService(combatXpService); } @Override public boolean initialize() { registerForIntent(ChatCommandIntent.TYPE); executor = Executors.newSingleThreadScheduledExecutor(ThreadUtilities.newThreadFactory("combat-service")); return super.initialize(); } @Override public boolean start() { executor.scheduleAtFixedRate(() -> periodicChecks(), 0, 5, TimeUnit.SECONDS); executor.scheduleAtFixedRate(() -> periodicRegeneration(), 1, 1, TimeUnit.SECONDS); return super.start(); } @Override public boolean terminate() { if (executor != null) { executor.shutdownNow(); try { executor.awaitTermination(3, TimeUnit.SECONDS); } catch (InterruptedException e) { } } return super.terminate(); } @Override public void onIntentReceived(Intent i) { switch(i.getType()) { case ChatCommandIntent.TYPE: processChatCommand((ChatCommandIntent) i); break; case DeathblowIntent.TYPE: procesDeathblow((DeathblowIntent) i); break; } } private void periodicChecks() { Set removeSet = new HashSet<>(); synchronized (inCombat) { for (CombatCreature combat : inCombat.values()) { if (combat.getTimeSinceCombat() >= 10E3) { // 10 sec removeSet.add(combat); } } } for (CombatCreature combat : removeSet) removeFromCombat(combat); } private void removeFromCombat(CombatCreature combat) { synchronized (inCombat) { inCombat.remove(combat.getCreature().getObjectId()); } exitCombat(combat.getCreature()); } private void periodicRegeneration() { synchronized (regeneratingActionCreatures) { Iterator iterator = regeneratingActionCreatures.iterator(); while (iterator.hasNext()) { regenerationActionTick(iterator.next(), iterator); } } synchronized (regeneratingHealthCreatures) { Iterator iterator = regeneratingHealthCreatures.iterator(); while (iterator.hasNext()) { regenerationHealthTick(iterator.next(), iterator); } } } private void regenerationActionTick(CreatureObject creatureObject, Iterator iterator) { if(creatureObject.getAction() < creatureObject.getMaxAction()) { int modification = 13; int level = creatureObject.getLevel(); if(level > 1) { modification += 4 * level; } if(creatureObject.modifyAction(modification) == 0) { // Their action didn't change, meaning they're maxed out iterator.remove(); } } else { // Maxed out - remove 'em iterator.remove(); } } private void regenerationHealthTick(CreatureObject creatureObject, Iterator iterator) { if(creatureObject.getHealth() < creatureObject.getMaxHealth()) { int modification = 40; int level = creatureObject.getLevel(); if(level > 1) { modification += 4 * level; } if(creatureObject.modifyHealth(modification) == 0) { // Their health didn't change, meaning they're maxed out iterator.remove(); } } else { // Maxed out - remove 'em iterator.remove(); } } private void processChatCommand(ChatCommandIntent cci) { if (!cci.getCommand().isCombatCommand() || !(cci.getCommand() instanceof CombatCommand)) return; CombatCommand c = (CombatCommand) cci.getCommand(); CombatStatus status = canPerform(cci.getSource(), cci.getTarget(), c); if (!handleStatus(cci.getSource(), status)) return; Object res = Scripts.invoke("commands/combat/"+c.getName(), "doCombat", cci.getSource(), cci.getTarget(), c); if (res == null) { handleStatus(cci.getSource(), CombatStatus.UNKNOWN); return; } updateCombatList(cci.getSource()); if (cci.getTarget() instanceof CreatureObject) updateCombatList((CreatureObject) cci.getTarget()); if (res instanceof Number) doCombat(cci.getSource(), cci.getTarget(), new AttackInfoLight(((Number) res).intValue()), c); else if (res instanceof AttackInfoLight) doCombat(cci.getSource(), cci.getTarget(), (AttackInfoLight) res, c); else { Log.w(this, "Unknown return from combat script: " + res); return; } } private void updateCombatList(CreatureObject creature) { CombatCreature combat; synchronized (inCombat) { combat = inCombat.get(creature.getObjectId()); } if (combat == null) { combat = new CombatCreature(creature); synchronized (inCombat) { inCombat.put(creature.getObjectId(), combat); } } combat.updateLastCombat(); } private void doCombat(CreatureObject source, SWGObject target, AttackInfoLight info, CombatCommand command) { CombatAction action = new CombatAction(source.getObjectId()); String anim = command.getRandomAnimation(source.getEquippedWeapon().getType()); action.setActionCrc(CRC.getCrc(anim)); action.setAttacker(source); action.setClientEffectId((byte) 0); action.setCommandCrc(command.getCrc()); action.setTrail(TrailLocation.WEAPON); if (target instanceof CreatureObject) { if (command.getAttackType() == AttackType.SINGLE_TARGET) doCombatSingle(source, (CreatureObject) target, info, command); action.addDefender((CreatureObject) target, true, (byte) 0, HitLocation.HIT_LOCATION_BODY, (short) info.getDamage()); } source.sendObserversAndSelf(action); } private void doCombatSingle(CreatureObject source, CreatureObject target, AttackInfoLight info, CombatCommand command) { if (!source.hasState(CreatureState.COMBAT)) enterCombat(source); if (!target.hasState(CreatureState.COMBAT)) enterCombat(target); target.addDefender(source); source.addDefender(target); addWeaponDamage(source, command, info); if (target.getHealth() <= info.getDamage()) doCreatureDeath(target, source); else target.modifyHealth(-info.getDamage()); } private void enterCombat(CreatureObject creature) { creature.setStatesBitmask(CreatureState.COMBAT); // If this creature is currently regenerating health, they should stop doing so now synchronized(regeneratingHealthCreatures) { regeneratingHealthCreatures.remove(creature); } } private void exitCombat(CreatureObject creature) { creature.clearStatesBitmask(CreatureState.COMBAT); creature.clearDefenders(); // Once out of combat, we can regenerate health - unless we're dead or incapacitated! switch(creature.getPosture()) { case DEAD: case INCAPACITATED: // We can't regenerate HAM if we're incapcitated or dead synchronized (regeneratingActionCreatures) { regeneratingActionCreatures.remove(creature); } synchronized (regeneratingHealthCreatures) { regeneratingHealthCreatures.remove(creature); } break; default: synchronized (regeneratingActionCreatures) { regeneratingActionCreatures.add(creature); } synchronized (regeneratingHealthCreatures) { regeneratingHealthCreatures.add(creature); } break; } } private void doCreatureDeath(CreatureObject killedCreature, CreatureObject killer) { killedCreature.setHealth(0); killer.removeDefender(killedCreature); // Let's check if the killer needs to remain in-combat... if(!killer.hasDefenders()) { // They have no active targets they're in combat with, make them exit combat exitCombat(killer); } // The creature should not be able to move or turn. killedCreature.setTurnScale(0); killedCreature.setMovementScale(0); // We need to handle this differently, depending on whether killedCreature is a player or not if(killedCreature.isPlayer()) { // TODO account for AI deathblowing players..? // If it's a player, they need to be incapacitated incapacitatePlayer(killedCreature); } else { // This is just a plain ol' NPC. Die! killCreature(killer, killedCreature); } exitCombat(killedCreature); } private void incapacitatePlayer(CreatureObject incapacitatedPlayer) { int incapacitationCounter = 15; incapacitatedPlayer.setPosture(Posture.INCAPACITATED); incapacitatedPlayer.setCounter(incapacitationCounter); Log.i(this, "%s was incapacitated", incapacitatedPlayer); // Once the incapacitation counter expires, revive them. synchronized(incapacitatedCreatures) { incapacitatedCreatures.put(incapacitatedPlayer, executor.schedule(() -> expireIncapacitation(incapacitatedPlayer), incapacitationCounter, TimeUnit.SECONDS)); } } private void expireIncapacitation(CreatureObject incapacitatedPlayer) { synchronized(incapacitatedCreatures) { incapacitatedCreatures.remove(incapacitatedPlayer); reviveCreature(incapacitatedPlayer); } } private void reviveCreature(CreatureObject revivedCreature) { if(revivedCreature.isPlayer()) revivedCreature.setCounter(0); revivedCreature.setPosture(Posture.UPRIGHT); // The creature is now able to turn around and move revivedCreature.setTurnScale(1); revivedCreature.setMovementScale(1); // Give 'em a percentage of their health and schedule them for HAM regeneration. revivedCreature.setHealth((int) (revivedCreature.getBaseHealth() * 0.1)); // Restores 10% health of their base health synchronized(regeneratingHealthCreatures) { regeneratingHealthCreatures.add(revivedCreature); } synchronized(regeneratingActionCreatures) { regeneratingActionCreatures.add(revivedCreature); } Log.i(this, "% was revived", revivedCreature); } private void killCreature(CreatureObject killer, CreatureObject corpse) { corpse.setPosture(Posture.DEAD); Log.i(this, "%s was killed", corpse); new CreatureKilledIntent(killer, corpse).broadcast(); } private void procesDeathblow(DeathblowIntent i) { CreatureObject killer = i.getKiller(); CreatureObject corpse = i.getCorpse(); // Only deathblowing players is allowed! if (!corpse.isPlayer()) { return; } // They must be enemies if (!corpse.isEnemy(killer)) { return; } // The target of the deathblow must be incapacitated! if (corpse.getPosture() != Posture.INCAPACITATED) { return; } // If they're deathblown while incapacitated, their incapacitation expiration timer should cancel synchronized (incapacitatedCreatures) { Future incapacitationTimer = incapacitatedCreatures.remove(corpse); if (incapacitationTimer != null) { if (incapacitationTimer.cancel(false)) { // If the task is running, let them get back up killCreature(killer, corpse); Log.i(this, "%s was deathblown by %s", corpse, killer); } } else { // Can't happen with the current code, but in case it's ever refactored... Log.e(this, "Incapacitation timer for player %s being deathblown unexpectedly didn't exist!", ""); } } } private boolean handleStatus(CreatureObject source, CombatStatus status) { switch (status) { case SUCCESS: return true; case NO_TARGET: showFlyText(source, "@combat_effects:target_invalid_fly", Scale.MEDIUM, Color.WHITE, ShowFlyText.Flag.PRIVATE); return false; case TOO_FAR: showFlyText(source, "@combat_effects:range_too_far", Scale.MEDIUM, Color.CYAN, ShowFlyText.Flag.PRIVATE); return false; default: showFlyText(source, "@combat_effects:cant_attack_fly", Scale.MEDIUM, Color.WHITE, ShowFlyText.Flag.PRIVATE); Log.e(this, "Character unable to attack. Player: %s Reason: %s", source.getName(), status); return false; } } private CombatStatus canPerform(CreatureObject source, SWGObject target, CombatCommand c) { if (source.getEquippedWeapon() == null) return CombatStatus.NO_WEAPON; if (!(target instanceof TangibleObject)) return CombatStatus.INVALID_TARGET; TangibleObject tangibleTarget = (TangibleObject) target; if(tangibleTarget.getPvpFaction() != PvpFaction.NEUTRAL) { if(!tangibleTarget.isEnemy(source)) { return CombatStatus.INVALID_TARGET; } } else if ((tangibleTarget.getPvpFlags() & PvpFlag.ATTACKABLE.getBitmask()) == 0) return CombatStatus.INVALID_TARGET; if(target instanceof CreatureObject) { CreatureObject creature = (CreatureObject) target; switch(creature.getPosture()) { case DEAD: case INCAPACITATED: return CombatStatus.INVALID_TARGET; } } CombatStatus status; switch (c.getAttackType()) { case AREA: case TARGET_AREA: status = canPerformArea(source, c); break; case SINGLE_TARGET: status = canPerformSingle(source, target, c); break; default: status = CombatStatus.UNKNOWN; break; } if (status != CombatStatus.SUCCESS) return status; status = Scripts.invoke("commands/combat/"+c.getName(), "canPerform", source, target, c); if (status == null) { return CombatStatus.UNKNOWN; } return status; } private CombatStatus canPerformSingle(CreatureObject source, SWGObject target, CombatCommand c) { if (target == null || !(target instanceof CreatureObject)) return CombatStatus.NO_TARGET; WeaponObject weapon = source.getEquippedWeapon(); double dist = source.getLocation().distanceTo(target.getLocation()); if (dist > weapon.getMaxRange() || (dist > c.getMaxRange() && c.getMaxRange() > 0)) return CombatStatus.TOO_FAR; return CombatStatus.SUCCESS; } private CombatStatus canPerformArea(CreatureObject source, CombatCommand c) { return CombatStatus.SUCCESS; } private void addWeaponDamage(CreatureObject source, CombatCommand command, AttackInfoLight info) { int abilityDamage = info.getDamage(); WeaponObject weapon = source.getEquippedWeapon(); int minDamage = weapon.getMinDamage(); int weaponDamage = random.nextInt((weapon.getMaxDamage() - minDamage) + 1) + minDamage; weaponDamage *= command.getPercentAddFromWeapon(); info.setDamage(abilityDamage + weaponDamage); } private void showFlyText(TangibleObject obj, String text, Scale scale, Color c, ShowFlyText.Flag ... flags) { obj.sendSelf(new ShowFlyText(obj.getObjectId(), text, scale, new RGB(c), flags)); } private static class CombatCreature { private final CreatureObject creature; private long lastCombat; public CombatCreature(CreatureObject creature) { this.creature = creature; } public void updateLastCombat() { lastCombat = System.nanoTime(); } public double getTimeSinceCombat() { return (System.nanoTime() - lastCombat) / 1E6; } public CreatureObject getCreature() { return creature; } } }