From dc6b1f8cbe9f28e040efbbc1c2aaf6809941402b Mon Sep 17 00:00:00 2001 From: HF Date: Sun, 24 May 2020 17:52:04 +0200 Subject: [PATCH] add MMORPG style Event --- README.md | 5 + src/client.js | 3 +- src/components/ChatMessage.jsx | 5 +- src/core/Image.js | 21 +- src/core/Void.js | 183 +++++++++++++++++ src/core/config.js | 3 + src/core/draw.js | 66 ++---- src/core/event.js | 354 +++++++++++++++++++++++++++++++++ src/core/setPixel.js | 52 +++++ src/data/models/Event.js | 106 ++++++++++ src/data/models/RedisCanvas.js | 4 +- src/socket/APISocketServer.js | 3 +- src/socket/websockets.js | 10 + src/styles/dark-round.css | 3 + src/styles/dark.css | 3 + src/styles/default.css | 3 + 16 files changed, 766 insertions(+), 58 deletions(-) create mode 100644 src/core/Void.js create mode 100644 src/core/event.js create mode 100644 src/core/setPixel.js create mode 100644 src/data/models/Event.js diff --git a/README.md b/README.md index fdef2ff..f20de69 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 11c1dd6..0dd94f9 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 f95717f..ce153ea 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 0000000..30798c4 --- /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 67f4071..0d5124b 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 6aa3dd0..3b0fe01 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 0000000..e3648d5 --- /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 0000000..f2b4e22 --- /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 0000000..0c8e6c0 --- /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 d5f3069..28e3c7f 100644 --- a/src/data/models/RedisCanvas.js +++ b/src/data/models/RedisCanvas.js @@ -38,7 +38,9 @@ class RedisCanvas { i: number, j: number, ): Promise { - // this key is also hardcoded into core/tilesBackup.js + // this key is also hardcoded into + // core/tilesBackup.js + // and ./EventData.js const key = `ch:${canvasId}:${i}:${j}`; return redis.getAsync(key); } diff --git a/src/socket/APISocketServer.js b/src/socket/APISocketServer.js index 6894f59..d94dafe 100644 --- a/src/socket/APISocketServer.js +++ b/src/socket/APISocketServer.js @@ -15,7 +15,8 @@ import WebSocketEvents from './WebSocketEvents'; import webSockets from './websockets'; import { getIPFromRequest } from '../utils/ip'; import Minecraft from '../core/minecraft'; -import { drawByCoords, setPixelByCoords } from '../core/draw'; +import { setPixelByCoords } from '../core/setPixel'; +import { drawByCoords } from '../core/draw'; import logger from '../core/logger'; import { APISOCKET_KEY } from '../core/config'; import chatProvider from '../core/ChatProvider'; diff --git a/src/socket/websockets.js b/src/socket/websockets.js index 62fbb59..48f1d97 100644 --- a/src/socket/websockets.js +++ b/src/socket/websockets.js @@ -11,15 +11,24 @@ import PixelUpdate from './packets/PixelUpdateServer'; class WebSockets { listeners: Array; + onlineCounter: number; constructor() { this.listeners = []; + this.onlineCounter = 0; } addListener(listener) { this.listeners.push(listener); } + remListener(listener) { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + } + /* * broadcast message via websocket * @param message Message to send @@ -125,6 +134,7 @@ class WebSockets { * @param online Number of users online */ broadcastOnlineCounter(online: number) { + this.onlineCounter = online; const buffer = OnlineCounter.dehydrate({ online }); this.listeners.forEach( (listener) => listener.broadcastOnlineCounter(buffer), diff --git a/src/styles/dark-round.css b/src/styles/dark-round.css index b6870c2..42de4d6 100644 --- a/src/styles/dark-round.css +++ b/src/styles/dark-round.css @@ -95,6 +95,9 @@ tr:nth-child(even) { .msg.info{ color: #ff91a6; } +.msg.event{ + color: #9dc8ff; +} .msg.greentext{ color: #94ff94; } diff --git a/src/styles/dark.css b/src/styles/dark.css index 8beb0b5..5a5e799 100644 --- a/src/styles/dark.css +++ b/src/styles/dark.css @@ -92,6 +92,9 @@ tr:nth-child(even) { .msg.info{ color: #ff91a6; } +.msg.event{ + color: #9dc8ff; +} .msg.greentext{ color: #94ff94; } diff --git a/src/styles/default.css b/src/styles/default.css index b483094..ecaf3d6 100644 --- a/src/styles/default.css +++ b/src/styles/default.css @@ -409,6 +409,9 @@ tr:nth-child(even) { .msg.info { color: #cc0000; } +.msg.event { + color: #3955c6; +} .msg.greentext{ color: green; }