package net.nationstatesplusplus.assembly; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Matcher; import net.nationstatesplusplus.assembly.model.HappeningType; import net.nationstatesplusplus.assembly.model.websocket.DataRequest; import net.nationstatesplusplus.assembly.model.websocket.PageType; import net.nationstatesplusplus.assembly.model.websocket.RequestType; import net.nationstatesplusplus.assembly.util.DatabaseAccess; import net.nationstatesplusplus.assembly.util.Utils; import org.apache.commons.dbutils.DbUtils; import org.apache.commons.lang.WordUtils; import org.apache.http.conn.HttpHostConnectException; import org.joda.time.Duration; import play.Logger; import play.libs.Json; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.limewoodMedia.nsapi.NationStates; import com.limewoodMedia.nsapi.exceptions.RateLimitReachedException; import com.limewoodMedia.nsapi.holders.HappeningData; import com.limewoodMedia.nsapi.holders.HappeningData.EventHappening; import com.mchange.v2.c3p0.ComboPooledDataSource; public class HappeningsTask implements Runnable { private final NationStates api; private final ComboPooledDataSource pool; private final DatabaseAccess access; private int maxEventId = -1; private int newEvents = 0; private int newEventSanityCounter = 0; private final Cache<Integer, Boolean> updateCache = CacheBuilder.newBuilder().maximumSize(250).expireAfterWrite(1, TimeUnit.MINUTES).build(); /** * A counter, when set > 0, runs happening update polls at 2s intervals, otherwise at 10s intervals. */ private final AtomicInteger highActivity = new AtomicInteger(1); private final static Cache<String, Boolean> puppetCache = CacheBuilder.newBuilder().maximumSize(1000).expireAfterWrite(5, TimeUnit.MINUTES).build(); public HappeningsTask(DatabaseAccess access, NationStates api) { this.api = api; this.pool = access.getPool(); this.access = access; try (Connection conn = pool.getConnection()) { try (PreparedStatement state = conn.prepareStatement("SELECT last_event_id FROM assembly.settings WHERE id = 1")) { try (ResultSet result = state.executeQuery()) { result.next(); maxEventId = result.getInt(1); } } } catch (SQLException e) { throw new RuntimeException(e); } } public static void markNationAsPuppet(String nation) { puppetCache.put(nation, true); } public static boolean isPuppetNation(String nation) { return puppetCache.getIfPresent(nation) != null; } private String parseHappening(String text) { int index = text.indexOf("%rmb%%"); if (index > -1) { int start = text.indexOf("%% %%"); text = text.substring(0, start + 3) + "##" + text.substring(start + 5, index) + "##" + text.substring(index + 6); } return text; } @Override public void run() { try { runImpl(); } catch (Exception e) { Logger.error("Exception processing happenings", e); highActivity.incrementAndGet(); } } public void runImpl() { Logger.info("Starting Happenings Task: " + maxEventId); HappeningData data; synchronized (api) { try { data = api.getHappeningInfo(null, -1, maxEventId); } catch (RateLimitReachedException e) { Logger.warn("Happenings monitoring rate limited!"); return; } catch (RuntimeException e) { if (e.getCause() instanceof HttpHostConnectException) { //NS may be down or under high load Logger.warn("Happenings monitoring failed to connect to NationStates.net!", e.getCause()); return; } else { Logger.error("Unhandled Exception monitoring happenings", e); return; } } newEvents = 0; final int oldEventId = maxEventId; Logger.info("Executing global happenings run. Max Event ID: " + maxEventId); for (EventHappening happening : data.happenings) { //Set the max id to the largest event id if (maxEventId < happening.eventId) { maxEventId = happening.eventId; } if (oldEventId < happening.eventId) { newEvents++; } } //Sanity check in case some genius decides to reset the event id sequence. if (newEvents == 0) { newEventSanityCounter++; } else { newEventSanityCounter = 0; } if (newEventSanityCounter > 60) { Logger.warn("HAPPENING EVENT IDS OUT OF SEQUENCE - RESETTING MAX EVENT ID!"); maxEventId = 0; } } try (Connection conn = pool.getConnection()) { try (PreparedStatement state = conn.prepareStatement("UPDATE assembly.settings SET last_event_id = ? WHERE id = 1")) { state.setInt(1, maxEventId); state.executeUpdate(); } try (PreparedStatement happeningInsert = conn.prepareStatement("INSERT INTO assembly.global_happenings (nation, happening, timestamp, type) VALUES (?, ?, ?, ?)", Statement.RETURN_GENERATED_KEYS)) { for (EventHappening happening : data.happenings) { final String text = happening.text; final long timestamp = happening.timestamp * 1000; Matcher match = Utils.NATION_PATTERN.matcher(text); int nationId = -1; String nation = ""; if (match.find()) { String title = text.substring(match.start() + 2, match.end() - 2); nation = Utils.sanitizeName(title); nationId = access.getNationId(nation); if (nationId == -1) { try (PreparedStatement insert = conn.prepareStatement("INSERT INTO assembly.nation (name, title, full_name, region, first_seen, wa_member) VALUES (?, ?, ?, ?, ?, 2)", Statement.RETURN_GENERATED_KEYS)) { insert.setString(1, nation); insert.setString(2, title); insert.setString(3, WordUtils.capitalizeFully(nation.replaceAll("_", " "))); insert.setInt(4, -1); insert.setLong(5, happening.timestamp); insert.executeUpdate(); try (ResultSet keys = insert.getGeneratedKeys()) { keys.next(); nationId = keys.getInt(1); access.getNationIdCache().put(nation, nationId); } } } } final int happeningType = HappeningType.match(text); final HappeningType type = HappeningType.getType(happeningType); if (happeningType == HappeningType.getType("ENDORSEMENT").getId()) { if (match.find()) { String title = text.substring(match.start() + 2, match.end() - 2); String otherNation = Utils.sanitizeName(title); addEndorsement(conn, access.getNationId(otherNation), nationId); //Add *was endorsed by* to db happeningInsert.setInt(1, access.getNationId(otherNation)); happeningInsert.setString(2, "@@" + otherNation + "@@ was endorsed by @@" + nation + "@@."); happeningInsert.setLong(3, timestamp); happeningInsert.setInt(4, happeningType); happeningInsert.executeUpdate(); HashMap<String, Object> dataRequest = new HashMap<String, Object>(); dataRequest.put("nation", access.getNationId(otherNation)); access.getWebsocketManager().onUpdate(PageType.NATION, RequestType.NATION_HAPPENINGS, new DataRequest(RequestType.NATION_HAPPENINGS, dataRequest), Json.toJson("{ }")); } } else if (happeningType == HappeningType.getType("WITHDREW_ENDORSEMENT").getId()) { if (match.find()) { String title = text.substring(match.start() + 2, match.end() - 2); String otherNation = Utils.sanitizeName(title); removeEndorsement(conn, access.getNationId(otherNation), nationId); } } else if (happeningType == HappeningType.getType("LOST_ENDORSEMENT").getId()) { if (match.find()) { String title = text.substring(match.start() + 2, match.end() - 2); String otherNation = Utils.sanitizeName(title); removeEndorsement(conn, nationId, access.getNationId(otherNation)); } } else if (happeningType == HappeningType.getType("RESIGNED_FROM_WORLD_ASSEMBLY").getId()) { resignFromWorldAssembly(conn, nationId, false); } else if (happeningType == HappeningType.getType("ADMITTED_TO_WORLD_ASSEMBLY").getId()) { joinWorldAssembly(conn, nationId); } else if (happeningType == HappeningType.getType("EJECTED_FOR_RULE_VIOLATIONS").getId()) { resignFromWorldAssembly(conn, nationId, true); } else if (happeningType == HappeningType.getType("ABOLISHED_REGIONAL_FLAG").getId()) { abolishRegionFlag(conn, access, text); } else if (happeningType == HappeningType.getType("RELOCATED").getId()) { relocateNation(conn, nationId, nation, text); } else if (updateCache.getIfPresent(nationId) == null && happeningType == HappeningType.getType("NEW_LEGISLATION").getId()) { setRegionUpdateTime(conn, nationId, timestamp); updateCache.put(nationId, true); } else if (happeningType == HappeningType.getType("RMB").getId()) { Matcher regions = Utils.REGION_PATTERN.matcher(text); if (regions.find()) { final int regionId = access.getRegionId(text.substring(regions.start() + 2, regions.end() - 2)); if (regionId > -1) { HashMap<String, Object> dataRequest = new HashMap<String, Object>(); dataRequest.put("region", regionId); access.getWebsocketManager().onUpdate(PageType.REGION, RequestType.RMB_MESSAGE, new DataRequest(RequestType.RMB_MESSAGE, dataRequest), Json.toJson("{ }")); } } } else if (nationId > -1 && (happeningType == HappeningType.getType("REFOUNDED").getId() || happeningType == HappeningType.getType("FOUNDED").getId())) { if (happeningType == HappeningType.getType("REFOUNDED").getId()) { //Ensure nation is dead access.markNationDead(nationId, conn); //Only erase flag if it was user uploaded final boolean eraseFlag; try (PreparedStatement flag = conn.prepareStatement("SELECT flag FROM assembly.nation WHERE id = ?")) { flag.setInt(1, nationId); try (ResultSet set = flag.executeQuery()) { eraseFlag = set.next() && set.getString(1).contains("/uploads/"); } } try (PreparedStatement alive = conn.prepareStatement("UPDATE assembly.nation SET alive = 1, wa_member = 2" + (eraseFlag ? ", flag = ?" : "") + " WHERE id = ?")) { if (eraseFlag) { alive.setString(1, "//nationstates.net/images/flags/Default.png"); alive.setInt(2, nationId); } else { alive.setInt(1, nationId); } alive.executeUpdate(); } } //Update region Matcher regions = Utils.REGION_PATTERN.matcher(text); if (regions.find()) { final int regionId = access.getRegionId(text.substring(regions.start() + 2, regions.end() - 2)); if (regionId > -1) { try (PreparedStatement update = conn.prepareStatement("UPDATE assembly.nation SET region = ?, wa_member = 2, puppet = ? WHERE id = ?")) { update.setInt(1, regionId); update.setInt(2, puppetCache.getIfPresent(nation) != null ? 1 : 0); update.setInt(3, nationId); update.executeUpdate(); } puppetCache.invalidate(nation); } } } else if (nationId > -1 && happeningType == HappeningType.getType("CEASED_TO_EXIST").getId()) { access.markNationDead(nationId, conn); } happeningInsert.setInt(1, nationId); happeningInsert.setString(2, parseHappening(text)); happeningInsert.setLong(3, timestamp); happeningInsert.setInt(4, happeningType); happeningInsert.executeUpdate(); try (ResultSet keys = happeningInsert.getGeneratedKeys()) { keys.next(); int happeningId = keys.getInt(1); if (type != null) { updateRegionHappenings(conn, access, nationId, happeningId, text, type); } } HashMap<String, Object> dataRequest = new HashMap<String, Object>(); dataRequest.put("nation", nationId); access.getWebsocketManager().onUpdate(PageType.NATION, RequestType.NATION_HAPPENINGS, new DataRequest(RequestType.NATION_HAPPENINGS, dataRequest), Json.toJson("{ }")); } } } catch (SQLException e) { Logger.error("Unable to update happenings", e); } } public static void abolishRegionFlag(Connection conn, DatabaseAccess access, String happening) throws SQLException { Matcher regions = Utils.REGION_PATTERN.matcher(happening); if (regions.find()) { int region = access.getRegionId(happening.substring(regions.start() + 2, regions.end() - 2)); if (region > -1) { PreparedStatement updateFlag = conn.prepareStatement("UPDATE assembly.region SET flag = ? WHERE id = ?"); updateFlag.setString(1, ""); updateFlag.setInt(2, region); updateFlag.executeUpdate(); DbUtils.closeQuietly(updateFlag); } } } public static void updateRegionHappenings(Connection conn, DatabaseAccess access, int nationId, int happeningId, String happening, HappeningType type) throws SQLException { String region1Happening = type.transformToRegion1Happening(happening); String region2Happening = type.transformToRegion2Happening(happening); List<Integer> regionIds = new ArrayList<Integer>(2); Matcher regions = Utils.REGION_PATTERN.matcher(happening); while(regions.find()) { regionIds.add(access.getRegionId(happening.substring(regions.start() + 2, regions.end() - 2))); } if (regionIds.size() == 0 && nationId > -1) { try (PreparedStatement select = conn.prepareStatement("SELECT region FROM assembly.nation WHERE id = ?")) { select.setInt(1, nationId); try (ResultSet result = select.executeQuery()) { if (result.next()) { regionIds.add(result.getInt(1)); } } } } try (PreparedStatement insert = conn.prepareStatement("INSERT INTO assembly.regional_happenings (global_id, region, happening) VALUES (?, ?, ?)")) { if (region1Happening != null && regionIds.size() > 0) { insert.setInt(1, happeningId); insert.setInt(2, regionIds.get(0)); insert.setString(3, region1Happening); insert.executeUpdate(); HashMap<String, Object> dataRequest = new HashMap<String, Object>(); dataRequest.put("region", regionIds.get(0)); access.getWebsocketManager().onUpdate(PageType.REGION, RequestType.REGION_HAPPENINGS, new DataRequest(RequestType.REGION_HAPPENINGS, dataRequest), Json.toJson("{ }")); } if (region2Happening != null && regionIds.size() > 1) { insert.setInt(1, happeningId); insert.setInt(2, regionIds.get(1)); insert.setString(3, region2Happening); insert.executeUpdate(); HashMap<String, Object> dataRequest = new HashMap<String, Object>(); dataRequest.put("region", regionIds.get(1)); access.getWebsocketManager().onUpdate(PageType.REGION, RequestType.REGION_HAPPENINGS, new DataRequest(RequestType.REGION_HAPPENINGS, dataRequest), Json.toJson("{ }")); } } } private synchronized void setRegionUpdateTime(Connection conn, int nationId, long timestamp) throws SQLException { final int region = getRegionOfNation(conn, nationId); if (region != -1) { PreparedStatement select = null, update = null, insert = null; ResultSet result = null; try { select = conn.prepareStatement("SELECT id, start, end FROM assembly.region_updates WHERE region = ? AND start BETWEEN ? AND ?"); select.setInt(1, region); select.setLong(2, timestamp - Duration.standardHours(1).getMillis()); select.setLong(3, timestamp + Duration.standardHours(1).getMillis()); result = select.executeQuery(); if (result.next()) { final int id = result.getInt(1); final long start = result.getLong(2); final long end = result.getLong(3); update = conn.prepareStatement("UPDATE assembly.region_updates SET start = ?, end = ? WHERE id = ?"); update.setLong(1, Math.min(start, timestamp)); update.setLong(2, Math.max(end, timestamp)); update.setInt(3, id); update.executeUpdate(); } else { insert = conn.prepareStatement("INSERT INTO assembly.region_updates (region, start, end) VALUES (?, ?, ?)"); insert.setLong(1, region); insert.setLong(2, timestamp); insert.setLong(3, timestamp); insert.executeUpdate(); } } finally { DbUtils.closeQuietly(result); DbUtils.closeQuietly(insert); DbUtils.closeQuietly(update); DbUtils.closeQuietly(select); } } else { Logger.info("Can not set region update time for nation [" + nationId + "], unknown region!"); } } private int getRegionOfNation(Connection conn, int nationId) throws SQLException { PreparedStatement select = null; ResultSet result = null; try { select = conn.prepareStatement("SELECT region FROM assembly.nation WHERE id = ?"); select.setInt(1, nationId); result = select.executeQuery(); if (result.next()) { return result.getInt(1); } return -1; } finally { DbUtils.closeQuietly(result); DbUtils.closeQuietly(select); } } private void relocateNation(Connection conn, int nationId, String nation, String happening) throws SQLException { Matcher match = Utils.REGION_PATTERN.matcher(happening); String prevRegion = null; String newRegion = null; if (match.find()) { String title = happening.substring(match.start() + 2, match.end() - 2); prevRegion = Utils.sanitizeName(title); } if (match.find()) { String title = happening.substring(match.start() + 2, match.end() - 2); newRegion = Utils.sanitizeName(title); } Logger.debug("Relocating " + nation + " from " + prevRegion + " to " + newRegion); if (prevRegion != null && newRegion != null) { //Double check they are still at their prev region before setting their new region! int newRegionId = getOrCreateRegion(conn, nation, newRegion); PreparedStatement update = conn.prepareStatement("UPDATE assembly.nation SET region = ?, wa_member = 2 WHERE id = ? AND region = ?"); update.setInt(1, newRegionId); update.setInt(2, nationId); update.setInt(3, getOrCreateRegion(conn, nation, prevRegion)); update.executeUpdate(); DbUtils.closeQuietly(update); } } private int getOrCreateRegion(Connection conn, String nation, String region) throws SQLException { PreparedStatement select = null; ResultSet result = null; try { select = conn.prepareStatement("SELECT id FROM assembly.region WHERE name = ?"); select.setString(1, region); result = select.executeQuery(); if (result.next()) { return result.getInt(1); } PreparedStatement insert = null; ResultSet keys = null; try { insert = conn.prepareStatement("INSERT INTO assembly.region (name, flag, founder, title) VALUES (?, ?, ?, ?)", Statement.RETURN_GENERATED_KEYS); insert.setString(1, region); insert.setString(2, ""); insert.setString(3, nation); insert.setString(4, Utils.formatName(region)); insert.executeUpdate(); keys = insert.getGeneratedKeys(); keys.next(); int id = keys.getInt(1); access.getRegionIdCache().put(region, id); return id; } finally { DbUtils.closeQuietly(keys); DbUtils.closeQuietly(insert); } } finally { DbUtils.closeQuietly(result); DbUtils.closeQuietly(select); } } private void addEndorsement(Connection conn, int endorsed, int endorser) throws SQLException { PreparedStatement selectDuplicates = conn.prepareStatement("SELECT endorsed FROM assembly.endorsements WHERE endorsed = ? AND endorser = ?"); selectDuplicates.setInt(1, endorsed); selectDuplicates.setInt(2, endorser); if (!selectDuplicates.executeQuery().next()) { PreparedStatement endorsements = conn.prepareStatement("INSERT INTO assembly.endorsements (endorser, endorsed) VALUES (?, ?)"); endorsements.setInt(1, endorser); endorsements.setInt(2, endorsed); endorsements.executeUpdate(); DbUtils.closeQuietly(endorsements); } DbUtils.closeQuietly(selectDuplicates); } private void removeEndorsement(Connection conn, int endorsed, int endorser) throws SQLException { PreparedStatement endorsements = conn.prepareStatement("DELETE FROM assembly.endorsements WHERE endorsed = ? AND endorser = ?"); endorsements.setInt(1, endorsed); endorsements.setInt(2, endorser); endorsements.executeUpdate(); DbUtils.closeQuietly(endorsements); } private void resignFromWorldAssembly(Connection conn, int nationId, boolean banned) throws SQLException { PreparedStatement endorsements = conn.prepareStatement("UPDATE assembly.nation SET wa_member = " + (banned ? "0" : "2") + " WHERE id = ?"); endorsements.setInt(1, nationId); endorsements.executeUpdate(); DbUtils.closeQuietly(endorsements); } private void joinWorldAssembly(Connection conn, int nationId) throws SQLException { PreparedStatement endorsements = conn.prepareStatement("UPDATE assembly.nation SET wa_member = 1 WHERE id = ?"); endorsements.setInt(1, nationId); endorsements.executeUpdate(); DbUtils.closeQuietly(endorsements); } }