/************************************************************************************
* 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 intents.BuffIntent;
import intents.chat.ChatBroadcastIntent;
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.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.encodables.ProsePackage;
import resources.encodables.StringId;
import resources.objects.SWGObject;
import resources.objects.creature.CreatureObject;
import resources.objects.tangible.TangibleObject;
import resources.objects.weapon.WeaponObject;
import resources.server_info.Log;
import utilities.ThreadUtilities;
public class CombatManager extends Manager {
private static final byte INCAP_TIMER = 10; // Amount of seconds to be incapacitated
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;
// TODO upon first combat, cache skillmod-related calculations
// TODO upon receiving SkillModIntent, the relevant calculation(s) must be updated
// TODO remove calculations if they haven't been accessed for a while?
// TODO remove calculations if the creature disappears
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 intent) {
if (!intent.getCommand().isCombatCommand() || !(intent.getCommand() instanceof CombatCommand))
return;
CombatCommand c = (CombatCommand) intent.getCommand();
CreatureObject source = intent.getSource();
SWGObject target = intent.getTarget();
// Regardless of HitType, the command might have action cost
addActionCost(source, c);
// TODO implement support for remaining HitTypes
switch (c.getHitType()) {
case ATTACK: handleAttack(source, target, c); break;
case BUFF: handleBuff(source, target, c); break;
default: handleStatus(source, CombatStatus.UNKNOWN); break;
}
}
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 handleAttack(CreatureObject source, SWGObject target, CombatCommand command) {
if (!handleStatus(source, canPerform(source, target, command)))
return;
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);
action.setUseLocation(false);
for(int i = 0; i < command.getAttackRolls(); i++) {
int damage = 0;
damage += calculateWeaponDamage(source, command);
damage += command.getAddedDamage();
if (target instanceof CreatureObject) {
if (command.getAttackType() == AttackType.SINGLE_TARGET)
doCombatSingle(source, (CreatureObject) target, damage, command);
action.addDefender((CreatureObject) target, true, (byte) 0, HitLocation.HIT_LOCATION_BODY, (short) damage);
}
}
source.sendObserversAndSelf(action);
}
private void handleBuff(CreatureObject source, SWGObject target, CombatCommand combatCommand) {
addBuff(source, source, combatCommand.getBuffNameSelf());
// Only CreatureObjects have buffs
if(target instanceof CreatureObject)
addBuff(source, (CreatureObject) target, combatCommand.getBuffNameTarget());
}
private void doCombatSingle(CreatureObject source, CreatureObject target, int damage, CombatCommand command) {
updateCombatList(source);
if (target instanceof CreatureObject)
updateCombatList((CreatureObject) target);
if (!source.isInCombat())
enterCombat(source);
if (!target.isInCombat())
enterCombat(target);
target.addDefender(source);
source.addDefender(target);
addBuff(source, target, command.getBuffNameTarget()); // Add target buff
if (target.getHealth() <= damage)
doCreatureDeath(target, source);
else
target.modifyHealth(-damage);
}
private void enterCombat(CreatureObject creature) {
creature.setInCombat(true);
// If this creature is currently regenerating health, they should stop doing so now
synchronized(regeneratingHealthCreatures) {
regeneratingHealthCreatures.remove(creature);
}
}
private void exitCombat(CreatureObject creature) {
creature.setInCombat(false);
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 corpse, CreatureObject killer) {
corpse.setHealth(0);
killer.removeDefender(corpse);
if(!killer.hasDefenders()) {
exitCombat(killer);
}
// The creature should not be able to move or turn.
corpse.setTurnScale(0);
corpse.setMovementScale(0);
if(corpse.isPlayer()) {
if (corpse.hasBuff("incapWeaken")) {
killCreature(killer, corpse);
} else {
incapacitatePlayer(killer, corpse);
}
} else {
killCreature(killer, corpse);
}
exitCombat(corpse);
}
private void incapacitatePlayer(CreatureObject incapacitator, CreatureObject incapacitated) {
incapacitated.setPosture(Posture.INCAPACITATED);
incapacitated.setCounter(INCAP_TIMER);
Log.i(this, "%s was incapacitated", incapacitated);
// Once the incapacitation counter expires, revive them.
synchronized(incapacitatedCreatures) {
incapacitatedCreatures.put(incapacitated, executor.schedule(() -> expireIncapacitation(incapacitated), INCAP_TIMER, TimeUnit.SECONDS));
}
new BuffIntent("incapWeaken", incapacitator, incapacitated, false).broadcast();
new ChatBroadcastIntent(incapacitator.getOwner(), new ProsePackage(new StringId("base_player", "prose_target_incap"), "TT", incapacitated.getName())).broadcast();
new ChatBroadcastIntent(incapacitated.getOwner(), new ProsePackage(new StringId("base_player", "prose_victim_incap"), "TT", incapacitator.getName())).broadcast();
}
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 by %s", corpse, killer);
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);
}
} 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;
case INVALID_TARGET:
showFlyText(source, "@combat_effects:target_invalid_fly", Scale.MEDIUM, Color.CYAN, ShowFlyText.Flag.PRIVATE);
return false;
default:
showFlyText(source, "@combat_effects:action_failed", Scale.MEDIUM, Color.WHITE, ShowFlyText.Flag.PRIVATE);
return false;
}
}
private CombatStatus canPerform(CreatureObject source, SWGObject target, CombatCommand c) {
if (source.getEquippedWeapon() == null)
return CombatStatus.NO_WEAPON;
if (target == null || source.equals(target))
return CombatStatus.SUCCESS;
if (!(target instanceof TangibleObject))
return CombatStatus.INVALID_TARGET;
if (!source.isEnemy((TangibleObject) target)) {
return CombatStatus.INVALID_TARGET;
}
if(target instanceof CreatureObject) {
switch(((CreatureObject) target).getPosture()) {
case DEAD:
case INCAPACITATED:
return CombatStatus.INVALID_TARGET;
default:
break;
}
}
switch (c.getAttackType()) {
case AREA:
case TARGET_AREA:
return canPerformArea(source, c);
case SINGLE_TARGET:
return canPerformSingle(source, target, c);
default:
return CombatStatus.UNKNOWN;
}
}
private CombatStatus canPerformSingle(CreatureObject source, SWGObject target, CombatCommand c) {
if (target == null || !(target instanceof TangibleObject))
return CombatStatus.NO_TARGET;
WeaponObject weapon = source.getEquippedWeapon();
double dist = source.getLocation().distanceTo(target.getWorldLocation());
float commandRange = c.getMaxRange();
float range = commandRange > 0 ? commandRange : weapon.getMaxRange();
if (dist > range)
return CombatStatus.TOO_FAR;
return CombatStatus.SUCCESS;
}
private CombatStatus canPerformArea(CreatureObject source, CombatCommand c) {
// TODO implement AoE
return CombatStatus.UNKNOWN;
}
private void addActionCost(CreatureObject source, CombatCommand command) {
double actionCost = command.getActionCost();
int currentAction = source.getAction();
if (actionCost <= 0 || actionCost > currentAction) {
return;
}
synchronized (regeneratingActionCreatures) {
source.setAction((int) (currentAction - actionCost));
regeneratingActionCreatures.add(source);
}
}
private int calculateWeaponDamage(CreatureObject source, CombatCommand command) {
WeaponObject weapon = source.getEquippedWeapon();
int minDamage = weapon.getMinDamage();
int weaponDamage = random.nextInt((weapon.getMaxDamage() - minDamage) + 1) + minDamage;
return (int) (weaponDamage * command.getPercentAddFromWeapon());
}
private void addBuff(CreatureObject caster, CreatureObject receiver, String buffName) {
if (buffName.isEmpty()) {
return;
}
new BuffIntent(buffName, caster, receiver, false).broadcast();
}
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;
}
}
}