com.spankingrpgs.model.characters.GameCharacter.java Source code

Java tutorial

Introduction

Here is the source code for com.spankingrpgs.model.characters.GameCharacter.java

Source

/*
 * CrimsonGlow is an adult computer roleplaying game with spanking content.
 * Copyright (C) 2015 Andrew Russell
 *
 *      This program is free software: you can redistribute it and/or modify
 *      it under the terms of the GNU General Public License as published by
 *      the Free Software Foundation, either version 3 of the License, or
 *      (at your option) any later version.
 *
 *      This program 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 General Public License for more details.
 *
 *      You should have received a copy of the GNU General Public License
 *      along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package com.spankingrpgs.model.characters;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.spankingrpgs.model.GameState;
import com.spankingrpgs.model.combat.CombatRange;
import com.spankingrpgs.model.skills.Skill;
import com.spankingrpgs.model.items.Equipment;
import com.spankingrpgs.model.story.EventDescription;
import com.spankingrpgs.util.CollectionUtils;

import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Models the characters that appear in the game. There is only one thing that implementers need
 * to implement: A method for syncing up primary statistics with secondary statistics that
 * gets applied whenever a primary statistic is incremented or decremented.
 */
public abstract class GameCharacter {

    private final static Logger LOG = Logger.getLogger(GameCharacter.class.getCanonicalName());

    /**
     * The character's name. This should be a unique identifier for the character, as it is the only value used to
     * test equality between characters.
     */
    private String name;
    /**
     * The names printed to the screen for the player to see. This does not need to be unique. There should be
     * one name for each gender, if the player is allowed to select the character's gender.
     */
    private Map<Gender, String> printedNames;
    /**
     * The name to display while in battle.
     */
    private String battleName;
    private Map<String, AppearanceElement> appearance;
    private String description;
    private Gender gender;

    /**
     * The character's primary statistics. Primary statistics are those whose values are only affected by equipment.
     * Other statistics have no impact on them.
     */
    private LinkedHashMap<String, Integer> primaryStatistics;

    /**
     * The character's secondary statistics. Secondary statistics are affected partially or completely by the value
     * of primary statistics.
     */
    private LinkedHashMap<String, Integer> secondaryStatistics;
    /**
     * The equippable slots of the character. Guaranteed a consistent iteration order based on the order slots appear
     * in the map.
     */
    private LinkedHashMap<String, EquipSlot> equipSlots;

    /**
     * A mapping from a status that the character is inflicted with, to the number of rounds remaining before the
     * status fades on its own
     */
    private Map<Status, Integer> statuses;

    /**
     * Character-specific in-combat spanking text. The key is some situation, and the value is the resulting text
     * (for example, the key may be the name of a position, or the concatentation of a position and the string
     * "continuation" to key into the text for the second round of a spanking)
     */
    private Map<String, EventDescription> spankingEvents;

    /**
     * A mapping from the names of the skills the character knows to their levels.
     */
    private LinkedHashMap<String, Integer> skills;

    /**
     * The list of ranges this character can attack at.
     */
    private List<CombatRange> attackRanges;

    private List<String> bumStatus;

    private SpankingRole role;

