add pixel stacking

This commit is contained in:
Grant 2024-03-08 15:37:24 -07:00
parent c3b8467b8f
commit e5821027dc
14 changed files with 467 additions and 69 deletions

View File

@ -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 = () => {
</span>
)}
<span>
Pixels: <span>123</span>
Pixels:{" "}
<span>
{pixels.available}/{config.canvas.pixel.maxStack}
</span>{" "}
<PlaceCountdown />
</span>
<span>
Users Online: <span>321</span>

View File

@ -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<IAppContext>({} as any);
@ -24,6 +24,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
const [cursorPosition, setCursorPosition] = useState<IPosition>();
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..."}

View File

@ -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<CanvasEvents> {
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<CanvasEvents> {
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<CanvasEvents> {
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<CanvasEvents> {
})
.then((ack) => {
if (ack.success) {
this.lastPlace = Date.now();
this.handlePixel(ack.data);
} else {
// TODO: handle undo pixel

View File

@ -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<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
@ -45,6 +47,14 @@ class Network extends EventEmitter<INetworkEvents> {
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);

View File

@ -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))
// );
}
})();

View File

@ -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<Pixel>) => 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<T> =
/**
* @template T the packet data
* @template E union type of errors possible
*/
export type PacketAck<T, E = string> =
| {
success: true;
data: T;
}
| { success: false; error: string };
| { success: false; error: E };
export type AuthSession = {
service: {

View File

@ -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]
}

View File

@ -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
);

View File

@ -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;

View File

@ -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

View File

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

View File

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

View File

@ -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 extends keyof IRedisKeys>(
key: Key,
...rest: Parameters<IRedisKeys[Key]>
): string {
return (this.keys[key] as any)(...rest);
}
keyRef<Key extends keyof IRedisKeys>(
key: Key
): (...params: Parameters<IRedisKeys[Key]>) => string {
return (...params) => this.key(key, ...params);
}
}
export const Redis = new _Redis();
export const Redis = new _Redis(RedisKeys);

View File

@ -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<string, User> = new Map();
sub: string;
lastPixelTime: Date;
pixelStack: number;
authSession?: AuthSession;
sockets: Set<Socket> = 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<any> {
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<User> {
const user = await this.fromSub(
auth.user.username + "@" + auth.service.instance.hostname
);
user.authSession = auth;
return user;
}
static async fromSub(sub: string): Promise<User> {
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";
}
}