Skip to content

Commit

Permalink
Feat/token items table state (#361)
Browse files Browse the repository at this point in the history
* feat: refactor to add token instances to table state

* chore: make launch a little more descriptive

* feat: visible tokens for frontend

* fix: work with angle token instance in unit tests
  • Loading branch information
micahg authored Jan 29, 2025
1 parent 13146b7 commit b8b52f7
Show file tree
Hide file tree
Showing 12 changed files with 242 additions and 114 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
],
"compounds": [
{
"name": "Start All",
"name": "Backend All",
"configurations": [ "Build API", "Build UI", "Run API"],
"stopAll": true,
}
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/models/tablestate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HydratedTokenInstance, Rect } from "@micahg/tbltp-common";

export interface TableState {
overlay?: string;
overlayRev?: number;
background?: string;
backgroundRev?: number;
viewport: Rect;
angle: number;
backgroundSize?: Rect;
tokens: HydratedTokenInstance[];
}
2 changes: 1 addition & 1 deletion packages/api/src/models/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const TokenSchema = new Schema<IToken>(
user: { type: Schema.Types.ObjectId, required: true },
name: { type: String, required: true },
visible: { type: Boolean, default: false },
asset: { type: Schema.Types.ObjectId, required: false },
asset: { type: Schema.Types.ObjectId, required: false, ref: "Asset" },
hitPoints: {
type: Number,
required: false,
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/models/tokeninstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface ITokenInstance extends TokenInstance {

const TokenInstanceSchema = new Schema<ITokenInstance>(
{
token: { type: Schema.Types.ObjectId, required: true },
token: { type: Schema.Types.ObjectId, required: true, ref: "Token" },
scene: { type: Schema.Types.ObjectId, required: true },
user: { type: Schema.Types.ObjectId, required: true },
name: { type: String, required: true },
Expand Down
67 changes: 57 additions & 10 deletions packages/api/src/routes/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,66 @@ import {
getTableTopByUser,
setTableTopByScene,
} from "../utils/tabletop";
import { getSceneById } from "../utils/scene";
import { getUserScene } from "../utils/scene";
import {
getSceneTokenInstanceAssets,
getSceneTokenInstances,
} from "../utils/tokeninstance";
import { HydratedTokenInstance } from "@micahg/tbltp-common";
import { IUser } from "../models/user";
import { ITokenInstance } from "../models/tokeninstance";
import { IScene } from "../models/scene";
import { Document } from "mongoose";

export async function hydrateStateToken(
user: IUser,
scene: IScene,
tokens: Document<unknown, object, ITokenInstance>[],
) {
const stuff = await getSceneTokenInstanceAssets(user, scene);
if (stuff.length !== tokens.length)
throw new Error("Token count mismatch", { cause: 400 });

export function updateState(req: Request, res: Response, next: NextFunction) {
// this only works because we're starting from the same query on user/scene/visible
const hydrated: HydratedTokenInstance[] = [];
for (const [i, token] of tokens.entries()) {
const t = token.toObject({
flattenObjectIds: true,
}) as unknown as HydratedTokenInstance;
t.token = stuff[i].assets.location;
hydrated.push(t);
}
return hydrated;
}

export async function updateState(
req: Request,
res: Response,
next: NextFunction,
) {
const sceneID: string = req.body.scene;

return getUser(req.auth)
.then((user) => userExistsOr401(user))
.then((user) => getOrCreateTableTop(user)) // maybe we can skip this and just update by ID
.then((table) => setTableTopByScene(table._id.toString(), sceneID))
.then((table) => getSceneById(sceneID, table.user.toString()))
.then((scene) => res.app.emit(ASSETS_UPDATED_SIG, scene))
.then(() => res.sendStatus(200))
.catch((err) => next(err));
try {
const user = await getUser(req.auth);
await userExistsOr401(user);
const table = await getOrCreateTableTop(user);
await setTableTopByScene(table._id.toString(), sceneID);
const scenePromise = getUserScene(user, sceneID);
// get *visible* tokens
const tokenPromise = getSceneTokenInstances(user, sceneID, true);
const [scene, tokens] = await Promise.all([scenePromise, tokenPromise]);
res.sendStatus(200);

// fucking normalized tokens - get unique token ids - if this ever scales this probably
// needs to come off the hot path -- how big could a

// this only works because we're starting from the same query on user/scene/visible
const hydrated = await hydrateStateToken(user, scene, tokens);

res.app.emit(ASSETS_UPDATED_SIG, scene, hydrated);
} catch (err) {
return next(err);
}
}

export function getState(req: Request, res: Response, next: NextFunction) {
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/utils/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export function sceneViewportValidator() {
},
});
}

// TODO DELETE THIS
export function getSceneById(id: string, userId: string) {
return Scene.findOne({ _id: { $eq: id }, user: userId });
}
Expand Down
42 changes: 39 additions & 3 deletions packages/api/src/utils/tokeninstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { knownMongoError } from "./errors";
import { IUser } from "../models/user";
import mongoose from "mongoose";
import { IScene } from "../models/scene";

export function tokenInstanceValidator() {
return checkSchema({
Expand Down Expand Up @@ -125,11 +126,46 @@ export function deleteTokenInstanceValidator() {
});
}

export function getSceneTokenInstances(user: IUser, scene: string) {
return TokenInstanceModel.find({
export function getSceneTokenInstances(
user: IUser,
scene: string,
visible?: boolean,
) {
const query = {
user: { $eq: user._id },
scene: { $eq: scene },
});
};
if (visible !== undefined) {
query["visible"] = { $eq: visible };
}
return TokenInstanceModel.find(query);
}

export function getSceneTokenInstanceAssets(user: IUser, scene: IScene) {
return TokenInstanceModel.aggregate([
{ $match: { scene: scene._id, visible: true } },
{ $project: { token: 1 } },
{
$lookup: {
from: "tokens",
localField: "token",
foreignField: "_id",
as: "tokens",
},
},
{ $project: { _id: 1, "tokens.asset": 1 } },
{ $unwind: "$tokens" },
{
$lookup: {
from: "assets",
localField: "tokens.asset",
foreignField: "_id",
as: "assets",
},
},
{ $project: { _id: 1, "assets.location": 1 } },
{ $unwind: "$assets" },
]);
}

export function getUserTokenInstance(user: IUser, id: string) {
Expand Down
160 changes: 87 additions & 73 deletions packages/api/src/utils/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import {
} from "./constants";

import { log } from "./logger";
import { TableState } from "@micahg/tbltp-common";
import { TableState } from "../models/tablestate";
import { getFakeUser } from "./auth";
import { IScene } from "../models/scene";
import { getUserByID } from "./user";
import { getOrCreateTableTop } from "./tabletop";
import { getSceneById } from "./scene";
import { getUserScene } from "./scene";
import { getSceneTokenInstances } from "./tokeninstance";
import { HydratedTokenInstance } from "@micahg/tbltp-common";
import { hydrateStateToken } from "../routes/state";

interface WSStateMessage {
method?: string;
Expand Down Expand Up @@ -51,7 +54,7 @@ function getVerifiedToken(token: string) {
});
}

function verifyConnection(sock: WebSocket, req: IncomingMessage) {
async function verifyConnection(sock: WebSocket, req: IncomingMessage) {
log.info(`Websocket connection established ${req.socket.remoteAddress}`);
let jwt;
try {
Expand All @@ -69,58 +72,65 @@ function verifyConnection(sock: WebSocket, req: IncomingMessage) {
return;
}

getUserByID(jwt.sub)
.then((user) => {
// close socket for invalid users
if (!user) throw new Error("invalid user", { cause: WS_INVALID_USER });
const userID: string = user._id.toString();
if (SOCKET_SESSIONS.has(userID)) {
log.info(`New connection - closing old WS for user ${userID}`);
SOCKET_SESSIONS.get(userID).close();
SOCKET_SESSIONS.delete(userID);
}
SOCKET_SESSIONS.set(userID, sock);
sock.on("close", () => {
SOCKET_SESSIONS.delete(userID);
log.info(`Total websocket connections ${SOCKET_SESSIONS.size}`);
});
return user;
})
.then((user) => getOrCreateTableTop(user))
.then((table) => {
if (!table.scene)
throw new Error("User has no scene set", { cause: WS_NO_SCENE });
return getSceneById(table.scene.toString(), table.user.toString());
})
.then((scene) => {
const state: TableState = {
overlay: scene.overlayContent,
overlayRev: scene.overlayContentRev,
background: scene.playerContent,
backgroundRev: scene.playerContentRev,
viewport: scene.viewport,
backgroundSize: scene.backgroundSize,
angle: scene.angle || 0,
};
const msg: WSStateMessage = {
method: "connection",
state: state,
};
// make sure we don't get shut down on the next interval
sock["live"] = true;
sock.on("pong", () => (sock["live"] = true));
sock.send(JSON.stringify(msg));
})
.catch((err) => {
const msg = Object.prototype.hasOwnProperty.call(err, "message")
? err.message
: JSON.stringify(err);
const reason = Object.prototype.hasOwnProperty.call(err, "cause")
? err.cause
: null;
closeSocketWithError(sock, msg, reason);
try {
// get and validate the user
const user = await getUserByID(jwt.sub);
if (!user) throw new Error("invalid user", { cause: WS_INVALID_USER });

// cleanup old sockets for the user
const userID: string = user._id.toString();
if (SOCKET_SESSIONS.has(userID)) {
log.info(`New connection - closing old WS for user ${userID}`);
SOCKET_SESSIONS.get(userID).close();
SOCKET_SESSIONS.delete(userID);
}
SOCKET_SESSIONS.set(userID, sock);
sock.on("close", () => {
SOCKET_SESSIONS.delete(userID);
log.info(`Total websocket connections ${SOCKET_SESSIONS.size}`);
});

// get and validate the table
const table = await getOrCreateTableTop(user);
if (!table.scene)
throw new Error("User has no scene set", { cause: WS_NO_SCENE });

const sceneId = table.scene.toString();
const tokenPromise = getSceneTokenInstances(user, sceneId, true);
const scenePromise = getUserScene(user, sceneId);
const [scene, tokens] = await Promise.all([scenePromise, tokenPromise]);

const hydrated = await hydrateStateToken(user, scene, tokens);

const state: TableState = {
overlay: scene.overlayContent,
overlayRev: scene.overlayContentRev,
background: scene.playerContent,
backgroundRev: scene.playerContentRev,
viewport: scene.viewport,
backgroundSize: scene.backgroundSize,
angle: scene.angle || 0,
tokens: hydrated,
};
const msg: WSStateMessage = {
method: "connection",
state: state,
};

// make sure we don't get shut down on the next interval
sock["live"] = true;
sock.on("pong", () => (sock["live"] = true));
sock.send(JSON.stringify(msg));
} catch (err) {
const msg = Object.prototype.hasOwnProperty.call(err, "message")
? err.message
: JSON.stringify(err);
const reason = Object.prototype.hasOwnProperty.call(err, "cause")
? err.cause
: null;
closeSocketWithError(sock, msg, reason);
}

sock.on("message", (buf) => {
const data = buf.toString();
log.info(`Received "${data}"`);
Expand All @@ -137,26 +147,30 @@ export function startWSServer(
const wss = new WebSocketServer({ server: nodeServer });
const emitter = app as EventEmitter;

emitter.on(ASSETS_UPDATED_SIG, (update: IScene) => {
const userID = update.user.toString();
if (!SOCKET_SESSIONS.has(userID)) return;
const tableState: TableState = {
overlay: update.overlayContent,
overlayRev: update.overlayContentRev,
background: update.playerContent,
backgroundRev: update.playerContentRev,
viewport: update.viewport,
backgroundSize: update.backgroundSize,
angle: update.angle || 0,
};
const sock: WebSocket = SOCKET_SESSIONS.get(userID);
const msg: WSStateMessage = {
method: ASSETS_UPDATED_SIG,
state: tableState,
};
log.info(`Sending ${JSON.stringify(msg)}`);
sock.send(JSON.stringify(msg));
});
emitter.on(
ASSETS_UPDATED_SIG,
(scene: IScene, tokens: HydratedTokenInstance[]) => {
const userID = scene.user.toString();
if (!SOCKET_SESSIONS.has(userID)) return;
const tableState: TableState = {
overlay: scene.overlayContent,
overlayRev: scene.overlayContentRev,
background: scene.playerContent,
backgroundRev: scene.playerContentRev,
viewport: scene.viewport,
backgroundSize: scene.backgroundSize,
angle: scene.angle || 0,
tokens: tokens,
};
const sock: WebSocket = SOCKET_SESSIONS.get(userID);
const msg: WSStateMessage = {
method: ASSETS_UPDATED_SIG,
state: tableState,
};
log.info(`Sending ${JSON.stringify(msg)}`);
sock.send(JSON.stringify(msg));
},
);

wss.on("connection", verifyConnection);
const interval = setInterval(() => {
Expand Down
Loading

0 comments on commit b8b52f7

Please sign in to comment.