    /**
     * Constructor.
     *
     * @param name  The name of the character
     * @param printedNames  The name that is actually displayed to the player
     * @param battleName  The battle name of the character
     * @param description  A description of the character
     * @param gender  The gender of the character
     * @param attackRanges  The ranges at which the character can attack
     * @param primaryStatistics  The primary statistics of the character
     * @param secondaryStatistics  The starting values of the secondary statistics of the character
     * @param appearance  The character's appearance
     * @param equipSlots  The character's equipSlots
     * @param skills  The character's skills
     * @param spankingEvents  The in-combat spanking events for this character
     * @param role  The role this character typically takes in a spanking
     */
    public GameCharacter(String name, Map<Gender, String> printedNames, String battleName, String description,
            Gender gender, List<CombatRange> attackRanges, LinkedHashMap<String, Integer> primaryStatistics,
            LinkedHashMap<String, Integer> secondaryStatistics, Map<String, AppearanceElement> appearance,
            LinkedHashMap<String, EquipSlot> equipSlots, Map<Skill, Integer> skills,
            Map<String, EventDescription> spankingEvents, SpankingRole role) {
        this.name = name;
        this.printedNames = printedNames;
        this.battleName = battleName;
        this.description = description;
        this.gender = gender;
        this.attackRanges = attackRanges;
        this.primaryStatistics = primaryStatistics;
        this.secondaryStatistics = secondaryStatistics;
        this.appearance = appearance;
        this.equipSlots = equipSlots;
        this.statuses = new LinkedHashMap<>();
        this.spankingEvents = spankingEvents;
        this.bumStatus = new LinkedList<>();
        this.skills = new LinkedHashMap<>();
        for (Map.Entry<Skill, Integer> skillEntry : skills.entrySet()) {
            this.skills.put(skillEntry.getKey().getName(), skillEntry.getValue());
        }
        this.role = role;
        LOG.log(Level.INFO,
                String.format(
                        "GameCharacter %s constructed: printedName: %s, statistics: %s, "
                                + "appearance: %s, equipSlots: %s, statuses: %s, spankingRole: %s",
                        this.name, this.printedNames, this.primaryStatistics, this.appearance, this.equipSlots,
                        this.statuses, this.role));
    }

    /**
     * Constructor. Skills default to an empty map
     *
     * @param name  The name of the character
     * @param printedNames  The name that is actually displayed to the player
     * @param battleName  The battle name of the character
     * @param description  A description of the character
     * @param gender  The gender of the character
     * @param attackRanges  The ranges at which the character can attack
     * @param primaryStatistics  The primary statistics of the character
     * @param secondaryStatistics  The starting values of the secondary statistics of the character
     * @param appearance  The character's appearance
     * @param equipSlots  The character's equipSlots
     * @param spankingEvents  The in-combat spanking events for this character
     * @param role  The role this charactr takes in a spanking
     */
    public GameCharacter(String name, Map<Gender, String> printedNames, String battleName, String description,
            Gender gender, List<CombatRange> attackRanges, LinkedHashMap<String, Integer> primaryStatistics,
            LinkedHashMap<String, Integer> secondaryStatistics, Map<String, AppearanceElement> appearance,
            LinkedHashMap<String, EquipSlot> equipSlots, Map<String, EventDescription> spankingEvents,
            SpankingRole role) {
        this(name, printedNames, battleName, description, gender, attackRanges, primaryStatistics,
                secondaryStatistics, appearance, equipSlots, new LinkedHashMap<>(), spankingEvents, role);
    }

    /**
     * This constructor will compute the values of the secondary statistics using the modifySecondaryStatistics
     * method.
     * <p>
     * This constructor is best used when initializing a plain character, without any of their own special bonuses.
     *  @param name  The name of the character
     * @param printedNames  The name to be displayed to the player
     * @param battleName  The battle name of the character
     * @param description  The description of the character
     * @param gender  The gender of the character
     * @param attackRanges  The ranges at which the character can attack
     * @param primaryStatistics  The character's primary statistics
     * @param secondaryStatisticNames  The names of the secondary statistics to be computed
     * @param appearance  The character's appearance
     * @param equipSlots  The slots into which the character can put equipment
     * @param spankingEvents  This character's in-combat spanking text
     * @param role  The role in a spanking that this character typically takes
     */
    public GameCharacter(String name, Map<Gender, String> printedNames, String battleName, String description,
            Gender gender, List<CombatRange> attackRanges, LinkedHashMap<String, Integer> primaryStatistics,
            List<String> secondaryStatisticNames, Map<String, AppearanceElement> appearance,
            LinkedHashMap<String, EquipSlot> equipSlots, Map<String, EventDescription> spankingEvents,
            SpankingRole role) {
        this(name, printedNames, battleName, description, gender, attackRanges, primaryStatistics,
                secondaryStatisticNames, appearance, equipSlots, new LinkedHashMap<>(), spankingEvents, role);
    }

