package org.ggp.base.util.game; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStreamReader; import java.io.PrintWriter; import java.security.MessageDigest; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; import external.JSON.JSONObject; /** * Cloud game repositories provide access to game resources stored on game * repository servers on the web, while continuing to work while the user is * offline through aggressive caching based on the immutability + versioning * scheme provided by the repository servers. * * Essentially, each game has a version number stored in the game metadata * file. Game resources are immutable until this version number changes, at * which point the game needs to be reloaded. Version numbers are passed along * and stored in the match descriptions, and repository servers will continue * to serve old versions when specifically requested, so it is valid to use any * historical game version when generating a match -- this is why we don't need * to worry about our offline cache becoming stale/invalid. However, to stay up * to date with the latest bugfixes, etc, we aggressively refresh the cache any * time we can connect to the repository server, as a matter of policy. * * Cached games are stored locally, in a directory managed by this class. These * files are compressed, to decrease their footprint on the local disk. GGP Base * has its SVN rules set up so that these caches are ignored by SVN. * * @author Sam */ public final class CloudGameRepository extends GameRepository { private final String theRepoURL; private final File theCacheDirectory; private static boolean needsRefresh = true; public CloudGameRepository(String theURL) { theRepoURL = RemoteGameRepository.properlyFormatURL(theURL); // Generate a unique hash of the repository URL, to use as the // local directory for files for the offline cache. StringBuilder theCacheHash = new StringBuilder(); try { byte[] bytesOfMessage = theRepoURL.getBytes("UTF-8"); MessageDigest md = MessageDigest.getInstance("MD5"); byte[] theDigest = md.digest(bytesOfMessage); for(int i = 0; i < theDigest.length; i++) { theCacheHash.append(Math.abs(theDigest[i])); } } catch(Exception e) { theCacheHash = null; } File theCachesDirectory = new File(System.getProperty("user.home"), ".ggpserver-gamecache"); theCachesDirectory.mkdir(); theCacheDirectory = new File(theCachesDirectory, "repoHash" + theCacheHash); if (theCacheDirectory.exists()) { // For existing caches, only force a full refresh at most once per day needsRefresh = (System.currentTimeMillis() - theCacheDirectory.lastModified()) > 86400000; } else { theCacheDirectory.mkdir(); needsRefresh = true; } if (needsRefresh) { Thread refreshThread = new RefreshCacheThread(theRepoURL); refreshThread.start(); // Update the game cache asynchronously if there are already games. // Otherwise, force a blocking update. if (theCacheDirectory.listFiles().length == 0) { try { refreshThread.join(); } catch (InterruptedException e) { ; } } theCacheDirectory.setLastModified(System.currentTimeMillis()); needsRefresh = false; } } @Override protected Set<String> getUncachedGameKeys() { Set<String> theKeys = new HashSet<String>(); for(File game : theCacheDirectory.listFiles()) { theKeys.add(game.getName().replace(".zip", "")); } return theKeys; } @Override protected Game getUncachedGame(String theKey) { Game cachedGame = loadGameFromCache(theKey); if (cachedGame != null) { return cachedGame; } // Request the game directly on a cache miss. return new RemoteGameRepository(theRepoURL).getGame(theKey); } // ================================================================ // Games are cached asynchronously in their own threads. class RefreshCacheForGameThread extends Thread { RemoteGameRepository theRepository; String theKey; public RefreshCacheForGameThread(RemoteGameRepository a, String b) { theRepository = a; theKey = b; } @Override public void run() { try { String theGameURL = theRepository.getGameURL(theKey); JSONObject theMetadata = RemoteGameRepository.getGameMetadataFromRepository(theGameURL); int repoVersion = theMetadata.getInt("version"); String versionedRepoURL = RemoteGameRepository.addVersionToGameURL(theGameURL, repoVersion); Game myGameVersion = loadGameFromCache(theKey); String myVersionedRepoURL = ""; if (myGameVersion != null) myVersionedRepoURL = myGameVersion.getRepositoryURL(); if (!versionedRepoURL.equals(myVersionedRepoURL)) { // Cache miss: we don't have the current version for // this game, and so we need to load it from the web. Game theGame = RemoteGameRepository.loadSingleGameFromMetadata(theKey, theGameURL, theMetadata); saveGameToCache(theKey, theGame); } } catch (Exception e) { e.printStackTrace(); } } } class RefreshCacheThread extends Thread { String theRepoURL; public RefreshCacheThread(String theRepoURL) { this.theRepoURL = theRepoURL; } @Override public void run() { try { // Sleep for the first two seconds after which the cache is loaded, // so that we don't interfere with the user interface startup. Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); return; } RemoteGameRepository remoteRepository = new RemoteGameRepository(theRepoURL); System.out.println("Updating the game cache..."); long beginTime = System.currentTimeMillis(); // Since games are immutable, we can guarantee that the games listed // by the repository server includes the games in the local cache, so // we can be happy just updating/refreshing the listed games. Set<String> theGameKeys = remoteRepository.getGameKeys(); if (theGameKeys == null) return; // If the server offers a single combined metadata file, download that // and use it to avoid checking games that haven't gotten new versions. JSONObject bundledMetadata = remoteRepository.getBundledMetadata(); if (bundledMetadata != null) { Set<String> unchangedKeys = new HashSet<String>(); for (String theKey : theGameKeys) { try { Game myGameVersion = loadGameFromCache(theKey); if (myGameVersion == null) continue; String remoteGameURL = remoteRepository.getGameURL(theKey); int remoteVersion = bundledMetadata.getJSONObject(theKey).getInt("version"); String remoteVersionedGameURL = RemoteGameRepository.addVersionToGameURL(remoteGameURL, remoteVersion); // Skip updating the game cache entry if the version is the same // and the cache entry was written less than a week ago. if (myGameVersion.getRepositoryURL().equals(remoteVersionedGameURL) && getCacheEntryAge(theKey) < 604800000) { unchangedKeys.add(theKey); } } catch (Exception e) { continue; } } theGameKeys.removeAll(unchangedKeys); } // Start threads to update every entry in the cache (or at least verify // that the entry doesn't need to be updated). Set<Thread> theThreads = new HashSet<Thread>(); for (String gameKey : theGameKeys) { Thread t = new RefreshCacheForGameThread(remoteRepository, gameKey); t.start(); theThreads.add(t); } // Wait until we've updated the cache before continuing. for (Thread t : theThreads) { try { t.join(); } catch (InterruptedException e) { ; } } long endTime = System.currentTimeMillis(); System.out.println("Updating the game cache took: " + (endTime - beginTime) + "ms."); } } // ================================================================ private synchronized void saveGameToCache(String theKey, Game theGame) { if (theGame == null) return; File theGameFile = new File(theCacheDirectory, theKey + ".zip"); try { theGameFile.createNewFile(); FileOutputStream fOut = new FileOutputStream(theGameFile); GZIPOutputStream gOut = new GZIPOutputStream(fOut); PrintWriter pw = new PrintWriter(gOut); pw.print(theGame.serializeToJSON()); pw.flush(); pw.close(); gOut.close(); fOut.close(); } catch (Exception e) { e.printStackTrace(); } } private synchronized Game loadGameFromCache(String theKey) { File theGameFile = new File(theCacheDirectory, theKey + ".zip"); String theLine = null; try { FileInputStream fIn = new FileInputStream(theGameFile); GZIPInputStream gIn = new GZIPInputStream(fIn); InputStreamReader ir = new InputStreamReader(gIn); BufferedReader br = new BufferedReader(ir); theLine = br.readLine(); br.close(); ir.close(); gIn.close(); fIn.close(); } catch (Exception e) { ; } if (theLine == null) return null; return Game.loadFromJSON(theLine); } private synchronized long getCacheEntryAge(String theKey) { File theGameFile = new File(theCacheDirectory, theKey + ".zip"); if (theGameFile.exists()) { return System.currentTimeMillis() - theGameFile.lastModified(); } return System.currentTimeMillis(); } // ================================================================ public static void main(String[] args) { GameRepository theRepository = new CloudGameRepository("games.ggp.org/base"); long beginTime = System.currentTimeMillis(); Map<String, Game> theGames = new HashMap<String, Game>(); for(String gameKey : theRepository.getGameKeys()) { theGames.put(gameKey, theRepository.getGame(gameKey)); } System.out.println("Games: " + theGames.size()); long endTime = System.currentTimeMillis(); System.out.println("Time: " + (endTime - beginTime) + "ms."); } }