jira-worm/snake.groovy

2396 lines
64 KiB
Groovy
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<int> :: 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<int> :: 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<int> :: body segments to insert at the head
//
// - `deltaboard`: changes on the board occured.
//
// `data`: array<tuple<int, int>> :: 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<Integer> body = new ArrayDeque<Integer>();
public int length = 4;
public int direction = (int)Math.floor(Math.random()*4);
public ArrayDeque<Integer> nextDirs = new ArrayDeque<Integer>();
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<Response> 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<Map> events = new ArrayList<Map>();
public ArrayDeque<Poller> pollers = new ArrayDeque<Poller>();
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<int>::new, ArrayList<int>::add, ArrayList<int>::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*height; i++) {
this.board[i] = BoardElem.AIR.getId();
}
}
public Board(Board underlay) {
this.underlay = underlay;
this.width = underlay.width;
this.height = underlay.height;
this.board = new byte[this.width * this.height];
}
public int posToIdx(int x, int y) {
if (x < 0 || x >= 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<Response> 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<int> freeSpace = Stream<int>
.iterate(0, v -> v + 1)
.limit(board.width * board.height)
.filter(i -> board.get(i) == BoardElem.AIR)
.collect(ArrayList<int>::new, ArrayList<int>::add, ArrayList<int>::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<Integer> 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<List<Integer>> boardDiff = [];
HashSet<Gamer> 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.<Integer>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<Gamer> toKill = [];
List<Gamer> 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<Integer> body = gamer.body.stream();
if (tailed.contains(gamer))
body = body.limit(gamer.body.size() - 1)
if (vboard.get(gamer.nextHead()).gamerCount() > 0)
body = Stream<Integer>.concat([gamer.nextHead()].stream(), body);
for (int idx : (Iterable<Integer>) () -> 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<Gamer> timedOutGamers = [];
for (gamer : gamers.values()) {
List<Gamer.Poller> 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<board.width*board.height; i++) {
l.add(board.board[i] == BoardElem.GAMER.getId() ? BoardElem.AIR.getId() : board.board[i]);
}
return l;
}())
]);
for (otherGamer : gamers.values()) {
def intro = otherGamer.introduceTo(gamer)
if (intro == null) continue;
gamer.events.add(intro);
}
} else if (gamer.pollers.size() >= 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<Event> events = new LinkedBlockingQueue<Event>(128);
private final Map<ApplicationUser, Gamer> gamers = new HashMap<ApplicationUser, Gamer>();
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<QueuedUpdate> 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<Gamer> 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<Response>(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<Response>(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 = '''\
<!DOCTYPE html>
<style>
html, body { margin: 0; padding: 0; }
body { position: relative; }
body, .content { width:100%; height: 100%; position: absolute; box-sizing: border-box; color: black; }
#welcomeScreen {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 3em;
background-color: white;
overflow: hidden;
}
#languageSelector {
position: absolute; top: 1em; right: 1em;
}
#welcomeMessage {
text-align: center;
}
#gameContent {
background-color: white;
padding: 1em;
display: flex;
flex-direction: row;
gap: 1em;
}
#gameLogo {
height: 4.5em;
}
#leaderboard {
width: 30ex;
overflow: hidden;
}
#leaderboard > h1 {
text-align: center;
font-size: inherit;
}
#leaderboardItems img {
vertical-align: center;
height: 1em;
width: 1em;
object-fit: middle;
border-radius: 0.5em;
overflow: hidden;
}
#leaderboardItems {
display: flex;
flex-direction: column;
width: 100%;
align-items: stretch;
gap: 0.5em;
}
#leaderboardItems > div {
display: flex;
flex-direction: row;
}
#leaderboardItems .lbName {
flex-grow: 1;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
#leaderboardItems .lbName.lbSelfName {
text-decoration: underline;
}
#leaderboardItems .lbScore {
color: #202020;
}
button:hover:not(:disabled) { background-color: #eaeaea; }
button:active:not(:disabled) { background-color: #bebebe; }
button:disabled { background-color: #cecece; color: #606060; }
button {
appearance: none;
outline: none;
border: 0.1em solid grey;
background-color: lightgray;
color: black;
font-size: 1.5em;
border-radius: 0.5em;
padding: 0.5em;
min-width: 8em;
}
canvas {
width:100%;height:100%;
flex-grow: 1;
object-fit: contain;
}
</style>
<div id=gameContent class="content" style="display: none">
<canvas id=gameCanvas></canvas>
<div id=leaderboard>
<h1>$snake.leaderboard.header</h1>
<div id=leaderboardItems>
</div>
</div>
</div>
<div id=welcomeScreen class="content" style="display: none">
<img id=gameLogo src="/s/eiryxg/10030008/1bqcfwe/_/images/logos/light/jira-software.png">
<div id=welcomeMessage style="display: none"></div>
<button id=joinBtn>$snake.welcome.joinBtn</button>
<select id=languageSelector></select>
</div>
<script>
let token = null;
let game = null;
let shouldRedraw = false;
let ctx = gameCanvas.getContext('2d');
let cancelGame = null;
let seqs = null;
let sprintKeyDown = false;
let unusedKeys = null;
let pollCount = 3,
minPollCount = 1,
maxPollCount = 16;
let doLoginRedirect = true;
if (location.hash == '#nologin') {
history.replaceState(null, '', (e => (e.hash='',e))(new URL(location)));
doLoginRedirect = false;
}
let language = null;
let languageStorageKey = 'jiraworm_se4s9uns_language';
for (let el of document.querySelectorAll('*')) {
if (el.childElementCount > 0) continue;
let tx = el.textContent.trim().match(/^\\$(.*)$/);
if (!tx) continue;
console.log('Translatable element: '+tx[1]);
el.updateTranslation = function() {
this.textContent = translate(tx[1]) ?? this.textContent;
}
}
function setLanguage(code) {
if (languageSelector.value != code)
languageSelector.value = code;
console.log('Setting language to '+code);
language = code;
for (let el of document.querySelectorAll('*')) {
if (!el.updateTranslation) continue;
el.updateTranslation();
}
}
languageSelector.onchange = function() {
setLanguage(this.value);
localStorage[languageStorageKey] = this.value;
}
let strings = {
en: {
snake: {
welcome: {
joinBtn: 'Become a worm',
takeover: 'The worm is controlled by another tab.',
gameover: 'Wriggled too hard... Your length: ${{0}}',
hint: {
keys: {
base: 'HINT: Use the ${{0}}.',
walk: 'arrow keys to move',
sprint: 'spacebar to sprint',
}
},
nogamer: 'Unable to join game (server full?). Try again later.',
serverError: {
crash: 'SERVER ERROR. Contact the administrator.\\nError ID: ${{0}}',
kick: 'Server kicked you out of the game.',
denied: 'Game access denied.',
},
clientError: 'Fatal error in client! ${{0}}',
},
leaderboard: {
header: 'TOP worms',
},
},
language: { name: 'English' }
},
ru: {
snake: {
welcome: {
joinBtn: 'Стать червяком',
takeover: 'Червяк управляется другой вкладкой.',
gameover: 'Доползлись... Ваша длина: ${{0}}',
hint: {
keys: {
base: 'ПОДСКАЗКА: Используйте ${{0}}.',
walk: 'клавиши стрелок чтобы перемещаться',
sprint: 'пробел чтобы ускориться'
}
},
nogamer: 'Не удалось присоединиться к игре (сервер полный?). Попробуйте позже.',
serverError: {
crash: 'ОШИБКА СЕРВЕРА. Свяжитесь с администратором.\\nID ошибки: ${{0}}',
kick: 'Сервер выбросил вас из игры.',
denied: 'Доступ к игре отказан.',
},
clientError: 'Фатальная ошибка в клиенте! ${{0}}',
},
leaderboard: {
header: 'ТОП червей'
}
},
language: { name: 'Русский' }
}
};
(() => {
let code = 'en';
let languages = ['en', 'ru'];
for (let lang of languages) {
languageSelector.add(new Option(strings[lang].language.name, lang))
}
try {
for (let c of [(e => e!=null ? [e] : [])(localStorage[languageStorageKey]), navigator.languages]) {
c+='';
let ok = false;
for (a of languages)
if (c == a || c.match('^'+a+'-')) {
code = a;
ok=true;
break;
}
if (ok) break;
}
} catch(e) {
console.error('Failed to get navigator language', e);
}
setLanguage(code);
})();
function translate(id, ...args) {
let o = strings[language];
if (!o) return null;
for (let i of id.split('.')) {
if (!(i in o)) {
o = null; break;
}
o = o[i];
}
if (typeof o != 'string')
return null;
return o.replace(/\\${{[0-9][0-9]*}}/g, e => { e = e.match(/\\d\\d*/)[0]-0; return args[e]; });
}
function L(id, ...args) {
return translate(id, ...args) ?? '$'+id+JSON.stringify(args);
}
let DIRS = Array(4);
{
let [x,y] = [1, 0];
for (let i=0; i<4; i++) {
DIRS[i] = [x,y];
[x,y] = [-y,x];
}
}
Object.assign(DIRS, {
dirToId(x, y) {
return DIRS.map(e => e+'').indexOf(`${x},${y}`);
}
});
function sendCommand(...args) {
let url = new URL("snakeAct", location);
url.searchParams.append('token', token);
let tBegin = performance.now();
fetch(url, {
method: "POST",
body: JSON.stringify([...args]),
headers: {
'Content-Type': "application/json"
}
}).then(() => console.debug('command POSTed in '+(performance.now()-tBegin)));
}
function html(t) {
let el = document.createElement('div');
el.innerHTML=t;
return el.querySelector('*');
}
function markKeyUse(key) {
if (!unusedKeys) return;
delete unusedKeys[key];
}
window.onkeydown = function(ev) {
if (ev.code == 'Enter') {
tryJoinGame();
return false;
} else if (ev.code == 'Escape') {
if (cancelGame)
cancelGame();
return false;
} else if (ev.code == 'Space') {
markKeyUse('sprint');
if (!sprintKeyDown)
sendCommand({type:'sprint',sprint:true})
sprintKeyDown = true;
}
let dir = ({
ArrowUp: [0,-1],
ArrowDown: [0,1],
ArrowLeft: [-1,0],
ArrowRight: [1,0]
})[ev.code];
if (dir == null) return;
ev.preventDefault();
markKeyUse('walk');
sendCommand({type:'dir',dir:DIRS.dirToId(...dir)});
}
window.onkeyup = function(ev) {
if (ev.code == 'Space') {
sendCommand({type:'sprint',sprint:false})
sprintKeyDown = false;
return false;
}
}
async function tryJoinGame(opts) {
if (cancelGame) return;
opts = opts ?? {};
let signal = opts.signal;
let evs;
let failed = false;
let loginRedir = false;
try {
cancelGame = opts.cancelGame ?? (() => {});
if (!opts.reconnecting)
joinBtn.disabled = true;
while (1) {
let netErrInfo = null;
evs = await pollOne({ join_game: opts.reconnecting ? 0 : 1 }, signal ? { signal } : {}).catch(e => {
console.error(e);
if (e.netInfo) netErrInfo = e.netInfo;
return [{type:"nogamer"}]
});
if (evs.find(e => e.type=='nogamer')) {
failed = true;
if (netErrInfo) {
if (opts.onloadChecking && netErrInfo.error == 'denied' && doLoginRedirect) {
location.replace(new URL('/login.jsp?os_destination='+location.pathname+'#nologin', location));
loginRedir = true;
return;
}
handleServerError(netErrInfo);
} else {
if (!opts.onloadChecking)
welcomeScreen.setMessage(L, opts.reconnecting ? 'snake.welcome.serverError.kick' : 'snake.welcome.nogamer');
else
welcomeScreen.setMessage();
}
welcomeScreen.setVisible(true);
return;
}
if (evs.find(e => e.type=='toktaken')) {
genToken();
continue;
}
break;
}
} catch(e) {
handleClientError(e, true);
if (!(signal && signal.aborted)) {
throw e;
} else {
console.error('TryJoin error', e);
}
} finally {
cancelGame = null;
if (failed && !opts.reconnecting)
joinBtn.disabled = false;
if (loginRedir)
return;
if (opts.reconnecting && signal && signal.aborted) {
welcomeScreen.setVisible(true);
welcomeScreen.setMessage();
return;
}
}
try {
for (let ev of evs)
handleEvent(ev);
} catch(e) {
handleClientError(e, true);
return;
}
if (!opts.reconnecting)
joinBtn.disabled = false;
welcomeScreen.setVisible(false);
welcomeScreen.setMessage();
gameLoop();
}
joinBtn.onclick = tryJoinGame;
welcomeScreen.setVisible = function(b) {
this.style = b ? '' : 'display: none';
gameContent.style = b ? 'display: none' : '';
}
welcomeScreen.setMessage = function(msg, ...args) {
if (typeof msg == 'function') {
welcomeMessage.updateTranslation = function() {
this.innerHTML = msg(...args).replace(/\\n/g, '<br>');
}
welcomeMessage.updateTranslation();
} else {
welcomeMessage.innerHTML = msg || '';
delete welcomeMessage.updateTranslation;
}
welcomeMessage.style = msg ? '' : 'display: none';
}
async function main() {
genToken();
return tryJoinGame({ reconnecting: true, onloadChecking: true });
}
Game = {
NULL: 0,
AIR: 1,
APPLE: 2,
GAMER: 64,
posToIdx(x, y) {
if (x < 0 || x >= this.width ) return -1;
if (y < 0 || y >= this.height) return -1;
return y*this.width+x;
},
idxToPos(i) {
return [(i%this.width)|0, (i/this.width)|0];
},
toString() {
let lines = [];
for (let y=0; y<this.height; y++) {
let line = [];
for (let x=0; x<this.width; x++) {
line.push(({
[Game.AIR]: '. ',
[Game.APPLE]: '<>',
[Game.GAMER]: '[]'
})[this.data[this.posToIdx(x,y)]]??'? ')
}
lines.push(line.join(''));
}
return lines.join('\\n');
}
};
function genToken() {
token = [1,2].map(e=>(Math.random()*(2**31)|0).toString(16).padStart(8,0))
.join('').slice(0,12);
seqs = null;
}
async function pollOne(opts, ropts) {
let url = new URL("snakePoll", location);
opts = { token, ...(opts ?? {}) };
for (let [k, v] of Object.entries(opts)) {
url.searchParams.append(k, v);
}
let tBegin = performance.now();
let res = await fetch(url, {
headers: {
'Accept': 'application/json'
},
method: "POST",
priority: 'high',
...(ropts ?? {})
});
let elapsed = performance.now() - tBegin;
if (!res.ok) {
let txt = await res.text();
let err = new Error(txt);
if (res.status == 401 || res.status == 403) {
err.netInfo = { error: 'denied' };
} else {
try {
err.netInfo = JSON.parse(txt);
} catch (e) {}
}
throw err;
}
let data = await res.json();
data.elapsed = elapsed;
data.reqBegin = tBegin;
return data;
}
function DataGenerator() {
this._gcs = [];
this._cond = null;
}
Object.assign(DataGenerator.prototype, {
async *[Symbol.asyncIterator]() {
while (1) {
let gencond = this._gcs.shift();
if (gencond)
gencond();
let val = await new Promise((ok) => {
this._cond = ok;
});
if (val == null) break;
yield val;
}
},
async push(msg) {
if (!this._cond) {
let gencond;
let genprom = new Promise((ok) => {
gencond = ok;
});
this._gcs.push(gencond);
await genprom;
}
this._cond(msg);
this._cond = null;
},
});
function SeqCounter(m) {
this._seq = 0;
this._m = m;
this._w = Array(m);
}
Object.assign(SeqCounter.prototype, {
inc() {
this._seq = (this._seq + 1) % this._m;
if (this._w[this._seq])
this._w[this._seq]();
},
async poll(seq, opts) {
let signal = null, callback = null;
if (opts instanceof AbortSignal)
signal = opts;
else if (opts instanceof Function)
callback = opts;
else if (opts)
({ signal, callback } = opts);
if (signal)
signal.throwIfAborted();
seq = (seq%this._m+this._m)%this._m;
if (this._seq == seq) {
try {
if (callback)
await callback();
} finally {
this.inc();
return;
}
}
if (this._w[seq])
throw new Error('duplicate seq');
await ((new Promise((res, rej) => {
this._w[seq] = () => { res(); delete this._w[seq]; };
if (signal)
signal.addEventListener('abort', () => rej(signal.reason), { once: true });
})));
try {
if (callback)
await callback();
} finally {
this.inc();
}
},
});
async function *poll(opts) {
let aborter = new AbortController();
let signal = aborter.signal;
let data = new DataGenerator();
let running = true;
let thrs = 0;
let polls = 0;
if (opts && opts.signal) {
opts.signal.addEventListener('abort', () => {
console.log('aborting polls');
aborter.abort(opts.signal.reason)
}, { once: true });
}
if (opts && opts.softSignal) {
opts.softSignal.addEventListener('abort', () => {
console.log('soft-closing polls');
running = false;
}, { once: true });
}
let maxRTT = null;
let poller = async () => {
try {
thrs++;
console.log(`Spawning poller; ${thrs}/${pollCount}`);
while (running) {
try {
let evs = await pollOne({}, { signal });
if (evs.find(e => e.type=='takeover')) {
console.warn("TAKEOVER");
aborter.abort();
data.push(Promise.resolve(evs));
throw aborter.reason;
}
let elapsed = evs.elapsed;
let seq = null;
evs = evs.filter(e => (e.type == 'seq' ? (seq=e, 0) : (1)));
evs = Promise.resolve(evs);
if (seq) {
evs = (evs => seqs.poll(seq.i, {
signal,
callback: async () => {
await data.push(evs);
}
}))(evs);
let rtt = elapsed - seq.dur;
console.debug(`poll rtt = ${elapsed} - ${seq.dur} = ${rtt}`);
if (rtt >= 0)
maxRTT = Math.max(rtt, maxRTT);
}
evs.then(
e => e && data.push(e),
e => data.push(Promise.reject(e))
);
} catch(e) {
data.push(Promise.reject(e));
return;
}
if (thrs > pollCount) {
console.log(`Exiting poller; ${thrs}/${pollCount} left`);
return;
}
}
} finally {
if (!--thrs)
data.push(null);
}
};
let rtts = Array(50).fill(null);
rtts.i = 0;
let tickrate = 1;
// Change amount of polling concurrency dynamically to compensate for higher latency
(async () => {
pollCount = Math.min(maxPollCount, Math.max(minPollCount, pollCount));
while (running) {
for (;thrs<pollCount;)
poller();
try { await sleep(100, signal); } catch(e) { console.log('Poll spawner exit'); return; }
let rtt = maxRTT ?? rtts[rtts.i];
maxRTT = null;
rtts.i = (rtts.i + 1)%rtts.length;
rtts[rtts.i] = rtt;
let frtts = rtts.filter(e => e != null && e == e);
if (frtts.length > 0) {
//let artt = frtts.reduce((a,b)=>Math.max(a,b));
let artt = frtts.sort((a,b) => a-b)[(frtts.length/2)|0];
tickrate = game?.tickrate ?? tickrate;
pollCount = Math.min(maxPollCount, Math.max(minPollCount, Math.ceil(artt / (1000/tickrate))));
}
}
})();
try {
for await (let el of data) {
yield await el;
}
} finally {
aborter.abort();
}
}
async function sleep(time, opts) {
let signal = null;
if (opts instanceof AbortSignal) {
signal = opts;
} else if (opts && opts.signal) {
signal = opts.signal;
}
if (signal) {
signal.throwIfAborted();
}
await new Promise((res, rej) => {
let cancels = [], tok = null;
if (signal) {
let evhdesc;
signal.addEventListener(...(evhdesc = ['abort',
() => (tok!=null ? clearTimeout(tok) :0, rej(signal.reason)),
{ once: true }]));
cancels.push(() => signal.removeEventListener(...evhdesc));
}
tok = setTimeout(() => {
cancels.forEach(f => f());
res();
}, time);
});
}
function handleEvent(ev) {
if (ev.type == "board") {
game = Object.setPrototypeOf({
width: ev.width,
height: ev.height,
tickrate: ev.tickrate,
data: ev.data,
gamers: Object.create(null)
}, Game)
maxPollCount = ev.maxpollers;
seqs = new SeqCounter(maxPollCount * 2);
shouldRedraw = true;
} else if (ev.type == "deltaboard") {
for (let [i,v] of ev.data)
game.data[i] = v;
shouldRedraw = true;
} else if (ev.type == "newplayer") {
if (ev.avatar) {
let img = new Image(1,1);
img.src = ev.avatar;
ev.avatar = img;
}
game.gamers[ev.id] = ev;
if (ev.self)
game.gamer = ev;
shouldRedraw = true;
} else if (ev.type == "moveplayer") {
let gamer = game.gamers[ev.id];
gamer.body.splice(gamer.body.length - ev.tail);
gamer.body.unshift(...ev.head);
shouldRedraw = true;
} else if (ev.type == "goneplayer") {
delete game.gamers[ev.id];
if (game.gamer && game.gamer.id == ev.id) {
welcomeScreen.setMessage(((gamerLength, unusedKeys) => _ => L('snake.welcome.gameover', gamerLength)+
(e => e.length ? `<br><br>`+L('snake.welcome.hint.keys.base', e.join(', ')) : e)(Object.keys(unusedKeys??{})
.map(e => (L(`snake.welcome.hint.keys.${e}`)))))(game.gamer.body.length, {...unusedKeys}));
delete game.gamer;
let canceller = cancelGame;
setTimeout(canceller, 2000);
}
shouldRedraw = true;
}
}
function handleServerError(netInfo) {
console.error('SERVER ERROR', netInfo);
welcomeScreen.setMessage(L, 'snake.welcome.serverError.'+netInfo.error, ...(e => e ? [e] : [])(netInfo.errid));
}
function handleClientError(e, injoin) {
console.error(injoin ? 'JOIN CRASHED' : 'GAME CRASHED', e);
welcomeScreen.setMessage(L, 'snake.welcome.clientError', e+'\\n\\n'+e.stack);
joinBtn.disabled = true;
}
async function gameLoop() {
unusedKeys = { walk: true, sprint: true };
let aborter = new AbortController();
let signal = aborter.signal;
function draw() {
if (signal.aborted)
return;
{
let { width: w, height: h } = gameCanvas.getBoundingClientRect();
if (h < w)
w = Math.round(h * (game.width / game.height));
else
h = Math.round(w * (game.height / game.width));
if (gameCanvas.width != w || gameCanvas.height != h) {
[gameCanvas.width, gameCanvas.height] = [w, h];
shouldRedraw = true;
}
}
if (shouldRedraw) {
shouldRedraw = false;
ctx.reset();
ctx.scale(
gameCanvas.width / game.width,
gameCanvas.height / game.height
);
for (let y=0; y<game.height; y++)
for (let x=0; x<game.width; x++) {
let c = game.data[game.posToIdx(x,y)];
if (c == Game.AIR) {
ctx.fillStyle = '#aeaeae';
ctx.beginPath();
ctx.ellipse(x+0.5, y+0.5, 0.1, 0.1, 0, 0, 2*Math.PI);
ctx.fill();
} else if (c == Game.APPLE) {
ctx.fillStyle = '#fa0000';
ctx.beginPath();
ctx.ellipse(x+0.5, y+0.5, 0.45, 0.45, 0, 0, 2*Math.PI);
ctx.fill();
} else if (c == Game.GAMER) {
/*
ctx.fillStyle = '#00a000';
ctx.beginPath();
ctx.ellipse(x+0.5, y+0.5, 0.5, 0.5, 0, 0, 2*Math.PI);
ctx.fill();
*/
}
}
leaderboardItems.textContent='';
for (let gamer of Object.values(game.gamers)) {
if (gamer.body.length > 0) {
ctx.fillStyle = '#00a000';
ctx.beginPath();
for (let i=gamer.body.length-1; i>0; i--) {
let [x1,y1] = game.idxToPos(gamer.body[i])
let [x2,y2] = game.idxToPos(gamer.body[i-1]);
[x1,x2] = x2 > x1 ? [x1,x2] : [x2,x1];
[y1,y2] = y2 > y1 ? [y1,y2] : [y2,y1];
let r = 0.4;
ctx.roundRect(x1+0.5-r, y1+0.5-r, x2-x1+r*2, y2-y1+r*2, r);
}
ctx.fill();
let [x,y] = game.idxToPos(gamer.body[0]);
ctx.save();
ctx.beginPath();
ctx.ellipse(x+0.5, y+0.5, 0.5, 0.5, 0, 0, 2*Math.PI);
ctx.clip();
if (gamer.avatar)
ctx.drawImage(gamer.avatar, x, y, 1, 1);
else
ctx.fillRect(x, y, 1, 1);
ctx.restore();
}
}
for (let gamer of Object.values(game.gamers).sort((a,b) => b.body.length - a.body.length)) {
let item = html(`
<div class=lbItem>
<div class="lbName${gamer.self ? ' lbSelfName' : ''}">
<img src="">
name
</div>
<div class=lbScore>1</div>
</div>
`);
let pfp = item.querySelector('img');
pfp.nextSibling.data = ' '+gamer.name;
if (gamer.avatar)
pfp.src = gamer.avatar.src;
else
pfp.remove();
item.querySelector('.lbScore').textContent = gamer.body.length;
leaderboardItems.append(item);
}
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
let reconnect = true;
cancelGame = () => {
console.log('Cancelling game');
reconnect = false;
aborter.abort();
}
try {
let softAborter = new AbortController;
let softSignal = softAborter.signal;
for await (let evs of poll({ signal, softSignal })) {
for (let ev of evs) {
if (ev.type == "takeover") {
welcomeScreen.setMessage(L, 'snake.welcome.takeover');
reconnect = false;
return;
}
if (ev.type == 'nogamer') {
welcomeScreen.setMessage(L, 'snake.welcome.serverError.kick');
reconnect = false;
softAborter.abort();
}
try {
handleEvent(ev);
} catch(e) {
handleClientError(e);
reconnect = false;
return;
}
}
}
} catch(e) {
console.error('POLL ERROR', e);
if (e.netInfo) {
handleServerError(e.netInfo);
aborter.abort();
reconnect = false;
}
} finally {
cancelGame = null;
game = null;
shouldRedraw = true;
if (!reconnect) {
welcomeScreen.setVisible(true);
console.log('Exiting back to main menu');
}
genToken();
aborter.abort();
if (reconnect) {
let aborter = new AbortController;
let signal = aborter.signal;
let canceller = () => aborter.abort();
cancelGame = canceller;
console.log('Trying to reconnect...');
welcomeScreen.setMessage();
await sleep(1000, signal)
.then(
() => {
cancelGame = null;
return tryJoinGame({ reconnecting: true, cancelGame: canceller, signal });
},
async (e) => {
cancelGame = null;
welcomeScreen.setVisible(true);
console.log("cancelled reconnect during wait");
throw e;
}
);
}
}
}
main();
</script>
'''
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);
}
}