From 45ad449f4ea35fb385432b8f1f3f8d0f340889b3 Mon Sep 17 00:00:00 2001 From: Grant Date: Sat, 27 Apr 2024 22:44:04 -0600 Subject: [PATCH] implement pixel undos (fixes #5) --- packages/client/src/components/App.tsx | 4 +- .../components/{ => Toolbar}/CanvasMeta.tsx | 6 +- .../{Pallete.scss => Toolbar/Palette.scss} | 6 +- .../{Pallete.tsx => Toolbar/Palette.tsx} | 11 ++-- .../src/components/Toolbar/ToolbarWrapper.tsx | 17 ++++++ .../src/components/Toolbar/UndoButton.tsx | 60 +++++++++++++++++++ packages/client/src/contexts/AppContext.tsx | 13 ++++ packages/client/src/lib/canvas.ts | 6 ++ packages/client/src/lib/network.ts | 18 ++---- packages/client/src/style.scss | 3 +- packages/lib/src/net.ts | 15 +++++ packages/server/prisma/dbml/schema.dbml | 1 + .../20240427225450_add_undo/migration.sql | 2 + packages/server/prisma/schema.prisma | 7 ++- packages/server/src/lib/Canvas.ts | 41 +++++++++++++ packages/server/src/lib/SocketServer.ts | 53 ++++++++++++++++ packages/server/src/models/User.ts | 47 ++++++++++++++- 17 files changed, 279 insertions(+), 31 deletions(-) rename packages/client/src/components/{ => Toolbar}/CanvasMeta.tsx (96%) rename packages/client/src/components/{Pallete.scss => Toolbar/Palette.scss} (96%) rename packages/client/src/components/{Pallete.tsx => Toolbar/Palette.tsx} (87%) create mode 100644 packages/client/src/components/Toolbar/ToolbarWrapper.tsx create mode 100644 packages/client/src/components/Toolbar/UndoButton.tsx create mode 100644 packages/server/prisma/migrations/20240427225450_add_undo/migration.sql diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx index 38494e6..e162521 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 b9867aa..80d0d90 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 c08a30f..424ceeb 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 518c74b..5e2cdfc 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 903e8d1..1f4b590 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 f028e4e..26b34e4 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 8c11c2d..8c2bf01 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 9c1c2af..e9c0b78 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 7381553..51ddc3b 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 b47549b..8590c29 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 0000000..4d5eddf --- /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 9bfab93..7616c16 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 4c4bbdb..1feb176 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 97cc5bd..321736c 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 e3486cb..1ee1d1e 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 -- GitLab