diff --git a/README.md b/README.md index fdef2ff6..f20de698 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Configuration takes place in the environment variables that are defined in ecosy | BACKUP_DIR | mounted directory of backup server | "/mnt/backup/" | | GMAIL_USER | gmail username if used for mails | "ppfun@gmail.com" | | GMAIL_PW | gmail password if used for mails | "lolrofls" | +| HOURLY_EVENT | run hourly void event on main canvas | 1 | Notes: @@ -238,6 +239,10 @@ Alternatively you can run it with pm2, just like pixelplanet. An example ecosyst Note: - You do not have to run backups or historical view, it's optional. +### Hourly Event + +Hourly event is an MMORPG style event that launches once in two hours where users have to fight against a growing void that starts at a random position at the main canvas. If they complete it successfully, the whole canvas will have half cooldown for a few minutes. + ### Historical view ![historicalview](promotion/historicalview.gif) diff --git a/src/client.js b/src/client.js index 11c1dd69..0dd94f97 100644 --- a/src/client.js +++ b/src/client.js @@ -36,7 +36,8 @@ function init() { ProtocolClient.on('pixelUpdate', ({ i, j, offset, color, }) => { - store.dispatch(receivePixelUpdate(i, j, offset, color)); + // remove protection + store.dispatch(receivePixelUpdate(i, j, offset, color & 0x7F)); }); ProtocolClient.on('pixelReturn', ({ retCode, wait, coolDownSeconds, diff --git a/src/components/ChatMessage.jsx b/src/components/ChatMessage.jsx index f95717fc..ce153ea4 100644 --- a/src/components/ChatMessage.jsx +++ b/src/components/ChatMessage.jsx @@ -20,9 +20,12 @@ function ChatMessage({ } const isInfo = (name === 'info'); + const isEvent = (name === 'event'); let className = 'msg'; if (isInfo) { className += ' info'; + } else if (isEvent) { + className += ' event'; } else if (msgArray[0][1].charAt(0) === '>') { className += ' greentext'; } @@ -30,7 +33,7 @@ function ChatMessage({ return (
{
- (!isInfo)
+ (!isInfo && !isEvent)
&& (
0) ? cOffX : 0;
@@ -212,19 +215,19 @@ export async function protectCanvasArea(
const endX = (cOffXE >= TILE_SIZE) ? TILE_SIZE : cOffXE;
const endY = (cOffYE >= TILE_SIZE) ? TILE_SIZE : cOffYE;
let pxlCnt = 0;
- for (let py = startX; py < endX; py += 1) {
- for (let px = startY; px < endY; px += 1) {
- const offset = (px + py * TILE_SIZE) * 3;
+ for (let py = startY; py < endY; py += 1) {
+ for (let px = startX; px < endX; px += 1) {
+ const offset = px + py * TILE_SIZE;
if (protect) {
chunk[offset] |= 0x80;
} else {
- chunk[offset] &= 0x07;
+ chunk[offset] &= 0x7F;
}
pxlCnt += 1;
}
}
if (pxlCnt) {
- const ret = await RedisCanvas.setChunk(cx, cy, chunk);
+ const ret = await RedisCanvas.setChunk(cx, cy, chunk, canvasId);
if (ret) {
// eslint-disable-next-line max-len
logger.info(`Set protection for ${pxlCnt} pixels in chunk ${cx}, ${cy}.`);
diff --git a/src/core/Void.js b/src/core/Void.js
new file mode 100644
index 00000000..30798c47
--- /dev/null
+++ b/src/core/Void.js
@@ -0,0 +1,183 @@
+/*
+ * this is the actual event
+ * A ever growing circle of random pixels starts at event area
+ * users fight it with background pixels
+ * if it reaches the TARGET_RADIUS size, the event is lost
+ *
+ * @flow
+ */
+import webSockets from '../socket/websockets';
+import WebSocketEvents from '../socket/WebSocketEvents';
+import PixelUpdate from '../socket/packets/PixelUpdateServer';
+import { setPixelByOffset } from './setPixel';
+import { TILE_SIZE } from './constants';
+import { CANVAS_ID } from '../data/models/Event';
+// eslint-disable-next-line import/no-unresolved
+import canvases from './canvases.json';
+
+const TARGET_RADIUS = 62;
+const EVENT_DURATION_MIN = 10;
+// const EVENT_DURATION_MIN = 1;
+
+class Void extends WebSocketEvents {
+ i: number;
+ j: number;
+ maxClr: number;
+ msTimeout: number;
+ pixelStack: Array;
+ area: Object;
+ curRadius: number;
+ curAngle: number;
+ curAngleDelta: number;
+ ended: boolean;
+
+ constructor(centerCell) {
+ super();
+ // chunk coordinates
+ const [i, j] = centerCell;
+ this.i = i;
+ this.j = j;
+ this.ended = false;
+ this.maxClr = canvases[CANVAS_ID].colors.length;
+ const area = TARGET_RADIUS ** 2 * Math.PI;
+ const online = webSockets.onlineCounter;
+ const requiredSpeed = online * 4;
+ const ppm = Math.ceil(area / EVENT_DURATION_MIN + requiredSpeed);
+ // timeout between pixels
+ this.msTimeout = 60 * 1000 / ppm;
+ // area where we log placed pixels
+ this.area = new Uint8Array(TILE_SIZE * 3 * TILE_SIZE * 3);
+ // array of pixels that we place before continue building (instant-defense)
+ this.pixelStack = [];
+ this.curRadius = 0;
+ this.curAngle = 0;
+ this.curAngleDelta = Math.PI;
+
+ this.voidLoop = this.voidLoop.bind(this);
+ this.cancel = this.cancel.bind(this);
+ this.checkStatus = this.checkStatus.bind(this);
+ this.broadcastPixelBuffer = this.broadcastPixelBuffer.bind(this);
+ webSockets.addListener(this);
+ this.voidLoop();
+ }
+
+ /*
+ * send pixel relative to 3x3 tile area
+ */
+ sendPixel(x, y, clr) {
+ const [u, v, off] = Void.coordsToOffset(x, y);
+ const i = this.i + u;
+ const j = this.j + v;
+ this.area[x + y * TILE_SIZE * 3] = clr;
+ setPixelByOffset(CANVAS_ID, clr, i, j, off);
+ }
+
+ /*
+ * check if pixel is set by us
+ * x, y relative to 3x3 tiles area
+ */
+ isSet(x, y, resetIfSet = false) {
+ const off = x + y * TILE_SIZE * 3;
+ const clr = this.area[off];
+ if (clr) {
+ if (resetIfSet) this.area[off] = 0;
+ return true;
+ }
+ return false;
+ }
+
+ static coordsToOffset(x, y) {
+ const ox = x % TILE_SIZE;
+ const oy = y % TILE_SIZE;
+ const off = ox + oy * TILE_SIZE;
+ const u = (x - ox) / TILE_SIZE - 1;
+ const v = (y - oy) / TILE_SIZE - 1;
+ return [u, v, off];
+ }
+
+ voidLoop() {
+ if (this.ended) {
+ return;
+ }
+ let clr = 0;
+ while (clr <= 2 || clr === 25) {
+ // choose random color
+ clr = Math.floor(Math.random() * this.maxClr);
+ }
+ const pxl = this.pixelStack.pop();
+ if (pxl) {
+ // use stack pixel if available
+ const [x, y] = pxl;
+ this.sendPixel(x, y, clr);
+ } else {
+ // build in a circle
+ /* that really is the best here */
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ this.curAngle += this.curAngleDelta;
+ if (this.curAngle > 2 * Math.PI) {
+ // it does skip some pixel, but thats ok
+ this.curRadius += 1;
+ if (this.curRadius > TARGET_RADIUS) {
+ this.ended = true;
+ return;
+ }
+ this.curAngleDelta = 2 * Math.PI / (2 * this.curRadius * Math.PI);
+ this.curAngle = 0;
+ }
+ const { curAngle, curRadius } = this;
+ let gk = Math.sin(curAngle) * curRadius;
+ let ak = Math.cos(curAngle) * curRadius;
+ if (gk > 0) gk = Math.floor(gk);
+ else gk = Math.ceil(gk);
+ if (ak > 0) ak = Math.floor(ak);
+ else ak = Math.ceil(ak);
+ const x = ak + TILE_SIZE * 1.5;
+ const y = gk + TILE_SIZE * 1.5;
+ if (this.isSet(x, y)) {
+ continue;
+ }
+ this.sendPixel(x, y, clr);
+ break;
+ }
+ }
+ setTimeout(this.voidLoop, this.msTimeout);
+ }
+
+ cancel() {
+ webSockets.remListener(this);
+ this.ended = true;
+ }
+
+ checkStatus() {
+ if (this.ended) {
+ webSockets.remListener(this);
+ return 100;
+ }
+ return Math.floor(this.curRadius * 100 / TARGET_RADIUS);
+ }
+
+ broadcastPixelBuffer(canvasId, chunkid, buffer) {
+ const {
+ i: pi,
+ j: pj,
+ offset: off,
+ color,
+ } = PixelUpdate.hydrate(buffer);
+ if (color <= 2 || color === 25) {
+ const { i, j } = this;
+ // 3x3 chunk area (this is hardcoded on multiple places)
+ if (pi >= i - 1 && pi <= i + 1 && pj >= j - 1 && pj <= j + 1) {
+ const uOff = (pi - i + 1) * TILE_SIZE;
+ const vOff = (pj - j + 1) * TILE_SIZE;
+ const x = uOff + off % TILE_SIZE;
+ const y = vOff + Math.floor(off / TILE_SIZE);
+ if (this.isSet(x, y, true)) {
+ this.pixelStack.push([x, y]);
+ }
+ }
+ }
+ }
+}
+
+export default Void;
diff --git a/src/core/config.js b/src/core/config.js
index 67f40716..0d5124b0 100644
--- a/src/core/config.js
+++ b/src/core/config.js
@@ -41,6 +41,9 @@ export const DISCORD_INVITE = process.env.DISCORD_INVITE
// Logging
export const LOG_MYSQL = parseInt(process.env.LOG_MYSQL, 10) || false;
+// do hourly event
+export const HOURLY_EVENT = parseInt(process.env.HOURLY_EVENT, 10) || false;
+
// Accounts
export const APISOCKET_KEY = process.env.APISOCKET_KEY || 'changethis';
// Comma seperated list of user ids of Admins
diff --git a/src/core/draw.js b/src/core/draw.js
index 6aa3dd0f..3b0fe01b 100644
--- a/src/core/draw.js
+++ b/src/core/draw.js
@@ -5,61 +5,21 @@ import { using } from 'bluebird';
import type { User } from '../data/models';
import { redlock } from '../data/redis';
import {
- getChunkOfPixel,
- getOffsetOfPixel,
getPixelFromChunkOffset,
} from './utils';
-import webSockets from '../socket/websockets';
import logger, { pixelLogger } from './logger';
import RedisCanvas from '../data/models/RedisCanvas';
+import {
+ setPixelByOffset,
+ setPixelByCoords,
+} from './setPixel';
+import rpgEvent from './event';
// eslint-disable-next-line import/no-unresolved
import canvases from './canvases.json';
import { THREE_CANVAS_HEIGHT, THREE_TILE_SIZE, TILE_SIZE } from './constants';
-/**
- *
- * @param canvasId
- * @param canvasId
- * @param color
- * @param x
- * @param y
- * @param z optional, if given its 3d canvas
- */
-export function setPixelByCoords(
- canvasId: number,
- color: ColorIndex,
- x: number,
- y: number,
- z: number = null,
-) {
- const canvasSize = canvases[canvasId].size;
- const [i, j] = getChunkOfPixel(canvasSize, x, y, z);
- const offset = getOffsetOfPixel(canvasSize, x, y, z);
- RedisCanvas.setPixelInChunk(i, j, offset, color, canvasId);
- webSockets.broadcastPixel(canvasId, i, j, offset, color);
-}
-
-/**
- *
- * By Offset is prefered on server side
- * @param canvasId
- * @param i Chunk coordinates
- * @param j
- * @param offset Offset of pixel withing chunk
- */
-export function setPixelByOffset(
- canvasId: number,
- color: ColorIndex,
- i: number,
- j: number,
- offset: number,
-) {
- RedisCanvas.setPixelInChunk(i, j, offset, color, canvasId);
- webSockets.broadcastPixel(canvasId, i, j, offset, color);
-}
-
/**
*
* By Offset is prefered on server side
@@ -143,6 +103,14 @@ export async function drawByOffset(
coolDown = (setColor & 0x3F) < canvas.cli ? canvas.bcd : canvas.pcd;
if (user.isAdmin()) {
coolDown = 0.0;
+ } else if (rpgEvent.success) {
+ if (rpgEvent.success === 1) {
+ // if HOURLY_EVENT got won
+ coolDown /= 2;
+ } else {
+ // if HOURLY_EVENT got lost
+ coolDown *= 2;
+ }
}
const now = Date.now();
@@ -299,6 +267,14 @@ export async function drawByCoords(
let coolDown = (setColor & 0x3F) < canvas.cli ? canvas.bcd : canvas.pcd;
if (user.isAdmin()) {
coolDown = 0.0;
+ } else if (rpgEvent.success) {
+ if (rpgEvent.success === 1) {
+ // if HOURLY_EVENT got won
+ coolDown /= 2;
+ } else {
+ // if HOURLY_EVENT got lost
+ coolDown *= 2;
+ }
}
const now = Date.now();
diff --git a/src/core/event.js b/src/core/event.js
new file mode 100644
index 00000000..e3648d53
--- /dev/null
+++ b/src/core/event.js
@@ -0,0 +1,354 @@
+/*
+ * This is an even that happens all 2h,
+ * if the users complete, they will get rewarded by half the cooldown sitewide
+ *
+ * @flow
+ */
+
+import logger from './logger';
+import {
+ nextEvent,
+ setNextEvent,
+ getEventArea,
+ clearOldEvent,
+ CANVAS_ID,
+} from '../data/models/Event';
+import Void from './Void';
+import { protectCanvasArea } from './Image';
+import { setPixelByOffset } from './setPixel';
+import { TILE_SIZE } from './constants';
+import chatProvider from './ChatProvider';
+import { HOURLY_EVENT } from './config';
+// eslint-disable-next-line import/no-unresolved
+import canvases from './canvases.json';
+
+// steps in minutes for event stages
+const STEPS = [30, 10, 2, 1, 0, -10, -15, -40, -60];
+// const STEPS = [4, 3, 2, 1, 0, -1, -2, -3, -4];
+// gap between events in min, starting 1h after last event
+// so 60 is a 2h gap, has to be higher than first and highest STEP numbers
+const EVENT_GAP_MIN = 60;
+// const EVENT_GAP_MIN = 5;
+
+/*
+ * draw cross in center of chunk
+ * @param centerCell chunk coordinates
+ * @param clr color
+ * @param style 0 solid, 1 dashed, 2 dashed invert
+ * @param radius Radius (total width/height will be radius * 2 + 1)
+ */
+function drawCross(centerCell, clr, style, radius) {
+ const [i, j] = centerCell;
+ const center = (TILE_SIZE + 1) * TILE_SIZE / 2;
+ if (style !== 2) {
+ setPixelByOffset(CANVAS_ID, clr, i, j, center);
+ }
+ for (let r = 1; r < radius; r += 1) {
+ if (style) {
+ if (r % 2) {
+ if (style === 1) continue;
+ } else if (style === 2) {
+ continue;
+ }
+ }
+ let offset = center - TILE_SIZE * r;
+ setPixelByOffset(CANVAS_ID, clr, i, j, offset);
+ offset = center + TILE_SIZE * r;
+ setPixelByOffset(CANVAS_ID, clr, i, j, offset);
+ offset = center - r;
+ setPixelByOffset(CANVAS_ID, clr, i, j, offset);
+ offset = center + r;
+ setPixelByOffset(CANVAS_ID, clr, i, j, offset);
+ }
+}
+
+
+class Event {
+ eventState: number;
+ eventTimestamp: number;
+ eventCenter: Array;
+ eventCenterC: Array;
+ eventArea: Array;
+ success: boolean;
+ void: Object;
+ chatTimeout: number;
+
+ constructor() {
+ this.enabled = HOURLY_EVENT;
+ this.eventState = -1;
+ this.eventCenterC = null;
+ this.void = null;
+ // 0 if waiting
+ // 1 if won
+ // 2 if lost
+ this.success = 0;
+ this.chatTimeout = 0;
+ this.runEventLoop = this.runEventLoop.bind(this);
+ if (HOURLY_EVENT) {
+ this.initEventLoop();
+ }
+ }
+
+ async initEventLoop() {
+ let eventTimestamp = await nextEvent();
+ if (!eventTimestamp) {
+ eventTimestamp = await Event.setNextEvent();
+ await this.calcEventCenter();
+ const [x, y, w, h] = this.eventArea;
+ await protectCanvasArea(CANVAS_ID, x, y, w, h, true);
+ }
+ this.eventTimestamp = eventTimestamp;
+ await this.calcEventCenter();
+ this.runEventLoop();
+ }
+
+ eventTimer() {
+ const now = Date.now();
+ return Math.floor((this.eventTimestamp - now) / 1000);
+ }
+
+ async calcEventCenter() {
+ const cCoor = await getEventArea();
+ if (cCoor) {
+ this.eventCenterC = cCoor;
+ const {
+ size: canvasSize,
+ } = canvases[CANVAS_ID];
+ const [ux, uy] = cCoor.map((z) => (z - 1) * TILE_SIZE - canvasSize / 2);
+ this.eventArea = [ux, uy, TILE_SIZE * 3, TILE_SIZE * 3];
+ }
+ }
+
+ static getDirection(x, y) {
+ const { size: canvasSize } = canvases[CANVAS_ID];
+ let direction = null;
+ const distSquared = x ** 2 + y ** 2;
+
+ if (distSquared < 1000 ** 2) direction = 'center';
+ else if (x < 0 && y < 0) direction = 'North-West';
+ else if (x >= 0 && y < 0) direction = 'North-East';
+ else if (x < 0 && y >= 0) direction = 'South-West';
+ else if (x >= 0 && y >= 0) direction = 'South-East';
+ if (distSquared > (canvasSize / 2) ** 2) direction = `far ${direction}`;
+
+ return direction;
+ }
+
+ static async setNextEvent() {
+ // define next Event area
+ const { size: canvasSize } = canvases[CANVAS_ID];
+ // make sure that its the center of a 3x3 area
+ const i = Math.floor(Math.random() * (canvasSize / TILE_SIZE - 2)) + 1;
+ const j = Math.floor(Math.random() * (canvasSize / TILE_SIZE - 2)) + 1;
+ // backup it and schedul next event in 1h
+ await setNextEvent(EVENT_GAP_MIN, i, j);
+ const timestamp = await nextEvent();
+ const x = i * TILE_SIZE - canvasSize / 2;
+ const y = j * TILE_SIZE - canvasSize / 2;
+ chatProvider.broadcastChatMessage(
+ 'event',
+ `Suspicious activity spotted in ${Event.getDirection(x, y)}`,
+ );
+ drawCross([i, j], 19, 0, 13);
+ logger.info(`Set next Event in 60min at ${x},${y}`);
+ return timestamp;
+ }
+
+ async runEventLoop() {
+ const {
+ eventState,
+ } = this;
+ const eventSeconds = this.eventTimer();
+ const eventMinutes = eventSeconds / 60;
+
+ if (eventMinutes > STEPS[0]) {
+ // 1h to 30min before Event: blinking dotted cross
+ if (eventState !== 1) {
+ this.eventState = 1;
+ // color 15 protected
+ drawCross(this.eventCenterC, 15, 1, 9);
+ drawCross(this.eventCenterC, 0, 2, 9);
+ } else {
+ this.eventState = 2;
+ drawCross(this.eventCenterC, 16, 2, 9);
+ drawCross(this.eventCenterC, 0, 1, 9);
+ }
+ setTimeout(this.runEventLoop, 2000);
+ } else if (eventMinutes > STEPS[1]) {
+ // 10min to 30min before Event: blinking solid cross
+ if (eventState !== 3 && eventState !== 4) {
+ this.eventState = 3;
+ const [x, y] = this.eventArea;
+ chatProvider.broadcastChatMessage(
+ 'event',
+ `Unstable area at ${Event.getDirection(x, y)} at concerning level`,
+ );
+ }
+ if (eventState !== 3) {
+ this.eventState = 3;
+ drawCross(this.eventCenterC, 30, 1, 9);
+ drawCross(this.eventCenterC, 0, 2, 9);
+ } else {
+ this.eventState = 4;
+ drawCross(this.eventCenterC, 31, 2, 9);
+ drawCross(this.eventCenterC, 0, 1, 9);
+ }
+ setTimeout(this.runEventLoop, 1500);
+ } else if (eventMinutes > STEPS[2]) {
+ // 2min to 10min before Event: blinking solid cross
+ if (eventState !== 5) {
+ this.eventState = 5;
+ drawCross(this.eventCenterC, 12, 0, 7);
+ } else {
+ this.eventState = 6;
+ drawCross(this.eventCenterC, 13, 0, 7);
+ }
+ setTimeout(this.runEventLoop, 1000);
+ } else if (eventMinutes > STEPS[3]) {
+ // 1min to 2min before Event: blinking solid cross red small
+ if (eventState !== 7 && eventState !== 8) {
+ this.eventState = 7;
+ const [x, y] = this.eventArea;
+ const [xNear, yNear] = [x, y].map((z) => {
+ const rand = Math.random() * 3000 - 500;
+ return Math.floor(z + TILE_SIZE * 1.5 + rand);
+ });
+ chatProvider.broadcastChatMessage(
+ 'event',
+ `Alert! Void is rising in 2min near #d,${xNear},${yNear},30`,
+ );
+ }
+ if (eventState !== 7) {
+ drawCross(this.eventCenterC, 11, 0, 5);
+ this.eventState = 7;
+ } else {
+ drawCross(this.eventCenterC, 12, 0, 5);
+ this.eventState = 8;
+ }
+ setTimeout(this.runEventLoop, 1000);
+ } else if (eventMinutes > STEPS[4]) {
+ // 1min till Event: blinking solid cross red small fase
+ if (eventState !== 9 && eventState !== 10) {
+ this.eventState = 9;
+ chatProvider.broadcastChatMessage(
+ 'event',
+ 'Alert! Threat rising!',
+ );
+ }
+ if (eventState !== 9) {
+ this.eventState = 9;
+ drawCross(this.eventCenterC, 11, 0, 3);
+ } else {
+ this.eventState = 10;
+ drawCross(this.eventCenterC, 19, 0, 3);
+ }
+ setTimeout(this.runEventLoop, 500);
+ } else if (eventMinutes > STEPS[5]) {
+ if (eventState !== 11) {
+ // start event
+ const [x, y, w, h] = this.eventArea;
+ await protectCanvasArea(CANVAS_ID, x, y, w, h, false);
+ logger.info(`Starting Event at ${x},${y} now`);
+ chatProvider.broadcastChatMessage(
+ 'event',
+ 'Fight starting!',
+ );
+ this.void = new Void(this.eventCenterC);
+ this.eventState = 11;
+ } else if (this.void) {
+ const percent = this.void.checkStatus();
+ if (percent === 100) {
+ // event lost
+ logger.info(`Event got lost after ${Math.abs(eventMinutes)} min`);
+ chatProvider.broadcastChatMessage(
+ 'event',
+ 'Threat couldn\'t be contained, abandon area',
+ );
+ this.success = 2;
+ this.void = null;
+ const [x, y, w, h] = this.eventArea;
+ await protectCanvasArea(CANVAS_ID, x, y, w, h, true);
+ } else {
+ const now = Date.now();
+ if (now > this.chatTimeout) {
+ chatProvider.broadcastChatMessage(
+ 'event',
+ `Void reached ${percent}% of its max size`,
+ );
+ this.chatTimeout = now + 40000;
+ }
+ }
+ }
+ // run event for 10min
+ setTimeout(this.runEventLoop, 1000);
+ } else if (eventMinutes > STEPS[6]) {
+ if (eventState !== 12) {
+ // after 10min of event
+ // check if won
+ if (this.void) {
+ if (this.void.checkStatus() !== 100) {
+ // event won
+ logger.info('Event got won! Cooldown sitewide now half.');
+ chatProvider.broadcastChatMessage(
+ 'event',
+ 'Threat successfully defeated. Good work!',
+ );
+ this.success = 1;
+ }
+ this.void.cancel();
+ this.void = null;
+ }
+ this.eventState = 12;
+ }
+ // for 30min after event
+ // do nothing
+ setTimeout(this.runEventLoop, 60000);
+ } else if (eventMinutes > STEPS[7]) {
+ if (eventState !== 13) {
+ // 5min after last Event
+ // end debuff if lost
+ if (this.success === 2) {
+ chatProvider.broadcastChatMessage(
+ 'event',
+ 'Void seems to leave again.',
+ );
+ this.success = 0;
+ }
+ this.eventState = 13;
+ }
+ setTimeout(this.runEventLoop, 60000);
+ } else if (eventMinutes > STEPS[8]) {
+ if (eventState !== 14) {
+ // 30min after last Event
+ // clear old event area
+ // reset success state
+ logger.info('Restoring old event area');
+ await clearOldEvent();
+ if (this.success === 1) {
+ chatProvider.broadcastChatMessage(
+ 'event',
+ 'Celebration time over, get back to work.',
+ );
+ this.success = 0;
+ }
+ this.eventState = 14;
+ }
+ // 30min to 50min after last Event
+ // do nothing
+ setTimeout(this.runEventLoop, 60000);
+ } else {
+ // 50min after last Event / 1h before next Event
+ // define and protect it
+ this.eventTimestamp = await Event.setNextEvent();
+ await this.calcEventCenter();
+ const [x, y, w, h] = this.eventArea;
+ await protectCanvasArea(CANVAS_ID, x, y, w, h, true);
+
+ setTimeout(this.runEventLoop, 60000);
+ }
+ }
+}
+
+const rpgEvent = new Event();
+
+export default rpgEvent;
diff --git a/src/core/setPixel.js b/src/core/setPixel.js
new file mode 100644
index 00000000..f2b4e220
--- /dev/null
+++ b/src/core/setPixel.js
@@ -0,0 +1,52 @@
+/* @flow */
+import RedisCanvas from '../data/models/RedisCanvas';
+import webSockets from '../socket/websockets';
+import {
+ getChunkOfPixel,
+ getOffsetOfPixel,
+} from './utils';
+// eslint-disable-next-line import/no-unresolved
+import canvases from './canvases.json';
+
+
+/**
+ *
+ * @param canvasId
+ * @param canvasId
+ * @param color
+ * @param x
+ * @param y
+ * @param z optional, if given its 3d canvas
+ */
+export function setPixelByCoords(
+ canvasId: number,
+ color: ColorIndex,
+ x: number,
+ y: number,
+ z: number = null,
+) {
+ const canvasSize = canvases[canvasId].size;
+ const [i, j] = getChunkOfPixel(canvasSize, x, y, z);
+ const offset = getOffsetOfPixel(canvasSize, x, y, z);
+ RedisCanvas.setPixelInChunk(i, j, offset, color, canvasId);
+ webSockets.broadcastPixel(canvasId, i, j, offset, color);
+}
+
+/**
+ *
+ * By Offset is prefered on server side
+ * @param canvasId
+ * @param i Chunk coordinates
+ * @param j
+ * @param offset Offset of pixel withing chunk
+ */
+export function setPixelByOffset(
+ canvasId: number,
+ color: ColorIndex,
+ i: number,
+ j: number,
+ offset: number,
+) {
+ RedisCanvas.setPixelInChunk(i, j, offset, color, canvasId);
+ webSockets.broadcastPixel(canvasId, i, j, offset, color);
+}
diff --git a/src/data/models/Event.js b/src/data/models/Event.js
new file mode 100644
index 00000000..0c8e6c0a
--- /dev/null
+++ b/src/data/models/Event.js
@@ -0,0 +1,106 @@
+/*
+ *
+ * data saving for hourly events
+ *
+ * @flow
+ */
+
+// its ok if its slow
+/* eslint-disable no-await-in-loop */
+
+import redis from '../redis';
+import logger from '../../core/logger';
+import RedisCanvas from './RedisCanvas';
+
+const EVENT_TIMESTAMP_KEY = 'evt:time';
+const EVENT_POSITION_KEY = 'evt:pos';
+const EVENT_BACKUP_PREFIX = 'evt:bck';
+// Note: Events always happen on canvas 0
+export const CANVAS_ID = '0';
+
+/*
+ * @return time till next event in seconds
+ */
+export async function nextEvent() {
+ const timestamp = await redis.getAsync(EVENT_TIMESTAMP_KEY);
+ if (timestamp) {
+ return Number(timestamp.toString());
+ }
+ return null;
+}
+
+/*
+ * @return cell of chunk coordinates of event
+ */
+export async function getEventArea() {
+ const pos = await redis.getAsync(EVENT_POSITION_KEY);
+ if (pos) {
+ return pos.toString().split(':').map((z) => Number(z));
+ }
+ return null;
+}
+
+/*
+ * restore area effected by last event
+ */
+export async function clearOldEvent() {
+ const pos = await getEventArea();
+ if (pos) {
+ const [i, j] = pos;
+ logger.info(`Restore last event area at ${i}/${j}`);
+ // 3x3 chunk area centered at i,j
+ for (let jc = j - 1; jc <= j + 1; jc += 1) {
+ for (let ic = i - 1; ic <= i + 1; ic += 1) {
+ const chunkKey = `${EVENT_BACKUP_PREFIX}:${ic}:${jc}`;
+ const chunk = await redis.getAsync(chunkKey);
+ if (!chunk) {
+ logger.warn(
+ // eslint-disable-next-line max-len
+ `Couldn't get chunk event backup for ${ic}/${jc}, which is weird`,
+ );
+ continue;
+ }
+ if (chunk.length <= 256) {
+ logger.info(
+ // eslint-disable-next-line max-len
+ `Tiny chunk in event backup, not-generated chunk at ${ic}/${jc}`,
+ );
+ await redis.delAsync(`ch:${CANVAS_ID}:${ic}:${jc}`);
+ } else {
+ logger.info(
+ `Restoring chunk ${ic}/${jc} from event`,
+ );
+ await redis.setAsync(`ch:${CANVAS_ID}:${ic}:${jc}`, chunk);
+ }
+ await redis.delAsync(chunkKey);
+ }
+ }
+ await redis.delAsync(EVENT_POSITION_KEY);
+ }
+}
+
+/*
+ * Set time of next event
+ * @param minutes minutes till next event
+ * @param i, j chunk coordinates of center of event
+ */
+export async function setNextEvent(minutes: number, i: number, j: number) {
+ await clearOldEvent();
+ for (let jc = j - 1; jc <= j + 1; jc += 1) {
+ for (let ic = i - 1; ic <= i + 1; ic += 1) {
+ let chunk = await redis.getAsync(`ch:${CANVAS_ID}:${ic}:${jc}`);
+ if (!chunk) {
+ // place a dummy Array inside to mark chunk as none-existent
+ const buff = new Uint8Array(3);
+ chunk = Buffer.from(buff);
+ // place dummy pixel to make RedisCanvas create chunk
+ await RedisCanvas.setPixelInChunk(ic, jc, 0, 0, CANVAS_ID);
+ }
+ const chunkKey = `${EVENT_BACKUP_PREFIX}:${ic}:${jc}`;
+ await redis.setAsync(chunkKey, chunk);
+ }
+ }
+ await redis.setAsync(EVENT_POSITION_KEY, `${i}:${j}`);
+ const timestamp = Date.now() + minutes * 60 * 1000;
+ await redis.setAsync(EVENT_TIMESTAMP_KEY, timestamp);
+}
diff --git a/src/data/models/RedisCanvas.js b/src/data/models/RedisCanvas.js
index d5f3069c..28e3c7f8 100644
--- a/src/data/models/RedisCanvas.js
+++ b/src/data/models/RedisCanvas.js
@@ -38,7 +38,9 @@ class RedisCanvas {
i: number,
j: number,
): Promise