Add a features (to-do) list, update tRPC and make scene types an abstract concept
Some checks failed
Build / build (push) Failing after 1m0s
Some checks failed
Build / build (push) Failing after 1m0s
This commit is contained in:
parent
0af6f1a585
commit
22d1b8ceff
29 changed files with 1179 additions and 1014 deletions
27
README.md
Normal file
27
README.md
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# level-up
|
||||||
|
|
||||||
|
> Interactive theater experience.
|
||||||
|
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [ ] Interaction scenes
|
||||||
|
- [ ] voting
|
||||||
|
- [ ] selecting an interaction for execution
|
||||||
|
- [ ] notifying when the chosen interaction is executed
|
||||||
|
- [ ] showing/hiding items manually
|
||||||
|
- [ ] vote rigging
|
||||||
|
- [ ] textual notes attached to specific interactions
|
||||||
|
- [ ] items marked as "not essential to the completion of the scene"
|
||||||
|
- [ ] multistep crafting without prescribed order of _combine_ interactions
|
||||||
|
|
||||||
|
- [ ] Choice scenes
|
||||||
|
- [ ] voting
|
||||||
|
- [ ] concluding the voting
|
||||||
|
- [ ] vote rigging
|
||||||
|
|
||||||
|
- [ ] temporarily disabling player input/output
|
||||||
|
- [ ] pre-start scene
|
||||||
|
- [ ] scene timers
|
||||||
|
- [ ] authentication
|
||||||
|
- [ ] achievements
|
126
backend/game.ts
126
backend/game.ts
|
@ -1,115 +1,53 @@
|
||||||
import EventEmitter from "eventemitter3"
|
import EventEmitter from "node:events"
|
||||||
import type { GameEvent } from "../shared/gameEvents"
|
import type { PlayerBroadcast } from "../shared/broadcast"
|
||||||
import type { Interaction, InteractionQueueItem } from "../shared/script/types"
|
|
||||||
import { script } from "../shared/script"
|
import { script } from "../shared/script"
|
||||||
import { findMatchingCombination, getInteractionQueueItemId } from "../shared/util"
|
import type { SceneDefinition } from "../shared/script/types"
|
||||||
|
import { sceneTypesById } from "./scene-types"
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
"game-event": [GameEvent]
|
"player-broadcast": [PlayerBroadcast]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Game extends EventEmitter<Events> {
|
export interface CurrentScene<Type extends keyof typeof sceneTypesById> {
|
||||||
private currentRoomId: string = script.roomsById.values().next()!.value!.id
|
id: string
|
||||||
private interactionQueue: Map<string, InteractionQueueItem> = new Map()
|
definition: SceneDefinition & { type: Type }
|
||||||
private visibleObjectIds = new Set<string>()
|
state: ReturnType<typeof sceneTypesById[Type]["createState"]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Game {
|
||||||
|
// @ts-expect-error
|
||||||
|
private currentScene: CurrentScene
|
||||||
|
public eventBus = new EventEmitter<Events>({ captureRejections: false })
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
this.switchScene("pre-start")
|
||||||
this.switchRoom(this.currentRoomId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getInitialStateEvents(): GameEvent[] {
|
getConnectionPlayerBroadcasts(): PlayerBroadcast[] {
|
||||||
const events: GameEvent[] = []
|
const events: PlayerBroadcast[] = []
|
||||||
|
events.push({ type: "scene-changed", sceneId: this.currentScene.id })
|
||||||
events.push({ type: "room-changed", roomId: this.currentRoomId })
|
events.push(...this.currentScene.state.getConnectionEvents())
|
||||||
|
|
||||||
this.interactionQueue.forEach(item => events.push({ type: "interaction-queued", item }))
|
|
||||||
script.roomsById.get(this.currentRoomId)!.initialObjects.forEach(o => {
|
|
||||||
if (!this.visibleObjectIds.has(o.id)) events.push({ type: "object-visibility-changed", id: o.id, isVisible: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
script.roomsById.get(this.currentRoomId)!.hiddenObjects.forEach(o => {
|
|
||||||
if (this.visibleObjectIds.has(o.id)) events.push({ type: "object-visibility-changed", id: o.id, isVisible: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
addInteractionVote(interaction: Interaction) {
|
switchScene(sceneId: string) {
|
||||||
const id = getInteractionQueueItemId(interaction)
|
const definition = script.scenesById.get(sceneId)
|
||||||
const existingItem = this.interactionQueue.get(id)
|
if (definition === undefined) throw new Error(`Unknown scene: ${sceneId}`)
|
||||||
if (existingItem === undefined) {
|
const type = sceneTypesById[definition.type]
|
||||||
const item = {
|
this.currentScene = {
|
||||||
id,
|
id: sceneId,
|
||||||
votes: 1,
|
definition,
|
||||||
interaction
|
state: type.createState(this, definition as any)
|
||||||
}
|
|
||||||
|
|
||||||
this.interactionQueue.set(item.id, item)
|
|
||||||
this.emit("game-event", { type: "interaction-queued", item })
|
|
||||||
} else {
|
|
||||||
existingItem.votes += 1
|
|
||||||
this.emit("game-event", { type: "interaction-votes-changed", id: existingItem.id, votes: existingItem.votes })
|
|
||||||
}
|
}
|
||||||
|
this.eventBus.emit("player-broadcast", { type: "scene-changed", sceneId: sceneId })
|
||||||
}
|
}
|
||||||
|
|
||||||
removeInteractionVote(id: string) {
|
withSceneState<Type extends keyof typeof sceneTypesById>(type: Type, block: (state: ReturnType<typeof sceneTypesById[Type]["createState"]>) => void) {
|
||||||
const item = this.interactionQueue.get(id)
|
if (this.currentScene.definition.type === type) {
|
||||||
if (item !== undefined) {
|
block(this.currentScene.state)
|
||||||
item.votes -= 1
|
|
||||||
this.emit("game-event", { type: "interaction-votes-changed", id: item.id, votes: item.votes })
|
|
||||||
|
|
||||||
if (item.votes <= 0) this.interactionQueue.delete(id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switchRoom(roomId: string) {
|
|
||||||
this.currentRoomId = roomId
|
|
||||||
this.interactionQueue.clear()
|
|
||||||
this.visibleObjectIds.clear()
|
|
||||||
script.roomsById.get(this.currentRoomId)!.initialObjects.forEach(o => this.visibleObjectIds.add(o.id))
|
|
||||||
this.emit("game-event", { type: "room-changed", roomId })
|
|
||||||
}
|
|
||||||
|
|
||||||
activateInteractionQueueItem(id: string) {
|
|
||||||
const item = this.interactionQueue.get(id)
|
|
||||||
if (item === undefined) return
|
|
||||||
this.interactionQueue.delete(id)
|
|
||||||
this.emit("game-event", { type: "interaction-votes-changed", id, votes: 0 })
|
|
||||||
switch (item.interaction.type) {
|
|
||||||
case "use":
|
|
||||||
this.setObjectVisibility(item.interaction.objectId, false)
|
|
||||||
break
|
|
||||||
|
|
||||||
case "combine":
|
|
||||||
const matchingCombination = findMatchingCombination(script.roomsById.get(this.currentRoomId)!.combinations, item.interaction.objectIds)
|
|
||||||
|
|
||||||
if (matchingCombination !== undefined) {
|
|
||||||
matchingCombination.inputs.forEach(input => {
|
|
||||||
if (input.isConsumed) this.setObjectVisibility(input.objectId, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
matchingCombination.outputIds.forEach(outputId => {
|
|
||||||
this.setObjectVisibility(outputId, true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeInteractionQueueItem(id: string) {
|
|
||||||
if (!this.interactionQueue.has(id)) return
|
|
||||||
this.emit("game-event", { type: "interaction-votes-changed", id, votes: 0 })
|
|
||||||
this.interactionQueue.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
setObjectVisibility(id: string, isVisible: boolean) {
|
|
||||||
if (isVisible) this.visibleObjectIds.add(id)
|
|
||||||
else this.visibleObjectIds.delete(id)
|
|
||||||
|
|
||||||
this.emit("game-event", { type: "object-visibility-changed", id, isVisible })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const game = new Game()
|
export const game = new Game()
|
|
@ -1,9 +1,7 @@
|
||||||
import createExpressApp, { static as staticMiddleware } from "express"
|
import createExpressApp, { static as staticMiddleware } from "express"
|
||||||
import { listen } from "listhen"
|
import { listen } from "listhen"
|
||||||
import { WebSocketServer } from "ws"
|
|
||||||
import { appRouter } from "./trpc"
|
import { appRouter } from "./trpc"
|
||||||
import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/adapters/express"
|
import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/adapters/express"
|
||||||
import { applyWSSHandler } from "@trpc/server/adapters/ws"
|
|
||||||
import { createContext } from "./trpc/base"
|
import { createContext } from "./trpc/base"
|
||||||
import { isDev } from "./isDev"
|
import { isDev } from "./isDev"
|
||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
|
@ -19,23 +17,10 @@ if (!isDev) expressApp.use(staticMiddleware(resolve(import.meta.dirname, "../dis
|
||||||
|
|
||||||
const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false })
|
const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false })
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" })
|
|
||||||
const wssTrpcHandler = applyWSSHandler({
|
|
||||||
wss,
|
|
||||||
router: appRouter,
|
|
||||||
createContext: () => createContext(undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
console.log("Received stop signal")
|
console.log("Received stop signal")
|
||||||
server.close()
|
server.close()
|
||||||
wssTrpcHandler.broadcastReconnectNotification()
|
|
||||||
wss.close(console.error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("SIGTERM", stop)
|
process.on("SIGTERM", stop)
|
||||||
process.on("SIGINT", stop)
|
process.on("SIGINT", stop)
|
||||||
|
|
||||||
process.on("exit", () => {
|
|
||||||
console.log("exit")
|
|
||||||
})
|
|
11
backend/scene-types/base.ts
Normal file
11
backend/scene-types/base.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import type { Game } from "../game"
|
||||||
|
import type { SceneDefinitionBase } from "../../shared/script/types"
|
||||||
|
|
||||||
|
export interface SceneType<DefinitionT extends SceneDefinitionBase, StateT extends SceneState> {
|
||||||
|
id: DefinitionT["id"]
|
||||||
|
createState(game: Game, definition: DefinitionT): StateT
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneState<EventT = any> {
|
||||||
|
getConnectionEvents(): EventT[]
|
||||||
|
}
|
14
backend/scene-types/choice.ts
Normal file
14
backend/scene-types/choice.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { type SceneState, type SceneType } from "./base"
|
||||||
|
import type { Game } from "../game"
|
||||||
|
import type { ChoiceSceneDefinition, TextSceneDefinition } from "../../shared/script/types"
|
||||||
|
|
||||||
|
export const ChoiceSceneType: SceneType<ChoiceSceneDefinition, SceneState> = {
|
||||||
|
id: "choice",
|
||||||
|
createState(game: Game, definition: ChoiceSceneDefinition): SceneState {
|
||||||
|
return {
|
||||||
|
getConnectionEvents() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
14
backend/scene-types/index.ts
Normal file
14
backend/scene-types/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import type { InteractionSceneEvent } from "../../shared/scene-types/interaction"
|
||||||
|
import type { ChoiceSceneEvent } from "../../shared/scene-types/choice"
|
||||||
|
import type { TextSceneEvent } from "../../shared/scene-types/text"
|
||||||
|
import { InteractionSceneType } from "./interaction"
|
||||||
|
import { TextSceneType } from "./text"
|
||||||
|
import { ChoiceSceneType } from "./choice"
|
||||||
|
|
||||||
|
export type SceneEvent = InteractionSceneEvent | ChoiceSceneEvent | TextSceneEvent
|
||||||
|
|
||||||
|
export const sceneTypesById = {
|
||||||
|
"interaction": InteractionSceneType,
|
||||||
|
"choice": ChoiceSceneType,
|
||||||
|
"text": TextSceneType
|
||||||
|
} as const
|
121
backend/scene-types/interaction.ts
Normal file
121
backend/scene-types/interaction.ts
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import { type SceneState, type SceneType } from "./base"
|
||||||
|
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"
|
||||||
|
|
||||||
|
export const InteractionSceneType = {
|
||||||
|
id: "interaction",
|
||||||
|
createState(game: Game, definition: InteractionSceneDefinition): InteractionSceneState {
|
||||||
|
return new InteractionSceneState(game, definition)
|
||||||
|
}
|
||||||
|
} as const satisfies SceneType<InteractionSceneDefinition, InteractionSceneState>
|
||||||
|
|
||||||
|
export class InteractionSceneState implements SceneState<InteractionSceneEvent> {
|
||||||
|
private interactionQueue: Map<string, { interaction: SuggestedInteraction; votes: number }> = new Map()
|
||||||
|
private ongoingInteractionExecution: SuggestedInteraction | null = null
|
||||||
|
private objectVisibilityById = new Map<string, boolean>()
|
||||||
|
|
||||||
|
constructor(private game: Game, private definition: InteractionSceneDefinition) {
|
||||||
|
Object.values(definition.objects).forEach(o => this.objectVisibilityById.set(o.id, o.reveal))
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionEvents(): InteractionSceneEvent[] {
|
||||||
|
const events: InteractionSceneEvent[] = []
|
||||||
|
events.push(this.getVotesChangedEvent())
|
||||||
|
|
||||||
|
this.objectVisibilityById.entries().forEach(([id, isVisible]) => events.push({ type: "object-visibility-changed", id, isVisible }))
|
||||||
|
|
||||||
|
if (this.ongoingInteractionExecution !== null) events.push({ type: "interaction-execution-started", interaction: this.ongoingInteractionExecution })
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: InteractionSceneEvent) {
|
||||||
|
this.game.emit("player-broadcast", { type: "scene-event", event })
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVotesChangedEvent() {
|
||||||
|
return { type: "votes-changed", votesByInteractionId: Object.fromEntries(this.interactionQueue.entries().map(([id, { votes }]) => ([id, votes]))) } as const
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
this.interactionQueue.set(item.id, item)
|
||||||
|
this.emit({ type: "interaction-queued", interaction })
|
||||||
|
} else {
|
||||||
|
existingItem.votes += 1
|
||||||
|
this.emitVotesChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startInteractionExecution(interaction: SuggestedInteraction) {
|
||||||
|
this.ongoingInteractionExecution = interaction
|
||||||
|
this.emit({ type: "interaction-execution-started", interaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
finishInteractionExecution(id: string, requireStarted: boolean = true) {
|
||||||
|
if (requireStarted && (this.ongoingInteractionExecution === null || id !== getSuggestedInteractionId(this.ongoingInteractionExecution))) return
|
||||||
|
this.ongoingInteractionExecution = null
|
||||||
|
this.emit({ type: "interaction-execution-finished" })
|
||||||
|
this.interactionQueue.delete(id)
|
||||||
|
this.emitVotesChanged()
|
||||||
|
|
||||||
|
const interaction = this.definition.interactionsById.get(id)
|
||||||
|
if (interaction === undefined) return
|
||||||
|
|
||||||
|
switch (interaction.type) {
|
||||||
|
case "use":
|
||||||
|
if (interaction.consume) {
|
||||||
|
this.emit({ type: "object-visibility-changed", id: interaction.objectId, isVisible: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case "combine":
|
||||||
|
interaction.inputObjects.entries().forEach(([id, { consume }]) => {
|
||||||
|
if (consume) this.setObjectVisibility(id, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
interaction.outputObjectIds.forEach(id => this.setObjectVisibility(id, true))
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeInteractionQueueItem(id: string) {
|
||||||
|
if (!this.interactionQueue.has(id)) return
|
||||||
|
this.interactionQueue.delete(id)
|
||||||
|
this.emitVotesChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
setObjectVisibility(objectId: string, isVisible: boolean) {
|
||||||
|
const current = this.objectVisibilityById.get(objectId)
|
||||||
|
if (current === undefined) throw new Error(`Unknown object: ${objectId}`)
|
||||||
|
if (current === isVisible) return
|
||||||
|
|
||||||
|
this.objectVisibilityById.set(objectId, isVisible)
|
||||||
|
this.emit({ type: "object-visibility-changed", id: objectId, isVisible })
|
||||||
|
}
|
||||||
|
}
|
14
backend/scene-types/text.ts
Normal file
14
backend/scene-types/text.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { type SceneState, type SceneType } from "./base"
|
||||||
|
import type { Game } from "../game"
|
||||||
|
import type { TextSceneDefinition } from "../../shared/script/types"
|
||||||
|
|
||||||
|
export const TextSceneType: SceneType<TextSceneDefinition, SceneState> = {
|
||||||
|
id: "text",
|
||||||
|
createState(game: Game, definition: TextSceneDefinition): SceneState {
|
||||||
|
return {
|
||||||
|
getConnectionEvents() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,31 +8,6 @@ export const directorRouter = t.router({
|
||||||
roomId: z.string()
|
roomId: z.string()
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
game.switchRoom(input.roomId)
|
game.switchScene(input.roomId)
|
||||||
}),
|
})
|
||||||
|
|
||||||
removeInteractionQueueItem: t.procedure
|
|
||||||
.input(z.object({
|
|
||||||
id: z.string()
|
|
||||||
}))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
game.removeInteractionQueueItem(input.id)
|
|
||||||
}),
|
|
||||||
|
|
||||||
activateInteractionQueueItem: t.procedure
|
|
||||||
.input(z.object({
|
|
||||||
id: z.string()
|
|
||||||
}))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
game.activateInteractionQueueItem(input.id)
|
|
||||||
}),
|
|
||||||
|
|
||||||
setObjectVisibility: t.procedure
|
|
||||||
.input(z.object({
|
|
||||||
id: z.string(),
|
|
||||||
isVisible: z.boolean()
|
|
||||||
}))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
game.setObjectVisibility(input.id, input.isVisible)
|
|
||||||
}),
|
|
||||||
})
|
})
|
|
@ -1,28 +1,24 @@
|
||||||
import { t } from "./base"
|
import { t } from "./base"
|
||||||
import { playerRouter } from "./player"
|
import { playerRouter } from "./player"
|
||||||
import { observable } from "@trpc/server/observable"
|
|
||||||
import type { GameEvent } from "../../shared/gameEvents"
|
|
||||||
import { directorRouter } from "./director"
|
import { directorRouter } from "./director"
|
||||||
import { game } from "../game"
|
import { game } from "../game"
|
||||||
|
import { on } from "node:events"
|
||||||
|
|
||||||
export const appRouter = t.router({
|
export const appRouter = t.router({
|
||||||
player: playerRouter,
|
player: playerRouter,
|
||||||
director: directorRouter,
|
director: directorRouter,
|
||||||
|
|
||||||
join: t.procedure
|
join: t.procedure
|
||||||
.subscription(({ ctx }) => {
|
.subscription(async function*() {
|
||||||
return observable<GameEvent>(emit => {
|
const iterable = on(game.eventBus, "player-broadcast")
|
||||||
const handleGameEvent = (event: GameEvent) => emit.next(event)
|
|
||||||
const handleDestroyed = () => setTimeout(() => emit.complete(), 500)
|
|
||||||
|
|
||||||
game.on("game-event", handleGameEvent)
|
for (const broadcast of game.getConnectionPlayerBroadcasts()) {
|
||||||
|
yield broadcast
|
||||||
|
}
|
||||||
|
|
||||||
game.getInitialStateEvents().forEach(event => emit.next(event))
|
for await (const broadcast of iterable) {
|
||||||
|
yield broadcast
|
||||||
return () => {
|
}
|
||||||
game.off("game-event", handleGameEvent)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,5 @@
|
||||||
import { t } from "./base"
|
import { t } from "./base"
|
||||||
import { z } from "zod"
|
|
||||||
import { interactionSchema } from "../../shared/script/types"
|
|
||||||
import { game } from "../game"
|
|
||||||
|
|
||||||
export const playerRouter = t.router({
|
export const playerRouter = t.router({
|
||||||
voteForInteraction: t.procedure
|
|
||||||
.input(z.object({
|
|
||||||
interaction: interactionSchema
|
|
||||||
}))
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
game.addInteractionVote(input.interaction)
|
|
||||||
}),
|
|
||||||
|
|
||||||
removeInteractionVote: t.procedure
|
|
||||||
.input(z.object({
|
|
||||||
queueItemId: z.string()
|
|
||||||
}))
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
game.removeInteractionVote(input.queueItemId)
|
|
||||||
}),
|
|
||||||
})
|
})
|
|
@ -36,7 +36,7 @@
|
||||||
+1
|
+1
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="mode === 'director' && (item.interaction.type !== 'combine' || findMatchingCombination(game.currentRoom.combinations, item.interaction.objectIds))"
|
v-if="mode === 'director' && (item.interaction.type !== 'combine' || findMatchingCombinationInteraction(game.currentRoom.combinations, item.interaction.objectIds))"
|
||||||
class="align-end py-1 w-full bg-green-800"
|
class="align-end py-1 w-full bg-green-800"
|
||||||
@click="game.activateInteractionQueueItem(item.id)"
|
@click="game.activateInteractionQueueItem(item.id)"
|
||||||
>
|
>
|
||||||
|
@ -65,7 +65,7 @@
|
||||||
import type { InteractionQueueItem } from "../../shared/script/types"
|
import type { InteractionQueueItem } from "../../shared/script/types"
|
||||||
import ObjectPicture from "./ObjectPicture.vue"
|
import ObjectPicture from "./ObjectPicture.vue"
|
||||||
import { useGame } from "../game"
|
import { useGame } from "../game"
|
||||||
import { findMatchingCombination } from "../../shared/util"
|
import { findMatchingCombinationInteraction } from "../../shared/util"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: InteractionQueueItem
|
item: InteractionQueueItem
|
||||||
|
|
|
@ -37,14 +37,14 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { GameObject } from "../../shared/script/types"
|
import type { SceneObjectDefinition } from "../../shared/script/types"
|
||||||
import interact from "@interactjs/interact"
|
import interact from "@interactjs/interact"
|
||||||
import { useCurrentElement } from "@vueuse/core"
|
import { useCurrentElement } from "@vueuse/core"
|
||||||
import { computed, onMounted, onUnmounted, ref, useCssModule } from "vue"
|
import { computed, onMounted, onUnmounted, ref, useCssModule } from "vue"
|
||||||
import ObjectPicture from "./ObjectPicture.vue"
|
import ObjectPicture from "./ObjectPicture.vue"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
object: GameObject
|
object: SceneObjectDefinition
|
||||||
isOverDropzone: boolean
|
isOverDropzone: boolean
|
||||||
markedFor: null | "use" | "combine" | "combine-first"
|
markedFor: null | "use" | "combine" | "combine-first"
|
||||||
}>()
|
}>()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center text-xl w-0 flex-grow py-4 bg-green-900 bg-opacity-60"
|
class="flex items-center justify-center text-xl w-0 flex-grow py-4 bg-opacity-60"
|
||||||
:class="$style.root"
|
:class="$style.root"
|
||||||
:data-has-floating="hasFloating"
|
:data-has-floating="hasFloating"
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { defineStore } from "pinia"
|
import { defineStore } from "pinia"
|
||||||
import { computed, reactive, ref } from "vue"
|
import { computed, reactive, ref } from "vue"
|
||||||
import { script } from "../shared/script"
|
import { script } from "../shared/script"
|
||||||
import type { GameObject, Interaction, InteractionQueueItem } from "../shared/script/types"
|
import type { SceneObjectDefinition, Interaction, InteractionQueueItem } from "../shared/script/types"
|
||||||
import { trpcClient } from "./trpc"
|
import { trpcClient } from "./trpc"
|
||||||
import type { GameEvent } from "../shared/gameEvents"
|
import type { PlayerBroadcast } from "../shared/broadcast"
|
||||||
import { getInteractionQueueItemId } from "../shared/util"
|
import { getInteractionQueueItemId } from "../shared/util"
|
||||||
|
|
||||||
export const useGame = defineStore("gameState", () => {
|
export const useGame = defineStore("gameState", () => {
|
||||||
|
@ -27,13 +27,13 @@ export const useGame = defineStore("gameState", () => {
|
||||||
visibleObjectIds,
|
visibleObjectIds,
|
||||||
sortedInteractionQueue,
|
sortedInteractionQueue,
|
||||||
allObjectsById: computed(() => {
|
allObjectsById: computed(() => {
|
||||||
const map = new Map<string, GameObject>()
|
const map = new Map<string, SceneObjectDefinition>()
|
||||||
currentRoom.value.initialObjects.forEach(o => map.set(o.id, o))
|
currentRoom.value.initialObjects.forEach(o => map.set(o.id, o))
|
||||||
currentRoom.value.hiddenObjects.forEach(o => map.set(o.id, o))
|
currentRoom.value.hiddenObjects.forEach(o => map.set(o.id, o))
|
||||||
return map
|
return map
|
||||||
}),
|
}),
|
||||||
visibleObjectsById: computed(() => {
|
visibleObjectsById: computed(() => {
|
||||||
const map = new Map<string, GameObject>()
|
const map = new Map<string, SceneObjectDefinition>()
|
||||||
currentRoom.value.initialObjects.values().filter(o => visibleObjectIds.has(o.id)).forEach(o => map.set(o.id, o))
|
currentRoom.value.initialObjects.values().filter(o => visibleObjectIds.has(o.id)).forEach(o => map.set(o.id, o))
|
||||||
currentRoom.value.hiddenObjects.values().filter(o => visibleObjectIds.has(o.id)).forEach(o => map.set(o.id, o))
|
currentRoom.value.hiddenObjects.values().filter(o => visibleObjectIds.has(o.id)).forEach(o => map.set(o.id, o))
|
||||||
return map
|
return map
|
||||||
|
@ -63,7 +63,7 @@ export const useGame = defineStore("gameState", () => {
|
||||||
setObjectVisibility(id: string, isVisible: boolean) {
|
setObjectVisibility(id: string, isVisible: boolean) {
|
||||||
trpcClient.director.setObjectVisibility.mutate({ id, isVisible })
|
trpcClient.director.setObjectVisibility.mutate({ id, isVisible })
|
||||||
},
|
},
|
||||||
handleGameEvent(event: GameEvent) {
|
handleGameEvent(event: PlayerBroadcast) {
|
||||||
console.log(event)
|
console.log(event)
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "room-changed":
|
case "room-changed":
|
||||||
|
|
|
@ -1,19 +1,14 @@
|
||||||
import { createTRPCProxyClient, createWSClient, wsLink } from "@trpc/client"
|
import { createTRPCClient, httpLink, httpSubscriptionLink, loggerLink, splitLink } from "@trpc/client"
|
||||||
import superjson from "superjson"
|
import superjson from "superjson"
|
||||||
import type { AppRouter } from "../backend"
|
import type { AppRouter } from "../backend"
|
||||||
|
|
||||||
const wsUrl = new URL(location.href)
|
export const trpcClient = createTRPCClient<AppRouter>({
|
||||||
wsUrl.protocol = wsUrl.protocol.replace("http", "ws")
|
|
||||||
wsUrl.pathname = "/ws"
|
|
||||||
wsUrl.hash = ""
|
|
||||||
|
|
||||||
export const trpcClient = createTRPCProxyClient<AppRouter>({
|
|
||||||
links: [
|
links: [
|
||||||
wsLink({
|
loggerLink(),
|
||||||
client: createWSClient({
|
splitLink({
|
||||||
url: wsUrl.href
|
condition: op => op.type === "subscription",
|
||||||
})
|
true: httpSubscriptionLink({ url: "/trpc", transformer: superjson }),
|
||||||
|
false: httpLink({ url: "/trpc", transformer: superjson }),
|
||||||
})
|
})
|
||||||
],
|
]
|
||||||
transformer: superjson
|
|
||||||
})
|
})
|
||||||
|
|
33
package.json
33
package.json
|
@ -10,16 +10,17 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/ph": "^1.2.2",
|
"@iconify-json/ph": "^1.2.2",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.1",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^22.13.8",
|
"@types/node": "^22.14.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"sass": "^1.85.1",
|
"sass": "^1.86.3",
|
||||||
"typescript": "^5.8.2",
|
"type-fest": "^4.39.1",
|
||||||
"unocss": "66.1.0-beta.3",
|
"typescript": "^5.8.3",
|
||||||
|
"unocss": "66.1.0-beta.10",
|
||||||
"unplugin-icons": "^22.1.0",
|
"unplugin-icons": "^22.1.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.2.5",
|
"@fontsource-variable/inter": "^5.2.5",
|
||||||
|
@ -27,26 +28,26 @@
|
||||||
"@interactjs/actions": "^1.10.27",
|
"@interactjs/actions": "^1.10.27",
|
||||||
"@interactjs/auto-start": "^1.10.27",
|
"@interactjs/auto-start": "^1.10.27",
|
||||||
"@interactjs/interact": "^1.10.27",
|
"@interactjs/interact": "^1.10.27",
|
||||||
"@trpc/client": "^10.45.2",
|
"@trpc/client": "^11.0.4",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^11.0.4",
|
||||||
"@unocss/preset-wind3": "66.1.0-beta.3",
|
"@unocss/preset-wind3": "66.1.0-beta.10",
|
||||||
"@vueuse/core": "^12.7.0",
|
"@vueuse/core": "^13.1.0",
|
||||||
"@vueuse/integrations": "^12.7.0",
|
"@vueuse/integrations": "^13.1.0",
|
||||||
"bufferutil": "^4.0.9",
|
"bufferutil": "^4.0.9",
|
||||||
"core-js": "^3.41.0",
|
"core-js": "^3.41.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"eventemitter3": "^5.0.1",
|
"eventemitter3": "^5.0.1",
|
||||||
"express": "^4.21.2",
|
"express": "^5.1.0",
|
||||||
"listhen": "^1.9.0",
|
"listhen": "^1.9.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"modern-normalize": "^3.0.1",
|
"modern-normalize": "^3.0.1",
|
||||||
"nanoid": "^5.1.2",
|
"nanoid": "^5.1.5",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
|
"temporal-polyfill": "^0.3.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"ws": "^8.18.1",
|
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
1520
pnpm-lock.yaml
generated
1520
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
5
shared/broadcast.ts
Normal file
5
shared/broadcast.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import type { SceneEvent } from "../backend/scene-types"
|
||||||
|
|
||||||
|
export type PlayerBroadcast =
|
||||||
|
| { type: "scene-changed"; sceneId: string }
|
||||||
|
| { type: "scene-event"; event: SceneEvent }
|
|
@ -1,7 +0,0 @@
|
||||||
import type { InteractionQueueItem } from "./script/types"
|
|
||||||
|
|
||||||
export type GameEvent =
|
|
||||||
| { type: "room-changed"; roomId: string }
|
|
||||||
| { type: "interaction-queued"; item: InteractionQueueItem }
|
|
||||||
| { type: "interaction-votes-changed"; id: string; votes: number }
|
|
||||||
| { type: "object-visibility-changed"; id: string; isVisible: boolean }
|
|
14
shared/mutations.ts
Normal file
14
shared/mutations.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const suggestedInteractionSchema = z.discriminatedUnion("type", [
|
||||||
|
z.object({
|
||||||
|
type: z.literal("use"),
|
||||||
|
objectId: z.string()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("combine"),
|
||||||
|
objectIds: z.set(z.string())
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
export type SuggestedInteraction = z.infer<typeof suggestedInteractionSchema>
|
2
shared/scene-types/choice.ts
Normal file
2
shared/scene-types/choice.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export type ChoiceSceneEvent =
|
||||||
|
| { type: "option-votes-changed"}
|
30
shared/scene-types/interaction.ts
Normal file
30
shared/scene-types/interaction.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import type { SuggestedInteraction } from "../mutations"
|
||||||
|
import type { CombineInteractionDefinition, UseInteractionDefinition } from "../script/types"
|
||||||
|
import { isEqual } from "lodash-es"
|
||||||
|
|
||||||
|
export const getSuggestedInteractionFromDefinition = (definedInteraction: UseInteractionDefinition<any> | CombineInteractionDefinition<any>): SuggestedInteraction => {
|
||||||
|
switch (definedInteraction.type) {
|
||||||
|
case "use": return { type: "use", objectId: definedInteraction.objectId }
|
||||||
|
case "combine": return { type: "combine", objectIds: new Set(definedInteraction.inputObjects.keys()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuggestedInteractionId(interaction: SuggestedInteraction) {
|
||||||
|
let id: string
|
||||||
|
if (interaction.type === "use") id = `use-${interaction.objectId}`
|
||||||
|
else if (interaction.type === "combine") id = `combine-${[...interaction.objectIds].sort().join("_")}`
|
||||||
|
else throw new Error("Unknown interaction type")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMatchingCombinationInteraction(combinations: Set<CombineInteractionDefinition<any>>, inputItemIds: Set<string>) {
|
||||||
|
return combinations.values().find(c => isEqual(new Set(Object.keys(c.inputObjects)), inputItemIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InteractionSceneEvent =
|
||||||
|
| { type: "interaction-queued"; interaction: SuggestedInteraction }
|
||||||
|
| { type: "votes-changed"; votesByInteractionId: Record<string, number> }
|
||||||
|
| { type: "object-visibility-changed"; id: string; isVisible: boolean }
|
||||||
|
| { type: "interaction-execution-started"; interaction: SuggestedInteraction }
|
||||||
|
| { type: "interaction-execution-finished" }
|
||||||
|
| { type: "interaction-execution-cancelled" }
|
1
shared/scene-types/text.ts
Normal file
1
shared/scene-types/text.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export type TextSceneEvent = never
|
|
@ -3,10 +3,10 @@ import { roomHauseingang } from "./rooms/hauseingang"
|
||||||
import { roomKueche } from "./rooms/küche"
|
import { roomKueche } from "./rooms/küche"
|
||||||
|
|
||||||
const script: Script = {
|
const script: Script = {
|
||||||
roomsById: new Map
|
scenesById: new Map()
|
||||||
}
|
}
|
||||||
|
|
||||||
script.roomsById.set("hauseingang", roomHauseingang)
|
script.scenesById.set(roomHauseingang.id, roomHauseingang)
|
||||||
script.roomsById.set("küche", roomKueche)
|
script.scenesById.set(roomKueche.id, roomKueche)
|
||||||
|
|
||||||
export { script }
|
export { script }
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Room } from "../types"
|
import type { SceneDefinition } from "../types"
|
||||||
import { cSet } from "../../util"
|
import { cSet } from "../../util"
|
||||||
|
|
||||||
export const roomHauseingang: Room = {
|
export const roomHauseingang: SceneDefinition = {
|
||||||
id: "hauseingang",
|
id: "hauseingang",
|
||||||
label: "Hauseingang",
|
label: "Hauseingang",
|
||||||
initialObjects: cSet(
|
initialObjects: cSet(
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Room } from "../types"
|
import type { SceneDefinition } from "../types"
|
||||||
import { cSet } from "../../util"
|
import { cSet } from "../../util"
|
||||||
|
|
||||||
export const roomKueche: Room = {
|
export const roomKueche: SceneDefinition = {
|
||||||
id: "küche",
|
id: "küche",
|
||||||
label: "Küche",
|
label: "Küche",
|
||||||
initialObjects: cSet(
|
initialObjects: cSet(
|
||||||
|
|
|
@ -1,46 +1,72 @@
|
||||||
import { z } from "zod"
|
import { Temporal } from "temporal-polyfill"
|
||||||
|
import type { Exact } from "type-fest"
|
||||||
|
import { getSuggestedInteractionFromDefinition, getSuggestedInteractionId } from "../scene-types/interaction"
|
||||||
|
import type { IdMap } from "../util"
|
||||||
|
|
||||||
export interface Script {
|
export interface Script {
|
||||||
roomsById: Map<string, Room>
|
scenesById: Map<string, SceneDefinition>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Room {
|
export interface SceneDefinitionBase {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
initialObjects: Set<GameObject>
|
plannedDuration: Temporal.Duration
|
||||||
hiddenObjects: Set<GameObject>
|
|
||||||
combinations: Set<Combination>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameObject {
|
export const defineInteractionScene = <ObjectIds extends string>(d: SceneDefinitionBase & {
|
||||||
|
type: "interaction"
|
||||||
|
objects: IdMap<SceneObjectDefinition & { id: ObjectIds }>
|
||||||
|
interactions: Array<UseInteractionDefinition<ObjectIds> | CombineInteractionDefinition<ObjectIds>>
|
||||||
|
}) => ({
|
||||||
|
...d,
|
||||||
|
interactionsById: new Map(d.interactions.map(i => [getSuggestedInteractionId(getSuggestedInteractionFromDefinition(i)), i]))
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface InteractionSceneDefinition<ObjectIds extends string = string> extends SceneDefinitionBase {
|
||||||
|
type: "interaction"
|
||||||
|
objects: IdMap<SceneObjectDefinition & { id: ObjectIds }>
|
||||||
|
interactionsById: Map<string, UseInteractionDefinition<ObjectIds> | CombineInteractionDefinition<ObjectIds>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneObjectDefinition {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
reveal: boolean
|
||||||
|
relevant: boolean
|
||||||
|
completionSteps?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseInteractionDefinition<AvailableObjectIds extends string> {
|
||||||
|
type: "use"
|
||||||
|
objectId: AvailableObjectIds
|
||||||
|
consume: boolean
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombineInteractionDefinition<AvailableObjectIds extends string> {
|
||||||
|
type: "combine"
|
||||||
|
inputObjects: Map<AvailableObjectIds, CombinationInteractionDefinitionInputOptions>
|
||||||
|
outputObjectIds: Set<AvailableObjectIds>
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombinationInteractionDefinitionInputOptions {
|
||||||
|
consume: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoiceSceneDefinition extends SceneDefinitionBase {
|
||||||
|
type: "choice"
|
||||||
|
options: ChoiceSceneDefinitionOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoiceSceneDefinitionOption {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Combination {
|
export interface TextSceneDefinition extends SceneDefinitionBase {
|
||||||
id: string
|
type: "text"
|
||||||
inputs: Set<{
|
text: string
|
||||||
objectId: string
|
|
||||||
isConsumed: boolean
|
|
||||||
}>
|
|
||||||
outputIds: Set<string>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const interactionSchema = z.discriminatedUnion("type", [
|
export type SceneDefinition = InteractionSceneDefinition | ChoiceSceneDefinition | TextSceneDefinition
|
||||||
z.object({
|
|
||||||
type: z.literal("use"),
|
|
||||||
objectId: z.string()
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal("combine"),
|
|
||||||
objectIds: z.set(z.string())
|
|
||||||
})
|
|
||||||
])
|
|
||||||
|
|
||||||
export type Interaction = z.infer<typeof interactionSchema>
|
|
||||||
|
|
||||||
export interface InteractionQueueItem {
|
|
||||||
id: string
|
|
||||||
votes: number
|
|
||||||
interaction: Interaction
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
import type { Combination, Interaction } from "./script/types"
|
export type IdMap<T extends { id: string }> = Map<T["id"], T>
|
||||||
import { isEqual } from "lodash-es"
|
|
||||||
|
|
||||||
export const cSet = <T>(...values: T[]) => new Set(values)
|
export const cSet = <T>(...values: T[]) => new Set(values)
|
||||||
export const cMap = <K extends string | number | symbol, V>(object: Record<K, V>) => new Map(Object.entries(object))
|
|
||||||
|
|
||||||
export function getInteractionQueueItemId(interaction: Interaction) {
|
export const cMap = <K extends string, V>(object: Record<K, V>): Map<K, V> => {
|
||||||
let id: string
|
const result = new Map<K, V>();
|
||||||
if (interaction.type === "use") id = `use-${interaction.objectId}`
|
for (const key in object) {
|
||||||
else if (interaction.type === "combine") id = `combine-${[...interaction.objectIds].sort().join("_")}`
|
result.set(key, object[key])
|
||||||
else throw new Error("Unknown interaction type")
|
}
|
||||||
return id
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findMatchingCombination(combinations: Set<Combination>, objectIds: Set<string>) {
|
export const cIdMap = <K extends string, V extends { id: K }>(...values: V[]) => {
|
||||||
return combinations.values().find(c => isEqual(new Set(c.inputs.values().map(i => i.objectId)), objectIds))
|
const result = new Map<K, V>();
|
||||||
|
for (const value of values) {
|
||||||
|
result.set(value.id, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
Loading…
Add table
Reference in a new issue