/* * AtBPreferences.java * * Copyright (c) 2014 Carl Spain. All rights reserved. * * This file is part of MekHQ. * * MekHQ 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. * * MekHQ 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 MekHQ. If not, see <http://www.gnu.org/licenses/>. */ package mekhq.campaign; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.Serializable; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.ResourceBundle; import java.util.function.Function; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import megamek.common.Compute; import megamek.common.MechSummary; import megamek.common.MechSummaryCache; import megamek.common.TargetRoll; import megamek.common.UnitType; import mekhq.MekHQ; import mekhq.campaign.personnel.Person; import mekhq.campaign.personnel.SkillType; import mekhq.campaign.rating.IUnitRating; /** * @author Neoancient * * Class that handles configuration options for Against the Bot campaigns * more extensive than what is handled by CampaignOptions. Most of the options * fall into one of two categories: they allow users to customize the various * tables in the rules, or they avoid hard-coding universe details. * */ public class AtBConfiguration implements Serializable { /** * */ private static final long serialVersionUID = 515628415152924457L; /* Used to indicate size of lance or equivalent in opfor forces */ public static final String ORG_IS = "IS"; public static final String ORG_CLAN = "CLAN"; public static final String ORG_CS = "CS"; /* Scenario generation */ private HashMap<String,ArrayList<WeightedTable<String>>> botForceTables = new HashMap<>(); private HashMap<String,ArrayList<WeightedTable<String>>> botLanceTables = new HashMap<>(); /* Contract generation */ private ArrayList<DatedRecord<String>> hiringHalls; /* Personnel and unit markets */ private int shipSearchCost = 100000; private int shipSearchLengthWeeks = 4; private Integer dropshipSearchTarget; private Integer jumpshipSearchTarget; private Integer warshipSearchTarget; private WeightedTable<String> dsTable; private WeightedTable<String> jsTable; private ResourceBundle defaultProperties; private AtBConfiguration() { hiringHalls = new ArrayList<DatedRecord<String>>(); dsTable = new WeightedTable<>(); jsTable = new WeightedTable<>(); defaultProperties = ResourceBundle.getBundle("mekhq.resources.AtBConfigDefaults"); } /** * Provide default values in case the file is missing or contains errors. */ private WeightedTable<String> getDefaultForceTable(String key, int index) { if(index < 0) { MekHQ.logError("Default force tables don't support negative weights, limiting to 0"); //$NON-NLS-1$ index = 0; } String property = defaultProperties.getString(key); String[] fields = property.split("\\|"); //$NON-NLS-1$ if(index >= fields.length) { // Deal with too short field lengths MekHQ.logError(String.format("Default force tables have %d weight entries; limiting the original value of %d.", fields.length, index)); //$NON-NLS-1$ index = fields.length - 1; } return parseDefaultWeightedTable(fields[index]); } private WeightedTable<String> parseDefaultWeightedTable(String entry) { return parseDefaultWeightedTable(entry, s -> s); } private <T>WeightedTable<T> parseDefaultWeightedTable(String entry, Function<String,T> fromString) { WeightedTable<T> retVal = new WeightedTable<>(); String[] entries = entry.split(","); for (String e : entries) { String[] fields = e.split(":"); retVal.add(Integer.parseInt(fields[0]), fromString.apply(fields[1])); } return retVal; } /** * Used if the config file is missing. */ private void setAllValuesToDefaults() { for (Enumeration<String> e = defaultProperties.getKeys(); e.hasMoreElements(); ) { String key = e.nextElement(); String property = defaultProperties.getString(key); switch (key) { case "botForce.IS": case "botForce.CLAN": case "botForce.CS": ArrayList<WeightedTable<String>> list = new ArrayList<>(); for (String entry : property.split("\\|")) { list.add(parseDefaultWeightedTable(entry)); } botForceTables.put(key.replace("botForce.", ""), list); break; case "botLance.IS": case "botLance.CLAN": case "botLance.CS": list = new ArrayList<>(); for (String entry : property.split("\\|")) { list.add(parseDefaultWeightedTable(entry)); } botLanceTables.put(key.replace("botLance.", ""), list); break; case "hiringHalls": SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd"); for (String entry : property.split("\\|")) { String[] fields = entry.split(","); try { hiringHalls.add(new DatedRecord<>(fields[0].length() > 0? df.parse(fields[0]) : null, fields[1].length() > 0? df.parse(fields[1]) : null, fields[2])); } catch (ParseException ex) { MekHQ.logError("Error parsing default date for hiring hall on " + fields[2]); MekHQ.logError(ex); } } break; case "shipSearchCost": shipSearchCost = Integer.parseInt(property); break; case "shipSearchLengthWeeks": shipSearchLengthWeeks = Integer.parseInt(property); break; case "shipSearchTarget.Dropship": dropshipSearchTarget = property.matches("\\d+")? Integer.valueOf(property) : null; break; case "shipSearchTarget.Jumpship": jumpshipSearchTarget = property.matches("\\d+")? Integer.valueOf(property) : null; break; case "shipSearchTarget.Warship": warshipSearchTarget = property.matches("\\d+")? Integer.valueOf(property) : null; break; case "ships.Dropship": dsTable = parseDefaultWeightedTable(property); break; case "ships.Jumpship": jsTable = parseDefaultWeightedTable(property); break; } } } public int weightClassIndex(int entityWeightClass) { return entityWeightClass - 1; } public int weightClassIndex(String wc) { switch (wc) { case "L": case "UL": return 0; case "M": return 1; case "H": return 2; case "A": case "C": case "SH": return 3; } throw new IllegalArgumentException("Could not parse weight class " + wc); } public String selectBotLances(String org, int weightClass) { return selectBotLances(org, weightClass, 0f); } public String selectBotLances(String org, int weightClass, float rollMod) { if (botForceTables.containsKey(org)) { final List<WeightedTable<String>> botForceTable = botForceTables.get(org); int weightClassIndex = weightClassIndex(weightClass); WeightedTable<String> table = null; if((weightClassIndex < 0) || (weightClassIndex >= botForceTable.size())) { MekHQ.logError( String.format("Bot force tables for organization \"%s\" don't have an entry for weight class %d, limiting to valid values", org, weightClass)); //$NON-NLS-1$ weightClassIndex = Math.max(0, Math.min(weightClassIndex, botForceTable.size() - 1)); } table = botForceTable.get(weightClassIndex); if (null == table) { table = getDefaultForceTable("botForce." + org, weightClassIndex); if (null == table) { MekHQ.logError( String.format("Default (fallback) bot force table for organization \"%s\" and weight class %d doesn't exist, ignoring", org, weightClass)); //$NON-NLS-1$ return null; } } return table.select(rollMod); } else { MekHQ.logError( String.format("Bot force tables for organization \"%s\" not found, ignoring", org)); //$NON-NLS-1$ return null; } } public String selectBotUnitWeights(String org, int weightClass) { return selectBotUnitWeights(org, weightClass, 0f); } public String selectBotUnitWeights(String org, int weightClass, float rollMod) { if (botLanceTables.containsKey(org)) { WeightedTable<String> table = botLanceTables.get(org).get(weightClassIndex(weightClass)); if (table == null) { table = this.getDefaultForceTable("botLance." + org, weightClassIndex(weightClass)); } return table.select(rollMod); } return null; } public boolean isHiringHall(String planet, Date date) { return hiringHalls.stream().anyMatch( rec -> rec.getValue().equals(planet) && rec.fitsDate(date)); } public int getShipSearchCost() { return shipSearchCost; } public int getShipSearchLengthWeeks() { return shipSearchLengthWeeks; } public int shipSearchCostPerWeek() { return shipSearchCost / shipSearchLengthWeeks; } public Integer getDropshipSearchTarget() { return dropshipSearchTarget; } public Integer getJumpshipSearchTarget() { return jumpshipSearchTarget; } public Integer getWarshipSearchTarget() { return warshipSearchTarget; } public Integer shipSearchTargetBase(int unitType) { switch (unitType) { case UnitType.DROPSHIP: return dropshipSearchTarget; case UnitType.JUMPSHIP: return jumpshipSearchTarget; case UnitType.WARSHIP: return warshipSearchTarget; } return null; } public TargetRoll shipSearchTargetRoll(int unitType, Campaign campaign) { if (shipSearchTargetBase(unitType) == null) { return new TargetRoll(TargetRoll.IMPOSSIBLE, "Base"); } TargetRoll target = new TargetRoll(shipSearchTargetBase(unitType), "Base"); Person adminLog = campaign.findBestInRole(Person.T_ADMIN_LOG, SkillType.S_ADMIN); int adminLogExp = (adminLog == null)?SkillType.EXP_ULTRA_GREEN:adminLog.getSkill(SkillType.S_ADMIN).getExperienceLevel(); for (Person p : campaign.getAdmins()) { if ((p.getPrimaryRole() == Person.T_ADMIN_LOG || p.getSecondaryRole() == Person.T_ADMIN_LOG) && p.getSkill(SkillType.S_ADMIN).getExperienceLevel() > adminLogExp) { adminLogExp = p.getSkill(SkillType.S_ADMIN).getExperienceLevel(); } } target.addModifier(SkillType.EXP_REGULAR - adminLogExp, "Admin/Logistics"); target.addModifier(IUnitRating.DRAGOON_C - campaign.getUnitRatingMod(), "Unit Rating"); return target; } public MechSummary findShip(int unitType) { WeightedTable<String> table = null; if (unitType == UnitType.JUMPSHIP) { table = jsTable; } else if (unitType == UnitType.DROPSHIP) { table = dsTable; } String shipName = table.select(); if (shipName == null) { return null; } return MechSummaryCache.getInstance().getMech(shipName); } public static AtBConfiguration loadFromXml() { AtBConfiguration retVal = new AtBConfiguration(); MekHQ.logMessage("Starting load of AtB configuration data from XML..."); DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); Document xmlDoc = null; try { FileInputStream fis = new FileInputStream("data/universe/atbconfig.xml"); DocumentBuilder db = dbf.newDocumentBuilder(); xmlDoc = db.parse(fis); } catch (FileNotFoundException ex) { MekHQ.logError("File data/universe/atbconfig.xml not found. Loading defaults."); retVal.setAllValuesToDefaults(); return retVal; } catch (Exception ex) { MekHQ.logError(ex); return retVal; } Element rootElement = xmlDoc.getDocumentElement(); NodeList nl = rootElement.getChildNodes(); rootElement.normalize(); for (int x = 0; x < nl.getLength(); x++) { Node wn = nl.item(x); switch (wn.getNodeName()) { case "scenarioGeneration": retVal.loadScenarioGenerationNodeFromXml(wn); break; case "contractGeneration": retVal.loadContractGenerationNodeFromXml(wn); break; case "shipSearch": retVal.loadShipSearchNodeFromXml(wn); break; } } return retVal; } private void loadScenarioGenerationNodeFromXml(Node node) { NodeList nl = node.getChildNodes(); for (int i = 0; i < nl.getLength(); i++) { Node wn = nl.item(i); String[] orgs; ArrayList<WeightedTable<String>> list; switch (wn.getNodeName()) { case "botForce": if (wn.getAttributes().getNamedItem("org") == null) { orgs = new String[1]; orgs[0] = ORG_IS; } else { orgs = wn.getAttributes().getNamedItem("org").getTextContent().split(","); } list = loadForceTableFromXml(wn); for (String org : orgs) { botForceTables.put(org, list); } break; case "botLance": if (wn.getAttributes().getNamedItem("org") == null) { orgs = new String[1]; orgs[0] = ORG_IS; } else { orgs = wn.getAttributes().getNamedItem("org").getTextContent().split(","); } list = loadForceTableFromXml(wn); for (String org : orgs) { botLanceTables.put(org, list); } break; } } } private ArrayList<WeightedTable<String>> loadForceTableFromXml(Node node) { ArrayList<WeightedTable<String>> retVal = new ArrayList<>(); NodeList nl = node.getChildNodes(); for (int i = 0; i < nl.getLength(); i++) { Node wn = nl.item(i); if (wn.getNodeName().equals("weightedTable")) { try { int weightClass = weightClassIndex(wn.getAttributes() .getNamedItem("weightClass").getTextContent()); while (retVal.size() <= weightClass) { retVal.add(null); } retVal.set(weightClass, loadWeightedTableFromXml(wn)); } catch (Exception ex) { MekHQ.logError(ex); MekHQ.logError("Could not parse weight class attribute for enemy forces table"); } } } return retVal; } private void loadContractGenerationNodeFromXml(Node node) { SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd"); NodeList nl = node.getChildNodes(); for (int i = 0; i < nl.getLength(); i++) { Node wn = nl.item(i); switch (wn.getNodeName()) { case "hiringHalls": hiringHalls.clear(); for (int j = 0; j < wn.getChildNodes().getLength(); j++) { Node wn2 = wn.getChildNodes().item(j); switch (wn2.getNodeName()) { case "hall": Date start = null; Date end = null; try { if (wn2.getAttributes().getNamedItem("start") != null) { start = new Date(df.parse(wn2.getAttributes().getNamedItem("start").getTextContent()).getTime()); } if (wn2.getAttributes().getNamedItem("end") != null) { end = new Date(df.parse(wn2.getAttributes().getNamedItem("end").getTextContent()).getTime()); } } catch (ParseException ex) { MekHQ.logError("Error parsing date for hiring hall on " + wn2.getTextContent()); MekHQ.logError(ex); } hiringHalls.add(new DatedRecord<String>(start, end, wn2.getTextContent())); break; } } break; } } } private void loadShipSearchNodeFromXml(Node node) { NodeList nl = node.getChildNodes(); for (int i = 0; i < nl.getLength(); i++) { Node wn = nl.item(i); switch (wn.getNodeName()) { case "shipSearchCost": shipSearchCost = Integer.parseInt(wn.getTextContent()); break; case "shipSearchLengthWeeks": shipSearchLengthWeeks = Integer.parseInt(wn.getTextContent()); break; case "target": if (wn.getAttributes().getNamedItem("unitType") != null) { Integer target = Integer.valueOf(wn.getTextContent()); switch (wn.getAttributes().getNamedItem("unitType").getTextContent()) { case "Dropship": dropshipSearchTarget = target; break; case "Jumpship": jumpshipSearchTarget = target; break; case "Warship": warshipSearchTarget = target; break; } } break; case "weightedTable": if (wn.getAttributes().getNamedItem("unitType") != null) { WeightedTable<String> map = loadWeightedTableFromXml(wn); switch (wn.getAttributes().getNamedItem("unitType").getTextContent()) { case "Dropship": dsTable = map; break; case "Jumpship": jsTable = map; } } break; } } } private WeightedTable<String> loadWeightedTableFromXml(Node node) { return loadWeightedTableFromXml(node, s -> s); } private <T>WeightedTable<T> loadWeightedTableFromXml(Node node, Function<String,T> fromString) { WeightedTable<T> retVal = new WeightedTable<>(); NodeList nl = node.getChildNodes(); for (int i = 0; i < nl.getLength(); i++) { Node wn = nl.item(i); if (wn.getNodeName().equals("entry")) { int weight = 1; if (wn.getAttributes().getNamedItem("weight") != null) { weight = Integer.parseInt(wn.getAttributes().getNamedItem("weight").getTextContent()); } retVal.add(weight, fromString.apply(wn.getTextContent())); } } return retVal; } /* * Attaches a start and end date to any object. * Either the start or end date can be null, indicating that * the value should apply to all dates from the beginning * or to the end of the epoch, respectively. */ static class DatedRecord<E> { private Date start; private Date end; private E value; public DatedRecord() { start = null; end = null; value = null; } public DatedRecord(Date s, Date e, E v) { if (s != null) { start = new Date(s.getTime()); } if (e != null) { end = new Date(e.getTime()); } value = v; } public void setStart(Date s) { if (start == null) { start = new Date(s.getTime()); } else { start.setTime(s.getTime()); } } public Date getStart() { return start; } public void setEnd(Date e) { if (end == null) { end = new Date(e.getTime()); } else { end.setTime(e.getTime()); } } public Date getEnd() { return end; } public void setValue(E v) { value = v; } public E getValue() { return value; } /** * * @param d * @return true if d is between the start and end date, inclusive */ public boolean fitsDate(Date d) { return (start == null || !start.after(d)) && (end == null || !end.before(d)); } } static class WeightedTable<T> implements Serializable { /** * */ private static final long serialVersionUID = 1984759212668176620L; private ArrayList<Integer> weights = new ArrayList<>(); private ArrayList<T> values = new ArrayList<>(); public void add(Integer weight, T value) { weights.add(weight); values.add(value); } public T remove(T value) { int index = values.indexOf(value); if (index > 0) { weights.remove(index); return values.remove(index); } return null; } public T select() { return select(0f); } /** * Select random entry proportionally to the weight values * @param rollMod - a modifier to the die roll, expressed as a fraction of the total weight * @return */ public T select(float rollMod) { int total = weights.stream().mapToInt(w -> w.intValue()).sum(); if (total > 0) { int roll = Math.min(Compute.randomInt(total) + (int)(total * rollMod + 0.5f), total - 1); for (int i = 0; i < weights.size(); i++) { if (roll < weights.get(i)) { return values.get(i); } roll -= weights.get(i); } } return null; } } }