diff --git a/backend/game.ts b/backend/game.ts index 3360d9d..c3333db 100644 --- a/backend/game.ts +++ b/backend/game.ts @@ -3,6 +3,8 @@ import type { PlayerBroadcast } from "../shared/broadcast" import { script } from "../shared/script" import type { SceneDefinition } from "../shared/script/types" import { sceneTypesById } from "./scene-types" +import type { Tagged } from "type-fest" +import type { SessionId } from "./session" interface Events { "player-broadcast": [PlayerBroadcast] @@ -31,6 +33,10 @@ export class Game { return events } + removePlayer(sessionId: SessionId) { + this.currentScene.state.removePlayer(sessionId) + } + switchScene(sceneId: string) { const definition = script.scenesById.get(sceneId) if (definition === undefined) throw new Error(`Unknown scene: ${sceneId}`) @@ -43,9 +49,11 @@ export class Game { this.eventBus.emit("player-broadcast", { type: "scene-changed", sceneId: sceneId }) } - withSceneState(type: Type, block: (state: ReturnType) => void) { + withSceneState(type: Type, block: (state: ReturnType) => R): R | null { if (this.currentScene.definition.type === type) { - block(this.currentScene.state) + return block(this.currentScene.state) + } else { + return null } } } diff --git a/backend/main.ts b/backend/main.ts index 8a100b0..4b6d8c5 100644 --- a/backend/main.ts +++ b/backend/main.ts @@ -10,7 +10,7 @@ const expressApp = createExpressApp() expressApp.use("/trpc", createTrpcMiddleware({ router: appRouter, - createContext: ({ req, res }) => createContext(res), + createContext: ({ req, res }) => createContext(req, res), })) if (!isDev) expressApp.use(staticMiddleware(resolve(import.meta.dirname, "../dist"))) diff --git a/backend/scene-types/base.ts b/backend/scene-types/base.ts index 894bfc6..f29c9ba 100644 --- a/backend/scene-types/base.ts +++ b/backend/scene-types/base.ts @@ -1,5 +1,6 @@ import type { Game } from "../game" import type { SceneDefinitionBase } from "../../shared/script/types" +import type { SessionId } from "../session" export interface SceneType { id: DefinitionT["id"] @@ -8,4 +9,5 @@ export interface SceneType { getConnectionEvents(): EventT[] + removePlayer(sessionId: SessionId): void } diff --git a/backend/scene-types/choice.ts b/backend/scene-types/choice.ts index a67d9c0..2611fdb 100644 --- a/backend/scene-types/choice.ts +++ b/backend/scene-types/choice.ts @@ -1,14 +1,87 @@ import { type SceneState, type SceneType } from "./base" import type { Game } from "../game" import type { ChoiceSceneDefinition, TextSceneDefinition } from "../../shared/script/types" +import type { ChoiceSceneEvent } from "../../shared/scene-types/choice" +import type { SessionId } from "../session" +import { getSuggestedInteractionId, type InteractionSceneEvent } from "../../shared/scene-types/interaction" +import type { SuggestedInteraction } from "../../shared/mutations" -export const ChoiceSceneType: SceneType = { +export const ChoiceSceneType: SceneType = { id: "choice", - createState(game: Game, definition: ChoiceSceneDefinition): SceneState { - return { - getConnectionEvents() { - return [] - } + createState(game: Game, definition: ChoiceSceneDefinition): ChoiceSceneState { + return new ChoiceSceneState(game, definition) + } +} + +class ChoiceSceneState implements SceneState { + private optionsById: Map }> = new Map() + private votedOptionIdBySessionId: Map = new Map() + private isVotingConcluded: boolean = false + + constructor(private game: Game, private definition: ChoiceSceneDefinition) { + definition.options.forEach(o => this.optionsById.set(o.id, { votes: new Set() })) + } + + getConnectionEvents(): ChoiceSceneEvent[] { + const events: ChoiceSceneEvent[] = [] + events.push(this.getVotesChangedEvent()) + + if (this.isVotingConcluded) events.push({ type: "voting-concluded" }) + return events + } + + removePlayer(sessionId: SessionId) { + const votedOptionId = this.votedOptionIdBySessionId.get(sessionId) + if (votedOptionId !== undefined) { + const { votes } = this.optionsById.get(votedOptionId)! + votes.delete(sessionId) + this.emitVotesChanged() } + + this.votedOptionIdBySessionId.delete(sessionId) + } + + private emit(event: ChoiceSceneEvent) { + this.game.eventBus.emit("player-broadcast", { type: "scene-event", event }) + } + + private getVotesChangedEvent(): ChoiceSceneEvent { + return { type: "votes-changed", votesByOptionId: Object.fromEntries(this.optionsById.entries().map(([id, { votes }]) => ([id, votes.size]))) } + } + + private emitVotesChanged() { + this.emit(this.getVotesChangedEvent()) + } + + setVote(optionId: string | null, sessionId: SessionId) { + const currentVotedOptionId = this.votedOptionIdBySessionId.get(sessionId) + if (currentVotedOptionId !== undefined) { + if (optionId === currentVotedOptionId) return + const { votes } = this.optionsById.get(currentVotedOptionId)! + votes.delete(sessionId) + this.votedOptionIdBySessionId.delete(sessionId) + } + + if (optionId !== null) { + const { votes } = this.optionsById.get(optionId)! + votes.add(sessionId) + this.votedOptionIdBySessionId.set(sessionId, optionId) + } + + this.emitVotesChanged() + } + + concludeVoting() { + if (this.isVotingConcluded) return + this.isVotingConcluded = true + this.emit({ type: "voting-concluded"}) + } + + restartVoting() { + this.isVotingConcluded = false + this.optionsById.forEach(({ votes }) => votes.clear()) + this.votedOptionIdBySessionId.clear() + this.emitVotesChanged() + this.emit({ type: "voting-restarted" }) } } \ No newline at end of file diff --git a/backend/scene-types/interaction.ts b/backend/scene-types/interaction.ts index 5922e60..704de74 100644 --- a/backend/scene-types/interaction.ts +++ b/backend/scene-types/interaction.ts @@ -3,6 +3,7 @@ import type { Game } from "../game" import type { SuggestedInteraction } from "../../shared/mutations" import type { InteractionSceneDefinition } from "../../shared/script/types" import { getSuggestedInteractionId, type InteractionSceneEvent } from "../../shared/scene-types/interaction" +import type { SessionId } from "../session" export const InteractionSceneType = { id: "interaction", @@ -12,7 +13,8 @@ export const InteractionSceneType = { } as const satisfies SceneType export class InteractionSceneState implements SceneState { - private interactionQueue: Map = new Map() + private interactionQueue: Map }> = new Map() + private suggestedInteractionIdBySessionId: Map = new Map() private ongoingInteractionExecution: SuggestedInteraction | null = null private objectVisibilityById = new Map() @@ -31,43 +33,61 @@ export class InteractionSceneState implements SceneState return events } - private emit(event: InteractionSceneEvent) { - this.game.emit("player-broadcast", { type: "scene-event", event }) + removePlayer(sessionId: SessionId) { + const suggestedInteractionId = this.suggestedInteractionIdBySessionId.get(sessionId) + if (suggestedInteractionId !== undefined) { + const { votes } = this.interactionQueue.get(suggestedInteractionId)! + votes.delete(sessionId) + if (votes.size <= 0) this.interactionQueue.delete(suggestedInteractionId) + + this.emitVotesChanged() + } + + this.suggestedInteractionIdBySessionId.delete(sessionId) } - private getVotesChangedEvent() { - return { type: "votes-changed", votesByInteractionId: Object.fromEntries(this.interactionQueue.entries().map(([id, { votes }]) => ([id, votes]))) } as const + private emit(event: InteractionSceneEvent) { + this.game.eventBus.emit("player-broadcast", { type: "scene-event", event }) + } + + private getVotesChangedEvent(): InteractionSceneEvent { + return { type: "votes-changed", votesByInteractionId: Object.fromEntries(this.interactionQueue.entries().map(([id, { votes }]) => ([id, votes.size]))) } } private emitVotesChanged() { this.emit(this.getVotesChangedEvent()) } - addInteractionVote(interaction: SuggestedInteraction) { - const id = getSuggestedInteractionId(interaction) - const existingItem = this.interactionQueue.get(id) - if (existingItem === undefined) { - const item = { - id, - votes: 1, - interaction + setInteractionVote(interaction: SuggestedInteraction | null, sessionId: SessionId) { + const newInteractionId = interaction === null ? null : getSuggestedInteractionId(interaction) + const currentSuggestedInteractionId = this.suggestedInteractionIdBySessionId.get(sessionId) + if (currentSuggestedInteractionId !== undefined) { + if (newInteractionId === currentSuggestedInteractionId) return + const { votes } = this.interactionQueue.get(currentSuggestedInteractionId)! + votes.delete(sessionId) + if (votes.size <= 0) this.interactionQueue.delete(currentSuggestedInteractionId) + this.suggestedInteractionIdBySessionId.delete(sessionId) + } + + if (newInteractionId !== null) { + const existingItem = this.interactionQueue.get(newInteractionId) + if (existingItem === undefined) { + const item = { + id: newInteractionId, + votes: new Set([sessionId]), + interaction: interaction! + } + + this.interactionQueue.set(item.id, item) + this.emit({ type: "interaction-queued", interaction: interaction! }) + } else { + existingItem.votes.add(sessionId) } - this.interactionQueue.set(item.id, item) - this.emit({ type: "interaction-queued", interaction }) - } else { - existingItem.votes += 1 - this.emitVotesChanged() + this.suggestedInteractionIdBySessionId.set(sessionId, newInteractionId) } - } - removeInteractionVote(id: string) { - const item = this.interactionQueue.get(id) - if (item !== undefined) { - item.votes -= 1 - if (item.votes <= 0) this.interactionQueue.delete(id) - this.emitVotesChanged() - } + this.emitVotesChanged() } startInteractionExecution(interaction: SuggestedInteraction) { @@ -75,12 +95,11 @@ export class InteractionSceneState implements SceneState this.emit({ type: "interaction-execution-started", interaction }) } - finishInteractionExecution(id: string, requireStarted: boolean = true) { - if (requireStarted && (this.ongoingInteractionExecution === null || id !== getSuggestedInteractionId(this.ongoingInteractionExecution))) return + finishInteractionExecution(id: string, onlyIfOngoing: boolean = true) { + if (onlyIfOngoing && (this.ongoingInteractionExecution === null || id !== getSuggestedInteractionId(this.ongoingInteractionExecution))) return this.ongoingInteractionExecution = null this.emit({ type: "interaction-execution-finished" }) - this.interactionQueue.delete(id) - this.emitVotesChanged() + this.removeInteractionFromQueue(id) const interaction = this.definition.interactionsById.get(id) if (interaction === undefined) return @@ -104,8 +123,16 @@ export class InteractionSceneState implements SceneState } } - removeInteractionQueueItem(id: string) { - if (!this.interactionQueue.has(id)) return + cancelInteractionExecution(id: string, onlyIfOngoing: boolean = true) { + if (onlyIfOngoing && (this.ongoingInteractionExecution === null || id !== getSuggestedInteractionId(this.ongoingInteractionExecution))) return + this.ongoingInteractionExecution = null + this.emit({ type: "interaction-execution-cancelled" }) + } + + removeInteractionFromQueue(id: string) { + const queueItem = this.interactionQueue.get(id) + if (queueItem === undefined) return + queueItem.votes.forEach(sessionId => this.suggestedInteractionIdBySessionId.delete(sessionId)) this.interactionQueue.delete(id) this.emitVotesChanged() } diff --git a/backend/scene-types/text.ts b/backend/scene-types/text.ts index ce201f5..e492e7f 100644 --- a/backend/scene-types/text.ts +++ b/backend/scene-types/text.ts @@ -1,6 +1,7 @@ import { type SceneState, type SceneType } from "./base" import type { Game } from "../game" import type { TextSceneDefinition } from "../../shared/script/types" +import type { SessionId } from "../session" export const TextSceneType: SceneType = { id: "text", @@ -8,6 +9,9 @@ export const TextSceneType: SceneType = { return { getConnectionEvents() { return [] + }, + removePlayer(sessionId: SessionId) { + } } } diff --git a/backend/session.ts b/backend/session.ts new file mode 100644 index 0000000..0e5f59c --- /dev/null +++ b/backend/session.ts @@ -0,0 +1,3 @@ +import type { Tagged } from "type-fest" + +export type SessionId = Tagged \ No newline at end of file diff --git a/backend/trpc/base.ts b/backend/trpc/base.ts index 2d588f7..39b22d1 100644 --- a/backend/trpc/base.ts +++ b/backend/trpc/base.ts @@ -1,14 +1,22 @@ import { initTRPC } from "@trpc/server" -import type { Response } from "express" +import type { Request, Response } from "express" import superjson from "superjson" +import type { SessionId } from "../session" export interface Context { res?: Response + sessionId: SessionId + isCrew: boolean } -export async function createContext(res: Response | undefined): Promise { +export async function createContext(req: Request, res: Response | undefined): Promise { + const sessionId = req.headers["auio-session"] + if (sessionId === null || typeof sessionId !== "string" || sessionId.length !== 64) throw new Error(`Missing or invalid session ID: ${sessionId}`) + return { - res + res, + sessionId: sessionId as SessionId, + isCrew: req.headers["auio-crew-token"] === process.env.AUIO_CREW_TOKEN } } diff --git a/backend/trpc/crew.ts b/backend/trpc/crew.ts new file mode 100644 index 0000000..4533a2f --- /dev/null +++ b/backend/trpc/crew.ts @@ -0,0 +1,75 @@ +import { t } from "./base" +import { z } from "zod" +import { game } from "../game" +import { suggestedInteractionSchema } from "../../shared/mutations" +import { TRPCError } from "@trpc/server" + +const crewProcedure = t.procedure.use(({ ctx, next }) => { + if (!ctx.isCrew) throw new TRPCError({ code: "UNAUTHORIZED" }) + return next() +}) + +export const crewRouter = t.router({ + switchRoom: crewProcedure + .input(z.object({ + roomId: z.string() + })) + .mutation(async ({ input }) => { + game.switchScene(input.roomId) + }), + + interactionScene: t.router({ + setObjectVisibility: crewProcedure + .input(z.object({ + objectId: z.string(), + isVisible: z.boolean() + })) + .mutation(({ input }) => { + game.withSceneState("interaction", s => s.setObjectVisibility(input.objectId, input.isVisible)) + }), + + startInteractionExecution: crewProcedure + .input(z.object({ + interaction: suggestedInteractionSchema + })) + .mutation(({ input }) => { + game.withSceneState("interaction", s => s.startInteractionExecution(input.interaction)) + }), + + finishInteractionExecution: crewProcedure + .input(z.object({ + interactionId: z.string(), + onlyIfOngoing: z.boolean() + })) + .mutation(({ input }) => { + game.withSceneState("interaction", s => s.finishInteractionExecution(input.interactionId, input.onlyIfOngoing)) + }), + + cancelInteractionExecution: crewProcedure + .input(z.object({ + interactionId: z.string(), + onlyIfOngoing: z.boolean() + })) + .mutation(({ input }) => { + game.withSceneState("interaction", s => s.cancelInteractionExecution(input.interactionId, input.onlyIfOngoing)) + }), + + removeInteractionFromQueue: crewProcedure + .input(z.object({ + interactionId: z.string() + })) + .mutation(({ input }) => { + game.withSceneState("interaction", s => s.removeInteractionFromQueue(input.interactionId)) + }), + }), + + choiceScene: t.router({ + concludeVoting: crewProcedure.mutation(() => { + game.withSceneState("choice", s => s.concludeVoting()) + }), + + restartVoting: crewProcedure.mutation(() => { + game.withSceneState("choice", s => s.restartVoting()) + }) + }) +}) \ No newline at end of file diff --git a/backend/trpc/director.ts b/backend/trpc/director.ts deleted file mode 100644 index e630fcb..0000000 --- a/backend/trpc/director.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { t } from "./base" -import { z } from "zod" -import { game } from "../game" - -export const directorRouter = t.router({ - switchRoom: t.procedure - .input(z.object({ - roomId: z.string() - })) - .mutation(async ({ input }) => { - game.switchScene(input.roomId) - }) -}) \ No newline at end of file diff --git a/backend/trpc/index.ts b/backend/trpc/index.ts index 9f2721f..1f05eaf 100644 --- a/backend/trpc/index.ts +++ b/backend/trpc/index.ts @@ -1,16 +1,20 @@ import { t } from "./base" import { playerRouter } from "./player" -import { directorRouter } from "./director" +import { crewRouter } from "./crew" import { game } from "../game" import { on } from "node:events" export const appRouter = t.router({ player: playerRouter, - director: directorRouter, + director: crewRouter, join: t.procedure - .subscription(async function*() { - const iterable = on(game.eventBus, "player-broadcast") + .subscription(async function*({ signal, ctx }) { + signal!.addEventListener("abort", () => { + game.removePlayer(ctx.sessionId) + }) + + const iterable = on(game.eventBus, "player-broadcast", { signal }) for (const broadcast of game.getConnectionPlayerBroadcasts()) { yield broadcast diff --git a/backend/trpc/player.ts b/backend/trpc/player.ts index 44ec3a7..3f63501 100644 --- a/backend/trpc/player.ts +++ b/backend/trpc/player.ts @@ -1,5 +1,26 @@ import { t } from "./base" +import { z } from "zod" +import { suggestedInteractionSchema } from "../../shared/mutations" +import { game } from "../game" export const playerRouter = t.router({ + interactionScene: t.router({ + setInteractionVote: t.procedure + .input(z.object({ + interaction: z.nullable(suggestedInteractionSchema) + })) + .mutation(({ ctx, input }) => { + game.withSceneState("interaction", s => s.setInteractionVote(input.interaction, ctx.sessionId)) + }), + }), + choiceScene: t.router({ + setVote: t.procedure + .input(z.object({ + optionId: z.nullable(z.string()) + })) + .mutation(({ ctx, input }) => { + game.withSceneState("choice", s => s.setVote(input.optionId, ctx.sessionId)) + }), + }) }) \ No newline at end of file diff --git a/shared/scene-types/choice.ts b/shared/scene-types/choice.ts index 0de94fc..b5a6c09 100644 --- a/shared/scene-types/choice.ts +++ b/shared/scene-types/choice.ts @@ -1,2 +1,4 @@ export type ChoiceSceneEvent = - | { type: "option-votes-changed"} \ No newline at end of file + | { type: "votes-changed", votesByOptionId: Record } + | { type: "voting-concluded" } + | { type: "voting-restarted" } \ No newline at end of file