package net.kimapr.JiraSnake import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate import com.atlassian.jira.component.ComponentAccessor import com.atlassian.sal.api.user.UserManager import com.atlassian.jira.user.ApplicationUser import com.atlassian.jira.util.thread.JiraThreadLocalUtilImpl import groovy.transform.BaseScript import groovy.transform.CompileStatic import javax.ws.rs.core.MultivaluedMap import javax.ws.rs.core.Response import javax.servlet.http.HttpServletRequest import org.apache.log4j.Logger import groovy.json.JsonBuilder import groovy.json.JsonSlurper import java.util.concurrent.BlockingQueue import java.util.concurrent.LinkedBlockingQueue import java.util.HashMap import java.util.ArrayList import java.util.stream.Stream import java.util.Collections import java.util.concurrent.TimeUnit @BaseScript CustomEndpointDelegate delegate // NOTE: it is highly recommended to set the logging level of the // `net.kimapr.JiraSnake` package to INFO in your JIRA/Log4j settings. // // See https://confluence.atlassian.com/adminjiraserver/logging-and-profiling-938847671.html // // With larger amounts of simultaneous players, the game may reach and exceed // the maximum limit of request handler threads enforced by JIRA. // To avoid this, you can adjust the thread limit in server.xml (using the // maxThreads option on Connector), or adjust the maximum player and poller // counts accordingly (your maxThreads should fit MAX_PLAYERS * MAXPOLLERS // connections while still leaving some headroom on top of that). def gamerGroups = ["jira-software-users", "jira-administrators"] // Adjust the settings below to your liking ! public class SnakeSettings { // // board size, in cells // public static final int BOARD_WIDTH = 50; public static final int BOARD_HEIGHT = 50; // // no more than this many players at once // public static final int MAX_PLAYERS = 20; // // how fast the game goes; when sprinting players move at this many cells per second // public static final double TICKRATE = 15.0; // // slow down players by this factor; sprint simply undoes the slowdown // public static final int SPRINT_SPEED = 3; // // lose weight every this many steps while sprinting // public static final int SPRINT_STAMINA = 10; // // this many apples per board cell. too high = low performance! // public static final double APPLE_DENSITY = 0.01; // // allow no more than this many parallel poll requests per player // public static final int MAXPOLLERS = 12; // // rate-limit polling to this many updates per second, globally. // reduces CPU usage at cost of "choppier" gameplay with more players // public static final double POLLRATE = 100.0; } // This package implements a game of Snake in Atlassian JIRA as a web app. // // There are 3 main endpoints on the server: // // - GET /snake :: serves the client as an HTML page // - POST /snakePoll :: poll the server for events, return an array of events as JSON (may be empty) // - POST /snakeAct :: submit an action to the server as an 1-element JSON array of actions // // `snakePoll`'s response array will include objects with a `type` entry // identifying the type of the event as well as a number of other entries // representing event-specific data. The following event types are recognized: // // - `board`: only sent on the first poll; initializes the game. // // `width`: int :: board width, in cells // `height`: int :: board height, in cells // `tickrate`: number :: game updates per second // `maxpollers`: int :: maximum poll parallelism allowed // `data`: array :: indexed as `data[y*width+x]`, each element // represents a game cell (see BoardElem enum // for possible values). // Note that player body segments are not // included here and are instead communicated // via the dedicated `*player` events. // // - `newplayer`: declares either a newly joined player or one that has already // existed at first poll time. // // `id`: string :: unique per-player identifier // `name`: string :: the player name // `avatar`: string? :: player avatar URL // `body`: array :: from head to tail, each element is // a board index where a body segment resides // `self`: bool? :: set to `true` if this event declares the // polling player themself // // - `goneplayer`: an existing player died or otherwise disappeared from the game. // // `id`: string :: which player // // - `moveplayer`: a player moved across the board. // // `id`: string :: which player // `tail`: int :: amount of body segments to remove from the tail // `head`: array :: body segments to insert at the head // // - `deltaboard`: changes on the board occured. // // `data`: array> :: each element is a "tuple" (array) containing // a board index and the new game cell value. // // - `nogamer`: a general "you are not in the game" event. // when received while attempting to join it means the join failed // and an error should be displayed. // if it happens mid-game, the game probably deleted you while you weren't looking. // the v-connection is over, do not continue polling. // // - `takeover`: another instance of the client is establishing a new connection // and this one will terminate. // the v-connection is over, do not continue polling. // // - `toktaken`: sent on a first poll if the token is taken and connection // should be retried with another one. // // - `seq`: every response with this event present must be sequenced among each // other based on the `i` field, rather than processed immediately. // // `i`: int :: starts at 0, see `SnakeGame.Gamer.sequenceEvents` // for how the next value is computed. // `dur`: number :: how long (ms) this poller was idle before returning the events // // `snakeAct` receives actions in a similar format as what `snakePoll` returns // (but currently only one object, no more no less, may be present in the array). // Recognized actions: // // - `dir`: change snake direction // // `dir`: int :: the direction (see SnakeGame.DIRS) // // - `sprint`: change whether the snake is sprinting or not // // `sprint`: bool :: true if sprint // // A shared central game loop lives in its own thread and is launched // on-demand via the SnakeGame class. When no one is playing, the game loop // is automatically destroyed. Request handlers submit poll and action events // to this game loop's event queue using the `poll` and `post` public methods. // // To start the game, the client first establishes a "virtual connection" to // the game server by `snakePoll`-ing the server with the `join_game=1` query // parameter: // // POST /.../snakePoll?token=blahblah&join_game=1 // // This starts the central game loop if it's not already running, and joins the // (already authenticated by JIRA ScriptRunner itself) user to the game if they // are not already in the game. The `token` parameter is chosen freely by the // client and is used to identify the v-connection on a per-user basis // (different users cannot mess with each other by choosing the same token). // // Each user may only have one active v-connection at a time. If a v-connection // is already active and the new v-connection is being created with a different // token, a `{"type":"takeover"}` event is dispatched to the old v-connection, // which is then subsequently destroyed and replaced with the new one. If the // tokens are equal, the new v-connection is rejected with an // `{"type":"toktaken"}` event instead (the client then retries with a different token). // // Upon receiving a `takeover` event, the client ceases all activity and returns // to the main menu. This makes sure that each user can only run the game in one // browser tab at a time, and can always continue in a new tab if they lose their // existing one somewhere. // // As soon as the client starts, before any user interaction, it would poll the // server with the `join_game=0` query parameter: // // POST /.../snakePoll?token=blahblah&join_game=0 // // Unlike `join_game=1`, this does not start the game loop or join the user to // the game. Instead, it immediately returns a `nogamer` event if this is not // already the case. If the client receives a `nogamer` event here, it will // show the main menu, otherwise it will jump straight into the game. That way // if the player refreshes the page the game continues running uninterrupted. // // In either case, a successful first poll will include the `board` event to // initialize the game board as well as a `newplayer` event for every player // currently in the game, including the polling player themself. // // Once a v-connection is established, the client will start continuously polling // the server for new events, passing the same token parameter: // // POST /.../snakePoll?token=blahblah // POST /.../snakePoll?token=blahblah // POST /.../snakePoll?token=blahblah // // It is not only allowed but recommended to fire multiple poll requests at the // same time; the server will distribute events among them. Doing so allows the // client to mitigate the effects of network latency to long polling: when doing // polling sequentially, having the server send out an event leaves the client // unable to receive any more events until the event is received and another poll // request reaches the server (this takes an entire network round trip), limiting // the maximum possible polling rate to 1/RTT, which can be less than the rate // at which game events occur (usually the tickrate). Parallel polling effectively // multiplies the maximum polling rate by the degree of parallelism; as such, the // bigger your latency, the more parallelism you'll need to mitigate it. // // When polling in parallel, be sure to take `seq` events into account. Events must // always be processed in the order the server sends them, but parallel polling may // disrupt this and let the client receive responses out of order; `seq` allows the // client to reorder them back into the intended sequence. The `dur` field of this // event also allows the client to calculate RTT by subtracting the value from // the total response time. // // While a v-connection is active, the client may submit player actions in the // request body to `snakeAct`, passing the same token parameter: // // POST /.../snakeAct?token=blahblah // // If the server ceases to receive poll requests from a client for long enough, the // v-connection will be automatically terminated and the player deleted from the game. // As mentioned before, the game loop will then exit soon afterwards. @CompileStatic class SnakeGame { class Gamer { public byte id; public ArrayDeque body = new ArrayDeque(); public int length = 4; public int direction = (int)Math.floor(Math.random()*4); public ArrayDeque nextDirs = new ArrayDeque(); public ApplicationUser user; public String pollToken; public int pollCounter = 0; public boolean sprint = false; public int hunger = 0; public long viewBeginTime = -1; public Gamer(Map args) { user = args["user"]; pollToken = args["pollToken"]; } static class Poller { public long timeStarted; // System.nanoTime() public long timeOut; public BlockingQueue que; public void submit(Response r) { if (que == null) return; def nque = que; que = null; nque.put(r); } } public void sequenceEvents(Poller poller) { events.add([ type: 'seq', i: pollCounter, dur: (System.nanoTime() - poller.timeStarted) / (NANOS / 1000L) ]); pollCounter = (pollCounter + 1) % (SnakeSettings.MAXPOLLERS*2); } public ArrayList events = new ArrayList(); public ArrayDeque pollers = new ArrayDeque(); public int nextHead(int direction) { Vec2 hp = board.idxToPos(body.first()); def (int x,int y) = [hp.x, hp.y]; def (int dx,int dy) = [DIRS[direction].x, DIRS[direction].y]; (x, y) = [x + dx, y + dy]; return board.posToIdx(x, y); } public int nextHead() { return nextHead(direction); } public boolean isSprinting() { return sprint && body.size() > 4; } public boolean shouldMove() { if (body.isEmpty()) return false; if (isSprinting()) { return true; } return gametime % SnakeSettings.SPRINT_SPEED == 0; } public boolean checkDir(int direction) { if (direction < 0 || direction >= 4) return false; int elem = body.stream() .skip(1).limit(1).reduce(-1, (_,v) -> v); if (elem == -1) return true; if (nextHead(direction) != elem) return true; return false; } public String getName() { return this.user.getUsername(); } public Map introduceTo(Gamer other) { if (body.isEmpty()) return null; def avatarService = ComponentAccessor.avatarService; def v = [ type: "newplayer", id: user.getKey(), name: getName(), body: body.stream() .collect(ArrayList::new, ArrayList::add, ArrayList::addAll) ]; try { v.put("avatar", avatarService.getAvatarURL(other.user, user).toString()); } catch(Throwable e) { L.warn("Failed to get avatar! " +L.errorToString(e)); } if (other == this) v.put("self", true); return v; } public int discoTime = 0; } static enum BoardElem { NULL(0), AIR(1), APPLE(2), GAMER(64), GAMER2(65), GAMER3(66), GAMER4(67); private int id; private static BoardElem[] idmap = new BoardElem[256]; static { for (BoardElem e : BoardElem.values()) { idmap[e.id] = e; } } private BoardElem(int id) { this.id = id & 0xFF; } public byte getId() { return (byte)id; } public boolean isWall() { switch (this) { case NULL: return true; //case GAMER: return true; default: return false; } } public int gamerCount() { return (id >= 64 && id <= 67) ? (id - 64 + 1) : 0 } public int calories() { return this == APPLE ? 1 : 0; } public boolean isApple() { return this == APPLE; } public static BoardElem withGamers(int count) { if (count == 0) return AIR; if (count > 4 || count < 0) throw new IndexOutOfBoundsException(count); return getById((byte)(64 + count - 1)); } public static BoardElem getById(byte id) { return idmap[((int)id) & 0xFF]; } } static class Vec2 { public final int x; public final int y; public Vec2(int x, int y) { this.x = x; this.y = y; } public int getAt(int i) { if (i == 0) return x; else if (i == 1) return y; else throw new IndexOutOfBoundsException(i); } } static class Board { public final int width; public final int height; public byte[] board; public Board underlay = null; public Board(int width, int height) { this.width = width; this.height = height; this.board = new byte[width*height]; for (int i=0; i= width ) return -1; if (y < 0 || y >= height) return -1; return y*width+x; } public Vec2 idxToPos(int i) { return new Vec2((int)(i%width), (int)(i/width)); } public BoardElem get(int i) { if (i < 0 || i >= width*height) return BoardElem.NULL; if (underlay != null && board[i] == 0) return underlay.get(i); return BoardElem.getById(board[i]); } public BoardElem get(int x, int y) { return get(posToIdx(x,y)) } public void set(int i, BoardElem v) { if (i < 0 || i >= width*height) throw new IndexOutOfBoundsException(); board[i] = v.getId(); } public void set(int x, int y, BoardElem v) { set(posToIdx(x,y), v) } } static class Event {} static class TickEvent extends Event {} static class PollEvent extends Event { public Gamer.Poller poller; public ApplicationUser user; public String token; public String joinGame; } static class PostEvent extends Event { public List actions; public ApplicationUser user; public String token; public BlockingQueue res; public void submit(Response r) { if (res == null) return; def nque = res; res = null; nque.put(r); } } class QueuedUpdate { Gamer target; Map data; long gametime; public QueuedUpdate(Map init) { target = init["target"]; data = init["data"]; gametime = SnakeGame.this.gametime; } } private static final double TICKRATE = SnakeSettings.TICKRATE; private static final int NOGAMER_TIMEOUT = (int)(1.0 * TICKRATE); private static final int DISCO_TIMEOUT = (int)(10.0 * TICKRATE); private static final int DEAD_DISCO_TIMEOUT = (int)(2.0 * TICKRATE); private static final long NANOS = 1000000000L; private static final long POLLTIME_MIN = (long)(33L*NANOS/10L); private static final long POLLTIME_MAX = (long)(67L*NANOS/10L); private static final Vec2[] DIRS; static { DIRS = new Vec2[4]; def (int x, int y) = [1, 0]; for (int i=0; i<4; i++) { DIRS[i] = new Vec2(x,y); (x,y) = [-y,x]; } } private int targetAppleCount() { //return 5 + gamers.size() * 2; return (int)((board.width*board.height) * SnakeSettings.APPLE_DENSITY); } private void replenishApples(Closure f) { if (board.width > 8 && board.height > 8) { int strikes = 0; while (apples < targetAppleCount()) { int x, y; x = (int)(2.0 + Math.random() * (board.width - 4)); y = (int)(2.0 + Math.random() * (board.height - 4)); if (board.get(x, y) == BoardElem.AIR) { strikes = 0; int p = board.posToIdx(x, y); f(p, BoardElem.APPLE); apples++; } else { strikes++; if (strikes >= (gamers.size()+1)*8) break; } } } if (apples >= targetAppleCount()) return; /* List freeSpace = Stream .iterate(0, v -> v + 1) .limit(board.width * board.height) .filter(i -> board.get(i) == BoardElem.AIR) .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); Collections.shuffle(freeSpace); while (apples < targetAppleCount() && !freeSpace.isEmpty()) { f((int)freeSpace.remove(freeSpace.size() - 1), BoardElem.APPLE); apples++; } */ } public boolean isDead() { return !alive; } private boolean createUser(ApplicationUser user, String token = null) { if (gamers.size() >= SnakeSettings.MAX_PLAYERS) { return false; } Gamer gamer = new Gamer( user: user, pollToken: token, ); boolean success = false; for (int i=0; i<(gamers.size()+1)*64; i++) { int x; int y; x = (int)(Math.random() * board.width); y = (int)(Math.random() * board.height); if (board.get(x, y) != BoardElem.AIR) continue; boolean strict = i < (gamers.size()+1)*16 && board.width > 16 && board.height > 16; int bestdir = -1; int dist = -1; for (Integer dir : ({ List nl = [0,1,2,3]; Collections.shuffle(nl); nl })()) { def (int dx,int dy) = [DIRS[dir].x,DIRS[dir].y]; def (int nx,int ny) = [x+dx,y+dy]; for (int j=1; j<=16; j++) { BoardElem v = board.get(nx, ny); if (v.isWall() || v.gamerCount() > 0) break; if (j > dist) { dist = j; bestdir = dir; } (nx, ny) = [nx+dx,ny+dy]; } } if (strict) { if (dist < 8) continue; dist = Integer.MAX_VALUE; for (Gamer otherGamer : gamers.values()) { if (otherGamer.body.isEmpty()) continue; Vec2 op = board.idxToPos(otherGamer.body.first()); def (int ox, int oy) = [op.x, op.y]; dist = Integer.min(Integer.max(Integer.max(ox-x, oy-y), Integer.max(x-ox, y-oy)), dist); } if (dist < 8) continue; } success = true; if (bestdir != -1) { gamer.direction = bestdir; } board.set(x, y, BoardElem.GAMER); gamer.body.add(board.posToIdx(x, y)); break; } if (!success) return false; for (Gamer otherGamer : gamers.values()) { def intro = gamer.introduceTo(otherGamer) if (intro == null) continue; globalEvents.add(new QueuedUpdate( target: otherGamer, data: intro )); } pushEvents(); L.info("New Gamer \""+gamer.getName()+"\"["+gamer.length+"]"); gamers.put(user, gamer); return true; } private void pushEvents() { if (!globalEvents.isEmpty()) { long nextPoll = System.nanoTime(); double pollDt = (nextPoll - lastPoll) / (double)NANOS; lastPoll = nextPoll; pollrateAccum += pollDt; double pollStep = (1.0 / SnakeSettings.POLLRATE) * gamers.size(); pollrateAccum = Math.min(pollrateAccum, pollStep * 2.0); L.debug('pollDt('+pollDt+'),\tpollStep('+pollStep+'),\tpollrateAccum('+pollrateAccum+')'); if (pollrateAccum >= pollStep) { pollrateAccum -= pollStep; for (QueuedUpdate event : globalEvents) { if (event.target != null) { if (event.target.viewBeginTime > event.gametime) continue; event.target.events.add(event.data); } else { for (Gamer gamer : gamers.values()) { if (gamer.viewBeginTime > event.gametime) continue; gamer.events.add(event.data); } } } globalEvents.clear(); for (Gamer gamer : gamers.values()) { if (gamer.events.isEmpty()) continue; def poller = gamer.pollers.poll(); if (poller == null) continue; gamer.sequenceEvents(poller); poller.submit(Response.ok((new JsonBuilder(gamer.events as List)).toString(), 'application/json').build()); L.debug("Gamer \""+gamer.getName()+"\"["+gamer.length+"] rmved poller["+gamer.pollers.size()+"]"); gamer.events.clear(); } pollsPushed++; } else { pollsSkipped++; } } } private void onTick() { Board vboard = new Board(board); List> boardDiff = []; HashSet tailed = new HashSet<>(); def diffWrite = { int i, BoardElem v -> def (was, will) = [board.get(i), v]; if (was == BoardElem.GAMER) was = BoardElem.AIR; if (will == BoardElem.GAMER) will = BoardElem.AIR; if (was != will) boardDiff.add(Arrays.asList(i, will.getId())); board.set(i, v); }; for (gamer : gamers.values()) { if (!gamer.shouldMove()) continue; if (!gamer.nextDirs.isEmpty()) { int dir = gamer.nextDirs.remove(); if (gamer.checkDir(dir)) { gamer.direction = dir; } } } // to ensure that the result of gamer movement does not depend on // iteration order, a careful multi-step process is needed. // we create an alternate copy of the board called "virtualspace", // which is used as a scratchpad for logic without affecting the real // board ("realspace"). the steps are as follows: // shift back gamer tails in virtualspace for (gamer : gamers.values()) { if (!gamer.shouldMove()) continue; if (gamer.length + board.get(gamer.nextHead()).calories() <= gamer.body.size()) { vboard.set(gamer.body.last(), BoardElem.withGamers(vboard.get(gamer.body.last()).gamerCount() - 1)); tailed.add(gamer); } } // push gamer heads forward in virtualspace. // if there is already a gamer in destination, create a multi-gamer cell for (gamer : gamers.values()) { if (!gamer.shouldMove()) continue; if (!vboard.get(gamer.nextHead()).isWall()) vboard.set(gamer.nextHead(), BoardElem.withGamers(vboard.get(gamer.nextHead()).gamerCount() + 1)) } // mark gamers whose head overlaps with another gamer (is a multi-gamer cell) for killing // mark the rest of gamers for moving List toKill = []; List toMove = []; for (gamer : gamers.values()) { if (!gamer.shouldMove()) continue; if (vboard.get(gamer.nextHead()).isWall() || vboard.get(gamer.nextHead()).gamerCount() > 1) toKill.add(gamer); else toMove.add(gamer); } // erase dead gamers from virtualspace for (gamer : toKill) { Stream body = gamer.body.stream(); if (tailed.contains(gamer)) body = body.limit(gamer.body.size() - 1) if (vboard.get(gamer.nextHead()).gamerCount() > 0) body = Stream.concat([gamer.nextHead()].stream(), body); for (int idx : (Iterable) () -> body.iterator()) vboard.set(idx, BoardElem.withGamers(vboard.get(idx).gamerCount() - 1)); } // REALSPACE: move gamers (tail) for (gamer : toMove) { def el = board.get(gamer.nextHead()) gamer.length += el.calories(); gamer.body.addFirst(gamer.nextHead()); if (board.get(gamer.body.first()).isApple()) { L.info("Gamer \""+gamer.getName()+"\"["+gamer.length+"] ate an APPLE"); apples--; } if (tailed.contains(gamer)) { diffWrite(gamer.body.last(), BoardElem.AIR); gamer.body.removeLast(); } } // REALSPACE: move gamers (head) for (gamer : toMove) { def farted = false; if (gamer.isSprinting()) { gamer.hunger++; if (gamer.hunger >= SnakeSettings.SPRINT_STAMINA) { gamer.hunger = 0; def trep = Math.random() < 0.8 ? BoardElem.APPLE : BoardElem.AIR; diffWrite(gamer.body.last(), trep); if (trep == BoardElem.APPLE) apples++; gamer.body.removeLast(); gamer.length--; L.info("Gamer \""+gamer.getName()+"\"["+gamer.length+"] lost weight to exercise"+(trep == BoardElem.APPLE ? " (APPLE placed)" : "")); farted = true; } } diffWrite(gamer.body.first(), BoardElem.GAMER); globalEvents.add(new QueuedUpdate( target: null, data: [ type: 'moveplayer', id: gamer.user.getKey(), tail: (tailed.contains(gamer) ? 1 : 0) + (farted ? 1 : 0), head: [gamer.body.first()] ] )); } // REALSPACE: kill gamers, placing apples in their place for (gamer : toKill) { for (idx : gamer.body) { if (vboard.get(idx) != BoardElem.AIR) continue; def el = Math.random() < 0.33 ? BoardElem.APPLE : BoardElem.AIR; diffWrite(idx, el); if (el == BoardElem.APPLE) apples++; } gamer.body.clear(); L.info("Gamer \""+gamer.getName()+"\"["+gamer.length+"] DIED due to head damage"); gamer.length = 0; globalEvents.add(new QueuedUpdate( target: null, data: [ type: 'goneplayer', id: gamer.user.getKey() ] )); } replenishApples(diffWrite); List timedOutGamers = []; for (gamer : gamers.values()) { List toRemove = []; for (poller : gamer.pollers) { if (System.nanoTime() - poller.timeStarted >= poller.timeOut) toRemove.add(poller); } for (poller : toRemove) { poller.submit(Response.ok('[]', 'application/json').build()); gamer.pollers.remove(poller); L.debug("Gamer \""+gamer.getName()+"\"["+gamer.length+"] rmved poller["+gamer.pollers.size()+"] due to non-event"); } if (gamer.pollers.isEmpty()) { if (gamer.discoTime == 0) { L.debug("Gamer \""+gamer.getName()+"\"["+gamer.length+"] is out of pollers!"); } gamer.discoTime++; } else { gamer.discoTime = 0; } if ((gamer.body.isEmpty()) ? (gamer.discoTime >= DEAD_DISCO_TIMEOUT) : (gamer.discoTime >= DISCO_TIMEOUT)) { timedOutGamers.add(gamer); } } for (gamer : timedOutGamers) { for (int idx : gamer.body) { diffWrite(idx, BoardElem.AIR); } if (!gamer.body.isEmpty()) { L.info("Timed Out Gamer \""+gamer.getName()+"\" DELETED"); globalEvents.add(new QueuedUpdate( target: null, data: [ type: 'goneplayer', id: gamer.user.getKey() ] )); } else { L.info("Dead Gamer \""+gamer.getName()+"\" exited"); } gamers.remove(gamer.user); } if (!boardDiff.isEmpty()) globalEvents.add(new QueuedUpdate( target: null, data: [ type: 'deltaboard', data: boardDiff ] )); pushEvents(); if (gamers.isEmpty()) { nogamerTime++; } else { nogamerTime = 0; } if (nogamerTime >= NOGAMER_TIMEOUT) { alive = false; L.info("No gamers! Exiting."); } } static public Response offlinePollResponse() { return Response.ok('[{"type":"nogamer"}]').build(); } private void onPoll(PollEvent ev) { boolean newView = false; if (!gamers.containsKey(ev.user)) { if(!(ev.joinGame=="1" && createUser(ev.user, ev.token))) { ev.poller.submit(offlinePollResponse()); return; } else { newView = true; } } Gamer gamer = gamers.get(ev.user); if (ev.joinGame != null && gamer.pollToken != null && gamer.pollToken == ev.token) { ev.poller.submit(Response.ok('[{"type":"toktaken"}]').build()); return; } if (gamer.pollToken != null && !gamer.pollToken.equals(ev.token)) { def takeover = { return Response.ok('[{"type":"takeover"}]', 'application/json').build(); }; if (ev.joinGame == null) { ev.poller.submit(takeover()); return; } gamer.events.clear(); for (Gamer.Poller p : gamer.pollers) { p.submit(takeover()); } gamer.pollers.clear(); gamer.pollToken = ev.token; newView = true; } else if (gamer.pollToken == null) { gamer.pollToken = ev.token; } if (newView) { gamer.viewBeginTime = gametime; gamer.pollCounter = 0; if (gamer.body.isEmpty()) { gamers.remove(gamer.user); onPoll(ev); return; } gamer.events.add([ type: "board", width: board.width, height: board.height, tickrate: TICKRATE, maxpollers: SnakeSettings.MAXPOLLERS, data: ({ List l = new ArrayList(board.width * board.height); for (int i=0; i= SnakeSettings.MAXPOLLERS) { ev.poller.submit(Response.status(429) .entity('Too many pollers!') .type('text/plain').build()) return; } if (!gamer.events.isEmpty()) { if (!newView) gamer.sequenceEvents(ev.poller); ev.poller.submit(Response.ok((new JsonBuilder(gamer.events as List)).toString(), 'application/json').build()); gamer.events.clear(); return; } gamer.pollers.add(ev.poller); L.debug("Gamer \""+gamer.getName()+"\"["+gamer.length+"] added poller["+gamer.pollers.size()+"]"); } private void onPost(PostEvent ev) { try { if (!gamers.containsKey(ev.user)) { ev.submit(Response.status(404) .entity("You don't exist").type("text/plain").build()); return; } Gamer gamer = gamers.get(ev.user); if (!gamer.pollToken.equals(ev.token)) { ev.submit(Response.status(404) .entity("Invalid token; poll with it first").type("text/plain").build()); return; } if (!(ev.actions instanceof List && ((List)ev.actions).size() == 1 && ev.actions[0] instanceof Map && ev.actions[0]["type"] instanceof String && (( (String)ev.actions[0]["type"] == "dir" && ev.actions[0]["dir"] instanceof Number && (Number)ev.actions[0]["dir"] >= 0 && (Number)ev.actions[0]["dir"] < 4 ) || ( (String)ev.actions[0]["type"] == "sprint" && ev.actions[0]["sprint"] instanceof Boolean )) )) { ev.submit(Response.status(400) .entity("Invalid argument").type("text/plain").build()); return; } if ((String)ev.actions[0]["type"] == "dir") { int dir = (int)ev.actions[0]["dir"]; while (gamer.nextDirs.size() >= 2) gamer.nextDirs.remove(); gamer.nextDirs.add(dir) } else if (ev.actions[0]["type"] == "sprint") { gamer.sprint = ev.actions[0]["sprint"]; } ev.submit(Response.ok("ok", "text/plain").build()); } catch(Throwable e) { String errId = java.util.UUID.randomUUID(); ev.submit(Response.status(500) .entity('Error! ID: '+errId).type('text/plain').build()); L.error("Error (onPost) "+errId+": " + L.errorToString(e)); throw e; } } private boolean alive = true; private long gametime = 0; private final BlockingQueue events = new LinkedBlockingQueue(128); private final Map gamers = new HashMap(); private Board board = new Board(SnakeSettings.BOARD_WIDTH, SnakeSettings.BOARD_HEIGHT); private int apples = 0; private int nogamerTime = 0; private long lastPoll = System.nanoTime(); private double pollrateAccum = 0.0; private List globalEvents = []; private int pollsPushed = 0; private int pollsSkipped = 0; public SnakeGame(Closure onExit) { String gameRunId = java.util.UUID.randomUUID(); replenishApples({ int i, BoardElem v -> board.set(i, v); }); Thread.start { try { L.info("BEGIN Tick thread "+gameRunId); double accum = 0.0; while (alive) { Thread.sleep((int)(1000.0/TICKRATE + Math.floor(accum))); accum %= 1; accum += 1000.0/TICKRATE%1; events.offer(new TickEvent(), 500L, TimeUnit.MILLISECONDS); } } finally { L.info("EXIT Tick thread "+gameRunId); } } def lastEvt = System.nanoTime(); def lastTick = System.nanoTime(); def avgTickTime = 0; def avgTickCount = 0; def TICKREPORT_INTERVAL = 2*NANOS; def avgTickNextCompute = System.nanoTime() + TICKREPORT_INTERVAL; def jiraTLU = new JiraThreadLocalUtilImpl(); def thr = Thread.start { try { try { jiraTLU.preCall(); L.info("BEGIN Main thread "+gameRunId); while (alive) { def ev = events.take(); lastEvt = System.nanoTime(); if (ev instanceof TickEvent) { def newTick = System.nanoTime(); avgTickTime += (newTick - lastTick) / 1000; avgTickCount += 1; if (System.nanoTime() >= avgTickNextCompute) { avgTickNextCompute = System.nanoTime() + TICKREPORT_INTERVAL; def tickrate = (1000000.0 / (avgTickTime / (double) avgTickCount)); L.debug("TICKRATE: " + tickrate); if (tickrate / TICKRATE < 0.98) { L.warn("LOW TPS: " + tickrate + "; TARGET = " + TICKRATE); } avgTickTime = 0; avgTickCount = 0; if (pollsSkipped > 0) { L.warn("POLLRATE limit hit! "+Math.floor(((double)pollsSkipped)/((double)pollsSkipped+pollsPushed)*1000.0)/10.0+"% updates skipped"); } pollsSkipped = 0; pollsPushed = 0; } lastTick = newTick; onTick(); gametime++; } else if (ev instanceof PollEvent) { onPoll(ev); } else if (ev instanceof PostEvent) { onPost(ev); } } } catch(Throwable e) { String errId = java.util.UUID.randomUUID(); List gamersToRemove = []; def crashRes = { return backendErrorRes(errId); }; for (gamer : gamers.values()) { boolean sent = false; for (poller : gamer.pollers) { poller.submit(crashRes()); sent = true; } gamer.pollers.clear(); if (sent) gamersToRemove.add(gamer); } for (gamer : gamersToRemove) { gamers.remove(gamer.user); } L.error("Error "+errId+": " + L.errorToString(e)); // try to make sure leftover gamers receive the error event long deadline = System.nanoTime() + 2L * NANOS; while (!gamers.isEmpty()) { long time = deadline - System.nanoTime(); if (time <= 0) break; L.debug("Waiting for disconnected gamers"); def ev = events.poll(deadline - System.nanoTime(), TimeUnit.NANOSECONDS); if (ev instanceof PollEvent) { ev.poller.submit(crashRes()); if (gamers.containsKey(ev.user) && gamers.get(ev.user).pollToken == ev.token) { L.debug("Removing Gamer \""+gamers.get(ev.user).getName()+"\""); gamers.remove(ev.user); } } else if (ev instanceof PostEvent) { ev.submit(crashRes()); } } } finally { alive = false; onExit(); for (gamer : gamers.values()) { for (poller : gamer.pollers) { poller.submit(Response.status(503) .entity((new JsonBuilder([ error: 'kick' ])).toString()).type('application/json').build()); } } } } catch(Throwable e) { L.error("Uncaught error! " + L.errorToString(e)); } finally { L.info("EXIT Main thread "+gameRunId); jiraTLU.postCall(SnakeAccessor.logger); } } Thread.start { try { L.info("BEGIN Watchdog thread "+gameRunId); while (alive) { Thread.sleep(1000); if (System.nanoTime() - lastEvt > 3*NANOS) { L.error("SnakeGame watchdog timeout!"); thr.interrupt(); } } } finally { L.info("EXIT Watchdog thread "+gameRunId); } } } public static Response backendTimeoutRes() { return Response.status(503).entity('Timed out').type('text/plain').build(); } public static Response backendErrorRes(String errId) { return Response.status(500) .entity((new JsonBuilder([ error: 'crash', errid: errId ])).toString()).type('application/json').build(); } public Response poll(ApplicationUser user, String token, String joinGame) { def poller = new Gamer.Poller( timeStarted: System.nanoTime(), timeOut: POLLTIME_MIN + (long)(Math.random()*(POLLTIME_MAX - POLLTIME_MIN)), que: new LinkedBlockingQueue(1) ); def que = poller.que; if (!events.offer(new PollEvent( poller: poller, user: user, token: token, joinGame: joinGame ), 5L, TimeUnit.SECONDS)) return backendTimeoutRes(); def val = que.poll(30L, TimeUnit.SECONDS); if (val == null) { poller.que = null; return backendTimeoutRes(); } return val; } public Response post(ApplicationUser user, String token, List actions) { def que = new LinkedBlockingQueue(1); if (!events.offer(new PostEvent( actions: actions, user: user, token: token, res: que ), 5L, TimeUnit.SECONDS)) return backendTimeoutRes(); def val = que.poll(5L, TimeUnit.SECONDS); if (val == null) { return backendTimeoutRes(); } return val; } } class L { @FunctionalInterface static private interface LogFunc { public void log(Logger obj, String s); } static private void doLog(LogFunc f, String s) { try { f(SnakeAccessor.logger, s); } catch(Throwable e) {} } static public void debug(String s) { doLog(Logger::debug, s); } static public void info(String s) { doLog(Logger::info, s); } static public void warn(String s) { doLog(Logger::warn, s); } static public void error(String s) { doLog(Logger::error, s); } static String errorToString(Throwable e) { def out = new java.io.ByteArrayOutputStream(); e.printStackTrace(new java.io.PrintStream(out)); return out.toString(); } } class SnakeAccessor { static private SnakeGame game = null; static final public Logger logger = Logger.getLogger("net.kimapr.JiraSnake"); static public synchronized SnakeGame getGame(boolean launch) { if ((game == null || game.isDead()) && launch) { game = new SnakeGame({ game = null }) } return game } static public SnakeGame getGame() { return getGame(false); } } def webPage = '''\ ''' snake( httpMethod: "GET", groups: [] ) { MultivaluedMap queryParams, HttpServletRequest req -> return Response.ok(webPage, 'text/html').build() } snakePoll( httpMethod: "POST", groups: gamerGroups ) { MultivaluedMap queryParams, HttpServletRequest req -> try { String token = queryParams.getFirst("token"); if (token == null) return Response.status(400).entity("Missing token").type('text/plain').build(); String joinGame = queryParams.getFirst("join_game"); if (!(joinGame == null || joinGame == "0" || joinGame == "1")) { joinGame = null; } def game = SnakeAccessor.getGame(joinGame == "1"); if (game == null) { return SnakeGame.offlinePollResponse(); } def userManager = ComponentAccessor.getOSGiComponentInstanceOfType(UserManager); def user = ComponentAccessor.userManager.getUserByKey(userManager.getRemoteUserKey(req).getStringValue()); return game.poll(user, token, joinGame); } catch(Throwable e) { String errId = java.util.UUID.randomUUID(); L.error('Uncaught error in snakePoll! '+errId+'; '+L.errorToString(e)); return SnakeGame.backendErrorRes(errId); } } snakeAct( httpMethod: "POST", groups: gamerGroups ) { MultivaluedMap queryParams, HttpServletRequest req -> try { String token = queryParams.getFirst("token"); if (token == null) return Response.status(400).entity("Missing token").type('text/plain').build(); def game = SnakeAccessor.getGame(false); if (game == null) { return Response.status(503).entity("Game not running").type('text/plain').build(); } def userManager = ComponentAccessor.getOSGiComponentInstanceOfType(UserManager); def user = ComponentAccessor.userManager.getUserByKey(userManager.getRemoteUserKey(req).getStringValue()); L.debug('Receiving action from "'+user.getName()+'"'); String body; if(1){ def writer = new ByteArrayOutputStream(); int length = 0; def istream = req.getInputStream(); def buf = new byte[1024]; while (1) { int cnt = istream.read(buf); if (cnt == -1) break; length += cnt; if (length > 1024) { L.debug('TOO BIG action from "'+user.getName()+'"'); return Response.status(413).entity("Request entity too large").type('text/plain').build(); } writer.write(buf, 0, cnt); } req.getInputStream().transferTo(writer); body = writer.toString(); } List action; try { action = (List)((new JsonSlurper()).parseText(body)); } catch(Throwable e) { L.debug('INVALID action from "'+user.getName()+'"'); return Response.status(400).entity('Invalid JSON').type('text/plain').build(); } L.debug('Sending action from "'+user.getName()+'" to game loop'); return game.post(user, token, action); } catch(Throwable e) { String errId = java.util.UUID.randomUUID(); L.error('Uncaught error in snakeAct! '+errId+'; '+L.errorToString(e)); return SnakeGame.backendErrorRes(errId); } }