From e5821027dc0f69c30b50e86f6f19ed34172524ec Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 Mar 2024 15:37:24 -0700 Subject: [PATCH] add pixel stacking --- packages/client/src/components/CanvasMeta.tsx | 45 +++++- packages/client/src/contexts/AppContext.tsx | 14 +- packages/client/src/lib/canvas.ts | 28 ++-- packages/client/src/lib/network.ts | 10 ++ packages/lib/src/canvas.ts | 21 +++ packages/lib/src/net.ts | 26 +++- packages/server/prisma/dbml/schema.dbml | 1 + .../migrations/20240304215631_/migration.sql | 52 +++++++ .../migrations/20240304220059_/migration.sql | 12 ++ packages/server/prisma/schema.prisma | 4 +- packages/server/src/lib/Canvas.ts | 29 ++-- packages/server/src/lib/SocketServer.ts | 143 +++++++++++++----- packages/server/src/lib/redis.ts | 47 +++++- packages/server/src/models/User.ts | 104 +++++++++++++ 14 files changed, 467 insertions(+), 69 deletions(-) create mode 100644 packages/lib/src/canvas.ts create mode 100644 packages/server/prisma/migrations/20240304215631_/migration.sql create mode 100644 packages/server/prisma/migrations/20240304220059_/migration.sql create mode 100644 packages/server/src/models/User.ts diff --git a/packages/client/src/components/CanvasMeta.tsx b/packages/client/src/components/CanvasMeta.tsx index 126cd63..e8e9e73 100644 --- a/packages/client/src/components/CanvasMeta.tsx +++ b/packages/client/src/components/CanvasMeta.tsx @@ -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 = () => { )} - Pixels: 123 + Pixels:{" "} + + {pixels.available}/{config.canvas.pixel.maxStack} + {" "} + Users Online: 321 diff --git a/packages/client/src/contexts/AppContext.tsx b/packages/client/src/contexts/AppContext.tsx index a3d4300..0357a0d 100644 --- a/packages/client/src/contexts/AppContext.tsx +++ b/packages/client/src/contexts/AppContext.tsx @@ -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({} as any); @@ -24,6 +24,8 @@ export const AppContext = ({ children }: PropsWithChildren) => { const [canvasPosition, setCanvasPosition] = useState(); const [cursorPosition, setCursorPosition] = useState(); + 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..."} diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts index bf888a5..2adcf6d 100644 --- a/packages/client/src/lib/canvas.ts +++ b/packages/client/src/lib/canvas.ts @@ -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 { 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 { 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 { 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 { }) .then((ack) => { if (ack.success) { + this.lastPlace = Date.now(); this.handlePixel(ack.data); } else { // TODO: handle undo pixel diff --git a/packages/client/src/lib/network.ts b/packages/client/src/lib/network.ts index b22e804..25ac900 100644 --- a/packages/client/src/lib/network.ts +++ b/packages/client/src/lib/network.ts @@ -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 = EventEmitter.ArgumentMap< @@ -45,6 +47,14 @@ class Network extends EventEmitter { 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); diff --git a/packages/lib/src/canvas.ts b/packages/lib/src/canvas.ts new file mode 100644 index 0000000..fed09ad --- /dev/null +++ b/packages/lib/src/canvas.ts @@ -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)) + // ); + } +})(); diff --git a/packages/lib/src/net.ts b/packages/lib/src/net.ts index 356349d..8eb16b6 100644 --- a/packages/lib/src/net.ts +++ b/packages/lib/src/net.ts @@ -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) => 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 = +/** + * @template T the packet data + * @template E union type of errors possible + */ +export type PacketAck = | { success: true; data: T; } - | { success: false; error: string }; + | { success: false; error: E }; export type AuthSession = { service: { diff --git a/packages/server/prisma/dbml/schema.dbml b/packages/server/prisma/dbml/schema.dbml index 8924f50..94e783d 100644 --- a/packages/server/prisma/dbml/schema.dbml +++ b/packages/server/prisma/dbml/schema.dbml @@ -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] } diff --git a/packages/server/prisma/migrations/20240304215631_/migration.sql b/packages/server/prisma/migrations/20240304215631_/migration.sql new file mode 100644 index 0000000..ab4deb2 --- /dev/null +++ b/packages/server/prisma/migrations/20240304215631_/migration.sql @@ -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 +); diff --git a/packages/server/prisma/migrations/20240304220059_/migration.sql b/packages/server/prisma/migrations/20240304220059_/migration.sql new file mode 100644 index 0000000..af33977 --- /dev/null +++ b/packages/server/prisma/migrations/20240304220059_/migration.sql @@ -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; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 169b49c..7114f93 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -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 diff --git a/packages/server/src/lib/Canvas.ts b/packages/server/src/lib/Canvas.ts index 1b0a67d..58ac773 100644 --- a/packages/server/src/lib/Canvas.ts +++ b/packages/server/src/lib/Canvas.ts @@ -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(","); } diff --git a/packages/server/src/lib/SocketServer.ts b/packages/server/src/lib/SocketServer.ts index 49e19d0..4244056 100644 --- a/packages/server/src/lib/SocketServer.ts +++ b/packages/server/src/lib/SocketServer.ts @@ -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; + } + + 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) { - const clientConfig = getClientConfig(); - const user = this.getUserFromSocket(socket); - Logger.debug("Socket connection " + (user ? "@" + user.sub : "No Auth")); + 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() - ) { - ack({ - success: false, - error: "pixel_cooldown", - }); - return; - } + if ( + pixel.x < 0 || + pixel.y < 0 || + pixel.x >= getClientConfig().canvas.size[0] || + pixel.y >= getClientConfig().canvas.size[1] + ) { + ack({ success: false, error: "invalid_pixel" }); + 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: { 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 }); diff --git a/packages/server/src/lib/redis.ts b/packages/server/src/lib/redis.ts index e10af05..75ef6d7 100644 --- a/packages/server/src/lib/redis.ts +++ b/packages/server/src/lib/redis.ts @@ -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: Key, + ...rest: Parameters + ): string { + return (this.keys[key] as any)(...rest); + } + + keyRef( + key: Key + ): (...params: Parameters) => string { + return (...params) => this.key(key, ...params); + } } -export const Redis = new _Redis(); +export const Redis = new _Redis(RedisKeys); diff --git a/packages/server/src/models/User.ts b/packages/server/src/models/User.ts new file mode 100644 index 0000000..e1ab2ca --- /dev/null +++ b/packages/server/src/models/User.ts @@ -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 = new Map(); + + sub: string; + lastPixelTime: Date; + pixelStack: number; + authSession?: AuthSession; + + sockets: Set = 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 { + 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 { + const user = await this.fromSub( + auth.user.username + "@" + auth.service.instance.hostname + ); + user.authSession = auth; + return user; + } + + static async fromSub(sub: string): Promise { + 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"; + } +}