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 groovy.transform.BaseScript 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.SynchronousQueue import java.util.concurrent.ArrayBlockingQueue import java.util.HashMap import java.util.ArrayList import java.util.stream.Stream import java.util.Collections import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException @BaseScript CustomEndpointDelegate delegate def gamerGroups = ["jira-software-users", "jira-administrators"] 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; 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() { events.add([ type: 'seq', i: pollCounter ]); pollCounter = (pollCounter + 1) % 32; } public ArrayList events = new ArrayList(); public ArrayDeque pollers = new ArrayDeque(); public int nextHead(int direction) { def (x,y) = board.idxToPos(body.first()); def (dx,dy) = DIRS[direction]; (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 % 3 == 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 Map introduceTo(Gamer other) { if (body.isEmpty()) return null; def avatarService = ComponentAccessor.avatarService; def v = [ type: "newplayer", id: user.getKey(), name: user.getUsername(), body: body ]; try { v.put("avatar", avatarService.getAvatarURL(other.user, user).toString()); } catch(Throwable e) { L.log(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 boolean joinGame; } static class UnpollEvent extends Event { public Gamer.Poller poller; public ApplicationUser user; } 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); } } private static final double TICKRATE = 15.0; 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 = 33L*NANOS/10L; private static final long POLLTIME_MAX = 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) * 0.01); } 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) { Gamer gamer = new Gamer( user: user, pollToken: token ); boolean success = false; for (int i=0; i<(gamers.size()+1)*64; i++) { int x, 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 (dir : ({ List nl = [0,1,2,3]; Collections.shuffle(nl); nl })()) { def (dx,dy) = DIRS[dir]; def (nx,ny) = [x+dx,y+dy]; for (int j=1; j<=16; j++) { def 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 (otherGamer : gamers.values()) { if (otherGamer.body.isEmpty()) continue; def (ox, oy) = board.idxToPos(otherGamer.body.first()); 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 (otherGamer : gamers.values()) { def intro = gamer.introduceTo(otherGamer) if (intro == null) continue; otherGamer.events.add(intro); } gamers.put(user, gamer); return true; } private void onTick() { List globalEvents = []; 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.AIR); 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 (idx : (Iterable) () -> body.iterator()) vboard.set(idx, BoardElem.withGamers(vboard.get(idx).gamerCount() - 1)); } // REALSPACE: move gamers (head and 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()) { apples--; } if (tailed.contains(gamer)) { diffWrite(gamer.body.last(), BoardElem.AIR); gamer.body.removeLast(); } def farted = false; if (gamer.isSprinting()) { gamer.hunger++; if (gamer.hunger >= 10) { 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--; farted = true; } } diffWrite(gamer.body.first(), BoardElem.GAMER); globalEvents.add([ 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(); gamer.length = 0; globalEvents.add([ 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); } if (gamer.pollers.isEmpty()) { 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()) globalEvents.add([ type: 'goneplayer', id: gamer.user.getKey() ]); gamers.remove(gamer.user); } if (!boardDiff.isEmpty()) globalEvents.add([ type: 'deltaboard', data: boardDiff ]); if (!globalEvents.isEmpty()) { //def serial = (new JsonBuilder(globalEvents)).toString(); for (gamer : gamers.values()) { def poller = gamer.pollers.poll(); //if (poller && gamer.events.isEmpty()) { // poller.submit(Response.ok(serial, 'application/json').build()); // continue; //} gamer.events.addAll(globalEvents); if (poller == null) continue; gamer.sequenceEvents(); poller.submit(Response.ok((new JsonBuilder(gamer.events as List)).toString(), 'application/json').build()); gamer.events.clear(); } } if (gamers.isEmpty()) { nogamerTime++; } else { nogamerTime = 0; } if (nogamerTime >= NOGAMER_TIMEOUT) { alive = false; L.log("No gamers! Exiting SnakeGame."); } } private void onPoll(PollEvent ev) { boolean newView = false; if (!gamers.containsKey(ev.user)) { if(!(ev.joinGame && createUser(ev.user, ev.token))) { ev.poller.submit(Response.ok('[{"type":"nogamer"}]').build()); return; } else { newView = true; } } Gamer gamer = gamers.get(ev.user); if (gamer.pollToken != null && !gamer.pollToken.equals(ev.token)) { gamer.events.clear(); for (Gamer.Poller p : gamer.pollers) { p.submit(Response.ok('[{"type":"takeover"}]', 'application/json').build()); } gamer.pollers.clear(); gamer.pollToken = ev.token; newView = true; } else if (gamer.pollToken == null) { gamer.pollToken = ev.token; } if (newView) { 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, data: ({ List l = new ArrayList(board.width * board.height); for (int i=0; i= 0 && ev.actions[0]["dir"] < 4 ) || ( ev.actions[0]["type"] == "sprint" && ev.actions[0]["sprint"] instanceof Boolean )) )) { ev.submit(Response.status(403) .entity("Invalid argument").type("text/plain").build()); return; } if (ev.actions[0]["type"] == "dir") { int dir = 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) { ev.submit(Response.status(503) .entity(errorToString(e)).type('text/plain').build()); throw e; } } private boolean alive = true; private long gametime = 0; private final BlockingQueue events = new SynchronousQueue(); private final Map gamers = new HashMap(); private Board board = new Board(50, 50); private int apples = 0; private int nogamerTime = 0; public static String lastError = null; static String errorToString(Throwable e) { def out = new java.io.ByteArrayOutputStream(); e.printStackTrace(new java.io.PrintStream(out)); return out.toString(); } public SnakeGame(Closure onExit) { L.log("Starting SnakeGame"); replenishApples({ int i, BoardElem v -> board.set(i, v); }); Thread.start { while (alive) { Thread.sleep((int)(1000.0/TICKRATE)); events.offer(new TickEvent(), 500L, TimeUnit.MILLISECONDS); } } def lastTick = System.nanoTime(); def thr = Thread.start { try { try { while (alive) { def ev = events.take(); lastTick = System.nanoTime(); if (ev instanceof TickEvent) { onTick(); gametime++; } else if (ev instanceof PollEvent) { onPoll(ev); } else if (ev instanceof UnpollEvent) { onUnpoll(ev); } else if (ev instanceof PostEvent) { onPost(ev); } } } catch(Throwable e) { for (gamer : gamers.values()) { for (poller : gamer.pollers) { poller.submit(Response.status(503) .entity(errorToString(e)).type('text/plain').build()); } } L.log("SnakeGame error! " + errorToString(e)); lastError = errorToString(e); } finally { L.log("SnakeGame is over."); alive = false; onExit(); for (gamer : gamers.values()) { for (poller : gamer.pollers) { poller.submit(Response.status(503).build()); } } } } catch(Throwable e) { lastError = errorToString(e); } } Thread.start { while (alive) { Thread.sleep(1000); if (System.nanoTime() - lastTick > 3*NANOS) { L.log("SnakeGame watchdog timeout!"); thr.interrupt(); } } } } public Response poll(ApplicationUser user, String token, boolean joinGame) { def poller = new Gamer.Poller( timeStarted: System.nanoTime(), timeOut: POLLTIME_MIN + (long)(Math.random()*(POLLTIME_MAX - POLLTIME_MIN)), que: new ArrayBlockingQueue(1) ); def que = poller.que; events.put(new PollEvent( poller: poller, user: user, token: token, joinGame: joinGame )); try { def val = que.poll(30L, TimeUnit.SECONDS); if (val == null) { throw new TimeoutException(); } return val; } catch(Throwable e) { poller.que = null; events.put(new UnpollEvent( user: user, poller: poller )); throw e; } } public Response post(ApplicationUser user, String token, List actions) { def que = new ArrayBlockingQueue(1); events.put(new PostEvent( actions: actions, user: user, token: token, res: que )); return que.take(); } } class L { static public void log(String s) { try { def logger = SnakeAccessor.logger; if (logger != null) logger.warn(s); } catch(Throwable e) {} } } class SnakeAccessor { static private SnakeGame game = null; static public Logger logger = null; static public synchronized SnakeGame getGame() { if (game == null || game.isDead()) { game = new SnakeGame({ game = null }) } return game } } def webPage = '''\ ''' snake( httpMethod: "GET", groups: gamerGroups ) { MultivaluedMap queryParams, HttpServletRequest req -> return Response.ok(webPage, 'text/html').build() } snakeIPC( httpMethod: "GET", groups: gamerGroups ) { MultivaluedMap queryParams, HttpServletRequest req -> String token = queryParams.getFirst("token"); if (token == null) return Response.status(400).entity("Missing token").type('text/plain').build(); boolean joinGame = queryParams.getFirst("join_game") != null; SnakeAccessor.logger = log; def game = SnakeAccessor.getGame(); def userManager = ComponentAccessor.getOSGiComponentInstanceOfType(UserManager); def user = ComponentAccessor.userManager.getUserByKey(userManager.getRemoteUserKey(req).getStringValue()); return game.poll(user, token, joinGame); } snakeError( httpMethod: "GET", groups: gamerGroups ) { MultivaluedMap queryParams, HttpServletRequest req -> return Response.ok(SnakeGame.lastError.toString(),'text/plain').build() } snakeIPC( httpMethod: "POST", groups: gamerGroups ) { MultivaluedMap queryParams, String body, HttpServletRequest req -> String token = queryParams.getFirst("token"); if (token == null) return Response.status(400).entity("Missing token").type('text/plain').build(); SnakeAccessor.logger = log; def game = SnakeAccessor.getGame(); def userManager = ComponentAccessor.getOSGiComponentInstanceOfType(UserManager); def user = ComponentAccessor.userManager.getUserByKey(userManager.getRemoteUserKey(req).getStringValue()); List action = (new JsonSlurper()).parseText(body); return game.post(user, token, action); }