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";
+ }
+}