add pixel stacking
This commit is contained in:
parent
c3b8467b8f
commit
e5821027dc
|
@ -5,10 +5,47 @@ import {
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from "@nextui-org/react";
|
} from "@nextui-org/react";
|
||||||
|
import { CanvasLib } from "@sc07-canvas/lib/src/canvas";
|
||||||
import { useAppContext } from "../contexts/AppContext";
|
import { useAppContext } from "../contexts/AppContext";
|
||||||
|
import { Canvas } from "../lib/canvas";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ClientConfig } from "@sc07-canvas/lib/src/net";
|
||||||
|
|
||||||
|
const getTimeLeft = (pixels: { available: number }, config: ClientConfig) => {
|
||||||
|
// this implementation matches the server's implementation
|
||||||
|
|
||||||
|
const cooldown = CanvasLib.getPixelCooldown(pixels.available + 1, config);
|
||||||
|
const pixelExpiresAt =
|
||||||
|
Canvas.instance?.lastPlace && Canvas.instance.lastPlace + cooldown * 1000;
|
||||||
|
const pixelCooldown = pixelExpiresAt && (Date.now() - pixelExpiresAt) / 1000;
|
||||||
|
|
||||||
|
if (!pixelCooldown) return undefined;
|
||||||
|
if (pixelCooldown > 0) return 0;
|
||||||
|
|
||||||
|
return Math.abs(pixelCooldown).toFixed(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlaceCountdown = () => {
|
||||||
|
const { pixels, config } = useAppContext();
|
||||||
|
const [timeLeft, setTimeLeft] = useState(getTimeLeft(pixels, config));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTimeLeft(getTimeLeft(pixels, config));
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [pixels]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>{pixels.available + 1 < config.canvas.pixel.maxStack && timeLeft + "s"}</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const CanvasMeta = () => {
|
export const CanvasMeta = () => {
|
||||||
const { canvasPosition, cursorPosition } = useAppContext();
|
const { canvasPosition, cursorPosition, pixels, config } = useAppContext();
|
||||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -30,7 +67,11 @@ export const CanvasMeta = () => {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
Pixels: <span>123</span>
|
Pixels:{" "}
|
||||||
|
<span>
|
||||||
|
{pixels.available}/{config.canvas.pixel.maxStack}
|
||||||
|
</span>{" "}
|
||||||
|
<PlaceCountdown />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Users Online: <span>321</span>
|
Users Online: <span>321</span>
|
||||||
|
|
|
@ -6,12 +6,12 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
AuthSession,
|
||||||
ClientConfig,
|
ClientConfig,
|
||||||
IAppContext,
|
IAppContext,
|
||||||
ICanvasPosition,
|
ICanvasPosition,
|
||||||
IPosition,
|
IPosition,
|
||||||
} from "../types";
|
} from "@sc07-canvas/lib/src/net";
|
||||||
import { AuthSession } from "@sc07-canvas/lib/src/net";
|
|
||||||
import Network from "../lib/network";
|
import Network from "../lib/network";
|
||||||
|
|
||||||
const appContext = createContext<IAppContext>({} as any);
|
const appContext = createContext<IAppContext>({} as any);
|
||||||
|
@ -24,6 +24,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
||||||
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
|
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
|
||||||
const [cursorPosition, setCursorPosition] = useState<IPosition>();
|
const [cursorPosition, setCursorPosition] = useState<IPosition>();
|
||||||
|
|
||||||
|
const [pixels, setPixels] = useState({ available: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleConfig(config: ClientConfig) {
|
function handleConfig(config: ClientConfig) {
|
||||||
console.info("Server sent config", config);
|
console.info("Server sent config", config);
|
||||||
|
@ -34,14 +36,21 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
||||||
setAuth(user);
|
setAuth(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePixels(pixels: { available: number }) {
|
||||||
|
setPixels(pixels);
|
||||||
|
}
|
||||||
|
|
||||||
Network.on("user", handleUser);
|
Network.on("user", handleUser);
|
||||||
Network.on("config", handleConfig);
|
Network.on("config", handleConfig);
|
||||||
|
Network.waitFor("pixels").then(([data]) => handlePixels(data));
|
||||||
|
Network.on("pixels", handlePixels);
|
||||||
|
|
||||||
Network.socket.connect();
|
Network.socket.connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
Network.off("user", handleUser);
|
Network.off("user", handleUser);
|
||||||
Network.off("config", handleConfig);
|
Network.off("config", handleConfig);
|
||||||
|
Network.off("pixels", handlePixels);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -54,6 +63,7 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
||||||
setCanvasPosition,
|
setCanvasPosition,
|
||||||
cursorPosition,
|
cursorPosition,
|
||||||
setCursorPosition,
|
setCursorPosition,
|
||||||
|
pixels,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{config ? children : "Loading..."}
|
{config ? children : "Loading..."}
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import EventEmitter from "eventemitter3";
|
import EventEmitter from "eventemitter3";
|
||||||
import { ClientConfig, IPalleteContext, IPosition, Pixel } from "../types";
|
import {
|
||||||
|
ClientConfig,
|
||||||
|
IPalleteContext,
|
||||||
|
IPosition,
|
||||||
|
Pixel,
|
||||||
|
} from "@sc07-canvas/lib/src/net";
|
||||||
import Network from "./network";
|
import Network from "./network";
|
||||||
import {
|
import {
|
||||||
ClickEvent,
|
ClickEvent,
|
||||||
|
@ -30,7 +35,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
private pixels: {
|
private pixels: {
|
||||||
[x_y: string]: { color: number; type: "full" | "pending" };
|
[x_y: string]: { color: number; type: "full" | "pending" };
|
||||||
} = {};
|
} = {};
|
||||||
private lastPlace: number | undefined;
|
lastPlace: number | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: ClientConfig,
|
config: ClientConfig,
|
||||||
|
@ -52,6 +57,9 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
|
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
|
||||||
|
|
||||||
Network.waitFor("canvas").then(([pixels]) => this.handleBatch(pixels));
|
Network.waitFor("canvas").then(([pixels]) => this.handleBatch(pixels));
|
||||||
|
Network.waitFor("pixelLastPlaced").then(
|
||||||
|
([time]) => (this.lastPlace = time)
|
||||||
|
);
|
||||||
|
|
||||||
this.draw();
|
this.draw();
|
||||||
}
|
}
|
||||||
|
@ -133,14 +141,13 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
place(x: number, y: number) {
|
place(x: number, y: number) {
|
||||||
if (!this.Pallete.getSelectedColor()) return;
|
if (!this.Pallete.getSelectedColor()) return;
|
||||||
|
|
||||||
if (this.lastPlace) {
|
// TODO: redo this as the server now verifies placements differently
|
||||||
if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
|
// if (this.lastPlace) {
|
||||||
console.log("cannot place; cooldown");
|
// if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
|
||||||
return;
|
// console.log("cannot place; cooldown");
|
||||||
}
|
// return;
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
this.lastPlace = Date.now();
|
|
||||||
|
|
||||||
Network.socket
|
Network.socket
|
||||||
.emitWithAck("place", {
|
.emitWithAck("place", {
|
||||||
|
@ -150,6 +157,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
})
|
})
|
||||||
.then((ack) => {
|
.then((ack) => {
|
||||||
if (ack.success) {
|
if (ack.success) {
|
||||||
|
this.lastPlace = Date.now();
|
||||||
this.handlePixel(ack.data);
|
this.handlePixel(ack.data);
|
||||||
} else {
|
} else {
|
||||||
// TODO: handle undo pixel
|
// TODO: handle undo pixel
|
||||||
|
|
|
@ -11,6 +11,8 @@ export interface INetworkEvents {
|
||||||
user: (user: AuthSession) => void;
|
user: (user: AuthSession) => void;
|
||||||
config: (user: ClientConfig) => void;
|
config: (user: ClientConfig) => void;
|
||||||
canvas: (pixels: string[]) => void;
|
canvas: (pixels: string[]) => void;
|
||||||
|
pixels: (data: { available: number }) => void;
|
||||||
|
pixelLastPlaced: (time: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
|
type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
|
||||||
|
@ -45,6 +47,14 @@ class Network extends EventEmitter<INetworkEvents> {
|
||||||
this._emit("canvas", pixels);
|
this._emit("canvas", pixels);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.socket.on("availablePixels", (count) => {
|
||||||
|
this._emit("pixels", { available: count });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("pixelLastPlaced", (time) => {
|
||||||
|
this._emit("pixelLastPlaced", time);
|
||||||
|
});
|
||||||
|
|
||||||
// this.socket.on("config", (config) => {
|
// this.socket.on("config", (config) => {
|
||||||
// Pallete.load(config.pallete);
|
// Pallete.load(config.pallete);
|
||||||
// Canvas.load(config.canvas);
|
// Canvas.load(config.canvas);
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { type ClientConfig } from "./net";
|
||||||
|
|
||||||
|
export const CanvasLib = new (class {
|
||||||
|
/**
|
||||||
|
* Get pixel cooldown
|
||||||
|
*
|
||||||
|
* @param pixelNumber What pixel is this
|
||||||
|
* @param config
|
||||||
|
* @returns Seconds to take to give the pixel
|
||||||
|
*/
|
||||||
|
getPixelCooldown(pixelNumber: number, config: ClientConfig) {
|
||||||
|
return pixelNumber * config.canvas.pixel.cooldown;
|
||||||
|
// const factorial = (n: number) => (n == 0 ? 1 : n * factorial(n - 1));
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// config.canvas.pixel.cooldown *
|
||||||
|
// config.canvas.pixel.multiplier *
|
||||||
|
// (2 + pixelNumber + factorial(pixelNumber))
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
})();
|
|
@ -6,10 +6,20 @@ export interface ServerToClientEvents {
|
||||||
config: (config: ClientConfig) => void;
|
config: (config: ClientConfig) => void;
|
||||||
pixel: (pixel: Pixel) => void;
|
pixel: (pixel: Pixel) => void;
|
||||||
online: (count: { count: number }) => void;
|
online: (count: { count: number }) => void;
|
||||||
|
availablePixels: (count: number) => void;
|
||||||
|
pixelLastPlaced: (time: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
place: (pixel: Pixel, ack: (_: PacketAck<Pixel>) => void) => void;
|
place: (
|
||||||
|
pixel: Pixel,
|
||||||
|
ack: (
|
||||||
|
_: PacketAck<
|
||||||
|
Pixel,
|
||||||
|
"no_user" | "invalid_pixel" | "pixel_cooldown" | "palette_color_invalid"
|
||||||
|
>
|
||||||
|
) => void
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// app context
|
// app context
|
||||||
|
@ -21,6 +31,7 @@ export interface IAppContext {
|
||||||
setCanvasPosition: (v: ICanvasPosition) => void;
|
setCanvasPosition: (v: ICanvasPosition) => void;
|
||||||
cursorPosition?: IPosition;
|
cursorPosition?: IPosition;
|
||||||
setCursorPosition: (v?: IPosition) => void;
|
setCursorPosition: (v?: IPosition) => void;
|
||||||
|
pixels: { available: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPalleteContext {
|
export interface IPalleteContext {
|
||||||
|
@ -55,6 +66,11 @@ export type PalleteColor = {
|
||||||
export type CanvasConfig = {
|
export type CanvasConfig = {
|
||||||
size: [number, number];
|
size: [number, number];
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
pixel: {
|
||||||
|
maxStack: number;
|
||||||
|
cooldown: number;
|
||||||
|
multiplier: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
|
@ -65,12 +81,16 @@ export type ClientConfig = {
|
||||||
canvas: CanvasConfig;
|
canvas: CanvasConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PacketAck<T> =
|
/**
|
||||||
|
* @template T the packet data
|
||||||
|
* @template E union type of errors possible
|
||||||
|
*/
|
||||||
|
export type PacketAck<T, E = string> =
|
||||||
| {
|
| {
|
||||||
success: true;
|
success: true;
|
||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
| { success: false; error: string };
|
| { success: false; error: E };
|
||||||
|
|
||||||
export type AuthSession = {
|
export type AuthSession = {
|
||||||
service: {
|
service: {
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
Table User {
|
Table User {
|
||||||
sub String [pk]
|
sub String [pk]
|
||||||
lastPixelTime DateTime [default: `now()`, not null]
|
lastPixelTime DateTime [default: `now()`, not null]
|
||||||
|
pixelStack Int [not null, default: 0]
|
||||||
pixels Pixel [not null]
|
pixels Pixel [not null]
|
||||||
FactionMember FactionMember [not null]
|
FactionMember FactionMember [not null]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Faction" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"image" TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FactionMember" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"sub" TEXT NOT NULL,
|
||||||
|
"factionId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FactionMember_sub_fkey" FOREIGN KEY ("sub") REFERENCES "User" ("sub") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "FactionMember_factionId_fkey" FOREIGN KEY ("factionId") REFERENCES "Faction" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FactionRole" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"level" INTEGER NOT NULL,
|
||||||
|
"factionId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FactionRole_factionId_fkey" FOREIGN KEY ("factionId") REFERENCES "Faction" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FactionSocial" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"factionId" TEXT NOT NULL,
|
||||||
|
"title" TEXT,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL,
|
||||||
|
CONSTRAINT "FactionSocial_factionId_fkey" FOREIGN KEY ("factionId") REFERENCES "Faction" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FactionSetting" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"factionId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FactionSetting_key_fkey" FOREIGN KEY ("key") REFERENCES "FactionSettingDefinition" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "FactionSetting_factionId_fkey" FOREIGN KEY ("factionId") REFERENCES "Faction" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FactionSettingDefinition" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"minimumLevel" INTEGER NOT NULL
|
||||||
|
);
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_User" (
|
||||||
|
"sub" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"lastPixelTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"pixelStack" INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("lastPixelTime", "sub") SELECT "lastPixelTime", "sub" FROM "User";
|
||||||
|
DROP TABLE "User";
|
||||||
|
ALTER TABLE "new_User" RENAME TO "User";
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
|
@ -16,12 +16,14 @@ datasource db {
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
sub String @id
|
sub String @id
|
||||||
lastPixelTime DateTime @default(now())
|
lastPixelTime DateTime @default(now()) // the time the last pixel was placed at
|
||||||
|
pixelStack Int @default(0) // amount of pixels stacked for this user
|
||||||
|
|
||||||
pixels Pixel[]
|
pixels Pixel[]
|
||||||
FactionMember FactionMember[]
|
FactionMember FactionMember[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: i cannot spell, rename this to PaletteColor
|
||||||
model PalleteColor {
|
model PalleteColor {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
|
import { CanvasConfig } from "@sc07-canvas/lib/src/net";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { Redis } from "./redis";
|
import { Redis } from "./redis";
|
||||||
|
|
||||||
const redis_keys = {
|
|
||||||
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
|
|
||||||
canvas: () => `CANVAS:PIXELS`,
|
|
||||||
};
|
|
||||||
|
|
||||||
class Canvas {
|
class Canvas {
|
||||||
private CANVAS_SIZE: [number, number];
|
private CANVAS_SIZE: [number, number];
|
||||||
|
|
||||||
|
@ -13,10 +9,15 @@ class Canvas {
|
||||||
this.CANVAS_SIZE = [100, 100];
|
this.CANVAS_SIZE = [100, 100];
|
||||||
}
|
}
|
||||||
|
|
||||||
getCanvasConfig() {
|
getCanvasConfig(): CanvasConfig {
|
||||||
return {
|
return {
|
||||||
size: this.CANVAS_SIZE,
|
size: this.CANVAS_SIZE,
|
||||||
zoom: 7,
|
zoom: 7,
|
||||||
|
pixel: {
|
||||||
|
cooldown: 60,
|
||||||
|
multiplier: 3,
|
||||||
|
maxStack: 6,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,7 +27,7 @@ class Canvas {
|
||||||
async pixelsToRedis() {
|
async pixelsToRedis() {
|
||||||
const redis = await Redis.getClient();
|
const redis = await Redis.getClient();
|
||||||
|
|
||||||
const key = redis_keys.pixelColor;
|
const key = Redis.keyRef("pixelColor");
|
||||||
|
|
||||||
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
|
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
|
||||||
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
|
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
|
||||||
|
@ -59,12 +60,12 @@ class Canvas {
|
||||||
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
|
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
|
||||||
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
|
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
|
||||||
pixels.push(
|
pixels.push(
|
||||||
(await redis.get(redis_keys.pixelColor(x, y))) || "transparent"
|
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.set(redis_keys.canvas(), pixels.join(","), { EX: 60 * 5 });
|
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
|
||||||
|
|
||||||
return pixels;
|
return pixels;
|
||||||
}
|
}
|
||||||
|
@ -76,20 +77,20 @@ class Canvas {
|
||||||
const redis = await Redis.getClient();
|
const redis = await Redis.getClient();
|
||||||
|
|
||||||
const pixels: string[] = (
|
const pixels: string[] = (
|
||||||
(await redis.get(redis_keys.canvas())) || ""
|
(await redis.get(Redis.key("canvas"))) || ""
|
||||||
).split(",");
|
).split(",");
|
||||||
|
|
||||||
pixels[this.CANVAS_SIZE[0] * y + x] =
|
pixels[this.CANVAS_SIZE[0] * y + x] =
|
||||||
(await redis.get(redis_keys.pixelColor(x, y))) || "transparent";
|
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent";
|
||||||
|
|
||||||
await redis.set(redis_keys.canvas(), pixels.join(","), { EX: 60 * 5 });
|
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPixelsArray() {
|
async getPixelsArray() {
|
||||||
const redis = await Redis.getClient();
|
const redis = await Redis.getClient();
|
||||||
|
|
||||||
if (await redis.exists(redis_keys.canvas())) {
|
if (await redis.exists(Redis.key("canvas"))) {
|
||||||
const cached = await redis.get(redis_keys.canvas());
|
const cached = await redis.get(Redis.key("canvas"));
|
||||||
return cached!.split(",");
|
return cached!.split(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,12 +5,15 @@ import {
|
||||||
Pixel,
|
Pixel,
|
||||||
ServerToClientEvents,
|
ServerToClientEvents,
|
||||||
} from "@sc07-canvas/lib/src/net";
|
} from "@sc07-canvas/lib/src/net";
|
||||||
|
import { CanvasLib } from "@sc07-canvas/lib/src/canvas";
|
||||||
import { Server, Socket as RawSocket } from "socket.io";
|
import { Server, Socket as RawSocket } from "socket.io";
|
||||||
import { session } from "./Express";
|
import { session } from "./Express";
|
||||||
import Canvas from "./Canvas";
|
import Canvas from "./Canvas";
|
||||||
import { PalleteColor } from "@prisma/client";
|
import { PalleteColor } from "@prisma/client";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { Logger } from "./Logger";
|
import { Logger } from "./Logger";
|
||||||
|
import { Redis } from "./redis";
|
||||||
|
import { User } from "../models/User";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get socket.io server config, generated from environment vars
|
* get socket.io server config, generated from environment vars
|
||||||
|
@ -68,61 +71,137 @@ export class SocketServer {
|
||||||
constructor(server: http.Server) {
|
constructor(server: http.Server) {
|
||||||
this.io = new Server(server, getSocketConfig());
|
this.io = new Server(server, getSocketConfig());
|
||||||
|
|
||||||
this.setupOnlineTick();
|
this.setupMasterShard();
|
||||||
|
|
||||||
this.io.engine.use(session);
|
this.io.engine.use(session);
|
||||||
this.io.on("connection", this.handleConnection.bind(this));
|
this.io.on("connection", this.handleConnection.bind(this));
|
||||||
|
|
||||||
|
// pixel stacking
|
||||||
|
// - needs to be exponential (takes longer to aquire more pixels stacked)
|
||||||
|
// - convert to config options instead of hard-coded
|
||||||
|
setInterval(async () => {
|
||||||
|
Logger.debug("Running pixel stacking...");
|
||||||
|
const redis = await Redis.getClient();
|
||||||
|
const sockets = await this.io.local.fetchSockets();
|
||||||
|
|
||||||
|
for (const socket of sockets) {
|
||||||
|
const sub = await redis.get(Redis.key("socketToSub", socket.id));
|
||||||
|
if (!sub) {
|
||||||
|
Logger.warn(`Socket ${socket.id} has no user`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.fromSub(sub);
|
||||||
|
if (!user) {
|
||||||
|
Logger.warn(
|
||||||
|
`Socket ${socket.id}'s user (${sub}) does not exist in the database`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// time in seconds since last pixel placement
|
||||||
|
// TODO: this causes a mismatch between placement times
|
||||||
|
// - going from 0 stack to 6 stack has a steady increase between each
|
||||||
|
// - going from 3 stack to 6 stack takes longer
|
||||||
|
const timeSinceLastPlace =
|
||||||
|
(Date.now() - user.lastPixelTime.getTime()) / 1000;
|
||||||
|
const cooldown = CanvasLib.getPixelCooldown(
|
||||||
|
user.pixelStack + 1,
|
||||||
|
getClientConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
// this impl has the side affect of giving previously offline users all the stack upon reconnecting
|
||||||
|
if (
|
||||||
|
timeSinceLastPlace >= cooldown &&
|
||||||
|
user.pixelStack < getClientConfig().canvas.pixel.maxStack
|
||||||
|
) {
|
||||||
|
await user.modifyStack(1);
|
||||||
|
Logger.debug(sub + " has gained another pixel in their stack");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection(socket: Socket) {
|
async handleConnection(socket: Socket) {
|
||||||
const clientConfig = getClientConfig();
|
const user =
|
||||||
const user = this.getUserFromSocket(socket);
|
socket.request.session.user &&
|
||||||
Logger.debug("Socket connection " + (user ? "@" + user.sub : "No Auth"));
|
(await User.fromAuthSession(socket.request.session.user));
|
||||||
|
Logger.debug(
|
||||||
|
`Socket ${socket.id} connection ` + (user ? "@" + user.sub : "No Auth")
|
||||||
|
);
|
||||||
|
|
||||||
|
user?.sockets.add(socket);
|
||||||
|
Logger.debug("handleConnection " + user?.sockets.size);
|
||||||
|
|
||||||
|
Redis.getClient().then((redis) => {
|
||||||
|
if (user) redis.set(Redis.key("socketToSub", socket.id), user.sub);
|
||||||
|
});
|
||||||
|
|
||||||
if (socket.request.session.user) {
|
if (socket.request.session.user) {
|
||||||
// inform the client of their session if it exists
|
// inform the client of their session if it exists
|
||||||
socket.emit("user", socket.request.session.user);
|
socket.emit("user", socket.request.session.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
socket.emit("availablePixels", user.pixelStack);
|
||||||
|
socket.emit("pixelLastPlaced", user.lastPixelTime.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
socket.emit("config", getClientConfig());
|
socket.emit("config", getClientConfig());
|
||||||
Canvas.getPixelsArray().then((pixels) => {
|
Canvas.getPixelsArray().then((pixels) => {
|
||||||
socket.emit("canvas", pixels);
|
socket.emit("canvas", pixels);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
Logger.debug(`Socket ${socket.id} disconnected`);
|
||||||
|
|
||||||
|
user?.sockets.delete(socket);
|
||||||
|
|
||||||
|
Redis.getClient().then((redis) => {
|
||||||
|
if (user) redis.del(Redis.key("socketToSub", socket.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("place", async (pixel, ack) => {
|
socket.on("place", async (pixel, ack) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
ack({ success: false, error: "no_user" });
|
ack({ success: false, error: "no_user" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const puser = await prisma.user.findFirst({ where: { sub: user.sub } });
|
if (
|
||||||
if (puser?.lastPixelTime) {
|
pixel.x < 0 ||
|
||||||
if (
|
pixel.y < 0 ||
|
||||||
puser.lastPixelTime.getTime() + clientConfig.pallete.pixel_cooldown >
|
pixel.x >= getClientConfig().canvas.size[0] ||
|
||||||
Date.now()
|
pixel.y >= getClientConfig().canvas.size[1]
|
||||||
) {
|
) {
|
||||||
ack({
|
ack({ success: false, error: "invalid_pixel" });
|
||||||
success: false,
|
return;
|
||||||
error: "pixel_cooldown",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const palleteColor = await prisma.palleteColor.findFirst({
|
// force a user data update
|
||||||
|
await user.update(true);
|
||||||
|
|
||||||
|
if (user.pixelStack < 1) {
|
||||||
|
ack({ success: false, error: "pixel_cooldown" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.modifyStack(-1);
|
||||||
|
|
||||||
|
const paletteColor = await prisma.palleteColor.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: pixel.color,
|
id: pixel.color,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!palleteColor) {
|
if (!paletteColor) {
|
||||||
ack({
|
ack({
|
||||||
success: false,
|
success: false,
|
||||||
error: "pallete_color_invalid",
|
error: "palette_color_invalid",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Canvas.setPixel(user, pixel.x, pixel.y, palleteColor.hex);
|
await Canvas.setPixel(user, pixel.x, pixel.y, paletteColor.hex);
|
||||||
|
|
||||||
const newPixel: Pixel = {
|
const newPixel: Pixel = {
|
||||||
x: pixel.x,
|
x: pixel.x,
|
||||||
|
@ -137,25 +216,19 @@ export class SocketServer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserFromSocket(socket: Socket) {
|
|
||||||
return socket.request.session.user
|
|
||||||
? {
|
|
||||||
sub:
|
|
||||||
socket.request.session.user.user.username +
|
|
||||||
"@" +
|
|
||||||
socket.request.session.user.service.instance.hostname,
|
|
||||||
...socket.request.session.user,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* setup the online people announcement
|
* Master Shard (need better name)
|
||||||
|
* This shard should be in charge of all user management, allowing for syncronized events
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* - online people announcement
|
||||||
*
|
*
|
||||||
* this does work with multiple socket.io instances, so this needs to only be executed by one shard
|
* this does work with multiple socket.io instances, so this needs to only be executed by one shard
|
||||||
*/
|
*/
|
||||||
setupOnlineTick() {
|
setupMasterShard() {
|
||||||
|
// online announcement event
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
|
// possible issue: this includes every connected socket, not user count
|
||||||
const sockets = await this.io.sockets.fetchSockets();
|
const sockets = await this.io.sockets.fetchSockets();
|
||||||
for (const socket of sockets) {
|
for (const socket of sockets) {
|
||||||
socket.emit("online", { count: sockets.length });
|
socket.emit("online", { count: sockets.length });
|
||||||
|
|
|
@ -2,14 +2,44 @@ import { RedisClientType } from "@redis/client";
|
||||||
import { createClient } from "redis";
|
import { createClient } from "redis";
|
||||||
import { Logger } from "./Logger";
|
import { Logger } from "./Logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typedef for RedisKeys
|
||||||
|
*/
|
||||||
|
interface IRedisKeys {
|
||||||
|
// canvas
|
||||||
|
pixelColor(x: number, y: number): string;
|
||||||
|
canvas(): string;
|
||||||
|
|
||||||
|
// users
|
||||||
|
socketToSub(socketId: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defined as a variable due to boottime augmentation
|
||||||
|
*/
|
||||||
|
const RedisKeys: IRedisKeys = {
|
||||||
|
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
|
||||||
|
canvas: () => `CANVAS:PIXELS`,
|
||||||
|
socketToSub: (socketId: string) => `CANVAS:SOCKET:${socketId}`,
|
||||||
|
};
|
||||||
|
|
||||||
class _Redis {
|
class _Redis {
|
||||||
isConnected = false;
|
isConnected = false;
|
||||||
client: RedisClientType;
|
client: RedisClientType;
|
||||||
|
|
||||||
constructor() {
|
keys: IRedisKeys;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis client wrapper constructor
|
||||||
|
*
|
||||||
|
* @param keys Definition of keys, passed as an argument to allow for augmentation from configuration on boot
|
||||||
|
*/
|
||||||
|
constructor(keys: IRedisKeys) {
|
||||||
this.client = createClient({
|
this.client = createClient({
|
||||||
url: process.env.REDIS_HOST,
|
url: process.env.REDIS_HOST,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.keys = keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
|
@ -29,6 +59,19 @@ class _Redis {
|
||||||
|
|
||||||
return this.client;
|
return this.client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
key<Key extends keyof IRedisKeys>(
|
||||||
|
key: Key,
|
||||||
|
...rest: Parameters<IRedisKeys[Key]>
|
||||||
|
): string {
|
||||||
|
return (this.keys[key] as any)(...rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
keyRef<Key extends keyof IRedisKeys>(
|
||||||
|
key: Key
|
||||||
|
): (...params: Parameters<IRedisKeys[Key]>) => string {
|
||||||
|
return (...params) => this.key(key, ...params);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Redis = new _Redis();
|
export const Redis = new _Redis(RedisKeys);
|
||||||
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { Logger } from "../lib/Logger";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
import { AuthSession } from "@sc07-canvas/lib/src/net";
|
||||||
|
|
||||||
|
interface IUserData {
|
||||||
|
sub: string;
|
||||||
|
lastPixelTime: Date;
|
||||||
|
pixelStack: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
static instances: Map<string, User> = new Map();
|
||||||
|
|
||||||
|
sub: string;
|
||||||
|
lastPixelTime: Date;
|
||||||
|
pixelStack: number;
|
||||||
|
authSession?: AuthSession;
|
||||||
|
|
||||||
|
sockets: Set<Socket> = new Set();
|
||||||
|
|
||||||
|
private _updatedAt: number;
|
||||||
|
|
||||||
|
private constructor(data: IUserData) {
|
||||||
|
Logger.debug("User class instansiated for " + data.sub);
|
||||||
|
|
||||||
|
this.sub = data.sub;
|
||||||
|
this.lastPixelTime = data.lastPixelTime;
|
||||||
|
this.pixelStack = data.pixelStack;
|
||||||
|
|
||||||
|
this._updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(force: boolean = false) {
|
||||||
|
if (this.isStale() && !force) return;
|
||||||
|
|
||||||
|
const userData = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
sub: this.sub,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userData) throw new UserNotFound();
|
||||||
|
|
||||||
|
this.lastPixelTime = userData.lastPixelTime;
|
||||||
|
this.pixelStack = userData.pixelStack;
|
||||||
|
}
|
||||||
|
|
||||||
|
async modifyStack(modifyBy: number): Promise<any> {
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { sub: this.sub },
|
||||||
|
data: {
|
||||||
|
pixelStack: { increment: modifyBy },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const socket of this.sockets) {
|
||||||
|
socket.emit("availablePixels", updatedUser.pixelStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we just modified the user data, so we should force an update
|
||||||
|
await this.update(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if this user data is stale and should be updated
|
||||||
|
* @see User#update
|
||||||
|
* @returns if this user data is stale
|
||||||
|
*/
|
||||||
|
private isStale() {
|
||||||
|
return Date.now() - this._updatedAt >= 1000 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromAuthSession(auth: AuthSession): Promise<User> {
|
||||||
|
const user = await this.fromSub(
|
||||||
|
auth.user.username + "@" + auth.service.instance.hostname
|
||||||
|
);
|
||||||
|
user.authSession = auth;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromSub(sub: string): Promise<User> {
|
||||||
|
if (this.instances.has(sub)) return this.instances.get(sub)!;
|
||||||
|
|
||||||
|
const userData = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
sub,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userData) throw new UserNotFound();
|
||||||
|
|
||||||
|
const newUser = new User(userData);
|
||||||
|
this.instances.set(sub, newUser);
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserNotFound extends Error {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.name = "UserNotFound";
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue