add pixel stacking
This commit is contained in:
parent
c3b8467b8f
commit
e5821027dc
|
@ -5,10 +5,47 @@ import {
|
|||
ModalHeader,
|
||||
useDisclosure,
|
||||
} from "@nextui-org/react";
|
||||
import { CanvasLib } from "@sc07-canvas/lib/src/canvas";
|
||||
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 = () => {
|
||||
const { canvasPosition, cursorPosition } = useAppContext();
|
||||
const { canvasPosition, cursorPosition, pixels, config } = useAppContext();
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
return (
|
||||
|
@ -30,7 +67,11 @@ export const CanvasMeta = () => {
|
|||
</span>
|
||||
)}
|
||||
<span>
|
||||
Pixels: <span>123</span>
|
||||
Pixels:{" "}
|
||||
<span>
|
||||
{pixels.available}/{config.canvas.pixel.maxStack}
|
||||
</span>{" "}
|
||||
<PlaceCountdown />
|
||||
</span>
|
||||
<span>
|
||||
Users Online: <span>321</span>
|
||||
|
|
|
@ -6,12 +6,12 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
AuthSession,
|
||||
ClientConfig,
|
||||
IAppContext,
|
||||
ICanvasPosition,
|
||||
IPosition,
|
||||
} from "../types";
|
||||
import { AuthSession } from "@sc07-canvas/lib/src/net";
|
||||
} from "@sc07-canvas/lib/src/net";
|
||||
import Network from "../lib/network";
|
||||
|
||||
const appContext = createContext<IAppContext>({} as any);
|
||||
|
@ -24,6 +24,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
|
||||
const [cursorPosition, setCursorPosition] = useState<IPosition>();
|
||||
|
||||
const [pixels, setPixels] = useState({ available: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
function handleConfig(config: ClientConfig) {
|
||||
console.info("Server sent config", config);
|
||||
|
@ -34,14 +36,21 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
setAuth(user);
|
||||
}
|
||||
|
||||
function handlePixels(pixels: { available: number }) {
|
||||
setPixels(pixels);
|
||||
}
|
||||
|
||||
Network.on("user", handleUser);
|
||||
Network.on("config", handleConfig);
|
||||
Network.waitFor("pixels").then(([data]) => handlePixels(data));
|
||||
Network.on("pixels", handlePixels);
|
||||
|
||||
Network.socket.connect();
|
||||
|
||||
return () => {
|
||||
Network.off("user", handleUser);
|
||||
Network.off("config", handleConfig);
|
||||
Network.off("pixels", handlePixels);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -54,6 +63,7 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
setCanvasPosition,
|
||||
cursorPosition,
|
||||
setCursorPosition,
|
||||
pixels,
|
||||
}}
|
||||
>
|
||||
{config ? children : "Loading..."}
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
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 {
|
||||
ClickEvent,
|
||||
|
@ -30,7 +35,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
private pixels: {
|
||||
[x_y: string]: { color: number; type: "full" | "pending" };
|
||||
} = {};
|
||||
private lastPlace: number | undefined;
|
||||
lastPlace: number | undefined;
|
||||
|
||||
constructor(
|
||||
config: ClientConfig,
|
||||
|
@ -52,6 +57,9 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
|
||||
|
||||
Network.waitFor("canvas").then(([pixels]) => this.handleBatch(pixels));
|
||||
Network.waitFor("pixelLastPlaced").then(
|
||||
([time]) => (this.lastPlace = time)
|
||||
);
|
||||
|
||||
this.draw();
|
||||
}
|
||||
|
@ -133,14 +141,13 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
place(x: number, y: number) {
|
||||
if (!this.Pallete.getSelectedColor()) return;
|
||||
|
||||
if (this.lastPlace) {
|
||||
if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
|
||||
console.log("cannot place; cooldown");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastPlace = Date.now();
|
||||
// TODO: redo this as the server now verifies placements differently
|
||||
// if (this.lastPlace) {
|
||||
// if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
|
||||
// console.log("cannot place; cooldown");
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
Network.socket
|
||||
.emitWithAck("place", {
|
||||
|
@ -150,6 +157,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
})
|
||||
.then((ack) => {
|
||||
if (ack.success) {
|
||||
this.lastPlace = Date.now();
|
||||
this.handlePixel(ack.data);
|
||||
} else {
|
||||
// TODO: handle undo pixel
|
||||
|
|
|
@ -11,6 +11,8 @@ export interface INetworkEvents {
|
|||
user: (user: AuthSession) => void;
|
||||
config: (user: ClientConfig) => void;
|
||||
canvas: (pixels: string[]) => void;
|
||||
pixels: (data: { available: number }) => void;
|
||||
pixelLastPlaced: (time: number) => void;
|
||||
}
|
||||
|
||||
type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
|
||||
|
@ -45,6 +47,14 @@ class Network extends EventEmitter<INetworkEvents> {
|
|||
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) => {
|
||||
// Pallete.load(config.pallete);
|
||||
// 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;
|
||||
pixel: (pixel: Pixel) => void;
|
||||
online: (count: { count: number }) => void;
|
||||
availablePixels: (count: number) => void;
|
||||
pixelLastPlaced: (time: number) => void;
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -21,6 +31,7 @@ export interface IAppContext {
|
|||
setCanvasPosition: (v: ICanvasPosition) => void;
|
||||
cursorPosition?: IPosition;
|
||||
setCursorPosition: (v?: IPosition) => void;
|
||||
pixels: { available: number };
|
||||
}
|
||||
|
||||
export interface IPalleteContext {
|
||||
|
@ -55,6 +66,11 @@ export type PalleteColor = {
|
|||
export type CanvasConfig = {
|
||||
size: [number, number];
|
||||
zoom: number;
|
||||
pixel: {
|
||||
maxStack: number;
|
||||
cooldown: number;
|
||||
multiplier: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientConfig = {
|
||||
|
@ -65,12 +81,16 @@ export type ClientConfig = {
|
|||
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;
|
||||
data: T;
|
||||
}
|
||||
| { success: false; error: string };
|
||||
| { success: false; error: E };
|
||||
|
||||
export type AuthSession = {
|
||||
service: {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
Table User {
|
||||
sub String [pk]
|
||||
lastPixelTime DateTime [default: `now()`, not null]
|
||||
pixelStack Int [not null, default: 0]
|
||||
pixels Pixel [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 {
|
||||
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[]
|
||||
FactionMember FactionMember[]
|
||||
}
|
||||
|
||||
// TODO: i cannot spell, rename this to PaletteColor
|
||||
model PalleteColor {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
import { CanvasConfig } from "@sc07-canvas/lib/src/net";
|
||||
import { prisma } from "./prisma";
|
||||
import { Redis } from "./redis";
|
||||
|
||||
const redis_keys = {
|
||||
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
|
||||
canvas: () => `CANVAS:PIXELS`,
|
||||
};
|
||||
|
||||
class Canvas {
|
||||
private CANVAS_SIZE: [number, number];
|
||||
|
||||
|
@ -13,10 +9,15 @@ class Canvas {
|
|||
this.CANVAS_SIZE = [100, 100];
|
||||
}
|
||||
|
||||
getCanvasConfig() {
|
||||
getCanvasConfig(): CanvasConfig {
|
||||
return {
|
||||
size: this.CANVAS_SIZE,
|
||||
zoom: 7,
|
||||
pixel: {
|
||||
cooldown: 60,
|
||||
multiplier: 3,
|
||||
maxStack: 6,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -26,7 +27,7 @@ class Canvas {
|
|||
async pixelsToRedis() {
|
||||
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 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 y = 0; y < this.CANVAS_SIZE[1]; y++) {
|
||||
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;
|
||||
}
|
||||
|
@ -76,20 +77,20 @@ class Canvas {
|
|||
const redis = await Redis.getClient();
|
||||
|
||||
const pixels: string[] = (
|
||||
(await redis.get(redis_keys.canvas())) || ""
|
||||
(await redis.get(Redis.key("canvas"))) || ""
|
||||
).split(",");
|
||||
|
||||
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() {
|
||||
const redis = await Redis.getClient();
|
||||
|
||||
if (await redis.exists(redis_keys.canvas())) {
|
||||
const cached = await redis.get(redis_keys.canvas());
|
||||
if (await redis.exists(Redis.key("canvas"))) {
|
||||
const cached = await redis.get(Redis.key("canvas"));
|
||||
return cached!.split(",");
|
||||
}
|
||||
|
||||
|
|
|
@ -5,12 +5,15 @@ import {
|
|||
Pixel,
|
||||
ServerToClientEvents,
|
||||
} from "@sc07-canvas/lib/src/net";
|
||||
import { CanvasLib } from "@sc07-canvas/lib/src/canvas";
|
||||
import { Server, Socket as RawSocket } from "socket.io";
|
||||
import { session } from "./Express";
|
||||
import Canvas from "./Canvas";
|
||||
import { PalleteColor } from "@prisma/client";
|
||||
import { prisma } from "./prisma";
|
||||
import { Logger } from "./Logger";
|
||||
import { Redis } from "./redis";
|
||||
import { User } from "../models/User";
|
||||
|
||||
/**
|
||||
* get socket.io server config, generated from environment vars
|
||||
|
@ -68,61 +71,137 @@ export class SocketServer {
|
|||
constructor(server: http.Server) {
|
||||
this.io = new Server(server, getSocketConfig());
|
||||
|
||||
this.setupOnlineTick();
|
||||
this.setupMasterShard();
|
||||
|
||||
this.io.engine.use(session);
|
||||
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;
|
||||
}
|
||||
|
||||
handleConnection(socket: Socket) {
|
||||
const clientConfig = getClientConfig();
|
||||
const user = this.getUserFromSocket(socket);
|
||||
Logger.debug("Socket connection " + (user ? "@" + user.sub : "No Auth"));
|
||||
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);
|
||||
}
|
||||
|
||||
async handleConnection(socket: Socket) {
|
||||
const user =
|
||||
socket.request.session.user &&
|
||||
(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) {
|
||||
// inform the client of their session if it exists
|
||||
socket.emit("user", socket.request.session.user);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
socket.emit("availablePixels", user.pixelStack);
|
||||
socket.emit("pixelLastPlaced", user.lastPixelTime.getTime());
|
||||
}
|
||||
|
||||
socket.emit("config", getClientConfig());
|
||||
Canvas.getPixelsArray().then((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) => {
|
||||
if (!user) {
|
||||
ack({ success: false, error: "no_user" });
|
||||
return;
|
||||
}
|
||||
|
||||
const puser = await prisma.user.findFirst({ where: { sub: user.sub } });
|
||||
if (puser?.lastPixelTime) {
|
||||
if (
|
||||
puser.lastPixelTime.getTime() + clientConfig.pallete.pixel_cooldown >
|
||||
Date.now()
|
||||
pixel.x < 0 ||
|
||||
pixel.y < 0 ||
|
||||
pixel.x >= getClientConfig().canvas.size[0] ||
|
||||
pixel.y >= getClientConfig().canvas.size[1]
|
||||
) {
|
||||
ack({
|
||||
success: false,
|
||||
error: "pixel_cooldown",
|
||||
});
|
||||
ack({ success: false, error: "invalid_pixel" });
|
||||
return;
|
||||
}
|
||||
|
||||
// force a user data update
|
||||
await user.update(true);
|
||||
|
||||
if (user.pixelStack < 1) {
|
||||
ack({ success: false, error: "pixel_cooldown" });
|
||||
return;
|
||||
}
|
||||
|
||||
const palleteColor = await prisma.palleteColor.findFirst({
|
||||
await user.modifyStack(-1);
|
||||
|
||||
const paletteColor = await prisma.palleteColor.findFirst({
|
||||
where: {
|
||||
id: pixel.color,
|
||||
},
|
||||
});
|
||||
if (!palleteColor) {
|
||||
if (!paletteColor) {
|
||||
ack({
|
||||
success: false,
|
||||
error: "pallete_color_invalid",
|
||||
error: "palette_color_invalid",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await Canvas.setPixel(user, pixel.x, pixel.y, palleteColor.hex);
|
||||
await Canvas.setPixel(user, pixel.x, pixel.y, paletteColor.hex);
|
||||
|
||||
const newPixel: Pixel = {
|
||||
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
|
||||
*/
|
||||
setupOnlineTick() {
|
||||
setupMasterShard() {
|
||||
// online announcement event
|
||||
setInterval(async () => {
|
||||
// possible issue: this includes every connected socket, not user count
|
||||
const sockets = await this.io.sockets.fetchSockets();
|
||||
for (const socket of sockets) {
|
||||
socket.emit("online", { count: sockets.length });
|
||||
|
|
|
@ -2,14 +2,44 @@ import { RedisClientType } from "@redis/client";
|
|||
import { createClient } from "redis";
|
||||
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 {
|
||||
isConnected = false;
|
||||
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({
|
||||
url: process.env.REDIS_HOST,
|
||||
});
|
||||
|
||||
this.keys = keys;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
|
@ -29,6 +59,19 @@ class _Redis {
|
|||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
key<Key extends keyof IRedisKeys>(
|
||||
key: Key,
|
||||
...rest: Parameters<IRedisKeys[Key]>
|
||||
): string {
|
||||
return (this.keys[key] as any)(...rest);
|
||||
}
|
||||
|
||||
export const Redis = new _Redis();
|
||||
keyRef<Key extends keyof IRedisKeys>(
|
||||
key: Key
|
||||
): (...params: Parameters<IRedisKeys[Key]>) => string {
|
||||
return (...params) => this.key(key, ...params);
|
||||
}
|
||||
}
|
||||
|
||||
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