diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx index 38494e63cd53055f799836c6eeddcef5a854dc19..e162521f05c3a44da4306902fa6fd4fbe1b7d517 100644 --- a/packages/client/src/components/App.tsx +++ b/packages/client/src/components/App.tsx @@ -1,10 +1,10 @@ import { Header } from "./Header"; import { AppContext } from "../contexts/AppContext"; import { CanvasWrapper } from "./CanvasWrapper"; -import { Pallete } from "./Pallete"; import { TemplateContext } from "../contexts/TemplateContext"; import { SettingsSidebar } from "./Settings/SettingsSidebar"; import { DebugModal } from "./Debug/DebugModal"; +import { ToolbarWrapper } from "./Toolbar/ToolbarWrapper"; const App = () => { return ( @@ -12,7 +12,7 @@ const App = () => {
- + diff --git a/packages/client/src/components/CanvasMeta.tsx b/packages/client/src/components/Toolbar/CanvasMeta.tsx similarity index 96% rename from packages/client/src/components/CanvasMeta.tsx rename to packages/client/src/components/Toolbar/CanvasMeta.tsx index b9867aa0f30327668dc83a2870021bce6f0ce119..80d0d90d281b1138bf27c1795c504c59cd785554 100644 --- a/packages/client/src/components/CanvasMeta.tsx +++ b/packages/client/src/components/Toolbar/CanvasMeta.tsx @@ -6,11 +6,11 @@ import { useDisclosure, } from "@nextui-org/react"; import { CanvasLib } from "@sc07-canvas/lib/src/canvas"; -import { useAppContext } from "../contexts/AppContext"; -import { Canvas } from "../lib/canvas"; +import { useAppContext } from "../../contexts/AppContext"; +import { Canvas } from "../../lib/canvas"; import { useEffect, useState } from "react"; import { ClientConfig } from "@sc07-canvas/lib/src/net"; -import network from "../lib/network"; +import network from "../../lib/network"; const getTimeLeft = (pixels: { available: number }, config: ClientConfig) => { // this implementation matches the server's implementation diff --git a/packages/client/src/components/Pallete.scss b/packages/client/src/components/Toolbar/Palette.scss similarity index 96% rename from packages/client/src/components/Pallete.scss rename to packages/client/src/components/Toolbar/Palette.scss index c08a30fc04631fcb3c61200e8ff72116f439b361..424ceebf9eb37020b733a7343407e9d55e108c46 100644 --- a/packages/client/src/components/Pallete.scss +++ b/packages/client/src/components/Toolbar/Palette.scss @@ -1,12 +1,16 @@ -#pallete { +#toolbar { position: fixed; left: 0; bottom: 0; width: 100%; +} +#pallete { display: flex; gap: 10px; padding: 10px; + z-index: 10; + position: relative; background-color: #fff; diff --git a/packages/client/src/components/Pallete.tsx b/packages/client/src/components/Toolbar/Palette.tsx similarity index 87% rename from packages/client/src/components/Pallete.tsx rename to packages/client/src/components/Toolbar/Palette.tsx index 518c74b154e0b6be73e8e14d8027e7492c3895d8..5e2cdfcadbf647ef02b8865d425eeb42cd11e5e9 100644 --- a/packages/client/src/components/Pallete.tsx +++ b/packages/client/src/components/Toolbar/Palette.tsx @@ -1,12 +1,11 @@ import { useEffect, useState } from "react"; -import { useAppContext } from "../contexts/AppContext"; -import { Canvas } from "../lib/canvas"; -import { IPalleteContext } from "../types"; +import { useAppContext } from "../../contexts/AppContext"; +import { Canvas } from "../../lib/canvas"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; -import { CanvasMeta } from "./CanvasMeta"; +import { IPalleteContext } from "@sc07-canvas/lib/src/net"; -export const Pallete = () => { +export const Palette = () => { const { config, user } = useAppContext(); const [pallete, setPallete] = useState({}); @@ -18,8 +17,6 @@ export const Pallete = () => { return (
- -
+
+ ); +}; diff --git a/packages/client/src/contexts/AppContext.tsx b/packages/client/src/contexts/AppContext.tsx index 903e8d1c5a6b3b711f98e8e4fd96fa05c95a6ace..1f4b590f4656e6cd9524689dfa67b61d8c79900d 100644 --- a/packages/client/src/contexts/AppContext.tsx +++ b/packages/client/src/contexts/AppContext.tsx @@ -25,6 +25,7 @@ export const AppContext = ({ children }: PropsWithChildren) => { const [cursorPosition, setCursorPosition] = useState(); const [pixels, setPixels] = useState({ available: 0 }); + const [undo, setUndo] = useState<{ available: true; expireAt: number }>(); // overlays visible const [settingsSidebar, setSettingsSidebar] = useState(false); @@ -43,10 +44,21 @@ export const AppContext = ({ children }: PropsWithChildren) => { setPixels(pixels); } + function handleUndo( + data: { available: false } | { available: true; expireAt: number } + ) { + if (data.available) { + setUndo({ available: true, expireAt: data.expireAt }); + } else { + setUndo(undefined); + } + } + Network.on("user", handleUser); Network.on("config", handleConfig); Network.waitFor("pixels").then(([data]) => handlePixels(data)); Network.on("pixels", handlePixels); + Network.on("undo", handleUndo); Network.socket.connect(); @@ -69,6 +81,7 @@ export const AppContext = ({ children }: PropsWithChildren) => { pixels, settingsSidebar, setSettingsSidebar, + undo, }} > {config ? children : "Loading..."} diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts index f028e4ea092129918d5cb322c821128c127af65d..26b34e4b07b0123b8b8d765df68d172c0d01bae6 100644 --- a/packages/client/src/lib/canvas.ts +++ b/packages/client/src/lib/canvas.ts @@ -163,6 +163,12 @@ export class Canvas extends EventEmitter { } else { // TODO: handle undo pixel alert("error: " + ack.error); + console.warn( + "Attempted to place pixel", + { x, y, color: this.Pallete.getSelectedColor()!.id }, + "and got error", + ack + ); } }); } diff --git a/packages/client/src/lib/network.ts b/packages/client/src/lib/network.ts index 8c11c2d7f866615ad029d18e081aeb4a00dd987f..8c2bf01fc08e5460277ae5e39a06c4fc8205fba5 100644 --- a/packages/client/src/lib/network.ts +++ b/packages/client/src/lib/network.ts @@ -16,6 +16,9 @@ export interface INetworkEvents { pixelLastPlaced: (time: number) => void; online: (count: number) => void; pixel: (pixel: Pixel) => void; + undo: ( + data: { available: false } | { available: true; expireAt: number } + ) => void; } type SentEventValue = EventEmitter.ArgumentMap< @@ -66,18 +69,9 @@ class Network extends EventEmitter { this.emit("pixel", pixel); }); - // this.socket.on("config", (config) => { - // Pallete.load(config.pallete); - // Canvas.load(config.canvas); - // }); - - // this.socket.on("pixel", (data: SPixelPacket) => { - // Canvas.handlePixel(data); - // }); - - // this.socket.on("canvas", (data: SCanvasPacket) => { - // Canvas.handleBatch(data); - // }); + this.socket.on("undo", (undo) => { + this.emit("undo", undo); + }); } private _emit: typeof this.emit = (event, ...args) => { diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss index 9c1c2af492d771019e99ac86c5da69ba17c2ceaa..e9c0b78b013ca4b72d0c16ed9677f24935e13107 100644 --- a/packages/client/src/style.scss +++ b/packages/client/src/style.scss @@ -86,6 +86,7 @@ header#main-header { #canvas-meta { position: absolute; top: -10px; + left: 10px; background-color: rgba(0, 0, 0, 0.5); color: #fff; border-radius: 5px; @@ -194,6 +195,6 @@ main { } } -@import "./components/Pallete.scss"; +@import "./components/Toolbar/Palette.scss"; @import "./components/Template.scss"; @import "./board.scss"; diff --git a/packages/lib/src/net.ts b/packages/lib/src/net.ts index 7381553152f5d58281e25e68fdb07704d5c9dff7..51ddc3bf117f526459d585b8d8ab8bdc1fa5711a 100644 --- a/packages/lib/src/net.ts +++ b/packages/lib/src/net.ts @@ -8,6 +8,9 @@ export interface ServerToClientEvents { online: (count: { count: number }) => void; availablePixels: (count: number) => void; pixelLastPlaced: (time: number) => void; + undo: ( + data: { available: false } | { available: true; expireAt: number } + ) => void; } export interface ClientToServerEvents { @@ -20,10 +23,12 @@ export interface ClientToServerEvents { > ) => void ) => void; + undo: (ack: (_: PacketAck<{}, "no_user" | "unavailable">) => void) => void; } // app context +// TODO: move to client/{...}/AppContext.tsx export interface IAppContext { config: ClientConfig; user?: AuthSession; @@ -34,6 +39,7 @@ export interface IAppContext { pixels: { available: number }; settingsSidebar: boolean; setSettingsSidebar: (v: boolean) => void; + undo?: { available: true; expireAt: number }; } export interface IPalleteContext { @@ -56,6 +62,9 @@ export interface IPosition { export type Pixel = { x: number; y: number; + /** + * Palette color ID or -1 for nothing + */ color: number; }; @@ -73,6 +82,12 @@ export type CanvasConfig = { cooldown: number; multiplier: number; }; + undo: { + /** + * time in ms to allow undos + */ + grace_period: number; + }; }; export type ClientConfig = { diff --git a/packages/server/prisma/dbml/schema.dbml b/packages/server/prisma/dbml/schema.dbml index b47549ba62c507979c66fd58555dc96b038c49c6..8590c29264670539cecaced6b0a7c7dd5337225a 100644 --- a/packages/server/prisma/dbml/schema.dbml +++ b/packages/server/prisma/dbml/schema.dbml @@ -6,6 +6,7 @@ Table User { sub String [pk] lastPixelTime DateTime [default: `now()`, not null] pixelStack Int [not null, default: 0] + undoExpires DateTime pixels Pixel [not null] FactionMember FactionMember [not null] } diff --git a/packages/server/prisma/migrations/20240427225450_add_undo/migration.sql b/packages/server/prisma/migrations/20240427225450_add_undo/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..4d5eddf2ff3804ba79be977cf6ca2334c539ab56 --- /dev/null +++ b/packages/server/prisma/migrations/20240427225450_add_undo/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "undoExpires" TIMESTAMP(3); diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 9bfab939d47a5f5a75561a58521ea0cf0bc8d1b2..7616c16f7441d036d6272ae7a126189e0335a59f 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -15,9 +15,10 @@ datasource db { } model User { - sub String @id - lastPixelTime DateTime @default(now()) // the time the last pixel was placed at - pixelStack Int @default(0) // amount of pixels stacked for this user + sub String @id + lastPixelTime DateTime @default(now()) // the time the last pixel was placed at + pixelStack Int @default(0) // amount of pixels stacked for this user + undoExpires DateTime? // when the undo for the most recent pixel expires at pixels Pixel[] FactionMember FactionMember[] diff --git a/packages/server/src/lib/Canvas.ts b/packages/server/src/lib/Canvas.ts index 4c4bbdb102fb7250a09502b9332642cb835278fe..1feb176c5faf8b3733c2a8125dc1d6ba436ed089 100644 --- a/packages/server/src/lib/Canvas.ts +++ b/packages/server/src/lib/Canvas.ts @@ -1,6 +1,7 @@ import { CanvasConfig } from "@sc07-canvas/lib/src/net"; import { prisma } from "./prisma"; import { Redis } from "./redis"; +import { SocketServer } from "./SocketServer"; class Canvas { private CANVAS_SIZE: [number, number]; @@ -18,6 +19,9 @@ class Canvas { multiplier: 3, maxStack: 6, }, + undo: { + grace_period: 5000, + }, }; } @@ -123,6 +127,43 @@ class Canvas { // i don't think it needs to be awaited await this.updateCanvasRedisAtPos(x, y); } + + /** + * Force a pixel to be updated in redis + * @param x + * @param y + */ + async refreshPixel(x: number, y: number) { + const redis = await Redis.getClient(); + const key = Redis.key("pixelColor", x, y); + + // find if any pixels exist at this spot, and pick the most recent one + const pixel = await prisma.pixel.findFirst({ + where: { x, y }, + orderBy: { createdAt: "desc" }, + }); + let paletteColorID = -1; + + // if pixel exists in redis + if (pixel) { + redis.set(key, pixel.color); + paletteColorID = (await prisma.paletteColor.findFirst({ + where: { hex: pixel.color }, + }))!.id; + } else { + redis.del(key); + } + + await this.updateCanvasRedisAtPos(x, y); + + // announce to everyone the pixel's color + // using -1 if no pixel is there anymore + SocketServer.instance.io.emit("pixel", { + x, + y, + color: paletteColorID, + }); + } } export default new Canvas(); diff --git a/packages/server/src/lib/SocketServer.ts b/packages/server/src/lib/SocketServer.ts index 97cc5bd0e666a2af29d0513f2e30f7337b47c9f5..321736c81af5b25df0a588237afb00d662b20a7e 100644 --- a/packages/server/src/lib/SocketServer.ts +++ b/packages/server/src/lib/SocketServer.ts @@ -66,9 +66,12 @@ const getClientConfig = (): ClientConfig => { type Socket = RawSocket; export class SocketServer { + static instance: SocketServer; io: Server; constructor(server: http.Server) { + SocketServer.instance = this; + this.io = new Server(server, getSocketConfig()); this.setupMasterShard(); @@ -206,6 +209,10 @@ export class SocketServer { await user.modifyStack(-1); await Canvas.setPixel(user, pixel.x, pixel.y, paletteColor.hex); + // give undo capabilities + await user.setUndo( + new Date(Date.now() + Canvas.getCanvasConfig().undo.grace_period) + ); const newPixel: Pixel = { x: pixel.x, @@ -218,6 +225,52 @@ export class SocketServer { }); socket.broadcast.emit("pixel", newPixel); }); + + socket.on("undo", async (ack) => { + if (!user) { + ack({ success: false, error: "no_user" }); + return; + } + + await user.update(true); + + if (!user.undoExpires) { + // user has no undo available + ack({ success: false, error: "unavailable" }); + return; + } + + const isExpired = user.undoExpires.getTime() - Date.now() < 0; + + if (isExpired) { + // expiration date is in the past, so no undo is available + ack({ success: false, error: "unavailable" }); + return; + } + + // find most recent pixel + const pixel = await prisma.pixel.findFirst({ + where: { userId: user.sub }, + orderBy: { createdAt: "desc" }, + }); + + if (!pixel) { + // user doesn't have a pixel, idk how we got here, but they can't do anything + ack({ success: false, error: "unavailable" }); + return; + } + + // mark the undo as used + await user.setUndo(); + + // delete most recent pixel + await prisma.pixel.delete({ where: { id: pixel.id } }); + + // trigger re-cache on redis + await Canvas.refreshPixel(pixel.x, pixel.y); + + ack({ success: true, data: {} }); + }); } /** diff --git a/packages/server/src/models/User.ts b/packages/server/src/models/User.ts index e3486cb3ed66820d476ad3669cc8f3c9cf889258..1ee1d1e8620d6dc5b3bb50666226267319803758 100644 --- a/packages/server/src/models/User.ts +++ b/packages/server/src/models/User.ts @@ -1,12 +1,17 @@ import { Socket } from "socket.io"; import { Logger } from "../lib/Logger"; import { prisma } from "../lib/prisma"; -import { AuthSession } from "@sc07-canvas/lib/src/net"; +import { + AuthSession, + ClientToServerEvents, + ServerToClientEvents, +} from "@sc07-canvas/lib/src/net"; interface IUserData { sub: string; lastPixelTime: Date; pixelStack: number; + undoExpires: Date | null; } export class User { @@ -16,8 +21,9 @@ export class User { lastPixelTime: Date; pixelStack: number; authSession?: AuthSession; + undoExpires?: Date; - sockets: Set = new Set(); + sockets: Set> = new Set(); private _updatedAt: number; @@ -27,6 +33,7 @@ export class User { this.sub = data.sub; this.lastPixelTime = data.lastPixelTime; this.pixelStack = data.pixelStack; + this.undoExpires = data.undoExpires || undefined; this._updatedAt = Date.now(); } @@ -44,6 +51,7 @@ export class User { this.lastPixelTime = userData.lastPixelTime; this.pixelStack = userData.pixelStack; + this.undoExpires = userData.undoExpires || undefined; } async modifyStack(modifyBy: number): Promise { @@ -62,6 +70,41 @@ export class User { await this.update(true); } + /** + * Set undoExpires in database and notify all user's sockets of undo ttl + */ + async setUndo(expires?: Date) { + if (expires) { + // expiration being set + + await prisma.user.update({ + where: { sub: this.sub }, + data: { + undoExpires: expires, + }, + }); + + for (const socket of this.sockets) { + socket.emit("undo", { available: true, expireAt: expires.getTime() }); + } + } else { + // clear undo capability + + await prisma.user.update({ + where: { sub: this.sub }, + data: { + undoExpires: undefined, + }, + }); + + for (const socket of this.sockets) { + socket.emit("undo", { available: false }); + } + } + + await this.update(true); + } + /** * Determine if this user data is stale and should be updated * @see User#update