2396 lines
64 KiB
Groovy
2396 lines
64 KiB
Groovy
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);
|
||
}
|
||
}
|