    /**
     * This constructor will compute the values of the secondary statistics using the modifySecondaryStatistics
     * method.
     * <p>
     * This constructor is best used when initializing a plain character, without any of their own special bonuses.
     *
     *  @param name  The name of the character
     * @param printedNames  The name to be displayed to the player
     * @param battleName  The battle name of the character
     * @param description  The description of the character
     * @param gender  The gender of the character
     * @param attackRanges  The ranges at which the character can attack
     * @param primaryStatistics  The character's primary statistics
     * @param secondaryStatisticNames  The names of the secondary statistics to be computed
     * @param appearance  The character's appearance
     * @param equipSlots  The slots into which the character can put equipment
     * @param skills  The character's skills
     * @param spankingEvents  This character's in-combat spanking text
     * @param role  The role this character tends to take in spankings
     */
    public GameCharacter(String name, Map<Gender, String> printedNames, String battleName, String description,
            Gender gender, List<CombatRange> attackRanges, LinkedHashMap<String, Integer> primaryStatistics,
            List<String> secondaryStatisticNames, Map<String, AppearanceElement> appearance,
            LinkedHashMap<String, EquipSlot> equipSlots, Map<Skill, Integer> skills,
            Map<String, EventDescription> spankingEvents, SpankingRole role) {
        this(name, printedNames, battleName, description, gender, attackRanges, primaryStatistics,
                CollectionUtils.buildMap(secondaryStatisticNames.stream(), () -> 0), appearance, equipSlots, skills,
                spankingEvents, role);
        primaryStatistics.keySet().forEach(this::modifySecondaryStatistics);
    }

