package net.sf.colossus.server; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.PrintWriter; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import net.sf.colossus.common.Constants; import net.sf.colossus.common.Options; import net.sf.colossus.game.Creature; import net.sf.colossus.game.EntrySide; import net.sf.colossus.game.Phase; import net.sf.colossus.game.Player; import net.sf.colossus.util.BuildInfo; import net.sf.colossus.util.ErrorUtils; import net.sf.colossus.variant.BattleHex; import net.sf.colossus.variant.CreatureType; import org.jdom.Document; import org.jdom.Element; import org.jdom.output.Format; import org.jdom.output.XMLOutputter; public class GameSaving { private static final Logger LOGGER = Logger.getLogger(GameSaving.class .getName()); private final GameServerSide game; private final Options options; /** * snapshot of game data (caretaker, players, legions, ...) at the last * "commit point", initially those are taken only at start of a phase. * (Later this might be also after each completed engagement/battle). * Savegame contains then this snapshot plus the redo-Data which was * additionally done after that. */ private Element phaseStartSnapshot; /** * Store timestamp of first created autosave file here; iscFile * will be generated with same timestamp */ private String firstAutosavefileTimestamp = null; private String iscmName = null; /** * List of filenames that has been created by AutoSave. * If option "keep max N autosave files" is set, when N+1th file was * created, first from this list will be deleted and so on. */ private final List<String> autoGeneratedFiles = new ArrayList<String>(); public GameSaving(GameServerSide game, Options options) { this.options = options; this.game = game; } /** * Take a new snapshot of the data (basic game data, * players with legions, and history) at the begin of a phase. * At every point of time there is always one such latest snapshot * in this.phaseStartSnapshot. */ private void takeSnapshotAtBeginOfPhase() { Element root = new Element("CommitPointSnapshot"); addBasicData(root); addPlayerData(root); this.phaseStartSnapshot = root; } /** * When a commit point is reached (typically, one phase is "Done" * and a new phase begins), * 1) take new snapshot of overall game state, player, legion, caretaker data * 2) flush the so far redoLog data to the history, * 3) clear the redoLog data. * */ public void commitPointReached() { // XXX /* System.out.println("Commit point reached. Turn=" + game.getTurnNumber() + ", player= " + game.getActivePlayer().getName() + ", phase=" + game.getPhaseName()); */ takeSnapshotAtBeginOfPhase(); game.getHistory().flushRecentToRoot(); } // unchecked conversions from JDOM @SuppressWarnings("unchecked") private void addSnapshotData(Element saveGameRoot, Element commitDataRoot) { Element copyOfSnapshot = (Element)commitDataRoot.clone(); List<Element> kids = new LinkedList<Element>( copyOfSnapshot.getChildren()); for (Element el : kids) { el.detach(); Element newEl = (Element)el.clone(); // System.out.println(" adding commit data, element name: " // + el.getName()); saveGameRoot.addContent(newEl); } } /** * Create the whole content that will be written to the save game file. * Takes the last phaseStartSnapshot plus redo-Data plus battle data plus * data files. * * @return The "ColossusSnapshot" root element containing all information */ private Element createSavegameContent() { Element root = new Element("ColossusSnapshot"); root.setAttribute("version", Constants.XML_SNAPSHOT_VERSION); root.setAttribute("createdByRelease", BuildInfo.getReleaseVersion() + " (" + BuildInfo.getRevisionInfoString() + ")"); root.setAttribute("iscmFileName", iscmName != null ? iscmName : ""); // System.out.println("- Adding snapshot data from last commit point"); addSnapshotData(root, this.phaseStartSnapshot); // Everything up to last commit point: // System.out.println("- Adding history"); root.addContent(game.getHistory().getCopy()); // Add the events since last commit point, some of them are more // detailed level. Redo log might also be empty, add it anyway, // otherwise there is trouble during loading. // System.out.println("- Adding redoLog"); Element redoLogElement = game.getHistory().getNewRedoLogElement(); // temporary solution - the save game file will basically be same state // as it was before engagement started if (game.isEngagementInProgress()) { redoLogElement = new Element("Redo"); } root.addContent(redoLogElement); // Battle stuff if (game.isEngagementInProgress() && game.getBattleSS() != null) { /* Disabled until it works properly */ boolean featureProperlyEnabled = false; if (featureProperlyEnabled) { addBattleData(root); } } return root; } /** * Adds the basic data: variant info, turn number, current player, * current phase, and caretaker. * * @param root The document root to which to add all the data */ private void addBasicData(Element root) { Element el = new Element("Variant"); el.setAttribute("dir", VariantSupport.getVarDirectory()); el.setAttribute("file", VariantSupport.getVarFilename()); el.setAttribute("name", VariantSupport.getVariantName()); root.addContent(el); el = new Element("TurnNumber"); el.addContent("" + game.getTurnNumber()); root.addContent(el); el = new Element("CurrentPlayer"); el.addContent("" + game.getActivePlayerNum()); root.addContent(el); el = new Element("CurrentPhase"); el.addContent("" + game.getPhase().toInt()); root.addContent(el); // Caretaker stacks Element careTakerEl = new Element("Caretaker"); for (CreatureType creature : game.getVariant().getCreatureTypes()) { el = new Element("Creature"); el.setAttribute("name", creature.getName()); el.setAttribute("remaining", "" + game.getCaretaker().getAvailableCount(creature)); el.setAttribute("dead", "" + game.getCaretaker().getDeadCount(creature)); careTakerEl.addContent(el); } // XXX temporarily out of use to keep save games small during development / debugging root.addContent(careTakerEl); } /** * Helper method, returns "null" if given string is null; * used by dumpLegion. * @param in the string to "null"ify if needed * @return "null" or the string itself */ private String notnull(String in) { if (in == null) { return "null"; } return in; } /** * Dump the given legion to an XML element * @param legion For which legion to dump the data * @param inBattle Whether this legion is currently involved into an * ongoing battle (i.e. battle data needs to be dumped too) * @return An XML Element with all Legion data */ private Element dumpLegion(LegionServerSide legion, boolean inBattle) { Element leg = new Element("Legion"); leg.setAttribute("name", legion.getMarkerId()); leg.setAttribute("currentHex", legion.getCurrentHex().getLabel()); leg.setAttribute("startingHex", legion.getStartingHex().getLabel()); leg.setAttribute("moved", "" + legion.hasMoved()); EntrySide entrySide = legion.getEntrySide(); if (entrySide == null) { // This should never happen. Let's follow it for a while // TODO This try-catch checking code can probably be removed // at some point. LOGGER.warning("EntrySide of Legion " + legion.getMarkerId() + " is null!?!"); entrySide = EntrySide.NOT_SET; } leg.setAttribute("entrySide", "" + entrySide.ordinal()); leg.setAttribute("parent", legion.getParent() != null ? notnull(legion .getParent().getMarkerId()) : "null"); leg.setAttribute("recruitName", String.valueOf(legion.getRecruit())); leg.setAttribute("battleTally", "" + legion.getBattleTally()); for (Creature critter : legion.getCreatures()) { Element cre = new Element("Creature"); cre.setAttribute("name", critter.getName()); if (inBattle) { cre.setAttribute("hits", "" + critter.getHits()); cre.setAttribute("currentHex", critter.getCurrentHex() .getLabel()); cre.setAttribute("startingHex", critter.getStartingHex() .getLabel()); cre.setAttribute("struck", "" + critter.hasStruck()); } leg.addContent(cre); } return leg; } /** * Adds the data for all players and their legions to an XML document * * @param root The document root to which to add the data */ private void addPlayerData(Element root) { // Players Element el; for (Player p : game.getPlayers()) { PlayerServerSide player = (PlayerServerSide)p; el = new Element("Player"); el.setAttribute("name", player.getName()); el.setAttribute("type", player.getType()); el.setAttribute("color", player.getColor().getName()); el.setAttribute("startingTower", player.getStartingTower() .getLabel()); el.setAttribute("score", "" + player.getScore()); el.setAttribute("dead", "" + player.isDead()); el.setAttribute("mulligansLeft", "" + player.getMulligansLeft()); el.setAttribute("colorsElim", player.getPlayersElim()); el.setAttribute("summoned", "" + player.hasSummoned()); Collection<LegionServerSide> legions = player.getLegions(); Iterator<LegionServerSide> it2 = legions.iterator(); while (it2.hasNext()) { LegionServerSide legion = it2.next(); el.addContent(dumpLegion( legion, game.isBattleInProgress() && (legion == game.getBattleSS().getAttackingLegion() || legion == game .getBattleSS().getDefendingLegion()))); } root.addContent(el); } } private void addBattleData(Element root) { Element bat = new Element("Battle"); bat.setAttribute("masterHexLabel", game.getBattleSS().getLocation() .getLabel()); bat.setAttribute("turnNumber", "" + game.getBattleSS().getBattleTurnNumber()); bat.setAttribute("activePlayer", "" + game.getBattleSS().getBattleActivePlayer().getName()); bat.setAttribute("phase", "" + game.getBattleSS().getBattlePhase().ordinal()); bat.setAttribute("summonState", "" + game.getBattleSS().getSummonState()); bat.setAttribute("carryDamage", "" + game.getBattleSS().getCarryDamage()); bat.setAttribute("preStrikeEffectsApplied", "" + game.getBattleSS().arePreStrikeEffectsApplied()); for (BattleHex hex : game.getBattleSS().getCarryTargets()) { Element ct = new Element("CarryTarget"); ct.addContent(hex.getLabel()); bat.addContent(ct); } root.addContent(bat); } /** * Generate the filename for autosaving (or just "Save" where one does * specify file name either) according to the pattern: * DIRECTORY/snap TIMESTAMP TURN-PLAYER-PHASE * * @return The file name/path, including directory */ private String makeAutosaveFileName() { Date date = new Date(); boolean withInfo = options.getOption(Options.autosaveVerboseNames, true); String infoPart = ""; if (withInfo) { String phaseInfo = game.getPhase().toString(); if (game.isPhase(Phase.FIGHT)) { int engagementsLeft = game.findEngagements().size(); phaseInfo += "-" + engagementsLeft + "moreEngagements"; } infoPart = "_" + game.getTurnNumber() + "-" + game.getActivePlayer() + "-" + phaseInfo; } String timeStamp = "" + date.getTime(); String name = Constants.SAVE_DIR_NAME + Constants.XML_SNAPSHOT_START + timeStamp + infoPart + Constants.XML_EXTENSION; if (firstAutosavefileTimestamp == null) { firstAutosavefileTimestamp = timeStamp; } return name; } /** * Ensure that saves/ directory in Colossus-home exists, or create it. * Throws IOException if creation fails. * * @throws IOException if the saves directory does not exist and creation * fails */ private void ensureSavesDirectory() throws IOException { File savesDir = new File(Constants.SAVE_DIR_NAME); if (!savesDir.exists() || !savesDir.isDirectory()) { LOGGER.info("Trying to make directory " + Constants.SAVE_DIR_NAME); if (!savesDir.mkdirs()) { LOGGER.log(Level.SEVERE, "Could not create saves directory " + Constants.SAVE_DIR_NAME); throw new IOException("Could not create saves directory " + Constants.SAVE_DIR_NAME); } } } /** * Produce one "automatically generated file name" for saving games, * including directory handling: * * 1) Creates the save game directory if it does not exist yet, * including error handling. * 2) Generates an "automatic" file name (both for autoSave and File-Save) * 3) if it is autosave and the option to keep only a limited number of * autosave files, add it to the list of autosave file names * * @param filename User specified filename, null for autosave or File-Save * @param autoSave Whether or not this was triggered by autosave * @param keep How many autosave files to keep, 0 for "keep all" * @return The automatically generated file name */ private String automaticFilenameHandling(final String filename, boolean autoSave, int keep) { String autosaveFilename = makeAutosaveFileName(); if (autoSave) { // Real autosave LOGGER.finest("Autosaving game to " + autosaveFilename); if (keep > 0) { autoGeneratedFiles.add(autosaveFilename); } } else { // File-Save (without providing a name) from File menu LOGGER.finest("File-Save saving the game to " + autosaveFilename); } return autosaveFilename; } /** * High-level method to save a file. Used for all three cases: Auto-save, * User specified file and File-Save (without specified file name). * * @param filename user specified filename, null for auto-save or File-Save * @param autoSave Whether or not this is autoSave * @throws IOException if the saves directory (for autosave or File-Save) * does not exist and creation fails */ private synchronized void saveGame(final String filename, boolean autoSave) throws IOException { int keep = options.getIntOption(Options.autosaveMaxKeep); String fn = null; if (filename == null || filename.equals("null")) { // Might throw IOException if directory can't be created ensureSavesDirectory(); fn = automaticFilenameHandling(filename, autoSave, keep); // automaticFilenameHandling did the logging already } else { fn = filename; LOGGER.info("Saving game to user-provided file name " + filename); } FileWriter fileWriter; PrintWriter out; try { fileWriter = new FileWriter(fn); out = new PrintWriter(fileWriter); } catch (IOException e) { LOGGER.log(Level.SEVERE, "Couldn't open " + fn, e); return; } // Not here any more. Should now be taken at begin of each phase. // takeSnapshotAtBeginOfPhase(); Element root = createSavegameContent(); Document doc = new Document(root); // Now write it all out to the file try { XMLOutputter putter = new XMLOutputter(Format.getPrettyFormat()); putter.output(doc, out); fileWriter.close(); if ((filename == null || filename.equals("null")) && keep > 0) { while (autoGeneratedFiles.size() > keep) { String delfilename = autoGeneratedFiles.remove(0); File fileToDelete = new File(delfilename); boolean success = fileToDelete.delete(); if (!success) { LOGGER.warning("Failed to delete autosave file " + delfilename + "!"); } } } } catch (IOException ex) { LOGGER.log(Level.SEVERE, "Error writing XML savegame.", ex); } } // ===================================================================== // And here it comes, the actual method that is called by GameServerSide // ===================================================================== /** * Call saveGame in a try-catch block. If any exception is caught, * log it, show an error dialog, and additionally if this was triggered * by autosave, disable the autosave from now on. * * @param filename The name of the file to create * @param autoSave True if this was triggered by autoSave */ void saveGameWithErrorHandling(final String filename, boolean autoSave) { try { saveGame(filename, autoSave); } catch (Exception e) { String autosaveNowOffMmessage = ""; if (autoSave) { options.setOption(Options.autosave, false); autosaveNowOffMmessage = " (autosave now disabled)"; } String doWhat = autoSave ? "auto-save" : "save"; String toWhere = filename == null ? "<automatically generated filename>" : (" file " + filename); String message = "Woooah! An exception was caught while " + "trying to " + doWhat + " game to " + toWhere + "\nStack trace:\n" + ErrorUtils.makeStackTraceString(e) + "\nSaving the game did probably not succeed" + autosaveNowOffMmessage + ".\n"; LOGGER.warning(message); ErrorUtils.showExceptionDialog(null, message, "Exception caught during saving!", false); } } private String makeIscName() { return Constants.ISC_FILE_START + firstAutosavefileTimestamp + Constants.ISC_FILE_EXTENTION; } /** * Prepare/create the file for the internal spectator client, * so that later commit messages in the spectators ClientHandler * upon "commit" can store messages to that file (and remove from * re-send queue). * * @return A PrintWriter for that file, or null if creation failed */ public PrintWriter createIscmFile() { PrintWriter iscFile = null; iscmName = makeIscName(); String iscmFullName = Constants.SAVE_DIR_NAME + iscmName; LOGGER.info("Creating iscm file " + iscmFullName); try { iscFile = new PrintWriter(new FileWriter(iscmFullName)); } catch (IOException e) { LOGGER.log(Level.SEVERE, "Couldn't open iscm-File " + iscmFullName, e); iscmName = null; return null; } return iscFile; } }