Skip to content
Canvas.ts 10.4 KiB
Newer Older
Grant's avatar
Grant committed
import { CanvasConfig } from "@sc07-canvas/lib/src/net";
Grant's avatar
Grant committed
import { prisma } from "./prisma";
import { Redis } from "./redis";
Grant's avatar
Grant committed
import { SocketServer } from "./SocketServer";
Grant's avatar
Grant committed
import { getLogger } from "./Logger";
import { Pixel } from "@prisma/client";
import { CanvasWorker } from "../workers/worker";
Grant's avatar
Grant committed

const Logger = getLogger("CANVAS");
Grant's avatar
Grant committed

class Canvas {
Grant's avatar
Grant committed
  /**
   * Size of the canvas
   */
  private canvasSize: [width: number, height: number];
Grant's avatar
Grant committed

  constructor() {
Grant's avatar
Grant committed
    this.canvasSize = [100, 100];
Grant's avatar
Grant committed
  }

Grant's avatar
Grant committed
  getCanvasConfig(): CanvasConfig {
Grant's avatar
Grant committed
    return {
Grant's avatar
Grant committed
      size: this.canvasSize,
Grant's avatar
Grant committed
      zoom: 7,
Grant's avatar
Grant committed
      pixel: {
Grant's avatar
Grant committed
        cooldown: 10,
Grant's avatar
Grant committed
        multiplier: 3,
        maxStack: 6,
      },
Grant's avatar
Grant committed
      undo: {
        grace_period: 5000,
      },
Grant's avatar
Grant committed
    };
  }

Grant's avatar
Grant committed
  /**
   * Change size of the canvas
   *
   * Expensive task, will take a bit
   *
   * @param width
   * @param height
   */
Grant's avatar
Grant committed
  async setSize(width: number, height: number, useStatic = false) {
    if (useStatic) {
      this.canvasSize = [width, height];
      return;
    }

    const now = Date.now();
    Logger.info("[Canvas#setSize] has started", {
      old: this.canvasSize,
      new: [width, height],
    });

Grant's avatar
Grant committed
    this.canvasSize = [width, height];
    await prisma.setting.upsert({
      where: { key: "canvas.size" },
      create: {
        key: "canvas.size",
        value: JSON.stringify({ width, height }),
      },
      update: {
        key: "canvas.size",
        value: JSON.stringify({ width, height }),
      },
    });
Grant's avatar
Grant committed

    // the redis key is 1D, since the dimentions changed we need to update it
    await this.canvasToRedis();

Grant's avatar
Grant committed
    // this gets called on startup, before the SocketServer is initialized
    // so only call if it's available
    if (SocketServer.instance) {
      // announce the new config, which contains the canvas size
      SocketServer.instance.broadcastConfig();
Grant's avatar
Grant committed
      // announce new pixel array that was generated previously
      await this.getPixelsArray().then((pixels) => {
        SocketServer.instance?.io.emit("canvas", pixels);
      });
    } else {
      Logger.warn(
        "[Canvas#setSize] No SocketServer instance, cannot broadcast config change"
      );
    }
Grant's avatar
Grant committed
    Logger.info(
      "[Canvas#setSize] has finished in " +
        ((Date.now() - now) / 1000).toFixed(1) +
        " seconds"
    );
Grant's avatar
Grant committed
  async forceUpdatePixelIsTop() {
    const now = Date.now();
    Logger.info("[Canvas#forceUpdatePixelIsTop] is starting...");
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    for (let x = 0; x < this.canvasSize[0]; x++) {
      for (let y = 0; y < this.canvasSize[1]; y++) {
Grant's avatar
Grant committed
        const pixel = (
          await prisma.pixel.findMany({
            where: { x, y },
            orderBy: {
              createdAt: "desc",
            },
            take: 1,
          })
        )?.[0];
Grant's avatar
Grant committed

Grant's avatar
Grant committed
        if (pixel) {
          await prisma.pixel.update({
            where: {
              id: pixel.id,
            },
            data: {
              isTop: true,
            },
          });
        }
Grant's avatar
Grant committed
      }
    }
Grant's avatar
Grant committed

    Logger.info(
      "[Canvas#forceUpdatePixelIsTop] has finished in " +
        ((Date.now() - now) / 1000).toFixed(1) +
        " seconds"
    );
Grant's avatar
Grant committed
   * Undo a pixel
   * @throws Error "Pixel is not on top"
   * @param pixel
   */
  async undoPixel(pixel: Pixel) {
    if (!pixel.isTop) throw new Error("Pixel is not on top");

    await prisma.pixel.update({
      where: { id: pixel.id },
      data: {
        deletedAt: new Date(),
        isTop: false,
      },
    });

    const coveringPixel = (
      await prisma.pixel.findMany({
        where: { x: pixel.x, y: pixel.y, createdAt: { lt: pixel.createdAt } },
        orderBy: { createdAt: "desc" },
        take: 1,
      })
    )?.[0];

    if (coveringPixel) {
      await prisma.pixel.update({
        where: { id: coveringPixel.id },
        data: {
          isTop: true,
        },
      });
    }
  }

  /**
   * Converts database pixels to Redis string
   *
   * @worker
   * @returns
Grant's avatar
Grant committed
   */
  canvasToRedis(): Promise<string[]> {
    return new Promise((res) => {
      Logger.info("Triggering canvasToRedis()");
      const [width, height] = this.getCanvasConfig().size;

      CanvasWorker.once("message", (msg) => {
        if (msg.type === "canvasToRedis") {
          Logger.info("Finished canvasToRedis()");
          res(msg.data);
        }
      });
Grant's avatar
Grant committed

      CanvasWorker.postMessage({
        type: "canvasToRedis",
        width,
        height,
      });
Grant's avatar
Grant committed
    });
Grant's avatar
Grant committed
  }

  /**
   * force an update at a specific position
   */
  async updateCanvasRedisAtPos(x: number, y: number) {
    const redis = await Redis.getClient();
Grant's avatar
Grant committed

    const pixels: string[] = (
Grant's avatar
Grant committed
      (await redis.get(Redis.key("canvas"))) || ""
    ).split(",");
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    const dbpixel = await this.getPixel(x, y);

    pixels[this.canvasSize[0] * y + x] = dbpixel?.color || "transparent";

    await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
  }

  async updateCanvasRedisWithBatch(
    pixelBatch: { x: number; y: number; hex: string }[]
  ) {
    const redis = await Redis.getClient();

    const pixels: string[] = (
      (await redis.get(Redis.key("canvas"))) || ""
    ).split(",");

    for (const pixel of pixelBatch) {
      pixels[this.canvasSize[0] * pixel.y + pixel.x] = pixel.hex;
    }
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
  async isPixelArrayCached() {
    const redis = await Redis.getClient();

    return await redis.exists(Redis.key("canvas"));
  }

Grant's avatar
Grant committed
  async getPixelsArray() {
    const redis = await Redis.getClient();
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    if (await redis.exists(Redis.key("canvas"))) {
      const cached = await redis.get(Redis.key("canvas"));
Grant's avatar
Grant committed
      return cached!.split(",");
    }

    return await this.canvasToRedis();
  }

  /**
   * Get if a pixel is maybe empty
   * @param x
   * @param y
   * @returns
   */
  async isPixelEmpty(x: number, y: number) {
Grant's avatar
Grant committed
    const pixel = await this.getPixel(x, y);
    return pixel === null;
  async getPixel(x: number, y: number) {
Grant's avatar
Grant committed
    return await prisma.pixel.findFirst({
      where: {
        x,
        y,
        isTop: true,
      },
    });
  }

  async fillArea(
    user: { sub: string },
    start: [x: number, y: number],
    end: [x: number, y: number],
    hex: string
  ) {
    await prisma.pixel.updateMany({
      where: {
        x: {
          gte: start[0],
          lt: end[0],
Grant's avatar
Grant committed
        y: {
          gte: start[1],
          lt: end[1],
Grant's avatar
Grant committed
        isTop: true,
      },
      data: {
        isTop: false,
      },
    });

    let pixels: {
      x: number;
      y: number;
    }[] = [];

    for (let x = start[0]; x <= end[0]; x++) {
      for (let y = start[1]; y <= end[1]; y++) {
        pixels.push({
          x,
          y,
        });
      }
    }

    await prisma.pixel.createMany({
      data: pixels.map((px) => ({
        userId: user.sub,
        color: hex,
        isTop: true,
        isModAction: true,
        ...px,
      })),
    });

    await this.updateCanvasRedisWithBatch(
      pixels.map((px) => ({
        ...px,
        hex,
      }))
    );
Grant's avatar
Grant committed
  async setPixel(
    user: { sub: string },
    x: number,
    y: number,
    hex: string,
    isModAction: boolean
  ) {
    // only one pixel can be on top at (x,y)
    await prisma.pixel.updateMany({
      where: { x, y, isTop: true },
      data: {
        isTop: false,
      },
    });
Grant's avatar
Grant committed

    await prisma.pixel.create({
      data: {
        userId: user.sub,
        color: hex,
        x,
        y,
Grant's avatar
Grant committed
        isTop: true,
        isModAction,
Grant's avatar
Grant committed
      },
    });

    await prisma.user.update({
      where: { sub: user.sub },
      data: { lastPixelTime: new Date() },
    });

    // maybe only update specific element?
    // i don't think it needs to be awaited
    await this.updateCanvasRedisAtPos(x, y);
Grant's avatar
Grant committed

    Logger.info(`${user.sub} placed pixel at (${x}, ${y})`);
Grant's avatar
Grant committed
  }
Grant's avatar
Grant committed

  /**
   * Force a pixel to be updated in redis
   * @param x
   * @param y
   */
  async refreshPixel(x: number, y: number) {
    // find if any pixels exist at this spot, and pick the most recent one
    const pixel = await this.getPixel(x, y);
Grant's avatar
Grant committed
    let paletteColorID = -1;

    // if pixel exists in redis
    if (pixel) {
      paletteColorID = (await prisma.paletteColor.findFirst({
        where: { hex: pixel.color },
      }))!.id;
    }

    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,
    });
  }

  /**
   * Generate heatmap of active pixels
   *
   * @note expensive operation, takes a bit to execute
   * @returns 2 character strings with 0-100 in radix 36 (depends on canvas size)
   */
  async generateHeatmap() {
Grant's avatar
Grant committed
    const redis_set = await Redis.getClient("MAIN");
    const redis_sub = await Redis.getClient("SUB");

    const now = Date.now();
    const minimumDate = new Date();
    minimumDate.setHours(minimumDate.getHours() - 3); // 3 hours ago

    const pad = (str: string) => (str.length < 2 ? "0" : "") + str;

    const heatmap: string[] = [];

Grant's avatar
Grant committed
    const topPixels = await prisma.pixel.findMany({
      where: { isTop: true, createdAt: { gte: minimumDate } },
    });

    for (let y = 0; y < this.canvasSize[1]; y++) {
      const arr: number[] = [];

      for (let x = 0; x < this.canvasSize[0]; x++) {
Grant's avatar
Grant committed
        const pixel = topPixels.find((px) => px.x === x && px.y === y);

        if (pixel) {
          arr.push(
            ((1 -
              (now - pixel.createdAt.getTime()) /
                (now - minimumDate.getTime())) *
              100) >>
              0
          );
        } else {
          arr.push(0);
        }
      }

      heatmap.push(arr.map((num) => pad(num.toString(36))).join(""));
    }

    const heatmapStr = heatmap.join("");

    // cache for 5 minutes
Grant's avatar
Grant committed
    await redis_set.setEx(Redis.key("heatmap"), 60 * 5, heatmapStr);

    // notify anyone interested about the new heatmap
    await redis_set.publish(Redis.key("channel_heatmap"), heatmapStr);
Grant's avatar
Grant committed
    // SocketServer.instance.io.to("sub:heatmap").emit("heatmap", heatmapStr);

    return heatmapStr;
  }

  /**
   * Get cache heatmap safely
   * @returns see Canvas#generateHeatmap
   */
  async getCachedHeatmap(): Promise<string | undefined> {
    const redis = await Redis.getClient();

    if (!(await redis.exists(Redis.key("heatmap")))) {
      Logger.warn("Canvas#getCachedHeatmap has no cached heatmap");
      return undefined;
    }

    return (await redis.get(Redis.key("heatmap"))) as string;
  }
Grant's avatar
Grant committed
}

export default new Canvas();