    /**
     * Performs a deep copy of the specified character.
     *
     * @param gameCharacter  The character to be copied
     */
    public GameCharacter(GameCharacter gameCharacter) {
        this.name = gameCharacter.name;
        this.printedNames = gameCharacter.printedNames.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
        this.battleName = gameCharacter.battleName;
        this.description = gameCharacter.description;
        this.gender = gameCharacter.gender;
        this.attackRanges = gameCharacter.attackRanges.stream().map(CombatRange::copy).collect(Collectors.toList());
        this.primaryStatistics = new LinkedHashMap<>(gameCharacter.primaryStatistics.size());
        this.primaryStatistics.putAll(gameCharacter.primaryStatistics);
        this.secondaryStatistics = new LinkedHashMap<>(gameCharacter.secondaryStatistics.size());
        this.secondaryStatistics.putAll(gameCharacter.secondaryStatistics);
        this.equipSlots = new LinkedHashMap<>(gameCharacter.equipSlots.size());
        this.equipSlots.putAll(gameCharacter.equipSlots.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, entry -> new EquipSlot(entry.getValue()))));
        this.skills = gameCharacter.skills.entrySet().stream()
                .collect(CollectionUtils.toLinkedHashMap(Map.Entry::getKey, Map.Entry::getValue));
        //Since AppearanceElements are immutable, a shallow copy is sufficient.
        this.appearance = gameCharacter.appearance.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
        //Since statuses are immutable, a shallow copy is sufficient
        this.statuses = gameCharacter.statuses.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
        this.spankingEvents = gameCharacter.spankingEvents.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
        this.bumStatus = gameCharacter.bumStatus.stream().collect(Collectors.toList());
        this.role = gameCharacter.getRole();
    }

    /**
     * Returns a deep copy of this GameCharacter
     *
     * @return  A deep copy of this GameCharacter
     */
    public abstract GameCharacter copy();

    /**
     * Given a JSON Object containing character state, replaces this character's state with the state stored in the
     * JSON object.
     *
     * @param characterObject  The JSON Object containing the state to load
     */
    public abstract void load(JsonNode characterObject);

    /**
     * Syncs up the values of the secondary statistics, with the new value of the primary statistics. This method
     * will recompute the values of all secondary statistics affected by the modified primary statistic.
     *
     * @param primaryStatisticName  The name of the primary statistic that has been modified
     */
    protected abstract void modifySecondaryStatistics(String primaryStatisticName);

    /**
     * Get the value of the specified statistic.
     *
     * @param statisticName  The name of the statistic to get the value of
     *
     * @return The value of the statistic
     *
     * @throws IllegalArgumentException if this character does not have a statistic with name {@code statisticName}.
     */
    @JsonIgnore
    public int getStatistic(String statisticName) {
        Integer primaryStatistic = primaryStatistics.get(statisticName);
        if (primaryStatistic != null) {
            return primaryStatistic;
        }
        return CollectionUtils.getValue(secondaryStatistics, statisticName);
    }

    /**
     * Returns the value of all the statistics in the order they were added to the character as an unmodifiable
     * map.
     *
     * @return the value of all the statistics in the order they were added to the character.
     */
    @JsonIgnore
    public Map<String, Integer> getAllStatistics() {
        LinkedHashMap<String, Integer> allStatistics = new LinkedHashMap<>(
                primaryStatistics.size() + secondaryStatistics.size());
        Stream.concat(primaryStatistics.entrySet().stream(), secondaryStatistics.entrySet().stream())
                .forEach(entry -> allStatistics.put(entry.getKey(), entry.getValue()));

        return Collections.unmodifiableMap(allStatistics);
    }

    /**
     * Returns an unmodifiable map describing the character's primary statistics.
     *
     * @return  An unmodifiable map describing the character's primary statistics
     */
    @JsonProperty("primaryStatistics")
    public Map<String, Integer> getAllPrimaryStatistics() {
        return Collections.unmodifiableMap(primaryStatistics);
    }

    @JsonProperty("secondaryStatistics")
    public abstract Map<String, Integer> getSerializedSecondaryStatistics();

    @JsonIgnore
    public Map<String, Integer> getAllSecondaryStatistics() {
        return Collections.unmodifiableMap(secondaryStatistics);
    }

    /**
     * Increments the specified statistic by the specified amount.
     * This method will not allow the specified statistic to fall below 0 if it is not already below 0.
     *
     * @param statisticName  The name of the statistic to increment
     * @param amount  The amount to increment the statistic by.
     * @throws IllegalArgumentException if this character does not have a statistic with name {@code statisticName}.
     */
    public void incrementStatistic(String statisticName, int amount) {
        incrementStatistic(statisticName, amount, 0);
    }

    /**
     * Increments the specified statistic by the specified amount.
     * This method will not allow the specified statistic to fall below {@code min} if it is not already below it.
     *
     * @param statisticName  The name of the statistic to increment
     * @param amount  The amount to increment the statistic by.
     * @param min  The minimum value allowed for this statistic
     * @throws IllegalArgumentException if this character does not have a statistic with name {@code statisticName}.
     */
    public void incrementStatistic(String statisticName, int amount, int min) {
        int currentValue = getStatistic(statisticName);
        int flooredAmount = currentValue >= min && currentValue + amount < min ? 0 - currentValue : amount;
        setStatistic(statisticName, getStatistic(statisticName) + flooredAmount);
    }

    /**
     * Decrements the specified statistic by the specified amount.
     * Does not allow the statistic to fall below 0 if the value is not already below 0.
     * <p>
     * Note that after this method is invoked, the decremented statistic may be negative.
     *
     * @param statisticName  The name of the statistic to decrement
     * @param amount  The amount to decrement the statistic by.
     *
     * @throws IllegalArgumentException if this character does not have a statistic with name {@code statisticName}.
     */
    public void decrementStatistic(String statisticName, int amount) {
        decrementStatistic(statisticName, amount, 0);
    }

    /**
     * Decrements the specified statistic by the specified amount.
     * Does not allow the statistic to fall below {@code min} if the value is not already below {@code min}.
     * <p>
     * Note that after this method is invoked, the decremented statistic may be negative.
     *
     * @param statisticName  The name of the statistic to decrement
     * @param amount  The amount to decrement the statistic by.
     * @param min  The minimum value this statistic is allowed to reach
     *
     * @throws IllegalArgumentException if this character does not have a statistic with name {@code statisticName}.
     */
    public void decrementStatistic(String statisticName, int amount, int min) {
        incrementStatistic(statisticName, 0 - amount, min);
    }

    /**
     * Given the name of a statistic, primary or secondary, sets the value of that statistic to the specified amount.
     * If the modified statistic is primary, then this method will also invoke
     * {@link GameCharacter#modifySecondaryStatistics(String)} on the name of the modified statistic.
     *
     * @param statisticName  The name of the statistic to be set
     * @param value  The new value of the statistic
     *
     * @throws IllegalArgumentException if this character does not have a statistic with name {@code statisticName}.
     */
    public void setStatistic(String statisticName, int value) {
        if (primaryStatistics.containsKey(statisticName)) {
            primaryStatistics.put(statisticName, value);
            modifySecondaryStatistics(statisticName);
        } else if (secondaryStatistics.containsKey(statisticName)) {
            secondaryStatistics.put(statisticName, value);
        } else {
            String msg = String.format("%s is not a valid key for %s", statisticName, getAllStatistics());
            LOG.log(Level.SEVERE, msg);
            throw new IllegalArgumentException(msg);
        }
    }

    /**
     * Equips an item.
     *
     * Also unequips anything that occupies at least one of the same slots as the item to be equipped.
     *
     * @param item  The item to be equipped.
     *
     * @return The items unequipped as a side-effect of equipping {@code item}.
     */
    public Set<Equipment> equip(Equipment item) {
        //Equip item, and get all the items that are displaced.
        List<String> newlyOccupiedSlotNames = item.getEquipSlotNames();
        Set<Equipment> unequippedItems = newlyOccupiedSlotNames.stream()
                .map(slotName -> CollectionUtils.getValue(equipSlots, slotName)).map(slot -> slot.equip(item))
                .filter(Optional::isPresent).map(Optional::get).collect(Collectors.toSet());

        //Fully unequip displaced items.
        unequippedItems.stream().flatMap(unequippedItem -> unequippedItem.getEquipSlotNames().stream())
                .filter(slotName -> !newlyOccupiedSlotNames.contains(slotName))
                .map(slotName -> CollectionUtils.getValue(equipSlots, slotName)).forEach(EquipSlot::unequip);

        modifySecondaryStatisticsOnEquip(item);

        return unequippedItems;
    }

    /**
     * This method is invoked every time an item is equipped, and modifies the character's secondary statistics based
     * on the properties of the item being equipped.
     *
     * @param item  The item being equipped
     */
    protected abstract void modifySecondaryStatisticsOnEquip(Equipment item);

    /**
     * Unequips the item in the specified equip slot.
     *
     * @param slotName  The name of the slot whose item is being unequipped
     * @return The item that was previously in this slot
     */
    public Optional<Equipment> unequip(String slotName) {
        Optional<Equipment> unequippedItem = CollectionUtils.getValue(equipSlots, slotName).unequip();
        if (unequippedItem.isPresent()) {
            modifySecondaryStatisticsOnUnEquip(unequippedItem.get());
        }
        return unequippedItem;
    }

    /**
     * This method is invoked every time an item is unequipped, and modifies the character's secondary statistics
     * based on the properites of the item being unequipped.
     *
     * @param item  The item being unequipped
     */
    protected abstract void modifySecondaryStatisticsOnUnEquip(Equipment item);

    /**
     * Returns the equipment in the specified slot.
     *
     * @param slotName  The name of the slot whose equipment is desired
     * @return The equipment in that slot
     */
    @JsonIgnore
    public Optional<Equipment> getEquipment(String slotName) {
        return CollectionUtils.getValue(equipSlots, slotName).getEquipped();
    }

    /**
     * Returns the names of all the equipment currently equipped by this character.
     *
     * @return  The set of names of all the equipment currently equipped by this character
     */
    public Set<String> getEquipmentNames() {
        return equipSlots.values().stream().map(EquipSlot::getEquipped).filter(Optional::isPresent)
                .map(Optional::get).map(Equipment::getName).collect(Collectors.toSet());
    }

    public Set<Equipment> getEquipment() {
        return equipSlots.values().stream().map(EquipSlot::getEquipped).filter(Optional::isPresent)
                .map(Optional::get).collect(Collectors.toSet());

    }

    @JsonIgnore
    public Map<String, EquipSlot> getEquipSlots() {
        return Collections.unmodifiableMap(equipSlots);
    }

    /**
     * Checks if the specified slot has any equipment.
     *
     * @param slotName  The name of the slot of interest
     * @return  True if the slot has equipment in it, false otherwise
     */
    public boolean hasEquipment(String slotName) {
        return CollectionUtils.getValue(equipSlots, slotName).hasEquipment();
    }

    public boolean isFemale() {
        return gender == Gender.FEMALE;
    }

    public boolean hasAGender() {
        return gender != null && gender != Gender.UNKNOWN;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @JsonIgnore
    public String getPrintedName() {
        return printedNames.get(gender);
    }

    /**
     * Returns an unmodifiable map of this character's male and female printed names.
     *
     * @return An unmodifiable map of this character's male and female printed names
     */
    public Map<Gender, String> getBothPrintedNames() {
        return Collections.unmodifiableMap(printedNames);
    }

    public void setBothPrintedNames(Map<Gender, String> printedNames) {
        this.printedNames = printedNames;
    }

    public List<CombatRange> getAttackRanges() {
        return Collections.unmodifiableList(attackRanges);
    }

    /**
     * Returns a subset of the character's attackable ranges based on the character's position.
     * <p>
     * If the character is grappling, this returns either the singleton list {@link CombatRange#GRAPPLE} or an
     * empty list. Otherwise, the method returns all of the character's targetable ranges except for
     * {@link CombatRange#GRAPPLE}.
     *
     * @param characterPosition  The character's current position
     *
     * @return A list of ranges the character can actually target from the specified position
     */
    @JsonIgnore
    public List<CombatRange> getTargetableRanges(CombatRange characterPosition) {
        if (characterPosition == CombatRange.GRAPPLE) {
            return getAttackRanges().stream().filter(range -> range == characterPosition)
                    .collect(Collectors.toList());
        }
        return attackRanges.stream().filter(range -> range != CombatRange.GRAPPLE).collect(Collectors.toList());
    }

    public void setAttackRanges(List<CombatRange> attackRanges) {
        this.attackRanges = attackRanges;
    }

    public Gender getGender() {
        return gender;
    }

    public void setGender(Gender gender) {
        this.gender = gender;
    }

    /**
     * Returns a description of the character. If no description was provided at construction, builds a simple
     * description based on the values in appearance
     *
     * @return A description of the character
     */
    @JsonIgnore
    public String getDescription() {
        if (description == null) {
            return String.join("\n", appearance.entrySet().stream()
                    .map(entry -> entry.getKey() + ": " + entry.getValue()).collect(Collectors.toList()));
        } else {
            return description;
        }
    }

    /**
     * Returns the actual value stored in {@code description} as opposed to {@link GameCharacter#getDescription()},
     * which computes a description if description is null.
     *
     * @return This character's description, or null if this character has no description
     */
    public String getRawDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    /**
     * Returns an unmodifiable view of this character's bum status.
     *
     * @return  An unmodifiable view of this character's bum status
     */
    public List<String> getBumStatus() {
        return Collections.unmodifiableList(bumStatus);
    }

    public void addBumStatus(String bumStatus) {
        this.bumStatus.add(bumStatus);
    }

    public void popBumStatus() {
        if (!bumStatus.isEmpty()) {
            bumStatus.remove(0);
        }
    }

    public void clearBumStatus() {
        bumStatus.clear();
    }

    /**
     * Modify the character's appearance.
     *
     * @param appearanceElementName  The appearance element to be changed
     * @param newAppearance  The element to replace the old element
     */
    public void changeAppearance(String appearanceElementName, AppearanceElement newAppearance) {
        appearance.put(appearanceElementName, newAppearance);
    }

    /**
     * Returns an adjective describing some aspect of the character, based on the desired adjective element.
     *
     * @return An adjective describing the character's bottom, based on the desired adjective element.
     */
    @JsonIgnore
    public String getAdjective(String appearanceElementName) {
        return CollectionUtils.getValue(appearance, appearanceElementName).generateAdjective();
    }

    /**
     * Returns an unmodifiable map describing the appearance of this character
     *
     * @return An unmodifiable map of this character's appearance elements
     */
    public Map<String, AppearanceElement> getAppearance() {
        return Collections.unmodifiableMap(appearance);
    }

    /**
     * Given the name of an appearance element, returns the associated appearance element.
     *
     * @param appearanceName  The name of the desired appearance element
     *
     * @return The desired appearance element
     */
    public AppearanceElement getAppearance(String appearanceName) {
        return CollectionUtils.getValue(appearance, appearanceName);
    }

    /**
     * Inflict the specified status for the specified duration on the character.
     * <p>
     * If this character already has the specified status, the status with the longer duration is used.
     *
     * @param status  The status to inflict
     * @param duration  The duration of the status
     */
    public void inflictStatus(Status status, int duration) {
        if (statuses.containsKey(status) && duration < statuses.get(status)) {
            return;
        } else if (statuses.containsKey(status)) {
            cureStatus(statuses.keySet().stream().filter(key -> key.equals(status)).findFirst().get());
        }
        statuses.put(status, duration);
        status.applyStatus(this);
    }

    /**
     * Cures the character of the specified status.
     * If the character is not inflicted with the specified status, this method does nothing.
     *
     * @param status  The status to cure the character of
     */
    public void cureStatus(Status status) {
        if (statuses.containsKey(status)) {
            statuses.remove(status);
            status.reverseStatus(this);
        }
    }

    /**
     * Cure all the statuses currently inflicting this character
     */
    public void cureAllStatuses() {
        Set<Status> statusSet = new HashSet<>(statuses.keySet());
        statusSet.stream().forEach(this::cureStatus);
    }

    /**
     * Reduces the duration of the specified status by the specified amount.
     * If after the reduction, the duration of the status is 0 or less, the status is cured. If this character is
     * not afflicted with the specified status, this method does nothing.
     *
     * @param status  The status whose duration should be reduced
     * @param reduction  The amount to reduce the duration by
     */
    public void decrementStatusDuration(Status status, int reduction) {
        if (statuses.containsKey(status)) {
            int duration = statuses.get(status) - reduction;
            if (duration <= 0) {
                cureStatus(status);
            } else {
                statuses.put(status, duration);
            }
        }
    }

    /**
     * Reduces the duration of all statuses on this character by the amount specified.
     *
     * @param reduction  The amount to reduce the duration of each status by
     */
    public void decrementAllStatusDurations(int reduction) {
        Set<Status> keys = new HashSet<>(statuses.keySet());
        keys.stream().forEach(status -> decrementStatusDuration(status, reduction));
    }

    /**
     * Returns an immutable map describing the statuses that inflict this character.
     *
     * @return An immutable map describing the statuses inflicting this character
     */
    public Map<Status, Integer> getStatuses() {
        return Collections.unmodifiableMap(statuses);
    }

    public void setStatuses(Map<Status, Integer> statuses) {
        this.statuses = statuses;
    }

    /**
     * Determines if this character is inflicted with the specified status.
     *
     * @param status  The status we're interested in
     *
     * @return  True if this character is inflicted with the specified status.
     */
    public boolean isInflictedWith(Status status) {
        return statuses.containsKey(status);
    }

    public void addSpankingEvent(String key, EventDescription value) {
        spankingEvents.put(key, value);
    }

    public void addSpankingEvents(Map<String, EventDescription> spankingEvents) {
        this.spankingEvents.putAll(spankingEvents);
    }

    /**
     * Returns the spanking text associated to the specified key.
     *
     * @param key  The key of the text desired
     * @return  The desired spanking text
     */
    @JsonIgnore
    public Optional<EventDescription> getSpankingEvent(String key) {
        return Optional.ofNullable(spankingEvents.get(key));
    }

    @JsonIgnore
    public Map<String, EventDescription> getSpankingEvents() {
        return Collections.unmodifiableMap(spankingEvents);
    }

    /**
     * Returns the level at which this character knows the specified skill, or an empty Optional if the character
     * does not know the skill.
     *
     * @param skill  The skill whose level is desired
     *
     * @return  The level at which this character knows the skill, or nothing if the character doesn't know the skill
     */
    @JsonIgnore
    public OptionalInt getSkillLevel(Skill skill) {
        return knowsSkill(skill) ? OptionalInt.of(skills.get(skill.getName())) : OptionalInt.empty();
    }

    /**
     * Learns the specified skill at level one.
     *
     * @param skill  The skill to learn
     * @return The set of new skills whose prerequisites have been met
     */
    public Set<Skill> learnSkill(Skill skill) {
        addSkill(skill.getName(), 1);
        return GameState.getInstance().getSkills().values().stream()
                .filter(newSkill -> newSkill.arePrerequisitesMet(this)).collect(Collectors.toSet());
    }

    /**
     * Adds the specified skill to this character's repetoire at the specified level.
     *
     * @param skillName  The name of the skill to be added
     * @param level  The level at which to add the skill
     */
    public void addSkill(String skillName, int level) {
        skills.put(skillName, level);
    }

    public void forgetSkill(String skillName) {
        skills.remove(skillName);
    }

    public void clearSkills() {
        skills.clear();
    }

    /**
     * Improves the level of the specified skill by 1.
     *
     * @param skill  The skill to improve by one level
     *
     * @return  The set of names of new skills whose prerequisites have been met
     */
    public Set<String> improveSkill(Skill skill) {
        skills.put(skill.getName(), skills.get(name) + 1);
        return GameState.getInstance().getSkills().values().stream()
                .filter(newSkill -> newSkill.arePrerequisitesMet(this)).map(Skill::getName)
                .collect(Collectors.toSet());
    }

    public boolean knowsSkill(Skill skill) {
        return skills.containsKey(skill.getName());
    }

    /**
     * Returns an unmodifiable map describing the skills this character knows, and the level at which they know them.
     *
     * @return An unmodifiable map describing the skills this character knows, and the level at which they know them.
     */
    public Map<String, Integer> getSkills() {
        return Collections.unmodifiableMap(skills);
    }

    public void setSkills(LinkedHashMap<String, Integer> skills) {
        this.skills = skills;
    }

    public String getBattleName() {
        return battleName;
    }

    public void setBattleName(String battleName) {
        this.battleName = battleName;
    }

    @JsonIgnore
    public SpankingRole getRole() {
        return role;
    }

    /**
     * Equality based on the name of the character. Note: This equality does _not_ check class. Therefore, a
     * GameCharacter with name "Sue" and a GameCharacterChildClass with name "Sue" will be considered equal.
     * However, the two objects will _not_ be considered equal if the other object is not a subclass of GameCharacter.
     *
     * @param o  The other GameCharacter being checked for equality
     * @return  True if the two characters are equal (in the sense of having the same name)
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || !(o instanceof GameCharacter)) {
            return false;
        }

        GameCharacter that = (GameCharacter) o;
        return this.name.equals(that.name);
    }

    @Override
    public String toString() {
        return "GameCharacter{" + "name='" + name + '\'' + ", printedName='" + printedNames + '\'' + ", appearance="
                + appearance + ", description='" + description + '\'' + ", primaryStatistics=" + primaryStatistics
                + ", secondaryStatistics=" + secondaryStatistics + ", equipSlots=" + equipSlots + ", statuses="
                + statuses + '}';
    }

    @Override
    public int hashCode() {
        return name != null ? name.hashCode() : 0;
    }
}