From aacc5ce41da4a37ebce9d4bfa3d4cd0764bf5aea Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 09:46:11 +0100 Subject: [PATCH 01/90] added pipelines --- .github/workflows/test.yml | 19 +++++++++++++++++++ package.json | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..408c048 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'yarn' + - run: yarn install --frozen-lockfile + - run: yarn test diff --git a/package.json b/package.json index 803cd03..4d51394 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "build": "tsc && vite build", "format": "prettier --write .", "lint": "eslint . --ext ts,tsx --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@awesome.me/webawesome": "^3.2.1", From d0499fc95112dfc4c4c800edc542b586fc5dacfc Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 23 Jun 2025 19:50:49 +0200 Subject: [PATCH 02/90] added new game engine (wip) --- src/engine/Engine.ts | 87 ++++++++++ src/engine/Namespace.ts | 33 ++++ src/engine/Plugin.ts | 38 +++++ src/engine/Random.ts | 36 +++++ src/engine/State.ts | 20 +++ src/engine/index.ts | 3 + src/engine/pipes/Action.ts | 93 +++++++++++ src/engine/pipes/Fps.ts | 15 ++ src/engine/pipes/Messages.ts | 105 ++++++++++++ src/game/GamePage.tsx | 111 ++++++++----- src/game/GameProvider.tsx | 231 ++++++++++++++++----------- src/game/components/FpsDisplay.tsx | 23 +++ src/game/components/GameMessages.tsx | 95 ++++------- src/game/components/Pause.tsx | 63 ++++++++ src/game/hooks/UseDispatchAction.tsx | 12 ++ src/game/hooks/UseGameValue.tsx | 7 + 16 files changed, 777 insertions(+), 195 deletions(-) create mode 100644 src/engine/Engine.ts create mode 100644 src/engine/Namespace.ts create mode 100644 src/engine/Plugin.ts create mode 100644 src/engine/Random.ts create mode 100644 src/engine/State.ts create mode 100644 src/engine/index.ts create mode 100644 src/engine/pipes/Action.ts create mode 100644 src/engine/pipes/Fps.ts create mode 100644 src/engine/pipes/Messages.ts create mode 100644 src/game/components/FpsDisplay.tsx create mode 100644 src/game/components/Pause.tsx create mode 100644 src/game/hooks/UseDispatchAction.tsx create mode 100644 src/game/hooks/UseGameValue.tsx diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts new file mode 100644 index 0000000..b267ec4 --- /dev/null +++ b/src/engine/Engine.ts @@ -0,0 +1,87 @@ +import { GameState, GameContext, Pipe, GameTiming } from './State'; +import cloneDeep from 'lodash/cloneDeep'; + +export class GameEngine { + constructor(initial: Partial, pipes: Pipe[]) { + this.state = { ...initial }; + this.pipes = pipes; + this.timing = { + tick: 0, + deltaTime: 0, + elapsedTime: 0, + }; + this.context = this.timing; + } + + /** + * The state of the engine. This object should contain all information to run the game from a cold start. + * + * Pipes may add any additional fields. + */ + private state: GameState; + + /** + * Engine pipes transform the game state and context by accepting the current state and context, and returning a new state and context. + */ + private pipes: Pipe[]; + + /** + * The context of the engine. May contain any ephemeral information of any plugin, however it is to be noted; + * Context may be discarded at any time, so it may not contain information necessary to restore the game state. + * As such, this object can contain inter-pipe communication, utility functions, or debugging information. + */ + private context: GameContext; + + /** + * Contains the timing information of the game engine. This may not be modified by either the outside nor by pipes. + */ + private timing: GameTiming; + + /** + * Returns the current game state. + */ + public getState(): GameState { + return this.state; + } + + /** + * Returns the current game context. + */ + public getContext(): GameContext { + return { + ...this.context, + ...this.timing, + }; + } + + /** + * Runs the game engine for a single tick, passing the delta time since the last tick. + */ + public tick(deltaTime: number): GameState { + this.timing.tick += 1; + this.timing.deltaTime = deltaTime; + this.timing.elapsedTime += deltaTime; + + let state = this.state; + let context = this.context; + + const buildContext = (): GameContext => ({ + ...context, + ...this.timing, + }); + + for (const pipe of this.pipes) { + try { + ({ state, context } = pipe({ state, context: buildContext() })); + } catch (err) { + // TODO: add debug info wrapper to pipe functions so they can be traced back to plugins + console.error('Pipe error:', err); + } + } + + this.state = cloneDeep(state); + this.context = cloneDeep(buildContext()); + + return this.state; + } +} diff --git a/src/engine/Namespace.ts b/src/engine/Namespace.ts new file mode 100644 index 0000000..955b3e9 --- /dev/null +++ b/src/engine/Namespace.ts @@ -0,0 +1,33 @@ +export const namespaced = + (namespace: string, values: T) => + (context: any): any => { + const parts = namespace.split('.'); + const last = parts.pop()!; + + let base = { ...context }; + let target = base; + + for (const key of parts) { + target[key] = { ...(target[key] ?? {}) }; + target = target[key]; + } + + target[last] = { + ...(target[last] ?? {}), + ...values, + }; + + return base; + }; + +export const fromNamespace = (context: any, namespace: string): T => { + const parts = namespace.split('.'); + let current = context; + + for (const key of parts) { + if (current == null) return {} as T; + current = current[key]; + } + + return current ?? ({} as T); +}; diff --git a/src/engine/Plugin.ts b/src/engine/Plugin.ts new file mode 100644 index 0000000..3722798 --- /dev/null +++ b/src/engine/Plugin.ts @@ -0,0 +1,38 @@ +import { fromNamespace, namespaced } from './Namespace'; + +export function createPluginInterface( + namespace: string +) { + let newState: any = undefined; + let newContext: any = undefined; + + return { + getState: (state: any): StateShape => + fromNamespace(state, namespace), + setState: (state: any, value: Partial) => { + newState = namespaced(namespace, value)(state); + }, + + getContext: (context: any): ContextShape => + fromNamespace(context, namespace), + setContext: (context: any, value: Partial) => { + newContext = namespaced(namespace, value)(context); + }, + + readState: (state: any, ns: string): T => + fromNamespace(state, ns), + writeState: (state: any, ns: string, value: object) => { + newState = namespaced(ns, value)(state); + }, + readContext: (context: any, ns: string): T => + fromNamespace(context, ns), + writeContext: (context: any, ns: string, value: object) => { + newContext = namespaced(ns, value)(context); + }, + + commit: () => ({ + state: newState, + context: newContext, + }), + }; +} diff --git a/src/engine/Random.ts b/src/engine/Random.ts new file mode 100644 index 0000000..fc06aa9 --- /dev/null +++ b/src/engine/Random.ts @@ -0,0 +1,36 @@ +export class Random { + private s: number; + + constructor(seed: string) { + this.s = Random.stringToSeed(seed); + } + + private static stringToSeed(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (Math.imul(31, hash) + str.charCodeAt(i)) | 0; + } + return hash >>> 0; + } + + next(): number { + this.s = Math.imul(48271, this.s) % 0x7fffffff; + return (this.s & 0x7fffffff) / 0x7fffffff; + } + + nextInt(max: number): number { + return Math.floor(this.next() * max); + } + + nextFloatRange(min: number, max: number): number { + return this.next() * (max - min) + min; + } + + nextBool(prob = 0.5): boolean { + return this.next() < prob; + } + + pick(arr: T[]): T { + return arr[this.nextInt(arr.length)]; + } +} diff --git a/src/engine/State.ts b/src/engine/State.ts new file mode 100644 index 0000000..67a7af9 --- /dev/null +++ b/src/engine/State.ts @@ -0,0 +1,20 @@ +export type GameState = { + [key: string]: any; +}; + +export type GameContext = { + [key: string]: any; +} & GameTiming; + +export type GameTiming = { + tick: number; + deltaTime: number; + elapsedTime: number; +}; + +export type PipeValue = { + state: GameState; + context: GameContext; +}; + +export type Pipe = (value: PipeValue) => PipeValue; diff --git a/src/engine/index.ts b/src/engine/index.ts new file mode 100644 index 0000000..bfc21fa --- /dev/null +++ b/src/engine/index.ts @@ -0,0 +1,3 @@ +export * from './Engine'; +export * from './Random'; +export * from './State'; diff --git a/src/engine/pipes/Action.ts b/src/engine/pipes/Action.ts new file mode 100644 index 0000000..ed6d37d --- /dev/null +++ b/src/engine/pipes/Action.ts @@ -0,0 +1,93 @@ +import { fromNamespace, namespaced } from '../Namespace'; +import { GameContext, Pipe } from '../State'; + +export type GameAction = { + type: string; + payload?: any; +}; + +const PLUGIN_NAMESPACE = 'core.actions'; + +export const assembleActionKey = (namespace: string, key: string): string => { + return `${namespace}/${key}`; +}; + +export const disassembleActionKey = ( + actionKey: string +): { namespace: string; key: string } => { + const index = actionKey.indexOf('/'); + if (index === -1) { + throw new Error(`Invalid action key: "${actionKey}"`); + } + return { + namespace: actionKey.slice(0, index), + key: actionKey.slice(index + 1), + }; +}; + +export const createActionContext = ( + action: GameAction +): Partial => { + return namespaced(PLUGIN_NAMESPACE, { + pendingActions: [action], + })({}); +}; + +export const dispatchAction = ( + context: GameContext, + action: GameAction +): GameContext => { + const { pendingActions = [] } = fromNamespace(context, PLUGIN_NAMESPACE); + + return namespaced(PLUGIN_NAMESPACE, { + pendingActions: [...pendingActions, action], + })(context); +}; + +export const getActions = ( + context: GameContext, + type: string, + { consume = false }: { consume?: boolean } = {} +): { context: GameContext; actions: GameAction[] } => { + const { currentActions = [] } = fromNamespace(context, PLUGIN_NAMESPACE); + + const { namespace, key } = disassembleActionKey(type); + const isWildcard = key === '*'; + + const matched: GameAction[] = []; + const unmatched: GameAction[] = []; + + for (const action of currentActions) { + const { namespace: actionNamespace, key: actionKey } = disassembleActionKey( + action.type + ); + + const isMatch = isWildcard + ? actionNamespace === namespace + : actionNamespace === namespace && actionKey === key; + + if (isMatch) matched.push(action); + else unmatched.push(action); + } + + return { + context: consume + ? namespaced(PLUGIN_NAMESPACE, { + currentActions: unmatched, + })(context) + : context, + actions: matched, + }; +}; + +export const actionPipeline: Pipe = ({ state, context }) => { + const { pendingActions = [] } = fromNamespace(context, PLUGIN_NAMESPACE); + + return { + state, + context: namespaced(PLUGIN_NAMESPACE, { + pendingActions: [], + currentActions: pendingActions, + })(context), + }; +}; diff --git a/src/engine/pipes/Fps.ts b/src/engine/pipes/Fps.ts new file mode 100644 index 0000000..59b8bcf --- /dev/null +++ b/src/engine/pipes/Fps.ts @@ -0,0 +1,15 @@ +import { PipeValue } from '../State'; +import { namespaced } from '../Namespace'; + +export const fpsPipe = ({ state, context }: PipeValue): PipeValue => { + const { deltaTime } = context; + + const fps = deltaTime > 0 ? 1000 / deltaTime : 0; + + return { + state, + context: namespaced('core', { + fps, + })(context), + }; +}; diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts new file mode 100644 index 0000000..ac9e217 --- /dev/null +++ b/src/engine/pipes/Messages.ts @@ -0,0 +1,105 @@ +import { GameContext, PipeValue } from '../State'; +import { fromNamespace, namespaced } from '../Namespace'; +import { GameAction } from './Action'; + +export interface GameMessagePrompt { + title: string; + action: GameAction; +} + +export interface GameMessage { + id: string; + title: string; + description?: string; + prompts?: GameMessagePrompt[]; + duration?: number; +} + +const PLUGIN_NAMESPACE = 'core.messages'; + +export type MessageContext = { + pendingMessages?: (Partial & Pick)[]; + sendMessage: ( + msg: Partial & Pick + ) => GameContext; +}; + +export type MessageState = { + messages: GameMessage[]; + timers: Record; +}; + +export const messagesPipe = ({ state, context }: PipeValue): PipeValue => { + const deltaTime = context.deltaTime; + + const { pendingMessages = [] } = fromNamespace( + context, + PLUGIN_NAMESPACE + ); + + const { messages = [], timers = {} } = fromNamespace( + state, + PLUGIN_NAMESPACE + ); + + const updated: GameMessage[] = []; + const updatedTimers: Record = {}; + + for (const message of messages) { + const remaining = timers[message.id]; + if (remaining != null) { + const next = remaining - deltaTime; + if (next > 0) { + updated.push(message); + updatedTimers[message.id] = next; + } + } else { + updated.push(message); + } + } + + for (const patch of pendingMessages) { + const existing = updated.find(m => m.id === patch.id); + + if (!existing) { + if (!patch.title) continue; + } + + const base = existing ?? { id: patch.id, title: patch.title! }; + + const merged: GameMessage = { + ...base, + ...patch, + }; + + const index = updated.findIndex(m => m.id === patch.id); + if (index >= 0) updated[index] = merged; + else updated.push(merged); + + if (patch.duration !== undefined) { + updatedTimers[patch.id] = patch.duration; + } + } + + const newState = namespaced(PLUGIN_NAMESPACE, { + messages: updated, + timers: updatedTimers, + })(state); + + const newContext = namespaced(PLUGIN_NAMESPACE, { + pendingMessages: [], + sendMessage: (msg: Partial & { id: string }): GameContext => { + const queue = + fromNamespace(newContext, PLUGIN_NAMESPACE) + .pendingMessages ?? []; + return namespaced(PLUGIN_NAMESPACE, { + pendingMessages: [...queue, msg], + })(newContext); + }, + })(context); + + return { + state: newState, + context: newContext, + }; +}; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 3a24597..9b09d14 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -1,20 +1,14 @@ import styled from 'styled-components'; -import { - GameHypno, - GameImages, - GameMeter, - GameIntensity, - GameSound, - GameInstructions, - GamePace, - GameEvents, - GameMessages, - GameWarmup, - GameEmergencyStop, - GameSettings, - GameVibrator, -} from './components'; -import { GameProvider } from './GameProvider'; +import { GameEngineProvider } from './GameProvider'; +import { FpsDisplay } from './components/FpsDisplay'; +import { useCallback } from 'react'; +import { PipeValue } from '../engine'; +import { fromNamespace, namespaced } from '../engine/Namespace'; +import { MessageContext, messagesPipe } from '../engine/pipes/Messages'; +import { GameMessages } from './components/GameMessages'; +import { PauseButton } from './components/Pause'; +import { assembleActionKey, getActions } from '../engine/pipes/Action'; +import { fpsPipe } from '../engine/pipes/Fps'; const StyledGamePage = styled.div` position: relative; @@ -28,10 +22,6 @@ const StyledGamePage = styled.div` align-items: center; `; -const StyledLogicElements = styled.div` - // these elements have no visual representation. This style is merely to group them. -`; - const StyledTopBar = styled.div` position: absolute; top: 0; @@ -81,31 +71,76 @@ const StyledBottomBar = styled.div` `; export const GamePage = () => { + const messageTestPipe = useCallback(({ context, state }: PipeValue) => { + const MSG_TEST_NAMESPACE = 'core.message_test'; + const { sent } = fromNamespace<{ sent?: boolean }>( + state, + MSG_TEST_NAMESPACE + ); + const { sendMessage } = fromNamespace( + context, + 'core.messages' + ); + + let newContext = context; + + const messageId = 'test-message'; + + if (!sent) { + newContext = sendMessage({ + id: messageId, + title: 'Test Message', + description: + 'This is a test message to demonstrate the message system.', + prompts: [ + { + title: 'Acknowledge', + action: { + type: assembleActionKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), + payload: { id: messageId }, + }, + }, + { + title: 'Dismiss', + action: { + type: assembleActionKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + payload: { id: messageId }, + }, + }, + ], + }); + } + + const { actions } = getActions( + newContext, + assembleActionKey(MSG_TEST_NAMESPACE, '*') + ); + + for (const _ of actions) { + newContext = sendMessage({ + id: messageId, + duration: 0, + }); + } + + return { + state: namespaced(MSG_TEST_NAMESPACE, { sent: true })(state), + context: newContext, + }; + }, []); + return ( - + - - - - - - - - - - + - - - - + - - + - + ); }; diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index dedd60d..fe0f2d2 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -1,100 +1,153 @@ -import { useCallback } from 'react'; -import { createStateProvider } from '../utils'; -import { ImageItem } from '../types'; - -export enum Paws { - left = 'left', - right = 'right', - both = 'both', - none = 'none', -} +import { + createContext, + useContext, + useEffect, + useRef, + useState, + ReactNode, +} from 'react'; +import { GameEngine, GameState, Pipe, GameContext } from '../engine'; +import { actionPipeline } from '../engine/pipes/Action'; -export const PawLabels: Record = { - [Paws.left]: 'Left', - [Paws.right]: 'Right', - [Paws.both]: 'Both', - [Paws.none]: 'Off', +type GameEngineContextValue = { + /** + * The current state of the game engine. + */ + state: GameState | null; + /** + * The current game context, which can be used to pass additional data to pipes. + */ + context: GameContext | null; + /** + * Hard pause the game engine, stopping all updates and rendering. + */ + pause: () => void; + /** + * Resume the game engine after a pause. + */ + resume: () => void; + /** + * Whether the game engine is currently running. + */ + isRunning: boolean; + /** + * Inject additional context into the game engine. Resets after each tick. + */ + injectContext: (patch: Partial) => void; }; -export enum Stroke { - up = 'up', - down = 'down', -} +const GameEngineContext = createContext( + undefined +); -export enum GamePhase { - pause = 'pause', - warmup = 'warmup', - active = 'active', - break = 'break', - finale = 'finale', - climax = 'climax', +export function useGameEngine() { + const ctx = useContext(GameEngineContext); + if (!ctx) + throw new Error('useGameEngine must be used inside GameEngineProvider'); + return ctx; } -export interface GameMessagePrompt { - title: string; - onClick: () => void | Promise; -} +type Props = { + children: ReactNode; + pipes?: Pipe[]; + useBuildGameContext?: () => Partial; +}; -export interface GameMessage { - id: string; - title: string; - description?: string; - prompts?: GameMessagePrompt[]; - duration?: number; -} +export function GameEngineProvider({ + children, + pipes = [], + useBuildGameContext, +}: Props) { + const engineRef = useRef(null); + const baseContextRef = useRef>({}); + const patchRef = useRef>({}); + const finalContextRef = useRef>({}); -export interface GameState { - pace: number; - intensity: number; - currentImage?: ImageItem; - seenImages: ImageItem[]; - nextImages: ImageItem[]; - currentHypno: number; - paws: Paws; - stroke: Stroke; - phase: GamePhase; - edged: boolean; - messages: GameMessage[]; -} + const [state, setState] = useState(null); + const [context, setContext] = useState(null); + const [isRunning, setIsRunning] = useState(true); + const runningRef = useRef(true); + const lastTimeRef = useRef(null); -export const initialGameState: GameState = { - pace: 0, - intensity: 0, - currentImage: undefined, - seenImages: [], - nextImages: [], - currentHypno: 0, - paws: Paws.none, - stroke: Stroke.down, - phase: GamePhase.warmup, - edged: false, - messages: [], -}; + baseContextRef.current = useBuildGameContext?.() || {}; + useEffect(() => { + // To inject our custom context into the engine, we create this side-loading pipe. + // This is idiomatic, because it does not require changing the game engine's internals. + const contextInjector: Pipe = ({ state, context }) => { + return { + state, + context: { + ...context, + ...finalContextRef.current, + }, + }; + }; + + engineRef.current = new GameEngine({}, [ + contextInjector, + actionPipeline, + ...pipes, + ]); + setState(engineRef.current.getState()); + setContext(engineRef.current.getContext()); + + let frameId: number; + + const loop = (time: number) => { + if (!engineRef.current) return; + + // When paused, we advance time but do not tick. + // This prevents incorrectly accumulating delta time during pauses. + if (lastTimeRef.current == null || !runningRef.current) { + lastTimeRef.current = time; + frameId = requestAnimationFrame(loop); + return; + } + + const deltaTime = time - lastTimeRef.current; + lastTimeRef.current = time; -export const { - Provider: GameProvider, - useProvider: useGame, - useProviderSelector: useGameValue, -} = createStateProvider({ - defaultData: initialGameState, -}); - -export const useSendMessage = () => { - const [, setMessages] = useGameValue('messages'); - - return useCallback( - (message: Partial & { id: string }) => { - setMessages(messages => { - const previous = messages.find(m => m.id === message.id); - return [ - ...messages.filter(m => m.id !== message.id), - { - ...previous, - ...message, - } as GameMessage, - ]; - }); - }, - [setMessages] + finalContextRef.current = { + ...baseContextRef.current, + ...patchRef.current, + }; + + patchRef.current = {}; + + engineRef.current.tick(deltaTime); + + setState(engineRef.current.getState()); + setContext(engineRef.current.getContext()); + + frameId = requestAnimationFrame(loop); + }; + + frameId = requestAnimationFrame(loop); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [pipes]); + + const pause = () => { + runningRef.current = false; + setIsRunning(false); + }; + + const resume = () => { + runningRef.current = true; + setIsRunning(true); + }; + + const injectContext = (patch: Partial) => { + patchRef.current = { ...patchRef.current, ...patch }; + }; + + return ( + + {children} + ); -}; +} diff --git a/src/game/components/FpsDisplay.tsx b/src/game/components/FpsDisplay.tsx new file mode 100644 index 0000000..1924fcc --- /dev/null +++ b/src/game/components/FpsDisplay.tsx @@ -0,0 +1,23 @@ +import { useGameEngine } from '../GameProvider'; + +export const FpsDisplay = () => { + const { context } = useGameEngine(); + + const fps = context?.core?.fps ? Math.round(context.core.fps) : null; + + return ( +
+ FPS: {fps || '...'} +
+ ); +}; diff --git a/src/game/components/GameMessages.tsx b/src/game/components/GameMessages.tsx index 4449217..1ea0e3d 100644 --- a/src/game/components/GameMessages.tsx +++ b/src/game/components/GameMessages.tsx @@ -1,10 +1,15 @@ -import { useCallback, useEffect, useState } from 'react'; -import styled from 'styled-components'; -import { GameMessage, GameMessagePrompt, useGameValue } from '../GameProvider'; -import { defaultTransition, playTone } from '../../utils'; +import { useEffect, useRef } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; +import styled from 'styled-components'; import { useTranslate } from '../../settings'; +import { defaultTransition, playTone } from '../../utils'; +import { GameMessage, MessageState } from '../../engine/pipes/Messages'; +import { useGameValue } from '../hooks/UseGameValue'; + +import _ from 'lodash'; +import { useDispatchAction } from '../hooks/UseDispatchAction'; + const StyledGameMessages = styled.div` display: flex; flex-direction: column; @@ -54,83 +59,37 @@ const StyledGameMessageButton = motion.create(styled.button` `); export const GameMessages = () => { - const [, setTimers] = useState>({}); - const [currentMessages, setCurrentMessages] = useState([]); - const [previousMessages, setPreviousMessages] = useState([]); - const [messages, setMessages] = useGameValue('messages'); + const { messages } = useGameValue('core.messages'); + const { dispatchAction } = useDispatchAction(); const translate = useTranslate(); - useEffect(() => { - setPreviousMessages(currentMessages); - setCurrentMessages(messages); - }, [currentMessages, messages]); + const prevMessagesRef = useRef([]); useEffect(() => { - const addedMessages = currentMessages.filter( - message => !previousMessages.includes(message) - ); - - const newTimers = addedMessages.reduce( - (acc, message) => { - if (message.duration) { - acc[message.id] = window.setTimeout( - () => setMessages(messages => messages.filter(m => m !== message)), - message.duration - ); - } - return acc; - }, - {} as Record - ); - - if (addedMessages.length > 0) { + const prevMessages = prevMessagesRef.current; + + const changed = messages?.some(newMsg => { + const oldMsg = prevMessages.find(prev => prev.id === newMsg.id); + return !oldMsg || !_.isEqual(oldMsg, newMsg); + }); + + if (changed) { playTone(200); } - const removedMessages = previousMessages.filter( - message => !currentMessages.includes(message) - ); - const removedTimers = removedMessages.map(message => message.id); - - setTimers(timers => ({ - ...Object.keys(timers).reduce((acc, key) => { - if (removedTimers.includes(key)) { - window.clearTimeout(timers[key]); - return acc; - } - return { ...acc, [key]: timers[key] }; - }, {}), - ...newTimers, - })); - }, [currentMessages, previousMessages, setMessages]); - - const onMessageClick = useCallback( - async (message: GameMessage, prompt: GameMessagePrompt) => { - await prompt.onClick(); - setMessages(messages => messages.filter(m => m !== message)); - }, - [setMessages] - ); + prevMessagesRef.current = messages ?? []; + }, [messages]); return ( - {currentMessages.map(message => ( + {messages?.map(message => ( {translate(message.title)} @@ -159,7 +118,7 @@ export const GameMessages = () => { ...defaultTransition, ease: 'circInOut', }} - onClick={() => onMessageClick(message, prompt)} + onClick={() => dispatchAction(prompt.action)} > {translate(prompt.title)} diff --git a/src/game/components/Pause.tsx b/src/game/components/Pause.tsx new file mode 100644 index 0000000..2b8d877 --- /dev/null +++ b/src/game/components/Pause.tsx @@ -0,0 +1,63 @@ +import { useState, useEffect } from 'react'; +import styled from 'styled-components'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; +import { useGameEngine } from '../GameProvider'; + +const PauseContainer = styled.div` + position: absolute; + bottom: 16px; + right: 16px; + z-index: 9999; +`; + +const PauseIconButton = styled.button` + display: flex; + align-items: center; + padding: 16px 12px; + gap: 8px; + border-radius: var(--border-radius) 0 0 0; + opacity: 0.8; + background: #a52727; + color: #fff; + font-size: 1rem; + cursor: pointer; + transition: filter 0.2s; + + &:hover { + filter: brightness(1.4); + } +`; + +export const PauseButton = () => { + const { pause, resume, isRunning } = useGameEngine(); + const [paused, setPaused] = useState(!isRunning); + + useEffect(() => { + setPaused(!isRunning); + }, [isRunning]); + + const togglePause = () => { + if (paused) { + resume(); + } else { + pause(); + } + setPaused(!paused); + }; + + return ( + + +

{paused ? 'Resume' : 'Pause'}

+ +
+
+ ); +}; diff --git a/src/game/hooks/UseDispatchAction.tsx b/src/game/hooks/UseDispatchAction.tsx new file mode 100644 index 0000000..a1bcd38 --- /dev/null +++ b/src/game/hooks/UseDispatchAction.tsx @@ -0,0 +1,12 @@ +import { createActionContext, GameAction } from '../../engine/pipes/Action'; +import { useGameEngine } from '../GameProvider'; + +export function useDispatchAction() { + const { injectContext } = useGameEngine(); + + return { + dispatchAction: (action: GameAction) => { + injectContext(createActionContext(action)); + }, + }; +} diff --git a/src/game/hooks/UseGameValue.tsx b/src/game/hooks/UseGameValue.tsx new file mode 100644 index 0000000..15bdc3d --- /dev/null +++ b/src/game/hooks/UseGameValue.tsx @@ -0,0 +1,7 @@ +import { fromNamespace } from '../../engine/Namespace'; +import { useGameEngine } from '../GameProvider'; + +export const useGameValue = (path: string): T => { + const { state } = useGameEngine(); + return fromNamespace(state, path); +}; From 59cc751b64d437c8f472249e963b85b6156be9b0 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 23 Jun 2025 23:40:38 +0200 Subject: [PATCH 03/90] added scheduler plugin (wip) --- src/engine/Composer.ts | 143 +++++++++++++++++++++++++++++++ src/engine/Namespace.ts | 4 +- src/engine/pipes/Action.ts | 87 ++++++++++--------- src/engine/pipes/Fps.ts | 4 +- src/engine/pipes/Messages.ts | 61 +++++++------- src/engine/pipes/Scheduler.ts | 73 ++++++++++++++++ src/game/GamePage.tsx | 153 ++++++++++++++++++++++++---------- src/game/GameProvider.tsx | 6 +- 8 files changed, 410 insertions(+), 121 deletions(-) create mode 100644 src/engine/Composer.ts create mode 100644 src/engine/pipes/Scheduler.ts diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts new file mode 100644 index 0000000..0732056 --- /dev/null +++ b/src/engine/Composer.ts @@ -0,0 +1,143 @@ +export type Transformer = ( + ...args: TArgs +) => (obj: TObj) => TObj; + +export class Composer { + private obj: T; + + constructor(initial: T) { + this.obj = initial; + } + + /** + * Creates a new ObjectComposer and applies the provided function to it, + * returning the transformed object. + */ + static build( + fn: (composer: Composer) => Composer + ): (obj: T) => T { + return (obj: T) => fn(new Composer(obj)).get(); + } + + /** + * Creates a composer focused on a specific key of the object, + * but returns the entire object when done. + */ + static focus( + base: TObj, + key: K, + fn: ( + inner: Composer> + ) => Composer> + ): Composer { + return new Composer(base).focus(key, fn); + } + + /** + * Focuses on a specific key of the object, allowing for + * transformation of that key's value. + */ + focus( + key: K, + fn: ( + composer: Composer> + ) => Composer> + ): this { + const base = this.obj as any; + const focused = new Composer(base[key] ?? {}); + const updated = fn(focused).get(); + this.obj = { + ...this.obj, + [key]: { ...(this.obj[key] as any), ...updated }, + }; + return this; + } + + /** + * Like `build, but specifically for composing a focus on a key + * of an object. + */ + static buildFocus( + key: K, + fn: ( + composer: Composer> + ) => Composer> + ): (obj: TObj) => TObj { + return (obj: TObj) => + Composer.focus( + obj, + key, + fn as (composer: Composer) => Composer + ).get(); + } + + /** + * Returns a new ObjectComposer with the transformed object. + */ + map(fn: (obj: T) => T): Composer { + return new Composer(fn(this.obj)); + } + + /** + * Chains a function that receives the composer instance. + */ + chain(fn: (composer: this) => this): this { + return fn(this); + } + + /** + * Applies a transformation tool to the current object. + */ + apply( + tool: Transformer, + ...args: TArgs + ): this { + this.obj = tool(...args)(this.obj); + return this; + } + + /** + * Sets a value in a specific namespace within the object. + */ + setIn(namespace: string, partial: object): this { + const parts = namespace.split('.'); + const last = parts.pop()!; + const root = { ...this.obj }; + let node: any = root; + + for (const key of parts) { + node[key] = { ...(node[key] ?? {}) }; + node = node[key]; + } + + node[last] = { + ...(node[last] ?? {}), + ...partial, + }; + + this.obj = root; + return this; + } + + /** + * Returns the value of a specific namespace within the object. + */ + from(namespace: string): TNamespace { + const parts = namespace.split('.'); + let current: any = this.obj; + + for (const part of parts) { + if (current == null) return {} as TNamespace; + current = current[part]; + } + + return current ?? ({} as TNamespace); + } + + /** + * Returns the current object. + */ + get(): T { + return this.obj; + } +} diff --git a/src/engine/Namespace.ts b/src/engine/Namespace.ts index 955b3e9..da6f1df 100644 --- a/src/engine/Namespace.ts +++ b/src/engine/Namespace.ts @@ -20,9 +20,9 @@ export const namespaced = return base; }; -export const fromNamespace = (context: any, namespace: string): T => { +export const fromNamespace = (values: any, namespace: string): T => { const parts = namespace.split('.'); - let current = context; + let current = values; for (const key of parts) { if (current == null) return {} as T; diff --git a/src/engine/pipes/Action.ts b/src/engine/pipes/Action.ts index ed6d37d..b6acedd 100644 --- a/src/engine/pipes/Action.ts +++ b/src/engine/pipes/Action.ts @@ -1,4 +1,4 @@ -import { fromNamespace, namespaced } from '../Namespace'; +import { Composer } from '../Composer'; import { GameContext, Pipe } from '../State'; export type GameAction = { @@ -6,6 +6,11 @@ export type GameAction = { payload?: any; }; +type ActionContext = { + pendingActions?: GameAction[]; + currentActions?: GameAction[]; +}; + const PLUGIN_NAMESPACE = 'core.actions'; export const assembleActionKey = (namespace: string, key: string): string => { @@ -25,31 +30,31 @@ export const disassembleActionKey = ( }; }; -export const createActionContext = ( - action: GameAction -): Partial => { - return namespaced(PLUGIN_NAMESPACE, { - pendingActions: [action], - })({}); -}; - -export const dispatchAction = ( - context: GameContext, - action: GameAction -): GameContext => { - const { pendingActions = [] } = fromNamespace(context, PLUGIN_NAMESPACE); - - return namespaced(PLUGIN_NAMESPACE, { - pendingActions: [...pendingActions, action], - })(context); -}; +export const createActionContext = (action: GameAction): Partial => + new Composer({}) + .setIn(PLUGIN_NAMESPACE, { + pendingActions: [action], + }) + .get(); + +export const dispatchAction = (action: GameAction) => + Composer.build(c => + c.setIn(PLUGIN_NAMESPACE, { + pendingActions: [ + ...(c.from(PLUGIN_NAMESPACE).pendingActions ?? []), + action, + ], + }) + ); export const getActions = ( context: GameContext, type: string, { consume = false }: { consume?: boolean } = {} ): { context: GameContext; actions: GameAction[] } => { - const { currentActions = [] } = fromNamespace(context, PLUGIN_NAMESPACE); + const composer = new Composer(context); + const { currentActions = [] } = + composer.from(PLUGIN_NAMESPACE); const { namespace, key } = disassembleActionKey(type); const isWildcard = key === '*'; @@ -58,36 +63,38 @@ export const getActions = ( const unmatched: GameAction[] = []; for (const action of currentActions) { - const { namespace: actionNamespace, key: actionKey } = disassembleActionKey( + const { namespace: actionNs, key: actionKey } = disassembleActionKey( action.type ); - const isMatch = isWildcard - ? actionNamespace === namespace - : actionNamespace === namespace && actionKey === key; + ? actionNs === namespace + : actionNs === namespace && actionKey === key; if (isMatch) matched.push(action); else unmatched.push(action); } + if (consume) { + composer.setIn(PLUGIN_NAMESPACE, { + currentActions: unmatched, + }); + } + return { - context: consume - ? namespaced(PLUGIN_NAMESPACE, { - currentActions: unmatched, - })(context) - : context, + context: composer.get(), actions: matched, }; }; -export const actionPipeline: Pipe = ({ state, context }) => { - const { pendingActions = [] } = fromNamespace(context, PLUGIN_NAMESPACE); - - return { - state, - context: namespaced(PLUGIN_NAMESPACE, { - pendingActions: [], - currentActions: pendingActions, - })(context), - }; -}; +/** + * Moves actions from pending to current. + * This prevents actions from being processed during the same frame they are created. + * This is important because pipes later in the pipeline add new actions. + */ +export const actionPipe: Pipe = Composer.buildFocus('context', ctx => + ctx.setIn(PLUGIN_NAMESPACE, { + pendingActions: [], + currentActions: + ctx.from(PLUGIN_NAMESPACE).pendingActions ?? [], + }) +); diff --git a/src/engine/pipes/Fps.ts b/src/engine/pipes/Fps.ts index 59b8bcf..a5a4b78 100644 --- a/src/engine/pipes/Fps.ts +++ b/src/engine/pipes/Fps.ts @@ -1,7 +1,7 @@ -import { PipeValue } from '../State'; +import { Pipe } from '../State'; import { namespaced } from '../Namespace'; -export const fpsPipe = ({ state, context }: PipeValue): PipeValue => { +export const fpsPipe: Pipe = ({ state, context }) => { const { deltaTime } = context; const fps = deltaTime > 0 ? 1000 / deltaTime : 0; diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index ac9e217..6d46ea0 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -1,5 +1,5 @@ -import { GameContext, PipeValue } from '../State'; -import { fromNamespace, namespaced } from '../Namespace'; +import { GameContext, Pipe } from '../State'; +import { Composer, Transformer } from '../Composer'; import { GameAction } from './Action'; export interface GameMessagePrompt { @@ -19,9 +19,10 @@ const PLUGIN_NAMESPACE = 'core.messages'; export type MessageContext = { pendingMessages?: (Partial & Pick)[]; - sendMessage: ( - msg: Partial & Pick - ) => GameContext; + sendMessage: Transformer< + [Partial & { id: string }], + GameContext + >; }; export type MessageState = { @@ -29,18 +30,16 @@ export type MessageState = { timers: Record; }; -export const messagesPipe = ({ state, context }: PipeValue): PipeValue => { +export const messagesPipe: Pipe = ({ state, context }) => { const deltaTime = context.deltaTime; - const { pendingMessages = [] } = fromNamespace( - context, - PLUGIN_NAMESPACE - ); + const stateComposer = new Composer(state); + const contextComposer = new Composer(context); - const { messages = [], timers = {} } = fromNamespace( - state, - PLUGIN_NAMESPACE - ); + const { messages = [], timers = {} } = + stateComposer.from(PLUGIN_NAMESPACE); + const { pendingMessages = [] } = + contextComposer.from(PLUGIN_NAMESPACE); const updated: GameMessage[] = []; const updatedTimers: Record = {}; @@ -61,9 +60,7 @@ export const messagesPipe = ({ state, context }: PipeValue): PipeValue => { for (const patch of pendingMessages) { const existing = updated.find(m => m.id === patch.id); - if (!existing) { - if (!patch.title) continue; - } + if (!existing && !patch.title) continue; const base = existing ?? { id: patch.id, title: patch.title! }; @@ -81,25 +78,27 @@ export const messagesPipe = ({ state, context }: PipeValue): PipeValue => { } } - const newState = namespaced(PLUGIN_NAMESPACE, { + stateComposer.setIn(PLUGIN_NAMESPACE, { messages: updated, timers: updatedTimers, - })(state); + }); - const newContext = namespaced(PLUGIN_NAMESPACE, { + contextComposer.setIn(PLUGIN_NAMESPACE, { pendingMessages: [], - sendMessage: (msg: Partial & { id: string }): GameContext => { - const queue = - fromNamespace(newContext, PLUGIN_NAMESPACE) - .pendingMessages ?? []; - return namespaced(PLUGIN_NAMESPACE, { - pendingMessages: [...queue, msg], - })(newContext); - }, - })(context); + sendMessage: (msg: GameMessage) => + Composer.build(ctx => + ctx.setIn(PLUGIN_NAMESPACE, { + pendingMessages: [ + ...(ctx.from(PLUGIN_NAMESPACE).pendingMessages ?? + []), + msg, + ], + }) + ), + }); return { - state: newState, - context: newContext, + state: stateComposer.get(), + context: contextComposer.get(), }; }; diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts new file mode 100644 index 0000000..16f73a9 --- /dev/null +++ b/src/engine/pipes/Scheduler.ts @@ -0,0 +1,73 @@ +import { Composer, Transformer } from '../Composer'; +import { GameContext, GameState, Pipe } from '../State'; +import { GameAction, dispatchAction } from './Action'; + +const PLUGIN_NAMESPACE = 'core.scheduler'; + +export type ScheduledAction = { + id?: string; + duration: number; + action: GameAction; +}; + +type SchedulerState = { + scheduled: ScheduledAction[]; +}; + +export type SchedulerContext = { + schedule: Transformer<[ScheduledAction], GameContext>; + cancel: Transformer<[string], GameContext>; +}; + +export const schedulerPipe: Pipe = ({ state, context }) => { + const deltaTime = context.deltaTime; + + const stateComposer = new Composer(state); + const contextComposer = new Composer(context); + + const scheduled = + stateComposer.from(PLUGIN_NAMESPACE).scheduled ?? []; + + const remaining: ScheduledAction[] = []; + const actionsToDispatch: GameAction[] = []; + + for (const entry of scheduled) { + const nextTime = entry.duration - deltaTime; + if (nextTime <= 0) { + actionsToDispatch.push(entry.action); + } else { + remaining.push({ ...entry, duration: nextTime }); + } + } + + stateComposer.setIn(PLUGIN_NAMESPACE, { scheduled: remaining }); + + for (const action of actionsToDispatch) { + contextComposer.apply(dispatchAction, action); + } + + contextComposer.setIn(PLUGIN_NAMESPACE, { + schedule: (action: ScheduledAction) => + Composer.build(c => + c.setIn(PLUGIN_NAMESPACE, { + scheduled: [ + ...(c.from(PLUGIN_NAMESPACE).scheduled ?? []), + action, + ], + }) + ), + cancel: (id: string) => + Composer.build(c => + c.setIn(PLUGIN_NAMESPACE, { + scheduled: ( + c.from(PLUGIN_NAMESPACE).scheduled ?? [] + ).filter(s => s.id !== id), + }) + ), + }); + + return { + state: stateComposer.get(), + context: contextComposer.get(), + }; +}; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 9b09d14..427a0d1 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -3,12 +3,17 @@ import { GameEngineProvider } from './GameProvider'; import { FpsDisplay } from './components/FpsDisplay'; import { useCallback } from 'react'; import { PipeValue } from '../engine'; -import { fromNamespace, namespaced } from '../engine/Namespace'; -import { MessageContext, messagesPipe } from '../engine/pipes/Messages'; +import { messagesPipe } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; -import { assembleActionKey, getActions } from '../engine/pipes/Action'; +import { + assembleActionKey, + disassembleActionKey, + getActions, +} from '../engine/pipes/Action'; import { fpsPipe } from '../engine/pipes/Fps'; +import { SchedulerContext } from '../engine/pipes/Scheduler'; +import { Composer } from '../engine/Composer'; const StyledGamePage = styled.div` position: relative; @@ -73,59 +78,119 @@ const StyledBottomBar = styled.div` export const GamePage = () => { const messageTestPipe = useCallback(({ context, state }: PipeValue) => { const MSG_TEST_NAMESPACE = 'core.message_test'; - const { sent } = fromNamespace<{ sent?: boolean }>( - state, - MSG_TEST_NAMESPACE - ); - const { sendMessage } = fromNamespace( - context, - 'core.messages' - ); - - let newContext = context; - const messageId = 'test-message'; + const composer = new Composer(context); + const { sent } = composer.from<{ sent: boolean }>(MSG_TEST_NAMESPACE); + if (!sent) { - newContext = sendMessage({ - id: messageId, - title: 'Test Message', - description: - 'This is a test message to demonstrate the message system.', - prompts: [ - { - title: 'Acknowledge', - action: { - type: assembleActionKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), - payload: { id: messageId }, - }, - }, - { - title: 'Dismiss', - action: { - type: assembleActionKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - payload: { id: messageId }, - }, - }, - ], - }); + composer.focus('core', core => + core.focus('messages', msg => + msg.apply(msg.get().sendMessage, { + id: messageId, + title: 'Test Message', + description: + 'This is a test message to demonstrate the message system.', + prompts: [ + { + title: 'Acknowledge', + action: { + type: assembleActionKey( + MSG_TEST_NAMESPACE, + 'acknowledgeMessage' + ), + }, + }, + { + title: 'Dismiss', + action: { + type: assembleActionKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + }, + }, + ], + }) + ) + ); } const { actions } = getActions( - newContext, + composer.get(), assembleActionKey(MSG_TEST_NAMESPACE, '*') ); - for (const _ of actions) { - newContext = sendMessage({ - id: messageId, - duration: 0, - }); + for (const action of actions) { + const key = disassembleActionKey(action.type).key; + + if (key === 'acknowledgeMessage') { + composer.focus('core', core => + core + .focus('scheduler', sched => + sched.apply( + sched.from('core.scheduler').schedule, + { + duration: 2000, + action: { + type: assembleActionKey( + MSG_TEST_NAMESPACE, + 'followupMessage' + ), + }, + } + ) + ) + .focus('messages', msg => + msg.apply(msg.get().sendMessage, { + id: messageId, + duration: 0, + }) + ) + ); + } + + if (key === 'dismissMessage') { + composer.focus('core', core => + core.focus('messages', msg => + msg.apply(msg.get().sendMessage, { + id: messageId, + duration: 0, + }) + ) + ); + } + + if (key === 'followupMessage') { + composer.focus('core', core => + core.focus('messages', msg => + msg.apply(msg.get().sendMessage, { + id: 'followup-message', + title: 'Follow-up Message', + description: + 'This is a follow-up message after acknowledging the test message.', + prompts: [ + { + title: 'Close', + action: { + type: assembleActionKey( + MSG_TEST_NAMESPACE, + 'dismissMessage' + ), + payload: { id: 'followup-message' }, + }, + }, + ], + }) + ) + ); + } } + console.log(composer.get()); + return { - state: namespaced(MSG_TEST_NAMESPACE, { sent: true })(state), - context: newContext, + state: new Composer(state) + .setIn(MSG_TEST_NAMESPACE, { sent: true }) + .get(), + context: composer.get(), }; }, []); diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index fe0f2d2..fe226f2 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -7,7 +7,8 @@ import { ReactNode, } from 'react'; import { GameEngine, GameState, Pipe, GameContext } from '../engine'; -import { actionPipeline } from '../engine/pipes/Action'; +import { actionPipe } from '../engine/pipes/Action'; +import { schedulerPipe } from '../engine/pipes/Scheduler'; type GameEngineContextValue = { /** @@ -85,7 +86,8 @@ export function GameEngineProvider({ engineRef.current = new GameEngine({}, [ contextInjector, - actionPipeline, + actionPipe, + schedulerPipe, ...pipes, ]); setState(engineRef.current.getState()); From de22e1f846cd94b4b533195f9de49454c1057045 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 23 Jun 2025 23:56:23 +0200 Subject: [PATCH 04/90] fixed context unwrapping --- src/engine/pipes/Messages.ts | 4 +- src/game/GamePage.tsx | 147 ++++++++++++++++------------------- 2 files changed, 69 insertions(+), 82 deletions(-) diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index 6d46ea0..1a67e43 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -15,10 +15,12 @@ export interface GameMessage { duration?: number; } +export type PartialGameMessage = Partial & Pick; + const PLUGIN_NAMESPACE = 'core.messages'; export type MessageContext = { - pendingMessages?: (Partial & Pick)[]; + pendingMessages?: PartialGameMessage[]; sendMessage: Transformer< [Partial & { id: string }], GameContext diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 427a0d1..0b747b9 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -3,7 +3,11 @@ import { GameEngineProvider } from './GameProvider'; import { FpsDisplay } from './components/FpsDisplay'; import { useCallback } from 'react'; import { PipeValue } from '../engine'; -import { messagesPipe } from '../engine/pipes/Messages'; +import { + MessageContext, + messagesPipe, + PartialGameMessage, +} from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; import { @@ -83,34 +87,35 @@ export const GamePage = () => { const composer = new Composer(context); const { sent } = composer.from<{ sent: boolean }>(MSG_TEST_NAMESPACE); - if (!sent) { - composer.focus('core', core => - core.focus('messages', msg => - msg.apply(msg.get().sendMessage, { - id: messageId, - title: 'Test Message', - description: - 'This is a test message to demonstrate the message system.', - prompts: [ - { - title: 'Acknowledge', - action: { - type: assembleActionKey( - MSG_TEST_NAMESPACE, - 'acknowledgeMessage' - ), - }, - }, - { - title: 'Dismiss', - action: { - type: assembleActionKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - }, - }, - ], - }) - ) + const send = (msg: PartialGameMessage) => + composer.apply( + composer.from<{ sendMessage: MessageContext['sendMessage'] }>( + 'core.messages' + ).sendMessage, + msg ); + + if (!sent) { + send({ + id: messageId, + title: 'Test Message', + description: + 'This is a test message to demonstrate the message system.', + prompts: [ + { + title: 'Acknowledge', + action: { + type: assembleActionKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), + }, + }, + { + title: 'Dismiss', + action: { + type: assembleActionKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + }, + }, + ], + }); } const { actions } = getActions( @@ -122,65 +127,45 @@ export const GamePage = () => { const key = disassembleActionKey(action.type).key; if (key === 'acknowledgeMessage') { - composer.focus('core', core => - core - .focus('scheduler', sched => - sched.apply( - sched.from('core.scheduler').schedule, - { - duration: 2000, - action: { - type: assembleActionKey( - MSG_TEST_NAMESPACE, - 'followupMessage' - ), - }, - } - ) - ) - .focus('messages', msg => - msg.apply(msg.get().sendMessage, { - id: messageId, - duration: 0, - }) - ) - ); + const schedule = + composer.from('core.scheduler').schedule; + + composer.apply(schedule, { + duration: 2000, + action: { + type: assembleActionKey(MSG_TEST_NAMESPACE, 'followupMessage'), + }, + }); + + send({ + id: messageId, + duration: 0, + }); } if (key === 'dismissMessage') { - composer.focus('core', core => - core.focus('messages', msg => - msg.apply(msg.get().sendMessage, { - id: messageId, - duration: 0, - }) - ) - ); + send({ + id: messageId, + duration: 0, + }); } if (key === 'followupMessage') { - composer.focus('core', core => - core.focus('messages', msg => - msg.apply(msg.get().sendMessage, { - id: 'followup-message', - title: 'Follow-up Message', - description: - 'This is a follow-up message after acknowledging the test message.', - prompts: [ - { - title: 'Close', - action: { - type: assembleActionKey( - MSG_TEST_NAMESPACE, - 'dismissMessage' - ), - payload: { id: 'followup-message' }, - }, - }, - ], - }) - ) - ); + send({ + id: 'followup-message', + title: 'Follow-up Message', + description: + 'This is a follow-up message after acknowledging the test message.', + prompts: [ + { + title: 'Close', + action: { + type: assembleActionKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + payload: { id: 'followup-message' }, + }, + }, + ], + }); } } From 951fceaa5011bea2ac99fbf935d3aff9433bf87e Mon Sep 17 00:00:00 2001 From: clragon Date: Tue, 24 Jun 2025 00:10:27 +0200 Subject: [PATCH 05/90] fixed scheduling --- src/engine/pipes/Scheduler.ts | 9 ++++----- src/game/GamePage.tsx | 31 ++++++++++++++++--------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 16f73a9..8b2d59f 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -1,5 +1,5 @@ import { Composer, Transformer } from '../Composer'; -import { GameContext, GameState, Pipe } from '../State'; +import { GameContext, Pipe } from '../State'; import { GameAction, dispatchAction } from './Action'; const PLUGIN_NAMESPACE = 'core.scheduler'; @@ -22,11 +22,10 @@ export type SchedulerContext = { export const schedulerPipe: Pipe = ({ state, context }) => { const deltaTime = context.deltaTime; - const stateComposer = new Composer(state); const contextComposer = new Composer(context); const scheduled = - stateComposer.from(PLUGIN_NAMESPACE).scheduled ?? []; + contextComposer.from(PLUGIN_NAMESPACE).scheduled ?? []; const remaining: ScheduledAction[] = []; const actionsToDispatch: GameAction[] = []; @@ -40,7 +39,7 @@ export const schedulerPipe: Pipe = ({ state, context }) => { } } - stateComposer.setIn(PLUGIN_NAMESPACE, { scheduled: remaining }); + contextComposer.setIn(PLUGIN_NAMESPACE, { scheduled: remaining }); for (const action of actionsToDispatch) { contextComposer.apply(dispatchAction, action); @@ -67,7 +66,7 @@ export const schedulerPipe: Pipe = ({ state, context }) => { }); return { - state: stateComposer.get(), + state: state, context: contextComposer.get(), }; }; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 0b747b9..a46881a 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -84,17 +84,17 @@ export const GamePage = () => { const MSG_TEST_NAMESPACE = 'core.message_test'; const messageId = 'test-message'; - const composer = new Composer(context); - const { sent } = composer.from<{ sent: boolean }>(MSG_TEST_NAMESPACE); + const stateComposer = new Composer(state); + const contextComposer = new Composer(context); const send = (msg: PartialGameMessage) => - composer.apply( - composer.from<{ sendMessage: MessageContext['sendMessage'] }>( - 'core.messages' - ).sendMessage, + contextComposer.apply( + contextComposer.from('core.messages').sendMessage, msg ); + const { sent } = stateComposer.from<{ sent: boolean }>(MSG_TEST_NAMESPACE); + if (!sent) { send({ id: messageId, @@ -119,7 +119,7 @@ export const GamePage = () => { } const { actions } = getActions( - composer.get(), + contextComposer.get(), assembleActionKey(MSG_TEST_NAMESPACE, '*') ); @@ -128,9 +128,9 @@ export const GamePage = () => { if (key === 'acknowledgeMessage') { const schedule = - composer.from('core.scheduler').schedule; + contextComposer.from('core.scheduler').schedule; - composer.apply(schedule, { + contextComposer.apply(schedule, { duration: 2000, action: { type: assembleActionKey(MSG_TEST_NAMESPACE, 'followupMessage'), @@ -148,6 +148,11 @@ export const GamePage = () => { id: messageId, duration: 0, }); + + send({ + id: 'followup-message', + duration: 0, + }); } if (key === 'followupMessage') { @@ -169,13 +174,9 @@ export const GamePage = () => { } } - console.log(composer.get()); - return { - state: new Composer(state) - .setIn(MSG_TEST_NAMESPACE, { sent: true }) - .get(), - context: composer.get(), + state: stateComposer.setIn(MSG_TEST_NAMESPACE, { sent: true }).get(), + context: contextComposer.get(), }; }, []); From feeeab24d7049a32490dd58da68ab0241c16aacf Mon Sep 17 00:00:00 2001 From: clragon Date: Tue, 24 Jun 2025 00:18:39 +0200 Subject: [PATCH 06/90] removed failed files --- src/engine/Namespace.ts | 33 --------------------------------- src/engine/Plugin.ts | 38 -------------------------------------- 2 files changed, 71 deletions(-) delete mode 100644 src/engine/Namespace.ts delete mode 100644 src/engine/Plugin.ts diff --git a/src/engine/Namespace.ts b/src/engine/Namespace.ts deleted file mode 100644 index da6f1df..0000000 --- a/src/engine/Namespace.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const namespaced = - (namespace: string, values: T) => - (context: any): any => { - const parts = namespace.split('.'); - const last = parts.pop()!; - - let base = { ...context }; - let target = base; - - for (const key of parts) { - target[key] = { ...(target[key] ?? {}) }; - target = target[key]; - } - - target[last] = { - ...(target[last] ?? {}), - ...values, - }; - - return base; - }; - -export const fromNamespace = (values: any, namespace: string): T => { - const parts = namespace.split('.'); - let current = values; - - for (const key of parts) { - if (current == null) return {} as T; - current = current[key]; - } - - return current ?? ({} as T); -}; diff --git a/src/engine/Plugin.ts b/src/engine/Plugin.ts deleted file mode 100644 index 3722798..0000000 --- a/src/engine/Plugin.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { fromNamespace, namespaced } from './Namespace'; - -export function createPluginInterface( - namespace: string -) { - let newState: any = undefined; - let newContext: any = undefined; - - return { - getState: (state: any): StateShape => - fromNamespace(state, namespace), - setState: (state: any, value: Partial) => { - newState = namespaced(namespace, value)(state); - }, - - getContext: (context: any): ContextShape => - fromNamespace(context, namespace), - setContext: (context: any, value: Partial) => { - newContext = namespaced(namespace, value)(context); - }, - - readState: (state: any, ns: string): T => - fromNamespace(state, ns), - writeState: (state: any, ns: string, value: object) => { - newState = namespaced(ns, value)(state); - }, - readContext: (context: any, ns: string): T => - fromNamespace(context, ns), - writeContext: (context: any, ns: string, value: object) => { - newContext = namespaced(ns, value)(context); - }, - - commit: () => ({ - state: newState, - context: newContext, - }), - }; -} From 2b6934c1449cba570f502e493de5795b04f7ae3d Mon Sep 17 00:00:00 2001 From: clragon Date: Tue, 24 Jun 2025 00:27:53 +0200 Subject: [PATCH 07/90] added note about scheduling --- src/engine/pipes/Scheduler.ts | 2 ++ src/game/GamePage.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 8b2d59f..a97923e 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -22,6 +22,8 @@ export type SchedulerContext = { export const schedulerPipe: Pipe = ({ state, context }) => { const deltaTime = context.deltaTime; + // TODO: scheduled actions should be stored in the state, not context. + // Use context to store scheduled scheduled actions, then transfer them to state when they arrive here. const contextComposer = new Composer(context); const scheduled = diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index a46881a..d7a3534 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -2,7 +2,7 @@ import styled from 'styled-components'; import { GameEngineProvider } from './GameProvider'; import { FpsDisplay } from './components/FpsDisplay'; import { useCallback } from 'react'; -import { PipeValue } from '../engine'; +import { Pipe } from '../engine'; import { MessageContext, messagesPipe, @@ -80,7 +80,7 @@ const StyledBottomBar = styled.div` `; export const GamePage = () => { - const messageTestPipe = useCallback(({ context, state }: PipeValue) => { + const messageTestPipe: Pipe = useCallback(({ context, state }) => { const MSG_TEST_NAMESPACE = 'core.message_test'; const messageId = 'test-message'; @@ -127,8 +127,8 @@ export const GamePage = () => { const key = disassembleActionKey(action.type).key; if (key === 'acknowledgeMessage') { - const schedule = - contextComposer.from('core.scheduler').schedule; + const { schedule } = + contextComposer.from('core.scheduler'); contextComposer.apply(schedule, { duration: 2000, From dcf53c7170db81f5296fc631856f7d698f90895e Mon Sep 17 00:00:00 2001 From: clragon Date: Tue, 24 Jun 2025 01:09:17 +0200 Subject: [PATCH 08/90] updated scheduling to use action system --- src/engine/pipes/Action.ts | 18 ++++++-- src/engine/pipes/Fps.ts | 19 +++----- src/engine/pipes/Scheduler.ts | 78 +++++++++++++++++++++------------ src/game/hooks/UseGameValue.tsx | 7 ++- 4 files changed, 75 insertions(+), 47 deletions(-) diff --git a/src/engine/pipes/Action.ts b/src/engine/pipes/Action.ts index b6acedd..37dd9b8 100644 --- a/src/engine/pipes/Action.ts +++ b/src/engine/pipes/Action.ts @@ -1,4 +1,4 @@ -import { Composer } from '../Composer'; +import { Composer, Transformer } from '../Composer'; import { GameContext, Pipe } from '../State'; export type GameAction = { @@ -6,9 +6,10 @@ export type GameAction = { payload?: any; }; -type ActionContext = { - pendingActions?: GameAction[]; - currentActions?: GameAction[]; +export type ActionContext = { + pendingActions: GameAction[]; + currentActions: GameAction[]; + dispatch: Transformer<[GameAction], GameContext>; }; const PLUGIN_NAMESPACE = 'core.actions'; @@ -96,5 +97,14 @@ export const actionPipe: Pipe = Composer.buildFocus('context', ctx => pendingActions: [], currentActions: ctx.from(PLUGIN_NAMESPACE).pendingActions ?? [], + dispatch: (action: GameAction) => + Composer.build(c => + c.setIn(PLUGIN_NAMESPACE, { + pendingActions: [ + ...(c.from(PLUGIN_NAMESPACE).pendingActions ?? []), + action, + ], + }) + ), }) ); diff --git a/src/engine/pipes/Fps.ts b/src/engine/pipes/Fps.ts index a5a4b78..69347a7 100644 --- a/src/engine/pipes/Fps.ts +++ b/src/engine/pipes/Fps.ts @@ -1,15 +1,8 @@ import { Pipe } from '../State'; -import { namespaced } from '../Namespace'; +import { Composer } from '../Composer'; -export const fpsPipe: Pipe = ({ state, context }) => { - const { deltaTime } = context; - - const fps = deltaTime > 0 ? 1000 / deltaTime : 0; - - return { - state, - context: namespaced('core', { - fps, - })(context), - }; -}; +export const fpsPipe: Pipe = Composer.buildFocus('context', ctx => + ctx.setIn('core', { + fps: ctx.get().deltaTime > 0 ? 1000 / ctx.get().deltaTime : 0, + }) +); diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index a97923e..584868a 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -1,6 +1,13 @@ import { Composer, Transformer } from '../Composer'; import { GameContext, Pipe } from '../State'; -import { GameAction, dispatchAction } from './Action'; +import { + ActionContext, + GameAction, + assembleActionKey, + disassembleActionKey, + dispatchAction, + getActions, +} from './Action'; const PLUGIN_NAMESPACE = 'core.scheduler'; @@ -21,54 +28,69 @@ export type SchedulerContext = { export const schedulerPipe: Pipe = ({ state, context }) => { const deltaTime = context.deltaTime; + const stateComposer = new Composer(state); - // TODO: scheduled actions should be stored in the state, not context. - // Use context to store scheduled scheduled actions, then transfer them to state when they arrive here. - const contextComposer = new Composer(context); + const { scheduled = [] } = + stateComposer.from(PLUGIN_NAMESPACE); - const scheduled = - contextComposer.from(PLUGIN_NAMESPACE).scheduled ?? []; + const { actions } = getActions( + context, + assembleActionKey(PLUGIN_NAMESPACE, '*'), + { consume: true } + ); + + const updatedSchedule = [...scheduled]; + const toDispatch: GameAction[] = []; + + for (const action of actions) { + const key = disassembleActionKey(action.type).key; + + if (key === 'schedule') { + updatedSchedule.push(action.payload); + } + + if (key === 'cancel') { + const id = action.payload; + const idx = updatedSchedule.findIndex(s => s.id === id); + if (idx !== -1) updatedSchedule.splice(idx, 1); + } + } const remaining: ScheduledAction[] = []; - const actionsToDispatch: GameAction[] = []; - for (const entry of scheduled) { - const nextTime = entry.duration - deltaTime; - if (nextTime <= 0) { - actionsToDispatch.push(entry.action); + for (const entry of updatedSchedule) { + const time = entry.duration - deltaTime; + if (time <= 0) { + toDispatch.push(entry.action); } else { - remaining.push({ ...entry, duration: nextTime }); + remaining.push({ ...entry, duration: time }); } } - contextComposer.setIn(PLUGIN_NAMESPACE, { scheduled: remaining }); + stateComposer.setIn(PLUGIN_NAMESPACE, { scheduled: remaining }); - for (const action of actionsToDispatch) { - contextComposer.apply(dispatchAction, action); - } + const contextComposer = new Composer(context); + for (const a of toDispatch) contextComposer.apply(dispatchAction, a); contextComposer.setIn(PLUGIN_NAMESPACE, { schedule: (action: ScheduledAction) => - Composer.build(c => - c.setIn(PLUGIN_NAMESPACE, { - scheduled: [ - ...(c.from(PLUGIN_NAMESPACE).scheduled ?? []), - action, - ], + Composer.build(ctx => + ctx.apply(ctx.from('core.actions').dispatch, { + type: assembleActionKey(PLUGIN_NAMESPACE, 'schedule'), + payload: action, }) ), cancel: (id: string) => - Composer.build(c => - c.setIn(PLUGIN_NAMESPACE, { - scheduled: ( - c.from(PLUGIN_NAMESPACE).scheduled ?? [] - ).filter(s => s.id !== id), + Composer.build(ctx => + ctx.apply(ctx.from('core.actions').dispatch, { + type: assembleActionKey(PLUGIN_NAMESPACE, 'cancel'), + payload: id, }) ), }); return { - state: state, + state: stateComposer.get(), context: contextComposer.get(), }; }; diff --git a/src/game/hooks/UseGameValue.tsx b/src/game/hooks/UseGameValue.tsx index 15bdc3d..c4228a0 100644 --- a/src/game/hooks/UseGameValue.tsx +++ b/src/game/hooks/UseGameValue.tsx @@ -1,7 +1,10 @@ -import { fromNamespace } from '../../engine/Namespace'; +import { Composer } from '../../engine/Composer'; import { useGameEngine } from '../GameProvider'; export const useGameValue = (path: string): T => { const { state } = useGameEngine(); - return fromNamespace(state, path); + if (!state) { + return {} as T; + } + return new Composer(state).from(path) as T; }; From 44f8d2a8579c0e8b9b530738cfde960988267631 Mon Sep 17 00:00:00 2001 From: clragon Date: Tue, 24 Jun 2025 01:25:10 +0200 Subject: [PATCH 09/90] updated messages to use action system --- src/engine/pipes/Action.ts | 21 +++++++++++--------- src/engine/pipes/Messages.ts | 38 ++++++++++++++---------------------- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/src/engine/pipes/Action.ts b/src/engine/pipes/Action.ts index 37dd9b8..dfb9223 100644 --- a/src/engine/pipes/Action.ts +++ b/src/engine/pipes/Action.ts @@ -10,6 +10,11 @@ export type ActionContext = { pendingActions: GameAction[]; currentActions: GameAction[]; dispatch: Transformer<[GameAction], GameContext>; + handle: ( + ctx: GameContext, + type: string, + opts?: { consume?: boolean } + ) => { context: GameContext; actions: GameAction[] }; }; const PLUGIN_NAMESPACE = 'core.actions'; @@ -97,14 +102,12 @@ export const actionPipe: Pipe = Composer.buildFocus('context', ctx => pendingActions: [], currentActions: ctx.from(PLUGIN_NAMESPACE).pendingActions ?? [], - dispatch: (action: GameAction) => - Composer.build(c => - c.setIn(PLUGIN_NAMESPACE, { - pendingActions: [ - ...(c.from(PLUGIN_NAMESPACE).pendingActions ?? []), - action, - ], - }) - ), + dispatch: dispatchAction, + handle: ( + ctx: GameContext, + type: string, + opts: { consume?: boolean } = {} + ): { context: GameContext; actions: GameAction[] } => + getActions(ctx, type, opts), }) ); diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index 1a67e43..9883a0a 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -1,6 +1,6 @@ import { GameContext, Pipe } from '../State'; import { Composer, Transformer } from '../Composer'; -import { GameAction } from './Action'; +import { ActionContext, assembleActionKey, GameAction } from './Action'; export interface GameMessagePrompt { title: string; @@ -20,11 +20,7 @@ export type PartialGameMessage = Partial & Pick; const PLUGIN_NAMESPACE = 'core.messages'; export type MessageContext = { - pendingMessages?: PartialGameMessage[]; - sendMessage: Transformer< - [Partial & { id: string }], - GameContext - >; + sendMessage: Transformer<[PartialGameMessage], GameContext>; }; export type MessageState = { @@ -40,8 +36,12 @@ export const messagesPipe: Pipe = ({ state, context }) => { const { messages = [], timers = {} } = stateComposer.from(PLUGIN_NAMESPACE); - const { pendingMessages = [] } = - contextComposer.from(PLUGIN_NAMESPACE); + + const { handle } = contextComposer.from('core.actions'); + + const { actions } = handle(context, `${PLUGIN_NAMESPACE}/sendMessage`, { + consume: true, + }); const updated: GameMessage[] = []; const updatedTimers: Record = {}; @@ -59,17 +59,13 @@ export const messagesPipe: Pipe = ({ state, context }) => { } } - for (const patch of pendingMessages) { + for (const action of actions) { + const patch = action.payload as GameMessage; const existing = updated.find(m => m.id === patch.id); - if (!existing && !patch.title) continue; const base = existing ?? { id: patch.id, title: patch.title! }; - - const merged: GameMessage = { - ...base, - ...patch, - }; + const merged: GameMessage = { ...base, ...patch }; const index = updated.findIndex(m => m.id === patch.id); if (index >= 0) updated[index] = merged; @@ -86,15 +82,11 @@ export const messagesPipe: Pipe = ({ state, context }) => { }); contextComposer.setIn(PLUGIN_NAMESPACE, { - pendingMessages: [], sendMessage: (msg: GameMessage) => - Composer.build(ctx => - ctx.setIn(PLUGIN_NAMESPACE, { - pendingMessages: [ - ...(ctx.from(PLUGIN_NAMESPACE).pendingMessages ?? - []), - msg, - ], + Composer.build(ctx => + ctx.apply(ctx.from('core.actions').dispatch, { + type: assembleActionKey(PLUGIN_NAMESPACE, 'sendMessage'), + payload: msg, }) ), }); From 21f5709a82c2683ea3c1a3d4fd18b06850d9361b Mon Sep 17 00:00:00 2001 From: clragon Date: Tue, 24 Jun 2025 09:32:06 +0200 Subject: [PATCH 10/90] renamed actions to events --- src/engine/pipes/Action.ts | 113 --------------------------- src/engine/pipes/Events.ts | 111 ++++++++++++++++++++++++++ src/engine/pipes/Messages.ts | 16 ++-- src/engine/pipes/Scheduler.ts | 58 +++++++------- src/game/GamePage.tsx | 30 +++---- src/game/GameProvider.tsx | 4 +- src/game/components/GameMessages.tsx | 6 +- src/game/hooks/UseDispatchAction.tsx | 12 --- src/game/hooks/UseDispatchEvent.tsx | 16 ++++ 9 files changed, 181 insertions(+), 185 deletions(-) delete mode 100644 src/engine/pipes/Action.ts create mode 100644 src/engine/pipes/Events.ts delete mode 100644 src/game/hooks/UseDispatchAction.tsx create mode 100644 src/game/hooks/UseDispatchEvent.tsx diff --git a/src/engine/pipes/Action.ts b/src/engine/pipes/Action.ts deleted file mode 100644 index dfb9223..0000000 --- a/src/engine/pipes/Action.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Composer, Transformer } from '../Composer'; -import { GameContext, Pipe } from '../State'; - -export type GameAction = { - type: string; - payload?: any; -}; - -export type ActionContext = { - pendingActions: GameAction[]; - currentActions: GameAction[]; - dispatch: Transformer<[GameAction], GameContext>; - handle: ( - ctx: GameContext, - type: string, - opts?: { consume?: boolean } - ) => { context: GameContext; actions: GameAction[] }; -}; - -const PLUGIN_NAMESPACE = 'core.actions'; - -export const assembleActionKey = (namespace: string, key: string): string => { - return `${namespace}/${key}`; -}; - -export const disassembleActionKey = ( - actionKey: string -): { namespace: string; key: string } => { - const index = actionKey.indexOf('/'); - if (index === -1) { - throw new Error(`Invalid action key: "${actionKey}"`); - } - return { - namespace: actionKey.slice(0, index), - key: actionKey.slice(index + 1), - }; -}; - -export const createActionContext = (action: GameAction): Partial => - new Composer({}) - .setIn(PLUGIN_NAMESPACE, { - pendingActions: [action], - }) - .get(); - -export const dispatchAction = (action: GameAction) => - Composer.build(c => - c.setIn(PLUGIN_NAMESPACE, { - pendingActions: [ - ...(c.from(PLUGIN_NAMESPACE).pendingActions ?? []), - action, - ], - }) - ); - -export const getActions = ( - context: GameContext, - type: string, - { consume = false }: { consume?: boolean } = {} -): { context: GameContext; actions: GameAction[] } => { - const composer = new Composer(context); - const { currentActions = [] } = - composer.from(PLUGIN_NAMESPACE); - - const { namespace, key } = disassembleActionKey(type); - const isWildcard = key === '*'; - - const matched: GameAction[] = []; - const unmatched: GameAction[] = []; - - for (const action of currentActions) { - const { namespace: actionNs, key: actionKey } = disassembleActionKey( - action.type - ); - const isMatch = isWildcard - ? actionNs === namespace - : actionNs === namespace && actionKey === key; - - if (isMatch) matched.push(action); - else unmatched.push(action); - } - - if (consume) { - composer.setIn(PLUGIN_NAMESPACE, { - currentActions: unmatched, - }); - } - - return { - context: composer.get(), - actions: matched, - }; -}; - -/** - * Moves actions from pending to current. - * This prevents actions from being processed during the same frame they are created. - * This is important because pipes later in the pipeline add new actions. - */ -export const actionPipe: Pipe = Composer.buildFocus('context', ctx => - ctx.setIn(PLUGIN_NAMESPACE, { - pendingActions: [], - currentActions: - ctx.from(PLUGIN_NAMESPACE).pendingActions ?? [], - dispatch: dispatchAction, - handle: ( - ctx: GameContext, - type: string, - opts: { consume?: boolean } = {} - ): { context: GameContext; actions: GameAction[] } => - getActions(ctx, type, opts), - }) -); diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts new file mode 100644 index 0000000..151feb1 --- /dev/null +++ b/src/engine/pipes/Events.ts @@ -0,0 +1,111 @@ +import { Composer, Transformer } from '../Composer'; +import { GameContext, Pipe } from '../State'; + +export type GameEvent = { + type: string; + payload?: any; +}; + +export type EventContext = { + pending: GameEvent[]; + current: GameEvent[]; + dispatch: Transformer<[GameEvent], GameContext>; + handle: ( + ctx: GameContext, + type: string, + opts?: { consume?: boolean } + ) => { context: GameContext; events: GameEvent[] }; +}; + +const PLUGIN_NAMESPACE = 'core.events'; + +export const getEventKey = (namespace: string, key: string): string => { + return `${namespace}/${key}`; +}; + +export const readEventKey = ( + key: string +): { namespace: string; key: string } => { + const index = key.indexOf('/'); + if (index === -1) { + throw new Error(`Invalid event key: "${key}"`); + } + return { + namespace: key.slice(0, index), + key: key.slice(index + 1), + }; +}; + +export const createEventContext = (event: GameEvent): Partial => + new Composer({}) + .setIn(PLUGIN_NAMESPACE, { + pending: [event], + }) + .get(); + +export const dispatchEvent = (event: GameEvent) => + Composer.build(c => + c.setIn(PLUGIN_NAMESPACE, { + pending: [ + ...(c.from(PLUGIN_NAMESPACE).pending ?? []), + event, + ], + }) + ); + +export const getEvents = ( + context: GameContext, + type: string, + { consume = false }: { consume?: boolean } = {} +): { context: GameContext; events: GameEvent[] } => { + const composer = new Composer(context); + const { current = [] } = composer.from(PLUGIN_NAMESPACE); + + const { namespace, key } = readEventKey(type); + const isWildcard = key === '*'; + + const matched: GameEvent[] = []; + const unmatched: GameEvent[] = []; + + for (const event of current) { + const { namespace: eventNamespace, key: eventKey } = readEventKey( + event.type + ); + const isMatch = isWildcard + ? eventNamespace === namespace + : eventNamespace === namespace && eventKey === key; + + if (isMatch) matched.push(event); + else unmatched.push(event); + } + + if (consume) { + composer.setIn(PLUGIN_NAMESPACE, { + current: unmatched, + }); + } + + return { + context: composer.get(), + events: matched, + }; +}; + +/** + * Moves events from pending to current. + * This prevents events from being processed during the same frame they are created. + * This is important because pipes later in the pipeline may add new events. + */ +export const eventPipe: Pipe = Composer.buildFocus('context', ctx => + ctx.setIn(PLUGIN_NAMESPACE, { + pending: [], + current: ctx.from(PLUGIN_NAMESPACE).pending ?? [], + dispatch: dispatchEvent, + handle: ( + ctx: GameContext, + type: string, + opts: { consume?: boolean } = {} + ): { context: GameContext; events: GameEvent[] } => + getEvents(ctx, type, opts), + }) +); diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index 9883a0a..8446631 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -1,10 +1,10 @@ import { GameContext, Pipe } from '../State'; import { Composer, Transformer } from '../Composer'; -import { ActionContext, assembleActionKey, GameAction } from './Action'; +import { EventContext, getEventKey, GameEvent } from './Events'; export interface GameMessagePrompt { title: string; - action: GameAction; + event: GameEvent; } export interface GameMessage { @@ -37,9 +37,9 @@ export const messagesPipe: Pipe = ({ state, context }) => { const { messages = [], timers = {} } = stateComposer.from(PLUGIN_NAMESPACE); - const { handle } = contextComposer.from('core.actions'); + const { handle } = contextComposer.from('core.events'); - const { actions } = handle(context, `${PLUGIN_NAMESPACE}/sendMessage`, { + const { events } = handle(context, `${PLUGIN_NAMESPACE}/sendMessage`, { consume: true, }); @@ -59,8 +59,8 @@ export const messagesPipe: Pipe = ({ state, context }) => { } } - for (const action of actions) { - const patch = action.payload as GameMessage; + for (const event of events) { + const patch = event.payload as GameMessage; const existing = updated.find(m => m.id === patch.id); if (!existing && !patch.title) continue; @@ -84,8 +84,8 @@ export const messagesPipe: Pipe = ({ state, context }) => { contextComposer.setIn(PLUGIN_NAMESPACE, { sendMessage: (msg: GameMessage) => Composer.build(ctx => - ctx.apply(ctx.from('core.actions').dispatch, { - type: assembleActionKey(PLUGIN_NAMESPACE, 'sendMessage'), + ctx.apply(ctx.from('core.events').dispatch, { + type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), payload: msg, }) ), diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 584868a..595d042 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -1,28 +1,28 @@ import { Composer, Transformer } from '../Composer'; import { GameContext, Pipe } from '../State'; import { - ActionContext, - GameAction, - assembleActionKey, - disassembleActionKey, - dispatchAction, - getActions, -} from './Action'; + EventContext, + GameEvent, + getEventKey, + readEventKey, + dispatchEvent, + getEvents, +} from './Events'; const PLUGIN_NAMESPACE = 'core.scheduler'; -export type ScheduledAction = { +export type ScheduledEvent = { id?: string; duration: number; - action: GameAction; + event: GameEvent; }; type SchedulerState = { - scheduled: ScheduledAction[]; + scheduled: ScheduledEvent[]; }; export type SchedulerContext = { - schedule: Transformer<[ScheduledAction], GameContext>; + schedule: Transformer<[ScheduledEvent], GameContext>; cancel: Transformer<[string], GameContext>; }; @@ -33,35 +33,33 @@ export const schedulerPipe: Pipe = ({ state, context }) => { const { scheduled = [] } = stateComposer.from(PLUGIN_NAMESPACE); - const { actions } = getActions( - context, - assembleActionKey(PLUGIN_NAMESPACE, '*'), - { consume: true } - ); + const { events } = getEvents(context, getEventKey(PLUGIN_NAMESPACE, '*'), { + consume: true, + }); const updatedSchedule = [...scheduled]; - const toDispatch: GameAction[] = []; + const toDispatch: GameEvent[] = []; - for (const action of actions) { - const key = disassembleActionKey(action.type).key; + for (const event of events) { + const key = readEventKey(event.type).key; if (key === 'schedule') { - updatedSchedule.push(action.payload); + updatedSchedule.push(event.payload); } if (key === 'cancel') { - const id = action.payload; + const id = event.payload; const idx = updatedSchedule.findIndex(s => s.id === id); if (idx !== -1) updatedSchedule.splice(idx, 1); } } - const remaining: ScheduledAction[] = []; + const remaining: ScheduledEvent[] = []; for (const entry of updatedSchedule) { const time = entry.duration - deltaTime; if (time <= 0) { - toDispatch.push(entry.action); + toDispatch.push(entry.event); } else { remaining.push({ ...entry, duration: time }); } @@ -70,20 +68,20 @@ export const schedulerPipe: Pipe = ({ state, context }) => { stateComposer.setIn(PLUGIN_NAMESPACE, { scheduled: remaining }); const contextComposer = new Composer(context); - for (const a of toDispatch) contextComposer.apply(dispatchAction, a); + for (const a of toDispatch) contextComposer.apply(dispatchEvent, a); contextComposer.setIn(PLUGIN_NAMESPACE, { - schedule: (action: ScheduledAction) => + schedule: (event: ScheduledEvent) => Composer.build(ctx => - ctx.apply(ctx.from('core.actions').dispatch, { - type: assembleActionKey(PLUGIN_NAMESPACE, 'schedule'), - payload: action, + ctx.apply(ctx.from('core.events').dispatch, { + type: getEventKey(PLUGIN_NAMESPACE, 'schedule'), + payload: event, }) ), cancel: (id: string) => Composer.build(ctx => - ctx.apply(ctx.from('core.actions').dispatch, { - type: assembleActionKey(PLUGIN_NAMESPACE, 'cancel'), + ctx.apply(ctx.from('core.events').dispatch, { + type: getEventKey(PLUGIN_NAMESPACE, 'cancel'), payload: id, }) ), diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index d7a3534..c8c6c82 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -10,11 +10,7 @@ import { } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; -import { - assembleActionKey, - disassembleActionKey, - getActions, -} from '../engine/pipes/Action'; +import { getEventKey, readEventKey, getEvents } from '../engine/pipes/Events'; import { fpsPipe } from '../engine/pipes/Fps'; import { SchedulerContext } from '../engine/pipes/Scheduler'; import { Composer } from '../engine/Composer'; @@ -104,27 +100,27 @@ export const GamePage = () => { prompts: [ { title: 'Acknowledge', - action: { - type: assembleActionKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), }, }, { title: 'Dismiss', - action: { - type: assembleActionKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), }, }, ], }); } - const { actions } = getActions( + const { events } = getEvents( contextComposer.get(), - assembleActionKey(MSG_TEST_NAMESPACE, '*') + getEventKey(MSG_TEST_NAMESPACE, '*') ); - for (const action of actions) { - const key = disassembleActionKey(action.type).key; + for (const event of events) { + const key = readEventKey(event.type).key; if (key === 'acknowledgeMessage') { const { schedule } = @@ -132,8 +128,8 @@ export const GamePage = () => { contextComposer.apply(schedule, { duration: 2000, - action: { - type: assembleActionKey(MSG_TEST_NAMESPACE, 'followupMessage'), + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), }, }); @@ -164,8 +160,8 @@ export const GamePage = () => { prompts: [ { title: 'Close', - action: { - type: assembleActionKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), payload: { id: 'followup-message' }, }, }, diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index fe226f2..44d09b4 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -7,7 +7,7 @@ import { ReactNode, } from 'react'; import { GameEngine, GameState, Pipe, GameContext } from '../engine'; -import { actionPipe } from '../engine/pipes/Action'; +import { eventPipe } from '../engine/pipes/Events'; import { schedulerPipe } from '../engine/pipes/Scheduler'; type GameEngineContextValue = { @@ -86,7 +86,7 @@ export function GameEngineProvider({ engineRef.current = new GameEngine({}, [ contextInjector, - actionPipe, + eventPipe, schedulerPipe, ...pipes, ]); diff --git a/src/game/components/GameMessages.tsx b/src/game/components/GameMessages.tsx index 1ea0e3d..5dd2a96 100644 --- a/src/game/components/GameMessages.tsx +++ b/src/game/components/GameMessages.tsx @@ -8,7 +8,7 @@ import { GameMessage, MessageState } from '../../engine/pipes/Messages'; import { useGameValue } from '../hooks/UseGameValue'; import _ from 'lodash'; -import { useDispatchAction } from '../hooks/UseDispatchAction'; +import { useDispatchEvent } from '../hooks/UseDispatchEvent'; const StyledGameMessages = styled.div` display: flex; @@ -60,7 +60,7 @@ const StyledGameMessageButton = motion.create(styled.button` export const GameMessages = () => { const { messages } = useGameValue('core.messages'); - const { dispatchAction } = useDispatchAction(); + const { dispatchEvent } = useDispatchEvent(); const translate = useTranslate(); const prevMessagesRef = useRef([]); @@ -118,7 +118,7 @@ export const GameMessages = () => { ...defaultTransition, ease: 'circInOut', }} - onClick={() => dispatchAction(prompt.action)} + onClick={() => dispatchEvent(prompt.event)} > {translate(prompt.title)} diff --git a/src/game/hooks/UseDispatchAction.tsx b/src/game/hooks/UseDispatchAction.tsx deleted file mode 100644 index a1bcd38..0000000 --- a/src/game/hooks/UseDispatchAction.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createActionContext, GameAction } from '../../engine/pipes/Action'; -import { useGameEngine } from '../GameProvider'; - -export function useDispatchAction() { - const { injectContext } = useGameEngine(); - - return { - dispatchAction: (action: GameAction) => { - injectContext(createActionContext(action)); - }, - }; -} diff --git a/src/game/hooks/UseDispatchEvent.tsx b/src/game/hooks/UseDispatchEvent.tsx new file mode 100644 index 0000000..966dddc --- /dev/null +++ b/src/game/hooks/UseDispatchEvent.tsx @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { createEventContext, GameEvent } from '../../engine/pipes/Events'; +import { useGameEngine } from '../GameProvider'; + +export function useDispatchEvent() { + const { injectContext } = useGameEngine(); + + return useMemo( + () => ({ + dispatchEvent: (event: GameEvent) => { + injectContext(createEventContext(event)); + }, + }), + [injectContext] + ); +} From 10eb0de8227af9a58d2046fa0a1b3a807ee18373 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 27 Jun 2025 21:37:28 +0200 Subject: [PATCH 11/90] updated composer dsl --- src/engine/Composer.ts | 168 ++++++++++++++-------------- src/engine/Engine.ts | 38 +++---- src/engine/Lens.ts | 66 +++++++++++ src/engine/Piper.ts | 5 + src/engine/State.ts | 6 +- src/engine/pipes/Events.ts | 122 ++++++++------------ src/engine/pipes/Fps.ts | 10 +- src/engine/pipes/Messages.ts | 147 ++++++++++++------------ src/engine/pipes/Scheduler.ts | 155 +++++++++++++------------ src/game/GamePage.tsx | 112 +------------------ src/game/GameProvider.tsx | 85 ++++++-------- src/game/hooks/UseDispatchEvent.tsx | 8 +- src/game/hooks/UseGameValue.tsx | 5 +- src/game/test.ts | 102 +++++++++++++++++ 14 files changed, 530 insertions(+), 499 deletions(-) create mode 100644 src/engine/Lens.ts create mode 100644 src/engine/Piper.ts create mode 100644 src/game/test.ts diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index 0732056..67d5996 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -1,7 +1,17 @@ +import { lensFromPath, Path } from './Lens'; + +/** + * A curried function that that maps an object from T to T, + * given a set of arguments. + */ export type Transformer = ( ...args: TArgs ) => (obj: TObj) => TObj; +/** + * A generalized object manipulation utility + * in a functional chaining style. + */ export class Composer { private obj: T; @@ -10,8 +20,8 @@ export class Composer { } /** - * Creates a new ObjectComposer and applies the provided function to it, - * returning the transformed object. + * A shortcut to easily build a mapping function for an object, + * using the created composer and returning the modified object. */ static build( fn: (composer: Composer) => Composer @@ -20,124 +30,118 @@ export class Composer { } /** - * Creates a composer focused on a specific key of the object, - * but returns the entire object when done. + * Modifies the object, then returns it in a new Composer instance. */ - static focus( - base: TObj, - key: K, - fn: ( - inner: Composer> - ) => Composer> - ): Composer { - return new Composer(base).focus(key, fn); + map(fn: (obj: T) => T): Composer { + return new Composer(fn(this.obj)); + } + + /** + * Runs a composer function. + */ + chain(fn: (composer: this) => this): this { + return fn(this); } /** - * Focuses on a specific key of the object, allowing for - * transformation of that key's value. + * Applies a transformer function to the current object, + * passing the provided arguments. */ - focus( - key: K, - fn: ( - composer: Composer> - ) => Composer> + apply( + tool: Transformer, + ...args: TArgs ): this { - const base = this.obj as any; - const focused = new Composer(base[key] ?? {}); - const updated = fn(focused).get(); - this.obj = { - ...this.obj, - [key]: { ...(this.obj[key] as any), ...updated }, - }; + this.obj = tool(...args)(this.obj); return this; } /** - * Like `build, but specifically for composing a focus on a key - * of an object. + * Applies a series of mapping functions to the current object. */ - static buildFocus( - key: K, - fn: ( - composer: Composer> - ) => Composer> - ): (obj: TObj) => TObj { - return (obj: TObj) => - Composer.focus( - obj, - key, - fn as (composer: Composer) => Composer - ).get(); + pipe(...pipes: ((t: T) => T)[]): this { + for (const p of pipes) this.obj = p(this.obj); + return this; } /** - * Returns a new ObjectComposer with the transformed object. + * Extracts the current object from the composer. */ - map(fn: (obj: T) => T): Composer { - return new Composer(fn(this.obj)); + get(): T; + /** + * Gets a value at the specified path in the object. + */ + get(path: Path): A; + get(path?: Path): A | T { + if (path === undefined) return this.obj; + return lensFromPath(path).get(this.obj); } /** - * Chains a function that receives the composer instance. + * Replaces the current object with a new value. */ - chain(fn: (composer: this) => this): this { - return fn(this); + set(value: T): this; + /** + * Sets a value at the specified path in the object. + */ + set(path: Path, value: A): this; + + set(pathOrValue: Path | T, maybeValue?: any): this { + if (maybeValue === undefined) { + this.obj = pathOrValue as T; + } else { + this.obj = lensFromPath(pathOrValue as Path).set(maybeValue)( + this.obj + ); + } + return this; } /** - * Applies a transformation tool to the current object. + * Runs a composer on a sub-object at the specified path, + * then updates the original composer and returns it. */ - apply( - tool: Transformer, - ...args: TArgs + zoom( + path: Path, + fn: (inner: Composer) => Composer ): this { - this.obj = tool(...args)(this.obj); + const lens = lensFromPath(path); + const inner = new Composer(lens.get(this.obj)); + const updated = fn(inner).get(); + this.obj = lens.set(updated)(this.obj); return this; } /** - * Sets a value in a specific namespace within the object. + * Updates the value at the specified path with the mapping function. */ - setIn(namespace: string, partial: object): this { - const parts = namespace.split('.'); - const last = parts.pop()!; - const root = { ...this.obj }; - let node: any = root; - - for (const key of parts) { - node[key] = { ...(node[key] ?? {}) }; - node = node[key]; - } - - node[last] = { - ...(node[last] ?? {}), - ...partial, - }; - - this.obj = root; + over(path: Path, fn: (a: A) => A): this { + this.obj = lensFromPath(path).over(fn)(this.obj); return this; } /** - * Returns the value of a specific namespace within the object. + * Runs a composer function with the value at the specified path. */ - from(namespace: string): TNamespace { - const parts = namespace.split('.'); - let current: any = this.obj; - - for (const part of parts) { - if (current == null) return {} as TNamespace; - current = current[part]; - } + bind( + path: Path, + fn: (value: A) => (composer: Composer) => Composer + ): Composer { + const lens = lensFromPath(path); + const value = lens.get(this.obj) ?? ({} as A); + return fn(value)(this); + } - return current ?? ({} as TNamespace); + /** + * Runs a composer function when the condition is true. + */ + when(condition: boolean, fn: (c: this) => this): this { + return condition ? fn(this) : this; } /** - * Returns the current object. + * Runs a composer function when the condition is false. */ - get(): T { - return this.obj; + unless(condition: boolean, fn: (c: this) => this): this { + return this.when(!condition, fn); } } diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index b267ec4..4f8887d 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -1,10 +1,10 @@ -import { GameState, GameContext, Pipe, GameTiming } from './State'; +import { GameState, GameContext, Pipe, GameTiming, GameFrame } from './State'; import cloneDeep from 'lodash/cloneDeep'; export class GameEngine { - constructor(initial: Partial, pipes: Pipe[]) { + constructor(initial: GameState, pipe: Pipe) { this.state = { ...initial }; - this.pipes = pipes; + this.pipe = pipe; this.timing = { tick: 0, deltaTime: 0, @@ -21,9 +21,9 @@ export class GameEngine { private state: GameState; /** - * Engine pipes transform the game state and context by accepting the current state and context, and returning a new state and context. + * The pipe is a function that produces a new game frame based on the current game frame. */ - private pipes: Pipe[]; + private pipe: Pipe; /** * The context of the engine. May contain any ephemeral information of any plugin, however it is to be noted; @@ -62,26 +62,22 @@ export class GameEngine { this.timing.deltaTime = deltaTime; this.timing.elapsedTime += deltaTime; - let state = this.state; - let context = this.context; + const frame: GameFrame = { + state: this.state, + context: { + ...this.context, + ...this.timing, + }, + }; + + const result = this.pipe(frame); - const buildContext = (): GameContext => ({ - ...context, + this.state = cloneDeep(result.state); + this.context = cloneDeep({ + ...result.context, ...this.timing, }); - for (const pipe of this.pipes) { - try { - ({ state, context } = pipe({ state, context: buildContext() })); - } catch (err) { - // TODO: add debug info wrapper to pipe functions so they can be traced back to plugins - console.error('Pipe error:', err); - } - } - - this.state = cloneDeep(state); - this.context = cloneDeep(buildContext()); - return this.state; } } diff --git a/src/engine/Lens.ts b/src/engine/Lens.ts new file mode 100644 index 0000000..0f0c435 --- /dev/null +++ b/src/engine/Lens.ts @@ -0,0 +1,66 @@ +import { cloneDeep } from 'lodash'; + +export type Lens = { + get: (source: S) => A; + set: (value: A) => (source: S) => S; + over: (fn: (a: A) => A) => (source: S) => S; +}; + +export type Path = (string | number | symbol)[] | string; + +export function normalizePath(path: Path): (string | number | symbol)[] { + if (Array.isArray(path)) { + return path.flatMap(segment => { + if (typeof segment === 'string') { + return segment.split('.') as string[]; + } + return [segment]; + }); + } + return path.split('.') as string[]; +} + +export function lensFromPath(path: Path): Lens { + const parts = normalizePath(path); + + if (parts.length === 0 || (parts.length === 1 && parts[0] === '')) { + return { + get: (source: S) => source as unknown as A, + set: (value: A) => (_source: S) => value as unknown as S, + over: (fn: (a: A) => A) => (source: S) => + fn(source as unknown as A) as unknown as S, + }; + } + + return { + get: (source: S): A => { + return parts.reduce((acc: unknown, key: any) => { + if (acc == null || typeof acc !== 'object') return undefined; + return (acc as any)[key]; + }, source) as A; + }, + + set: + (value: A) => + (source: S): S => { + const root = cloneDeep(source) as any; + let node = root; + + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i]; + node[key] = { ...(node[key] ?? {}) }; + node = node[key]; + } + + node[parts[parts.length - 1]] = value; + return root; + }, + + over: + (fn: (a: A) => A) => + (source: S): S => { + const current = lensFromPath(parts).get(source) ?? ({} as A); + return lensFromPath(parts).set(fn(current))(source); + }, + }; +} diff --git a/src/engine/Piper.ts b/src/engine/Piper.ts new file mode 100644 index 0000000..26c3832 --- /dev/null +++ b/src/engine/Piper.ts @@ -0,0 +1,5 @@ +import { Pipe } from './State'; +import { Composer } from './Composer'; + +export const Piper = (pipes: Pipe[]): Pipe => + Composer.build(c => c.pipe(...pipes)); diff --git a/src/engine/State.ts b/src/engine/State.ts index 67a7af9..cb1004c 100644 --- a/src/engine/State.ts +++ b/src/engine/State.ts @@ -12,9 +12,11 @@ export type GameTiming = { elapsedTime: number; }; -export type PipeValue = { +export type GameFrame = { state: GameState; context: GameContext; }; -export type Pipe = (value: PipeValue) => PipeValue; +export type Pipe = (value: GameFrame) => GameFrame; + +export type PipeTransformer = (...args: TArgs) => Pipe; diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 151feb1..651d204 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -1,20 +1,19 @@ -import { Composer, Transformer } from '../Composer'; -import { GameContext, Pipe } from '../State'; +import { Composer } from '../Composer'; +import { GameContext, GameState, Pipe, PipeTransformer } from '../State'; export type GameEvent = { type: string; payload?: any; }; -export type EventContext = { +export type EventState = { pending: GameEvent[]; current: GameEvent[]; - dispatch: Transformer<[GameEvent], GameContext>; - handle: ( - ctx: GameContext, - type: string, - opts?: { consume?: boolean } - ) => { context: GameContext; events: GameEvent[] }; +}; + +export type EventContext = { + dispatch: PipeTransformer<[GameEvent]>; + handle: PipeTransformer<[string, (event: GameEvent) => Pipe]>; }; const PLUGIN_NAMESPACE = 'core.events'; @@ -36,76 +35,53 @@ export const readEventKey = ( }; }; -export const createEventContext = (event: GameEvent): Partial => - new Composer({}) - .setIn(PLUGIN_NAMESPACE, { - pending: [event], - }) - .get(); - -export const dispatchEvent = (event: GameEvent) => - Composer.build(c => - c.setIn(PLUGIN_NAMESPACE, { - pending: [ - ...(c.from(PLUGIN_NAMESPACE).pending ?? []), - event, - ], - }) +export const dispatchEvent: PipeTransformer<[GameEvent]> = event => + Composer.build(frame => + frame.over( + `state.${PLUGIN_NAMESPACE}.pending`, + (pending = []) => [...pending, event] + ) ); -export const getEvents = ( - context: GameContext, - type: string, - { consume = false }: { consume?: boolean } = {} -): { context: GameContext; events: GameEvent[] } => { - const composer = new Composer(context); - const { current = [] } = composer.from(PLUGIN_NAMESPACE); - - const { namespace, key } = readEventKey(type); - const isWildcard = key === '*'; - - const matched: GameEvent[] = []; - const unmatched: GameEvent[] = []; - - for (const event of current) { - const { namespace: eventNamespace, key: eventKey } = readEventKey( - event.type - ); - const isMatch = isWildcard - ? eventNamespace === namespace - : eventNamespace === namespace && eventKey === key; - - if (isMatch) matched.push(event); - else unmatched.push(event); - } - - if (consume) { - composer.setIn(PLUGIN_NAMESPACE, { - current: unmatched, - }); - } - - return { - context: composer.get(), - events: matched, - }; -}; +export const handleEvent: PipeTransformer< + [string, (event: GameEvent) => Pipe] +> = (type, fn) => + Composer.build(frame => + frame.bind( + `state.${PLUGIN_NAMESPACE}`, + ({ current = [] }) => + c => + c.pipe( + ...current + .filter(event => { + const { namespace: ns, key: k } = readEventKey(event.type); + const { namespace, key } = readEventKey(type); + return key === '*' + ? ns === namespace + : ns === namespace && k === key; + }) + .map(fn) + ) + ) + ); /** * Moves events from pending to current. * This prevents events from being processed during the same frame they are created. * This is important because pipes later in the pipeline may add new events. */ -export const eventPipe: Pipe = Composer.buildFocus('context', ctx => - ctx.setIn(PLUGIN_NAMESPACE, { - pending: [], - current: ctx.from(PLUGIN_NAMESPACE).pending ?? [], - dispatch: dispatchEvent, - handle: ( - ctx: GameContext, - type: string, - opts: { consume?: boolean } = {} - ): { context: GameContext; events: GameEvent[] } => - getEvents(ctx, type, opts), - }) +export const eventPipe: Pipe = Composer.build(frame => + frame + .zoom('state', state => + state.over(PLUGIN_NAMESPACE, ({ pending = [] }) => ({ + pending: [], + current: pending, + })) + ) + .zoom('context', context => + context.set(PLUGIN_NAMESPACE, { + dispatch: dispatchEvent, + handle: handleEvent, + }) + ) ); diff --git a/src/engine/pipes/Fps.ts b/src/engine/pipes/Fps.ts index 69347a7..bdc0d3e 100644 --- a/src/engine/pipes/Fps.ts +++ b/src/engine/pipes/Fps.ts @@ -1,8 +1,10 @@ import { Pipe } from '../State'; import { Composer } from '../Composer'; -export const fpsPipe: Pipe = Composer.buildFocus('context', ctx => - ctx.setIn('core', { - fps: ctx.get().deltaTime > 0 ? 1000 / ctx.get().deltaTime : 0, - }) +export const fpsPipe: Pipe = Composer.build(c => + c.bind( + ['context', 'deltaTime'], + delta => c => + c.set(['context', 'core', 'fps'], delta > 0 ? 1000 / delta : 0) + ) ); diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index 8446631..46025ed 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -1,5 +1,5 @@ -import { GameContext, Pipe } from '../State'; -import { Composer, Transformer } from '../Composer'; +import { Pipe, PipeTransformer } from '../State'; +import { Composer } from '../Composer'; import { EventContext, getEventKey, GameEvent } from './Events'; export interface GameMessagePrompt { @@ -20,7 +20,7 @@ export type PartialGameMessage = Partial & Pick; const PLUGIN_NAMESPACE = 'core.messages'; export type MessageContext = { - sendMessage: Transformer<[PartialGameMessage], GameContext>; + sendMessage: PipeTransformer<[PartialGameMessage]>; }; export type MessageState = { @@ -28,71 +28,76 @@ export type MessageState = { timers: Record; }; -export const messagesPipe: Pipe = ({ state, context }) => { - const deltaTime = context.deltaTime; - - const stateComposer = new Composer(state); - const contextComposer = new Composer(context); - - const { messages = [], timers = {} } = - stateComposer.from(PLUGIN_NAMESPACE); - - const { handle } = contextComposer.from('core.events'); - - const { events } = handle(context, `${PLUGIN_NAMESPACE}/sendMessage`, { - consume: true, - }); - - const updated: GameMessage[] = []; - const updatedTimers: Record = {}; - - for (const message of messages) { - const remaining = timers[message.id]; - if (remaining != null) { - const next = remaining - deltaTime; - if (next > 0) { - updated.push(message); - updatedTimers[message.id] = next; - } - } else { - updated.push(message); - } - } - - for (const event of events) { - const patch = event.payload as GameMessage; - const existing = updated.find(m => m.id === patch.id); - if (!existing && !patch.title) continue; - - const base = existing ?? { id: patch.id, title: patch.title! }; - const merged: GameMessage = { ...base, ...patch }; - - const index = updated.findIndex(m => m.id === patch.id); - if (index >= 0) updated[index] = merged; - else updated.push(merged); - - if (patch.duration !== undefined) { - updatedTimers[patch.id] = patch.duration; - } - } - - stateComposer.setIn(PLUGIN_NAMESPACE, { - messages: updated, - timers: updatedTimers, - }); - - contextComposer.setIn(PLUGIN_NAMESPACE, { - sendMessage: (msg: GameMessage) => - Composer.build(ctx => - ctx.apply(ctx.from('core.events').dispatch, { - type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), - payload: msg, - }) - ), - }); - - return { - state: stateComposer.get(), - context: contextComposer.get(), - }; -}; +export const messagesPipe: Pipe = Composer.build(c => { + const { dispatch, handle } = c.get([ + 'context', + 'core', + 'events', + ]); + + return c + .bind( + ['context', 'deltaTime'], + delta => c => + c.over( + ['state', PLUGIN_NAMESPACE], + ({ messages = [], timers = {} }) => { + const updated: GameMessage[] = []; + const updatedTimers: Record = {}; + + for (const message of messages) { + const remaining = timers[message.id]; + if (remaining != null) { + const next = remaining - delta; + if (next > 0) { + updated.push(message); + updatedTimers[message.id] = next; + } + } else { + updated.push(message); + } + } + + return { messages: updated, timers: updatedTimers }; + } + ) + ) + + .set(['context', PLUGIN_NAMESPACE], { + sendMessage: (msg: PartialGameMessage) => + Composer.build(c => + c.apply(dispatch, { + type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), + payload: msg, + }) + ), + }) + + .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), event => + Composer.build(c => + c.over( + ['state', PLUGIN_NAMESPACE], + ({ messages = [], timers = {} }) => { + const patch = event.payload as GameMessage; + const existing = messages.find(m => m.id === patch.id); + if (!existing && !patch.title) return { messages, timers }; + + const base = existing ?? { id: patch.id, title: patch.title! }; + const merged: GameMessage = { ...base, ...patch }; + + const newMessages = [...messages]; + const index = newMessages.findIndex(m => m.id === patch.id); + if (index >= 0) newMessages[index] = merged; + else newMessages.push(merged); + + const newTimers = { ...timers }; + if (patch.duration !== undefined) { + newTimers[patch.id] = patch.duration; + } + + return { messages: newMessages, timers: newTimers }; + } + ) + ) + ); +}); diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 595d042..af4512c 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -1,13 +1,6 @@ -import { Composer, Transformer } from '../Composer'; -import { GameContext, Pipe } from '../State'; -import { - EventContext, - GameEvent, - getEventKey, - readEventKey, - dispatchEvent, - getEvents, -} from './Events'; +import { Composer } from '../Composer'; +import { Pipe, PipeTransformer } from '../State'; +import { EventContext, GameEvent, getEventKey } from './Events'; const PLUGIN_NAMESPACE = 'core.scheduler'; @@ -19,76 +12,82 @@ export type ScheduledEvent = { type SchedulerState = { scheduled: ScheduledEvent[]; + current: GameEvent[]; }; export type SchedulerContext = { - schedule: Transformer<[ScheduledEvent], GameContext>; - cancel: Transformer<[string], GameContext>; + schedule: PipeTransformer<[ScheduledEvent]>; + cancel: PipeTransformer<[string]>; }; -export const schedulerPipe: Pipe = ({ state, context }) => { - const deltaTime = context.deltaTime; - const stateComposer = new Composer(state); - - const { scheduled = [] } = - stateComposer.from(PLUGIN_NAMESPACE); - - const { events } = getEvents(context, getEventKey(PLUGIN_NAMESPACE, '*'), { - consume: true, - }); - - const updatedSchedule = [...scheduled]; - const toDispatch: GameEvent[] = []; - - for (const event of events) { - const key = readEventKey(event.type).key; - - if (key === 'schedule') { - updatedSchedule.push(event.payload); - } - - if (key === 'cancel') { - const id = event.payload; - const idx = updatedSchedule.findIndex(s => s.id === id); - if (idx !== -1) updatedSchedule.splice(idx, 1); - } - } - - const remaining: ScheduledEvent[] = []; - - for (const entry of updatedSchedule) { - const time = entry.duration - deltaTime; - if (time <= 0) { - toDispatch.push(entry.event); - } else { - remaining.push({ ...entry, duration: time }); - } - } - - stateComposer.setIn(PLUGIN_NAMESPACE, { scheduled: remaining }); - - const contextComposer = new Composer(context); - for (const a of toDispatch) contextComposer.apply(dispatchEvent, a); - - contextComposer.setIn(PLUGIN_NAMESPACE, { - schedule: (event: ScheduledEvent) => - Composer.build(ctx => - ctx.apply(ctx.from('core.events').dispatch, { - type: getEventKey(PLUGIN_NAMESPACE, 'schedule'), - payload: event, - }) - ), - cancel: (id: string) => - Composer.build(ctx => - ctx.apply(ctx.from('core.events').dispatch, { - type: getEventKey(PLUGIN_NAMESPACE, 'cancel'), - payload: id, - }) - ), - }); - - return { - state: stateComposer.get(), - context: contextComposer.get(), - }; -}; +export const schedulerPipe: Pipe = Composer.build(c => { + const { dispatch, handle } = c.get([ + 'context', + 'core', + 'events', + ]); + + return c + .bind( + ['context', 'deltaTime'], + delta => c => + c.over( + ['state', PLUGIN_NAMESPACE], + ({ scheduled = [] }) => { + const remaining: ScheduledEvent[] = []; + const current: GameEvent[] = []; + + for (const entry of scheduled) { + const time = entry.duration - delta; + if (time <= 0) { + current.push(entry.event); + } else { + remaining.push({ ...entry, duration: time }); + } + } + + return { scheduled: remaining, current }; + } + ) + ) + .bind( + ['state', PLUGIN_NAMESPACE, 'current'], + events => c => c.pipe(...events.map(dispatch)) + ) + + .set(['context', PLUGIN_NAMESPACE], { + schedule: (e: ScheduledEvent) => + Composer.build(c => + c.apply(dispatch, { + type: getEventKey(PLUGIN_NAMESPACE, 'schedule'), + payload: e, + }) + ), + + cancel: (id: string) => + Composer.build(c => + c.apply(dispatch, { + type: getEventKey(PLUGIN_NAMESPACE, 'cancel'), + payload: id, + }) + ), + }) + + .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'schedule'), event => + Composer.build(c => + c.over( + ['state', PLUGIN_NAMESPACE, 'scheduled'], + (list = []) => [...list, event.payload] + ) + ) + ) + + .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'cancel'), event => + Composer.build(c => + c.over( + ['state', PLUGIN_NAMESPACE, 'scheduled'], + (list = []) => list.filter(s => s.id !== event.payload) + ) + ) + ); +}); diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index c8c6c82..3652864 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -1,19 +1,11 @@ import styled from 'styled-components'; import { GameEngineProvider } from './GameProvider'; import { FpsDisplay } from './components/FpsDisplay'; -import { useCallback } from 'react'; -import { Pipe } from '../engine'; -import { - MessageContext, - messagesPipe, - PartialGameMessage, -} from '../engine/pipes/Messages'; +import { messagesPipe } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; -import { getEventKey, readEventKey, getEvents } from '../engine/pipes/Events'; import { fpsPipe } from '../engine/pipes/Fps'; -import { SchedulerContext } from '../engine/pipes/Scheduler'; -import { Composer } from '../engine/Composer'; +import { messageTestPipe } from './test'; const StyledGamePage = styled.div` position: relative; @@ -76,106 +68,6 @@ const StyledBottomBar = styled.div` `; export const GamePage = () => { - const messageTestPipe: Pipe = useCallback(({ context, state }) => { - const MSG_TEST_NAMESPACE = 'core.message_test'; - const messageId = 'test-message'; - - const stateComposer = new Composer(state); - const contextComposer = new Composer(context); - - const send = (msg: PartialGameMessage) => - contextComposer.apply( - contextComposer.from('core.messages').sendMessage, - msg - ); - - const { sent } = stateComposer.from<{ sent: boolean }>(MSG_TEST_NAMESPACE); - - if (!sent) { - send({ - id: messageId, - title: 'Test Message', - description: - 'This is a test message to demonstrate the message system.', - prompts: [ - { - title: 'Acknowledge', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), - }, - }, - { - title: 'Dismiss', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - }, - }, - ], - }); - } - - const { events } = getEvents( - contextComposer.get(), - getEventKey(MSG_TEST_NAMESPACE, '*') - ); - - for (const event of events) { - const key = readEventKey(event.type).key; - - if (key === 'acknowledgeMessage') { - const { schedule } = - contextComposer.from('core.scheduler'); - - contextComposer.apply(schedule, { - duration: 2000, - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), - }, - }); - - send({ - id: messageId, - duration: 0, - }); - } - - if (key === 'dismissMessage') { - send({ - id: messageId, - duration: 0, - }); - - send({ - id: 'followup-message', - duration: 0, - }); - } - - if (key === 'followupMessage') { - send({ - id: 'followup-message', - title: 'Follow-up Message', - description: - 'This is a follow-up message after acknowledging the test message.', - prompts: [ - { - title: 'Close', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - payload: { id: 'followup-message' }, - }, - }, - ], - }); - } - } - - return { - state: stateComposer.setIn(MSG_TEST_NAMESPACE, { sent: true }).get(), - context: contextComposer.get(), - }; - }, []); - return ( diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 44d09b4..7a33238 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -9,6 +9,8 @@ import { import { GameEngine, GameState, Pipe, GameContext } from '../engine'; import { eventPipe } from '../engine/pipes/Events'; import { schedulerPipe } from '../engine/pipes/Scheduler'; +import { Piper } from '../engine/Piper'; +import { Composer } from '../engine/Composer'; type GameEngineContextValue = { /** @@ -16,7 +18,7 @@ type GameEngineContextValue = { */ state: GameState | null; /** - * The current game context, which can be used to pass additional data to pipes. + * The current game context which contains inter-pipe data and debugging information. */ context: GameContext | null; /** @@ -32,9 +34,9 @@ type GameEngineContextValue = { */ isRunning: boolean; /** - * Inject additional context into the game engine. Resets after each tick. + * Queue a one-shot pipe to run in the next tick only. */ - injectContext: (patch: Partial) => void; + injectImpulse: (pipe: Pipe) => void; }; const GameEngineContext = createContext( @@ -51,47 +53,35 @@ export function useGameEngine() { type Props = { children: ReactNode; pipes?: Pipe[]; - useBuildGameContext?: () => Partial; }; -export function GameEngineProvider({ - children, - pipes = [], - useBuildGameContext, -}: Props) { +export function GameEngineProvider({ children, pipes = [] }: Props) { const engineRef = useRef(null); - const baseContextRef = useRef>({}); - const patchRef = useRef>({}); - const finalContextRef = useRef>({}); + const runningRef = useRef(true); + const lastTimeRef = useRef(null); const [state, setState] = useState(null); const [context, setContext] = useState(null); - const [isRunning, setIsRunning] = useState(true); - const runningRef = useRef(true); - const lastTimeRef = useRef(null); + const [isRunning, setIsRunning] = useState(runningRef.current); + + const pendingImpulseRef = useRef([]); + const activeImpulseRef = useRef([]); - baseContextRef.current = useBuildGameContext?.() || {}; useEffect(() => { - // To inject our custom context into the engine, we create this side-loading pipe. - // This is idiomatic, because it does not require changing the game engine's internals. - const contextInjector: Pipe = ({ state, context }) => { - return { - state, - context: { - ...context, - ...finalContextRef.current, - }, - }; - }; + runningRef.current = isRunning; + }, [isRunning]); - engineRef.current = new GameEngine({}, [ - contextInjector, - eventPipe, - schedulerPipe, - ...pipes, - ]); - setState(engineRef.current.getState()); - setContext(engineRef.current.getContext()); + useEffect(() => { + // To inject one-shot pipes (impulses) into the engine, + // we use the pending ref to stage them, and the active ref to apply them. + const impulsePipe: Pipe = Composer.build(c => + c.pipe(...activeImpulseRef.current) + ); + + engineRef.current = new GameEngine( + {}, + Piper([impulsePipe, eventPipe, schedulerPipe, ...pipes]) + ); let frameId: number; @@ -109,12 +99,9 @@ export function GameEngineProvider({ const deltaTime = time - lastTimeRef.current; lastTimeRef.current = time; - finalContextRef.current = { - ...baseContextRef.current, - ...patchRef.current, - }; - - patchRef.current = {}; + // activate pending impulses + activeImpulseRef.current = pendingImpulseRef.current; + pendingImpulseRef.current = []; engineRef.current.tick(deltaTime); @@ -131,23 +118,17 @@ export function GameEngineProvider({ }; }, [pipes]); - const pause = () => { - runningRef.current = false; - setIsRunning(false); - }; + const pause = () => setIsRunning(false); - const resume = () => { - runningRef.current = true; - setIsRunning(true); - }; + const resume = () => setIsRunning(true); - const injectContext = (patch: Partial) => { - patchRef.current = { ...patchRef.current, ...patch }; + const injectImpulse = (pipe: Pipe) => { + pendingImpulseRef.current.push(pipe); }; return ( {children} diff --git a/src/game/hooks/UseDispatchEvent.tsx b/src/game/hooks/UseDispatchEvent.tsx index 966dddc..57ccc93 100644 --- a/src/game/hooks/UseDispatchEvent.tsx +++ b/src/game/hooks/UseDispatchEvent.tsx @@ -1,16 +1,16 @@ import { useMemo } from 'react'; -import { createEventContext, GameEvent } from '../../engine/pipes/Events'; +import { dispatchEvent, GameEvent } from '../../engine/pipes/Events'; import { useGameEngine } from '../GameProvider'; export function useDispatchEvent() { - const { injectContext } = useGameEngine(); + const { injectImpulse } = useGameEngine(); return useMemo( () => ({ dispatchEvent: (event: GameEvent) => { - injectContext(createEventContext(event)); + injectImpulse(dispatchEvent(event)); }, }), - [injectContext] + [injectImpulse] ); } diff --git a/src/game/hooks/UseGameValue.tsx b/src/game/hooks/UseGameValue.tsx index c4228a0..d8c57de 100644 --- a/src/game/hooks/UseGameValue.tsx +++ b/src/game/hooks/UseGameValue.tsx @@ -1,10 +1,11 @@ import { Composer } from '../../engine/Composer'; +import { Path } from '../../engine/Lens'; import { useGameEngine } from '../GameProvider'; -export const useGameValue = (path: string): T => { +export const useGameValue = (path: Path): T => { const { state } = useGameEngine(); if (!state) { return {} as T; } - return new Composer(state).from(path) as T; + return new Composer(state).get(path) ?? ({} as T); }; diff --git a/src/game/test.ts b/src/game/test.ts new file mode 100644 index 0000000..cff13a1 --- /dev/null +++ b/src/game/test.ts @@ -0,0 +1,102 @@ +import { Pipe } from '../engine'; +import { Composer } from '../engine/Composer'; +import { EventContext, getEventKey } from '../engine/pipes/Events'; +import { MessageContext } from '../engine/pipes/Messages'; +import { SchedulerContext } from '../engine/pipes/Scheduler'; + +const MSG_TEST_NAMESPACE = 'core.message_test'; +const messageId = 'test-message'; +const followupId = 'followup-message'; + +export const messageTestPipe: Pipe = Composer.build(c => { + const { sendMessage } = c.get([ + 'context', + 'core', + 'messages', + ]); + const { handle } = c.get(['context', 'core', 'events']); + const { schedule } = c.get([ + 'context', + 'core', + 'scheduler', + ]); + + return c + .bind<{ sent: boolean }>( + ['state', MSG_TEST_NAMESPACE], + ({ sent = false }) => + c => + c.unless(sent, c => + c + .apply(sendMessage, { + id: messageId, + title: 'Test Message', + description: + 'This is a test message to demonstrate the message system.', + prompts: [ + { + title: 'Acknowledge', + event: { + type: getEventKey( + MSG_TEST_NAMESPACE, + 'acknowledgeMessage' + ), + }, + }, + { + title: 'Dismiss', + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + }, + }, + ], + }) + + .set(['state', MSG_TEST_NAMESPACE, 'sent'], true) + ) + ) + + .apply(handle, getEventKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), () => + Composer.build(c => + c + .apply(schedule, { + duration: 2000, + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), + }, + }) + .apply(sendMessage, { + id: messageId, + duration: 0, + }) + ) + ) + + .apply(handle, getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), () => + Composer.build(c => + c + .apply(sendMessage, { id: messageId, duration: 0 }) + .apply(sendMessage, { id: followupId, duration: 0 }) + ) + ) + + .apply(handle, getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), () => + Composer.build(c => + c.apply(sendMessage, { + id: followupId, + title: 'Follow-up Message', + description: + 'This is a follow-up message after acknowledging the test message.', + prompts: [ + { + title: 'Close', + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + payload: { id: followupId }, + }, + }, + ], + }) + ) + ); +}); From d23fa0bfa04968efcaf8cc3a4992488590bf7535 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 27 Jun 2025 23:01:17 +0200 Subject: [PATCH 12/90] rewrote messages to use scheduling --- src/engine/pipes/Messages.ts | 98 ++++++++++++++++++----------------- src/engine/pipes/Scheduler.ts | 5 +- 2 files changed, 54 insertions(+), 49 deletions(-) diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index 46025ed..d8ba97c 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransformer } from '../State'; import { Composer } from '../Composer'; import { EventContext, getEventKey, GameEvent } from './Events'; +import { SchedulerContext } from './Scheduler'; export interface GameMessagePrompt { title: string; @@ -25,7 +26,6 @@ export type MessageContext = { export type MessageState = { messages: GameMessage[]; - timers: Record; }; export const messagesPipe: Pipe = Composer.build(c => { @@ -36,33 +36,6 @@ export const messagesPipe: Pipe = Composer.build(c => { ]); return c - .bind( - ['context', 'deltaTime'], - delta => c => - c.over( - ['state', PLUGIN_NAMESPACE], - ({ messages = [], timers = {} }) => { - const updated: GameMessage[] = []; - const updatedTimers: Record = {}; - - for (const message of messages) { - const remaining = timers[message.id]; - if (remaining != null) { - const next = remaining - delta; - if (next > 0) { - updated.push(message); - updatedTimers[message.id] = next; - } - } else { - updated.push(message); - } - } - - return { messages: updated, timers: updatedTimers }; - } - ) - ) - .set(['context', PLUGIN_NAMESPACE], { sendMessage: (msg: PartialGameMessage) => Composer.build(c => @@ -74,29 +47,58 @@ export const messagesPipe: Pipe = Composer.build(c => { }) .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), event => + Composer.build(c => { + const messageId = (event.payload as GameMessage).id; + const { schedule, cancel } = c.get([ + 'context', + 'core.scheduler', + ]); + + return c + .over( + ['state', PLUGIN_NAMESPACE], + ({ messages = [] }) => { + const existing = messages.find(m => m.id === messageId); + const patch = event.payload as GameMessage; + return { + messages: [ + ...messages.filter(m => m.id !== messageId), + ...(existing || patch.title + ? [{ ...existing, ...patch }] + : []), + ], + }; + } + ) + + .bind( + ['state', PLUGIN_NAMESPACE], + ({ messages = [] }) => + c => { + const updated = messages.find(m => m.id === messageId); + const scheduleId = `${PLUGIN_NAMESPACE}.message.${messageId}`; + return updated?.duration !== undefined + ? c.apply(schedule, { + id: scheduleId, + duration: updated!.duration!, + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), + payload: updated.id, + }, + }) + : c.apply(cancel, scheduleId); + } + ); + }) + ) + + .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), event => Composer.build(c => c.over( ['state', PLUGIN_NAMESPACE], - ({ messages = [], timers = {} }) => { - const patch = event.payload as GameMessage; - const existing = messages.find(m => m.id === patch.id); - if (!existing && !patch.title) return { messages, timers }; - - const base = existing ?? { id: patch.id, title: patch.title! }; - const merged: GameMessage = { ...base, ...patch }; - - const newMessages = [...messages]; - const index = newMessages.findIndex(m => m.id === patch.id); - if (index >= 0) newMessages[index] = merged; - else newMessages.push(merged); - - const newTimers = { ...timers }; - if (patch.duration !== undefined) { - newTimers[patch.id] = patch.duration; - } - - return { messages: newMessages, timers: newTimers }; - } + ({ messages = [] }) => ({ + messages: messages.filter(m => m.id !== event.payload), + }) ) ) ); diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index af4512c..57b3685 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -77,7 +77,10 @@ export const schedulerPipe: Pipe = Composer.build(c => { Composer.build(c => c.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => [...list, event.payload] + (list = []) => [ + ...list.filter(e => e.id !== event.payload.id), + event.payload, + ] ) ) ) From 34dd49d7345382c318c50851e6dfa4ecc53f6a62 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 27 Jun 2025 23:10:33 +0200 Subject: [PATCH 13/90] fixed updating messages preserving index --- src/engine/pipes/Messages.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index d8ba97c..e77adf8 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -58,16 +58,19 @@ export const messagesPipe: Pipe = Composer.build(c => { .over( ['state', PLUGIN_NAMESPACE], ({ messages = [] }) => { - const existing = messages.find(m => m.id === messageId); const patch = event.payload as GameMessage; - return { - messages: [ - ...messages.filter(m => m.id !== messageId), - ...(existing || patch.title - ? [{ ...existing, ...patch }] - : []), - ], + const index = messages.findIndex(m => m.id === patch.id); + const existing = messages[index]; + + if (!existing && !patch.title) return { messages }; + + const updated = [...messages]; + updated[index < 0 ? updated.length : index] = { + ...existing, + ...patch, }; + + return { messages: updated }; } ) From cd5a04dfe1d6d0ce2079e83468824e645c9f2d73 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 28 Jun 2025 00:01:54 +0200 Subject: [PATCH 14/90] added composer shortcuts for simple pipes --- src/engine/Composer.ts | 26 ++++++++++++++++++++++++++ src/engine/pipes/Events.ts | 12 +++++------- src/engine/pipes/Messages.ts | 22 +++++++++------------- src/game/test.ts | 30 ++++++++++++++---------------- 4 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index 67d5996..afb46b0 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -55,6 +55,16 @@ export class Composer { return this; } + /** + * Shorthand for building a composer that applies a transformer. + */ + static apply( + tool: Transformer, + ...args: TArgs + ) { + return (obj: T): T => tool(...args)(obj); + } + /** * Applies a series of mapping functions to the current object. */ @@ -96,6 +106,14 @@ export class Composer { return this; } + /** + * Shorthand for building a composer that sets a path. + */ + static set(path: Path, value: A) { + return (obj: T): T => + lensFromPath(path).set(value)(obj); + } + /** * Runs a composer on a sub-object at the specified path, * then updates the original composer and returns it. @@ -119,6 +137,14 @@ export class Composer { return this; } + /** + * Shorthand for building a composer that updates a path. + */ + static over(path: Path, fn: (a: A) => A) { + return (obj: T): T => + lensFromPath(path).over(fn)(obj); + } + /** * Runs a composer function with the value at the specified path. */ diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 651d204..aec5080 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -36,18 +36,16 @@ export const readEventKey = ( }; export const dispatchEvent: PipeTransformer<[GameEvent]> = event => - Composer.build(frame => - frame.over( - `state.${PLUGIN_NAMESPACE}.pending`, - (pending = []) => [...pending, event] - ) + Composer.over( + `state.${PLUGIN_NAMESPACE}.pending`, + (pending = []) => [...pending, event] ); export const handleEvent: PipeTransformer< [string, (event: GameEvent) => Pipe] > = (type, fn) => - Composer.build(frame => - frame.bind( + Composer.build(c => + c.bind( `state.${PLUGIN_NAMESPACE}`, ({ current = [] }) => c => diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index e77adf8..b76f3bd 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -38,12 +38,10 @@ export const messagesPipe: Pipe = Composer.build(c => { return c .set(['context', PLUGIN_NAMESPACE], { sendMessage: (msg: PartialGameMessage) => - Composer.build(c => - c.apply(dispatch, { - type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), - payload: msg, - }) - ), + Composer.apply(dispatch, { + type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), + payload: msg, + }), }) .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), event => @@ -96,13 +94,11 @@ export const messagesPipe: Pipe = Composer.build(c => { ) .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), event => - Composer.build(c => - c.over( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => ({ - messages: messages.filter(m => m.id !== event.payload), - }) - ) + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ messages = [] }) => ({ + messages: messages.filter(m => m.id !== event.payload), + }) ) ); }); diff --git a/src/game/test.ts b/src/game/test.ts index cff13a1..8cbdb32 100644 --- a/src/game/test.ts +++ b/src/game/test.ts @@ -81,22 +81,20 @@ export const messageTestPipe: Pipe = Composer.build(c => { ) .apply(handle, getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), () => - Composer.build(c => - c.apply(sendMessage, { - id: followupId, - title: 'Follow-up Message', - description: - 'This is a follow-up message after acknowledging the test message.', - prompts: [ - { - title: 'Close', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - payload: { id: followupId }, - }, + Composer.apply(sendMessage, { + id: followupId, + title: 'Follow-up Message', + description: + 'This is a follow-up message after acknowledging the test message.', + prompts: [ + { + title: 'Close', + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + payload: { id: followupId }, }, - ], - }) - ) + }, + ], + }) ); }); From 046e2d3225403003185891f98de966190d32e115 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 28 Jun 2025 21:14:48 +0200 Subject: [PATCH 15/90] removed apply in favour of pipe --- src/engine/Composer.ts | 37 ++++------- src/engine/pipes/Messages.ts | 117 +++++++++++++++++++--------------- src/engine/pipes/Scheduler.ts | 20 +++--- src/game/test.ts | 104 ++++++++++++++++-------------- 4 files changed, 141 insertions(+), 137 deletions(-) diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index afb46b0..542ac13 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -4,7 +4,7 @@ import { lensFromPath, Path } from './Lens'; * A curried function that that maps an object from T to T, * given a set of arguments. */ -export type Transformer = ( +export type Transformer = ( ...args: TArgs ) => (obj: TObj) => TObj; @@ -44,33 +44,18 @@ export class Composer { } /** - * Applies a transformer function to the current object, - * passing the provided arguments. + * Applies a series of mapping functions to the current object. */ - apply( - tool: Transformer, - ...args: TArgs - ): this { - this.obj = tool(...args)(this.obj); + pipe(...pipes: ((t: T) => T)[]): this { + for (const p of pipes) this.obj = p(this.obj); return this; } /** - * Shorthand for building a composer that applies a transformer. - */ - static apply( - tool: Transformer, - ...args: TArgs - ) { - return (obj: T): T => tool(...args)(obj); - } - - /** - * Applies a series of mapping functions to the current object. + * Shorthand for building a composer that applies a series of mapping functions to the current object. */ - pipe(...pipes: ((t: T) => T)[]): this { - for (const p of pipes) this.obj = p(this.obj); - return this; + static pipe(...pipes: ((t: T) => T)[]): (obj: T) => T { + return Composer.build(composer => composer.pipe(...pipes)); } /** @@ -95,11 +80,11 @@ export class Composer { */ set(path: Path, value: A): this; - set(pathOrValue: Path | T, maybeValue?: any): this { + set(pathOrValue: Path | T, maybeValue?: unknown): this { if (maybeValue === undefined) { this.obj = pathOrValue as T; } else { - this.obj = lensFromPath(pathOrValue as Path).set(maybeValue)( + this.obj = lensFromPath(pathOrValue as Path).set(maybeValue)( this.obj ); } @@ -111,7 +96,7 @@ export class Composer { */ static set(path: Path, value: A) { return (obj: T): T => - lensFromPath(path).set(value)(obj); + Composer.build(c => c.set(path, value))(obj); } /** @@ -142,7 +127,7 @@ export class Composer { */ static over(path: Path, fn: (a: A) => A) { return (obj: T): T => - lensFromPath(path).over(fn)(obj); + Composer.build(c => c.over(path, fn))(obj); } /** diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index b76f3bd..88bfefb 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -37,68 +37,79 @@ export const messagesPipe: Pipe = Composer.build(c => { return c .set(['context', PLUGIN_NAMESPACE], { - sendMessage: (msg: PartialGameMessage) => - Composer.apply(dispatch, { - type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), - payload: msg, - }), + sendMessage: msg => + Composer.pipe( + dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), + payload: msg, + }) + ), }) - .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), event => - Composer.build(c => { - const messageId = (event.payload as GameMessage).id; - const { schedule, cancel } = c.get([ - 'context', - 'core.scheduler', - ]); + .pipe( + handle(getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), event => + Composer.build(c => { + const messageId = (event.payload as GameMessage).id; + const { schedule, cancel } = c.get([ + 'context', + 'core.scheduler', + ]); - return c - .over( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => { - const patch = event.payload as GameMessage; - const index = messages.findIndex(m => m.id === patch.id); - const existing = messages[index]; + return c + .over( + ['state', PLUGIN_NAMESPACE], + ({ messages = [] }) => { + const patch = event.payload as GameMessage; + const index = messages.findIndex(m => m.id === patch.id); + const existing = messages[index]; - if (!existing && !patch.title) return { messages }; + if (!existing && !patch.title) return { messages }; - const updated = [...messages]; - updated[index < 0 ? updated.length : index] = { - ...existing, - ...patch, - }; + const updated = [...messages]; + updated[index < 0 ? updated.length : index] = { + ...existing, + ...patch, + }; - return { messages: updated }; - } - ) - - .bind( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => - c => { - const updated = messages.find(m => m.id === messageId); - const scheduleId = `${PLUGIN_NAMESPACE}.message.${messageId}`; - return updated?.duration !== undefined - ? c.apply(schedule, { - id: scheduleId, - duration: updated!.duration!, - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), - payload: updated.id, - }, - }) - : c.apply(cancel, scheduleId); + return { messages: updated }; } - ); - }) - ) + ) - .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), event => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => ({ - messages: messages.filter(m => m.id !== event.payload), + .bind( + ['state', PLUGIN_NAMESPACE], + ({ messages = [] }) => + c => { + const updated = messages.find(m => m.id === messageId); + const scheduleId = `${PLUGIN_NAMESPACE}.message.${messageId}`; + return c.pipe( + updated?.duration !== undefined + ? schedule({ + id: scheduleId, + duration: updated!.duration!, + event: { + type: getEventKey( + PLUGIN_NAMESPACE, + 'expireMessage' + ), + payload: updated.id, + }, + }) + : cancel(scheduleId) + ); + } + ); }) ) + ) + + .pipe( + handle(getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ messages = [] }) => ({ + messages: messages.filter(m => m.id !== event.payload), + }) + ) + ) ); }); diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 57b3685..6c747c7 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -57,25 +57,25 @@ export const schedulerPipe: Pipe = Composer.build(c => { .set(['context', PLUGIN_NAMESPACE], { schedule: (e: ScheduledEvent) => - Composer.build(c => - c.apply(dispatch, { + Composer.pipe( + dispatch({ type: getEventKey(PLUGIN_NAMESPACE, 'schedule'), payload: e, }) ), cancel: (id: string) => - Composer.build(c => - c.apply(dispatch, { + Composer.pipe( + dispatch({ type: getEventKey(PLUGIN_NAMESPACE, 'cancel'), payload: id, }) ), }) - .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'schedule'), event => - Composer.build(c => - c.over( + .pipe( + handle(getEventKey(PLUGIN_NAMESPACE, 'schedule'), event => + Composer.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => [ ...list.filter(e => e.id !== event.payload.id), @@ -85,9 +85,9 @@ export const schedulerPipe: Pipe = Composer.build(c => { ) ) - .apply(handle, getEventKey(PLUGIN_NAMESPACE, 'cancel'), event => - Composer.build(c => - c.over( + .pipe( + handle(getEventKey(PLUGIN_NAMESPACE, 'cancel'), event => + Composer.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => list.filter(s => s.id !== event.payload) ) diff --git a/src/game/test.ts b/src/game/test.ts index 8cbdb32..bf937d6 100644 --- a/src/game/test.ts +++ b/src/game/test.ts @@ -28,73 +28,81 @@ export const messageTestPipe: Pipe = Composer.build(c => { c => c.unless(sent, c => c - .apply(sendMessage, { - id: messageId, - title: 'Test Message', - description: - 'This is a test message to demonstrate the message system.', - prompts: [ - { - title: 'Acknowledge', - event: { - type: getEventKey( - MSG_TEST_NAMESPACE, - 'acknowledgeMessage' - ), + .pipe( + sendMessage({ + id: messageId, + title: 'Test Message', + description: + 'This is a test message to demonstrate the message system.', + prompts: [ + { + title: 'Acknowledge', + event: { + type: getEventKey( + MSG_TEST_NAMESPACE, + 'acknowledgeMessage' + ), + }, }, - }, - { - title: 'Dismiss', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + { + title: 'Dismiss', + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + }, }, - }, - ], - }) + ], + }) + ) .set(['state', MSG_TEST_NAMESPACE, 'sent'], true) ) ) - .apply(handle, getEventKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), () => - Composer.build(c => - c - .apply(schedule, { + .pipe( + handle(getEventKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), () => + Composer.pipe( + schedule({ duration: 2000, event: { type: getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), }, - }) - .apply(sendMessage, { + }), + sendMessage({ id: messageId, duration: 0, }) + ) ) ) - .apply(handle, getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), () => - Composer.build(c => - c - .apply(sendMessage, { id: messageId, duration: 0 }) - .apply(sendMessage, { id: followupId, duration: 0 }) + .pipe( + handle(getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), () => + Composer.pipe( + sendMessage({ id: messageId, duration: 0 }), + sendMessage({ id: followupId, duration: 0 }) + ) ) ) - .apply(handle, getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), () => - Composer.apply(sendMessage, { - id: followupId, - title: 'Follow-up Message', - description: - 'This is a follow-up message after acknowledging the test message.', - prompts: [ - { - title: 'Close', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - payload: { id: followupId }, - }, - }, - ], - }) + .pipe( + handle(getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), () => + Composer.pipe( + sendMessage({ + id: followupId, + title: 'Follow-up Message', + description: + 'This is a follow-up message after acknowledging the test message.', + prompts: [ + { + title: 'Close', + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + payload: { id: followupId }, + }, + }, + ], + }) + ) + ) ); }); From 7ce301f161ab87549bbd7e8909cd385975575e98 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 28 Jun 2025 21:41:10 +0200 Subject: [PATCH 16/90] restored message ui --- src/game/test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/game/test.ts b/src/game/test.ts index bf937d6..02c216b 100644 --- a/src/game/test.ts +++ b/src/game/test.ts @@ -32,8 +32,6 @@ export const messageTestPipe: Pipe = Composer.build(c => { sendMessage({ id: messageId, title: 'Test Message', - description: - 'This is a test message to demonstrate the message system.', prompts: [ { title: 'Acknowledge', @@ -90,8 +88,7 @@ export const messageTestPipe: Pipe = Composer.build(c => { sendMessage({ id: followupId, title: 'Follow-up Message', - description: - 'This is a follow-up message after acknowledging the test message.', + description: 'Ready', prompts: [ { title: 'Close', From b8928525ef55ca5011d47c7ba5ca2e1aceb61b1c Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 28 Jun 2025 21:52:55 +0200 Subject: [PATCH 17/90] added use game context --- src/game/hooks/UseGameContext.tsx | 11 +++++++++++ src/game/hooks/index.ts | 3 +++ 2 files changed, 14 insertions(+) create mode 100644 src/game/hooks/UseGameContext.tsx create mode 100644 src/game/hooks/index.ts diff --git a/src/game/hooks/UseGameContext.tsx b/src/game/hooks/UseGameContext.tsx new file mode 100644 index 0000000..85b6107 --- /dev/null +++ b/src/game/hooks/UseGameContext.tsx @@ -0,0 +1,11 @@ +import { Composer } from '../../engine/Composer'; +import { Path } from '../../engine/Lens'; +import { useGameEngine } from '../GameProvider'; + +export const useGameContext = (path: Path): T => { + const { context } = useGameEngine(); + if (!context) { + return {} as T; + } + return new Composer(context).get(path) ?? ({} as T); +}; diff --git a/src/game/hooks/index.ts b/src/game/hooks/index.ts new file mode 100644 index 0000000..a760aae --- /dev/null +++ b/src/game/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './UseDispatchEvent'; +export * from './UseGameContext'; +export * from './UseGameValue'; From a0d2c05bf3bf6103d1eb2e679bb8ac0541d6eb49 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 29 Jun 2025 10:33:04 +0200 Subject: [PATCH 18/90] improved composer.bind ease of use --- src/engine/Composer.ts | 19 ++++++++++------- src/engine/index.ts | 4 ++++ src/engine/pipes/Events.ts | 25 ++++++++-------------- src/engine/pipes/Fps.ts | 10 ++++----- src/engine/pipes/Messages.ts | 4 ++-- src/engine/pipes/Scheduler.ts | 39 ++++++++++++++++------------------- src/game/GamePage.tsx | 7 ++++++- src/game/pipes/Settings.ts | 16 ++++++++++++++ src/game/pipes/index.ts | 1 + src/game/test.ts | 3 ++- 10 files changed, 74 insertions(+), 54 deletions(-) create mode 100644 src/game/pipes/Settings.ts create mode 100644 src/game/pipes/index.ts diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index 542ac13..a0d1bfa 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -133,13 +133,18 @@ export class Composer { /** * Runs a composer function with the value at the specified path. */ - bind( - path: Path, - fn: (value: A) => (composer: Composer) => Composer - ): Composer { - const lens = lensFromPath(path); - const value = lens.get(this.obj) ?? ({} as A); - return fn(value)(this); + bind(path: Path, fn: Transformer<[A], T>): this { + const value = lensFromPath(path).get(this.obj) ?? ({} as A); + this.obj = fn(value)(this.obj); + return this; + } + + /** + * Shorthand for building a composer that reads a value at a path and applies a transformer. + */ + static bind(path: Path, fn: Transformer<[A], any>) { + return (obj: T): T => + Composer.build(c => c.bind(path, fn))(obj); } /** diff --git a/src/engine/index.ts b/src/engine/index.ts index bfc21fa..d220f9a 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,3 +1,7 @@ +export * from './pipes'; +export * from './Composer'; export * from './Engine'; +export * from './Lens'; +export * from './Piper'; export * from './Random'; export * from './State'; diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index aec5080..7de0c4d 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -44,22 +44,15 @@ export const dispatchEvent: PipeTransformer<[GameEvent]> = event => export const handleEvent: PipeTransformer< [string, (event: GameEvent) => Pipe] > = (type, fn) => - Composer.build(c => - c.bind( - `state.${PLUGIN_NAMESPACE}`, - ({ current = [] }) => - c => - c.pipe( - ...current - .filter(event => { - const { namespace: ns, key: k } = readEventKey(event.type); - const { namespace, key } = readEventKey(type); - return key === '*' - ? ns === namespace - : ns === namespace && k === key; - }) - .map(fn) - ) + Composer.bind(`state.${PLUGIN_NAMESPACE}`, ({ current = [] }) => + Composer.pipe( + ...current + .filter(event => { + const { namespace: ns, key: k } = readEventKey(event.type); + const { namespace, key } = readEventKey(type); + return key === '*' ? ns === namespace : ns === namespace && k === key; + }) + .map(fn) ) ); diff --git a/src/engine/pipes/Fps.ts b/src/engine/pipes/Fps.ts index bdc0d3e..7d861df 100644 --- a/src/engine/pipes/Fps.ts +++ b/src/engine/pipes/Fps.ts @@ -1,10 +1,8 @@ import { Pipe } from '../State'; import { Composer } from '../Composer'; -export const fpsPipe: Pipe = Composer.build(c => - c.bind( - ['context', 'deltaTime'], - delta => c => - c.set(['context', 'core', 'fps'], delta > 0 ? 1000 / delta : 0) - ) +export const fpsPipe: Pipe = Composer.bind( + ['context', 'deltaTime'], + delta => + Composer.set(['context', 'core', 'fps'], delta > 0 ? 1000 / delta : 0) ); diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index 88bfefb..e2f193f 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -78,7 +78,7 @@ export const messagesPipe: Pipe = Composer.build(c => { .bind( ['state', PLUGIN_NAMESPACE], ({ messages = [] }) => - c => { + Composer.build(c => { const updated = messages.find(m => m.id === messageId); const scheduleId = `${PLUGIN_NAMESPACE}.message.${messageId}`; return c.pipe( @@ -96,7 +96,7 @@ export const messagesPipe: Pipe = Composer.build(c => { }) : cancel(scheduleId) ); - } + }) ); }) ) diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 6c747c7..f5068ba 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -28,31 +28,28 @@ export const schedulerPipe: Pipe = Composer.build(c => { ]); return c - .bind( - ['context', 'deltaTime'], - delta => c => - c.over( - ['state', PLUGIN_NAMESPACE], - ({ scheduled = [] }) => { - const remaining: ScheduledEvent[] = []; - const current: GameEvent[] = []; + .bind(['context', 'deltaTime'], delta => + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ scheduled = [] }) => { + const remaining: ScheduledEvent[] = []; + const current: GameEvent[] = []; - for (const entry of scheduled) { - const time = entry.duration - delta; - if (time <= 0) { - current.push(entry.event); - } else { - remaining.push({ ...entry, duration: time }); - } + for (const entry of scheduled) { + const time = entry.duration - delta; + if (time <= 0) { + current.push(entry.event); + } else { + remaining.push({ ...entry, duration: time }); } - - return { scheduled: remaining, current }; } - ) + + return { scheduled: remaining, current }; + } + ) ) - .bind( - ['state', PLUGIN_NAMESPACE, 'current'], - events => c => c.pipe(...events.map(dispatch)) + .bind(['state', PLUGIN_NAMESPACE, 'current'], events => + Composer.pipe(...events.map(dispatch)) ) .set(['context', PLUGIN_NAMESPACE], { diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 3652864..1146221 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -6,6 +6,7 @@ import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; import { fpsPipe } from '../engine/pipes/Fps'; import { messageTestPipe } from './test'; +import { useSettingsPipe } from './pipes'; const StyledGamePage = styled.div` position: relative; @@ -68,8 +69,12 @@ const StyledBottomBar = styled.div` `; export const GamePage = () => { + const settingsPipe = useSettingsPipe(); + return ( - + diff --git a/src/game/pipes/Settings.ts b/src/game/pipes/Settings.ts new file mode 100644 index 0000000..8dd1b70 --- /dev/null +++ b/src/game/pipes/Settings.ts @@ -0,0 +1,16 @@ +import { useEffect, useRef } from 'react'; +import { useSettings } from '../../settings'; +import { Composer, Pipe } from '../../engine'; + +export const useSettingsPipe = (): Pipe => { + const [settings] = useSettings(); + const ref = useRef(settings); + + useEffect(() => { + if (ref.current !== settings) { + ref.current = settings; + } + }, [settings]); + + return Composer.set(['context', 'settings'], ref.current); +}; diff --git a/src/game/pipes/index.ts b/src/game/pipes/index.ts new file mode 100644 index 0000000..90e2697 --- /dev/null +++ b/src/game/pipes/index.ts @@ -0,0 +1 @@ +export * from './Settings'; diff --git a/src/game/test.ts b/src/game/test.ts index 02c216b..005cd51 100644 --- a/src/game/test.ts +++ b/src/game/test.ts @@ -25,7 +25,7 @@ export const messageTestPipe: Pipe = Composer.build(c => { .bind<{ sent: boolean }>( ['state', MSG_TEST_NAMESPACE], ({ sent = false }) => - c => + Composer.build(c => c.unless(sent, c => c .pipe( @@ -54,6 +54,7 @@ export const messageTestPipe: Pipe = Composer.build(c => { .set(['state', MSG_TEST_NAMESPACE, 'sent'], true) ) + ) ) .pipe( From 528726ea822ff4af74b010b3b462525a2f02c0b7 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 29 Jun 2025 11:04:39 +0200 Subject: [PATCH 19/90] improved composer.chain ease of use --- src/engine/Composer.ts | 67 ++++++++++++++++++++++------------- src/engine/Piper.ts | 2 +- src/engine/pipes/Events.ts | 2 +- src/engine/pipes/Messages.ts | 6 ++-- src/engine/pipes/Scheduler.ts | 2 +- src/game/GameProvider.tsx | 2 +- src/game/test.ts | 52 +++++++++++++-------------- 7 files changed, 75 insertions(+), 58 deletions(-) diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index a0d1bfa..4818ade 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -8,6 +8,10 @@ export type Transformer = ( ...args: TArgs ) => (obj: TObj) => TObj; +export type Compositor = ( + composer: Composer +) => Composer; + /** * A generalized object manipulation utility * in a functional chaining style. @@ -20,27 +24,17 @@ export class Composer { } /** - * A shortcut to easily build a mapping function for an object, - * using the created composer and returning the modified object. - */ - static build( - fn: (composer: Composer) => Composer - ): (obj: T) => T { - return (obj: T) => fn(new Composer(obj)).get(); - } - - /** - * Modifies the object, then returns it in a new Composer instance. + * Runs a composer function. */ - map(fn: (obj: T) => T): Composer { - return new Composer(fn(this.obj)); + chain(fn: (composer: this) => this): this { + return fn(this); } /** - * Runs a composer function. + * Shorthand for building a composer that runs a function. */ - chain(fn: (composer: this) => this): this { - return fn(this); + static chain(fn: Compositor): (obj: T) => T { + return (obj: T) => fn(new Composer(obj)).get(); } /** @@ -55,7 +49,7 @@ export class Composer { * Shorthand for building a composer that applies a series of mapping functions to the current object. */ static pipe(...pipes: ((t: T) => T)[]): (obj: T) => T { - return Composer.build(composer => composer.pipe(...pipes)); + return Composer.chain(composer => composer.pipe(...pipes)); } /** @@ -96,17 +90,14 @@ export class Composer { */ static set(path: Path, value: A) { return (obj: T): T => - Composer.build(c => c.set(path, value))(obj); + Composer.chain(c => c.set(path, value))(obj); } /** * Runs a composer on a sub-object at the specified path, * then updates the original composer and returns it. */ - zoom( - path: Path, - fn: (inner: Composer) => Composer - ): this { + zoom(path: Path, fn: Compositor): this { const lens = lensFromPath(path); const inner = new Composer(lens.get(this.obj)); const updated = fn(inner).get(); @@ -114,6 +105,14 @@ export class Composer { return this; } + /** + * Shorthand for building a composer that zooms into a path + */ + static zoom(path: Path, fn: Compositor) { + return (obj: T): T => + new Composer(obj).zoom(path, fn).get(); + } + /** * Updates the value at the specified path with the mapping function. */ @@ -127,7 +126,7 @@ export class Composer { */ static over(path: Path, fn: (a: A) => A) { return (obj: T): T => - Composer.build(c => c.over(path, fn))(obj); + Composer.chain(c => c.over(path, fn))(obj); } /** @@ -144,7 +143,7 @@ export class Composer { */ static bind(path: Path, fn: Transformer<[A], any>) { return (obj: T): T => - Composer.build(c => c.bind(path, fn))(obj); + Composer.chain(c => c.bind(path, fn))(obj); } /** @@ -154,10 +153,30 @@ export class Composer { return condition ? fn(this) : this; } + /** + * Shorthand for building a composer that runs a function when the condition is true. + */ + static when( + condition: boolean, + fn: Compositor + ): (obj: T) => T { + return (obj: T) => Composer.chain(c => c.when(condition, fn))(obj); + } + /** * Runs a composer function when the condition is false. */ unless(condition: boolean, fn: (c: this) => this): this { return this.when(!condition, fn); } + + /** + * Shorthand for building a composer that runs a function when the condition is false. + */ + static unless( + condition: boolean, + fn: Compositor + ): (obj: T) => T { + return Composer.when(!condition, fn); + } } diff --git a/src/engine/Piper.ts b/src/engine/Piper.ts index 26c3832..0dbb42b 100644 --- a/src/engine/Piper.ts +++ b/src/engine/Piper.ts @@ -2,4 +2,4 @@ import { Pipe } from './State'; import { Composer } from './Composer'; export const Piper = (pipes: Pipe[]): Pipe => - Composer.build(c => c.pipe(...pipes)); + Composer.chain(c => c.pipe(...pipes)); diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 7de0c4d..e191ae4 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -61,7 +61,7 @@ export const handleEvent: PipeTransformer< * This prevents events from being processed during the same frame they are created. * This is important because pipes later in the pipeline may add new events. */ -export const eventPipe: Pipe = Composer.build(frame => +export const eventPipe: Pipe = Composer.chain(frame => frame .zoom('state', state => state.over(PLUGIN_NAMESPACE, ({ pending = [] }) => ({ diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index e2f193f..b3310c0 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -28,7 +28,7 @@ export type MessageState = { messages: GameMessage[]; }; -export const messagesPipe: Pipe = Composer.build(c => { +export const messagesPipe: Pipe = Composer.chain(c => { const { dispatch, handle } = c.get([ 'context', 'core', @@ -48,7 +48,7 @@ export const messagesPipe: Pipe = Composer.build(c => { .pipe( handle(getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), event => - Composer.build(c => { + Composer.chain(c => { const messageId = (event.payload as GameMessage).id; const { schedule, cancel } = c.get([ 'context', @@ -78,7 +78,7 @@ export const messagesPipe: Pipe = Composer.build(c => { .bind( ['state', PLUGIN_NAMESPACE], ({ messages = [] }) => - Composer.build(c => { + Composer.chain(c => { const updated = messages.find(m => m.id === messageId); const scheduleId = `${PLUGIN_NAMESPACE}.message.${messageId}`; return c.pipe( diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index f5068ba..13a6fd5 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -20,7 +20,7 @@ export type SchedulerContext = { cancel: PipeTransformer<[string]>; }; -export const schedulerPipe: Pipe = Composer.build(c => { +export const schedulerPipe: Pipe = Composer.chain(c => { const { dispatch, handle } = c.get([ 'context', 'core', diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 7a33238..12564a8 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -74,7 +74,7 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { useEffect(() => { // To inject one-shot pipes (impulses) into the engine, // we use the pending ref to stage them, and the active ref to apply them. - const impulsePipe: Pipe = Composer.build(c => + const impulsePipe: Pipe = Composer.chain(c => c.pipe(...activeImpulseRef.current) ); diff --git a/src/game/test.ts b/src/game/test.ts index 005cd51..8eaff6a 100644 --- a/src/game/test.ts +++ b/src/game/test.ts @@ -8,7 +8,7 @@ const MSG_TEST_NAMESPACE = 'core.message_test'; const messageId = 'test-message'; const followupId = 'followup-message'; -export const messageTestPipe: Pipe = Composer.build(c => { +export const messageTestPipe: Pipe = Composer.chain(c => { const { sendMessage } = c.get([ 'context', 'core', @@ -25,35 +25,33 @@ export const messageTestPipe: Pipe = Composer.build(c => { .bind<{ sent: boolean }>( ['state', MSG_TEST_NAMESPACE], ({ sent = false }) => - Composer.build(c => - c.unless(sent, c => - c - .pipe( - sendMessage({ - id: messageId, - title: 'Test Message', - prompts: [ - { - title: 'Acknowledge', - event: { - type: getEventKey( - MSG_TEST_NAMESPACE, - 'acknowledgeMessage' - ), - }, + Composer.unless(sent, c => + c + .pipe( + sendMessage({ + id: messageId, + title: 'Test Message', + prompts: [ + { + title: 'Acknowledge', + event: { + type: getEventKey( + MSG_TEST_NAMESPACE, + 'acknowledgeMessage' + ), }, - { - title: 'Dismiss', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - }, + }, + { + title: 'Dismiss', + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), }, - ], - }) - ) + }, + ], + }) + ) - .set(['state', MSG_TEST_NAMESPACE, 'sent'], true) - ) + .set(['state', MSG_TEST_NAMESPACE, 'sent'], true) ) ) From a162b2ed2ea2d5f37076fd306ff2f6eae5ddeeaf Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 29 Jun 2025 11:15:52 +0200 Subject: [PATCH 20/90] fixed old lens Path usage --- src/engine/pipes/Events.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index e191ae4..8648700 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -37,14 +37,14 @@ export const readEventKey = ( export const dispatchEvent: PipeTransformer<[GameEvent]> = event => Composer.over( - `state.${PLUGIN_NAMESPACE}.pending`, + ['state', PLUGIN_NAMESPACE, 'pending'], (pending = []) => [...pending, event] ); export const handleEvent: PipeTransformer< [string, (event: GameEvent) => Pipe] > = (type, fn) => - Composer.bind(`state.${PLUGIN_NAMESPACE}`, ({ current = [] }) => + Composer.bind(['state', PLUGIN_NAMESPACE], ({ current = [] }) => Composer.pipe( ...current .filter(event => { From 71dfffaa98ab88789d8620c31f0c41571b43f5bc Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 29 Jun 2025 11:36:15 +0200 Subject: [PATCH 21/90] improved event handle typing --- src/engine/pipes/Events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 8648700..729d332 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -13,7 +13,7 @@ export type EventState = { export type EventContext = { dispatch: PipeTransformer<[GameEvent]>; - handle: PipeTransformer<[string, (event: GameEvent) => Pipe]>; + handle: PipeTransformer<[string, PipeTransformer<[GameEvent]>]>; }; const PLUGIN_NAMESPACE = 'core.events'; From a476a1c1cec5b486eb44e7aaa497843eb5162bcf Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 29 Jun 2025 16:47:13 +0200 Subject: [PATCH 22/90] added intensity pipe --- src/engine/pipes/index.ts | 4 ++++ src/game/GamePage.tsx | 11 ++++++++++- src/game/GameProvider.tsx | 4 ++++ src/game/components/GameIntensity.tsx | 19 ------------------- src/game/pipes/Intensity.ts | 23 +++++++++++++++++++++++ src/game/test.ts | 8 +++----- 6 files changed, 44 insertions(+), 25 deletions(-) create mode 100644 src/engine/pipes/index.ts delete mode 100644 src/game/components/GameIntensity.tsx create mode 100644 src/game/pipes/Intensity.ts diff --git a/src/engine/pipes/index.ts b/src/engine/pipes/index.ts new file mode 100644 index 0000000..cb14754 --- /dev/null +++ b/src/engine/pipes/index.ts @@ -0,0 +1,4 @@ +export * from './Events'; +export * from './Fps'; +export * from './Messages'; +export * from './Scheduler'; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 1146221..92b5575 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -7,6 +7,8 @@ import { PauseButton } from './components/Pause'; import { fpsPipe } from '../engine/pipes/Fps'; import { messageTestPipe } from './test'; import { useSettingsPipe } from './pipes'; +import { GameIntensity } from './components/GameIntensity'; +import { intensityPipe } from './pipes/Intensity'; const StyledGamePage = styled.div` position: relative; @@ -73,10 +75,17 @@ export const GamePage = () => { return ( + diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 12564a8..2ac7268 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -115,6 +115,10 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { return () => { cancelAnimationFrame(frameId); + lastTimeRef.current = null; + engineRef.current = null; + pendingImpulseRef.current = []; + activeImpulseRef.current = []; }; }, [pipes]); diff --git a/src/game/components/GameIntensity.tsx b/src/game/components/GameIntensity.tsx deleted file mode 100644 index 7df0bd0..0000000 --- a/src/game/components/GameIntensity.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useSetting } from '../../settings'; -import { useLooping } from '../../utils'; -import { GamePhase, useGameValue } from '../GameProvider'; - -export const GameIntensity = () => { - const [, setIntensity] = useGameValue('intensity'); - const [phase] = useGameValue('phase'); - const [duration] = useSetting('gameDuration'); - - useLooping( - () => { - setIntensity(prev => Math.min(prev + 1, 100)); - }, - duration * 10, - phase === GamePhase.active - ); - - return null; -}; diff --git a/src/game/pipes/Intensity.ts b/src/game/pipes/Intensity.ts new file mode 100644 index 0000000..a417286 --- /dev/null +++ b/src/game/pipes/Intensity.ts @@ -0,0 +1,23 @@ +import { Composer, GameContext, Pipe } from '../../engine'; + +const PLUGIN_NAMESPACE = 'core.intensity'; + +export type IntensityState = { + intensity: number; +}; + +export const intensityPipe: Pipe = Composer.bind( + ['context', 'deltaTime'], + delta => + Composer.bind(['context'], ({ settings }) => + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ intensity = 0 }) => ({ + intensity: Math.min( + 1, + intensity + delta / (settings.gameDuration * 1000) + ), + }) + ) + ) +); diff --git a/src/game/test.ts b/src/game/test.ts index 8eaff6a..99a2caf 100644 --- a/src/game/test.ts +++ b/src/game/test.ts @@ -45,6 +45,7 @@ export const messageTestPipe: Pipe = Composer.chain(c => { title: 'Dismiss', event: { type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + payload: { id: messageId }, }, }, ], @@ -73,11 +74,8 @@ export const messageTestPipe: Pipe = Composer.chain(c => { ) .pipe( - handle(getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), () => - Composer.pipe( - sendMessage({ id: messageId, duration: 0 }), - sendMessage({ id: followupId, duration: 0 }) - ) + handle(getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), event => + Composer.pipe(sendMessage({ id: event.payload.id, duration: 0 })) ) ) From 40a340ee37cd9043a3f717f7972650fa295dbb7c Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 24 Aug 2025 21:49:38 +0200 Subject: [PATCH 23/90] ignored any usage --- .eslintrc.cjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 86794da..1929e3d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -16,5 +16,8 @@ module.exports = { { allowConstantExport: true }, ], '@typescript-eslint/no-unused-vars': 'warn', + // I am going to be real with you, this pipe system is not going to be very typed. + // Lets just. put this aside for now. + '@typescript-eslint/no-explicit-any': 'off', }, }; From c3d384541aa9591a54cd1809bb7c7eba8b094b65 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 24 Aug 2025 21:50:32 +0200 Subject: [PATCH 24/90] added image pipeline --- src/game/GamePage.tsx | 4 + src/game/GameProvider.tsx | 1 + src/game/components/GameImages.tsx | 75 +++---------- src/game/components/GameIntensity.tsx | 32 ++++++ src/game/components/GameMessages.tsx | 4 +- src/game/hooks/UseGameValue.tsx | 2 +- src/game/pipes/Image.ts | 150 ++++++++++++++++++++++++++ src/game/pipes/Settings.ts | 21 +++- src/game/pipes/index.ts | 1 + 9 files changed, 221 insertions(+), 69 deletions(-) create mode 100644 src/game/components/GameIntensity.tsx create mode 100644 src/game/pipes/Image.ts diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 92b5575..65dda0a 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -9,6 +9,8 @@ import { messageTestPipe } from './test'; import { useSettingsPipe } from './pipes'; import { GameIntensity } from './components/GameIntensity'; import { intensityPipe } from './pipes/Intensity'; +import { imagePipe } from './pipes/Image'; +import { GameImages } from './components/GameImages'; const StyledGamePage = styled.div` position: relative; @@ -80,10 +82,12 @@ export const GamePage = () => { messagesPipe, settingsPipe, intensityPipe, + imagePipe, messageTestPipe, ]} > + diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 2ac7268..c5065d4 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -43,6 +43,7 @@ const GameEngineContext = createContext( undefined ); +// eslint-disable-next-line react-refresh/only-export-components export function useGameEngine() { const ctx = useContext(GameEngineContext); if (!ctx) diff --git a/src/game/components/GameImages.tsx b/src/game/components/GameImages.tsx index 02273c8..6ab5205 100644 --- a/src/game/components/GameImages.tsx +++ b/src/game/components/GameImages.tsx @@ -1,11 +1,11 @@ import styled from 'styled-components'; -import { useImages, useSetting } from '../../settings'; -import { useGameValue } from '../GameProvider'; +import { useSetting } from '../../settings'; import { motion } from 'framer-motion'; import { JoiImage } from '../../common'; -import { useAutoRef, useImagePreloader, useLooping } from '../../utils'; +import { useImagePreloader } from '../../utils'; import { ImageSize, ImageType } from '../../types'; -import { useCallback, useMemo, useEffect } from 'react'; +import { useGameState } from '../hooks'; +import { ImageState } from '../pipes'; const StyledGameImages = styled.div` position: absolute; @@ -44,64 +44,16 @@ const StyledBackgroundImage = motion.create(styled.div` `); export const GameImages = () => { - const [images] = useImages(); - const [currentImage, setCurrentImage] = useGameValue('currentImage'); - const [seenImages, setSeenImages] = useGameValue('seenImages'); - const [nextImages, setNextImages] = useGameValue('nextImages'); - const [intensity] = useGameValue('intensity'); + const { currentImage, nextImages = [] } = useGameState([ + 'core.images', + ]); + const { intensity } = useGameState(['core.intensity']); const [videoSound] = useSetting('videoSound'); const [highRes] = useSetting('highRes'); - const [imageDuration] = useSetting('imageDuration'); - const [intenseImages] = useSetting('intenseImages'); useImagePreloader(nextImages, highRes ? ImageSize.full : ImageSize.preview); - const imagesTracker = useAutoRef({ - images, - currentImage, - setCurrentImage, - seenImages, - setSeenImages, - nextImages, - setNextImages, - }); - - const switchImage = useCallback(() => { - const { - images, - currentImage, - setCurrentImage, - seenImages, - setSeenImages, - nextImages, - setNextImages, - } = imagesTracker.current; - - let next = nextImages; - if (next.length <= 0) { - next = images.sort(() => Math.random() - 0.5).slice(0, 3); - } - const seen = [...seenImages, ...(currentImage ? [currentImage] : [])]; - if (seen.length > images.length / 2) { - seen.shift(); - } - const unseen = images.filter(i => !seen.includes(i)); - setCurrentImage(next.shift()); - setSeenImages(seen); - setNextImages([...next, unseen[Math.floor(Math.random() * unseen.length)]]); - }, [imagesTracker]); - - const switchDuration = useMemo(() => { - if (intenseImages) { - const scaleFactor = Math.max((100 - intensity) / 100, 0.1); - return Math.max(imageDuration * scaleFactor * 1000, 1000); - } - return imageDuration * 1000; - }, [imageDuration, intenseImages, intensity]); - - useEffect(() => switchImage(), [switchImage]); - - useLooping(switchImage, switchDuration); + const switchDuration = Math.max((100 - intensity * 100) * 80, 2000); return ( @@ -119,7 +71,6 @@ export const GameImages = () => { @@ -128,9 +79,11 @@ export const GameImages = () => { { + const { intensity } = useGameState(['core.intensity']); + + return ( + + + + + ); +}; diff --git a/src/game/components/GameMessages.tsx b/src/game/components/GameMessages.tsx index 5dd2a96..b3325f6 100644 --- a/src/game/components/GameMessages.tsx +++ b/src/game/components/GameMessages.tsx @@ -5,7 +5,7 @@ import { useTranslate } from '../../settings'; import { defaultTransition, playTone } from '../../utils'; import { GameMessage, MessageState } from '../../engine/pipes/Messages'; -import { useGameValue } from '../hooks/UseGameValue'; +import { useGameState } from '../hooks/UseGameValue'; import _ from 'lodash'; import { useDispatchEvent } from '../hooks/UseDispatchEvent'; @@ -59,7 +59,7 @@ const StyledGameMessageButton = motion.create(styled.button` `); export const GameMessages = () => { - const { messages } = useGameValue('core.messages'); + const { messages } = useGameState('core.messages'); const { dispatchEvent } = useDispatchEvent(); const translate = useTranslate(); diff --git a/src/game/hooks/UseGameValue.tsx b/src/game/hooks/UseGameValue.tsx index d8c57de..52ab5f7 100644 --- a/src/game/hooks/UseGameValue.tsx +++ b/src/game/hooks/UseGameValue.tsx @@ -2,7 +2,7 @@ import { Composer } from '../../engine/Composer'; import { Path } from '../../engine/Lens'; import { useGameEngine } from '../GameProvider'; -export const useGameValue = (path: Path): T => { +export const useGameState = (path: Path): T => { const { state } = useGameEngine(); if (!state) { return {} as T; diff --git a/src/game/pipes/Image.ts b/src/game/pipes/Image.ts new file mode 100644 index 0000000..bf6ce0c --- /dev/null +++ b/src/game/pipes/Image.ts @@ -0,0 +1,150 @@ +import { Composer } from '../../engine'; +import { GameFrame, Pipe, PipeTransformer } from '../../engine/State'; +import { ImageItem } from '../../types'; +import { EventContext, getEventKey } from '../../engine/pipes/Events'; +import { SchedulerContext } from '../../engine/pipes/Scheduler'; + +const PLUGIN_NAMESPACE = 'core.images'; + +export type ImageState = { + currentImage?: ImageItem; + seenImages: ImageItem[]; + nextImages: ImageItem[]; +}; + +export type ImageContext = { + switchImage: PipeTransformer<[]>; + setCurrentImage: PipeTransformer<[ImageItem | undefined]>; + getCurrentImage: PipeTransformer< + [(currentImage: ImageItem | undefined) => Pipe] + >; +}; + +const getImageSwitchDuration = (intensity: number): number => { + return Math.max((100 - intensity * 100) * 80, 2000); +}; + +export const imagePipe: Pipe = Composer.chain(c => { + const { dispatch, handle } = c.get(['context', 'core.events']); + const { schedule } = c.get(['context', 'core.scheduler']); + + return c + .bind(['context', 'images'], images => + Composer.bind( + ['state', PLUGIN_NAMESPACE], + (state = { seenImages: [], nextImages: [] }) => + Composer.when( + !state.currentImage && images.length > 0, + c => { + const shuffled = [...images].sort(() => Math.random() - 0.5); + return c.pipe( + Composer.over(['state', PLUGIN_NAMESPACE], () => ({ + ...state, + currentImage: shuffled[0], + nextImages: shuffled.slice(1, 4), + })), + dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), + payload: undefined, + }) + ); + } + ) + ) + ) + + .set(['context', PLUGIN_NAMESPACE], { + switchImage: () => + Composer.pipe( + dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'switch'), + payload: undefined, + }) + ), + + setCurrentImage: image => + Composer.pipe( + dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'setImage'), + payload: image, + }) + ), + + getCurrentImage: fn => + Composer.bind(['state', PLUGIN_NAMESPACE, 'currentImage'], fn), + }) + + .pipe( + handle(getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), () => + Composer.bind( + ['state', 'core.intensity', 'intensity'], + (intensity = 0) => { + const duration = getImageSwitchDuration(intensity); + return Composer.pipe( + schedule({ + id: 'scheduleNext', + duration, + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), + payload: undefined, + }, + }), + dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'switch'), + }) + ); + } + ) + ) + ) + + .pipe( + handle(getEventKey(PLUGIN_NAMESPACE, 'switch'), () => + Composer.bind(['context', 'images'], images => + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ currentImage, seenImages = [], nextImages = [] }) => { + if (images.length === 0) { + return { currentImage: undefined, seenImages, nextImages }; + } + + let next = [...nextImages]; + if (next.length <= 0) { + next = [...images].sort(() => Math.random() - 0.5).slice(0, 3); + } + + const seen = [ + ...seenImages, + ...(currentImage ? [currentImage] : []), + ]; + if (seen.length > images.length / 2) { + seen.shift(); + } + + const unseen = images.filter((i: ImageItem) => !seen.includes(i)); + const newCurrent = next.shift(); + + if (unseen.length > 0) { + next.push(unseen[Math.floor(Math.random() * unseen.length)]); + } + + return { + currentImage: newCurrent, + seenImages: seen, + nextImages: next, + }; + } + ) + ) + ) + ) + + .pipe( + handle(getEventKey(PLUGIN_NAMESPACE, 'setImage'), event => + Composer.over(['state', PLUGIN_NAMESPACE], state => ({ + ...state, + currentImage: event.payload, + })) + ) + ); +}); diff --git a/src/game/pipes/Settings.ts b/src/game/pipes/Settings.ts index 8dd1b70..2dd1d41 100644 --- a/src/game/pipes/Settings.ts +++ b/src/game/pipes/Settings.ts @@ -1,16 +1,27 @@ import { useEffect, useRef } from 'react'; -import { useSettings } from '../../settings'; +import { useSettings, useImages } from '../../settings'; import { Composer, Pipe } from '../../engine'; export const useSettingsPipe = (): Pipe => { const [settings] = useSettings(); - const ref = useRef(settings); + const [images] = useImages(); + const settingsRef = useRef(settings); + const imagesRef = useRef(images); useEffect(() => { - if (ref.current !== settings) { - ref.current = settings; + if (settingsRef.current !== settings) { + settingsRef.current = settings; } }, [settings]); - return Composer.set(['context', 'settings'], ref.current); + useEffect(() => { + if (imagesRef.current !== images) { + imagesRef.current = images; + } + }, [images]); + + return Composer.pipe( + Composer.set(['context', 'settings'], settingsRef.current), + Composer.set(['context', 'images'], imagesRef.current) + ); }; diff --git a/src/game/pipes/index.ts b/src/game/pipes/index.ts index 90e2697..07f22f0 100644 --- a/src/game/pipes/index.ts +++ b/src/game/pipes/index.ts @@ -1 +1,2 @@ +export * from './Image'; export * from './Settings'; From 9489300950e3748b5a27776ec116d867b115d902 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 24 Aug 2025 23:29:00 +0200 Subject: [PATCH 25/90] moved random image switching into own pipe --- src/engine/Composer.ts | 2 +- src/game/GamePage.tsx | 3 +- src/game/pipes/Image.ts | 141 +++++++++++---------------------- src/game/pipes/RandomImages.ts | 106 +++++++++++++++++++++++++ src/game/pipes/index.ts | 1 + src/game/test.ts | 53 ++++++------- 6 files changed, 180 insertions(+), 126 deletions(-) create mode 100644 src/game/pipes/RandomImages.ts diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index 4818ade..3bf3023 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -133,7 +133,7 @@ export class Composer { * Runs a composer function with the value at the specified path. */ bind(path: Path, fn: Transformer<[A], T>): this { - const value = lensFromPath(path).get(this.obj) ?? ({} as A); + const value = lensFromPath(path).get(this.obj); this.obj = fn(value)(this.obj); return this; } diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 65dda0a..93d2107 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -9,7 +9,7 @@ import { messageTestPipe } from './test'; import { useSettingsPipe } from './pipes'; import { GameIntensity } from './components/GameIntensity'; import { intensityPipe } from './pipes/Intensity'; -import { imagePipe } from './pipes/Image'; +import { imagePipe, randomImagesPipe } from './pipes'; import { GameImages } from './components/GameImages'; const StyledGamePage = styled.div` @@ -83,6 +83,7 @@ export const GamePage = () => { settingsPipe, intensityPipe, imagePipe, + randomImagesPipe, messageTestPipe, ]} > diff --git a/src/game/pipes/Image.ts b/src/game/pipes/Image.ts index bf6ce0c..69cc112 100644 --- a/src/game/pipes/Image.ts +++ b/src/game/pipes/Image.ts @@ -1,8 +1,7 @@ import { Composer } from '../../engine'; -import { GameFrame, Pipe, PipeTransformer } from '../../engine/State'; +import { Pipe, PipeTransformer } from '../../engine/State'; import { ImageItem } from '../../types'; import { EventContext, getEventKey } from '../../engine/pipes/Events'; -import { SchedulerContext } from '../../engine/pipes/Scheduler'; const PLUGIN_NAMESPACE = 'core.images'; @@ -13,52 +12,21 @@ export type ImageState = { }; export type ImageContext = { - switchImage: PipeTransformer<[]>; + pushNextImage: PipeTransformer<[ImageItem]>; setCurrentImage: PipeTransformer<[ImageItem | undefined]>; - getCurrentImage: PipeTransformer< - [(currentImage: ImageItem | undefined) => Pipe] - >; -}; - -const getImageSwitchDuration = (intensity: number): number => { - return Math.max((100 - intensity * 100) * 80, 2000); + setNextImages: PipeTransformer<[ImageItem[]]>; }; export const imagePipe: Pipe = Composer.chain(c => { const { dispatch, handle } = c.get(['context', 'core.events']); - const { schedule } = c.get(['context', 'core.scheduler']); return c - .bind(['context', 'images'], images => - Composer.bind( - ['state', PLUGIN_NAMESPACE], - (state = { seenImages: [], nextImages: [] }) => - Composer.when( - !state.currentImage && images.length > 0, - c => { - const shuffled = [...images].sort(() => Math.random() - 0.5); - return c.pipe( - Composer.over(['state', PLUGIN_NAMESPACE], () => ({ - ...state, - currentImage: shuffled[0], - nextImages: shuffled.slice(1, 4), - })), - dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), - payload: undefined, - }) - ); - } - ) - ) - ) - .set(['context', PLUGIN_NAMESPACE], { - switchImage: () => + pushNextImage: image => Composer.pipe( dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'switch'), - payload: undefined, + type: getEventKey(PLUGIN_NAMESPACE, 'pushNext'), + payload: image, }) ), @@ -70,72 +38,55 @@ export const imagePipe: Pipe = Composer.chain(c => { }) ), - getCurrentImage: fn => - Composer.bind(['state', PLUGIN_NAMESPACE, 'currentImage'], fn), + setNextImages: images => + Composer.pipe( + dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'setNextImages'), + payload: images, + }) + ), }) .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), () => - Composer.bind( - ['state', 'core.intensity', 'intensity'], - (intensity = 0) => { - const duration = getImageSwitchDuration(intensity); - return Composer.pipe( - schedule({ - id: 'scheduleNext', - duration, - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), - payload: undefined, - }, - }), - dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'switch'), - }) - ); + handle(getEventKey(PLUGIN_NAMESPACE, 'pushNext'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ currentImage, seenImages = [], nextImages = [] }) => { + const newImage = event.payload; + const next = [...nextImages]; + const seen = [...seenImages]; + + if (currentImage) { + const existingIndex = seen.indexOf(currentImage); + if (existingIndex !== -1) { + seen.splice(existingIndex, 1); + } + seen.unshift(currentImage); + } + + if (seen.length > 500) { + seen.pop(); + } + + next.push(newImage); + const newCurrent = next.shift(); + + return { + currentImage: newCurrent, + seenImages: seen, + nextImages: next, + }; } ) ) ) .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'switch'), () => - Composer.bind(['context', 'images'], images => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ currentImage, seenImages = [], nextImages = [] }) => { - if (images.length === 0) { - return { currentImage: undefined, seenImages, nextImages }; - } - - let next = [...nextImages]; - if (next.length <= 0) { - next = [...images].sort(() => Math.random() - 0.5).slice(0, 3); - } - - const seen = [ - ...seenImages, - ...(currentImage ? [currentImage] : []), - ]; - if (seen.length > images.length / 2) { - seen.shift(); - } - - const unseen = images.filter((i: ImageItem) => !seen.includes(i)); - const newCurrent = next.shift(); - - if (unseen.length > 0) { - next.push(unseen[Math.floor(Math.random() * unseen.length)]); - } - - return { - currentImage: newCurrent, - seenImages: seen, - nextImages: next, - }; - } - ) - ) + handle(getEventKey(PLUGIN_NAMESPACE, 'setNextImages'), event => + Composer.over(['state', PLUGIN_NAMESPACE], state => ({ + ...state, + nextImages: event.payload, + })) ) ) diff --git a/src/game/pipes/RandomImages.ts b/src/game/pipes/RandomImages.ts new file mode 100644 index 0000000..ddfc79e --- /dev/null +++ b/src/game/pipes/RandomImages.ts @@ -0,0 +1,106 @@ +import { Composer } from '../../engine'; +import { Pipe } from '../../engine/State'; +import { ImageItem } from '../../types'; +import { EventContext, getEventKey } from '../../engine/pipes/Events'; +import { SchedulerContext } from '../../engine/pipes/Scheduler'; + +const PLUGIN_NAMESPACE = 'core.random_images'; + +const getImageSwitchDuration = (intensity: number): number => { + return Math.max((100 - intensity * 100) * 80, 2000); +}; + +export const randomImagesPipe: Pipe = Composer.chain(c => { + const { dispatch, handle } = c.get(['context', 'core.events']); + const { schedule } = c.get(['context', 'core.scheduler']); + + return c.pipe( + Composer.bind( + ['state', PLUGIN_NAMESPACE, 'initialized'], + (initialized = false) => + Composer.unless(initialized, c => + c.pipe( + Composer.set(['state', PLUGIN_NAMESPACE, 'initialized'], true), + Composer.bind(['context', 'images'], images => + Composer.when(images.length > 0, c => { + const shuffled = [...images].sort(() => Math.random() - 0.5); + const initialImages = shuffled.slice( + 0, + Math.min(3, images.length) + ); + + return c.pipe( + ...initialImages.map(image => + dispatch({ + type: getEventKey('core.images', 'pushNext'), + payload: image, + }) + ), + dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), + }) + ); + }) + ) + ) + ) + ), + + handle(getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), () => + Composer.bind( + ['state', 'core.intensity', 'intensity'], + (intensity = 0) => + Composer.bind(['context', 'images'], images => + Composer.bind( + ['state', 'core.images', 'seenImages'], + (seenImages = []) => + Composer.when(images.length > 0, c => { + const imagesWithDistance = images.map(image => { + const seenIndex = seenImages.indexOf(image); + const distance = + seenIndex === -1 ? seenImages.length : seenIndex; + return { image, distance }; + }); + + imagesWithDistance.sort((a, b) => b.distance - a.distance); + + const weights = imagesWithDistance.map((_, index) => + Math.max(1, imagesWithDistance.length - index) + ); + const totalWeight = weights.reduce( + (sum, weight) => sum + weight, + 0 + ); + + let random = Math.random() * totalWeight; + let selectedIndex = 0; + for (let i = 0; i < weights.length; i++) { + random -= weights[i]; + if (random <= 0) { + selectedIndex = i; + break; + } + } + + const randomImage = imagesWithDistance[selectedIndex].image; + + return c.pipe( + dispatch({ + type: getEventKey('core.images', 'pushNext'), + payload: randomImage, + }), + schedule({ + id: 'randomImageSwitch', + duration: getImageSwitchDuration(intensity), + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), + }, + }) + ); + }) + ) + ) + ) + ) + ); +}); diff --git a/src/game/pipes/index.ts b/src/game/pipes/index.ts index 07f22f0..ea6a1d8 100644 --- a/src/game/pipes/index.ts +++ b/src/game/pipes/index.ts @@ -1,2 +1,3 @@ export * from './Image'; +export * from './RandomImages'; export * from './Settings'; diff --git a/src/game/test.ts b/src/game/test.ts index 99a2caf..d0f20a0 100644 --- a/src/game/test.ts +++ b/src/game/test.ts @@ -22,38 +22,33 @@ export const messageTestPipe: Pipe = Composer.chain(c => { ]); return c - .bind<{ sent: boolean }>( - ['state', MSG_TEST_NAMESPACE], - ({ sent = false }) => - Composer.unless(sent, c => - c - .pipe( - sendMessage({ - id: messageId, - title: 'Test Message', - prompts: [ - { - title: 'Acknowledge', - event: { - type: getEventKey( - MSG_TEST_NAMESPACE, - 'acknowledgeMessage' - ), - }, + .bind(['state', MSG_TEST_NAMESPACE, 'sent'], sent => + Composer.unless(sent, c => + c + .pipe( + sendMessage({ + id: messageId, + title: 'Test Message', + prompts: [ + { + title: 'Acknowledge', + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), }, - { - title: 'Dismiss', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - payload: { id: messageId }, - }, + }, + { + title: 'Dismiss', + event: { + type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), + payload: { id: messageId }, }, - ], - }) - ) + }, + ], + }) + ) - .set(['state', MSG_TEST_NAMESPACE, 'sent'], true) - ) + .set(['state', MSG_TEST_NAMESPACE, 'sent'], true) + ) ) .pipe( From b439a8e80af131308a4935fbe8bf49c7591166f8 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 24 Aug 2025 23:44:09 +0200 Subject: [PATCH 26/90] added game phase pipe --- src/game/GamePage.tsx | 2 ++ src/game/pipes/Phase.ts | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/game/pipes/Phase.ts diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 93d2107..e1ff61e 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -11,6 +11,7 @@ import { GameIntensity } from './components/GameIntensity'; import { intensityPipe } from './pipes/Intensity'; import { imagePipe, randomImagesPipe } from './pipes'; import { GameImages } from './components/GameImages'; +import { phasePipe } from './pipes/Phase'; const StyledGamePage = styled.div` position: relative; @@ -81,6 +82,7 @@ export const GamePage = () => { fpsPipe, messagesPipe, settingsPipe, + phasePipe, intensityPipe, imagePipe, randomImagesPipe, diff --git a/src/game/pipes/Phase.ts b/src/game/pipes/Phase.ts new file mode 100644 index 0000000..2223a29 --- /dev/null +++ b/src/game/pipes/Phase.ts @@ -0,0 +1,49 @@ +import { Composer } from '../../engine/Composer'; +import { + GameContext, + GameState, + Pipe, + PipeTransformer, +} from '../../engine/State'; + +export enum GamePhase { + warmup = 'warmup', + active = 'active', + break = 'break', + finale = 'finale', + climax = 'climax', +} + +export type PhaseState = { + current: GamePhase; +}; + +export type PhaseContext = { + setPhase: PipeTransformer<[GamePhase]>; +}; + +const PLUGIN_NAMESPACE = 'core.phase'; + +export const setPhase: PipeTransformer<[GamePhase]> = phase => + Composer.set(['state', PLUGIN_NAMESPACE, 'current'], phase); + +export const getPhase = (state: GameState): GamePhase => { + return state[PLUGIN_NAMESPACE]?.current ?? GamePhase.warmup; +}; + +export const phasePipe: Pipe = Composer.chain(frame => + frame + .zoom('state', state => { + if (!state.get(PLUGIN_NAMESPACE)) { + state = state.set(PLUGIN_NAMESPACE, { + current: GamePhase.warmup, + }); + } + return state; + }) + .zoom('context', context => + context.set(PLUGIN_NAMESPACE, { + setPhase, + }) + ) +); From 950a2562e47b2fc6a491a638a7ee84d62a9d6b59 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 24 Aug 2025 23:55:48 +0200 Subject: [PATCH 27/90] improved fps display --- src/engine/pipes/Fps.ts | 25 ++++++++++++++++++++++++- src/game/components/FpsDisplay.tsx | 11 ++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/engine/pipes/Fps.ts b/src/engine/pipes/Fps.ts index 7d861df..a4ecf3b 100644 --- a/src/engine/pipes/Fps.ts +++ b/src/engine/pipes/Fps.ts @@ -1,8 +1,31 @@ import { Pipe } from '../State'; import { Composer } from '../Composer'; +export type FpsContext = { + value: number; + history: number[]; +}; + +const PLUGIN_NAMESPACE = 'core.fps'; +const HISTORY_SIZE = 30; + export const fpsPipe: Pipe = Composer.bind( ['context', 'deltaTime'], delta => - Composer.set(['context', 'core', 'fps'], delta > 0 ? 1000 / delta : 0) + Composer.bind(['context', PLUGIN_NAMESPACE], existingFps => + Composer.chain(frame => { + const currentFps = delta > 0 ? 1000 / delta : 0; + const history = existingFps?.history ?? []; + + const newHistory = [...history, currentFps].slice(-HISTORY_SIZE); + const newFpsData: FpsContext = { + value: currentFps, + history: newHistory, + }; + + return frame.zoom(['context'], context => + context.set(PLUGIN_NAMESPACE, newFpsData) + ); + }) + ) ); diff --git a/src/game/components/FpsDisplay.tsx b/src/game/components/FpsDisplay.tsx index 1924fcc..4e3165e 100644 --- a/src/game/components/FpsDisplay.tsx +++ b/src/game/components/FpsDisplay.tsx @@ -1,9 +1,14 @@ -import { useGameEngine } from '../GameProvider'; +import { FpsContext } from '../../engine'; +import { useGameContext } from '../hooks'; export const FpsDisplay = () => { - const { context } = useGameEngine(); + const { history, value } = useGameContext('core.fps'); - const fps = context?.core?.fps ? Math.round(context.core.fps) : null; + const fps = Math.round( + history?.length > 0 + ? history.reduce((sum, fps) => sum + fps, 0) / history.length + : value ?? 0 + ); return (
Date: Mon, 25 Aug 2025 01:31:35 +0200 Subject: [PATCH 28/90] added warump pipe --- src/engine/pipes/Scheduler.ts | 4 ++ src/game/GamePage.tsx | 6 +-- src/game/GameProvider.tsx | 23 ++------ src/game/components/FpsDisplay.tsx | 4 +- src/game/components/Pause.tsx | 2 +- src/game/hooks/UseDispatchEvent.tsx | 2 +- src/game/hooks/UseGameContext.tsx | 2 +- src/game/hooks/UseGameEngine.tsx | 9 ++++ src/game/hooks/UseGameValue.tsx | 2 +- src/game/pipes/Phase.ts | 21 +++----- src/game/pipes/Warmup.ts | 81 +++++++++++++++++++++++++++++ src/game/pipes/index.ts | 1 + 12 files changed, 115 insertions(+), 42 deletions(-) create mode 100644 src/game/hooks/UseGameEngine.tsx create mode 100644 src/game/pipes/Warmup.ts diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 13a6fd5..8be98d4 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -4,6 +4,10 @@ import { EventContext, GameEvent, getEventKey } from './Events'; const PLUGIN_NAMESPACE = 'core.scheduler'; +export const getScheduleKey = (namespace: string, key: string): string => { + return `${namespace}/schedule/${key}`; +}; + export type ScheduledEvent = { id?: string; duration: number; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index e1ff61e..225ce48 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -5,8 +5,7 @@ import { messagesPipe } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; import { fpsPipe } from '../engine/pipes/Fps'; -import { messageTestPipe } from './test'; -import { useSettingsPipe } from './pipes'; +import { useSettingsPipe, warmupPipe } from './pipes'; import { GameIntensity } from './components/GameIntensity'; import { intensityPipe } from './pipes/Intensity'; import { imagePipe, randomImagesPipe } from './pipes'; @@ -86,7 +85,8 @@ export const GamePage = () => { intensityPipe, imagePipe, randomImagesPipe, - messageTestPipe, + warmupPipe, + // messageTestPipe, ]} > diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index c5065d4..6ac33b9 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -1,11 +1,4 @@ -import { - createContext, - useContext, - useEffect, - useRef, - useState, - ReactNode, -} from 'react'; +import { createContext, useEffect, useRef, useState, ReactNode } from 'react'; import { GameEngine, GameState, Pipe, GameContext } from '../engine'; import { eventPipe } from '../engine/pipes/Events'; import { schedulerPipe } from '../engine/pipes/Scheduler'; @@ -39,17 +32,9 @@ type GameEngineContextValue = { injectImpulse: (pipe: Pipe) => void; }; -const GameEngineContext = createContext( - undefined -); - -// eslint-disable-next-line react-refresh/only-export-components -export function useGameEngine() { - const ctx = useContext(GameEngineContext); - if (!ctx) - throw new Error('useGameEngine must be used inside GameEngineProvider'); - return ctx; -} +export const GameEngineContext = createContext< + GameEngineContextValue | undefined +>(undefined); type Props = { children: ReactNode; diff --git a/src/game/components/FpsDisplay.tsx b/src/game/components/FpsDisplay.tsx index 4e3165e..e0bf7b4 100644 --- a/src/game/components/FpsDisplay.tsx +++ b/src/game/components/FpsDisplay.tsx @@ -14,8 +14,8 @@ export const FpsDisplay = () => {
(path: Path): T => { const { context } = useGameEngine(); diff --git a/src/game/hooks/UseGameEngine.tsx b/src/game/hooks/UseGameEngine.tsx new file mode 100644 index 0000000..8298025 --- /dev/null +++ b/src/game/hooks/UseGameEngine.tsx @@ -0,0 +1,9 @@ +import { useContext } from 'react'; +import { GameEngineContext } from '../GameProvider'; + +export function useGameEngine() { + const ctx = useContext(GameEngineContext); + if (!ctx) + throw new Error('useGameEngine must be used inside GameEngineProvider'); + return ctx; +} diff --git a/src/game/hooks/UseGameValue.tsx b/src/game/hooks/UseGameValue.tsx index 52ab5f7..e5a7507 100644 --- a/src/game/hooks/UseGameValue.tsx +++ b/src/game/hooks/UseGameValue.tsx @@ -1,6 +1,6 @@ import { Composer } from '../../engine/Composer'; import { Path } from '../../engine/Lens'; -import { useGameEngine } from '../GameProvider'; +import { useGameEngine } from './UseGameEngine'; export const useGameState = (path: Path): T => { const { state } = useGameEngine(); diff --git a/src/game/pipes/Phase.ts b/src/game/pipes/Phase.ts index 2223a29..571066b 100644 --- a/src/game/pipes/Phase.ts +++ b/src/game/pipes/Phase.ts @@ -27,23 +27,16 @@ const PLUGIN_NAMESPACE = 'core.phase'; export const setPhase: PipeTransformer<[GamePhase]> = phase => Composer.set(['state', PLUGIN_NAMESPACE, 'current'], phase); -export const getPhase = (state: GameState): GamePhase => { - return state[PLUGIN_NAMESPACE]?.current ?? GamePhase.warmup; -}; - export const phasePipe: Pipe = Composer.chain(frame => frame - .zoom('state', state => { - if (!state.get(PLUGIN_NAMESPACE)) { - state = state.set(PLUGIN_NAMESPACE, { + .zoom('state', state => + state.unless(state.get(PLUGIN_NAMESPACE) != null, state => + state.set(PLUGIN_NAMESPACE, { current: GamePhase.warmup, - }); - } - return state; - }) + }) + ) + ) .zoom('context', context => - context.set(PLUGIN_NAMESPACE, { - setPhase, - }) + context.set(PLUGIN_NAMESPACE, { setPhase }) ) ); diff --git a/src/game/pipes/Warmup.ts b/src/game/pipes/Warmup.ts new file mode 100644 index 0000000..4d0aa52 --- /dev/null +++ b/src/game/pipes/Warmup.ts @@ -0,0 +1,81 @@ +import { Composer, Pipe } from '../../engine'; +import { EventContext, getEventKey } from '../../engine/pipes/Events'; +import { MessageContext } from '../../engine/pipes/Messages'; +import { getScheduleKey, SchedulerContext } from '../../engine/pipes/Scheduler'; +import { GamePhase, setPhase } from './Phase'; +import { Settings } from '../../settings'; + +const PLUGIN_NAMESPACE = 'core.warmup'; + +export type WarmupState = { + initialized: boolean; +}; + +export const warmupPipe: Pipe = Composer.chain(c => { + const { sendMessage } = c.get(['context', 'core.messages']); + const { handle } = c.get(['context', 'core.events']); + const { schedule, cancel } = c.get([ + 'context', + 'core.scheduler', + ]); + + return c + .bind(['state', 'core.phase', 'current'], currentPhase => + Composer.bind(['context', 'settings'], settings => + Composer.bind( + ['state', PLUGIN_NAMESPACE], + (state = { initialized: false }) => + Composer.when(currentPhase === GamePhase.warmup, frame => + frame + .when(settings.warmupDuration === 0, f => + f.pipe(setPhase(GamePhase.active)) + ) + .when(settings.warmupDuration > 0 && !state.initialized, f => + f.pipe( + Composer.set(PLUGIN_NAMESPACE, { + initialized: true, + }), + sendMessage({ + id: GamePhase.warmup, + title: 'Get yourself ready!', + prompts: [ + { + title: `I'm ready, $master`, + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), + }, + }, + ], + }), + schedule({ + id: getScheduleKey(PLUGIN_NAMESPACE, 'autoStart'), + duration: settings.warmupDuration * 1000, + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), + }, + }) + ) + ) + ) + ) + ) + ) + + .pipe( + handle(getEventKey(PLUGIN_NAMESPACE, 'startGame'), () => + Composer.pipe( + cancel(getScheduleKey(PLUGIN_NAMESPACE, 'autoStart')), + Composer.set(PLUGIN_NAMESPACE, { + initialized: false, + }), + sendMessage({ + id: GamePhase.warmup, + title: 'Now follow what I say, $player!', + duration: 5000, + prompts: undefined, + }), + setPhase(GamePhase.active) + ) + ) + ); +}); diff --git a/src/game/pipes/index.ts b/src/game/pipes/index.ts index ea6a1d8..43af78d 100644 --- a/src/game/pipes/index.ts +++ b/src/game/pipes/index.ts @@ -1,3 +1,4 @@ export * from './Image'; export * from './RandomImages'; export * from './Settings'; +export * from './Warmup'; From 916765fa42f251005d5cc9f1abbe684e22899f48 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 25 Aug 2025 02:28:03 +0200 Subject: [PATCH 29/90] improved composer dsl --- src/engine/Composer.ts | 7 ++ src/engine/pipes/Events.ts | 43 ++++++--- src/engine/pipes/Messages.ts | 140 +++++++++++++--------------- src/engine/pipes/Scheduler.ts | 126 +++++++++++++------------- src/game/pipes/Image.ts | 131 ++++++++++++--------------- src/game/pipes/Phase.ts | 21 ++--- src/game/pipes/RandomImages.ts | 161 ++++++++++++++++----------------- src/game/pipes/Warmup.ts | 118 +++++++++++------------- 8 files changed, 361 insertions(+), 386 deletions(-) diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index 3bf3023..d51bdb2 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -65,6 +65,13 @@ export class Composer { return lensFromPath(path).get(this.obj); } + /** + * Shorthand for getting a value at the specified path from an object. + */ + static get(path: Path) { + return (obj: T): A => lensFromPath(path).get(obj); + } + /** * Replaces the current object with a new value. */ diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 729d332..53b304b 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -56,23 +56,38 @@ export const handleEvent: PipeTransformer< ) ); +export class Events { + static dispatch(event: GameEvent): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ dispatch }) => dispatch(event) + ); + } + + static handle(type: string, fn: (event: GameEvent) => Pipe): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ handle }) => handle(type, fn) + ); + } +} + /** * Moves events from pending to current. * This prevents events from being processed during the same frame they are created. * This is important because pipes later in the pipeline may add new events. */ -export const eventPipe: Pipe = Composer.chain(frame => - frame - .zoom('state', state => - state.over(PLUGIN_NAMESPACE, ({ pending = [] }) => ({ - pending: [], - current: pending, - })) - ) - .zoom('context', context => - context.set(PLUGIN_NAMESPACE, { - dispatch: dispatchEvent, - handle: handleEvent, - }) - ) +export const eventPipe: Pipe = Composer.pipe( + Composer.zoom('state', state => + state.over(PLUGIN_NAMESPACE, ({ pending = [] }) => ({ + pending: [], + current: pending, + })) + ), + Composer.zoom('context', context => + context.set(PLUGIN_NAMESPACE, { + dispatch: dispatchEvent, + handle: handleEvent, + }) + ) ); diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index b3310c0..e2eca3b 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -1,7 +1,7 @@ import { Pipe, PipeTransformer } from '../State'; import { Composer } from '../Composer'; -import { EventContext, getEventKey, GameEvent } from './Events'; -import { SchedulerContext } from './Scheduler'; +import { getEventKey, GameEvent, Events } from './Events'; +import { Scheduler } from './Scheduler'; export interface GameMessagePrompt { title: string; @@ -28,88 +28,72 @@ export type MessageState = { messages: GameMessage[]; }; -export const messagesPipe: Pipe = Composer.chain(c => { - const { dispatch, handle } = c.get([ - 'context', - 'core', - 'events', - ]); - - return c - .set(['context', PLUGIN_NAMESPACE], { - sendMessage: msg => - Composer.pipe( - dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), - payload: msg, - }) - ), - }) +export class Messages { + static send(message: PartialGameMessage): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ sendMessage }) => sendMessage(message) + ); + } +} - .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), event => - Composer.chain(c => { - const messageId = (event.payload as GameMessage).id; - const { schedule, cancel } = c.get([ - 'context', - 'core.scheduler', - ]); +export const messagesPipe: Pipe = Composer.pipe( + Composer.set(['context', PLUGIN_NAMESPACE], { + sendMessage: msg => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), + payload: msg, + }), + }), - return c - .over( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => { - const patch = event.payload as GameMessage; - const index = messages.findIndex(m => m.id === patch.id); - const existing = messages[index]; + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), event => + Composer.pipe( + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ messages = [] }) => { + const patch = event.payload as GameMessage; + const index = messages.findIndex(m => m.id === patch.id); + const existing = messages[index]; - if (!existing && !patch.title) return { messages }; + if (!existing && !patch.title) return { messages }; - const updated = [...messages]; - updated[index < 0 ? updated.length : index] = { - ...existing, - ...patch, - }; + const updated = [...messages]; + updated[index < 0 ? updated.length : index] = { + ...existing, + ...patch, + }; - return { messages: updated }; - } - ) + return { messages: updated }; + } + ), - .bind( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => - Composer.chain(c => { - const updated = messages.find(m => m.id === messageId); - const scheduleId = `${PLUGIN_NAMESPACE}.message.${messageId}`; - return c.pipe( - updated?.duration !== undefined - ? schedule({ - id: scheduleId, - duration: updated!.duration!, - event: { - type: getEventKey( - PLUGIN_NAMESPACE, - 'expireMessage' - ), - payload: updated.id, - }, - }) - : cancel(scheduleId) - ); - }) - ); - }) + Composer.bind( + ['state', PLUGIN_NAMESPACE], + ({ messages = [] }) => { + const messageId = (event.payload as GameMessage).id; + const updated = messages.find(m => m.id === messageId); + const scheduleId = `${PLUGIN_NAMESPACE}.message.${messageId}`; + return updated?.duration !== undefined + ? Scheduler.schedule({ + id: scheduleId, + duration: updated!.duration!, + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), + payload: updated.id, + }, + }) + : Scheduler.cancel(scheduleId); + } ) ) + ), - .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), event => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => ({ - messages: messages.filter(m => m.id !== event.payload), - }) - ) - ) - ); -}); + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ messages = [] }) => ({ + messages: messages.filter(m => m.id !== event.payload), + }) + ) + ) +); diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 8be98d4..a8dbf5f 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -1,6 +1,6 @@ import { Composer } from '../Composer'; import { Pipe, PipeTransformer } from '../State'; -import { EventContext, GameEvent, getEventKey } from './Events'; +import { Events, GameEvent, getEventKey } from './Events'; const PLUGIN_NAMESPACE = 'core.scheduler'; @@ -24,74 +24,76 @@ export type SchedulerContext = { cancel: PipeTransformer<[string]>; }; -export const schedulerPipe: Pipe = Composer.chain(c => { - const { dispatch, handle } = c.get([ - 'context', - 'core', - 'events', - ]); +export class Scheduler { + static schedule(event: ScheduledEvent): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ schedule }) => schedule(event) + ); + } - return c - .bind(['context', 'deltaTime'], delta => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ scheduled = [] }) => { - const remaining: ScheduledEvent[] = []; - const current: GameEvent[] = []; + static cancel(id: string): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ cancel }) => cancel(id) + ); + } +} - for (const entry of scheduled) { - const time = entry.duration - delta; - if (time <= 0) { - current.push(entry.event); - } else { - remaining.push({ ...entry, duration: time }); - } - } +export const schedulerPipe: Pipe = Composer.pipe( + Composer.bind(['context', 'deltaTime'], delta => + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ scheduled = [] }) => { + const remaining: ScheduledEvent[] = []; + const current: GameEvent[] = []; - return { scheduled: remaining, current }; + for (const entry of scheduled) { + const time = entry.duration - delta; + if (time <= 0) { + current.push(entry.event); + } else { + remaining.push({ ...entry, duration: time }); + } } - ) - ) - .bind(['state', PLUGIN_NAMESPACE, 'current'], events => - Composer.pipe(...events.map(dispatch)) + + return { scheduled: remaining, current }; + } ) + ), + + Composer.bind(['state', PLUGIN_NAMESPACE, 'current'], events => + Composer.pipe(...events.map(Events.dispatch)) + ), - .set(['context', PLUGIN_NAMESPACE], { - schedule: (e: ScheduledEvent) => - Composer.pipe( - dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'schedule'), - payload: e, - }) - ), + Composer.set(['context', PLUGIN_NAMESPACE], { + schedule: (e: ScheduledEvent) => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'schedule'), + payload: e, + }), - cancel: (id: string) => - Composer.pipe( - dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'cancel'), - payload: id, - }) - ), - }) + cancel: (id: string) => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'cancel'), + payload: id, + }), + }), - .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'schedule'), event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => [ - ...list.filter(e => e.id !== event.payload.id), - event.payload, - ] - ) - ) + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'schedule'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE, 'scheduled'], + (list = []) => [ + ...list.filter(e => e.id !== event.payload.id), + event.payload, + ] ) + ), - .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'cancel'), event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => list.filter(s => s.id !== event.payload) - ) - ) - ); -}); + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'cancel'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE, 'scheduled'], + (list = []) => list.filter(s => s.id !== event.payload) + ) + ) +); diff --git a/src/game/pipes/Image.ts b/src/game/pipes/Image.ts index 69cc112..703f02f 100644 --- a/src/game/pipes/Image.ts +++ b/src/game/pipes/Image.ts @@ -1,7 +1,7 @@ import { Composer } from '../../engine'; import { Pipe, PipeTransformer } from '../../engine/State'; import { ImageItem } from '../../types'; -import { EventContext, getEventKey } from '../../engine/pipes/Events'; +import { Events, getEventKey } from '../../engine/pipes/Events'; const PLUGIN_NAMESPACE = 'core.images'; @@ -17,85 +17,70 @@ export type ImageContext = { setNextImages: PipeTransformer<[ImageItem[]]>; }; -export const imagePipe: Pipe = Composer.chain(c => { - const { dispatch, handle } = c.get(['context', 'core.events']); +export const imagePipe: Pipe = Composer.pipe( + Composer.set(['context', PLUGIN_NAMESPACE], { + pushNextImage: image => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'pushNext'), + payload: image, + }), - return c - .set(['context', PLUGIN_NAMESPACE], { - pushNextImage: image => - Composer.pipe( - dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'pushNext'), - payload: image, - }) - ), + setCurrentImage: image => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'setImage'), + payload: image, + }), - setCurrentImage: image => - Composer.pipe( - dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'setImage'), - payload: image, - }) - ), + setNextImages: images => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'setNextImages'), + payload: images, + }), + }), - setNextImages: images => - Composer.pipe( - dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'setNextImages'), - payload: images, - }) - ), - }) + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'pushNext'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ currentImage, seenImages = [], nextImages = [] }) => { + const newImage = event.payload; + const next = [...nextImages]; + const seen = [...seenImages]; - .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'pushNext'), event => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ currentImage, seenImages = [], nextImages = [] }) => { - const newImage = event.payload; - const next = [...nextImages]; - const seen = [...seenImages]; - - if (currentImage) { - const existingIndex = seen.indexOf(currentImage); - if (existingIndex !== -1) { - seen.splice(existingIndex, 1); - } - seen.unshift(currentImage); - } + if (currentImage) { + const existingIndex = seen.indexOf(currentImage); + if (existingIndex !== -1) { + seen.splice(existingIndex, 1); + } + seen.unshift(currentImage); + } - if (seen.length > 500) { - seen.pop(); - } + if (seen.length > 500) { + seen.pop(); + } - next.push(newImage); - const newCurrent = next.shift(); + next.push(newImage); + const newCurrent = next.shift(); - return { - currentImage: newCurrent, - seenImages: seen, - nextImages: next, - }; - } - ) - ) + return { + currentImage: newCurrent, + seenImages: seen, + nextImages: next, + }; + } ) + ), - .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'setNextImages'), event => - Composer.over(['state', PLUGIN_NAMESPACE], state => ({ - ...state, - nextImages: event.payload, - })) - ) - ) + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'setNextImages'), event => + Composer.over(['state', PLUGIN_NAMESPACE], state => ({ + ...state, + nextImages: event.payload, + })) + ), - .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'setImage'), event => - Composer.over(['state', PLUGIN_NAMESPACE], state => ({ - ...state, - currentImage: event.payload, - })) - ) - ); -}); + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'setImage'), event => + Composer.over(['state', PLUGIN_NAMESPACE], state => ({ + ...state, + currentImage: event.payload, + })) + ) +); diff --git a/src/game/pipes/Phase.ts b/src/game/pipes/Phase.ts index 571066b..77c02f5 100644 --- a/src/game/pipes/Phase.ts +++ b/src/game/pipes/Phase.ts @@ -27,16 +27,15 @@ const PLUGIN_NAMESPACE = 'core.phase'; export const setPhase: PipeTransformer<[GamePhase]> = phase => Composer.set(['state', PLUGIN_NAMESPACE, 'current'], phase); -export const phasePipe: Pipe = Composer.chain(frame => - frame - .zoom('state', state => - state.unless(state.get(PLUGIN_NAMESPACE) != null, state => - state.set(PLUGIN_NAMESPACE, { - current: GamePhase.warmup, - }) - ) - ) - .zoom('context', context => - context.set(PLUGIN_NAMESPACE, { setPhase }) +export const phasePipe: Pipe = Composer.pipe( + Composer.zoom('state', state => + state.unless(state.get(PLUGIN_NAMESPACE) != null, state => + state.set(PLUGIN_NAMESPACE, { + current: GamePhase.warmup, + }) ) + ), + Composer.zoom('context', context => + context.set(PLUGIN_NAMESPACE, { setPhase }) + ) ); diff --git a/src/game/pipes/RandomImages.ts b/src/game/pipes/RandomImages.ts index ddfc79e..a9bfe62 100644 --- a/src/game/pipes/RandomImages.ts +++ b/src/game/pipes/RandomImages.ts @@ -1,8 +1,8 @@ import { Composer } from '../../engine'; import { Pipe } from '../../engine/State'; import { ImageItem } from '../../types'; -import { EventContext, getEventKey } from '../../engine/pipes/Events'; -import { SchedulerContext } from '../../engine/pipes/Scheduler'; +import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Scheduler } from '../../engine/pipes/Scheduler'; const PLUGIN_NAMESPACE = 'core.random_images'; @@ -10,97 +10,92 @@ const getImageSwitchDuration = (intensity: number): number => { return Math.max((100 - intensity * 100) * 80, 2000); }; -export const randomImagesPipe: Pipe = Composer.chain(c => { - const { dispatch, handle } = c.get(['context', 'core.events']); - const { schedule } = c.get(['context', 'core.scheduler']); - - return c.pipe( - Composer.bind( - ['state', PLUGIN_NAMESPACE, 'initialized'], - (initialized = false) => - Composer.unless(initialized, c => - c.pipe( - Composer.set(['state', PLUGIN_NAMESPACE, 'initialized'], true), - Composer.bind(['context', 'images'], images => - Composer.when(images.length > 0, c => { - const shuffled = [...images].sort(() => Math.random() - 0.5); - const initialImages = shuffled.slice( - 0, - Math.min(3, images.length) - ); +export const randomImagesPipe: Pipe = Composer.pipe( + Composer.bind( + ['state', PLUGIN_NAMESPACE, 'initialized'], + (initialized = false) => + Composer.unless(initialized, c => + c.pipe( + Composer.set(['state', PLUGIN_NAMESPACE, 'initialized'], true), + Composer.bind(['context', 'images'], images => + Composer.when(images.length > 0, c => { + const shuffled = [...images].sort(() => Math.random() - 0.5); + const initialImages = shuffled.slice( + 0, + Math.min(3, images.length) + ); - return c.pipe( - ...initialImages.map(image => - dispatch({ - type: getEventKey('core.images', 'pushNext'), - payload: image, - }) - ), - dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), + return c.pipe( + ...initialImages.map(image => + Events.dispatch({ + type: getEventKey('core.images', 'pushNext'), + payload: image, }) - ); - }) - ) + ), + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), + }) + ); + }) ) ) - ), + ) + ), - handle(getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), () => - Composer.bind( - ['state', 'core.intensity', 'intensity'], - (intensity = 0) => - Composer.bind(['context', 'images'], images => - Composer.bind( - ['state', 'core.images', 'seenImages'], - (seenImages = []) => - Composer.when(images.length > 0, c => { - const imagesWithDistance = images.map(image => { - const seenIndex = seenImages.indexOf(image); - const distance = - seenIndex === -1 ? seenImages.length : seenIndex; - return { image, distance }; - }); + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), () => + Composer.bind( + ['state', 'core.intensity', 'intensity'], + (intensity = 0) => + Composer.bind(['context', 'images'], images => + Composer.bind( + ['state', 'core.images', 'seenImages'], + (seenImages = []) => + Composer.when(images.length > 0, c => { + const imagesWithDistance = images.map(image => { + const seenIndex = seenImages.indexOf(image); + const distance = + seenIndex === -1 ? seenImages.length : seenIndex; + return { image, distance }; + }); - imagesWithDistance.sort((a, b) => b.distance - a.distance); + imagesWithDistance.sort((a, b) => b.distance - a.distance); - const weights = imagesWithDistance.map((_, index) => - Math.max(1, imagesWithDistance.length - index) - ); - const totalWeight = weights.reduce( - (sum, weight) => sum + weight, - 0 - ); + const weights = imagesWithDistance.map((_, index) => + Math.max(1, imagesWithDistance.length - index) + ); + const totalWeight = weights.reduce( + (sum, weight) => sum + weight, + 0 + ); - let random = Math.random() * totalWeight; - let selectedIndex = 0; - for (let i = 0; i < weights.length; i++) { - random -= weights[i]; - if (random <= 0) { - selectedIndex = i; - break; - } + let random = Math.random() * totalWeight; + let selectedIndex = 0; + for (let i = 0; i < weights.length; i++) { + random -= weights[i]; + if (random <= 0) { + selectedIndex = i; + break; } + } - const randomImage = imagesWithDistance[selectedIndex].image; + const randomImage = imagesWithDistance[selectedIndex].image; - return c.pipe( - dispatch({ - type: getEventKey('core.images', 'pushNext'), - payload: randomImage, - }), - schedule({ - id: 'randomImageSwitch', - duration: getImageSwitchDuration(intensity), - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), - }, - }) - ); - }) - ) + return c.pipe( + Events.dispatch({ + type: getEventKey('core.images', 'pushNext'), + payload: randomImage, + }), + Scheduler.schedule({ + id: 'randomImageSwitch', + duration: getImageSwitchDuration(intensity), + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), + }, + }) + ); + }) ) - ) + ) ) - ); -}); + ) +); diff --git a/src/game/pipes/Warmup.ts b/src/game/pipes/Warmup.ts index 4d0aa52..f59e743 100644 --- a/src/game/pipes/Warmup.ts +++ b/src/game/pipes/Warmup.ts @@ -1,7 +1,7 @@ import { Composer, Pipe } from '../../engine'; -import { EventContext, getEventKey } from '../../engine/pipes/Events'; -import { MessageContext } from '../../engine/pipes/Messages'; -import { getScheduleKey, SchedulerContext } from '../../engine/pipes/Scheduler'; +import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Messages } from '../../engine/pipes/Messages'; +import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; import { GamePhase, setPhase } from './Phase'; import { Settings } from '../../settings'; @@ -11,71 +11,59 @@ export type WarmupState = { initialized: boolean; }; -export const warmupPipe: Pipe = Composer.chain(c => { - const { sendMessage } = c.get(['context', 'core.messages']); - const { handle } = c.get(['context', 'core.events']); - const { schedule, cancel } = c.get([ - 'context', - 'core.scheduler', - ]); - - return c - .bind(['state', 'core.phase', 'current'], currentPhase => - Composer.bind(['context', 'settings'], settings => - Composer.bind( - ['state', PLUGIN_NAMESPACE], - (state = { initialized: false }) => - Composer.when(currentPhase === GamePhase.warmup, frame => - frame - .when(settings.warmupDuration === 0, f => - f.pipe(setPhase(GamePhase.active)) - ) - .when(settings.warmupDuration > 0 && !state.initialized, f => - f.pipe( - Composer.set(PLUGIN_NAMESPACE, { - initialized: true, - }), - sendMessage({ - id: GamePhase.warmup, - title: 'Get yourself ready!', - prompts: [ - { - title: `I'm ready, $master`, - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), - }, +export const warmupPipe: Pipe = Composer.pipe( + Composer.bind(['state', 'core.phase', 'current'], currentPhase => + Composer.bind(['context', 'settings'], settings => + Composer.bind( + ['state', PLUGIN_NAMESPACE], + (state = { initialized: false }) => + Composer.when(currentPhase === GamePhase.warmup, frame => + frame + .when(settings.warmupDuration === 0, f => + f.pipe(setPhase(GamePhase.active)) + ) + .when(settings.warmupDuration > 0 && !state.initialized, f => + f.pipe( + Composer.set(PLUGIN_NAMESPACE, { + initialized: true, + }), + Messages.send({ + id: GamePhase.warmup, + title: 'Get yourself ready!', + prompts: [ + { + title: `I'm ready, $master`, + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), }, - ], - }), - schedule({ - id: getScheduleKey(PLUGIN_NAMESPACE, 'autoStart'), - duration: settings.warmupDuration * 1000, - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), }, - }) - ) + ], + }), + Scheduler.schedule({ + id: getScheduleKey(PLUGIN_NAMESPACE, 'autoStart'), + duration: settings.warmupDuration * 1000, + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), + }, + }) ) - ) - ) + ) + ) ) ) + ), - .pipe( - handle(getEventKey(PLUGIN_NAMESPACE, 'startGame'), () => - Composer.pipe( - cancel(getScheduleKey(PLUGIN_NAMESPACE, 'autoStart')), - Composer.set(PLUGIN_NAMESPACE, { - initialized: false, - }), - sendMessage({ - id: GamePhase.warmup, - title: 'Now follow what I say, $player!', - duration: 5000, - prompts: undefined, - }), - setPhase(GamePhase.active) - ) - ) - ); -}); + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'startGame'), () => + Composer.pipe( + Scheduler.cancel(getScheduleKey(PLUGIN_NAMESPACE, 'autoStart')), + Composer.set(PLUGIN_NAMESPACE, { initialized: false }), + Messages.send({ + id: GamePhase.warmup, + title: 'Now follow what I say, $player!', + duration: 5000, + prompts: undefined, + }), + setPhase(GamePhase.active) + ) + ) +); From 0d193d9d3cb7daaf6a494671a62963c2d38bd279 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 25 Aug 2025 03:48:41 +0200 Subject: [PATCH 30/90] improved static composer functions --- src/engine/Composer.ts | 10 +-- src/engine/pipes/Events.ts | 10 +-- src/engine/pipes/Messages.ts | 46 ++++++++----- src/game/pipes/Phase.ts | 19 ++++-- src/game/pipes/RandomImages.ts | 121 +++++++++++++++++---------------- src/game/pipes/Warmup.ts | 20 +++--- 6 files changed, 127 insertions(+), 99 deletions(-) diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index d51bdb2..7a70f23 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -115,9 +115,9 @@ export class Composer { /** * Shorthand for building a composer that zooms into a path */ - static zoom(path: Path, fn: Compositor) { + static zoom(path: Path, fn: (a: A) => A) { return (obj: T): T => - new Composer(obj).zoom(path, fn).get(); + Composer.chain(c => c.zoom(path, c => c.pipe(fn)))(obj); } /** @@ -165,9 +165,9 @@ export class Composer { */ static when( condition: boolean, - fn: Compositor + fn: (obj: T) => T ): (obj: T) => T { - return (obj: T) => Composer.chain(c => c.when(condition, fn))(obj); + return Composer.chain(c => c.when(condition, c => c.pipe(fn))); } /** @@ -182,7 +182,7 @@ export class Composer { */ static unless( condition: boolean, - fn: Compositor + fn: (obj: T) => T ): (obj: T) => T { return Composer.when(!condition, fn); } diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 53b304b..ba1abe8 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -78,14 +78,16 @@ export class Events { * This is important because pipes later in the pipeline may add new events. */ export const eventPipe: Pipe = Composer.pipe( - Composer.zoom('state', state => - state.over(PLUGIN_NAMESPACE, ({ pending = [] }) => ({ + Composer.zoom( + 'state', + Composer.over(PLUGIN_NAMESPACE, ({ pending = [] }) => ({ pending: [], current: pending, })) ), - Composer.zoom('context', context => - context.set(PLUGIN_NAMESPACE, { + Composer.zoom( + 'context', + Composer.set(PLUGIN_NAMESPACE, { dispatch: dispatchEvent, handle: handleEvent, }) diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts index e2eca3b..c7f0785 100644 --- a/src/engine/pipes/Messages.ts +++ b/src/engine/pipes/Messages.ts @@ -1,7 +1,7 @@ import { Pipe, PipeTransformer } from '../State'; import { Composer } from '../Composer'; import { getEventKey, GameEvent, Events } from './Events'; -import { Scheduler } from './Scheduler'; +import { getScheduleKey, Scheduler } from './Scheduler'; export interface GameMessagePrompt { title: string; @@ -67,24 +67,34 @@ export const messagesPipe: Pipe = Composer.pipe( } ), - Composer.bind( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => { - const messageId = (event.payload as GameMessage).id; - const updated = messages.find(m => m.id === messageId); - const scheduleId = `${PLUGIN_NAMESPACE}.message.${messageId}`; - return updated?.duration !== undefined - ? Scheduler.schedule({ - id: scheduleId, - duration: updated!.duration!, - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), - payload: updated.id, - }, - }) - : Scheduler.cancel(scheduleId); + Composer.chain(c => { + const { messages = [] } = c.get([ + 'state', + PLUGIN_NAMESPACE, + ]); + const messageId = (event.payload as GameMessage).id; + const updated = messages.find(m => m.id === messageId); + const scheduleId = getScheduleKey( + PLUGIN_NAMESPACE, + `message/${messageId}` + ); + + if (updated?.duration !== undefined) { + const eventType = getEventKey(PLUGIN_NAMESPACE, 'expireMessage'); + return c.pipe( + Scheduler.schedule({ + id: scheduleId, + duration: updated.duration, + event: { + type: eventType, + payload: updated.id, + }, + }) + ); + } else { + return c.pipe(Scheduler.cancel(scheduleId)); } - ) + }) ) ), diff --git a/src/game/pipes/Phase.ts b/src/game/pipes/Phase.ts index 77c02f5..8f8337d 100644 --- a/src/game/pipes/Phase.ts +++ b/src/game/pipes/Phase.ts @@ -28,14 +28,19 @@ export const setPhase: PipeTransformer<[GamePhase]> = phase => Composer.set(['state', PLUGIN_NAMESPACE, 'current'], phase); export const phasePipe: Pipe = Composer.pipe( - Composer.zoom('state', state => - state.unless(state.get(PLUGIN_NAMESPACE) != null, state => - state.set(PLUGIN_NAMESPACE, { - current: GamePhase.warmup, - }) + Composer.zoom( + 'state', + Composer.bind(PLUGIN_NAMESPACE, state => + Composer.when( + state == null, + Composer.set(PLUGIN_NAMESPACE, { + current: GamePhase.warmup, + }) + ) ) ), - Composer.zoom('context', context => - context.set(PLUGIN_NAMESPACE, { setPhase }) + Composer.zoom( + 'context', + Composer.set(PLUGIN_NAMESPACE, { setPhase }) ) ); diff --git a/src/game/pipes/RandomImages.ts b/src/game/pipes/RandomImages.ts index a9bfe62..b9fd921 100644 --- a/src/game/pipes/RandomImages.ts +++ b/src/game/pipes/RandomImages.ts @@ -2,7 +2,7 @@ import { Composer } from '../../engine'; import { Pipe } from '../../engine/State'; import { ImageItem } from '../../types'; import { Events, getEventKey } from '../../engine/pipes/Events'; -import { Scheduler } from '../../engine/pipes/Scheduler'; +import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; const PLUGIN_NAMESPACE = 'core.random_images'; @@ -14,29 +14,33 @@ export const randomImagesPipe: Pipe = Composer.pipe( Composer.bind( ['state', PLUGIN_NAMESPACE, 'initialized'], (initialized = false) => - Composer.unless(initialized, c => - c.pipe( + Composer.unless( + initialized, + Composer.pipe( Composer.set(['state', PLUGIN_NAMESPACE, 'initialized'], true), Composer.bind(['context', 'images'], images => - Composer.when(images.length > 0, c => { - const shuffled = [...images].sort(() => Math.random() - 0.5); - const initialImages = shuffled.slice( - 0, - Math.min(3, images.length) - ); + Composer.when( + images.length > 0, + Composer.chain(c => { + const shuffled = [...images].sort(() => Math.random() - 0.5); + const initialImages = shuffled.slice( + 0, + Math.min(3, images.length) + ); - return c.pipe( - ...initialImages.map(image => + return c.pipe( + ...initialImages.map(image => + Events.dispatch({ + type: getEventKey('core.images', 'pushNext'), + payload: image, + }) + ), Events.dispatch({ - type: getEventKey('core.images', 'pushNext'), - payload: image, + type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), }) - ), - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), - }) - ); - }) + ); + }) + ) ) ) ) @@ -50,50 +54,53 @@ export const randomImagesPipe: Pipe = Composer.pipe( Composer.bind( ['state', 'core.images', 'seenImages'], (seenImages = []) => - Composer.when(images.length > 0, c => { - const imagesWithDistance = images.map(image => { - const seenIndex = seenImages.indexOf(image); - const distance = - seenIndex === -1 ? seenImages.length : seenIndex; - return { image, distance }; - }); + Composer.when( + images.length > 0, + Composer.chain(c => { + const imagesWithDistance = images.map(image => { + const seenIndex = seenImages.indexOf(image); + const distance = + seenIndex === -1 ? seenImages.length : seenIndex; + return { image, distance }; + }); - imagesWithDistance.sort((a, b) => b.distance - a.distance); + imagesWithDistance.sort((a, b) => b.distance - a.distance); - const weights = imagesWithDistance.map((_, index) => - Math.max(1, imagesWithDistance.length - index) - ); - const totalWeight = weights.reduce( - (sum, weight) => sum + weight, - 0 - ); + const weights = imagesWithDistance.map((_, index) => + Math.max(1, imagesWithDistance.length - index) + ); + const totalWeight = weights.reduce( + (sum, weight) => sum + weight, + 0 + ); - let random = Math.random() * totalWeight; - let selectedIndex = 0; - for (let i = 0; i < weights.length; i++) { - random -= weights[i]; - if (random <= 0) { - selectedIndex = i; - break; + let random = Math.random() * totalWeight; + let selectedIndex = 0; + for (let i = 0; i < weights.length; i++) { + random -= weights[i]; + if (random <= 0) { + selectedIndex = i; + break; + } } - } - const randomImage = imagesWithDistance[selectedIndex].image; + const randomImage = imagesWithDistance[selectedIndex].image; - return c.pipe( - Events.dispatch({ - type: getEventKey('core.images', 'pushNext'), - payload: randomImage, - }), - Scheduler.schedule({ - id: 'randomImageSwitch', - duration: getImageSwitchDuration(intensity), - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), - }, - }) - ); - }) + return c.pipe( + Events.dispatch({ + type: getEventKey('core.images', 'pushNext'), + payload: randomImage, + }), + Scheduler.schedule({ + id: getScheduleKey(PLUGIN_NAMESPACE, 'randomImageSwitch'), + duration: getImageSwitchDuration(intensity), + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), + }, + }) + ); + }) + ) ) ) ) diff --git a/src/game/pipes/Warmup.ts b/src/game/pipes/Warmup.ts index f59e743..52ce1af 100644 --- a/src/game/pipes/Warmup.ts +++ b/src/game/pipes/Warmup.ts @@ -17,14 +17,17 @@ export const warmupPipe: Pipe = Composer.pipe( Composer.bind( ['state', PLUGIN_NAMESPACE], (state = { initialized: false }) => - Composer.when(currentPhase === GamePhase.warmup, frame => - frame - .when(settings.warmupDuration === 0, f => - f.pipe(setPhase(GamePhase.active)) - ) - .when(settings.warmupDuration > 0 && !state.initialized, f => - f.pipe( - Composer.set(PLUGIN_NAMESPACE, { + Composer.when( + currentPhase === GamePhase.warmup, + Composer.pipe( + Composer.when( + settings.warmupDuration === 0, + setPhase(GamePhase.active) + ), + Composer.when( + settings.warmupDuration > 0 && !state.initialized, + Composer.pipe( + Composer.set(['state', PLUGIN_NAMESPACE], { initialized: true, }), Messages.send({ @@ -48,6 +51,7 @@ export const warmupPipe: Pipe = Composer.pipe( }) ) ) + ) ) ) ) From e26c907adbdb360efcd379379ffeac0b7991b1c3 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 25 Aug 2025 13:04:43 +0200 Subject: [PATCH 31/90] made intensity phase aware --- src/game/pipes/Intensity.ts | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/game/pipes/Intensity.ts b/src/game/pipes/Intensity.ts index a417286..c1cb144 100644 --- a/src/game/pipes/Intensity.ts +++ b/src/game/pipes/Intensity.ts @@ -1,4 +1,6 @@ -import { Composer, GameContext, Pipe } from '../../engine'; +import { Composer, Pipe } from '../../engine'; +import { Settings } from '../../settings'; +import { GamePhase } from './Phase'; const PLUGIN_NAMESPACE = 'core.intensity'; @@ -6,18 +8,23 @@ export type IntensityState = { intensity: number; }; -export const intensityPipe: Pipe = Composer.bind( - ['context', 'deltaTime'], - delta => - Composer.bind(['context'], ({ settings }) => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ intensity = 0 }) => ({ - intensity: Math.min( - 1, - intensity + delta / (settings.gameDuration * 1000) - ), - }) +export const intensityPipe: Pipe = Composer.bind( + ['state', 'core.phase', 'current'], + currentPhase => + Composer.when( + currentPhase === GamePhase.active, + Composer.bind(['context', 'deltaTime'], delta => + Composer.bind(['context', 'settings'], settings => + Composer.over( + ['state', PLUGIN_NAMESPACE], + ({ intensity = 0 }) => ({ + intensity: Math.min( + 1, + intensity + delta / (settings.gameDuration * 1000) + ), + }) + ) + ) ) ) ); From b74fb105a4b62f67e2310e6ae993efd1c4abc9c4 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 25 Aug 2025 15:13:56 +0200 Subject: [PATCH 32/90] added pace pipe --- src/game/GamePage.tsx | 3 ++- src/game/components/GamePace.tsx | 9 ++++---- src/game/pipes/Pace.ts | 39 ++++++++++++++++++++++++++++++++ src/game/pipes/index.ts | 1 + 4 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 src/game/pipes/Pace.ts diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 225ce48..6e1e120 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -5,7 +5,7 @@ import { messagesPipe } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; import { fpsPipe } from '../engine/pipes/Fps'; -import { useSettingsPipe, warmupPipe } from './pipes'; +import { useSettingsPipe, warmupPipe, pacePipe } from './pipes'; import { GameIntensity } from './components/GameIntensity'; import { intensityPipe } from './pipes/Intensity'; import { imagePipe, randomImagesPipe } from './pipes'; @@ -82,6 +82,7 @@ export const GamePage = () => { messagesPipe, settingsPipe, phasePipe, + pacePipe, intensityPipe, imagePipe, randomImagesPipe, diff --git a/src/game/components/GamePace.tsx b/src/game/components/GamePace.tsx index 56dec88..70bcacd 100644 --- a/src/game/components/GamePace.tsx +++ b/src/game/components/GamePace.tsx @@ -1,14 +1,15 @@ import { useEffect } from 'react'; -import { useGameValue } from '../GameProvider'; +import { useGameContext } from '../hooks'; import { useSetting } from '../../settings'; +import { PaceContext } from '../pipes/Pace'; export const GamePace = () => { const [minPace] = useSetting('minPace'); - const [, setPace] = useGameValue('pace'); + const { resetPace } = useGameContext(['core.pace']); useEffect(() => { - setPace(minPace); - }, [minPace, setPace]); + resetPace(); + }, [minPace, resetPace]); return null; }; diff --git a/src/game/pipes/Pace.ts b/src/game/pipes/Pace.ts new file mode 100644 index 0000000..6c59065 --- /dev/null +++ b/src/game/pipes/Pace.ts @@ -0,0 +1,39 @@ +import { Composer, Pipe, PipeTransformer } from '../../engine'; +import { Settings } from '../../settings'; + +const PLUGIN_NAMESPACE = 'core.pace'; + +export type PaceState = { + pace: number; +}; + +export type PaceContext = { + setPace: PipeTransformer<[number]>; + resetPace: PipeTransformer<[]>; +}; + +export const pacePipe: Pipe = Composer.pipe( + Composer.bind(['context', 'settings'], settings => + Composer.bind(['state', PLUGIN_NAMESPACE], state => + Composer.when( + state == null, + Composer.set(['state', PLUGIN_NAMESPACE], { + pace: settings.minPace, + }) + ) + ) + ), + + Composer.set(['context', PLUGIN_NAMESPACE], { + setPace: (pace: number) => + Composer.set(['state', PLUGIN_NAMESPACE, 'pace'], pace), + + resetPace: () => + Composer.bind(['context', 'settings'], settings => + Composer.set( + ['state', PLUGIN_NAMESPACE, 'pace'], + settings.minPace + ) + ), + }) +); diff --git a/src/game/pipes/index.ts b/src/game/pipes/index.ts index 43af78d..39850d8 100644 --- a/src/game/pipes/index.ts +++ b/src/game/pipes/index.ts @@ -1,4 +1,5 @@ export * from './Image'; +export * from './Pace'; export * from './RandomImages'; export * from './Settings'; export * from './Warmup'; From ad1026c3d932609d5d9e5c6e6d906784368c56bf Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 2 Nov 2025 18:58:15 +0100 Subject: [PATCH 33/90] added tests --- .gitignore | 1 + package.json | 4 +- src/engine/Composer.test.ts | 281 ++++++++++++++++++++++ src/engine/Engine.test.ts | 92 +++++++ src/engine/Lens.test.ts | 219 +++++++++++++++++ src/engine/Lens.ts | 1 + vite.config.ts | 11 +- yarn.lock | 468 +++++++++++++++++++++++++++++++++++- 8 files changed, 1069 insertions(+), 8 deletions(-) create mode 100644 src/engine/Composer.test.ts create mode 100644 src/engine/Engine.test.ts create mode 100644 src/engine/Lens.test.ts diff --git a/.gitignore b/.gitignore index a547bf3..de9ee66 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +coverage # Editor directories and files .vscode/* diff --git a/package.json b/package.json index 4d51394..9bea6a5 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^4.0.6", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -63,7 +64,8 @@ "jsdom": "^27.1.0", "prettier": "^3.2.5", "typescript": "^5.2.2", - "vite": "^5.2.0" + "vite": "^5.2.0", + "vitest": "^4.0.6" }, "engines": { "node": "^20.0.0", diff --git a/src/engine/Composer.test.ts b/src/engine/Composer.test.ts new file mode 100644 index 0000000..e1de116 --- /dev/null +++ b/src/engine/Composer.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from 'vitest'; +import { Composer } from './Composer'; + +describe('Composer', () => { + describe('instance methods', () => { + describe('chain', () => { + it('should chain composer functions', () => { + const obj = { count: 5 }; + const result = new Composer(obj) + .chain(c => c.set('count', 10)) + .chain(c => c.over('count', x => x * 2)) + .get(); + + expect(result.count).toBe(20); + }); + }); + + describe('pipe', () => { + it('should apply multiple functions in sequence', () => { + const obj = { value: 1 }; + const result = new Composer(obj) + .pipe( + o => ({ ...o, value: o.value + 1 }), + o => ({ ...o, value: o.value * 2 }) + ) + .get(); + + expect(result.value).toBe(4); + }); + }); + + describe('get', () => { + it('should get the whole object when no path provided', () => { + const obj = { foo: 'bar' }; + const result = new Composer(obj).get(); + + expect(result).toEqual({ foo: 'bar' }); + }); + + it('should get nested value', () => { + const obj = { a: { b: { c: 42 } } }; + const result = new Composer(obj).get('a.b.c'); + + expect(result).toBe(42); + }); + }); + + describe('set', () => { + it('should replace entire object', () => { + const obj = { old: 'value' }; + const result = new Composer(obj).set({ new: 'value' }).get(); + + expect(result).toEqual({ new: 'value' }); + }); + + it('should set nested value', () => { + const obj = { a: { b: 1 } }; + const result = new Composer(obj).set('a.b', 42).get(); + + expect(result.a.b).toBe(42); + }); + }); + + describe('zoom', () => { + it('should compose on a nested object', () => { + const obj = { + user: { + name: 'Alice', + age: 30, + }, + }; + + const result = new Composer(obj) + .zoom('user', c => c.set('age', 31).set('name', 'Bob')) + .get(); + + expect(result.user.name).toBe('Bob'); + expect(result.user.age).toBe(31); + }); + }); + + describe('over', () => { + it('should update a nested property', () => { + const obj = { counter: 5 }; + const result = new Composer(obj) + .over('counter', x => x + 1) + .get(); + + expect(result.counter).toBe(6); + }); + }); + + describe('bind', () => { + it('should read value and apply transformer', () => { + const obj = { x: 10, y: 0 }; + const result = new Composer(obj) + .bind('x', x => o => ({ ...o, y: x * 2 })) + .get(); + + expect(result.y).toBe(20); + expect(result.x).toBe(10); + }); + }); + + describe('when', () => { + it('should apply function when condition is true', () => { + const obj = { value: 5 }; + const result = new Composer(obj) + .when(true, c => c.set('value', 10)) + .get(); + + expect(result.value).toBe(10); + }); + + it('should skip function when condition is false', () => { + const obj = { value: 5 }; + const result = new Composer(obj) + .when(false, c => c.set('value', 10)) + .get(); + + expect(result.value).toBe(5); + }); + }); + + describe('unless', () => { + it('should skip function when condition is true', () => { + const obj = { value: 5 }; + const result = new Composer(obj) + .unless(true, c => c.set('value', 10)) + .get(); + + expect(result.value).toBe(5); + }); + + it('should apply function when condition is false', () => { + const obj = { value: 5 }; + const result = new Composer(obj) + .unless(false, c => c.set('value', 10)) + .get(); + + expect(result.value).toBe(10); + }); + }); + }); + + describe('static methods', () => { + describe('Composer.chain', () => { + it('should create a function that chains composers', () => { + const fn = Composer.chain<{ count: number }>(c => + c.set('count', 10).over('count', x => x * 2) + ); + + const result = fn({ count: 5 }); + expect(result.count).toBe(20); + }); + }); + + describe('Composer.pipe', () => { + it('should create a function that pipes transformations', () => { + const fn = Composer.pipe<{ value: number }>( + o => ({ ...o, value: o.value + 1 }), + o => ({ ...o, value: o.value * 2 }) + ); + + const result = fn({ value: 1 }); + expect(result.value).toBe(4); + }); + }); + + describe('Composer.get', () => { + it('should create a getter function', () => { + const getValue = Composer.get('a.b.c'); + const result = getValue({ a: { b: { c: 42 } } }); + + expect(result).toBe(42); + }); + }); + + describe('Composer.set', () => { + it('should create a setter function', () => { + const setCount = Composer.set('count', 100); + const result = setCount({ count: 0 }); + + expect(result.count).toBe(100); + }); + }); + + describe('Composer.zoom', () => { + it('should create a zoom function', () => { + const updateUser = Composer.zoom<{ age: number }>('user', u => ({ + ...u, + age: u.age + 1, + })); + + const result = updateUser({ user: { age: 30 } }); + expect(result.user.age).toBe(31); + }); + }); + + describe('Composer.over', () => { + it('should create an over function', () => { + const increment = Composer.over('counter', x => x + 1); + const result = increment({ counter: 5 }); + + expect(result.counter).toBe(6); + }); + + it('should update a deeply nested object', () => { + const obj = { + data: { + user: { + profile: { + scores: [10, 20, 30], + }, + }, + }, + }; + + const result = Composer.over( + ['data', 'user.profile.scores'], + scores => [...scores, 40] + )(obj); + + expect(result.data.user.profile.scores).toEqual([10, 20, 30, 40]); + }); + }); + + describe('Composer.bind', () => { + it('should create a bind function', () => { + const fn = Composer.bind('x', x => o => ({ ...o, y: x * 2 })); + const result = fn({ x: 10, y: 0 }); + + expect(result.y).toBe(20); + }); + }); + + describe('Composer.when', () => { + it('should create a conditional function (true)', () => { + const fn = Composer.when<{ value: number }>(true, o => ({ + ...o, + value: o.value * 2, + })); + + const result = fn({ value: 5 }); + expect(result.value).toBe(10); + }); + + it('should create a conditional function (false)', () => { + const fn = Composer.when<{ value: number }>(false, o => ({ + ...o, + value: o.value * 2, + })); + + const result = fn({ value: 5 }); + expect(result.value).toBe(5); + }); + }); + + describe('Composer.unless', () => { + it('should create a conditional function (true)', () => { + const fn = Composer.unless<{ value: number }>(true, o => ({ + ...o, + value: o.value * 2, + })); + + const result = fn({ value: 5 }); + expect(result.value).toBe(5); + }); + + it('should create a conditional function (false)', () => { + const fn = Composer.unless<{ value: number }>(false, o => ({ + ...o, + value: o.value * 2, + })); + + const result = fn({ value: 5 }); + expect(result.value).toBe(10); + }); + }); + }); +}); diff --git a/src/engine/Engine.test.ts b/src/engine/Engine.test.ts new file mode 100644 index 0000000..dfc43d7 --- /dev/null +++ b/src/engine/Engine.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { GameEngine } from './Engine'; +import { GameFrame, Pipe } from './State'; + +describe('GameEngine', () => { + it('should initialize with given state', () => { + const initialState = { foo: 'bar' }; + const pipe: Pipe = frame => frame; + const engine = new GameEngine(initialState, pipe); + + expect(engine.getState()).toEqual({ foo: 'bar' }); + }); + + it('should increment tick on each tick', () => { + const engine = new GameEngine({}, frame => frame); + + engine.tick(16); + expect(engine.getContext().tick).toBe(1); + + engine.tick(16); + expect(engine.getContext().tick).toBe(2); + }); + + it('should accumulate elapsed time', () => { + const engine = new GameEngine({}, frame => frame); + + engine.tick(16); + expect(engine.getContext().elapsedTime).toBe(16); + + engine.tick(20); + expect(engine.getContext().elapsedTime).toBe(36); + }); + + it('should update deltaTime on each tick', () => { + const engine = new GameEngine({}, frame => frame); + + engine.tick(16); + expect(engine.getContext().deltaTime).toBe(16); + + engine.tick(33); + expect(engine.getContext().deltaTime).toBe(33); + }); + + it('should pass state through pipe', () => { + const pipe: Pipe = (frame: GameFrame) => ({ + ...frame, + state: { ...frame.state, modified: true }, + }); + + const engine = new GameEngine({}, pipe); + engine.tick(16); + + expect(engine.getState()).toEqual({ modified: true }); + }); + + it('should deep clone state after pipe execution', () => { + const pipe: Pipe = (frame: GameFrame) => { + const nested = { value: 42 }; + return { + ...frame, + state: { nested }, + }; + }; + + const engine = new GameEngine({}, pipe); + engine.tick(16); + + const state1 = engine.getState(); + engine.tick(16); + const state2 = engine.getState(); + + expect(state1.nested).not.toBe(state2.nested); + expect(state1.nested).toEqual(state2.nested); + }); + + it('should deep clone context after pipe execution', () => { + const pipe: Pipe = (frame: GameFrame) => ({ + ...frame, + context: { ...frame.context, data: { value: 42 } }, + }); + + const engine = new GameEngine({}, pipe); + engine.tick(16); + + const ctx1 = engine.getContext(); + engine.tick(16); + const ctx2 = engine.getContext(); + + expect(ctx1.data).not.toBe(ctx2.data); + expect(ctx1.data).toEqual(ctx2.data); + }); +}); diff --git a/src/engine/Lens.test.ts b/src/engine/Lens.test.ts new file mode 100644 index 0000000..8e1055a --- /dev/null +++ b/src/engine/Lens.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest'; +import { lensFromPath, normalizePath } from './Lens'; + +describe('Lens', () => { + describe('normalizePath', () => { + it('should split string paths by dots', () => { + expect(normalizePath('a.b.c')).toEqual(['a', 'b', 'c']); + }); + + it('should handle single segment paths', () => { + expect(normalizePath('foo')).toEqual(['foo']); + }); + + it('should return array paths as-is', () => { + expect(normalizePath(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']); + }); + + it('should flatten array paths with dotted strings', () => { + expect(normalizePath(['a', 'b.c', 'd'])).toEqual(['a', 'b', 'c', 'd']); + }); + + it('should handle numeric keys', () => { + expect(normalizePath(['arr', 0, 'prop'])).toEqual(['arr', 0, 'prop']); + }); + + it('should handle symbol keys', () => { + const sym = Symbol('test'); + expect(normalizePath(['obj', sym])).toEqual(['obj', sym]); + }); + }); + + describe('lensFromPath', () => { + describe('get', () => { + it('should retrieve a nested value', () => { + const lens = lensFromPath<{ a: { b: number } }, number>('a.b'); + const obj = { a: { b: 42 } }; + + expect(lens.get(obj)).toBe(42); + }); + + it('should return undefined for missing paths', () => { + const lens = lensFromPath<{ a: { b?: number } }, number>('a.b'); + const obj = { a: {} }; + + expect(lens.get(obj)).toBeUndefined(); + }); + + it('should return undefined for null/undefined intermediate values', () => { + const lens = lensFromPath<{ a: null }, any>('a.b'); + const obj = { a: null }; + + expect(lens.get(obj)).toBeUndefined(); + }); + + it('should handle array indexing', () => { + const lens = lensFromPath<{ arr: number[] }, number>(['arr', 1]); + const obj = { arr: [10, 20, 30] }; + + expect(lens.get(obj)).toBe(20); + }); + + it('should handle empty path (identity)', () => { + const lens = lensFromPath<{ foo: string }, { foo: string }>(''); + const obj = { foo: 'bar' }; + + expect(lens.get(obj)).toEqual(obj); + }); + }); + + describe('set', () => { + it('should set a nested value', () => { + const lens = lensFromPath<{ a: { b: number } }, number>('a.b'); + const obj = { a: { b: 1 } }; + + const result = lens.set(42)(obj); + + expect(result.a.b).toBe(42); + expect(obj.a.b).toBe(1); + }); + + it('should create missing intermediate objects', () => { + const lens = lensFromPath<{ a?: { b?: number } }, number>('a.b'); + const obj = {}; + + const result = lens.set(42)(obj); + + expect(result.a?.b).toBe(42); + }); + + it('should not mutate the original object', () => { + const lens = lensFromPath<{ a: { b: number } }, number>('a.b'); + const obj = { a: { b: 1 } }; + + const result = lens.set(42)(obj); + + expect(result).not.toBe(obj); + expect(result.a).not.toBe(obj.a); + expect(obj.a.b).toBe(1); + }); + + it('should handle array indexing', () => { + const lens = lensFromPath<{ arr: number[] }, number>(['arr', 1]); + const obj = { arr: [10, 20, 30] }; + + const result = lens.set(99)(obj); + + expect(result.arr[1]).toBe(99); + expect(obj.arr[1]).toBe(20); + }); + + it('should handle empty path (replace entire value)', () => { + const lens = lensFromPath<{ foo: string }, { foo: string }>(''); + const obj = { foo: 'bar' }; + + const result = lens.set({ foo: 'baz' })(obj); + + expect(result).toEqual({ foo: 'baz' }); + expect(obj.foo).toBe('bar'); + }); + }); + + describe('over', () => { + it('should handle empty path (transform entire value)', () => { + const lens = lensFromPath<{ count: number }, { count: number }>(''); + const obj = { count: 5 }; + + const result = lens.over(x => ({ count: x.count * 2 }))(obj); + + expect(result).toEqual({ count: 10 }); + expect(obj.count).toBe(5); + }); + + it('should transform a nested value', () => { + const lens = lensFromPath<{ a: { b: number } }, number>('a.b'); + const obj = { a: { b: 5 } }; + + const result = lens.over(x => x * 2)(obj); + + expect(result.a.b).toBe(10); + expect(obj.a.b).toBe(5); + }); + + it('should provide empty object when value is undefined', () => { + const lens = lensFromPath< + { a?: { b?: { value: number } } }, + { value: number } + >('a.b'); + const obj = {}; + + const result = lens.over(x => ({ value: (x.value || 0) + 1 }))(obj); + + expect(result.a?.b?.value).toBe(1); + }); + + it('should not mutate the original object', () => { + const lens = lensFromPath<{ a: { b: number } }, number>('a.b'); + const obj = { a: { b: 1 } }; + + const result = lens.over(x => x + 10)(obj); + + expect(result).not.toBe(obj); + expect(result.a).not.toBe(obj.a); + expect(obj.a.b).toBe(1); + }); + + it('should handle array transformations', () => { + const lens = lensFromPath<{ arr: number[] }, number>(['arr', 0]); + const obj = { arr: [1, 2, 3] }; + + const result = lens.over(x => x * 10)(obj); + + expect(result.arr[0]).toBe(10); + expect(obj.arr[0]).toBe(1); + }); + + it('should handle complex transformations', () => { + type State = { + plugins: { + active: string[]; + inserting: string[]; + }; + }; + + const lens = lensFromPath('plugins'); + const obj: State = { + plugins: { + active: ['a', 'b'], + inserting: [], + }, + }; + + const result = lens.over(plugins => ({ + active: [...plugins.active, ...plugins.inserting], + inserting: [], + }))(obj); + + expect(result.plugins.active).toEqual(['a', 'b']); + expect(result.plugins.inserting).toEqual([]); + expect(obj.plugins.active).toEqual(['a', 'b']); + }); + }); + + describe('deeply nested paths', () => { + it('should handle deep nesting', () => { + const lens = lensFromPath< + { a: { b: { c: { d: { e: number } } } } }, + number + >('a.b.c.d.e'); + const obj = { a: { b: { c: { d: { e: 42 } } } } }; + + expect(lens.get(obj)).toBe(42); + + const result = lens.set(99)(obj); + expect(result.a.b.c.d.e).toBe(99); + expect(obj.a.b.c.d.e).toBe(42); + }); + }); + }); +}); diff --git a/src/engine/Lens.ts b/src/engine/Lens.ts index 0f0c435..46bb15b 100644 --- a/src/engine/Lens.ts +++ b/src/engine/Lens.ts @@ -26,6 +26,7 @@ export function lensFromPath(path: Path): Lens { if (parts.length === 0 || (parts.length === 1 && parts[0] === '')) { return { get: (source: S) => source as unknown as A, + // eslint-disable-next-line @typescript-eslint/no-unused-vars set: (value: A) => (_source: S) => value as unknown as S, over: (fn: (a: A) => A) => (source: S) => fn(source as unknown as A) as unknown as S, diff --git a/vite.config.ts b/vite.config.ts index 5816367..51bd8d1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ @@ -25,4 +25,13 @@ export default defineConfig({ }, force: true, }, + test: { + environment: 'jsdom', + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'], + }, + }, }); diff --git a/yarn.lock b/yarn.lock index ea64a25..98c84fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -296,6 +296,11 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@bcoe/v8-coverage@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" + integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== + "@csstools/color-helpers@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-6.0.1.tgz#637c08a61bea78be9b602216f47b0fb93c996178" @@ -356,116 +361,246 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== +"@esbuild/aix-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" + integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== + "@esbuild/android-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== +"@esbuild/android-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" + integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== + "@esbuild/android-arm@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== +"@esbuild/android-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" + integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== + "@esbuild/android-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== +"@esbuild/android-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" + integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== + "@esbuild/darwin-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== +"@esbuild/darwin-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" + integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== + "@esbuild/darwin-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== +"@esbuild/darwin-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" + integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== + "@esbuild/freebsd-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== +"@esbuild/freebsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" + integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== + "@esbuild/freebsd-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== +"@esbuild/freebsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" + integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== + "@esbuild/linux-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== +"@esbuild/linux-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" + integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== + "@esbuild/linux-arm@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== +"@esbuild/linux-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" + integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== + "@esbuild/linux-ia32@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== +"@esbuild/linux-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" + integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== + "@esbuild/linux-loong64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== +"@esbuild/linux-loong64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" + integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== + "@esbuild/linux-mips64el@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== +"@esbuild/linux-mips64el@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" + integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== + "@esbuild/linux-ppc64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== +"@esbuild/linux-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" + integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== + "@esbuild/linux-riscv64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== +"@esbuild/linux-riscv64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" + integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== + "@esbuild/linux-s390x@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== +"@esbuild/linux-s390x@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" + integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== + "@esbuild/linux-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== +"@esbuild/linux-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" + integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== + +"@esbuild/netbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" + integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== + "@esbuild/netbsd-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== +"@esbuild/netbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" + integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== + +"@esbuild/openbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" + integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== + "@esbuild/openbsd-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== +"@esbuild/openbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" + integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== + +"@esbuild/openharmony-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" + integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== + "@esbuild/sunos-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== +"@esbuild/sunos-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" + integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== + "@esbuild/win32-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== +"@esbuild/win32-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" + integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== + "@esbuild/win32-ia32@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== +"@esbuild/win32-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" + integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== + "@esbuild/win32-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== +"@esbuild/win32-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" + integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.9.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" @@ -619,12 +754,12 @@ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": version "1.5.5" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28", "@jridgewell/trace-mapping@^0.3.31": version "0.3.31" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -815,6 +950,11 @@ resolved "https://registry.yarnpkg.com/@shoelace-style/localize/-/localize-3.2.1.tgz#9aa0078bef68a357070b104df95c75701c962c79" integrity sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA== +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -848,6 +988,14 @@ dependencies: "@babel/types" "^7.28.2" +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + "@types/d3-array@^3.0.3": version "3.2.2" resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" @@ -899,7 +1047,12 @@ resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== -"@types/estree@1.0.8": +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@1.0.8", "@types/estree@^1.0.0": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -1059,6 +1212,80 @@ "@types/babel__core" "^7.20.5" react-refresh "^0.17.0" +"@vitest/coverage-v8@^4.0.6": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz#b9c4db7479acd51d5f0ced91b2853c29c3d0cda7" + integrity sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg== + dependencies: + "@bcoe/v8-coverage" "^1.0.2" + "@vitest/utils" "4.0.18" + ast-v8-to-istanbul "^0.3.10" + istanbul-lib-coverage "^3.2.2" + istanbul-lib-report "^3.0.1" + istanbul-reports "^3.2.0" + magicast "^0.5.1" + obug "^2.1.1" + std-env "^3.10.0" + tinyrainbow "^3.0.3" + +"@vitest/expect@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.0.18.tgz#361510d99fbf20eb814222e4afcb8539d79dc94d" + integrity sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.0.18" + "@vitest/utils" "4.0.18" + chai "^6.2.1" + tinyrainbow "^3.0.3" + +"@vitest/mocker@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.0.18.tgz#b9735da114ef65ea95652c5bdf13159c6fab4865" + integrity sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ== + dependencies: + "@vitest/spy" "4.0.18" + estree-walker "^3.0.3" + magic-string "^0.30.21" + +"@vitest/pretty-format@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.0.18.tgz#fbccd4d910774072ec15463553edb8ca5ce53218" + integrity sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw== + dependencies: + tinyrainbow "^3.0.3" + +"@vitest/runner@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.0.18.tgz#c2c0a3ed226ec85e9312f9cc8c43c5b3a893a8b1" + integrity sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw== + dependencies: + "@vitest/utils" "4.0.18" + pathe "^2.0.3" + +"@vitest/snapshot@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.0.18.tgz#bcb40fd6d742679c2ac927ba295b66af1c6c34c5" + integrity sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA== + dependencies: + "@vitest/pretty-format" "4.0.18" + magic-string "^0.30.21" + pathe "^2.0.3" + +"@vitest/spy@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.0.18.tgz#ba0f20503fb6d08baf3309d690b3efabdfa88762" + integrity sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw== + +"@vitest/utils@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.0.18.tgz#9636b16d86a4152ec68a8d6859cff702896433d4" + integrity sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA== + dependencies: + "@vitest/pretty-format" "4.0.18" + tinyrainbow "^3.0.3" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -1106,6 +1333,20 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +ast-v8-to-istanbul@^0.3.10: + version "0.3.11" + resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz#725b1f5e2ffdc8d71620cb5e78d6dc976d65e97a" + integrity sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.31" + estree-walker "^3.0.3" + js-tokens "^10.0.0" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1203,6 +1444,11 @@ caniuse-lite@^1.0.30001759: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz#1ad91594fad7dc233777c2781879ab5409f7d9c2" integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg== +chai@^6.2.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + chalk@^4.0.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -1463,6 +1709,11 @@ es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" @@ -1509,6 +1760,38 @@ esbuild@^0.21.3: "@esbuild/win32-ia32" "0.21.5" "@esbuild/win32-x64" "0.21.5" +esbuild@^0.27.0: + version "0.27.3" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" + integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.3" + "@esbuild/android-arm" "0.27.3" + "@esbuild/android-arm64" "0.27.3" + "@esbuild/android-x64" "0.27.3" + "@esbuild/darwin-arm64" "0.27.3" + "@esbuild/darwin-x64" "0.27.3" + "@esbuild/freebsd-arm64" "0.27.3" + "@esbuild/freebsd-x64" "0.27.3" + "@esbuild/linux-arm" "0.27.3" + "@esbuild/linux-arm64" "0.27.3" + "@esbuild/linux-ia32" "0.27.3" + "@esbuild/linux-loong64" "0.27.3" + "@esbuild/linux-mips64el" "0.27.3" + "@esbuild/linux-ppc64" "0.27.3" + "@esbuild/linux-riscv64" "0.27.3" + "@esbuild/linux-s390x" "0.27.3" + "@esbuild/linux-x64" "0.27.3" + "@esbuild/netbsd-arm64" "0.27.3" + "@esbuild/netbsd-x64" "0.27.3" + "@esbuild/openbsd-arm64" "0.27.3" + "@esbuild/openbsd-x64" "0.27.3" + "@esbuild/openharmony-arm64" "0.27.3" + "@esbuild/sunos-x64" "0.27.3" + "@esbuild/win32-arm64" "0.27.3" + "@esbuild/win32-ia32" "0.27.3" + "@esbuild/win32-x64" "0.27.3" + escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -1624,6 +1907,13 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -1639,6 +1929,11 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== +expect-type@^1.2.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" + integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -1677,6 +1972,11 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -1878,6 +2178,11 @@ html-encoding-sniffer@^6.0.0: dependencies: "@exodus/bytes" "^1.6.0" +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + http-proxy-agent@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" @@ -1967,11 +2272,38 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-reports@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + javascript-natural-sort@^0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59" integrity sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw== +js-tokens@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-10.0.0.tgz#dffe7599b4a8bb7fe30aff8d0235234dffb79831" + integrity sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -2111,6 +2443,29 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +magicast@^0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.5.2.tgz#70cea9df729c164485049ea5df85a390281dfb9d" + integrity sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + source-map-js "^1.2.1" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + marked@^17: version "17.0.2" resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.2.tgz#a103f82bed9653dd1d74c15f74107c84ddbe749d" @@ -2230,6 +2585,11 @@ object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== +obug@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.1.tgz#2cba74ff241beb77d63055ddf4cd1e9f90b538be" + integrity sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ== + once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -2302,6 +2662,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + pica@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/pica/-/pica-9.0.1.tgz#9ba5a5e81fc09dca9800abef9fb8388434b18b2f" @@ -2322,6 +2687,11 @@ picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + postcss-value-parser@^4.0.2: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" @@ -2336,7 +2706,7 @@ postcss@8.4.49: picocolors "^1.1.1" source-map-js "^1.2.1" -postcss@^8.4.43: +postcss@^8.4.43, postcss@^8.5.6: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -2496,7 +2866,7 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup@^4.20.0: +rollup@^4.20.0, rollup@^4.43.0: version "4.57.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.1.tgz#947f70baca32db2b9c594267fe9150aa316e5a88" integrity sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A== @@ -2561,7 +2931,7 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.6.0: +semver@^7.5.3, semver@^7.6.0: version "7.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -2583,6 +2953,11 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -2598,6 +2973,16 @@ spark-md5@^3.0.2: resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc" integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -2662,6 +3047,29 @@ tiny-invariant@^1.3.1: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" + integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== + +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tinyrainbow@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.0.3.tgz#984a5b1c1b25854a9b6bccbe77964d0593d1ea42" + integrity sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q== + tldts-core@^7.0.23: version "7.0.23" resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.23.tgz#47bf18282a44641304a399d247703413b5d3e309" @@ -2778,6 +3186,46 @@ vite@^5.2.0: optionalDependencies: fsevents "~2.3.3" +"vite@^6.0.0 || ^7.0.0": + version "7.3.1" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507" + integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== + dependencies: + esbuild "^0.27.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^4.0.6: + version "4.0.18" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.0.18.tgz#56f966353eca0b50f4df7540cd4350ca6d454a05" + integrity sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ== + dependencies: + "@vitest/expect" "4.0.18" + "@vitest/mocker" "4.0.18" + "@vitest/pretty-format" "4.0.18" + "@vitest/runner" "4.0.18" + "@vitest/snapshot" "4.0.18" + "@vitest/spy" "4.0.18" + "@vitest/utils" "4.0.18" + es-module-lexer "^1.7.0" + expect-type "^1.2.2" + magic-string "^0.30.21" + obug "^2.1.1" + pathe "^2.0.3" + picomatch "^4.0.3" + std-env "^3.10.0" + tinybench "^2.9.0" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.0.3" + vite "^6.0.0 || ^7.0.0" + why-is-node-running "^2.3.0" + w3c-xmlserializer@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" @@ -2827,6 +3275,14 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + word-wrap@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" From 23c8323aba4f048224c1bd9fdbba9340160896d8 Mon Sep 17 00:00:00 2001 From: clragon Date: Wed, 11 Feb 2026 21:12:49 +0100 Subject: [PATCH 34/90] added plugin system --- src/engine/DOMBatcher.ts | 127 ++++++++++++++ src/engine/index.ts | 1 + src/engine/pipes/Plugins.test.ts | 282 +++++++++++++++++++++++++++++++ src/engine/pipes/Plugins.ts | 252 +++++++++++++++++++++++++++ src/engine/pipes/Storage.test.ts | 204 ++++++++++++++++++++++ src/engine/pipes/Storage.ts | 159 +++++++++++++++++ src/engine/pipes/index.ts | 2 + 7 files changed, 1027 insertions(+) create mode 100644 src/engine/DOMBatcher.ts create mode 100644 src/engine/pipes/Plugins.test.ts create mode 100644 src/engine/pipes/Plugins.ts create mode 100644 src/engine/pipes/Storage.test.ts create mode 100644 src/engine/pipes/Storage.ts diff --git a/src/engine/DOMBatcher.ts b/src/engine/DOMBatcher.ts new file mode 100644 index 0000000..2057298 --- /dev/null +++ b/src/engine/DOMBatcher.ts @@ -0,0 +1,127 @@ +/** + * DOM Batching System + * + * Intercepts DOM manipulation methods during plugin execution and queues them. + * All operations are applied at once after all plugin phases complete, + * preventing multiple DOM reflows within a single frame. + */ + +type DOMOperation = { + type: + | 'setAttribute' + | 'removeAttribute' + | 'appendChild' + | 'removeChild' + | 'insertBefore'; + target: Element; + args: any[]; +}; + +const domOperationQueue: DOMOperation[] = []; +const originalMethods: Map = new Map(); +let isDOMBatchingActive = false; + +/** + * Start intercepting DOM operations and queue them + */ +export function startDOMBatching(): void { + if (isDOMBatchingActive) return; + isDOMBatchingActive = true; + domOperationQueue.length = 0; + + // Store originals if not already stored + if (originalMethods.size === 0) { + originalMethods.set('setAttribute', Element.prototype.setAttribute); + originalMethods.set('removeAttribute', Element.prototype.removeAttribute); + originalMethods.set('appendChild', Element.prototype.appendChild); + originalMethods.set('removeChild', Element.prototype.removeChild); + originalMethods.set('insertBefore', Element.prototype.insertBefore); + } + + // Override methods to queue operations + Element.prototype.setAttribute = function ( + this: Element, + name: string, + value: string + ) { + domOperationQueue.push({ + type: 'setAttribute', + target: this, + args: [name, value], + }); + }; + + Element.prototype.removeAttribute = function (this: Element, name: string) { + domOperationQueue.push({ + type: 'removeAttribute', + target: this, + args: [name], + }); + }; + + Element.prototype.appendChild = function ( + this: Element, + child: T + ): T { + domOperationQueue.push({ + type: 'appendChild', + target: this, + args: [child], + }); + return child; + } as any; + + Element.prototype.removeChild = function ( + this: Element, + child: T + ): T { + domOperationQueue.push({ + type: 'removeChild', + target: this, + args: [child], + }); + return child; + } as any; + + Element.prototype.insertBefore = function ( + this: Element, + newNode: T, + referenceNode: Node | null + ): T { + domOperationQueue.push({ + type: 'insertBefore', + target: this, + args: [newNode, referenceNode], + }); + return newNode; + } as any; +} + +/** + * Restore original DOM methods + */ +export function stopDOMBatching(): void { + if (!isDOMBatchingActive) return; + isDOMBatchingActive = false; + + Element.prototype.setAttribute = originalMethods.get('setAttribute') as any; + Element.prototype.removeAttribute = originalMethods.get( + 'removeAttribute' + ) as any; + Element.prototype.appendChild = originalMethods.get('appendChild') as any; + Element.prototype.removeChild = originalMethods.get('removeChild') as any; + Element.prototype.insertBefore = originalMethods.get('insertBefore') as any; +} + +/** + * Apply all queued DOM operations in order, then clear the queue + */ +export function flushDOMOperations(): void { + for (const op of domOperationQueue) { + const method = originalMethods.get(op.type); + if (method) { + method.apply(op.target, op.args); + } + } + domOperationQueue.length = 0; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index d220f9a..9e74c66 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,5 +1,6 @@ export * from './pipes'; export * from './Composer'; +export * from './DOMBatcher'; export * from './Engine'; export * from './Lens'; export * from './Piper'; diff --git a/src/engine/pipes/Plugins.test.ts b/src/engine/pipes/Plugins.test.ts new file mode 100644 index 0000000..2a0efe4 --- /dev/null +++ b/src/engine/pipes/Plugins.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + Plugin, + INBUILT_PLUGINS, + pluginManagerPipe, + activatePlugin, + deactivatePlugin, + savePlugin, + PLUGIN_NAMESPACE, + PluginManagerState, +} from './Plugins'; +import { GameFrame } from '../State'; +import { Composer } from '../Composer'; + +describe('Plugin System', () => { + beforeEach(() => { + localStorage.clear(); + INBUILT_PLUGINS.length = 0; + }); + + describe('Plugin Lifecycle', () => { + it('should activate plugin on first frame', () => { + let activateCalled = false; + let updateCalled = false; + + const testPlugin: Plugin = { + id: 'test.plugin', + activate: (frame: GameFrame) => { + activateCalled = true; + return frame; + }, + update: (frame: GameFrame) => { + updateCalled = true; + return frame; + }, + }; + + INBUILT_PLUGINS.push(testPlugin); + + let frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + frame = Composer.over>( + ['state', PLUGIN_NAMESPACE], + (plugins = {}) => ({ + active: [], + inserting: ['test.plugin'], + removing: [], + ...plugins, + }) + )(frame); + + pluginManagerPipe(frame); + + expect(activateCalled).toBe(true); + expect(updateCalled).toBe(true); + }); + + it('should update active plugin', () => { + let updateCount = 0; + + const testPlugin: Plugin = { + id: 'test.plugin', + update: (frame: GameFrame) => { + updateCount++; + return frame; + }, + }; + + INBUILT_PLUGINS.push(testPlugin); + + let frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + frame = Composer.over>( + ['state', PLUGIN_NAMESPACE], + (plugins = {}) => ({ + active: ['test.plugin'], + inserting: [], + removing: [], + ...plugins, + }) + )(frame); + + pluginManagerPipe(frame); + pluginManagerPipe(frame); + + expect(updateCount).toBe(2); + }); + + it('should deactivate plugin', () => { + let deactivateCalled = false; + let updateCalled = false; + + const testPlugin: Plugin = { + id: 'test.plugin', + update: (frame: GameFrame) => { + updateCalled = true; + return frame; + }, + deactivate: (frame: GameFrame) => { + deactivateCalled = true; + return frame; + }, + }; + + INBUILT_PLUGINS.push(testPlugin); + + let frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + frame = Composer.over>( + ['state', PLUGIN_NAMESPACE], + (plugins = {}) => ({ + active: ['test.plugin'], + inserting: [], + removing: ['test.plugin'], + ...plugins, + }) + )(frame); + + pluginManagerPipe(frame); + + expect(deactivateCalled).toBe(true); + expect(updateCalled).toBe(false); + }); + + it('should update state after lifecycle execution', () => { + const testPlugin: Plugin = { + id: 'test.plugin', + }; + + INBUILT_PLUGINS.push(testPlugin); + + let frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + frame = Composer.over>( + ['state', PLUGIN_NAMESPACE], + (plugins = {}) => ({ + active: [], + inserting: ['test.plugin'], + removing: [], + ...plugins, + }) + )(frame); + + const result = pluginManagerPipe(frame); + + expect(result.state.how.joi.plugins).toEqual({ + active: ['test.plugin'], + inserting: [], + removing: [], + }); + }); + }); + + describe('Plugin Registry', () => { + it('should include inbuilt plugins in registry', () => { + const testPlugin: Plugin = { + id: 'test.inbuilt', + }; + + INBUILT_PLUGINS.push(testPlugin); + + const frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + const result = pluginManagerPipe(frame); + + const registry = result.context.how?.joi?.plugins?.registry; + expect(registry['test.inbuilt']).toEqual(testPlugin); + }); + }); + + describe('activatePlugin / deactivatePlugin', () => { + it('should add plugin to inserting list', () => { + let frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + frame = Composer.over>( + ['state', PLUGIN_NAMESPACE], + (plugins = {}) => ({ + active: [], + inserting: [], + removing: [], + ...plugins, + }) + )(frame); + + const result = activatePlugin('test.plugin')(frame); + + expect(result.state.how.joi.plugins.inserting).toContain('test.plugin'); + }); + + it('should add plugin to removing list', () => { + let frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + frame = Composer.over>( + ['state', PLUGIN_NAMESPACE], + (plugins = {}) => ({ + active: ['test.plugin'], + inserting: [], + removing: [], + ...plugins, + }) + )(frame); + + const result = deactivatePlugin('test.plugin')(frame); + + expect(result.state.how.joi.plugins.removing).toContain('test.plugin'); + }); + }); + + describe('savePlugin', () => { + it('should save plugin code to storage', () => { + const code = 'export default { id: "test" }'; + const frame: GameFrame = { + state: {}, + context: { + tick: 0, + deltaTime: 0, + elapsedTime: 0, + }, + }; + + savePlugin('test.plugin', code)(frame); + + const stored = localStorage.getItem('how.joi.plugin.code/test.plugin'); + expect(stored).toBe(JSON.stringify(code)); + }); + }); + + describe('Plugin Loading', () => { + it('should start loading user plugins from storage', () => { + const pluginCode = ` + export default { + id: 'user.plugin', + meta: { name: 'User Plugin' } + }; + `; + + localStorage.setItem( + 'how.joi.plugins.user', + JSON.stringify(['user.plugin']) + ); + localStorage.setItem( + 'how.joi.plugin.code/user.plugin', + JSON.stringify(pluginCode) + ); + + const frame: GameFrame = { + state: {}, + context: { + tick: 0, + deltaTime: 0, + elapsedTime: 0, + }, + }; + + const result = pluginManagerPipe(frame); + + const pluginContext = result.context.how?.joi?.plugins; + expect(pluginContext.pending.has('user.plugin')).toBe(true); + }); + }); +}); diff --git a/src/engine/pipes/Plugins.ts b/src/engine/pipes/Plugins.ts new file mode 100644 index 0000000..a2d073a --- /dev/null +++ b/src/engine/pipes/Plugins.ts @@ -0,0 +1,252 @@ +import { Composer } from '../Composer'; +import { Pipe, GameFrame } from '../State'; +import { + startDOMBatching, + stopDOMBatching, + flushDOMOperations, +} from '../DOMBatcher'; +import { Storage } from './Storage'; + +export const PLUGIN_NAMESPACE = 'how.joi.plugins'; + +export type PluginId = string; + +export type PluginMeta = { + name?: string; + description?: string; + version?: string; + author?: string; +}; + +export type Plugin = { + id: PluginId; + meta?: PluginMeta; + activate?: Pipe; + update?: Pipe; + deactivate?: Pipe; +}; + +export type PluginManagerState = { + active: PluginId[]; + inserting: PluginId[]; + removing: PluginId[]; +}; + +export type PluginRegistry = { + [id: PluginId]: Plugin; +}; + +type PluginLoad = { + promise: Promise; + result?: Plugin; + error?: Error; +}; + +type PluginManagerContext = { + registry: PluginRegistry; + pending: Map; +}; + +const defaultState = (): PluginManagerState => ({ + active: [], + inserting: [], + removing: [], +}); + +const defaultContext = (): PluginManagerContext => ({ + registry: {}, + pending: new Map(), +}); + +export const INBUILT_PLUGINS: Plugin[] = []; + +async function load(code: string): Promise { + const blob = new Blob([code], { type: 'text/javascript' }); + const url = URL.createObjectURL(blob); + + try { + const module = await import(/* @vite-ignore */ url); + const plugin: Plugin = module.default || module.plugin; + + if (!plugin || !plugin.id) { + throw new Error('Plugin must export a Plugin object with an id'); + } + + return plugin; + } finally { + URL.revokeObjectURL(url); + } +} + +export function savePlugin(id: PluginId, code: string): Pipe { + return Storage.set(`how.joi.plugin.code/${id}`, code); +} + +export function activatePlugin(id: PluginId): Pipe { + return Composer.over( + ['state', PLUGIN_NAMESPACE], + (state = defaultState()) => { + if (state.inserting?.includes(id)) return state; + return { + ...state, + inserting: [...(state.inserting || []), id], + }; + } + ); +} + +export function deactivatePlugin(id: PluginId): Pipe { + return Composer.over( + ['state', PLUGIN_NAMESPACE], + (state = defaultState()) => { + if (state.removing?.includes(id)) return state; + return { + ...state, + removing: [...(state.removing || []), id], + }; + } + ); +} + +function getPlugin(registry: PluginRegistry, id: PluginId): Plugin | undefined { + return registry[id]; +} + +// STAGE 1: Register inbuilt plugins + resolve completed async loads +const buildRegistryPipe: Pipe = Composer.over( + ['context', PLUGIN_NAMESPACE], + (ctx = defaultContext()) => { + const registry = { ...ctx.registry }; + const pending = new Map(ctx.pending); + + for (const plugin of INBUILT_PLUGINS) { + registry[plugin.id] = plugin; + } + + for (const [id, entry] of pending) { + if (entry.result) { + registry[entry.result.id] = entry.result; + pending.delete(id); + } else if (entry.error) { + pending.delete(id); + } + } + + return { registry, pending }; + } +); + +// STAGE 2: Load user plugins from Storage, start async imports for new ones +const loadUserPluginsPipe: Pipe = Storage.bind( + 'how.joi.plugins.user', + userPluginIds => + Composer.pipe( + ...(userPluginIds || []).map(id => + Storage.bind(`how.joi.plugin.code/${id}`, code => + Composer.bind( + ['context', PLUGIN_NAMESPACE], + (ctx = defaultContext()) => { + if (ctx.registry[id] || ctx.pending.has(id) || !code) { + return (frame: GameFrame) => frame; + } + + const pluginLoad: PluginLoad = { + promise: load(code), + }; + + pluginLoad.promise.then( + plugin => { + pluginLoad.result = plugin; + }, + error => { + pluginLoad.error = error; + } + ); + + const pending = new Map(ctx.pending); + pending.set(id, pluginLoad); + + return Composer.set( + ['context', PLUGIN_NAMESPACE], + { registry: ctx.registry, pending } + ); + } + ) + ) + ) + ) +); + +// STAGE 3: Execute plugin lifecycle with DOM batching +const lifecyclePipe: Pipe = Composer.bind( + ['state', PLUGIN_NAMESPACE], + (state = defaultState()) => { + const { inserting = [], active = [], removing = [] } = state; + + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + (ctx = defaultContext()) => { + const pipes: Pipe[] = []; + + pipes.push((frame: GameFrame) => { + startDOMBatching(); + return frame; + }); + + for (const id of inserting) { + const plugin = getPlugin(ctx.registry, id); + if (plugin?.activate) pipes.push(plugin.activate); + } + + for (const id of active) { + if (!removing.includes(id)) { + const plugin = getPlugin(ctx.registry, id); + if (plugin?.update) pipes.push(plugin.update); + } + } + + for (const id of inserting) { + const plugin = getPlugin(ctx.registry, id); + if (plugin?.update) pipes.push(plugin.update); + } + + for (const id of removing) { + const plugin = getPlugin(ctx.registry, id); + if (plugin?.deactivate) pipes.push(plugin.deactivate); + } + + pipes.push((frame: GameFrame) => { + stopDOMBatching(); + flushDOMOperations(); + return frame; + }); + + return Composer.pipe(...pipes); + } + ); + } +); + +// STAGE 4: Transition state for next frame +const transitionPipe: Pipe = Composer.over( + ['state', PLUGIN_NAMESPACE], + (state = defaultState()) => { + const { active = [], inserting = [], removing = [] } = state; + + return { + active: [ + ...active.filter(id => !removing.includes(id)), + ...inserting.filter(id => !active.includes(id)), + ], + inserting: [], + removing: [], + }; + } +); + +export const pluginManagerPipe: Pipe = Composer.pipe( + buildRegistryPipe, + loadUserPluginsPipe, + lifecyclePipe, + transitionPipe +); diff --git a/src/engine/pipes/Storage.test.ts b/src/engine/pipes/Storage.test.ts new file mode 100644 index 0000000..c9d72ce --- /dev/null +++ b/src/engine/pipes/Storage.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + Storage, + storagePipe, + STORAGE_NAMESPACE, + StorageContext, +} from './Storage'; +import { GameFrame } from '../State'; +import { Composer } from '../Composer'; + +describe('Storage', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + describe('Storage.bind', () => { + it('should load value from localStorage', () => { + localStorage.setItem('test.key', JSON.stringify({ value: 42 })); + + let result: any; + const pipe = Storage.bind('test.key', (value: any) => { + result = value; + return (frame: GameFrame) => frame; + }); + + const frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + pipe(frame); + expect(result).toEqual({ value: 42 }); + }); + + it('should return undefined for missing key', () => { + let result: any; + const pipe = Storage.bind('missing.key', (value: any) => { + result = value; + return (frame: GameFrame) => frame; + }); + + const frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + pipe(frame); + expect(result).toBeUndefined(); + }); + + it('should use cached values when available', () => { + let callCount = 0; + const pipe = Storage.bind('test.key', () => { + callCount++; + return (frame: GameFrame) => frame; + }); + + let frame1: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 1000 }, + }; + + frame1 = Composer.over>( + ['context', STORAGE_NAMESPACE], + (storage = {}) => ({ + cache: {}, + ...storage, + }) + )(frame1); + + pipe(frame1); + expect(callCount).toBe(1); + + let frame2: GameFrame = { + state: {}, + context: { tick: 1, deltaTime: 16, elapsedTime: 1016 }, + }; + + frame2 = Composer.over>( + ['context', STORAGE_NAMESPACE], + (storage = {}) => ({ + cache: { + 'test.key': { + value: 'cached', + expiry: 31000, + }, + }, + ...storage, + }) + )(frame2); + + pipe(frame2); + expect(callCount).toBe(2); + }); + }); + + describe('Storage.set', () => { + it('should write to localStorage', () => { + const pipe = Storage.set('test.key', { foo: 'bar' }); + + const frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + pipe(frame); + + const stored = localStorage.getItem('test.key'); + expect(JSON.parse(stored!)).toEqual({ foo: 'bar' }); + }); + + it('should update cache', () => { + const pipe = Storage.set('test.key', 'value'); + + let frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 1000 }, + }; + + frame = Composer.over>( + ['context', STORAGE_NAMESPACE], + (storage = {}) => ({ + cache: {}, + ...storage, + }) + )(frame); + + const result = pipe(frame); + + const storage = result.context.how.joi.storage; + expect(storage.cache['test.key'].value).toBe('value'); + expect(storage.cache['test.key'].expiry).toBe(31000); + }); + }); + + describe('Storage.remove', () => { + it('should remove from localStorage', () => { + localStorage.setItem('test.key', 'value'); + + const pipe = Storage.remove('test.key'); + + const frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + pipe(frame); + + expect(localStorage.getItem('test.key')).toBeNull(); + }); + + it('should remove from cache', () => { + const pipe = Storage.remove('test.key'); + + let frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + }; + + frame = Composer.over>( + ['context', STORAGE_NAMESPACE], + (storage = {}) => ({ + cache: { + 'test.key': { value: 'foo', expiry: 1000 }, + }, + ...storage, + }) + )(frame); + + const result = pipe(frame); + + const storage = result.context.how.joi.storage; + expect(storage.cache['test.key']).toBeUndefined(); + }); + }); + + describe('storagePipe', () => { + it('should clean up expired cache entries', () => { + let frame: GameFrame = { + state: {}, + context: { tick: 0, deltaTime: 0, elapsedTime: 20000 }, + }; + + frame = Composer.over>( + ['context', STORAGE_NAMESPACE], + (storage = {}) => ({ + cache: { + 'expired.key': { value: 'old', expiry: 10000 }, + 'valid.key': { value: 'new', expiry: 50000 }, + }, + ...storage, + }) + )(frame); + + const result = storagePipe(frame); + + const storage = result.context.how.joi.storage; + expect(storage.cache['expired.key']).toBeUndefined(); + expect(storage.cache['valid.key']).toBeDefined(); + expect(storage.cache['valid.key'].value).toBe('new'); + }); + }); +}); diff --git a/src/engine/pipes/Storage.ts b/src/engine/pipes/Storage.ts new file mode 100644 index 0000000..76a83a7 --- /dev/null +++ b/src/engine/pipes/Storage.ts @@ -0,0 +1,159 @@ +/** + * Storage Pipe + * + * Primitive pipe that provides access to localStorage. + * Uses lazy loading with time-based cache (hot settings). + * + * Architecture: + * - Settings are loaded on-demand from localStorage + * - Cached in context with expiry time (measured in deltaTime) + * - Hot cache expires after inactivity to save memory + * - Writes are immediate to localStorage and update cache + */ + +import { Composer } from '../Composer'; +import { Pipe } from '../State'; + +export const STORAGE_NAMESPACE = 'how.joi.storage'; +const CACHE_TTL = 30000; // 30 seconds of game time (deltaTime sum) + +export type CacheEntry = { + value: any; + expiry: number; // game time when this expires +}; + +export type StorageContext = { + cache: { [key: string]: CacheEntry }; +}; + +/** + * Storage pipe - updates cache expiry based on deltaTime + */ +export const storagePipe: Pipe = Composer.pipe( + // Clean up expired entries + Composer.bind(['context', 'elapsedTime'], elapsedTime => + Composer.over(['context', STORAGE_NAMESPACE], ctx => { + const newCache: { [key: string]: CacheEntry } = {}; + + // Keep only non-expired entries + for (const [key, entry] of Object.entries(ctx?.cache || {})) { + if (entry.expiry > elapsedTime) { + newCache[key] = entry; + } + } + + return { cache: newCache }; + }) + ) +); + +/** + * Storage API for reading and writing to localStorage + */ +export class Storage { + /** + * Loads a value from localStorage + */ + private static load(key: string): T | undefined { + try { + const value = localStorage.getItem(key); + if (value !== null) { + return JSON.parse(value) as T; + } + } catch (e) { + console.error(`Failed to load from localStorage key "${key}":`, e); + } + return undefined; + } + + /** + * Gets a value, using cache or loading from localStorage + */ + static bind(key: string, fn: (value: T | undefined) => Pipe): Pipe { + return Composer.bind( + ['context', STORAGE_NAMESPACE], + ctx => { + const cache = ctx?.cache || {}; + const cached = cache[key]; + + // Return cached value if available and not expired + if (cached) { + return fn(cached.value as T | undefined); + } + + // Load from localStorage + const value = Storage.load(key); + + // Cache it (will be set with expiry in the next pipe) + return Composer.pipe( + Composer.bind(['context', 'elapsedTime'], elapsedTime => + Composer.over( + ['context', STORAGE_NAMESPACE], + ctx => ({ + cache: { + ...(ctx?.cache || {}), + [key]: { + value, + expiry: elapsedTime + CACHE_TTL, + }, + }, + }) + ) + ), + fn(value) + ); + } + ); + } + + /** + * Sets a value in localStorage and updates cache + */ + static set(key: string, value: T): Pipe { + return frame => { + // Write to localStorage + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error('Failed to write to localStorage:', e); + } + + // Update cache + return Composer.bind(['context', 'elapsedTime'], elapsedTime => + Composer.over(['context', STORAGE_NAMESPACE], ctx => ({ + cache: { + ...(ctx?.cache || {}), + [key]: { + value, + expiry: elapsedTime + CACHE_TTL, + }, + }, + })) + )(frame); + }; + } + + /** + * Removes a value from localStorage and cache + */ + static remove(key: string): Pipe { + return frame => { + // Remove from localStorage + try { + localStorage.removeItem(key); + } catch (e) { + console.error('Failed to remove from localStorage:', e); + } + + // Remove from cache + return Composer.over( + ['context', STORAGE_NAMESPACE], + ctx => { + const newCache = { ...(ctx?.cache || {}) }; + delete newCache[key]; + return { cache: newCache }; + } + )(frame); + }; + } +} diff --git a/src/engine/pipes/index.ts b/src/engine/pipes/index.ts index cb14754..c73aaf9 100644 --- a/src/engine/pipes/index.ts +++ b/src/engine/pipes/index.ts @@ -1,4 +1,6 @@ export * from './Events'; export * from './Fps'; export * from './Messages'; +export * from './Plugins'; export * from './Scheduler'; +export * from './Storage'; From 039d3b26a81de8dc29f01453931df1a3cc4a0cde Mon Sep 17 00:00:00 2001 From: clragon Date: Wed, 11 Feb 2026 21:22:16 +0100 Subject: [PATCH 35/90] added imperative composer api --- src/engine/Composer.test.ts | 73 +++++++++++++++++++++++++++++++++++++ src/engine/Composer.ts | 21 +++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/engine/Composer.test.ts b/src/engine/Composer.test.ts index e1de116..c30baf4 100644 --- a/src/engine/Composer.test.ts +++ b/src/engine/Composer.test.ts @@ -277,5 +277,78 @@ describe('Composer', () => { expect(result.value).toBe(10); }); }); + + describe('Composer.do', () => { + it('should expose get and set imperatively', () => { + const fn = Composer.do<{ x: number; y: number }>(({ get, set }) => { + const x = get('x'); + set('y', x * 3); + }); + + const result = fn({ x: 7, y: 0 }); + expect(result.y).toBe(21); + expect(result.x).toBe(7); + }); + + it('should expose over imperatively', () => { + const fn = Composer.do<{ count: number }>(({ over }) => { + over('count', c => c + 10); + }); + + const result = fn({ count: 5 }); + expect(result.count).toBe(15); + }); + + it('should see mutations from earlier in the same block', () => { + const fn = Composer.do<{ a: number; b: number }>(({ get, set }) => { + set('a', 99); + const a = get('a'); + set('b', a + 1); + }); + + const result = fn({ a: 0, b: 0 }); + expect(result.a).toBe(99); + expect(result.b).toBe(100); + }); + + it('should interop with functional pipes via pipe()', () => { + const double = Composer.over('value', v => v * 2); + const addTen = Composer.over('value', v => v + 10); + + const fn = Composer.do<{ value: number }>(({ pipe }) => { + pipe(double, addTen); + }); + + const result = fn({ value: 5 }); + expect(result.value).toBe(20); + }); + + it('should support if/else instead of when/unless', () => { + const make = (flag: boolean) => + Composer.do<{ value: number }>(({ get, set }) => { + const v = get('value'); + if (flag) { + set('value', v * 2); + } else { + set('value', v + 1); + } + }); + + expect(make(true)({ value: 5 }).value).toBe(10); + expect(make(false)({ value: 5 }).value).toBe(6); + }); + + it('should work with nested paths', () => { + type State = { user: { profile: { score: number } } }; + + const fn = Composer.do(({ get, set }) => { + const score = get('user.profile.score'); + set('user.profile.score', score + 100); + }); + + const result = fn({ user: { profile: { score: 50 } } }); + expect(result.user.profile.score).toBe(150); + }); + }); }); }); diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index 7a70f23..4bc48ee 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -12,6 +12,12 @@ export type Compositor = ( composer: Composer ) => Composer; +export type ComposerScope = { + [K in keyof Composer as Composer[K] extends (...args: any[]) => any + ? K + : never]: Composer[K]; +}; + /** * A generalized object manipulation utility * in a functional chaining style. @@ -186,4 +192,19 @@ export class Composer { ): (obj: T) => T { return Composer.when(!condition, fn); } + + static do( + fn: (scope: ComposerScope) => void + ): (obj: T) => T { + return Composer.chain(c => { + const scope = {} as ComposerScope; + for (const key of Object.getOwnPropertyNames(Composer.prototype)) { + if (key !== 'constructor' && typeof (c as any)[key] === 'function') { + (scope as any)[key] = (c as any)[key].bind(c); + } + } + fn(scope); + return c; + }); + } } From 872e18fc95ab2c1c7914288591ca7c6cffe0f900 Mon Sep 17 00:00:00 2001 From: clragon Date: Wed, 11 Feb 2026 21:43:23 +0100 Subject: [PATCH 36/90] added some guard rails to imperative composer --- src/engine/Composer.test.ts | 19 +++++++++++++++++++ src/engine/Composer.ts | 22 ++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/engine/Composer.test.ts b/src/engine/Composer.test.ts index c30baf4..c9abf90 100644 --- a/src/engine/Composer.test.ts +++ b/src/engine/Composer.test.ts @@ -349,6 +349,25 @@ describe('Composer', () => { const result = fn({ user: { profile: { score: 50 } } }); expect(result.user.profile.score).toBe(150); }); + + it('should throw if callback is async', () => { + expect(() => { + const fn = Composer.do<{ x: number }>((async (scope: any) => { + scope.set('x', 1); + }) as any); + fn({ x: 0 }); + }).toThrow('must not be async'); + }); + + it('should throw if scope is used after block completes', () => { + let leaked: any; + const fn = Composer.do<{ x: number }>(scope => { + leaked = scope; + }); + fn({ x: 0 }); + + expect(() => leaked.set('x', 99)).toThrow('after block completed'); + }); }); }); }); diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index 4bc48ee..adaabe7 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -197,13 +197,31 @@ export class Composer { fn: (scope: ComposerScope) => void ): (obj: T) => T { return Composer.chain(c => { + let sealed = false; const scope = {} as ComposerScope; for (const key of Object.getOwnPropertyNames(Composer.prototype)) { if (key !== 'constructor' && typeof (c as any)[key] === 'function') { - (scope as any)[key] = (c as any)[key].bind(c); + const bound = (c as any)[key].bind(c); + if (import.meta.env.DEV) { + (scope as any)[key] = (...args: any[]) => { + if (sealed) + throw new Error( + 'Composer.do() scope used after block completed' + ); + return bound(...args); + }; + } else { + (scope as any)[key] = bound; + } } } - fn(scope); + const result: unknown = fn(scope); + if (import.meta.env.DEV) { + if (result && typeof (result as any).then === 'function') { + throw new Error('Composer.do() callback must not be async'); + } + sealed = true; + } return c; }); } From b6ea0dd1cb44b163fc8ddb0d4e910527044270d9 Mon Sep 17 00:00:00 2001 From: clragon Date: Thu, 12 Feb 2026 09:43:27 +0100 Subject: [PATCH 37/90] added else to when --- src/engine/Composer.test.ts | 48 +++++++++++++++++++++++++++++++++++++ src/engine/Composer.ts | 20 ++++++++++++---- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/engine/Composer.test.ts b/src/engine/Composer.test.ts index c9abf90..341f2a0 100644 --- a/src/engine/Composer.test.ts +++ b/src/engine/Composer.test.ts @@ -120,6 +120,32 @@ describe('Composer', () => { expect(result.value).toBe(5); }); + + it('should apply else when condition is false', () => { + const obj = { value: 5 }; + const result = new Composer(obj) + .when( + false, + c => c.set('value', 10), + c => c.set('value', 99) + ) + .get(); + + expect(result.value).toBe(99); + }); + + it('should not apply else when condition is true', () => { + const obj = { value: 5 }; + const result = new Composer(obj) + .when( + true, + c => c.set('value', 10), + c => c.set('value', 99) + ) + .get(); + + expect(result.value).toBe(10); + }); }); describe('unless', () => { @@ -254,6 +280,28 @@ describe('Composer', () => { const result = fn({ value: 5 }); expect(result.value).toBe(5); }); + + it('should apply else function when false', () => { + const fn = Composer.when<{ value: number }>( + false, + o => ({ ...o, value: o.value * 2 }), + o => ({ ...o, value: o.value + 1 }) + ); + + const result = fn({ value: 5 }); + expect(result.value).toBe(6); + }); + + it('should skip else function when true', () => { + const fn = Composer.when<{ value: number }>( + true, + o => ({ ...o, value: o.value * 2 }), + o => ({ ...o, value: o.value + 1 }) + ); + + const result = fn({ value: 5 }); + expect(result.value).toBe(10); + }); }); describe('Composer.unless', () => { diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index adaabe7..a6a710f 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -162,8 +162,13 @@ export class Composer { /** * Runs a composer function when the condition is true. */ - when(condition: boolean, fn: (c: this) => this): this { - return condition ? fn(this) : this; + when( + condition: boolean, + fn: (c: this) => this, + elseFn?: (c: this) => this + ): this { + if (condition) return fn(this); + return elseFn ? elseFn(this) : this; } /** @@ -171,9 +176,16 @@ export class Composer { */ static when( condition: boolean, - fn: (obj: T) => T + fn: (obj: T) => T, + elseFn?: (obj: T) => T ): (obj: T) => T { - return Composer.chain(c => c.when(condition, c => c.pipe(fn))); + return Composer.chain(c => + c.when( + condition, + c => c.pipe(fn), + elseFn ? c => c.pipe(elseFn) : undefined + ) + ); } /** From 4fa329ebea912b0bf98c62bfeaea2b46b1679855 Mon Sep 17 00:00:00 2001 From: clragon Date: Thu, 12 Feb 2026 21:16:33 +0100 Subject: [PATCH 38/90] added typed paths to composer --- src/engine/Composer.ts | 77 +++++++++++++++++++++++++++--------------- src/engine/Lens.ts | 23 ++++++++++++- 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index a6a710f..5aa46de 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -65,8 +65,8 @@ export class Composer { /** * Gets a value at the specified path in the object. */ - get(path: Path): A; - get(path?: Path): A | T { + get(path: Path): A; + get(path?: Path): A | T { if (path === undefined) return this.obj; return lensFromPath(path).get(this.obj); } @@ -74,7 +74,7 @@ export class Composer { /** * Shorthand for getting a value at the specified path from an object. */ - static get(path: Path) { + static get(path: Path) { return (obj: T): A => lensFromPath(path).get(obj); } @@ -85,7 +85,7 @@ export class Composer { /** * Sets a value at the specified path in the object. */ - set(path: Path, value: A): this; + set(path: Path, value: A): this; set(pathOrValue: Path | T, maybeValue?: unknown): this { if (maybeValue === undefined) { @@ -101,7 +101,7 @@ export class Composer { /** * Shorthand for building a composer that sets a path. */ - static set(path: Path, value: A) { + static set(path: Path, value: A) { return (obj: T): T => Composer.chain(c => c.set(path, value))(obj); } @@ -129,7 +129,7 @@ export class Composer { /** * Updates the value at the specified path with the mapping function. */ - over(path: Path, fn: (a: A) => A): this { + over(path: Path, fn: (a: A) => A): this { this.obj = lensFromPath(path).over(fn)(this.obj); return this; } @@ -137,7 +137,7 @@ export class Composer { /** * Shorthand for building a composer that updates a path. */ - static over(path: Path, fn: (a: A) => A) { + static over(path: Path, fn: (a: A) => A) { return (obj: T): T => Composer.chain(c => c.over(path, fn))(obj); } @@ -145,7 +145,7 @@ export class Composer { /** * Runs a composer function with the value at the specified path. */ - bind(path: Path, fn: Transformer<[A], T>): this { + bind(path: Path, fn: Transformer<[A], T>): this { const value = lensFromPath(path).get(this.obj); this.obj = fn(value)(this.obj); return this; @@ -154,7 +154,7 @@ export class Composer { /** * Shorthand for building a composer that reads a value at a path and applies a transformer. */ - static bind(path: Path, fn: Transformer<[A], any>) { + static bind(path: Path, fn: Transformer<[A], any>) { return (obj: T): T => Composer.chain(c => c.bind(path, fn))(obj); } @@ -205,36 +205,57 @@ export class Composer { return Composer.when(!condition, fn); } + /** + * Runs an imperative block with destructured scope methods (get, set, over, pipe, bind). + */ static do( fn: (scope: ComposerScope) => void ): (obj: T) => T { - return Composer.chain(c => { - let sealed = false; + return (obj: T): T => { + const c = new Composer(obj); const scope = {} as ComposerScope; + + // bind all composer methods to the scope object for (const key of Object.getOwnPropertyNames(Composer.prototype)) { - if (key !== 'constructor' && typeof (c as any)[key] === 'function') { - const bound = (c as any)[key].bind(c); - if (import.meta.env.DEV) { - (scope as any)[key] = (...args: any[]) => { - if (sealed) - throw new Error( - 'Composer.do() scope used after block completed' - ); - return bound(...args); - }; - } else { - (scope as any)[key] = bound; - } - } + if (key === 'constructor' || typeof (c as any)[key] !== 'function') + continue; + (scope as any)[key] = (c as any)[key].bind(c); } - const result: unknown = fn(scope); + if (import.meta.env.DEV) { + let sealed = false; + + // throw if scope is used after block returns (leaked references) + for (const key of Object.keys(scope)) { + const method = (scope as any)[key]; + (scope as any)[key] = (...args: any[]) => { + if (sealed) + throw new Error('Composer.do() scope used after block completed'); + return method(...args); + }; + } + + // shallow-freeze get() results to catch accidental mutation + const rawGet = (scope as any).get; + (scope as any).get = (...args: any[]) => { + const val = rawGet(...args); + return val !== null && typeof val === 'object' + ? Object.freeze(val) + : val; + }; + + const result: unknown = fn(scope); + + // catch async callbacks that would silently lose writes if (result && typeof (result as any).then === 'function') { throw new Error('Composer.do() callback must not be async'); } sealed = true; + } else { + fn(scope); } - return c; - }); + + return c.get(); + }; } } diff --git a/src/engine/Lens.ts b/src/engine/Lens.ts index 46bb15b..0b26167 100644 --- a/src/engine/Lens.ts +++ b/src/engine/Lens.ts @@ -6,7 +6,28 @@ export type Lens = { over: (fn: (a: A) => A) => (source: S) => S; }; -export type Path = (string | number | symbol)[] | string; +export type StringPath = (string | number | symbol)[] | string; + +export type TypedPath = (string | number | symbol)[] & { + readonly __type?: T; +} & (T extends object + ? { readonly [K in keyof T]-?: TypedPath } + : unknown); + +export type Path = StringPath | TypedPath; + +export function typedPath( + segments: (string | number | symbol)[] +): TypedPath { + return new Proxy(segments, { + get(target, prop, receiver) { + if (prop in target || typeof prop === 'symbol') { + return Reflect.get(target, prop, receiver); + } + return typedPath([...target, prop as string]); + }, + }) as unknown as TypedPath; +} export function normalizePath(path: Path): (string | number | symbol)[] { if (Array.isArray(path)) { From 19253d382f2c93c18c0a30fdd4d1cdaf3a1735fe Mon Sep 17 00:00:00 2001 From: clragon Date: Thu, 12 Feb 2026 22:47:19 +0100 Subject: [PATCH 39/90] improved plugin system --- src/engine/pipes/Plugins.test.ts | 282 --------------------- src/engine/pipes/Plugins.ts | 252 ------------------ src/engine/pipes/index.ts | 4 +- src/engine/plugins/PluginInstaller.test.ts | 37 +++ src/engine/plugins/PluginInstaller.ts | 123 +++++++++ src/engine/plugins/PluginManager.test.ts | 224 ++++++++++++++++ src/engine/plugins/PluginManager.ts | 259 +++++++++++++++++++ src/engine/plugins/Plugins.ts | 31 +++ src/game/GamePage.tsx | 13 +- src/game/plugins/fps.ts | 80 ++++++ src/game/plugins/index.ts | 10 + 11 files changed, 774 insertions(+), 541 deletions(-) delete mode 100644 src/engine/pipes/Plugins.test.ts delete mode 100644 src/engine/pipes/Plugins.ts create mode 100644 src/engine/plugins/PluginInstaller.test.ts create mode 100644 src/engine/plugins/PluginInstaller.ts create mode 100644 src/engine/plugins/PluginManager.test.ts create mode 100644 src/engine/plugins/PluginManager.ts create mode 100644 src/engine/plugins/Plugins.ts create mode 100644 src/game/plugins/fps.ts create mode 100644 src/game/plugins/index.ts diff --git a/src/engine/pipes/Plugins.test.ts b/src/engine/pipes/Plugins.test.ts deleted file mode 100644 index 2a0efe4..0000000 --- a/src/engine/pipes/Plugins.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - Plugin, - INBUILT_PLUGINS, - pluginManagerPipe, - activatePlugin, - deactivatePlugin, - savePlugin, - PLUGIN_NAMESPACE, - PluginManagerState, -} from './Plugins'; -import { GameFrame } from '../State'; -import { Composer } from '../Composer'; - -describe('Plugin System', () => { - beforeEach(() => { - localStorage.clear(); - INBUILT_PLUGINS.length = 0; - }); - - describe('Plugin Lifecycle', () => { - it('should activate plugin on first frame', () => { - let activateCalled = false; - let updateCalled = false; - - const testPlugin: Plugin = { - id: 'test.plugin', - activate: (frame: GameFrame) => { - activateCalled = true; - return frame; - }, - update: (frame: GameFrame) => { - updateCalled = true; - return frame; - }, - }; - - INBUILT_PLUGINS.push(testPlugin); - - let frame: GameFrame = { - state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, - }; - - frame = Composer.over>( - ['state', PLUGIN_NAMESPACE], - (plugins = {}) => ({ - active: [], - inserting: ['test.plugin'], - removing: [], - ...plugins, - }) - )(frame); - - pluginManagerPipe(frame); - - expect(activateCalled).toBe(true); - expect(updateCalled).toBe(true); - }); - - it('should update active plugin', () => { - let updateCount = 0; - - const testPlugin: Plugin = { - id: 'test.plugin', - update: (frame: GameFrame) => { - updateCount++; - return frame; - }, - }; - - INBUILT_PLUGINS.push(testPlugin); - - let frame: GameFrame = { - state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, - }; - - frame = Composer.over>( - ['state', PLUGIN_NAMESPACE], - (plugins = {}) => ({ - active: ['test.plugin'], - inserting: [], - removing: [], - ...plugins, - }) - )(frame); - - pluginManagerPipe(frame); - pluginManagerPipe(frame); - - expect(updateCount).toBe(2); - }); - - it('should deactivate plugin', () => { - let deactivateCalled = false; - let updateCalled = false; - - const testPlugin: Plugin = { - id: 'test.plugin', - update: (frame: GameFrame) => { - updateCalled = true; - return frame; - }, - deactivate: (frame: GameFrame) => { - deactivateCalled = true; - return frame; - }, - }; - - INBUILT_PLUGINS.push(testPlugin); - - let frame: GameFrame = { - state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, - }; - - frame = Composer.over>( - ['state', PLUGIN_NAMESPACE], - (plugins = {}) => ({ - active: ['test.plugin'], - inserting: [], - removing: ['test.plugin'], - ...plugins, - }) - )(frame); - - pluginManagerPipe(frame); - - expect(deactivateCalled).toBe(true); - expect(updateCalled).toBe(false); - }); - - it('should update state after lifecycle execution', () => { - const testPlugin: Plugin = { - id: 'test.plugin', - }; - - INBUILT_PLUGINS.push(testPlugin); - - let frame: GameFrame = { - state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, - }; - - frame = Composer.over>( - ['state', PLUGIN_NAMESPACE], - (plugins = {}) => ({ - active: [], - inserting: ['test.plugin'], - removing: [], - ...plugins, - }) - )(frame); - - const result = pluginManagerPipe(frame); - - expect(result.state.how.joi.plugins).toEqual({ - active: ['test.plugin'], - inserting: [], - removing: [], - }); - }); - }); - - describe('Plugin Registry', () => { - it('should include inbuilt plugins in registry', () => { - const testPlugin: Plugin = { - id: 'test.inbuilt', - }; - - INBUILT_PLUGINS.push(testPlugin); - - const frame: GameFrame = { - state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, - }; - - const result = pluginManagerPipe(frame); - - const registry = result.context.how?.joi?.plugins?.registry; - expect(registry['test.inbuilt']).toEqual(testPlugin); - }); - }); - - describe('activatePlugin / deactivatePlugin', () => { - it('should add plugin to inserting list', () => { - let frame: GameFrame = { - state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, - }; - - frame = Composer.over>( - ['state', PLUGIN_NAMESPACE], - (plugins = {}) => ({ - active: [], - inserting: [], - removing: [], - ...plugins, - }) - )(frame); - - const result = activatePlugin('test.plugin')(frame); - - expect(result.state.how.joi.plugins.inserting).toContain('test.plugin'); - }); - - it('should add plugin to removing list', () => { - let frame: GameFrame = { - state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, - }; - - frame = Composer.over>( - ['state', PLUGIN_NAMESPACE], - (plugins = {}) => ({ - active: ['test.plugin'], - inserting: [], - removing: [], - ...plugins, - }) - )(frame); - - const result = deactivatePlugin('test.plugin')(frame); - - expect(result.state.how.joi.plugins.removing).toContain('test.plugin'); - }); - }); - - describe('savePlugin', () => { - it('should save plugin code to storage', () => { - const code = 'export default { id: "test" }'; - const frame: GameFrame = { - state: {}, - context: { - tick: 0, - deltaTime: 0, - elapsedTime: 0, - }, - }; - - savePlugin('test.plugin', code)(frame); - - const stored = localStorage.getItem('how.joi.plugin.code/test.plugin'); - expect(stored).toBe(JSON.stringify(code)); - }); - }); - - describe('Plugin Loading', () => { - it('should start loading user plugins from storage', () => { - const pluginCode = ` - export default { - id: 'user.plugin', - meta: { name: 'User Plugin' } - }; - `; - - localStorage.setItem( - 'how.joi.plugins.user', - JSON.stringify(['user.plugin']) - ); - localStorage.setItem( - 'how.joi.plugin.code/user.plugin', - JSON.stringify(pluginCode) - ); - - const frame: GameFrame = { - state: {}, - context: { - tick: 0, - deltaTime: 0, - elapsedTime: 0, - }, - }; - - const result = pluginManagerPipe(frame); - - const pluginContext = result.context.how?.joi?.plugins; - expect(pluginContext.pending.has('user.plugin')).toBe(true); - }); - }); -}); diff --git a/src/engine/pipes/Plugins.ts b/src/engine/pipes/Plugins.ts deleted file mode 100644 index a2d073a..0000000 --- a/src/engine/pipes/Plugins.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { Composer } from '../Composer'; -import { Pipe, GameFrame } from '../State'; -import { - startDOMBatching, - stopDOMBatching, - flushDOMOperations, -} from '../DOMBatcher'; -import { Storage } from './Storage'; - -export const PLUGIN_NAMESPACE = 'how.joi.plugins'; - -export type PluginId = string; - -export type PluginMeta = { - name?: string; - description?: string; - version?: string; - author?: string; -}; - -export type Plugin = { - id: PluginId; - meta?: PluginMeta; - activate?: Pipe; - update?: Pipe; - deactivate?: Pipe; -}; - -export type PluginManagerState = { - active: PluginId[]; - inserting: PluginId[]; - removing: PluginId[]; -}; - -export type PluginRegistry = { - [id: PluginId]: Plugin; -}; - -type PluginLoad = { - promise: Promise; - result?: Plugin; - error?: Error; -}; - -type PluginManagerContext = { - registry: PluginRegistry; - pending: Map; -}; - -const defaultState = (): PluginManagerState => ({ - active: [], - inserting: [], - removing: [], -}); - -const defaultContext = (): PluginManagerContext => ({ - registry: {}, - pending: new Map(), -}); - -export const INBUILT_PLUGINS: Plugin[] = []; - -async function load(code: string): Promise { - const blob = new Blob([code], { type: 'text/javascript' }); - const url = URL.createObjectURL(blob); - - try { - const module = await import(/* @vite-ignore */ url); - const plugin: Plugin = module.default || module.plugin; - - if (!plugin || !plugin.id) { - throw new Error('Plugin must export a Plugin object with an id'); - } - - return plugin; - } finally { - URL.revokeObjectURL(url); - } -} - -export function savePlugin(id: PluginId, code: string): Pipe { - return Storage.set(`how.joi.plugin.code/${id}`, code); -} - -export function activatePlugin(id: PluginId): Pipe { - return Composer.over( - ['state', PLUGIN_NAMESPACE], - (state = defaultState()) => { - if (state.inserting?.includes(id)) return state; - return { - ...state, - inserting: [...(state.inserting || []), id], - }; - } - ); -} - -export function deactivatePlugin(id: PluginId): Pipe { - return Composer.over( - ['state', PLUGIN_NAMESPACE], - (state = defaultState()) => { - if (state.removing?.includes(id)) return state; - return { - ...state, - removing: [...(state.removing || []), id], - }; - } - ); -} - -function getPlugin(registry: PluginRegistry, id: PluginId): Plugin | undefined { - return registry[id]; -} - -// STAGE 1: Register inbuilt plugins + resolve completed async loads -const buildRegistryPipe: Pipe = Composer.over( - ['context', PLUGIN_NAMESPACE], - (ctx = defaultContext()) => { - const registry = { ...ctx.registry }; - const pending = new Map(ctx.pending); - - for (const plugin of INBUILT_PLUGINS) { - registry[plugin.id] = plugin; - } - - for (const [id, entry] of pending) { - if (entry.result) { - registry[entry.result.id] = entry.result; - pending.delete(id); - } else if (entry.error) { - pending.delete(id); - } - } - - return { registry, pending }; - } -); - -// STAGE 2: Load user plugins from Storage, start async imports for new ones -const loadUserPluginsPipe: Pipe = Storage.bind( - 'how.joi.plugins.user', - userPluginIds => - Composer.pipe( - ...(userPluginIds || []).map(id => - Storage.bind(`how.joi.plugin.code/${id}`, code => - Composer.bind( - ['context', PLUGIN_NAMESPACE], - (ctx = defaultContext()) => { - if (ctx.registry[id] || ctx.pending.has(id) || !code) { - return (frame: GameFrame) => frame; - } - - const pluginLoad: PluginLoad = { - promise: load(code), - }; - - pluginLoad.promise.then( - plugin => { - pluginLoad.result = plugin; - }, - error => { - pluginLoad.error = error; - } - ); - - const pending = new Map(ctx.pending); - pending.set(id, pluginLoad); - - return Composer.set( - ['context', PLUGIN_NAMESPACE], - { registry: ctx.registry, pending } - ); - } - ) - ) - ) - ) -); - -// STAGE 3: Execute plugin lifecycle with DOM batching -const lifecyclePipe: Pipe = Composer.bind( - ['state', PLUGIN_NAMESPACE], - (state = defaultState()) => { - const { inserting = [], active = [], removing = [] } = state; - - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - (ctx = defaultContext()) => { - const pipes: Pipe[] = []; - - pipes.push((frame: GameFrame) => { - startDOMBatching(); - return frame; - }); - - for (const id of inserting) { - const plugin = getPlugin(ctx.registry, id); - if (plugin?.activate) pipes.push(plugin.activate); - } - - for (const id of active) { - if (!removing.includes(id)) { - const plugin = getPlugin(ctx.registry, id); - if (plugin?.update) pipes.push(plugin.update); - } - } - - for (const id of inserting) { - const plugin = getPlugin(ctx.registry, id); - if (plugin?.update) pipes.push(plugin.update); - } - - for (const id of removing) { - const plugin = getPlugin(ctx.registry, id); - if (plugin?.deactivate) pipes.push(plugin.deactivate); - } - - pipes.push((frame: GameFrame) => { - stopDOMBatching(); - flushDOMOperations(); - return frame; - }); - - return Composer.pipe(...pipes); - } - ); - } -); - -// STAGE 4: Transition state for next frame -const transitionPipe: Pipe = Composer.over( - ['state', PLUGIN_NAMESPACE], - (state = defaultState()) => { - const { active = [], inserting = [], removing = [] } = state; - - return { - active: [ - ...active.filter(id => !removing.includes(id)), - ...inserting.filter(id => !active.includes(id)), - ], - inserting: [], - removing: [], - }; - } -); - -export const pluginManagerPipe: Pipe = Composer.pipe( - buildRegistryPipe, - loadUserPluginsPipe, - lifecyclePipe, - transitionPipe -); diff --git a/src/engine/pipes/index.ts b/src/engine/pipes/index.ts index c73aaf9..046f4d3 100644 --- a/src/engine/pipes/index.ts +++ b/src/engine/pipes/index.ts @@ -1,6 +1,8 @@ export * from './Events'; export * from './Fps'; export * from './Messages'; -export * from './Plugins'; +export * from '../plugins/PluginInstaller'; +export * from '../plugins/PluginManager'; +export * from '../plugins/Plugins'; export * from './Scheduler'; export * from './Storage'; diff --git a/src/engine/plugins/PluginInstaller.test.ts b/src/engine/plugins/PluginInstaller.test.ts new file mode 100644 index 0000000..696271c --- /dev/null +++ b/src/engine/plugins/PluginInstaller.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { pluginInstallerPipe } from './PluginInstaller'; +import { GameFrame } from '../State'; + +const PLUGIN_NAMESPACE = 'core.plugin_installer'; + +const makeFrame = (overrides?: Partial): GameFrame => ({ + state: {}, + context: { tick: 0, deltaTime: 16, elapsedTime: 0 }, + ...overrides, +}); + +describe('Plugin Installer', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('User Plugin Loading', () => { + it('should start loading user plugins from storage', () => { + const pluginCode = `export default { id: 'user.plugin' };`; + + localStorage.setItem( + `${PLUGIN_NAMESPACE}.user`, + JSON.stringify(['user.plugin']) + ); + localStorage.setItem( + `${PLUGIN_NAMESPACE}.code/user.plugin`, + JSON.stringify(pluginCode) + ); + + const result = pluginInstallerPipe(makeFrame()); + + const installerContext = (result.context as any).core?.plugin_installer; + expect(installerContext.pending.has('user.plugin')).toBe(true); + }); + }); +}); diff --git a/src/engine/plugins/PluginInstaller.ts b/src/engine/plugins/PluginInstaller.ts new file mode 100644 index 0000000..7e5f1ba --- /dev/null +++ b/src/engine/plugins/PluginInstaller.ts @@ -0,0 +1,123 @@ +import { Composer } from '../Composer'; +import { Pipe, GameFrame } from '../State'; +import { Storage } from '../pipes/Storage'; +import { PluginManager } from './PluginManager'; +import { pluginPaths, type PluginId, type Plugin } from './Plugins'; + +const PLUGIN_NAMESPACE = 'core.plugin_installer'; + +type PluginLoad = { + promise: Promise; + result?: Plugin; + error?: Error; +}; + +type InstallerState = { + installed: PluginId[]; +}; + +type InstallerContext = { + pending: Map; +}; + +const ins = pluginPaths(PLUGIN_NAMESPACE); + +const storageKey = { + user: `${PLUGIN_NAMESPACE}.user`, + code: (id: PluginId) => `${PLUGIN_NAMESPACE}.code/${id}`, +}; + +async function load(code: string): Promise { + const blob = new Blob([code], { type: 'text/javascript' }); + const url = URL.createObjectURL(blob); + + try { + const module = await import(/* @vite-ignore */ url); + const plugin: Plugin = module.default || module.plugin; + + if (!plugin || !plugin.id) { + throw new Error('Plugin must export a Plugin object with an id'); + } + + return plugin; + } finally { + URL.revokeObjectURL(url); + } +} + +const importPipe: Pipe = Storage.bind( + storageKey.user, + (userPluginIds = []) => + Composer.pipe( + ...userPluginIds.map(id => + Storage.bind(storageKey.code(id), code => + Composer.do(({ get, over }) => { + const installed = get(ins.state.installed) ?? []; + const pending = get(ins.context.pending); + + if (installed.includes(id) || pending?.has(id)) return; + + if (!code) { + console.error( + `[PluginInstaller] plugin "${id}" has no code in storage` + ); + return; + } + + over(ins.context.pending, pending => { + if (!(pending instanceof Map)) pending = new Map(); + // TODO: generic async resolver pipe? + const pluginLoad: PluginLoad = { + promise: load(code), + }; + pluginLoad.promise.then( + plugin => { + pluginLoad.result = plugin; + }, + error => { + pluginLoad.error = error; + } + ); + return new Map([...pending, [id, pluginLoad]]); + }); + }) + ) + ) + ) +); + +const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { + const pending = get(ins.context.pending); + if (!pending?.size) return; + + const resolved: Plugin[] = []; + const remaining = new Map(); + + for (const [id, entry] of pending) { + if (entry.result) { + resolved.push(entry.result); + } else if (entry.error) { + // TODO: provide state for failed plugins + console.error( + `[PluginInstaller] failed to load plugin "${id}":`, + entry.error + ); + } else { + remaining.set(id, entry); + } + } + + if (resolved.length > 0) { + pipe(...resolved.map(PluginManager.register)); + over(ins.state.installed, (ids = []) => [ + ...ids, + ...resolved.map(p => p.id), + ]); + } + + if (remaining.size !== pending.size) { + set(ins.context.pending, remaining); + } +}); + +export const pluginInstallerPipe: Pipe = Composer.pipe(importPipe, resolvePipe); diff --git a/src/engine/plugins/PluginManager.test.ts b/src/engine/plugins/PluginManager.test.ts new file mode 100644 index 0000000..ff5b8a7 --- /dev/null +++ b/src/engine/plugins/PluginManager.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Plugin, EnabledMap } from './Plugins'; +import { PluginManager, pluginManagerPipe } from './PluginManager'; +import { eventPipe } from '../pipes/Events'; +import { Composer } from '../Composer'; +import { Pipe, GameFrame } from '../State'; + +const PLUGIN_NAMESPACE = 'core.plugin_manager'; + +const makeFrame = (overrides?: Partial): GameFrame => ({ + state: {}, + context: { tick: 0, deltaTime: 16, elapsedTime: 0 }, + ...overrides, +}); + +const tick = (frame: GameFrame, n = 1): GameFrame => ({ + ...frame, + context: { + ...frame.context, + tick: frame.context.tick + n, + deltaTime: 16, + elapsedTime: frame.context.elapsedTime + 16 * n, + }, +}); + +const gamePipe: Pipe = Composer.pipe(eventPipe, pluginManagerPipe); + +const getLoadedIds = (frame: GameFrame): string[] => + (frame.state as any)?.core?.plugin_manager?.loaded ?? []; + +const getLoadedRefs = (frame: GameFrame): Record => + (frame.context as any)?.core?.plugin_manager?.loadedRefs ?? {}; + +function bootstrap(plugin: Plugin): GameFrame { + const frame0 = gamePipe(makeFrame()); + const frame1 = PluginManager.register(plugin)(frame0); + return gamePipe(tick(frame1)); +} + +describe('Plugin System', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('Registration + auto-enable', () => { + it('should activate a registered plugin after event cycle', () => { + let activateCalled = false; + let updateCalled = false; + + bootstrap({ + id: 'test.plugin', + activate: (frame: GameFrame) => { + activateCalled = true; + return frame; + }, + update: (frame: GameFrame) => { + updateCalled = true; + return frame; + }, + }); + + expect(activateCalled).toBe(true); + expect(updateCalled).toBe(true); + }); + + it('should auto-enable registered plugins in Storage', () => { + bootstrap({ id: 'test.plugin' }); + + const stored: EnabledMap = JSON.parse( + localStorage.getItem(`${PLUGIN_NAMESPACE}.enabled`)! + ); + expect(stored['test.plugin']).toBe(true); + }); + + it('should track loaded plugin in state', () => { + const result = bootstrap({ id: 'test.plugin' }); + expect(getLoadedIds(result)).toContain('test.plugin'); + }); + + it('should store plugin ref in context', () => { + const result = bootstrap({ id: 'test.plugin' }); + expect(getLoadedRefs(result)).toHaveProperty('test.plugin'); + }); + }); + + describe('Lifecycle', () => { + it('should update active plugin on subsequent frames', () => { + let updateCount = 0; + + const frame1 = bootstrap({ + id: 'test.plugin', + update: (frame: GameFrame) => { + updateCount++; + return frame; + }, + }); + expect(updateCount).toBe(1); + + gamePipe(tick(frame1)); + expect(updateCount).toBe(2); + }); + + it('should deactivate plugin when disabled', () => { + let deactivateCalled = false; + + const frame1 = bootstrap({ + id: 'test.plugin', + deactivate: (frame: GameFrame) => { + deactivateCalled = true; + return frame; + }, + }); + expect(getLoadedIds(frame1)).toContain('test.plugin'); + + const frame2 = PluginManager.disable('test.plugin')(frame1); + const frame3 = gamePipe(tick(frame2)); + + expect(deactivateCalled).toBe(true); + expect(getLoadedIds(frame3)).not.toContain('test.plugin'); + }); + + it('should re-enable a disabled plugin', () => { + let activateCount = 0; + + const frame1 = bootstrap({ + id: 'test.plugin', + activate: (frame: GameFrame) => { + activateCount++; + return frame; + }, + }); + expect(activateCount).toBe(1); + + const frame2 = PluginManager.disable('test.plugin')(frame1); + const frame3 = gamePipe(tick(frame2)); + + const frame4 = PluginManager.enable('test.plugin')(frame3); + gamePipe(tick(frame4, 2)); + + expect(activateCount).toBe(2); + }); + }); + + describe('Unregister', () => { + it('should deactivate and remove a plugin from registry', () => { + let deactivateCalled = false; + + const frame1 = bootstrap({ + id: 'test.plugin', + deactivate: (frame: GameFrame) => { + deactivateCalled = true; + return frame; + }, + }); + expect(getLoadedIds(frame1)).toContain('test.plugin'); + + const frame2 = PluginManager.unregister('test.plugin')(frame1); + const frame3 = gamePipe(tick(frame2)); + + expect(deactivateCalled).toBe(true); + expect(getLoadedIds(frame3)).not.toContain('test.plugin'); + }); + + it('should not re-activate after unregister', () => { + let activateCount = 0; + + const frame1 = bootstrap({ + id: 'test.plugin', + activate: (frame: GameFrame) => { + activateCount++; + return frame; + }, + }); + expect(activateCount).toBe(1); + + const frame2 = PluginManager.unregister('test.plugin')(frame1); + const frame3 = gamePipe(tick(frame2)); + + gamePipe(tick(frame3, 2)); + expect(activateCount).toBe(1); + }); + }); + + describe('Disabled plugin persistence', () => { + it('should not activate a disabled plugin on restart', () => { + localStorage.setItem( + `${PLUGIN_NAMESPACE}.enabled`, + JSON.stringify({ 'test.plugin': false }) + ); + + const result = bootstrap({ id: 'test.plugin' }); + expect(getLoadedIds(result)).not.toContain('test.plugin'); + }); + }); + + describe('PluginManager.enable / PluginManager.disable', () => { + it('should persist enabled state to Storage', () => { + const frame0 = gamePipe(makeFrame()); + const frame1 = PluginManager.enable('test.plugin')(frame0); + gamePipe(tick(frame1)); + + const stored: EnabledMap = JSON.parse( + localStorage.getItem(`${PLUGIN_NAMESPACE}.enabled`)! + ); + expect(stored['test.plugin']).toBe(true); + }); + + it('should persist disabled state to Storage', () => { + localStorage.setItem( + `${PLUGIN_NAMESPACE}.enabled`, + JSON.stringify({ 'test.plugin': true }) + ); + + const frame0 = gamePipe(makeFrame()); + const frame1 = PluginManager.disable('test.plugin')(frame0); + gamePipe(tick(frame1)); + + const stored: EnabledMap = JSON.parse( + localStorage.getItem(`${PLUGIN_NAMESPACE}.enabled`)! + ); + expect(stored['test.plugin']).toBe(false); + }); + }); +}); diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts new file mode 100644 index 0000000..a6617f9 --- /dev/null +++ b/src/engine/plugins/PluginManager.ts @@ -0,0 +1,259 @@ +import { Composer } from '../Composer'; +import { Pipe, PipeTransformer, GameFrame } from '../State'; +import { + startDOMBatching, + stopDOMBatching, + flushDOMOperations, +} from '../DOMBatcher'; +import { Storage } from '../pipes/Storage'; +import { Events, getEventKey } from '../pipes/Events'; +import { + pluginPaths, + type PluginId, + type Plugin, + type PluginRegistry, + type EnabledMap, +} from './Plugins'; + +const PLUGIN_NAMESPACE = 'core.plugin_manager'; + +const eventType = { + register: getEventKey(PLUGIN_NAMESPACE, 'register'), + unregister: getEventKey(PLUGIN_NAMESPACE, 'unregister'), + enable: getEventKey(PLUGIN_NAMESPACE, 'enable'), + disable: getEventKey(PLUGIN_NAMESPACE, 'disable'), +}; + +const storageKey = { + enabled: `${PLUGIN_NAMESPACE}.enabled`, +}; + +type PluginManagerState = { + loaded: PluginId[]; +}; + +export type PluginManagerAPI = { + register: PipeTransformer<[Plugin]>; + unregister: PipeTransformer<[PluginId]>; + enable: PipeTransformer<[PluginId]>; + disable: PipeTransformer<[PluginId]>; +}; + +type PluginManagerContext = PluginManagerAPI & { + registry: PluginRegistry; + loadedRefs: Record; + toLoad: PluginId[]; + toUnload: PluginId[]; +}; + +const pm = pluginPaths( + PLUGIN_NAMESPACE +); + +export class PluginManager { + static register(plugin: Plugin): Pipe { + return Composer.bind(pm.context, ({ register }) => + register(plugin) + ); + } + + static unregister(id: PluginId): Pipe { + return Composer.bind(pm.context, ({ unregister }) => + unregister(id) + ); + } + + static enable(id: PluginId): Pipe { + return Composer.bind(pm.context, ({ enable }) => + enable(id) + ); + } + + static disable(id: PluginId): Pipe { + return Composer.bind(pm.context, ({ disable }) => + disable(id) + ); + } +} + +const apiPipe: Pipe = Composer.over(pm.context, ctx => ({ + ...ctx, + + register: plugin => + Events.dispatch({ + type: eventType.register, + payload: plugin, + }), + + unregister: id => + Events.dispatch({ + type: eventType.unregister, + payload: id, + }), + + enable: id => + Events.dispatch({ + type: eventType.enable, + payload: id, + }), + + disable: id => + Events.dispatch({ + type: eventType.disable, + payload: id, + }), +})); + +// TODO: enable/disable plugin storage should probably live elsewhere. +const enableDisablePipe: Pipe = Composer.pipe( + Events.handle(eventType.enable, event => + Storage.bind(storageKey.enabled, (map = {}) => + Storage.set(storageKey.enabled, { + ...map, + [event.payload as PluginId]: true, + }) + ) + ), + Events.handle(eventType.disable, event => + Storage.bind(storageKey.enabled, (map = {}) => + Storage.set(storageKey.enabled, { + ...map, + [event.payload as PluginId]: false, + }) + ) + ) +); + +const reconcilePipe: Pipe = Composer.pipe( + Events.handle(eventType.register, event => + Composer.do(({ over }) => { + const plugin = event.payload as Plugin; + over(pm.context.registry, registry => ({ + ...registry, + [plugin.id]: plugin, + })); + }) + ), + Events.handle(eventType.unregister, event => + Composer.do(({ over }) => { + const id = event.payload as PluginId; + over(pm.context.toUnload, (ids = []) => + Array.isArray(ids) ? [...ids, id] : [id] + ); + }) + ), + Storage.bind(storageKey.enabled, (stored = {}) => + Composer.do(({ get, set, pipe }) => { + const registry = get(pm.context.registry) ?? {}; + const loaded = get(pm.state.loaded) ?? []; + const forcedUnload = get(pm.context.toUnload) ?? []; + + const map = { ...stored }; + let dirty = false; + + for (const id of Object.keys(registry)) { + if (!(id in map)) { + map[id] = true; + dirty = true; + } + } + + const shouldBeLoaded = new Set( + Object.keys(map).filter(id => map[id] && registry[id]) + ); + + for (const id of forcedUnload) shouldBeLoaded.delete(id); + + const currentlyLoaded = new Set(loaded); + + const toUnload = [...currentlyLoaded].filter( + id => !shouldBeLoaded.has(id) + ); + + const toLoad = [...shouldBeLoaded].filter(id => !currentlyLoaded.has(id)); + + if (!dirty && toLoad.length === 0 && toUnload.length === 0) return; + + if (dirty) pipe(Storage.set(storageKey.enabled, map)); + if (toLoad.length > 0) set(pm.context.toLoad, toLoad); + if (toUnload.length > 0) set(pm.context.toUnload, toUnload); + }) + ) +); + +// TODO: lifecycle should include error handling +const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { + const toUnload = get(pm.context.toUnload) ?? []; + const toLoad = get(pm.context.toLoad) ?? []; + const loadedRefs = get(pm.context.loadedRefs) ?? {}; + const registry = get(pm.context.registry) ?? {}; + + const deactivates = toUnload + .map(id => (loadedRefs[id] ?? registry[id])?.deactivate) + .filter(Boolean) as Pipe[]; + + const activates = toLoad + .map(id => registry[id]?.activate) + .filter(Boolean) as Pipe[]; + + const activeIds = [ + ...Object.keys(loadedRefs).filter(id => !toUnload.includes(id)), + ...toLoad, + ]; + + const updates = activeIds + .map(id => (loadedRefs[id] ?? registry[id])?.update) + .filter(Boolean) as Pipe[]; + + const pipes = [...deactivates, ...activates, ...updates]; + if (pipes.length === 0) return; + + startDOMBatching(); + pipe(...pipes); + stopDOMBatching(); + flushDOMOperations(); +}); + +const finalizePipe: Pipe = Composer.pipe( + Events.handle(eventType.unregister, event => + Composer.do(({ over }) => { + const id = event.payload as PluginId; + over(pm.context.registry, registry => { + const next = { ...registry }; + delete next[id]; + return next; + }); + }) + ), + Composer.do(({ get, set, over }) => { + const toUnload = get(pm.context.toUnload) ?? []; + const toLoad = get(pm.context.toLoad) ?? []; + + if (toLoad.length === 0 && toUnload.length === 0) return; + + const loadedRefs = get(pm.context.loadedRefs) ?? {}; + const registry = get(pm.context.registry) ?? {}; + + const newRefs = { ...loadedRefs }; + for (const id of toUnload) delete newRefs[id]; + for (const id of toLoad) { + if (registry[id]) newRefs[id] = registry[id]; + } + + set(pm.state.loaded, Object.keys(newRefs)); + over(pm.context, ctx => ({ + ...ctx, + loadedRefs: newRefs, + toLoad: [], + toUnload: [], + })); + }) +); + +export const pluginManagerPipe: Pipe = Composer.pipe( + apiPipe, + enableDisablePipe, + reconcilePipe, + lifecyclePipe, + finalizePipe +); diff --git a/src/engine/plugins/Plugins.ts b/src/engine/plugins/Plugins.ts new file mode 100644 index 0000000..9994bc7 --- /dev/null +++ b/src/engine/plugins/Plugins.ts @@ -0,0 +1,31 @@ +import { typedPath, TypedPath } from '../Lens'; +import { Pipe } from '../State'; + +export function pluginPaths>( + namespace: string +): { state: TypedPath; context: TypedPath } { + return { + state: typedPath(['state', namespace]), + context: typedPath(['context', namespace]), + }; +} + +export type PluginId = string; + +export type PluginMeta = { + name?: string; + description?: string; + version?: string; + author?: string; +}; + +export type Plugin = { + id: PluginId; + meta?: PluginMeta; + activate?: Pipe; + update?: Pipe; + deactivate?: Pipe; +}; + +export type PluginRegistry = Record; +export type EnabledMap = Record; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 6e1e120..d0277ec 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -1,16 +1,17 @@ import styled from 'styled-components'; import { GameEngineProvider } from './GameProvider'; -import { FpsDisplay } from './components/FpsDisplay'; import { messagesPipe } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; -import { fpsPipe } from '../engine/pipes/Fps'; import { useSettingsPipe, warmupPipe, pacePipe } from './pipes'; import { GameIntensity } from './components/GameIntensity'; import { intensityPipe } from './pipes/Intensity'; import { imagePipe, randomImagesPipe } from './pipes'; import { GameImages } from './components/GameImages'; import { phasePipe } from './pipes/Phase'; +import { pluginInstallerPipe } from '../engine/plugins/PluginInstaller'; +import { pluginManagerPipe } from '../engine/plugins/PluginManager'; +import { registerPlugins } from './plugins'; const StyledGamePage = styled.div` position: relative; @@ -78,7 +79,6 @@ export const GamePage = () => { return ( { imagePipe, randomImagesPipe, warmupPipe, - // messageTestPipe, + pluginManagerPipe, + pluginInstallerPipe, + registerPlugins, ]} > - + - diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts new file mode 100644 index 0000000..ed4a4da --- /dev/null +++ b/src/game/plugins/fps.ts @@ -0,0 +1,80 @@ +import { Plugin, pluginPaths } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine/Composer'; + +const PLUGIN_ID = 'core.fps'; +const PLUGIN_VERSION = '0.1.0'; +const ELEMENT_ATTR = 'data-plugin-id'; +const HISTORY_SIZE = 30; + +export type FpsState = { + value: number; + history: number[]; +}; + +type FpsContext = { + el: HTMLElement; +}; + +const fps = pluginPaths(PLUGIN_ID); + +export function createFpsPlugin(): Plugin { + return { + id: PLUGIN_ID, + meta: { + name: 'FPS Counter', + version: PLUGIN_VERSION, + }, + + activate: frame => { + const existing = document.querySelector( + `[${ELEMENT_ATTR}="${PLUGIN_ID}"]` + ); + if (existing) existing.remove(); + + const el = document.createElement('div'); + el.setAttribute(ELEMENT_ATTR, PLUGIN_ID); + Object.assign(el.style, { + position: 'absolute', + top: '8px', + right: '8px', + background: 'black', + color: 'white', + padding: '4px 8px', + fontFamily: 'monospace', + fontSize: '12px', + zIndex: '9999', + pointerEvents: 'none', + }); + document.querySelector('.game-page')?.appendChild(el); + + return Composer.pipe( + Composer.set(fps.state, { value: 0, history: [] }), + Composer.set(fps.context, { el }) + )(frame); + }, + + update: Composer.do(({ get, set }) => { + const delta = get(['context', 'deltaTime']); + const s = get(fps.state); + const el = get(fps.context)?.el; + + const current = delta > 0 ? 1000 / delta : 0; + const history = [...s.history, current].slice(-HISTORY_SIZE); + const avg = + history.length > 0 + ? history.reduce((sum, v) => sum + v, 0) / history.length + : current; + + if (el) el.textContent = `${Math.round(avg)} FPS`; + + set(fps.state, { value: current, history }); + }), + + deactivate: Composer.do(({ get, set }) => { + const el = get(fps.context)?.el; + if (el) el.remove(); + set(fps.state, undefined); + set(fps.context, undefined); + }), + }; +} diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts new file mode 100644 index 0000000..4f4fe09 --- /dev/null +++ b/src/game/plugins/index.ts @@ -0,0 +1,10 @@ +import { PluginManager } from '../../engine/plugins/PluginManager'; +import { Composer } from '../../engine/Composer'; +import { Pipe } from '../../engine/State'; +import { createFpsPlugin } from './fps'; + +const plugins = [createFpsPlugin()]; + +export const registerPlugins: Pipe = Composer.pipe( + ...plugins.map(p => PluginManager.register(p)) +); From 5ce7cacc22228a6bb0b8b0d2774de30f72c17894 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 13 Feb 2026 13:43:55 +0100 Subject: [PATCH 40/90] added pause plugin --- src/engine/pipes/Scheduler.ts | 49 ++++++++++++++++++++ src/game/GameProvider.tsx | 32 ++----------- src/game/components/Pause.tsx | 19 +++----- src/game/pipes/Intensity.ts | 7 +-- src/game/pipes/Warmup.ts | 84 ++++++++++++++++++---------------- src/game/plugins/index.ts | 3 +- src/game/plugins/pause.ts | 85 +++++++++++++++++++++++++++++++++++ 7 files changed, 195 insertions(+), 84 deletions(-) create mode 100644 src/game/plugins/pause.ts diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index a8dbf5f..33fc7cb 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -12,6 +12,7 @@ export type ScheduledEvent = { id?: string; duration: number; event: GameEvent; + held?: boolean; }; type SchedulerState = { @@ -22,6 +23,8 @@ type SchedulerState = { export type SchedulerContext = { schedule: PipeTransformer<[ScheduledEvent]>; cancel: PipeTransformer<[string]>; + hold: PipeTransformer<[string]>; + release: PipeTransformer<[string]>; }; export class Scheduler { @@ -38,6 +41,20 @@ export class Scheduler { ({ cancel }) => cancel(id) ); } + + static hold(id: string): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ hold }) => hold(id) + ); + } + + static release(id: string): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ release }) => release(id) + ); + } } export const schedulerPipe: Pipe = Composer.pipe( @@ -49,6 +66,10 @@ export const schedulerPipe: Pipe = Composer.pipe( const current: GameEvent[] = []; for (const entry of scheduled) { + if (entry.held) { + remaining.push(entry); + continue; + } const time = entry.duration - delta; if (time <= 0) { current.push(entry.event); @@ -78,6 +99,18 @@ export const schedulerPipe: Pipe = Composer.pipe( type: getEventKey(PLUGIN_NAMESPACE, 'cancel'), payload: id, }), + + hold: (id: string) => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'hold'), + payload: id, + }), + + release: (id: string) => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'release'), + payload: id, + }), }), Events.handle(getEventKey(PLUGIN_NAMESPACE, 'schedule'), event => @@ -95,5 +128,21 @@ export const schedulerPipe: Pipe = Composer.pipe( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => list.filter(s => s.id !== event.payload) ) + ), + + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'hold'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE, 'scheduled'], + (list = []) => + list.map(s => (s.id === event.payload ? { ...s, held: true } : s)) + ) + ), + + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'release'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE, 'scheduled'], + (list = []) => + list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) + ) ) ); diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 6ac33b9..16170df 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -14,24 +14,13 @@ type GameEngineContextValue = { * The current game context which contains inter-pipe data and debugging information. */ context: GameContext | null; - /** - * Hard pause the game engine, stopping all updates and rendering. - */ - pause: () => void; - /** - * Resume the game engine after a pause. - */ - resume: () => void; - /** - * Whether the game engine is currently running. - */ - isRunning: boolean; /** * Queue a one-shot pipe to run in the next tick only. */ injectImpulse: (pipe: Pipe) => void; }; +// eslint-disable-next-line react-refresh/only-export-components export const GameEngineContext = createContext< GameEngineContextValue | undefined >(undefined); @@ -43,20 +32,14 @@ type Props = { export function GameEngineProvider({ children, pipes = [] }: Props) { const engineRef = useRef(null); - const runningRef = useRef(true); const lastTimeRef = useRef(null); const [state, setState] = useState(null); const [context, setContext] = useState(null); - const [isRunning, setIsRunning] = useState(runningRef.current); const pendingImpulseRef = useRef([]); const activeImpulseRef = useRef([]); - useEffect(() => { - runningRef.current = isRunning; - }, [isRunning]); - useEffect(() => { // To inject one-shot pipes (impulses) into the engine, // we use the pending ref to stage them, and the active ref to apply them. @@ -74,9 +57,7 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { const loop = (time: number) => { if (!engineRef.current) return; - // When paused, we advance time but do not tick. - // This prevents incorrectly accumulating delta time during pauses. - if (lastTimeRef.current == null || !runningRef.current) { + if (lastTimeRef.current == null) { lastTimeRef.current = time; frameId = requestAnimationFrame(loop); return; @@ -85,7 +66,6 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { const deltaTime = time - lastTimeRef.current; lastTimeRef.current = time; - // activate pending impulses activeImpulseRef.current = pendingImpulseRef.current; pendingImpulseRef.current = []; @@ -108,18 +88,12 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { }; }, [pipes]); - const pause = () => setIsRunning(false); - - const resume = () => setIsRunning(true); - const injectImpulse = (pipe: Pipe) => { pendingImpulseRef.current.push(pipe); }; return ( - + {children} ); diff --git a/src/game/components/Pause.tsx b/src/game/components/Pause.tsx index 35360e2..bda5228 100644 --- a/src/game/components/Pause.tsx +++ b/src/game/components/Pause.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from 'react'; import styled from 'styled-components'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; import { useGameEngine } from '../hooks/UseGameEngine'; +import { useGameState } from '../hooks/UseGameValue'; +import { Pause, PauseState } from '../plugins/pause'; const PauseContainer = styled.div` position: absolute; @@ -30,20 +31,12 @@ const PauseIconButton = styled.button` `; export const PauseButton = () => { - const { pause, resume, isRunning } = useGameEngine(); - const [paused, setPaused] = useState(!isRunning); - - useEffect(() => { - setPaused(!isRunning); - }, [isRunning]); + const { injectImpulse } = useGameEngine(); + const pauseState = useGameState('core.pause'); + const paused = pauseState?.paused ?? false; const togglePause = () => { - if (paused) { - resume(); - } else { - pause(); - } - setPaused(!paused); + injectImpulse(Pause.togglePause); }; return ( diff --git a/src/game/pipes/Intensity.ts b/src/game/pipes/Intensity.ts index c1cb144..fd26715 100644 --- a/src/game/pipes/Intensity.ts +++ b/src/game/pipes/Intensity.ts @@ -1,6 +1,7 @@ import { Composer, Pipe } from '../../engine'; import { Settings } from '../../settings'; import { GamePhase } from './Phase'; +import { Pause } from '../plugins/pause'; const PLUGIN_NAMESPACE = 'core.intensity'; @@ -8,9 +9,8 @@ export type IntensityState = { intensity: number; }; -export const intensityPipe: Pipe = Composer.bind( - ['state', 'core.phase', 'current'], - currentPhase => +export const intensityPipe: Pipe = Pause.whenPlaying( + Composer.bind(['state', 'core.phase', 'current'], currentPhase => Composer.when( currentPhase === GamePhase.active, Composer.bind(['context', 'deltaTime'], delta => @@ -27,4 +27,5 @@ export const intensityPipe: Pipe = Composer.bind( ) ) ) + ) ); diff --git a/src/game/pipes/Warmup.ts b/src/game/pipes/Warmup.ts index 52ce1af..8b901d8 100644 --- a/src/game/pipes/Warmup.ts +++ b/src/game/pipes/Warmup.ts @@ -4,62 +4,66 @@ import { Messages } from '../../engine/pipes/Messages'; import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; import { GamePhase, setPhase } from './Phase'; import { Settings } from '../../settings'; +import { Pause } from '../plugins/pause'; const PLUGIN_NAMESPACE = 'core.warmup'; +const AUTOSTART_KEY = getScheduleKey(PLUGIN_NAMESPACE, 'autoStart'); export type WarmupState = { initialized: boolean; }; export const warmupPipe: Pipe = Composer.pipe( - Composer.bind(['state', 'core.phase', 'current'], currentPhase => - Composer.bind(['context', 'settings'], settings => - Composer.bind( - ['state', PLUGIN_NAMESPACE], - (state = { initialized: false }) => - Composer.when( - currentPhase === GamePhase.warmup, - Composer.pipe( - Composer.when( - settings.warmupDuration === 0, - setPhase(GamePhase.active) - ), - Composer.when( - settings.warmupDuration > 0 && !state.initialized, - Composer.pipe( - Composer.set(['state', PLUGIN_NAMESPACE], { - initialized: true, - }), - Messages.send({ - id: GamePhase.warmup, - title: 'Get yourself ready!', - prompts: [ - { - title: `I'm ready, $master`, - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), + Pause.whenPlaying( + Composer.bind(['state', 'core.phase', 'current'], currentPhase => + Composer.bind(['context', 'settings'], settings => + Composer.bind( + ['state', PLUGIN_NAMESPACE], + (state = { initialized: false }) => + Composer.when( + currentPhase === GamePhase.warmup, + Composer.pipe( + Composer.when( + settings.warmupDuration === 0, + setPhase(GamePhase.active) + ), + Composer.when( + settings.warmupDuration > 0 && !state.initialized, + Composer.pipe( + Composer.set(['state', PLUGIN_NAMESPACE], { + initialized: true, + }), + Messages.send({ + id: GamePhase.warmup, + title: 'Get yourself ready!', + prompts: [ + { + title: `I'm ready, $master`, + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), + }, }, + ], + }), + Scheduler.schedule({ + id: AUTOSTART_KEY, + duration: settings.warmupDuration * 1000, + event: { + type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), }, - ], - }), - Scheduler.schedule({ - id: getScheduleKey(PLUGIN_NAMESPACE, 'autoStart'), - duration: settings.warmupDuration * 1000, - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), - }, - }) + }) + ) ) ) ) - ) + ) ) ) ), Events.handle(getEventKey(PLUGIN_NAMESPACE, 'startGame'), () => Composer.pipe( - Scheduler.cancel(getScheduleKey(PLUGIN_NAMESPACE, 'autoStart')), + Scheduler.cancel(AUTOSTART_KEY), Composer.set(PLUGIN_NAMESPACE, { initialized: false }), Messages.send({ id: GamePhase.warmup, @@ -69,5 +73,9 @@ export const warmupPipe: Pipe = Composer.pipe( }), setPhase(GamePhase.active) ) - ) + ), + + Pause.onPause(() => Scheduler.hold(AUTOSTART_KEY)), + + Pause.onResume(() => Scheduler.release(AUTOSTART_KEY)) ); diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index 4f4fe09..d278c07 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -2,8 +2,9 @@ import { PluginManager } from '../../engine/plugins/PluginManager'; import { Composer } from '../../engine/Composer'; import { Pipe } from '../../engine/State'; import { createFpsPlugin } from './fps'; +import { createPausePlugin } from './pause'; -const plugins = [createFpsPlugin()]; +const plugins = [createPausePlugin(), createFpsPlugin()]; export const registerPlugins: Pipe = Composer.pipe( ...plugins.map(p => PluginManager.register(p)) diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts new file mode 100644 index 0000000..697a7d1 --- /dev/null +++ b/src/game/plugins/pause.ts @@ -0,0 +1,85 @@ +import { Plugin, pluginPaths } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine/Composer'; +import { GameFrame, Pipe, PipeTransformer } from '../../engine/State'; +import { Events, getEventKey } from '../../engine/pipes/Events'; + +const PLUGIN_ID = 'core.pause'; + +export type PauseState = { + paused: boolean; + prev: boolean; +}; + +type PauseContext = { + setPaused: PipeTransformer<[boolean]>; + togglePause: Pipe; +}; + +const paths = pluginPaths(PLUGIN_ID); + +const eventType = { + on: getEventKey(PLUGIN_ID, 'on'), + off: getEventKey(PLUGIN_ID, 'off'), +}; + +export class Pause { + static setPaused(val: boolean): Pipe { + return Composer.set(paths.state.paused, val); + } + + static get togglePause(): Pipe { + return Composer.bind(paths.state, ({ paused }) => + Pause.setPaused(!paused) + ); + } + + static whenPaused(pipe: Pipe): Pipe { + return Composer.bind(paths.state, ({ paused }) => + Composer.when(paused, pipe) + ); + } + + static whenPlaying(pipe: Pipe): Pipe { + return Composer.bind(paths.state, ({ paused }) => + Composer.when(!paused, pipe) + ); + } + + static onPause(fn: () => Pipe): Pipe { + return Events.handle(eventType.on, fn); + } + + static onResume(fn: () => Pipe): Pipe { + return Events.handle(eventType.off, fn); + } +} + +export function createPausePlugin(): Plugin { + return { + id: PLUGIN_ID, + meta: { + name: 'Pause', + version: '0.1.0', + }, + + activate: Composer.set(paths.state, { paused: false, prev: false }), + + update: Composer.pipe( + Composer.set(paths.context, { + setPaused: val => Pause.setPaused(val), + togglePause: Pause.togglePause, + }), + Composer.do(({ get, set, pipe }) => { + const { paused, prev } = get(paths.state); + if (paused === prev) return; + set(paths.state.prev, paused); + pipe(Events.dispatch({ type: paused ? eventType.on : eventType.off })); + }) + ), + + deactivate: Composer.pipe( + Composer.set(paths.state, undefined), + Composer.set(paths.context, undefined) + ), + }; +} From 4923df46d9b4fbf250592b2213e19e87a78124a7 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 13 Feb 2026 15:13:34 +0100 Subject: [PATCH 41/90] added global sdk object for plugins --- src/engine/Engine.ts | 6 +- src/engine/plugins/PluginInstaller.test.ts | 172 +++++++++++++++++++-- src/engine/plugins/PluginInstaller.ts | 29 ++-- src/engine/plugins/PluginManager.ts | 1 + src/engine/sdk.ts | 33 ++++ src/game/GamePage.tsx | 6 +- src/game/components/Pause.tsx | 2 +- src/game/pipes/Intensity.ts | 2 +- src/game/pipes/Warmup.ts | 2 +- src/game/plugins/fps.ts | 13 +- src/game/plugins/index.ts | 6 +- src/game/plugins/pause.ts | 47 +++--- 12 files changed, 256 insertions(+), 63 deletions(-) create mode 100644 src/engine/sdk.ts diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 4f8887d..9f5c240 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -15,8 +15,7 @@ export class GameEngine { /** * The state of the engine. This object should contain all information to run the game from a cold start. - * - * Pipes may add any additional fields. + * Pipes may add any additional fields. Must be serializable to JSON. */ private state: GameState; @@ -28,7 +27,7 @@ export class GameEngine { /** * The context of the engine. May contain any ephemeral information of any plugin, however it is to be noted; * Context may be discarded at any time, so it may not contain information necessary to restore the game state. - * As such, this object can contain inter-pipe communication, utility functions, or debugging information. + * It is not required to be serializable. As such, this object can contain inter-pipe communication, utility functions, or debugging information. */ private context: GameContext; @@ -72,6 +71,7 @@ export class GameEngine { const result = this.pipe(frame); + // TODO: this is (probably) expensive. We could make it debug only? this.state = cloneDeep(result.state); this.context = cloneDeep({ ...result.context, diff --git a/src/engine/plugins/PluginInstaller.test.ts b/src/engine/plugins/PluginInstaller.test.ts index 696271c..4c54623 100644 --- a/src/engine/plugins/PluginInstaller.test.ts +++ b/src/engine/plugins/PluginInstaller.test.ts @@ -1,6 +1,11 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { pluginInstallerPipe } from './PluginInstaller'; -import { GameFrame } from '../State'; +import { pluginManagerPipe } from './PluginManager'; +import { eventPipe } from '../pipes/Events'; +import { Composer } from '../Composer'; +import { GameFrame, Pipe } from '../State'; +import { sdk } from '../sdk'; +import type { Plugin } from './Plugins'; const PLUGIN_NAMESPACE = 'core.plugin_installer'; @@ -10,28 +15,161 @@ const makeFrame = (overrides?: Partial): GameFrame => ({ ...overrides, }); +const tick = (frame: GameFrame): GameFrame => ({ + ...frame, + context: { + ...frame.context, + tick: frame.context.tick + 1, + deltaTime: 16, + elapsedTime: frame.context.elapsedTime + 16, + }, +}); + +const fullPipe: Pipe = Composer.pipe( + eventPipe, + pluginManagerPipe, + pluginInstallerPipe +); + +const getPending = (frame: GameFrame): Map | undefined => + (frame.context as any)?.core?.plugin_installer?.pending; + +const getInstalledIds = (frame: GameFrame): string[] => + (frame.state as any)?.core?.plugin_installer?.installed ?? []; + +const getLoadedIds = (frame: GameFrame): string[] => + (frame.state as any)?.core?.plugin_manager?.loaded ?? []; + +function makeLoadResult(plugin: Plugin, name: string) { + const exported = { plugin, name }; + return { + promise: Promise.resolve({ plugin, exported }), + result: { plugin, exported }, + }; +} + describe('Plugin Installer', () => { beforeEach(() => { localStorage.clear(); }); - describe('User Plugin Loading', () => { - it('should start loading user plugins from storage', () => { - const pluginCode = `export default { id: 'user.plugin' };`; + afterEach(() => { + delete (sdk as any).TestPlugin; + }); + + it('should create pending entries from storage', () => { + localStorage.setItem( + `${PLUGIN_NAMESPACE}.user`, + JSON.stringify(['user.test']) + ); + localStorage.setItem( + `${PLUGIN_NAMESPACE}.code/user.test`, + JSON.stringify( + `export default class Test { static plugin = { id: 'user.test' } }` + ) + ); + + const result = pluginInstallerPipe(makeFrame()); + const pending = getPending(result); + + expect(pending).toBeInstanceOf(Map); + expect(pending!.has('user.test')).toBe(true); + expect(pending!.get('user.test')).toHaveProperty('promise'); + }); + + it('should skip plugins with no code in storage', () => { + localStorage.setItem( + `${PLUGIN_NAMESPACE}.user`, + JSON.stringify(['user.nocode']) + ); + + const result = pluginInstallerPipe(makeFrame()); + expect(getPending(result)?.has('user.nocode')).toBeFalsy(); + }); + + it('should skip already installed plugins', () => { + localStorage.setItem( + `${PLUGIN_NAMESPACE}.user`, + JSON.stringify(['user.test']) + ); + localStorage.setItem( + `${PLUGIN_NAMESPACE}.code/user.test`, + JSON.stringify(`code`) + ); + + const result = pluginInstallerPipe( + makeFrame({ + state: { core: { plugin_installer: { installed: ['user.test'] } } }, + }) + ); + + expect(getPending(result)?.has('user.test')).toBeFalsy(); + }); + + it('should register resolved plugins with PluginManager', () => { + let activated = false; + const testPlugin: Plugin = { + id: 'user.resolved', + activate: frame => { + activated = true; + return frame; + }, + }; + + const frame0 = fullPipe(makeFrame()); + + const frame1 = Composer.set( + ['context', 'core', 'plugin_installer', 'pending'], + new Map([['user.resolved', makeLoadResult(testPlugin, 'TestPlugin')]]) + )(frame0); + + const frame2 = fullPipe(tick(frame1)); + + expect(getInstalledIds(frame2)).toContain('user.resolved'); + + const frame3 = fullPipe(tick(frame2)); + + expect(activated).toBe(true); + expect(getLoadedIds(frame3)).toContain('user.resolved'); + }); + + it('should register plugin class on sdk by name', () => { + const testPlugin: Plugin = { id: 'user.sdk' }; + + const frame0 = fullPipe(makeFrame()); + + const frame1 = Composer.set( + ['context', 'core', 'plugin_installer', 'pending'], + new Map([['user.sdk', makeLoadResult(testPlugin, 'TestPlugin')]]) + )(frame0); + + fullPipe(tick(frame1)); + + expect((sdk as any).TestPlugin).toBeDefined(); + expect((sdk as any).TestPlugin.plugin.id).toBe('user.sdk'); + }); + + it('should drop errored plugins from pending', () => { + const error = new Error('load failed'); + + const frame0 = fullPipe(makeFrame()); - localStorage.setItem( - `${PLUGIN_NAMESPACE}.user`, - JSON.stringify(['user.plugin']) - ); - localStorage.setItem( - `${PLUGIN_NAMESPACE}.code/user.plugin`, - JSON.stringify(pluginCode) - ); + const frame1 = Composer.set( + ['context', 'core', 'plugin_installer', 'pending'], + new Map([ + [ + 'user.broken', + { + promise: Promise.reject(error).catch(() => {}), + error, + }, + ], + ]) + )(frame0); - const result = pluginInstallerPipe(makeFrame()); + const frame2 = fullPipe(tick(frame1)); - const installerContext = (result.context as any).core?.plugin_installer; - expect(installerContext.pending.has('user.plugin')).toBe(true); - }); + expect(getPending(frame2)?.has('user.broken')).toBeFalsy(); + expect(getInstalledIds(frame2)).not.toContain('user.broken'); }); }); diff --git a/src/engine/plugins/PluginInstaller.ts b/src/engine/plugins/PluginInstaller.ts index 7e5f1ba..07551ec 100644 --- a/src/engine/plugins/PluginInstaller.ts +++ b/src/engine/plugins/PluginInstaller.ts @@ -1,17 +1,23 @@ import { Composer } from '../Composer'; import { Pipe, GameFrame } from '../State'; import { Storage } from '../pipes/Storage'; +import { sdk } from '../sdk'; import { PluginManager } from './PluginManager'; import { pluginPaths, type PluginId, type Plugin } from './Plugins'; const PLUGIN_NAMESPACE = 'core.plugin_installer'; type PluginLoad = { - promise: Promise; - result?: Plugin; + promise: Promise; + result?: PluginLoadResult; error?: Error; }; +type PluginLoadResult = { + plugin: Plugin; + exported: any; +}; + type InstallerState = { installed: PluginId[]; }; @@ -27,19 +33,23 @@ const storageKey = { code: (id: PluginId) => `${PLUGIN_NAMESPACE}.code/${id}`, }; -async function load(code: string): Promise { +async function load(code: string): Promise { + (globalThis as any).sdk = sdk; + const blob = new Blob([code], { type: 'text/javascript' }); const url = URL.createObjectURL(blob); try { const module = await import(/* @vite-ignore */ url); - const plugin: Plugin = module.default || module.plugin; + const exported = module.default; - if (!plugin || !plugin.id) { - throw new Error('Plugin must export a Plugin object with an id'); + if (!exported?.plugin?.id) { + throw new Error( + 'Plugin must export a default class with a static plugin field' + ); } - return plugin; + return { plugin: exported.plugin, exported }; } finally { URL.revokeObjectURL(url); } @@ -95,7 +105,8 @@ const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { for (const [id, entry] of pending) { if (entry.result) { - resolved.push(entry.result); + (sdk as any)[entry.result.exported.name] = entry.result.exported; + resolved.push(entry.result.plugin); } else if (entry.error) { // TODO: provide state for failed plugins console.error( @@ -110,7 +121,7 @@ const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { if (resolved.length > 0) { pipe(...resolved.map(PluginManager.register)); over(ins.state.installed, (ids = []) => [ - ...ids, + ...(Array.isArray(ids) ? ids : []), ...resolved.map(p => p.id), ]); } diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index a6617f9..009da8c 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -182,6 +182,7 @@ const reconcilePipe: Pipe = Composer.pipe( ); // TODO: lifecycle should include error handling +// TODO: OTEL spans for performance monitoring const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const toUnload = get(pm.context.toUnload) ?? []; const toLoad = get(pm.context.toLoad) ?? []; diff --git a/src/engine/sdk.ts b/src/engine/sdk.ts new file mode 100644 index 0000000..065944a --- /dev/null +++ b/src/engine/sdk.ts @@ -0,0 +1,33 @@ +import { Composer } from './Composer'; +import { Events } from './pipes/Events'; +import { Messages } from './pipes/Messages'; +import { Scheduler } from './pipes/Scheduler'; +import { Storage } from './pipes/Storage'; +import { PluginManager } from './plugins/PluginManager'; +import { pluginPaths } from './plugins/Plugins'; +import { Random } from './Random'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface PluginSDK {} + +export interface SDK extends PluginSDK { + Composer: typeof Composer; + Events: typeof Events; + Messages: typeof Messages; + Scheduler: typeof Scheduler; + Storage: typeof Storage; + PluginManager: typeof PluginManager; + Random: typeof Random; + pluginPaths: typeof pluginPaths; +} + +export const sdk: SDK = { + Composer, + Events, + Messages, + Scheduler, + Storage, + PluginManager, + Random, + pluginPaths, +} as SDK; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index d0277ec..9c6acbb 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -80,6 +80,9 @@ export const GamePage = () => { { imagePipe, randomImagesPipe, warmupPipe, - pluginManagerPipe, - pluginInstallerPipe, - registerPlugins, ]} > diff --git a/src/game/components/Pause.tsx b/src/game/components/Pause.tsx index bda5228..257d0a1 100644 --- a/src/game/components/Pause.tsx +++ b/src/game/components/Pause.tsx @@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; import { useGameEngine } from '../hooks/UseGameEngine'; import { useGameState } from '../hooks/UseGameValue'; -import { Pause, PauseState } from '../plugins/pause'; +import Pause, { type PauseState } from '../plugins/pause'; const PauseContainer = styled.div` position: absolute; diff --git a/src/game/pipes/Intensity.ts b/src/game/pipes/Intensity.ts index fd26715..313c140 100644 --- a/src/game/pipes/Intensity.ts +++ b/src/game/pipes/Intensity.ts @@ -1,7 +1,7 @@ import { Composer, Pipe } from '../../engine'; import { Settings } from '../../settings'; import { GamePhase } from './Phase'; -import { Pause } from '../plugins/pause'; +import Pause from '../plugins/pause'; const PLUGIN_NAMESPACE = 'core.intensity'; diff --git a/src/game/pipes/Warmup.ts b/src/game/pipes/Warmup.ts index 8b901d8..58de693 100644 --- a/src/game/pipes/Warmup.ts +++ b/src/game/pipes/Warmup.ts @@ -4,7 +4,7 @@ import { Messages } from '../../engine/pipes/Messages'; import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; import { GamePhase, setPhase } from './Phase'; import { Settings } from '../../settings'; -import { Pause } from '../plugins/pause'; +import Pause from '../plugins/pause'; const PLUGIN_NAMESPACE = 'core.warmup'; const AUTOSTART_KEY = getScheduleKey(PLUGIN_NAMESPACE, 'autoStart'); diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts index ed4a4da..492afeb 100644 --- a/src/game/plugins/fps.ts +++ b/src/game/plugins/fps.ts @@ -1,8 +1,9 @@ -import { Plugin, pluginPaths } from '../../engine/plugins/Plugins'; -import { Composer } from '../../engine/Composer'; +import type { Plugin } from '../../engine/plugins/Plugins'; +import { sdk } from '../../engine/sdk'; + +const { Composer, pluginPaths } = sdk; const PLUGIN_ID = 'core.fps'; -const PLUGIN_VERSION = '0.1.0'; const ELEMENT_ATTR = 'data-plugin-id'; const HISTORY_SIZE = 30; @@ -17,12 +18,12 @@ type FpsContext = { const fps = pluginPaths(PLUGIN_ID); -export function createFpsPlugin(): Plugin { - return { +export default class Fps { + static plugin: Plugin = { id: PLUGIN_ID, meta: { name: 'FPS Counter', - version: PLUGIN_VERSION, + version: '0.1.0', }, activate: frame => { diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index d278c07..d440430 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -1,10 +1,10 @@ import { PluginManager } from '../../engine/plugins/PluginManager'; import { Composer } from '../../engine/Composer'; import { Pipe } from '../../engine/State'; -import { createFpsPlugin } from './fps'; -import { createPausePlugin } from './pause'; +import Fps from './fps'; +import Pause from './pause'; -const plugins = [createPausePlugin(), createFpsPlugin()]; +const plugins = [Pause.plugin, Fps.plugin]; export const registerPlugins: Pipe = Composer.pipe( ...plugins.map(p => PluginManager.register(p)) diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index 697a7d1..8b150a8 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -1,7 +1,15 @@ -import { Plugin, pluginPaths } from '../../engine/plugins/Plugins'; +import type { Plugin } from '../../engine/plugins/Plugins'; +import { sdk } from '../../engine/sdk'; import { Composer } from '../../engine/Composer'; import { GameFrame, Pipe, PipeTransformer } from '../../engine/State'; import { Events, getEventKey } from '../../engine/pipes/Events'; +import { pluginPaths } from '../../engine/plugins/Plugins'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Pause: typeof Pause; + } +} const PLUGIN_ID = 'core.pause'; @@ -15,33 +23,33 @@ type PauseContext = { togglePause: Pipe; }; -const paths = pluginPaths(PLUGIN_ID); +const pause = pluginPaths(PLUGIN_ID); const eventType = { on: getEventKey(PLUGIN_ID, 'on'), off: getEventKey(PLUGIN_ID, 'off'), }; -export class Pause { +export default class Pause { static setPaused(val: boolean): Pipe { - return Composer.set(paths.state.paused, val); + return Composer.set(pause.state.paused, val); } static get togglePause(): Pipe { - return Composer.bind(paths.state, ({ paused }) => - Pause.setPaused(!paused) + return Composer.bind(pause.state, state => + Pause.setPaused(!state?.paused) ); } static whenPaused(pipe: Pipe): Pipe { - return Composer.bind(paths.state, ({ paused }) => - Composer.when(paused, pipe) + return Composer.bind(pause.state, state => + Composer.when(!!state?.paused, pipe) ); } static whenPlaying(pipe: Pipe): Pipe { - return Composer.bind(paths.state, ({ paused }) => - Composer.when(!paused, pipe) + return Composer.bind(pause.state, state => + Composer.when(!state?.paused, pipe) ); } @@ -52,34 +60,35 @@ export class Pause { static onResume(fn: () => Pipe): Pipe { return Events.handle(eventType.off, fn); } -} -export function createPausePlugin(): Plugin { - return { + static plugin: Plugin = { id: PLUGIN_ID, meta: { name: 'Pause', version: '0.1.0', }, - activate: Composer.set(paths.state, { paused: false, prev: false }), + activate: frame => { + sdk.Pause = Pause; + return Composer.set(pause.state, { paused: false, prev: false })(frame); + }, update: Composer.pipe( - Composer.set(paths.context, { + Composer.set(pause.context, { setPaused: val => Pause.setPaused(val), togglePause: Pause.togglePause, }), Composer.do(({ get, set, pipe }) => { - const { paused, prev } = get(paths.state); + const { paused, prev } = get(pause.state); if (paused === prev) return; - set(paths.state.prev, paused); + set(pause.state.prev, paused); pipe(Events.dispatch({ type: paused ? eventType.on : eventType.off })); }) ), deactivate: Composer.pipe( - Composer.set(paths.state, undefined), - Composer.set(paths.context, undefined) + Composer.set(pause.state, undefined), + Composer.set(pause.context, undefined) ), }; } From a4b2c92cfa137b8630fed6f991e9cdfaee152000 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 13 Feb 2026 16:46:07 +0100 Subject: [PATCH 42/90] added performance monitoring --- src/engine/pipes/Perf.test.ts | 237 ++++++++++++++++++++++++++++ src/engine/pipes/Perf.ts | 132 ++++++++++++++++ src/engine/pipes/index.ts | 1 + src/engine/plugins/PluginManager.ts | 16 +- src/engine/sdk.ts | 3 + src/game/GameProvider.tsx | 3 +- src/game/plugins/fps.ts | 56 +++---- src/game/plugins/index.ts | 3 +- src/game/plugins/pause.ts | 26 +-- src/game/plugins/perf.ts | 120 ++++++++++++++ 10 files changed, 551 insertions(+), 46 deletions(-) create mode 100644 src/engine/pipes/Perf.test.ts create mode 100644 src/engine/pipes/Perf.ts create mode 100644 src/game/plugins/perf.ts diff --git a/src/engine/pipes/Perf.test.ts b/src/engine/pipes/Perf.test.ts new file mode 100644 index 0000000..6496257 --- /dev/null +++ b/src/engine/pipes/Perf.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect } from 'vitest'; +import { Composer } from '../Composer'; +import { GameFrame, Pipe } from '../State'; +import { eventPipe } from './Events'; +import { + perfPipe, + withTiming, + Perf, + type PerfContext, + type PluginPerfEntry, +} from './Perf'; + +const makeFrame = (overrides?: Partial): GameFrame => ({ + state: {}, + context: { tick: 0, deltaTime: 16, elapsedTime: 0 }, + ...overrides, +}); + +const tick = (frame: GameFrame): GameFrame => ({ + ...frame, + context: { + ...frame.context, + tick: frame.context.tick + 1, + deltaTime: 16, + elapsedTime: frame.context.elapsedTime + 16, + }, +}); + +const basePipe: Pipe = Composer.pipe(eventPipe, perfPipe); + +const getPerfCtx = (frame: GameFrame): PerfContext | undefined => + (frame.context as any)?.core?.perf; + +const getEntry = ( + frame: GameFrame, + pluginId: string, + phase: string +): PluginPerfEntry | undefined => + (getPerfCtx(frame)?.plugins as any)?.[pluginId]?.[phase]; + +describe('Perf', () => { + describe('perfPipe', () => { + it('should initialize perf context with defaults', () => { + const result = basePipe(makeFrame()); + const ctx = getPerfCtx(result); + + expect(ctx).toBeDefined(); + expect(ctx!.plugins).toEqual({}); + expect(ctx!.config.pluginBudget).toBe(4); + }); + + it('should preserve existing metrics across frames', () => { + const frame0 = basePipe(makeFrame()); + + const noop: Pipe = frame => frame; + const timed = withTiming('test.plugin', 'update', noop); + const frame1 = Composer.pipe(basePipe, timed)(tick(frame0)); + + expect(getEntry(frame1, 'test.plugin', 'update')).toBeDefined(); + + const frame2 = basePipe(tick(frame1)); + expect(getEntry(frame2, 'test.plugin', 'update')).toBeDefined(); + }); + }); + + describe('withTiming', () => { + it('should record duration into perf context', () => { + const noop: Pipe = frame => frame; + const pipe = Composer.pipe( + basePipe, + withTiming('test.plugin', 'update', noop) + ); + + const result = pipe(makeFrame()); + const entry = getEntry(result, 'test.plugin', 'update'); + + expect(entry).toBeDefined(); + expect(entry!.last).toBeGreaterThanOrEqual(0); + expect(entry!.avg).toBeGreaterThanOrEqual(0); + expect(entry!.max).toBeGreaterThanOrEqual(0); + expect(entry!.samples).toHaveLength(1); + }); + + it('should accumulate samples across invocations', () => { + const noop: Pipe = frame => frame; + const pipe = Composer.pipe( + basePipe, + withTiming('test.plugin', 'update', noop) + ); + + let frame = makeFrame(); + for (let i = 0; i < 5; i++) { + frame = pipe(tick(frame)); + } + + const entry = getEntry(frame, 'test.plugin', 'update'); + expect(entry!.samples).toHaveLength(5); + }); + + it('should cap rolling window at 60 samples', () => { + const noop: Pipe = frame => frame; + const pipe = Composer.pipe( + basePipe, + withTiming('test.plugin', 'update', noop) + ); + + let frame = makeFrame(); + for (let i = 0; i < 80; i++) { + frame = pipe(tick(frame)); + } + + const entry = getEntry(frame, 'test.plugin', 'update'); + expect(entry!.samples).toHaveLength(60); + }); + + it('should track max as all-time high', () => { + let slowOnce = true; + const sometimesSlow: Pipe = frame => { + if (slowOnce) { + slowOnce = false; + const end = performance.now() + 1; + while (performance.now() < end /* busy wait */); + } + return frame; + }; + + const pipe = Composer.pipe( + basePipe, + withTiming('test.plugin', 'update', sometimesSlow) + ); + + let frame = pipe(makeFrame()); + const maxAfterSlow = getEntry(frame, 'test.plugin', 'update')!.max; + expect(maxAfterSlow).toBeGreaterThan(0.5); + + frame = pipe(tick(frame)); + const maxAfterFast = getEntry(frame, 'test.plugin', 'update')!.max; + expect(maxAfterFast).toBeGreaterThanOrEqual(maxAfterSlow); + }); + + it('should track separate phases independently', () => { + const noop: Pipe = frame => frame; + const pipe = Composer.pipe( + basePipe, + withTiming('test.plugin', 'activate', noop), + withTiming('test.plugin', 'update', noop) + ); + + const result = pipe(makeFrame()); + + expect(getEntry(result, 'test.plugin', 'activate')).toBeDefined(); + expect(getEntry(result, 'test.plugin', 'update')).toBeDefined(); + }); + + it('should track separate plugins independently', () => { + const noop: Pipe = frame => frame; + const pipe = Composer.pipe( + basePipe, + withTiming('plugin.a', 'update', noop), + withTiming('plugin.b', 'update', noop) + ); + + const result = pipe(makeFrame()); + + expect(getEntry(result, 'plugin.a', 'update')).toBeDefined(); + expect(getEntry(result, 'plugin.b', 'update')).toBeDefined(); + }); + + it('should work without perfPipe (graceful fallback)', () => { + const noop: Pipe = frame => frame; + const pipe = Composer.pipe( + eventPipe, + withTiming('test.plugin', 'update', noop) + ); + + const result = pipe(makeFrame()); + const entry = getEntry(result, 'test.plugin', 'update'); + + expect(entry).toBeDefined(); + expect(entry!.last).toBeGreaterThanOrEqual(0); + }); + }); + + describe('over budget events', () => { + it('should dispatch over_budget event when plugin exceeds budget', () => { + const slow: Pipe = frame => { + const end = performance.now() + 6; + while (performance.now() < end /* busy wait */); + return frame; + }; + + const pipe = Composer.pipe( + basePipe, + withTiming('test.plugin', 'update', slow) + ); + const frame1 = pipe(makeFrame()); + + const pending = (frame1.state as any)?.core?.events?.pending ?? []; + const overBudgetEvents = pending.filter( + (e: any) => e.type === 'core.perf/over_budget' + ); + + expect(overBudgetEvents).toHaveLength(1); + expect(overBudgetEvents[0].payload.id).toBe('test.plugin'); + expect(overBudgetEvents[0].payload.phase).toBe('update'); + expect(overBudgetEvents[0].payload.duration).toBeGreaterThan(4); + }); + + it('should not dispatch event when within budget', () => { + const noop: Pipe = frame => frame; + const pipe = Composer.pipe( + basePipe, + withTiming('test.plugin', 'update', noop) + ); + const frame1 = pipe(makeFrame()); + + const pending = (frame1.state as any)?.core?.events?.pending ?? []; + const overBudgetEvents = pending.filter( + (e: any) => e.type === 'core.perf/over_budget' + ); + + expect(overBudgetEvents).toHaveLength(0); + }); + }); + + describe('Perf.configure', () => { + it('should update budget via configure event', () => { + const frame0 = basePipe(makeFrame()); + + const frame1 = Perf.configure({ pluginBudget: 2 })(frame0); + const frame2 = basePipe(tick(frame1)); + + const ctx = getPerfCtx(frame2); + expect(ctx!.config.pluginBudget).toBe(2); + }); + }); +}); diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts new file mode 100644 index 0000000..31aebc9 --- /dev/null +++ b/src/engine/pipes/Perf.ts @@ -0,0 +1,132 @@ +import { Composer } from '../Composer'; +import { GameFrame, Pipe } from '../State'; +import { Events, getEventKey, GameEvent } from './Events'; +import { pluginPaths, PluginId } from '../plugins/Plugins'; + +export type PluginHookPhase = 'activate' | 'update' | 'deactivate'; + +export type PluginPerfEntry = { + last: number; + avg: number; + max: number; + samples: number[]; +}; + +export type PerfMetrics = Record< + PluginId, + Partial> +>; + +export type PerfConfig = { + pluginBudget: number; +}; + +export type PerfContext = { + plugins: PerfMetrics; + config: PerfConfig; +}; + +const PLUGIN_NAMESPACE = 'core.perf'; +const WINDOW_SIZE = 60; + +const DEFAULT_CONFIG: PerfConfig = { + pluginBudget: 4, +}; + +const eventType = { + overBudget: getEventKey(PLUGIN_NAMESPACE, 'over_budget'), + configure: getEventKey(PLUGIN_NAMESPACE, 'configure'), +}; + +const perf = pluginPaths(PLUGIN_NAMESPACE); + +export function withTiming( + id: PluginId, + phase: PluginHookPhase, + pluginPipe: Pipe +): Pipe { + return Composer.do(({ get, set, pipe }) => { + const before = performance.now(); + pipe(pluginPipe); + const after = performance.now(); + const duration = after - before; + + const ctx = get(perf.context) ?? { plugins: {}, config: DEFAULT_CONFIG }; + const pluginMetrics = ctx.plugins[id] ?? {}; + const entry = pluginMetrics[phase]; + + const samples = entry + ? [...entry.samples, duration].slice(-WINDOW_SIZE) + : [duration]; + + const avg = + samples.length > 0 + ? samples.reduce((sum, v) => sum + v, 0) / samples.length + : duration; + + const max = entry ? Math.max(entry.max, duration) : duration; + + const newEntry: PluginPerfEntry = { last: duration, avg, max, samples }; + + set(perf.context, { + ...ctx, + plugins: { + ...ctx.plugins, + [id]: { + ...pluginMetrics, + [phase]: newEntry, + }, + }, + }); + + const budget = ctx.config.pluginBudget; + if (duration > budget) { + if (import.meta.env.DEV) { + console.warn( + `[perf] ${id} ${phase} took ${duration.toFixed(2)}ms (budget: ${budget}ms)` + ); + } + pipe( + Events.dispatch({ + type: eventType.overBudget, + payload: { id, phase, duration, budget }, + }) + ); + } + }); +} + +export class Perf { + static paths = perf; + + static configure(config: Partial): Pipe { + return Events.dispatch({ + type: eventType.configure, + payload: config, + }); + } + + static onOverBudget(fn: (event: GameEvent) => Pipe): Pipe { + return Events.handle(eventType.overBudget, fn); + } +} + +export const perfPipe: Pipe = Composer.pipe( + Composer.over( + perf.context, + (ctx = { plugins: {}, config: DEFAULT_CONFIG }) => ({ + ...ctx, + plugins: ctx.plugins ?? {}, + config: ctx.config ?? DEFAULT_CONFIG, + }) + ), + Events.handle(eventType.configure, event => + Composer.over(perf.context, ctx => ({ + ...ctx, + config: { + ...ctx.config, + ...(event.payload as Partial), + }, + })) + ) +); diff --git a/src/engine/pipes/index.ts b/src/engine/pipes/index.ts index 046f4d3..6040f39 100644 --- a/src/engine/pipes/index.ts +++ b/src/engine/pipes/index.ts @@ -6,3 +6,4 @@ export * from '../plugins/PluginManager'; export * from '../plugins/Plugins'; export * from './Scheduler'; export * from './Storage'; +export * from './Perf'; diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index 009da8c..e9d03cc 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -14,6 +14,7 @@ import { type PluginRegistry, type EnabledMap, } from './Plugins'; +import { withTiming } from '../pipes/Perf'; const PLUGIN_NAMESPACE = 'core.plugin_manager'; @@ -190,11 +191,17 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const registry = get(pm.context.registry) ?? {}; const deactivates = toUnload - .map(id => (loadedRefs[id] ?? registry[id])?.deactivate) + .map(id => { + const p = (loadedRefs[id] ?? registry[id])?.deactivate; + return p ? withTiming(id, 'deactivate', p) : undefined; + }) .filter(Boolean) as Pipe[]; const activates = toLoad - .map(id => registry[id]?.activate) + .map(id => { + const p = registry[id]?.activate; + return p ? withTiming(id, 'activate', p) : undefined; + }) .filter(Boolean) as Pipe[]; const activeIds = [ @@ -203,7 +210,10 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { ]; const updates = activeIds - .map(id => (loadedRefs[id] ?? registry[id])?.update) + .map(id => { + const p = (loadedRefs[id] ?? registry[id])?.update; + return p ? withTiming(id, 'update', p) : undefined; + }) .filter(Boolean) as Pipe[]; const pipes = [...deactivates, ...activates, ...updates]; diff --git a/src/engine/sdk.ts b/src/engine/sdk.ts index 065944a..c83e43a 100644 --- a/src/engine/sdk.ts +++ b/src/engine/sdk.ts @@ -6,6 +6,7 @@ import { Storage } from './pipes/Storage'; import { PluginManager } from './plugins/PluginManager'; import { pluginPaths } from './plugins/Plugins'; import { Random } from './Random'; +import { Perf } from './pipes/Perf'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface PluginSDK {} @@ -17,6 +18,7 @@ export interface SDK extends PluginSDK { Scheduler: typeof Scheduler; Storage: typeof Storage; PluginManager: typeof PluginManager; + Perf: typeof Perf; Random: typeof Random; pluginPaths: typeof pluginPaths; } @@ -28,6 +30,7 @@ export const sdk: SDK = { Scheduler, Storage, PluginManager, + Perf, Random, pluginPaths, } as SDK; diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 16170df..62c5b78 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -2,6 +2,7 @@ import { createContext, useEffect, useRef, useState, ReactNode } from 'react'; import { GameEngine, GameState, Pipe, GameContext } from '../engine'; import { eventPipe } from '../engine/pipes/Events'; import { schedulerPipe } from '../engine/pipes/Scheduler'; +import { perfPipe } from '../engine/pipes/Perf'; import { Piper } from '../engine/Piper'; import { Composer } from '../engine/Composer'; @@ -49,7 +50,7 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { engineRef.current = new GameEngine( {}, - Piper([impulsePipe, eventPipe, schedulerPipe, ...pipes]) + Piper([impulsePipe, eventPipe, schedulerPipe, perfPipe, ...pipes]) ); let frameId: number; diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts index 492afeb..b1f81d7 100644 --- a/src/game/plugins/fps.ts +++ b/src/game/plugins/fps.ts @@ -5,18 +5,15 @@ const { Composer, pluginPaths } = sdk; const PLUGIN_ID = 'core.fps'; const ELEMENT_ATTR = 'data-plugin-id'; +const STYLE_ID = `${PLUGIN_ID}-styles`; const HISTORY_SIZE = 30; -export type FpsState = { - value: number; - history: number[]; -}; - type FpsContext = { el: HTMLElement; + history: number[]; }; -const fps = pluginPaths(PLUGIN_ID); +const fps = pluginPaths(PLUGIN_ID); export default class Fps { static plugin: Plugin = { @@ -27,6 +24,25 @@ export default class Fps { }, activate: frame => { + const style = + document.getElementById(STYLE_ID) ?? document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + [${ELEMENT_ATTR}="${PLUGIN_ID}"] { + position: absolute; + top: 8px; + right: 8px; + background: black; + color: white; + padding: 4px 8px; + font-family: monospace; + font-size: 12px; + z-index: 9999; + pointer-events: none; + } + `; + if (!style.parentNode) document.head.appendChild(style); + const existing = document.querySelector( `[${ELEMENT_ATTR}="${PLUGIN_ID}"]` ); @@ -34,47 +50,31 @@ export default class Fps { const el = document.createElement('div'); el.setAttribute(ELEMENT_ATTR, PLUGIN_ID); - Object.assign(el.style, { - position: 'absolute', - top: '8px', - right: '8px', - background: 'black', - color: 'white', - padding: '4px 8px', - fontFamily: 'monospace', - fontSize: '12px', - zIndex: '9999', - pointerEvents: 'none', - }); document.querySelector('.game-page')?.appendChild(el); - return Composer.pipe( - Composer.set(fps.state, { value: 0, history: [] }), - Composer.set(fps.context, { el }) - )(frame); + return Composer.set(fps.context, { el, history: [] })(frame); }, update: Composer.do(({ get, set }) => { const delta = get(['context', 'deltaTime']); - const s = get(fps.state); - const el = get(fps.context)?.el; + const ctx = get(fps.context); + if (!ctx) return; const current = delta > 0 ? 1000 / delta : 0; - const history = [...s.history, current].slice(-HISTORY_SIZE); + const history = [...ctx.history, current].slice(-HISTORY_SIZE); const avg = history.length > 0 ? history.reduce((sum, v) => sum + v, 0) / history.length : current; - if (el) el.textContent = `${Math.round(avg)} FPS`; + if (ctx.el) ctx.el.textContent = `${Math.round(avg)} FPS`; - set(fps.state, { value: current, history }); + set(fps.context, { ...ctx, history }); }), deactivate: Composer.do(({ get, set }) => { const el = get(fps.context)?.el; if (el) el.remove(); - set(fps.state, undefined); set(fps.context, undefined); }), }; diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index d440430..d140ed7 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -3,8 +3,9 @@ import { Composer } from '../../engine/Composer'; import { Pipe } from '../../engine/State'; import Fps from './fps'; import Pause from './pause'; +import PerfOverlay from './perf'; -const plugins = [Pause.plugin, Fps.plugin]; +const plugins = [Pause.plugin, Fps.plugin, PerfOverlay.plugin]; export const registerPlugins: Pipe = Composer.pipe( ...plugins.map(p => PluginManager.register(p)) diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index 8b150a8..5659a14 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -70,21 +70,21 @@ export default class Pause { activate: frame => { sdk.Pause = Pause; - return Composer.set(pause.state, { paused: false, prev: false })(frame); + return Composer.pipe( + Composer.set(pause.state, { paused: false, prev: false }), + Composer.set(pause.context, { + setPaused: val => Pause.setPaused(val), + togglePause: Pause.togglePause, + }) + )(frame); }, - update: Composer.pipe( - Composer.set(pause.context, { - setPaused: val => Pause.setPaused(val), - togglePause: Pause.togglePause, - }), - Composer.do(({ get, set, pipe }) => { - const { paused, prev } = get(pause.state); - if (paused === prev) return; - set(pause.state.prev, paused); - pipe(Events.dispatch({ type: paused ? eventType.on : eventType.off })); - }) - ), + update: Composer.do(({ get, set, pipe }) => { + const { paused, prev } = get(pause.state); + if (paused === prev) return; + set(pause.state.prev, paused); + pipe(Events.dispatch({ type: paused ? eventType.on : eventType.off })); + }), deactivate: Composer.pipe( Composer.set(pause.state, undefined), diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts new file mode 100644 index 0000000..2541a9b --- /dev/null +++ b/src/game/plugins/perf.ts @@ -0,0 +1,120 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import type { PerfMetrics, PluginHookPhase } from '../../engine/pipes/Perf'; +import { sdk } from '../../engine/sdk'; + +const { Composer, Perf, pluginPaths } = sdk; + +const PLUGIN_ID = 'core.perf_overlay'; +const ELEMENT_ATTR = 'data-plugin-id'; +const STYLE_ID = `${PLUGIN_ID}-styles`; + +type PerfOverlayContext = { + el: HTMLElement; +}; + +const po = pluginPaths(PLUGIN_ID); + +function budgetColor(duration: number, budget: number): string { + const ratio = duration / budget; + if (ratio < 0.5) return '#4ade80'; + if (ratio < 1.0) return '#facc15'; + return '#f87171'; +} + +function formatLine( + id: string, + phase: PluginHookPhase, + last: number, + avg: number, + budget: number +): string { + const name = id.padEnd(24); + const ph = phase.padEnd(11); + const l = `${last.toFixed(2)}ms`.padStart(8); + const a = `avg ${avg.toFixed(2)}ms`.padStart(12); + const color = budgetColor(avg, budget); + return `${name}${ph}${l}${a}`; +} + +export default class PerfOverlay { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Performance Overlay', + version: '0.1.0', + }, + + activate: frame => { + const style = + document.getElementById(STYLE_ID) ?? document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + [${ELEMENT_ATTR}="${PLUGIN_ID}"] { + position: absolute; + top: 42px; + right: 8px; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px 12px; + font-family: monospace; + font-size: 11px; + line-height: 1.4; + z-index: 9999; + pointer-events: none; + white-space: pre; + border-radius: 4px; + } + `; + if (!style.parentNode) document.head.appendChild(style); + + const existing = document.querySelector( + `[${ELEMENT_ATTR}="${PLUGIN_ID}"]` + ); + if (existing) existing.remove(); + + const el = document.createElement('div'); + el.setAttribute(ELEMENT_ATTR, PLUGIN_ID); + document.querySelector('.game-page')?.appendChild(el); + + return Composer.set(po.context, { el })(frame); + }, + + update: Composer.do(({ get }) => { + const el = get(po.context)?.el; + if (!el) return; + + const ctx = get(Perf.paths.context); + if (!ctx) return; + + const { plugins, config } = ctx; + const lines: string[] = []; + + for (const [id, phases] of Object.entries(plugins as PerfMetrics)) { + if (id === PLUGIN_ID) continue; + for (const [phase, entry] of Object.entries(phases)) { + if (!entry) continue; + lines.push( + formatLine( + id, + phase as PluginHookPhase, + entry.last, + entry.avg, + config.pluginBudget + ) + ); + } + } + + el.innerHTML = + lines.length > 0 + ? lines.join('\n') + : 'no plugin data'; + }), + + deactivate: Composer.do(({ get, set }) => { + const el = get(po.context)?.el; + if (el) el.remove(); + set(po.context, undefined); + }), + }; +} From 96ab0a0c3e0edac141b8ee9e4bed9e3d32390159 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 13 Feb 2026 17:58:54 +0100 Subject: [PATCH 43/90] moved game pips to plugins --- src/engine/Composer.test.ts | 37 +++++++ src/engine/Composer.ts | 15 +++ src/engine/plugins/PluginInstaller.test.ts | 11 +- src/engine/plugins/PluginInstaller.ts | 26 ++--- src/engine/plugins/PluginManager.test.ts | 9 +- src/engine/plugins/PluginManager.ts | 31 ++++-- src/engine/plugins/Plugins.ts | 7 +- src/game/GamePage.tsx | 11 +- src/game/components/GameImages.tsx | 2 +- src/game/components/GamePace.tsx | 2 +- src/game/pipes/Image.ts | 86 -------------- src/game/pipes/Intensity.ts | 31 ------ src/game/pipes/Pace.ts | 39 ------- src/game/pipes/Phase.ts | 46 -------- src/game/pipes/RandomImages.ts | 108 ------------------ src/game/pipes/Warmup.ts | 81 -------------- src/game/pipes/index.ts | 4 - src/game/plugins/fps.ts | 8 +- src/game/plugins/image.ts | 123 +++++++++++++++++++++ src/game/plugins/index.ts | 20 +++- src/game/plugins/intensity.ts | 51 +++++++++ src/game/plugins/pace.ts | 60 ++++++++++ src/game/plugins/pause.ts | 40 +++---- src/game/plugins/perf.ts | 1 - src/game/plugins/phase.ts | 83 ++++++++++++++ src/game/plugins/randomImages.ts | 96 ++++++++++++++++ src/game/plugins/warmup.ts | 103 +++++++++++++++++ 27 files changed, 660 insertions(+), 471 deletions(-) delete mode 100644 src/game/pipes/Image.ts delete mode 100644 src/game/pipes/Intensity.ts delete mode 100644 src/game/pipes/Pace.ts delete mode 100644 src/game/pipes/Phase.ts delete mode 100644 src/game/pipes/RandomImages.ts delete mode 100644 src/game/pipes/Warmup.ts create mode 100644 src/game/plugins/image.ts create mode 100644 src/game/plugins/intensity.ts create mode 100644 src/game/plugins/pace.ts create mode 100644 src/game/plugins/phase.ts create mode 100644 src/game/plugins/randomImages.ts create mode 100644 src/game/plugins/warmup.ts diff --git a/src/engine/Composer.test.ts b/src/engine/Composer.test.ts index 341f2a0..c087af8 100644 --- a/src/engine/Composer.test.ts +++ b/src/engine/Composer.test.ts @@ -260,6 +260,43 @@ describe('Composer', () => { }); }); + describe('Composer.call', () => { + it('should read a function from path and call it with args', () => { + type State = { + value: number; + api: { setValue: (n: number) => (obj: any) => any }; + }; + + const obj: State = { + value: 0, + api: { setValue: n => o => ({ ...o, value: n }) }, + }; + + const result = Composer.call( + 'api.setValue', + 42 + )(obj); + + expect(result.value).toBe(42); + }); + + it('should work with multiple arguments', () => { + type State = { + result: number; + api: { add: (a: number, b: number) => (obj: any) => any }; + }; + + const obj: State = { + result: 0, + api: { add: (a, b) => o => ({ ...o, result: a + b }) }, + }; + + const result = Composer.call('api.add', 3, 7)(obj); + + expect(result.result).toBe(10); + }); + }); + describe('Composer.when', () => { it('should create a conditional function (true)', () => { const fn = Composer.when<{ value: number }>(true, o => ({ diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index 5aa46de..45a5d18 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -159,6 +159,21 @@ export class Composer { Composer.chain(c => c.bind(path, fn))(obj); } + call (obj: any) => any>( + path: Path, + ...args: Parameters + ): this { + return this.bind(path, (fn: A) => fn(...args)); + } + + static call (obj: any) => any>( + path: Path, + ...args: Parameters + ) { + return (obj: T): T => + Composer.chain(c => c.call(path, ...args))(obj); + } + /** * Runs a composer function when the condition is true. */ diff --git a/src/engine/plugins/PluginInstaller.test.ts b/src/engine/plugins/PluginInstaller.test.ts index 4c54623..8b4f1ad 100644 --- a/src/engine/plugins/PluginInstaller.test.ts +++ b/src/engine/plugins/PluginInstaller.test.ts @@ -5,7 +5,7 @@ import { eventPipe } from '../pipes/Events'; import { Composer } from '../Composer'; import { GameFrame, Pipe } from '../State'; import { sdk } from '../sdk'; -import type { Plugin } from './Plugins'; +import type { Plugin, PluginClass } from './Plugins'; const PLUGIN_NAMESPACE = 'core.plugin_installer'; @@ -41,10 +41,10 @@ const getLoadedIds = (frame: GameFrame): string[] => (frame.state as any)?.core?.plugin_manager?.loaded ?? []; function makeLoadResult(plugin: Plugin, name: string) { - const exported = { plugin, name }; + const cls = { plugin, name } as PluginClass; return { - promise: Promise.resolve({ plugin, exported }), - result: { plugin, exported }, + promise: Promise.resolve(cls), + result: cls, }; } @@ -143,7 +143,8 @@ describe('Plugin Installer', () => { new Map([['user.sdk', makeLoadResult(testPlugin, 'TestPlugin')]]) )(frame0); - fullPipe(tick(frame1)); + const frame2 = fullPipe(tick(frame1)); + fullPipe(tick(frame2)); expect((sdk as any).TestPlugin).toBeDefined(); expect((sdk as any).TestPlugin.plugin.id).toBe('user.sdk'); diff --git a/src/engine/plugins/PluginInstaller.ts b/src/engine/plugins/PluginInstaller.ts index 07551ec..6177007 100644 --- a/src/engine/plugins/PluginInstaller.ts +++ b/src/engine/plugins/PluginInstaller.ts @@ -3,21 +3,16 @@ import { Pipe, GameFrame } from '../State'; import { Storage } from '../pipes/Storage'; import { sdk } from '../sdk'; import { PluginManager } from './PluginManager'; -import { pluginPaths, type PluginId, type Plugin } from './Plugins'; +import { pluginPaths, type PluginId, type PluginClass } from './Plugins'; const PLUGIN_NAMESPACE = 'core.plugin_installer'; type PluginLoad = { - promise: Promise; - result?: PluginLoadResult; + promise: Promise; + result?: PluginClass; error?: Error; }; -type PluginLoadResult = { - plugin: Plugin; - exported: any; -}; - type InstallerState = { installed: PluginId[]; }; @@ -33,7 +28,7 @@ const storageKey = { code: (id: PluginId) => `${PLUGIN_NAMESPACE}.code/${id}`, }; -async function load(code: string): Promise { +async function load(code: string): Promise { (globalThis as any).sdk = sdk; const blob = new Blob([code], { type: 'text/javascript' }); @@ -41,15 +36,15 @@ async function load(code: string): Promise { try { const module = await import(/* @vite-ignore */ url); - const exported = module.default; + const cls = module.default; - if (!exported?.plugin?.id) { + if (!cls?.plugin?.id) { throw new Error( 'Plugin must export a default class with a static plugin field' ); } - return { plugin: exported.plugin, exported }; + return cls; } finally { URL.revokeObjectURL(url); } @@ -100,13 +95,12 @@ const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { const pending = get(ins.context.pending); if (!pending?.size) return; - const resolved: Plugin[] = []; + const resolved: PluginClass[] = []; const remaining = new Map(); for (const [id, entry] of pending) { if (entry.result) { - (sdk as any)[entry.result.exported.name] = entry.result.exported; - resolved.push(entry.result.plugin); + resolved.push(entry.result); } else if (entry.error) { // TODO: provide state for failed plugins console.error( @@ -122,7 +116,7 @@ const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { pipe(...resolved.map(PluginManager.register)); over(ins.state.installed, (ids = []) => [ ...(Array.isArray(ids) ? ids : []), - ...resolved.map(p => p.id), + ...resolved.map(cls => cls.plugin.id), ]); } diff --git a/src/engine/plugins/PluginManager.test.ts b/src/engine/plugins/PluginManager.test.ts index ff5b8a7..143ed26 100644 --- a/src/engine/plugins/PluginManager.test.ts +++ b/src/engine/plugins/PluginManager.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { Plugin, EnabledMap } from './Plugins'; +import { Plugin, PluginClass, EnabledMap } from './Plugins'; import { PluginManager, pluginManagerPipe } from './PluginManager'; import { eventPipe } from '../pipes/Events'; import { Composer } from '../Composer'; @@ -31,9 +31,14 @@ const getLoadedIds = (frame: GameFrame): string[] => const getLoadedRefs = (frame: GameFrame): Record => (frame.context as any)?.core?.plugin_manager?.loadedRefs ?? {}; +const makePluginClass = (plugin: Plugin): PluginClass => ({ + plugin, + name: plugin.id, +}); + function bootstrap(plugin: Plugin): GameFrame { const frame0 = gamePipe(makeFrame()); - const frame1 = PluginManager.register(plugin)(frame0); + const frame1 = PluginManager.register(makePluginClass(plugin))(frame0); return gamePipe(tick(frame1)); } diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index e9d03cc..52aac6d 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -10,11 +10,12 @@ import { Events, getEventKey } from '../pipes/Events'; import { pluginPaths, type PluginId, - type Plugin, + type PluginClass, type PluginRegistry, type EnabledMap, } from './Plugins'; import { withTiming } from '../pipes/Perf'; +import { sdk } from '../sdk'; const PLUGIN_NAMESPACE = 'core.plugin_manager'; @@ -34,7 +35,7 @@ type PluginManagerState = { }; export type PluginManagerAPI = { - register: PipeTransformer<[Plugin]>; + register: PipeTransformer<[PluginClass]>; unregister: PipeTransformer<[PluginId]>; enable: PipeTransformer<[PluginId]>; disable: PipeTransformer<[PluginId]>; @@ -42,7 +43,7 @@ export type PluginManagerAPI = { type PluginManagerContext = PluginManagerAPI & { registry: PluginRegistry; - loadedRefs: Record; + loadedRefs: Record; toLoad: PluginId[]; toUnload: PluginId[]; }; @@ -52,9 +53,9 @@ const pm = pluginPaths( ); export class PluginManager { - static register(plugin: Plugin): Pipe { + static register(pluginClass: PluginClass): Pipe { return Composer.bind(pm.context, ({ register }) => - register(plugin) + register(pluginClass) ); } @@ -128,10 +129,10 @@ const enableDisablePipe: Pipe = Composer.pipe( const reconcilePipe: Pipe = Composer.pipe( Events.handle(eventType.register, event => Composer.do(({ over }) => { - const plugin = event.payload as Plugin; + const cls = event.payload as PluginClass; over(pm.context.registry, registry => ({ ...registry, - [plugin.id]: plugin, + [cls.plugin.id]: cls, })); }) ), @@ -190,16 +191,26 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const loadedRefs = get(pm.context.loadedRefs) ?? {}; const registry = get(pm.context.registry) ?? {}; + for (const id of toUnload) { + const cls = loadedRefs[id] ?? registry[id]; + if (cls) delete (sdk as any)[cls.name]; + } + + for (const id of toLoad) { + const cls = registry[id]; + if (cls) (sdk as any)[cls.name] = cls; + } + const deactivates = toUnload .map(id => { - const p = (loadedRefs[id] ?? registry[id])?.deactivate; + const p = (loadedRefs[id] ?? registry[id])?.plugin.deactivate; return p ? withTiming(id, 'deactivate', p) : undefined; }) .filter(Boolean) as Pipe[]; const activates = toLoad .map(id => { - const p = registry[id]?.activate; + const p = registry[id]?.plugin.activate; return p ? withTiming(id, 'activate', p) : undefined; }) .filter(Boolean) as Pipe[]; @@ -211,7 +222,7 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const updates = activeIds .map(id => { - const p = (loadedRefs[id] ?? registry[id])?.update; + const p = (loadedRefs[id] ?? registry[id])?.plugin.update; return p ? withTiming(id, 'update', p) : undefined; }) .filter(Boolean) as Pipe[]; diff --git a/src/engine/plugins/Plugins.ts b/src/engine/plugins/Plugins.ts index 9994bc7..00a8b5d 100644 --- a/src/engine/plugins/Plugins.ts +++ b/src/engine/plugins/Plugins.ts @@ -27,5 +27,10 @@ export type Plugin = { deactivate?: Pipe; }; -export type PluginRegistry = Record; +export type PluginClass = { + plugin: Plugin; + name: string; +}; + +export type PluginRegistry = Record; export type EnabledMap = Record; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 9c6acbb..ad02834 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -3,12 +3,9 @@ import { GameEngineProvider } from './GameProvider'; import { messagesPipe } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; -import { useSettingsPipe, warmupPipe, pacePipe } from './pipes'; +import { useSettingsPipe } from './pipes'; import { GameIntensity } from './components/GameIntensity'; -import { intensityPipe } from './pipes/Intensity'; -import { imagePipe, randomImagesPipe } from './pipes'; import { GameImages } from './components/GameImages'; -import { phasePipe } from './pipes/Phase'; import { pluginInstallerPipe } from '../engine/plugins/PluginInstaller'; import { pluginManagerPipe } from '../engine/plugins/PluginManager'; import { registerPlugins } from './plugins'; @@ -84,12 +81,6 @@ export const GamePage = () => { pluginInstallerPipe, registerPlugins, settingsPipe, - phasePipe, - pacePipe, - intensityPipe, - imagePipe, - randomImagesPipe, - warmupPipe, ]} > diff --git a/src/game/components/GameImages.tsx b/src/game/components/GameImages.tsx index 6ab5205..7aaf477 100644 --- a/src/game/components/GameImages.tsx +++ b/src/game/components/GameImages.tsx @@ -5,7 +5,7 @@ import { JoiImage } from '../../common'; import { useImagePreloader } from '../../utils'; import { ImageSize, ImageType } from '../../types'; import { useGameState } from '../hooks'; -import { ImageState } from '../pipes'; +import { ImageState } from '../plugins/image'; const StyledGameImages = styled.div` position: absolute; diff --git a/src/game/components/GamePace.tsx b/src/game/components/GamePace.tsx index 70bcacd..1d82c1b 100644 --- a/src/game/components/GamePace.tsx +++ b/src/game/components/GamePace.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useGameContext } from '../hooks'; import { useSetting } from '../../settings'; -import { PaceContext } from '../pipes/Pace'; +import { PaceContext } from '../plugins/pace'; export const GamePace = () => { const [minPace] = useSetting('minPace'); diff --git a/src/game/pipes/Image.ts b/src/game/pipes/Image.ts deleted file mode 100644 index 703f02f..0000000 --- a/src/game/pipes/Image.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Composer } from '../../engine'; -import { Pipe, PipeTransformer } from '../../engine/State'; -import { ImageItem } from '../../types'; -import { Events, getEventKey } from '../../engine/pipes/Events'; - -const PLUGIN_NAMESPACE = 'core.images'; - -export type ImageState = { - currentImage?: ImageItem; - seenImages: ImageItem[]; - nextImages: ImageItem[]; -}; - -export type ImageContext = { - pushNextImage: PipeTransformer<[ImageItem]>; - setCurrentImage: PipeTransformer<[ImageItem | undefined]>; - setNextImages: PipeTransformer<[ImageItem[]]>; -}; - -export const imagePipe: Pipe = Composer.pipe( - Composer.set(['context', PLUGIN_NAMESPACE], { - pushNextImage: image => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'pushNext'), - payload: image, - }), - - setCurrentImage: image => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'setImage'), - payload: image, - }), - - setNextImages: images => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'setNextImages'), - payload: images, - }), - }), - - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'pushNext'), event => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ currentImage, seenImages = [], nextImages = [] }) => { - const newImage = event.payload; - const next = [...nextImages]; - const seen = [...seenImages]; - - if (currentImage) { - const existingIndex = seen.indexOf(currentImage); - if (existingIndex !== -1) { - seen.splice(existingIndex, 1); - } - seen.unshift(currentImage); - } - - if (seen.length > 500) { - seen.pop(); - } - - next.push(newImage); - const newCurrent = next.shift(); - - return { - currentImage: newCurrent, - seenImages: seen, - nextImages: next, - }; - } - ) - ), - - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'setNextImages'), event => - Composer.over(['state', PLUGIN_NAMESPACE], state => ({ - ...state, - nextImages: event.payload, - })) - ), - - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'setImage'), event => - Composer.over(['state', PLUGIN_NAMESPACE], state => ({ - ...state, - currentImage: event.payload, - })) - ) -); diff --git a/src/game/pipes/Intensity.ts b/src/game/pipes/Intensity.ts deleted file mode 100644 index 313c140..0000000 --- a/src/game/pipes/Intensity.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Composer, Pipe } from '../../engine'; -import { Settings } from '../../settings'; -import { GamePhase } from './Phase'; -import Pause from '../plugins/pause'; - -const PLUGIN_NAMESPACE = 'core.intensity'; - -export type IntensityState = { - intensity: number; -}; - -export const intensityPipe: Pipe = Pause.whenPlaying( - Composer.bind(['state', 'core.phase', 'current'], currentPhase => - Composer.when( - currentPhase === GamePhase.active, - Composer.bind(['context', 'deltaTime'], delta => - Composer.bind(['context', 'settings'], settings => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ intensity = 0 }) => ({ - intensity: Math.min( - 1, - intensity + delta / (settings.gameDuration * 1000) - ), - }) - ) - ) - ) - ) - ) -); diff --git a/src/game/pipes/Pace.ts b/src/game/pipes/Pace.ts deleted file mode 100644 index 6c59065..0000000 --- a/src/game/pipes/Pace.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Composer, Pipe, PipeTransformer } from '../../engine'; -import { Settings } from '../../settings'; - -const PLUGIN_NAMESPACE = 'core.pace'; - -export type PaceState = { - pace: number; -}; - -export type PaceContext = { - setPace: PipeTransformer<[number]>; - resetPace: PipeTransformer<[]>; -}; - -export const pacePipe: Pipe = Composer.pipe( - Composer.bind(['context', 'settings'], settings => - Composer.bind(['state', PLUGIN_NAMESPACE], state => - Composer.when( - state == null, - Composer.set(['state', PLUGIN_NAMESPACE], { - pace: settings.minPace, - }) - ) - ) - ), - - Composer.set(['context', PLUGIN_NAMESPACE], { - setPace: (pace: number) => - Composer.set(['state', PLUGIN_NAMESPACE, 'pace'], pace), - - resetPace: () => - Composer.bind(['context', 'settings'], settings => - Composer.set( - ['state', PLUGIN_NAMESPACE, 'pace'], - settings.minPace - ) - ), - }) -); diff --git a/src/game/pipes/Phase.ts b/src/game/pipes/Phase.ts deleted file mode 100644 index 8f8337d..0000000 --- a/src/game/pipes/Phase.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Composer } from '../../engine/Composer'; -import { - GameContext, - GameState, - Pipe, - PipeTransformer, -} from '../../engine/State'; - -export enum GamePhase { - warmup = 'warmup', - active = 'active', - break = 'break', - finale = 'finale', - climax = 'climax', -} - -export type PhaseState = { - current: GamePhase; -}; - -export type PhaseContext = { - setPhase: PipeTransformer<[GamePhase]>; -}; - -const PLUGIN_NAMESPACE = 'core.phase'; - -export const setPhase: PipeTransformer<[GamePhase]> = phase => - Composer.set(['state', PLUGIN_NAMESPACE, 'current'], phase); - -export const phasePipe: Pipe = Composer.pipe( - Composer.zoom( - 'state', - Composer.bind(PLUGIN_NAMESPACE, state => - Composer.when( - state == null, - Composer.set(PLUGIN_NAMESPACE, { - current: GamePhase.warmup, - }) - ) - ) - ), - Composer.zoom( - 'context', - Composer.set(PLUGIN_NAMESPACE, { setPhase }) - ) -); diff --git a/src/game/pipes/RandomImages.ts b/src/game/pipes/RandomImages.ts deleted file mode 100644 index b9fd921..0000000 --- a/src/game/pipes/RandomImages.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Composer } from '../../engine'; -import { Pipe } from '../../engine/State'; -import { ImageItem } from '../../types'; -import { Events, getEventKey } from '../../engine/pipes/Events'; -import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; - -const PLUGIN_NAMESPACE = 'core.random_images'; - -const getImageSwitchDuration = (intensity: number): number => { - return Math.max((100 - intensity * 100) * 80, 2000); -}; - -export const randomImagesPipe: Pipe = Composer.pipe( - Composer.bind( - ['state', PLUGIN_NAMESPACE, 'initialized'], - (initialized = false) => - Composer.unless( - initialized, - Composer.pipe( - Composer.set(['state', PLUGIN_NAMESPACE, 'initialized'], true), - Composer.bind(['context', 'images'], images => - Composer.when( - images.length > 0, - Composer.chain(c => { - const shuffled = [...images].sort(() => Math.random() - 0.5); - const initialImages = shuffled.slice( - 0, - Math.min(3, images.length) - ); - - return c.pipe( - ...initialImages.map(image => - Events.dispatch({ - type: getEventKey('core.images', 'pushNext'), - payload: image, - }) - ), - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), - }) - ); - }) - ) - ) - ) - ) - ), - - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), () => - Composer.bind( - ['state', 'core.intensity', 'intensity'], - (intensity = 0) => - Composer.bind(['context', 'images'], images => - Composer.bind( - ['state', 'core.images', 'seenImages'], - (seenImages = []) => - Composer.when( - images.length > 0, - Composer.chain(c => { - const imagesWithDistance = images.map(image => { - const seenIndex = seenImages.indexOf(image); - const distance = - seenIndex === -1 ? seenImages.length : seenIndex; - return { image, distance }; - }); - - imagesWithDistance.sort((a, b) => b.distance - a.distance); - - const weights = imagesWithDistance.map((_, index) => - Math.max(1, imagesWithDistance.length - index) - ); - const totalWeight = weights.reduce( - (sum, weight) => sum + weight, - 0 - ); - - let random = Math.random() * totalWeight; - let selectedIndex = 0; - for (let i = 0; i < weights.length; i++) { - random -= weights[i]; - if (random <= 0) { - selectedIndex = i; - break; - } - } - - const randomImage = imagesWithDistance[selectedIndex].image; - - return c.pipe( - Events.dispatch({ - type: getEventKey('core.images', 'pushNext'), - payload: randomImage, - }), - Scheduler.schedule({ - id: getScheduleKey(PLUGIN_NAMESPACE, 'randomImageSwitch'), - duration: getImageSwitchDuration(intensity), - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'scheduleNext'), - }, - }) - ); - }) - ) - ) - ) - ) - ) -); diff --git a/src/game/pipes/Warmup.ts b/src/game/pipes/Warmup.ts deleted file mode 100644 index 58de693..0000000 --- a/src/game/pipes/Warmup.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Composer, Pipe } from '../../engine'; -import { Events, getEventKey } from '../../engine/pipes/Events'; -import { Messages } from '../../engine/pipes/Messages'; -import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; -import { GamePhase, setPhase } from './Phase'; -import { Settings } from '../../settings'; -import Pause from '../plugins/pause'; - -const PLUGIN_NAMESPACE = 'core.warmup'; -const AUTOSTART_KEY = getScheduleKey(PLUGIN_NAMESPACE, 'autoStart'); - -export type WarmupState = { - initialized: boolean; -}; - -export const warmupPipe: Pipe = Composer.pipe( - Pause.whenPlaying( - Composer.bind(['state', 'core.phase', 'current'], currentPhase => - Composer.bind(['context', 'settings'], settings => - Composer.bind( - ['state', PLUGIN_NAMESPACE], - (state = { initialized: false }) => - Composer.when( - currentPhase === GamePhase.warmup, - Composer.pipe( - Composer.when( - settings.warmupDuration === 0, - setPhase(GamePhase.active) - ), - Composer.when( - settings.warmupDuration > 0 && !state.initialized, - Composer.pipe( - Composer.set(['state', PLUGIN_NAMESPACE], { - initialized: true, - }), - Messages.send({ - id: GamePhase.warmup, - title: 'Get yourself ready!', - prompts: [ - { - title: `I'm ready, $master`, - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), - }, - }, - ], - }), - Scheduler.schedule({ - id: AUTOSTART_KEY, - duration: settings.warmupDuration * 1000, - event: { - type: getEventKey(PLUGIN_NAMESPACE, 'startGame'), - }, - }) - ) - ) - ) - ) - ) - ) - ) - ), - - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'startGame'), () => - Composer.pipe( - Scheduler.cancel(AUTOSTART_KEY), - Composer.set(PLUGIN_NAMESPACE, { initialized: false }), - Messages.send({ - id: GamePhase.warmup, - title: 'Now follow what I say, $player!', - duration: 5000, - prompts: undefined, - }), - setPhase(GamePhase.active) - ) - ), - - Pause.onPause(() => Scheduler.hold(AUTOSTART_KEY)), - - Pause.onResume(() => Scheduler.release(AUTOSTART_KEY)) -); diff --git a/src/game/pipes/index.ts b/src/game/pipes/index.ts index 39850d8..90e2697 100644 --- a/src/game/pipes/index.ts +++ b/src/game/pipes/index.ts @@ -1,5 +1 @@ -export * from './Image'; -export * from './Pace'; -export * from './RandomImages'; export * from './Settings'; -export * from './Warmup'; diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts index b1f81d7..719673e 100644 --- a/src/game/plugins/fps.ts +++ b/src/game/plugins/fps.ts @@ -1,7 +1,5 @@ +import { Composer, GameContext, pluginPaths, typedPath } from '../../engine'; import type { Plugin } from '../../engine/plugins/Plugins'; -import { sdk } from '../../engine/sdk'; - -const { Composer, pluginPaths } = sdk; const PLUGIN_ID = 'core.fps'; const ELEMENT_ATTR = 'data-plugin-id'; @@ -14,13 +12,13 @@ type FpsContext = { }; const fps = pluginPaths(PLUGIN_ID); +const gameContext = typedPath(['context']); export default class Fps { static plugin: Plugin = { id: PLUGIN_ID, meta: { name: 'FPS Counter', - version: '0.1.0', }, activate: frame => { @@ -56,7 +54,7 @@ export default class Fps { }, update: Composer.do(({ get, set }) => { - const delta = get(['context', 'deltaTime']); + const delta = get(gameContext.deltaTime); const ctx = get(fps.context); if (!ctx) return; diff --git a/src/game/plugins/image.ts b/src/game/plugins/image.ts new file mode 100644 index 0000000..5a492e6 --- /dev/null +++ b/src/game/plugins/image.ts @@ -0,0 +1,123 @@ +import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; +import { Pipe, PipeTransformer } from '../../engine/State'; +import { Composer } from '../../engine'; +import { Events, getEventKey } from '../../engine/pipes/Events'; +import { ImageItem } from '../../types'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Image: typeof Image; + } +} + +const PLUGIN_ID = 'core.images'; + +export type ImageState = { + currentImage?: ImageItem; + seenImages: ImageItem[]; + nextImages: ImageItem[]; +}; + +type ImageContext = { + pushNextImage: PipeTransformer<[ImageItem]>; + setCurrentImage: PipeTransformer<[ImageItem | undefined]>; + setNextImages: PipeTransformer<[ImageItem[]]>; +}; + +const image = pluginPaths(PLUGIN_ID); + +const eventType = { + pushNext: getEventKey(PLUGIN_ID, 'pushNext'), + setImage: getEventKey(PLUGIN_ID, 'setImage'), + setNextImages: getEventKey(PLUGIN_ID, 'setNextImages'), +}; + +export default class Image { + static pushNextImage(img: ImageItem): Pipe { + return Composer.call(image.context.pushNextImage, img); + } + + static setCurrentImage(img: ImageItem | undefined): Pipe { + return Composer.call(image.context.setCurrentImage, img); + } + + static setNextImages(imgs: ImageItem[]): Pipe { + return Composer.call(image.context.setNextImages, imgs); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Image', + }, + + activate: Composer.pipe( + Composer.set(image.state, { + currentImage: undefined, + seenImages: [], + nextImages: [], + }), + Composer.set(image.context, { + pushNextImage: (img: ImageItem) => + Events.dispatch({ type: eventType.pushNext, payload: img }), + setCurrentImage: (img: ImageItem | undefined) => + Events.dispatch({ type: eventType.setImage, payload: img }), + setNextImages: (imgs: ImageItem[]) => + Events.dispatch({ type: eventType.setNextImages, payload: imgs }), + }) + ), + + update: Composer.pipe( + Events.handle(eventType.pushNext, event => + Composer.over( + image.state, + ({ currentImage, seenImages = [], nextImages = [] }) => { + const newImage = event.payload; + const next = [...nextImages]; + const seen = [...seenImages]; + + if (currentImage) { + const existingIndex = seen.indexOf(currentImage); + if (existingIndex !== -1) { + seen.splice(existingIndex, 1); + } + seen.unshift(currentImage); + } + + if (seen.length > 500) { + seen.pop(); + } + + next.push(newImage); + const newCurrent = next.shift(); + + return { + currentImage: newCurrent, + seenImages: seen, + nextImages: next, + }; + } + ) + ), + + Events.handle(eventType.setNextImages, event => + Composer.over(image.state, state => ({ + ...state, + nextImages: event.payload, + })) + ), + + Events.handle(eventType.setImage, event => + Composer.over(image.state, state => ({ + ...state, + currentImage: event.payload, + })) + ) + ), + + deactivate: Composer.pipe( + Composer.set(image.state, undefined), + Composer.set(image.context, undefined) + ), + }; +} diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index d140ed7..0972b82 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -2,11 +2,27 @@ import { PluginManager } from '../../engine/plugins/PluginManager'; import { Composer } from '../../engine/Composer'; import { Pipe } from '../../engine/State'; import Fps from './fps'; +import Intensity from './intensity'; import Pause from './pause'; +import Phase from './phase'; +import Pace from './pace'; import PerfOverlay from './perf'; +import Image from './image'; +import RandomImages from './randomImages'; +import Warmup from './warmup'; -const plugins = [Pause.plugin, Fps.plugin, PerfOverlay.plugin]; +const plugins = [ + Pause, + Phase, + Pace, + Intensity, + Image, + RandomImages, + Warmup, + Fps, + PerfOverlay, +]; export const registerPlugins: Pipe = Composer.pipe( - ...plugins.map(p => PluginManager.register(p)) + ...plugins.map(PluginManager.register) ); diff --git a/src/game/plugins/intensity.ts b/src/game/plugins/intensity.ts new file mode 100644 index 0000000..2f0fd3d --- /dev/null +++ b/src/game/plugins/intensity.ts @@ -0,0 +1,51 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { sdk } from '../../engine/sdk'; +import { typedPath } from '../../engine/Lens'; +import { Settings } from '../../settings'; +import Phase, { GamePhase } from './phase'; +import Pause from './pause'; +import { GameContext } from '../../engine'; + +const { Composer, pluginPaths } = sdk; + +declare module '../../engine/sdk' { + interface PluginSDK { + Intensity: typeof Intensity; + } +} + +const PLUGIN_ID = 'core.intensity'; + +export type IntensityState = { + intensity: number; +}; + +const intensity = pluginPaths(PLUGIN_ID); +const gameContext = typedPath(['context']); +const settings = typedPath(gameContext.settings); + +export default class Intensity { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Intensity', + }, + + activate: Composer.set(intensity.state, { intensity: 0 }), + + update: Pause.whenPlaying( + Phase.whenPhase( + GamePhase.active, + Composer.do(({ get, over }) => { + const delta = get(gameContext.deltaTime); + const s = get(settings); + over(intensity.state, ({ intensity: i = 0 }) => ({ + intensity: Math.min(1, i + delta / (s.gameDuration * 1000)), + })); + }) + ) + ), + + deactivate: Composer.set(intensity.state, undefined), + }; +} diff --git a/src/game/plugins/pace.ts b/src/game/plugins/pace.ts new file mode 100644 index 0000000..c6dca89 --- /dev/null +++ b/src/game/plugins/pace.ts @@ -0,0 +1,60 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Pipe, PipeTransformer } from '../../engine/State'; +import { typedPath } from '../../engine/Lens'; +import { Settings } from '../../settings'; +import { Composer, pluginPaths } from '../../engine'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Pace: typeof Pace; + } +} + +const PLUGIN_ID = 'core.pace'; + +export type PaceState = { + pace: number; +}; + +export type PaceContext = { + setPace: PipeTransformer<[number]>; + resetPace: PipeTransformer<[]>; +}; + +const pace = pluginPaths(PLUGIN_ID); +const settings = typedPath(['context', 'settings']); + +export default class Pace { + static setPace(val: number): Pipe { + return Composer.call(pace.context.setPace, val); + } + + static resetPace(): Pipe { + return Composer.call(pace.context.resetPace); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Pace', + }, + + activate: Composer.pipe( + Composer.bind(settings, s => + Composer.set(pace.state, { pace: s.minPace }) + ), + Composer.set(pace.context, { + setPace: (val: number) => Composer.set(pace.state.pace, val), + resetPace: () => + Composer.bind(settings, s => + Composer.set(pace.state.pace, s.minPace) + ), + }) + ), + + deactivate: Composer.pipe( + Composer.set(pace.state, undefined), + Composer.set(pace.context, undefined) + ), + }; +} diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index 5659a14..dc17257 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -1,9 +1,9 @@ import type { Plugin } from '../../engine/plugins/Plugins'; +import { Pipe, PipeTransformer } from '../../engine/State'; import { sdk } from '../../engine/sdk'; -import { Composer } from '../../engine/Composer'; -import { GameFrame, Pipe, PipeTransformer } from '../../engine/State'; -import { Events, getEventKey } from '../../engine/pipes/Events'; -import { pluginPaths } from '../../engine/plugins/Plugins'; +import { getEventKey } from '../../engine/pipes/Events'; + +const { Composer, Events, pluginPaths } = sdk; declare module '../../engine/sdk' { interface PluginSDK { @@ -32,23 +32,21 @@ const eventType = { export default class Pause { static setPaused(val: boolean): Pipe { - return Composer.set(pause.state.paused, val); + return Composer.call(pause.context.setPaused, val); } static get togglePause(): Pipe { - return Composer.bind(pause.state, state => - Pause.setPaused(!state?.paused) - ); + return Composer.bind(pause.context.togglePause, fn => fn); } static whenPaused(pipe: Pipe): Pipe { - return Composer.bind(pause.state, state => + return Composer.bind(pause.state, state => Composer.when(!!state?.paused, pipe) ); } static whenPlaying(pipe: Pipe): Pipe { - return Composer.bind(pause.state, state => + return Composer.bind(pause.state, state => Composer.when(!state?.paused, pipe) ); } @@ -65,21 +63,19 @@ export default class Pause { id: PLUGIN_ID, meta: { name: 'Pause', - version: '0.1.0', }, - activate: frame => { - sdk.Pause = Pause; - return Composer.pipe( - Composer.set(pause.state, { paused: false, prev: false }), - Composer.set(pause.context, { - setPaused: val => Pause.setPaused(val), - togglePause: Pause.togglePause, - }) - )(frame); - }, + activate: Composer.do(({ set }) => { + set(pause.state, { paused: false, prev: false }); + set(pause.context, { + setPaused: val => Composer.set(pause.state.paused, val), + togglePause: Composer.bind(pause.state, state => + Composer.set(pause.state.paused, !state?.paused) + ), + }); + }), - update: Composer.do(({ get, set, pipe }) => { + update: Composer.do(({ get, set, pipe }) => { const { paused, prev } = get(pause.state); if (paused === prev) return; set(pause.state.prev, paused); diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts index 2541a9b..d9baa9b 100644 --- a/src/game/plugins/perf.ts +++ b/src/game/plugins/perf.ts @@ -41,7 +41,6 @@ export default class PerfOverlay { id: PLUGIN_ID, meta: { name: 'Performance Overlay', - version: '0.1.0', }, activate: frame => { diff --git a/src/game/plugins/phase.ts b/src/game/plugins/phase.ts new file mode 100644 index 0000000..32def9c --- /dev/null +++ b/src/game/plugins/phase.ts @@ -0,0 +1,83 @@ +import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; +import { Pipe, PipeTransformer } from '../../engine/State'; +import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Composer } from '../../engine'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Phase: typeof Phase; + } +} + +const PLUGIN_ID = 'core.phase'; + +export enum GamePhase { + warmup = 'warmup', + active = 'active', + break = 'break', + finale = 'finale', + climax = 'climax', +} + +export type PhaseState = { + current: string; + prev: string; +}; + +type PhaseContext = { + setPhase: PipeTransformer<[string]>; +}; + +const phase = pluginPaths(PLUGIN_ID); + +const eventType = { + enter: (p: string) => getEventKey(PLUGIN_ID, `enter.${p}`), + leave: (p: string) => getEventKey(PLUGIN_ID, `leave.${p}`), +}; + +export default class Phase { + static setPhase(p: string): Pipe { + return Composer.call(phase.context.setPhase, p); + } + + static whenPhase(p: string, pipe: Pipe): Pipe { + return Composer.bind(phase.state, state => + Composer.when(state?.current === p, pipe) + ); + } + + static onEnter(p: string, fn: () => Pipe): Pipe { + return Events.handle(eventType.enter(p), fn); + } + + static onLeave(p: string, fn: () => Pipe): Pipe { + return Events.handle(eventType.leave(p), fn); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Phase', + }, + + activate: Composer.do(({ set }) => { + set(phase.state, { current: GamePhase.warmup, prev: GamePhase.warmup }); + set(phase.context, { + setPhase: p => Composer.set(phase.state.current, p), + }); + }), + + update: Composer.do(({ get, set, pipe }) => { + const { current, prev } = get(phase.state); + if (current === prev) return; + set(phase.state.prev, current); + pipe(Events.dispatch({ type: eventType.leave(prev) })); + pipe(Events.dispatch({ type: eventType.enter(current) })); + }), + + deactivate: Composer.pipe( + Composer.set(phase.state, undefined), + Composer.set(phase.context, undefined) + ), + }; +} diff --git a/src/game/plugins/randomImages.ts b/src/game/plugins/randomImages.ts new file mode 100644 index 0000000..a3c4ad9 --- /dev/null +++ b/src/game/plugins/randomImages.ts @@ -0,0 +1,96 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine'; +import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Scheduler, getScheduleKey } from '../../engine/pipes/Scheduler'; +import { typedPath } from '../../engine/Lens'; +import { ImageItem } from '../../types'; +import Image, { ImageState } from './image'; +import { IntensityState } from './intensity'; + +declare module '../../engine/sdk' { + interface PluginSDK { + RandomImages: typeof RandomImages; + } +} + +const PLUGIN_ID = 'core.random_images'; + +const images = typedPath(['context', 'images']); +const intensityState = typedPath(['state', 'core.intensity']); +const imageState = typedPath(['state', 'core.images']); + +const eventType = { + scheduleNext: getEventKey(PLUGIN_ID, 'scheduleNext'), +}; + +const scheduleId = getScheduleKey(PLUGIN_ID, 'randomImageSwitch'); + +const getImageSwitchDuration = (intensity: number): number => { + return Math.max((100 - intensity * 100) * 80, 2000); +}; + +export default class RandomImages { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'RandomImages', + }, + + activate: Composer.do(({ get, pipe }) => { + const imgs = get(images); + if (!imgs || imgs.length === 0) return; + + const shuffled = [...imgs].sort(() => Math.random() - 0.5); + const initial = shuffled.slice(0, Math.min(3, imgs.length)); + + for (const img of initial) { + pipe(Image.pushNextImage(img)); + } + pipe(Events.dispatch({ type: eventType.scheduleNext })); + }), + + update: Events.handle(eventType.scheduleNext, () => + Composer.do(({ get, pipe }) => { + const imgs = get(images); + if (!imgs || imgs.length === 0) return; + + const { seenImages = [] } = get(imageState) ?? {}; + const { intensity = 0 } = get(intensityState) ?? {}; + + const imagesWithDistance = imgs.map(image => { + const seenIndex = seenImages.indexOf(image); + const distance = seenIndex === -1 ? seenImages.length : seenIndex; + return { image, distance }; + }); + + imagesWithDistance.sort((a, b) => b.distance - a.distance); + + const weights = imagesWithDistance.map((_, index) => + Math.max(1, imagesWithDistance.length - index) + ); + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + + let random = Math.random() * totalWeight; + let selectedIndex = 0; + for (let i = 0; i < weights.length; i++) { + random -= weights[i]; + if (random <= 0) { + selectedIndex = i; + break; + } + } + + const randomImage = imagesWithDistance[selectedIndex].image; + + pipe(Image.pushNextImage(randomImage)); + pipe( + Scheduler.schedule({ + id: scheduleId, + duration: getImageSwitchDuration(intensity), + event: { type: eventType.scheduleNext }, + }) + ); + }) + ), + }; +} diff --git a/src/game/plugins/warmup.ts b/src/game/plugins/warmup.ts new file mode 100644 index 0000000..f415223 --- /dev/null +++ b/src/game/plugins/warmup.ts @@ -0,0 +1,103 @@ +import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine'; +import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Messages } from '../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../engine/pipes/Scheduler'; +import { typedPath } from '../../engine/Lens'; +import { Settings } from '../../settings'; +import { GameContext } from '../../engine'; +import Phase, { GamePhase } from './phase'; +import Pause from './pause'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Warmup: typeof Warmup; + } +} + +const PLUGIN_ID = 'core.warmup'; + +type WarmupState = { + initialized: boolean; +}; + +const warmup = pluginPaths(PLUGIN_ID); +const gameContext = typedPath(['context']); +const settings = typedPath(gameContext.settings); + +const AUTOSTART_KEY = getScheduleKey(PLUGIN_ID, 'autoStart'); + +const eventType = { + startGame: getEventKey(PLUGIN_ID, 'startGame'), +}; + +export default class Warmup { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Warmup', + }, + + activate: Composer.set(warmup.state, { initialized: false }), + + update: Composer.pipe( + Pause.whenPlaying( + Phase.whenPhase( + GamePhase.warmup, + Composer.do(({ get, set, pipe }) => { + const s = get(settings); + if (!s) return; + + if (s.warmupDuration === 0) { + pipe(Phase.setPhase(GamePhase.active)); + return; + } + + const state = get(warmup.state); + if (state?.initialized) return; + + set(warmup.state.initialized, true); + pipe( + Messages.send({ + id: GamePhase.warmup, + title: 'Get yourself ready!', + prompts: [ + { + title: `I'm ready, $master`, + event: { type: eventType.startGame }, + }, + ], + }) + ); + pipe( + Scheduler.schedule({ + id: AUTOSTART_KEY, + duration: s.warmupDuration * 1000, + event: { type: eventType.startGame }, + }) + ); + }) + ) + ), + + Events.handle(eventType.startGame, () => + Composer.pipe( + Scheduler.cancel(AUTOSTART_KEY), + Composer.set(warmup.state, { initialized: false }), + Messages.send({ + id: GamePhase.warmup, + title: 'Now follow what I say, $player!', + duration: 5000, + prompts: undefined, + }), + Phase.setPhase(GamePhase.active) + ) + ), + + Pause.onPause(() => Scheduler.hold(AUTOSTART_KEY)), + Pause.onResume(() => Scheduler.release(AUTOSTART_KEY)) + ), + + deactivate: Composer.set(warmup.state, undefined), + }; +} From 3f9def2e8f3eafd6290baee6cd7b06e70ed4870a Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 13 Feb 2026 18:08:42 +0100 Subject: [PATCH 44/90] removed deep cloning --- src/engine/Engine.test.ts | 28 ++++++++++++---------------- src/engine/Engine.ts | 11 ++++++----- src/engine/Lens.ts | 12 ++++++------ src/engine/freeze.ts | 6 ++++++ 4 files changed, 30 insertions(+), 27 deletions(-) create mode 100644 src/engine/freeze.ts diff --git a/src/engine/Engine.test.ts b/src/engine/Engine.test.ts index dfc43d7..664c34b 100644 --- a/src/engine/Engine.test.ts +++ b/src/engine/Engine.test.ts @@ -53,14 +53,11 @@ describe('GameEngine', () => { expect(engine.getState()).toEqual({ modified: true }); }); - it('should deep clone state after pipe execution', () => { - const pipe: Pipe = (frame: GameFrame) => { - const nested = { value: 42 }; - return { - ...frame, - state: { nested }, - }; - }; + it('should produce new state references per tick', () => { + const pipe: Pipe = (frame: GameFrame) => ({ + ...frame, + state: { ...frame.state, nested: { value: 42 } }, + }); const engine = new GameEngine({}, pipe); engine.tick(16); @@ -73,20 +70,19 @@ describe('GameEngine', () => { expect(state1.nested).toEqual(state2.nested); }); - it('should deep clone context after pipe execution', () => { + it('should freeze state in dev mode', () => { const pipe: Pipe = (frame: GameFrame) => ({ ...frame, - context: { ...frame.context, data: { value: 42 } }, + state: { ...frame.state, nested: { value: 42 } }, }); const engine = new GameEngine({}, pipe); engine.tick(16); - const ctx1 = engine.getContext(); - engine.tick(16); - const ctx2 = engine.getContext(); - - expect(ctx1.data).not.toBe(ctx2.data); - expect(ctx1.data).toEqual(ctx2.data); + const state = engine.getState(); + expect(() => { + state.nested.value = 99; + }).toThrow(); + expect(state.nested.value).toBe(42); }); }); diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 9f5c240..db420a4 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -1,5 +1,5 @@ import { GameState, GameContext, Pipe, GameTiming, GameFrame } from './State'; -import cloneDeep from 'lodash/cloneDeep'; +import { deepFreeze } from './freeze'; export class GameEngine { constructor(initial: GameState, pipe: Pipe) { @@ -71,12 +71,13 @@ export class GameEngine { const result = this.pipe(frame); - // TODO: this is (probably) expensive. We could make it debug only? - this.state = cloneDeep(result.state); - this.context = cloneDeep({ + this.state = result.state; + this.context = { ...result.context, ...this.timing, - }); + }; + + if (import.meta.env.DEV) deepFreeze(this.state); return this.state; } diff --git a/src/engine/Lens.ts b/src/engine/Lens.ts index 0b26167..428e8fc 100644 --- a/src/engine/Lens.ts +++ b/src/engine/Lens.ts @@ -1,5 +1,3 @@ -import { cloneDeep } from 'lodash'; - export type Lens = { get: (source: S) => A; set: (value: A) => (source: S) => S; @@ -54,7 +52,7 @@ export function lensFromPath(path: Path): Lens { }; } - return { + const lens: Lens = { get: (source: S): A => { return parts.reduce((acc: unknown, key: any) => { if (acc == null || typeof acc !== 'object') return undefined; @@ -65,7 +63,7 @@ export function lensFromPath(path: Path): Lens { set: (value: A) => (source: S): S => { - const root = cloneDeep(source) as any; + const root = { ...source } as any; let node = root; for (let i = 0; i < parts.length - 1; i++) { @@ -81,8 +79,10 @@ export function lensFromPath(path: Path): Lens { over: (fn: (a: A) => A) => (source: S): S => { - const current = lensFromPath(parts).get(source) ?? ({} as A); - return lensFromPath(parts).set(fn(current))(source); + const current = lens.get(source) ?? ({} as A); + return lens.set(fn(current))(source); }, }; + + return lens; } diff --git a/src/engine/freeze.ts b/src/engine/freeze.ts new file mode 100644 index 0000000..931d6b1 --- /dev/null +++ b/src/engine/freeze.ts @@ -0,0 +1,6 @@ +export function deepFreeze(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj; + Object.freeze(obj); + for (const val of Object.values(obj)) deepFreeze(val); + return obj; +} From 8bc1e83382790a76176d8161b40c9653776de488 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 13 Feb 2026 18:17:31 +0100 Subject: [PATCH 45/90] added expiration to performance overlay --- src/engine/Composer.ts | 15 ++++-------- src/engine/freeze.ts | 1 + src/engine/pipes/Perf.ts | 49 ++++++++++++++++++++++++++++++++++++---- src/game/plugins/perf.ts | 22 +++++++++--------- 4 files changed, 62 insertions(+), 25 deletions(-) diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index 45a5d18..a8c10d0 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -1,4 +1,5 @@ import { lensFromPath, Path } from './Lens'; +import { deepFreeze } from './freeze'; /** * A curried function that that maps an object from T to T, @@ -68,7 +69,10 @@ export class Composer { get(path: Path): A; get(path?: Path): A | T { if (path === undefined) return this.obj; - return lensFromPath(path).get(this.obj); + const val = lensFromPath(path).get(this.obj); + // Modifying an object retrieved from the composer would cause bugs + if (import.meta.env.DEV) return deepFreeze(val); + return val; } /** @@ -250,15 +254,6 @@ export class Composer { }; } - // shallow-freeze get() results to catch accidental mutation - const rawGet = (scope as any).get; - (scope as any).get = (...args: any[]) => { - const val = rawGet(...args); - return val !== null && typeof val === 'object' - ? Object.freeze(val) - : val; - }; - const result: unknown = fn(scope); // catch async callbacks that would silently lose writes diff --git a/src/engine/freeze.ts b/src/engine/freeze.ts index 931d6b1..5452acc 100644 --- a/src/engine/freeze.ts +++ b/src/engine/freeze.ts @@ -1,5 +1,6 @@ export function deepFreeze(obj: T): T { if (obj === null || typeof obj !== 'object') return obj; + if (Object.isFrozen(obj)) return obj; Object.freeze(obj); for (const val of Object.values(obj)) deepFreeze(val); return obj; diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index 31aebc9..08db4e2 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -1,7 +1,8 @@ import { Composer } from '../Composer'; -import { GameFrame, Pipe } from '../State'; +import { GameContext, GameFrame, Pipe } from '../State'; import { Events, getEventKey, GameEvent } from './Events'; import { pluginPaths, PluginId } from '../plugins/Plugins'; +import { typedPath } from '../Lens'; export type PluginHookPhase = 'activate' | 'update' | 'deactivate'; @@ -10,6 +11,7 @@ export type PluginPerfEntry = { avg: number; max: number; samples: number[]; + lastTick: number; }; export type PerfMetrics = Record< @@ -27,7 +29,8 @@ export type PerfContext = { }; const PLUGIN_NAMESPACE = 'core.perf'; -const WINDOW_SIZE = 60; +const SAMPLE_SIZE = 60; +const EXPIRY_TICKS = 900; const DEFAULT_CONFIG: PerfConfig = { pluginBudget: 4, @@ -39,6 +42,7 @@ const eventType = { }; const perf = pluginPaths(PLUGIN_NAMESPACE); +const gameContext = typedPath(['context']); export function withTiming( id: PluginId, @@ -51,12 +55,13 @@ export function withTiming( const after = performance.now(); const duration = after - before; + const tick = get(gameContext.tick) ?? 0; const ctx = get(perf.context) ?? { plugins: {}, config: DEFAULT_CONFIG }; const pluginMetrics = ctx.plugins[id] ?? {}; const entry = pluginMetrics[phase]; const samples = entry - ? [...entry.samples, duration].slice(-WINDOW_SIZE) + ? [...entry.samples, duration].slice(-SAMPLE_SIZE) : [duration]; const avg = @@ -66,7 +71,13 @@ export function withTiming( const max = entry ? Math.max(entry.max, duration) : duration; - const newEntry: PluginPerfEntry = { last: duration, avg, max, samples }; + const newEntry: PluginPerfEntry = { + last: duration, + avg, + max, + samples, + lastTick: tick, + }; set(perf.context, { ...ctx, @@ -120,6 +131,36 @@ export const perfPipe: Pipe = Composer.pipe( config: ctx.config ?? DEFAULT_CONFIG, }) ), + + Composer.do(({ get, set }) => { + const tick = get(gameContext.tick) ?? 0; + const ctx = get(perf.context); + if (!ctx) return; + + let dirty = false; + const pruned: PerfMetrics = {}; + + for (const [id, phases] of Object.entries(ctx.plugins)) { + const kept: Partial> = {}; + for (const [phase, entry] of Object.entries(phases)) { + if (entry && tick - entry.lastTick <= EXPIRY_TICKS) { + kept[phase as PluginHookPhase] = entry; + } else { + dirty = true; + } + } + if (Object.keys(kept).length > 0) { + pruned[id] = kept; + } else { + dirty = true; + } + } + + if (dirty) { + set(perf.context, { ...ctx, plugins: pruned }); + } + }), + Events.handle(eventType.configure, event => Composer.over(perf.context, ctx => ({ ...ctx, diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts index d9baa9b..ed2c351 100644 --- a/src/game/plugins/perf.ts +++ b/src/game/plugins/perf.ts @@ -87,19 +87,19 @@ export default class PerfOverlay { const { plugins, config } = ctx; const lines: string[] = []; - - for (const [id, phases] of Object.entries(plugins as PerfMetrics)) { - if (id === PLUGIN_ID) continue; - for (const [phase, entry] of Object.entries(phases)) { + const phaseOrder: PluginHookPhase[] = [ + 'activate', + 'update', + 'deactivate', + ]; + + for (const phase of phaseOrder) { + for (const [id, phases] of Object.entries(plugins as PerfMetrics)) { + if (id === PLUGIN_ID) continue; + const entry = phases[phase]; if (!entry) continue; lines.push( - formatLine( - id, - phase as PluginHookPhase, - entry.last, - entry.avg, - config.pluginBudget - ) + formatLine(id, phase, entry.last, entry.avg, config.pluginBudget) ); } } From 4b0c841375318f674e896b1111ef8facb9dab8af Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 13 Feb 2026 20:18:43 +0100 Subject: [PATCH 46/90] added remaining game features (wip) --- src/game/GamePage.tsx | 19 +- src/game/components/GameEmergencyStop.tsx | 57 +---- src/game/components/GameEvents.tsx | 162 ------------ src/game/components/GameHypno.tsx | 33 +-- src/game/components/GameInstructions.tsx | 30 ++- src/game/components/GameMeter.tsx | 68 ++--- src/game/components/GameSettings.tsx | 109 ++++---- src/game/components/GameSound.tsx | 12 +- src/game/components/GameVibrator.tsx | 24 +- src/game/components/GameWarmup.tsx | 53 ---- src/game/components/events/clean-up.ts | 31 --- src/game/components/events/climax.ts | 133 ---------- src/game/components/events/double-pace.ts | 42 ---- src/game/components/events/edge.ts | 19 -- src/game/components/events/half-pace.ts | 43 ---- src/game/components/events/index.ts | 9 - src/game/components/events/pause.ts | 24 -- src/game/components/events/random-grip.ts | 23 -- src/game/components/events/random-pace.ts | 23 -- src/game/components/events/rising-pace.ts | 43 ---- src/game/components/index.ts | 3 - src/game/hooks/index.ts | 1 + src/game/plugins/dealer.ts | 150 +++++++++++ src/game/plugins/dice/cleanUp.ts | 54 ++++ src/game/plugins/dice/climax.ts | 292 ++++++++++++++++++++++ src/game/plugins/dice/doublePace.ts | 91 +++++++ src/game/plugins/dice/edge.ts | 47 ++++ src/game/plugins/dice/emergencyStop.ts | 103 ++++++++ src/game/plugins/dice/halfPace.ts | 93 +++++++ src/game/plugins/dice/pause.ts | 55 ++++ src/game/plugins/dice/randomGrip.ts | 60 +++++ src/game/plugins/dice/randomPace.ts | 65 +++++ src/game/plugins/dice/risingPace.ts | 127 ++++++++++ src/game/plugins/dice/types.ts | 55 ++++ src/game/plugins/hypno.ts | 75 ++++++ src/game/plugins/index.ts | 6 + src/game/plugins/intensity.ts | 5 +- src/game/plugins/pause.ts | 7 +- src/game/plugins/perf.ts | 6 +- src/game/plugins/stroke.ts | 80 ++++++ src/game/test.ts | 97 ------- 41 files changed, 1528 insertions(+), 901 deletions(-) delete mode 100644 src/game/components/GameEvents.tsx delete mode 100644 src/game/components/GameWarmup.tsx delete mode 100644 src/game/components/events/clean-up.ts delete mode 100644 src/game/components/events/climax.ts delete mode 100644 src/game/components/events/double-pace.ts delete mode 100644 src/game/components/events/edge.ts delete mode 100644 src/game/components/events/half-pace.ts delete mode 100644 src/game/components/events/index.ts delete mode 100644 src/game/components/events/pause.ts delete mode 100644 src/game/components/events/random-grip.ts delete mode 100644 src/game/components/events/random-pace.ts delete mode 100644 src/game/components/events/rising-pace.ts create mode 100644 src/game/plugins/dealer.ts create mode 100644 src/game/plugins/dice/cleanUp.ts create mode 100644 src/game/plugins/dice/climax.ts create mode 100644 src/game/plugins/dice/doublePace.ts create mode 100644 src/game/plugins/dice/edge.ts create mode 100644 src/game/plugins/dice/emergencyStop.ts create mode 100644 src/game/plugins/dice/halfPace.ts create mode 100644 src/game/plugins/dice/pause.ts create mode 100644 src/game/plugins/dice/randomGrip.ts create mode 100644 src/game/plugins/dice/randomPace.ts create mode 100644 src/game/plugins/dice/risingPace.ts create mode 100644 src/game/plugins/dice/types.ts create mode 100644 src/game/plugins/hypno.ts create mode 100644 src/game/plugins/stroke.ts delete mode 100644 src/game/test.ts diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index ad02834..64f66f9 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -4,11 +4,17 @@ import { messagesPipe } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { PauseButton } from './components/Pause'; import { useSettingsPipe } from './pipes'; -import { GameIntensity } from './components/GameIntensity'; import { GameImages } from './components/GameImages'; import { pluginInstallerPipe } from '../engine/plugins/PluginInstaller'; import { pluginManagerPipe } from '../engine/plugins/PluginManager'; import { registerPlugins } from './plugins'; +import { GameMeter } from './components/GameMeter'; +import { GameHypno } from './components/GameHypno'; +import { GameSound } from './components/GameSound'; +import { GameVibrator } from './components/GameVibrator'; +import { GameInstructions } from './components/GameInstructions'; +import { GameEmergencyStop } from './components/GameEmergencyStop'; +import { GameSettings } from './components/GameSettings'; const StyledGamePage = styled.div` position: relative; @@ -86,13 +92,20 @@ export const GamePage = () => { - + - + + + + + + + + ); diff --git a/src/game/components/GameEmergencyStop.tsx b/src/game/components/GameEmergencyStop.tsx index fa0d974..5a8902a 100644 --- a/src/game/components/GameEmergencyStop.tsx +++ b/src/game/components/GameEmergencyStop.tsx @@ -1,52 +1,19 @@ -import { GamePhase, useGameValue, useSendMessage } from '../GameProvider'; import { useCallback } from 'react'; -import { wait } from '../../utils'; -import { useSetting } from '../../settings'; import { WaButton, WaIcon } from '@awesome.me/webawesome/dist/react'; +import { useGameEngine, useGameState } from '../hooks'; +import { GamePhase, PhaseState } from '../plugins/phase'; +import { dispatchEvent } from '../../engine/pipes/Events'; +import { getEventKey } from '../../engine/pipes/Events'; export const GameEmergencyStop = () => { - const [phase, setPhase] = useGameValue('phase'); - const [intensity, setIntensity] = useGameValue('intensity'); - const [, setPace] = useGameValue('pace'); - const [minPace] = useSetting('minPace'); - const sendMessage = useSendMessage(); - const messageId = 'emergency-stop'; - - const onStop = useCallback(async () => { - const timeToCalmDown = Math.ceil((intensity * 500 + 10000) / 1000); - - setPhase(GamePhase.break); - - sendMessage({ - id: messageId, - title: 'Calm down with your $hands off.', - }); - - // maybe percentage based reduction - setIntensity(intensity => Math.max(intensity - 30, 0)); - setPace(minPace); - - await wait(5000); - - for (let i = 0; i < timeToCalmDown; i++) { - sendMessage({ - id: messageId, - description: `${timeToCalmDown - i}...`, - }); - await wait(1000); - } - - sendMessage({ - id: messageId, - title: 'Put your $hands back.', - description: undefined, - duration: 5000, - }); - - await wait(2000); - - setPhase(GamePhase.active); - }, [intensity, minPace, sendMessage, setIntensity, setPace, setPhase]); + const { current: phase } = useGameState(['core.phase']) ?? {}; + const { injectImpulse } = useGameEngine(); + + const onStop = useCallback(() => { + injectImpulse( + dispatchEvent({ type: getEventKey('core.dice', 'emergencyStop') }) + ); + }, [injectImpulse]); return ( <> diff --git a/src/game/components/GameEvents.tsx b/src/game/components/GameEvents.tsx deleted file mode 100644 index 4150cb5..0000000 --- a/src/game/components/GameEvents.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ -import { MutableRefObject, useEffect } from 'react'; -import { GameEvent } from '../../types'; -import { - GamePhase, - GameState, - useGame, - useGameValue, - useSendMessage, -} from '../GameProvider'; -import { Settings, useSetting, useSettings } from '../../settings'; -import { - useLooping, - useAutoRef, - createStateSetters, - StateWithSetters, -} from '../../utils'; -import { - cleanUpEvent, - climaxEvent, - doublePaceEvent, - edgeEvent, - halfPaceEvent, - pauseEvent, - randomGripEvent, - randomPaceEvent, - risingPaceEvent, -} from './events'; - -export interface EventData { - game: StateWithSetters & { - sendMessage: ReturnType; - }; - settings: StateWithSetters; -} - -export type EventDataRef = MutableRefObject; - -export const rollEventDice = (data: EventDataRef) => { - const { - game: { intensity, phase, edged }, - settings: { events }, - } = data.current; - - const roll = (chance: number): boolean => - Math.floor(Math.random() * chance) === 0; - - if (phase !== GamePhase.active) return null; - - if ( - events.includes(GameEvent.climax) && - intensity >= 100 && - (!events.includes(GameEvent.edge) || edged) - ) { - return GameEvent.climax; - } - - if (events.includes(GameEvent.edge) && intensity >= 90 && !edged) { - return GameEvent.edge; - } - - if (events.includes(GameEvent.randomPace) && roll(10)) { - return GameEvent.randomPace; - } - - if (events.includes(GameEvent.cleanUp) && intensity >= 75 && roll(25)) { - return GameEvent.cleanUp; - } - - if (events.includes(GameEvent.randomGrip) && roll(50)) { - return GameEvent.randomGrip; - } - - if ( - events.includes(GameEvent.doublePace) && - intensity >= 20 && - roll(50 - (intensity - 20) * 0.25) - ) { - return GameEvent.doublePace; - } - - if ( - events.includes(GameEvent.halfPace) && - intensity >= 10 && - intensity <= 50 && - roll(50) - ) { - return GameEvent.halfPace; - } - - if (events.includes(GameEvent.pause) && intensity >= 15 && roll(50)) { - return GameEvent.pause; - } - - if (events.includes(GameEvent.risingPace) && intensity >= 30 && roll(30)) { - return GameEvent.risingPace; - } - - return null; -}; - -export const handleEvent = async (event: GameEvent, data: EventDataRef) => { - await { - climax: climaxEvent, - edge: edgeEvent, - pause: pauseEvent, - halfPace: halfPaceEvent, - risingPace: risingPaceEvent, - doublePace: doublePaceEvent, - randomPace: randomPaceEvent, - randomGrip: randomGripEvent, - cleanUp: cleanUpEvent, - }[event](data); -}; - -export const silenceEventData = (data: EventDataRef): EventDataRef => { - return { - get current() { - return { - ...data.current, - game: { - ...data.current.game, - sendMessage: () => {}, - }, - }; - }, - }; -}; - -export const GameEvents = () => { - const [phase] = useGameValue('phase'); - const [, setPaws] = useGameValue('paws'); - const [events] = useSetting('events'); - const sendMessage = useSendMessage(); - - const data = useAutoRef({ - game: { - ...createStateSetters(...useGame()), - sendMessage: sendMessage, - }, - settings: createStateSetters(...useSettings()), - }); - - useEffect(() => { - if (phase === GamePhase.active && events.includes(GameEvent.randomGrip)) { - randomGripEvent(silenceEventData(data)); - } - }, [data, events, phase, setPaws]); - - useLooping( - async () => { - const event = rollEventDice(data); - if (event) { - await handleEvent(event, data); - } - }, - 1000, - phase === GamePhase.active - ); - - return null; -}; diff --git a/src/game/components/GameHypno.tsx b/src/game/components/GameHypno.tsx index 238e108..5b42a3e 100644 --- a/src/game/components/GameHypno.tsx +++ b/src/game/components/GameHypno.tsx @@ -1,10 +1,11 @@ import styled from 'styled-components'; import { useSetting, useTranslate } from '../../settings'; import { GameHypnoType, HypnoPhrases } from '../../types'; -import { useLooping } from '../../utils'; -import { GamePhase, useGameValue } from '../GameProvider'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { motion } from 'framer-motion'; +import { useGameState } from '../hooks'; +import { IntensityState } from '../plugins/intensity'; +import { HypnoState } from '../plugins/hypno'; const StyledGameHypno = motion.create(styled.div` pointer-events: none; @@ -15,33 +16,25 @@ const StyledGameHypno = motion.create(styled.div` export const GameHypno = () => { const [hypno] = useSetting('hypno'); - const [current, setCurrent] = useGameValue('currentHypno'); - const [phase] = useGameValue('phase'); - const [intensity] = useGameValue('intensity'); + const { currentPhrase = 0 } = useGameState(['core.hypno']) ?? {}; + const { intensity = 0 } = + useGameState(['core.intensity']) ?? {}; const translate = useTranslate(); const phrase = useMemo(() => { + if (hypno === GameHypnoType.off) return ''; const phrases = HypnoPhrases[hypno]; if (phrases.length <= 0) return ''; - return translate(phrases[current % phrases.length]); - }, [current, hypno, translate]); + return translate(phrases[currentPhrase % phrases.length]); + }, [currentPhrase, hypno, translate]); - const onTick = useCallback(() => { - setCurrent(Math.floor(Math.random() * HypnoPhrases[hypno].length)); - }, [hypno, setCurrent]); + const delay = useMemo(() => 3000 - intensity * 100 * 29, [intensity]); - const delay = useMemo(() => 3000 - intensity * 29, [intensity]); - - const enabled = useMemo( - () => phase === GamePhase.active && hypno !== GameHypnoType.off, - [phase, hypno] - ); - - useLooping(onTick, delay, enabled); + if (hypno === GameHypnoType.off || !phrase) return null; return ( { - const [pace] = useGameValue('pace'); - const [intensity] = useGameValue('intensity'); + const { pace = 0 } = useGameState(['core.pace']) ?? {}; + const { intensity = 0 } = + useGameState(['core.intensity']) ?? {}; + const { paws = Paws.both } = useGameState(['core.dice']) ?? {}; const [maxPace] = useSetting('maxPace'); const paceSection = useMemo(() => maxPace / 3, [maxPace]); - const [paws] = useGameValue('paws'); const [events] = useSetting('events'); const useRandomGrip = useMemo( - () => events.includes(GameEvent.randomGrip), + () => events.includes(GameEventType.randomGrip), [events] ); + const intensityPct = Math.round(intensity * 100); + return ( @@ -95,13 +101,17 @@ export const GameInstructions = () => { <> - + - + @@ -112,14 +122,14 @@ export const GameInstructions = () => { )} diff --git a/src/game/components/GameMeter.tsx b/src/game/components/GameMeter.tsx index ad12661..a1ddcc9 100644 --- a/src/game/components/GameMeter.tsx +++ b/src/game/components/GameMeter.tsx @@ -1,8 +1,11 @@ import styled from 'styled-components'; -import { GamePhase, Stroke, useGameValue } from '../GameProvider'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { motion } from 'framer-motion'; -import { defaultTransition, useLooping } from '../../utils'; +import { defaultTransition } from '../../utils'; +import { useGameState } from '../hooks'; +import { GamePhase, PhaseState } from '../plugins/phase'; +import { StrokeDirection, StrokeState } from '../plugins/stroke'; +import { PaceState } from '../plugins/pace'; const StyledGameMeter = styled.div` pointer-events: none; @@ -22,70 +25,33 @@ enum MeterColor { } export const GameMeter = () => { - const [stroke, setStroke] = useGameValue('stroke'); - const [phase] = useGameValue('phase'); - const [pace] = useGameValue('pace'); + const { stroke } = useGameState(['core.stroke']) ?? {}; + const { current: phase } = useGameState(['core.phase']) ?? {}; + const { pace } = useGameState(['core.pace']) ?? {}; const switchDuration = useMemo(() => { - if (pace === 0) return 0; + if (!pace || pace === 0) return 0; return (1 / pace) * 1000; }, [pace]); - const updateStroke = useCallback(() => { - setStroke(stroke => { - switch (stroke) { - case Stroke.up: - return Stroke.down; - case Stroke.down: - return Stroke.up; - } - }); - }, [setStroke]); - - useLooping( - updateStroke, - switchDuration, - [GamePhase.active, GamePhase.finale].includes(phase) && pace > 0 - ); - const size = useMemo(() => { - switch (phase) { - case GamePhase.active: - case GamePhase.finale: - return (() => { - switch (stroke) { - case Stroke.up: - return 1; - case Stroke.down: - return 0.6; - } - })(); + if (phase === GamePhase.active || phase === GamePhase.finale) { + return stroke === StrokeDirection.up ? 1 : 0.6; } return 0; }, [phase, stroke]); const duration = useMemo(() => { - switch (phase) { - case GamePhase.active: - case GamePhase.finale: - if (pace >= 5) { - return 100; - } - if (pace >= 3) { - return 250; - } - return 550; + if (phase === GamePhase.active || phase === GamePhase.finale) { + if (pace && pace >= 5) return 100; + if (pace && pace >= 3) return 250; + return 550; } return 0; }, [phase, pace]); const color = useMemo(() => { - switch (stroke) { - case Stroke.up: - return MeterColor.light; - case Stroke.down: - return MeterColor.dark; - } + return stroke === StrokeDirection.up ? MeterColor.light : MeterColor.dark; }, [stroke]); return ( diff --git a/src/game/components/GameSettings.tsx b/src/game/components/GameSettings.tsx index 0352ab1..ac173ca 100644 --- a/src/game/components/GameSettings.tsx +++ b/src/game/components/GameSettings.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { BoardSettings, ClimaxSettings, @@ -11,14 +11,18 @@ import { PlayerSettings, VibratorSettings, } from '../../settings'; -import { GamePhase, useGameValue, useSendMessage } from '../GameProvider'; -import { useFullscreen, useLooping } from '../../utils'; +import { useFullscreen } from '../../utils'; import { WaButton, WaDialog, WaDivider, WaIcon, } from '@awesome.me/webawesome/dist/react'; +import { useGameEngine, useGameState } from '../hooks'; +import { GamePhase, PhaseState } from '../plugins/phase'; +import Pause from '../plugins/pause'; +import { Messages } from '../../engine/pipes/Messages'; +import { Composer } from '../../engine'; const StyledGameSettings = styled.div` display: flex; @@ -55,56 +59,77 @@ const GameSettingsDialogContent = memo(() => ( )); +const MESSAGE_ID = 'game-settings'; + export const GameSettings = () => { const [open, setOpen] = useState(false); - const [phase, setPhase] = useGameValue('phase'); - const [timer, setTimer] = useState(undefined); + const { current: phase } = useGameState(['core.phase']) ?? {}; const [fullscreen, setFullscreen] = useFullscreen(); - const sendMessage = useSendMessage(); - const messageId = 'game-settings'; + const { injectImpulse } = useGameEngine(); + const timerRef = useRef(undefined); + const [countdown, setCountdown] = useState(undefined); + const wasActiveRef = useRef(false); const onOpen = useCallback( - (open: boolean) => { - if (open) { - setTimer(undefined); - setPhase(phase => { - if (phase === GamePhase.active) { - return GamePhase.pause; - } - return phase; - }); + (opening: boolean) => { + if (opening) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = undefined; + } + setCountdown(undefined); + wasActiveRef.current = phase === GamePhase.active; + if (phase === GamePhase.active) { + injectImpulse(Pause.setPaused(true)); + } } else { - setTimer(3000); + if (wasActiveRef.current) { + setCountdown(3000); + } } - setOpen(open); + setOpen(opening); }, - [setPhase] + [injectImpulse, phase] ); - useLooping( - () => { - if (timer === undefined) return; - if (timer > 0) { - sendMessage({ - id: messageId, - title: 'Get ready to continue.', - description: `${timer * 0.001}...`, - }); - setTimer(timer - 1000); - } else if (timer === 0) { - sendMessage({ - id: messageId, - title: 'Continue.', - description: undefined, - duration: 1500, - }); - setPhase(GamePhase.active); - setTimer(undefined); + useEffect(() => { + if (countdown === undefined || open) return; + + if (countdown <= 0) { + injectImpulse( + Composer.pipe( + Messages.send({ + id: MESSAGE_ID, + title: 'Continue.', + description: undefined, + duration: 1500, + }), + Pause.setPaused(false) + ) + ); + setCountdown(undefined); + return; + } + + injectImpulse( + Messages.send({ + id: MESSAGE_ID, + title: 'Get ready to continue.', + description: `${countdown * 0.001}...`, + }) + ); + + timerRef.current = window.setTimeout(() => { + setCountdown(c => (c !== undefined ? c - 1000 : undefined)); + }, 1000); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = undefined; } - }, - 1000, - !open && phase === GamePhase.pause && timer !== undefined - ); + }; + }, [countdown, open, injectImpulse]); return ( diff --git a/src/game/components/GameSound.tsx b/src/game/components/GameSound.tsx index b43d958..00d4cfc 100644 --- a/src/game/components/GameSound.tsx +++ b/src/game/components/GameSound.tsx @@ -1,20 +1,22 @@ import { useEffect, useState } from 'react'; -import { GamePhase, Stroke, useGameValue } from '../GameProvider'; import { playTone } from '../../utils/sound'; import { wait } from '../../utils'; +import { useGameState } from '../hooks'; +import { GamePhase, PhaseState } from '../plugins/phase'; +import { StrokeDirection, StrokeState } from '../plugins/stroke'; export const GameSound = () => { - const [stroke] = useGameValue('stroke'); - const [phase] = useGameValue('phase'); + const { stroke } = useGameState(['core.stroke']) ?? {}; + const { current: phase } = useGameState(['core.phase']) ?? {}; const [currentPhase, setCurrentPhase] = useState(phase); useEffect(() => { switch (stroke) { - case Stroke.up: + case StrokeDirection.up: playTone(425); break; - case Stroke.down: + case StrokeDirection.down: playTone(625); break; } diff --git a/src/game/components/GameVibrator.tsx b/src/game/components/GameVibrator.tsx index 7ed4233..9d81bdc 100644 --- a/src/game/components/GameVibrator.tsx +++ b/src/game/components/GameVibrator.tsx @@ -1,19 +1,23 @@ import { useEffect, useState } from 'react'; -import { GamePhase, Stroke, useGameValue } from '../GameProvider'; import { useAutoRef, useVibratorValue, VibrationMode, wait } from '../../utils'; import { useSetting } from '../../settings'; +import { useGameState } from '../hooks'; +import { GamePhase, PhaseState } from '../plugins/phase'; +import { StrokeDirection, StrokeState } from '../plugins/stroke'; +import { PaceState } from '../plugins/pace'; +import { IntensityState } from '../plugins/intensity'; export const GameVibrator = () => { - const [stroke] = useGameValue('stroke'); - const [intensity] = useGameValue('intensity'); - const [pace] = useGameValue('pace'); - const [phase] = useGameValue('phase'); + const { stroke } = useGameState(['core.stroke']) ?? {}; + const { intensity } = useGameState(['core.intensity']) ?? {}; + const { pace } = useGameState(['core.pace']) ?? {}; + const { current: phase } = useGameState(['core.phase']) ?? {}; const [mode] = useSetting('vibrations'); const [devices] = useVibratorValue('devices'); const data = useAutoRef({ - intensity, - pace, + intensity: (intensity ?? 0) * 100, + pace: pace ?? 1, devices, mode, }); @@ -23,7 +27,7 @@ export const GameVibrator = () => { useEffect(() => { const { intensity, pace, devices, mode } = data.current; switch (stroke) { - case Stroke.up: + case StrokeDirection.up: switch (mode) { case VibrationMode.constant: { const strength = intensity / 100; @@ -38,7 +42,7 @@ export const GameVibrator = () => { } } break; - case Stroke.down: + case StrokeDirection.down: break; } }, [data, stroke]); @@ -46,7 +50,7 @@ export const GameVibrator = () => { useEffect(() => { const { devices, mode } = data.current; if (currentPhase == phase) return; - if ([GamePhase.break, GamePhase.pause].includes(phase)) { + if (phase === GamePhase.break) { devices.forEach(device => device.setVibration(0)); } if (phase === GamePhase.climax) { diff --git a/src/game/components/GameWarmup.tsx b/src/game/components/GameWarmup.tsx deleted file mode 100644 index 65f7bc0..0000000 --- a/src/game/components/GameWarmup.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useSetting } from '../../settings'; -import { GamePhase, useGameValue, useSendMessage } from '../GameProvider'; - -export const GameWarmup = () => { - const [warmup] = useSetting('warmupDuration'); - const [phase, setPhase] = useGameValue('phase'); - const [, setTimer] = useState(null); - const sendMessage = useSendMessage(); - - const onStart = useCallback(() => { - setPhase(GamePhase.active); - setTimer(timer => { - if (timer) window.clearTimeout(timer); - return null; - }); - sendMessage({ - id: GamePhase.warmup, - title: 'Now follow what I say, $player!', - duration: 5000, - prompts: undefined, - }); - }, [sendMessage, setPhase]); - - useEffect(() => { - if (phase !== GamePhase.warmup) return; - if (warmup === 0) { - setPhase(GamePhase.active); - return; - } - setTimer(window.setTimeout(onStart, warmup * 1000)); - - sendMessage({ - id: GamePhase.warmup, - title: 'Get yourself ready!', - prompts: [ - { - title: `I'm ready, $master`, - onClick: onStart, - }, - ], - }); - - return () => { - setTimer(timer => { - if (timer) window.clearTimeout(timer); - return null; - }); - }; - }, [onStart, phase, sendMessage, setPhase, warmup]); - - return null; -}; diff --git a/src/game/components/events/clean-up.ts b/src/game/components/events/clean-up.ts deleted file mode 100644 index d44687e..0000000 --- a/src/game/components/events/clean-up.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { GameEvent, CleanUpDescriptions } from '../../../types'; -import { GamePhase } from '../../GameProvider'; -import { EventDataRef } from '../GameEvents'; - -export const cleanUpEvent = async (data: EventDataRef) => { - const { - game: { setPhase, sendMessage }, - settings: { body }, - } = data.current; - - setPhase(GamePhase.pause); - sendMessage({ - id: GameEvent.cleanUp, - title: `Lick up any ${CleanUpDescriptions[body]}`, - duration: undefined, - prompts: [ - { - title: `I'm done, $master`, - onClick: () => { - sendMessage({ - id: GameEvent.cleanUp, - title: 'Good $player', - duration: 5000, - prompts: undefined, - }); - setPhase(GamePhase.active); - }, - }, - ], - }); -}; diff --git a/src/game/components/events/climax.ts b/src/game/components/events/climax.ts deleted file mode 100644 index 8daaa66..0000000 --- a/src/game/components/events/climax.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { GameEvent } from '../../../types'; -import { wait } from '../../../utils'; -import { GamePhase } from '../../GameProvider'; -import { EventDataRef } from '../GameEvents'; - -export const climaxEvent = (data: EventDataRef) => { - const { - game: { setPhase, sendMessage, setPace, setIntensity }, - settings: { minPace, climaxChance, ruinChance }, - } = data.current; - - setPhase(GamePhase.finale); // this disables events - sendMessage({ - id: GameEvent.climax, - title: 'Are you edging?', - prompts: [ - { - title: "I'm edging, $master", - onClick: async () => { - sendMessage({ - id: GameEvent.climax, - title: 'Stay on the edge, $player', - prompts: undefined, - }); - setPace(minPace); - await wait(3000); - sendMessage({ - id: GameEvent.climax, - description: '3...', - }); - await wait(5000); - sendMessage({ - id: GameEvent.climax, - description: '2...', - }); - await wait(5000); - sendMessage({ - id: GameEvent.climax, - description: '1...', - }); - await wait(5000); - - if (Math.random() * 100 <= climaxChance) { - if (Math.random() * 100 <= ruinChance) { - setPhase(GamePhase.pause); - sendMessage({ - id: GameEvent.climax, - title: '$HANDS OFF! Ruin your orgasm!', - description: undefined, - }); - await wait(3000); - sendMessage({ - id: GameEvent.climax, - title: 'Clench in desperation', - }); - } else { - setPhase(GamePhase.climax); - sendMessage({ - id: GameEvent.climax, - title: 'Cum!', - description: undefined, - }); - } - for (let i = 0; i < 10; i++) { - setIntensity(intensity => intensity - 10); - await wait(1000); - } - sendMessage({ - id: GameEvent.climax, - title: 'Good job, $player', - prompts: [ - { - title: 'Leave', - onClick: () => { - window.location.href = '/'; - }, - }, - ], - }); - } else { - setPhase(GamePhase.pause); - sendMessage({ - id: GameEvent.climax, - title: '$HANDS OFF! Do not cum!', - description: undefined, - }); - for (let i = 0; i < 5; i++) { - setIntensity(intensity => intensity - 20); - await wait(1000); - } - sendMessage({ - id: GameEvent.climax, - title: 'Good $player. Let yourself cool off', - }); - await wait(5000); - sendMessage({ - id: GameEvent.climax, - title: 'Leave now.', - prompts: [ - { - title: 'Leave', - onClick: () => { - window.location.href = '/'; - }, - }, - ], - }); - } - }, - }, - { - title: "I can't", - onClick: async () => { - sendMessage({ - id: GameEvent.climax, - title: "You're pathetic. Stop for a moment", - }); - setPhase(GamePhase.pause); - setIntensity(0); // TODO: this essentially restarts the game. is this a good idea? - await wait(20000); - sendMessage({ - id: GameEvent.climax, - title: 'Start to $stroke again', - duration: 5000, - }); - setPace(minPace); - setPhase(GamePhase.active); - await wait(15000); - }, - }, - ], - }); -}; diff --git a/src/game/components/events/double-pace.ts b/src/game/components/events/double-pace.ts deleted file mode 100644 index 3d71ea6..0000000 --- a/src/game/components/events/double-pace.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { GameEvent } from '../../../types'; -import { round, wait } from '../../../utils'; -import { EventDataRef, silenceEventData } from '../GameEvents'; -import { randomPaceEvent } from './random-pace'; - -export const doublePaceEvent = async (data: EventDataRef) => { - const { - game: { pace, setPace, sendMessage }, - settings: { maxPace }, - } = data.current; - const newPace = Math.min(round(pace * 2), maxPace); - setPace(newPace); - sendMessage({ - id: GameEvent.doublePace, - title: 'Double pace!', - }); - const duration = 9000; - const durationPortion = duration / 3; - sendMessage({ - id: GameEvent.doublePace, - description: '3...', - }); - await wait(durationPortion); - sendMessage({ - id: GameEvent.doublePace, - description: '2...', - }); - await wait(durationPortion); - sendMessage({ - id: GameEvent.doublePace, - description: '1...', - }); - await wait(durationPortion); - sendMessage({ - id: GameEvent.doublePace, - title: 'Done! Back to normal pace', - description: undefined, - duration: 5000, - }); - - randomPaceEvent(silenceEventData(data)); -}; diff --git a/src/game/components/events/edge.ts b/src/game/components/events/edge.ts deleted file mode 100644 index d91a97f..0000000 --- a/src/game/components/events/edge.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { GameEvent } from '../../../types'; -import { wait } from '../../../utils'; -import { EventDataRef } from '../GameEvents'; - -export const edgeEvent = async (data: EventDataRef) => { - const { - game: { setEdged, setPace, sendMessage }, - settings: { minPace }, - } = data.current; - - setEdged(true); - setPace(minPace); - sendMessage({ - id: GameEvent.edge, - title: `You should getting close to the edge. Don't cum yet.`, - duration: 10000, - }); - await wait(10000); -}; diff --git a/src/game/components/events/half-pace.ts b/src/game/components/events/half-pace.ts deleted file mode 100644 index 8c095fd..0000000 --- a/src/game/components/events/half-pace.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { GameEvent } from '../../../types'; -import { round, wait } from '../../../utils'; -import { EventDataRef, silenceEventData } from '../GameEvents'; -import { randomPaceEvent } from './random-pace'; - -export const halfPaceEvent = async (data: EventDataRef) => { - const { - game: { pace, setPace, sendMessage }, - settings: { minPace }, - } = data.current; - - sendMessage({ - id: GameEvent.halfPace, - title: 'Half pace!', - }); - const newPace = Math.max(round(pace / 2), minPace); - setPace(newPace); - const duration = Math.ceil(Math.random() * 20000) + 12000; - const durationPortion = duration / 3; - sendMessage({ - id: GameEvent.halfPace, - description: '3...', - }); - await wait(durationPortion); - sendMessage({ - id: GameEvent.halfPace, - description: '2...', - }); - await wait(durationPortion); - sendMessage({ - id: GameEvent.halfPace, - description: '1...', - }); - await wait(durationPortion); - sendMessage({ - id: GameEvent.halfPace, - title: 'Done! Back to normal pace', - description: undefined, - duration: 5000, - }); - - randomPaceEvent(silenceEventData(data)); -}; diff --git a/src/game/components/events/index.ts b/src/game/components/events/index.ts deleted file mode 100644 index 3fa73b3..0000000 --- a/src/game/components/events/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './clean-up'; -export * from './climax'; -export * from './double-pace'; -export * from './edge'; -export * from './half-pace'; -export * from './pause'; -export * from './random-grip'; -export * from './random-pace'; -export * from './rising-pace'; diff --git a/src/game/components/events/pause.ts b/src/game/components/events/pause.ts deleted file mode 100644 index 8229edc..0000000 --- a/src/game/components/events/pause.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { GameEvent } from '../../../types'; -import { wait } from '../../../utils'; -import { GamePhase } from '../../GameProvider'; -import { EventDataRef } from '../GameEvents'; - -export const pauseEvent = async (data: EventDataRef) => { - const { - game: { intensity, setPhase, sendMessage }, - } = data.current; - - sendMessage({ - id: GameEvent.pause, - title: 'Stop stroking!', - }); - setPhase(GamePhase.pause); - const duration = Math.ceil(-100 * intensity + 12000); - await wait(duration); - sendMessage({ - id: GameEvent.pause, - title: 'Start stroking again!', - duration: 5000, - }); - setPhase(GamePhase.active); -}; diff --git a/src/game/components/events/random-grip.ts b/src/game/components/events/random-grip.ts deleted file mode 100644 index 14c6504..0000000 --- a/src/game/components/events/random-grip.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { GameEvent } from '../../../types'; -import { wait } from '../../../utils'; -import { Paws, PawLabels } from '../../GameProvider'; -import { EventDataRef } from '../GameEvents'; - -export const randomGripEvent = async (data: EventDataRef) => { - const { - game: { paws, setPaws, sendMessage }, - } = data.current; - - let newPaws: Paws; - const seed = Math.random(); - if (seed < 0.33) newPaws = paws === Paws.both ? Paws.left : Paws.both; - if (seed < 0.66) newPaws = paws === Paws.left ? Paws.right : Paws.left; - newPaws = paws === Paws.right ? Paws.both : Paws.right; - setPaws(newPaws); - sendMessage({ - id: GameEvent.randomGrip, - title: `Grip changed to ${PawLabels[newPaws]}!`, - duration: 5000, - }); - await wait(10000); -}; diff --git a/src/game/components/events/random-pace.ts b/src/game/components/events/random-pace.ts deleted file mode 100644 index d1212ce..0000000 --- a/src/game/components/events/random-pace.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { GameEvent } from '../../../types'; -import { intensityToPaceRange, round, wait } from '../../../utils'; -import { EventDataRef } from '../GameEvents'; - -export const randomPaceEvent = async (data: EventDataRef) => { - const { - game: { intensity, setPace, sendMessage }, - settings: { minPace, maxPace, steepness, timeshift }, - } = data.current; - - const { min, max } = intensityToPaceRange(intensity, steepness, timeshift, { - min: minPace, - max: maxPace, - }); - const newPace = round(Math.random() * (max - min) + min); - setPace(newPace); - sendMessage({ - id: GameEvent.randomPace, - title: `Pace changed to ${newPace}!`, - duration: 5000, - }); - await wait(9000); -}; diff --git a/src/game/components/events/rising-pace.ts b/src/game/components/events/rising-pace.ts deleted file mode 100644 index 9241865..0000000 --- a/src/game/components/events/rising-pace.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { GameEvent } from '../../../types'; -import { intensityToPaceRange, wait, round } from '../../../utils'; -import { EventDataRef } from '../GameEvents'; -import { randomPaceEvent } from './random-pace'; - -export const risingPaceEvent = async (data: EventDataRef) => { - const { - game: { intensity, setPace, sendMessage }, - settings: { minPace, maxPace, steepness, timeshift }, - } = data.current; - - sendMessage({ - id: GameEvent.risingPace, - title: 'Rising pace strokes!', - }); - const acceleration = Math.round(100 / Math.min(intensity, 35)); - const { max } = intensityToPaceRange(intensity, steepness, timeshift, { - min: minPace, - max: maxPace, - }); - const portion = (max - minPace) / acceleration; - let newPace = minPace; - setPace(newPace); - for (let i = 0; i < acceleration; i++) { - await wait(10000); - newPace = round(newPace + portion); - setPace(newPace); - sendMessage({ - id: GameEvent.risingPace, - title: `Pace rising to ${newPace}!`, - duration: 5000, - }); - } - await wait(10000); - sendMessage({ - id: GameEvent.risingPace, - title: 'Stay at this pace for a bit', - duration: 5000, - }); - await wait(15000); - - randomPaceEvent(data); -}; diff --git a/src/game/components/index.ts b/src/game/components/index.ts index 4cb0ba1..4bde389 100644 --- a/src/game/components/index.ts +++ b/src/game/components/index.ts @@ -1,6 +1,4 @@ -export * from './events'; export * from './GameEmergencyStop'; -export * from './GameEvents'; export * from './GameHypno'; export * from './GameImages'; export * from './GameInstructions'; @@ -11,4 +9,3 @@ export * from './GamePace'; export * from './GameSettings'; export * from './GameSound'; export * from './GameVibrator'; -export * from './GameWarmup'; diff --git a/src/game/hooks/index.ts b/src/game/hooks/index.ts index a760aae..1da3915 100644 --- a/src/game/hooks/index.ts +++ b/src/game/hooks/index.ts @@ -1,3 +1,4 @@ export * from './UseDispatchEvent'; export * from './UseGameContext'; +export * from './UseGameEngine'; export * from './UseGameValue'; diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts new file mode 100644 index 0000000..0aeece5 --- /dev/null +++ b/src/game/plugins/dealer.ts @@ -0,0 +1,150 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine/Composer'; +import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Scheduler } from '../../engine/pipes/Scheduler'; +import { GamePhase } from './phase'; +import Pause from './pause'; +import { GameEvent as GameEventType } from '../../types'; +import { + PLUGIN_ID, + dice, + phaseState, + intensityState, + settings, + gameContext, + Paws, + DiceOutcome, +} from './dice/types'; +import { edgeOutcome } from './dice/edge'; +import { pauseOutcome } from './dice/pause'; +import { randomPaceOutcome } from './dice/randomPace'; +import { doublePaceOutcome } from './dice/doublePace'; +import { halfPaceOutcome } from './dice/halfPace'; +import { risingPaceOutcome } from './dice/risingPace'; +import { randomGripOutcome } from './dice/randomGrip'; +import { cleanUpOutcome } from './dice/cleanUp'; +import { climaxOutcome } from './dice/climax'; +import { + emergencyStopPipes, + emergencyStopScheduleKeys, +} from './dice/emergencyStop'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Dealer: typeof Dealer; + } +} + +const outcomes: DiceOutcome[] = [ + climaxOutcome, + edgeOutcome, + randomPaceOutcome, + cleanUpOutcome, + randomGripOutcome, + doublePaceOutcome, + halfPaceOutcome, + pauseOutcome, + risingPaceOutcome, +]; + +const rollChances: Record = { + [GameEventType.randomPace]: 10, + [GameEventType.cleanUp]: 25, + [GameEventType.randomGrip]: 50, + [GameEventType.doublePace]: 50, + [GameEventType.halfPace]: 50, + [GameEventType.pause]: 50, + [GameEventType.risingPace]: 30, +}; + +const eventKeyForOutcome = (id: GameEventType): string => + getEventKey(PLUGIN_ID, id); + +const allScheduleKeys = [ + ...outcomes.flatMap(o => o.scheduleKeys), + ...emergencyStopScheduleKeys, +]; + +export default class Dealer { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Dealer', + }, + + activate: Composer.do(({ get, set }) => { + set(dice.state, { + edged: false, + paws: Paws.both, + busy: false, + rollTimer: 0, + }); + + const s = get(settings); + if (s?.events?.includes(GameEventType.randomGrip)) { + const seed = Math.random(); + let paws = Paws.both; + if (seed < 0.33) paws = Paws.left; + else if (seed < 0.66) paws = Paws.right; + set(dice.state.paws, paws); + } + }), + + update: Composer.pipe( + Pause.whenPlaying( + Composer.do(({ get, set, pipe }) => { + const phase = get(phaseState)?.current; + if (phase !== GamePhase.active) return; + + const state = get(dice.state); + if (!state || state.busy) return; + + const delta = get(gameContext.deltaTime); + const elapsed = state.rollTimer + delta; + + if (elapsed < 1000) { + set(dice.state.rollTimer, elapsed); + return; + } + + set(dice.state.rollTimer, 0); + + const i = (get(intensityState)?.intensity ?? 0) * 100; + const s = get(settings); + if (!s) return; + + for (const outcome of outcomes) { + if (!s.events.includes(outcome.id)) continue; + if (!outcome.check(i, state.edged, s.events)) continue; + + const chance = rollChances[outcome.id]; + if (chance && Math.floor(Math.random() * chance) !== 0) continue; + + set(dice.state.busy, true); + pipe(Events.dispatch({ type: eventKeyForOutcome(outcome.id) })); + return; + } + }) + ), + + ...outcomes.map(o => o.pipes), + + emergencyStopPipes, + + Pause.onPause(() => + Composer.pipe(...allScheduleKeys.map(id => Scheduler.hold(id))) + ), + Pause.onResume(() => + Composer.pipe(...allScheduleKeys.map(id => Scheduler.release(id))) + ) + ), + + deactivate: Composer.set(dice.state, undefined), + }; + + static get paths() { + return dice; + } +} + +export { Paws, PawLabels, type DiceState } from './dice/types'; diff --git a/src/game/plugins/dice/cleanUp.ts b/src/game/plugins/dice/cleanUp.ts new file mode 100644 index 0000000..8f192fe --- /dev/null +++ b/src/game/plugins/dice/cleanUp.ts @@ -0,0 +1,54 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import Phase, { GamePhase } from '../phase'; +import { + GameEvent as GameEventType, + CleanUpDescriptions, +} from '../../../types'; +import { PLUGIN_ID, settings, setBusy, DiceOutcome } from './types'; + +const ev = { + cleanUp: getEventKey(PLUGIN_ID, 'cleanUp'), + done: getEventKey(PLUGIN_ID, 'cleanUp.done'), +}; + +export const cleanUpOutcome: DiceOutcome = { + id: GameEventType.cleanUp, + check: intensity => intensity >= 75, + scheduleKeys: [], + pipes: Composer.pipe( + Events.handle(ev.cleanUp, () => + Composer.do(({ get, pipe }) => { + const s = get(settings); + if (!s) return; + pipe(Phase.setPhase(GamePhase.break)); + pipe( + Messages.send({ + id: GameEventType.cleanUp, + title: `Lick up any ${CleanUpDescriptions[s.body]}`, + duration: undefined, + prompts: [ + { + title: `I'm done, $master`, + event: { type: ev.done }, + }, + ], + }) + ); + }) + ), + Events.handle(ev.done, () => + Composer.pipe( + Messages.send({ + id: GameEventType.cleanUp, + title: 'Good $player', + duration: 5000, + prompts: undefined, + }), + Phase.setPhase(GamePhase.active), + setBusy(false) + ) + ) + ), +}; diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts new file mode 100644 index 0000000..5473e97 --- /dev/null +++ b/src/game/plugins/dice/climax.ts @@ -0,0 +1,292 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import Phase, { GamePhase } from '../phase'; +import Pace from '../pace'; +import { GameEvent as GameEventType } from '../../../types'; +import { IntensityState } from '../intensity'; +import { + PLUGIN_ID, + intensityState, + settings, + setBusy, + DiceOutcome, +} from './types'; + +const ev = { + climax: getEventKey(PLUGIN_ID, 'climax'), + edging: getEventKey(PLUGIN_ID, 'climax.edging'), + cant: getEventKey(PLUGIN_ID, 'climax.cant'), + countdown3: getEventKey(PLUGIN_ID, 'climax.countdown3'), + countdown2: getEventKey(PLUGIN_ID, 'climax.countdown2'), + countdown1: getEventKey(PLUGIN_ID, 'climax.countdown1'), + resolve: getEventKey(PLUGIN_ID, 'climax.resolve'), + end: getEventKey(PLUGIN_ID, 'climax.end'), + cantResume: getEventKey(PLUGIN_ID, 'climax.cantResume'), + cantEnd: getEventKey(PLUGIN_ID, 'climax.cantEnd'), + leave: getEventKey(PLUGIN_ID, 'leave'), +}; + +const sched = { + countdown3: getScheduleKey(PLUGIN_ID, 'climax.countdown3'), + countdown2: getScheduleKey(PLUGIN_ID, 'climax.countdown2'), + countdown1: getScheduleKey(PLUGIN_ID, 'climax.countdown1'), + resolve: getScheduleKey(PLUGIN_ID, 'climax.resolve'), + end: getScheduleKey(PLUGIN_ID, 'climax.end'), + cantResume: getScheduleKey(PLUGIN_ID, 'climax.cantResume'), + cantEnd: getScheduleKey(PLUGIN_ID, 'climax.cantEnd'), +}; + +export const climaxOutcome: DiceOutcome = { + id: GameEventType.climax, + check: (intensity, edged, events) => + intensity >= 100 && (!events.includes(GameEventType.edge) || edged), + scheduleKeys: Object.values(sched), + pipes: Composer.pipe( + Events.handle(ev.climax, () => + Composer.pipe( + Phase.setPhase(GamePhase.finale), + Messages.send({ + id: GameEventType.climax, + title: 'Are you edging?', + prompts: [ + { + title: "I'm edging, $master", + event: { type: ev.edging }, + }, + { + title: "I can't", + event: { type: ev.cant }, + }, + ], + }) + ) + ), + + Events.handle(ev.edging, () => + Composer.do(({ get, pipe }) => { + const s = get(settings); + if (!s) return; + pipe( + Messages.send({ + id: GameEventType.climax, + title: 'Stay on the edge, $player', + prompts: undefined, + }) + ); + pipe(Pace.setPace(s.minPace)); + pipe( + Scheduler.schedule({ + id: sched.countdown3, + duration: 3000, + event: { type: ev.countdown3 }, + }) + ); + }) + ), + + Events.handle(ev.countdown3, () => + Composer.pipe( + Messages.send({ id: GameEventType.climax, description: '3...' }), + Scheduler.schedule({ + id: sched.countdown2, + duration: 5000, + event: { type: ev.countdown2 }, + }) + ) + ), + + Events.handle(ev.countdown2, () => + Composer.pipe( + Messages.send({ id: GameEventType.climax, description: '2...' }), + Scheduler.schedule({ + id: sched.countdown1, + duration: 5000, + event: { type: ev.countdown1 }, + }) + ) + ), + + Events.handle(ev.countdown1, () => + Composer.pipe( + Messages.send({ id: GameEventType.climax, description: '1...' }), + Scheduler.schedule({ + id: sched.resolve, + duration: 5000, + event: { type: ev.resolve }, + }) + ) + ), + + Events.handle(ev.resolve, () => + Composer.do(({ get, pipe }) => { + const s = get(settings); + if (!s) return; + + if (Math.random() * 100 <= s.climaxChance) { + const ruin = Math.random() * 100 <= s.ruinChance; + if (ruin) { + pipe(Phase.setPhase(GamePhase.break)); + pipe( + Messages.send({ + id: GameEventType.climax, + title: '$HANDS OFF! Ruin your orgasm!', + description: undefined, + }) + ); + } else { + pipe(Phase.setPhase(GamePhase.climax)); + pipe( + Messages.send({ + id: GameEventType.climax, + title: 'Cum!', + description: undefined, + }) + ); + } + pipe( + Scheduler.schedule({ + id: sched.end, + duration: 3000, + event: { + type: ev.end, + payload: { countdown: 10, ruin }, + }, + }) + ); + } else { + pipe(Phase.setPhase(GamePhase.break)); + pipe( + Messages.send({ + id: GameEventType.climax, + title: '$HANDS OFF! Do not cum!', + description: undefined, + }) + ); + pipe( + Scheduler.schedule({ + id: sched.end, + duration: 1000, + event: { + type: ev.end, + payload: { countdown: 5, denied: true }, + }, + }) + ); + } + }) + ), + + Events.handle(ev.end, event => + Composer.do(({ pipe }) => { + const { countdown, denied, ruin } = event.payload; + + pipe( + Composer.over(intensityState, (s: IntensityState) => ({ + intensity: Math.max(0, s.intensity - (denied ? 0.2 : 0.1)), + })) + ); + + if (countdown <= 1) { + if (denied) { + pipe( + Messages.send({ + id: GameEventType.climax, + title: 'Good $player. Let yourself cool off', + }) + ); + pipe( + Scheduler.schedule({ + id: sched.cantEnd, + duration: 5000, + event: { type: ev.cantEnd }, + }) + ); + } else { + pipe( + Messages.send({ + id: GameEventType.climax, + title: ruin ? 'Clench in desperation' : 'Good job, $player', + prompts: [ + { + title: 'Leave', + event: { type: ev.leave }, + }, + ], + }) + ); + } + } else { + pipe( + Scheduler.schedule({ + id: sched.end, + duration: 1000, + event: { + type: ev.end, + payload: { countdown: countdown - 1, denied, ruin }, + }, + }) + ); + } + }) + ), + + Events.handle(ev.cantEnd, () => + Messages.send({ + id: GameEventType.climax, + title: 'Leave now.', + prompts: [ + { + title: 'Leave', + event: { type: ev.leave }, + }, + ], + }) + ), + + Events.handle(ev.cant, () => + Composer.do(({ pipe }) => { + pipe( + Messages.send({ + id: GameEventType.climax, + title: "You're pathetic. Stop for a moment", + prompts: undefined, + }) + ); + pipe(Phase.setPhase(GamePhase.break)); + pipe(Composer.set(intensityState.intensity, 0)); + pipe( + Scheduler.schedule({ + id: sched.cantResume, + duration: 20000, + event: { type: ev.cantResume }, + }) + ); + }) + ), + + Events.handle(ev.cantResume, () => + Composer.do(({ get, pipe }) => { + const s = get(settings); + if (!s) return; + pipe( + Messages.send({ + id: GameEventType.climax, + title: 'Start to $stroke again', + duration: 5000, + }) + ); + pipe(Pace.setPace(s.minPace)); + pipe(Phase.setPhase(GamePhase.active)); + pipe(setBusy(false)); + }) + ), + + Events.handle(ev.leave, () => + Composer.do(() => { + window.location.href = '/'; + }) + ) + ), +}; diff --git a/src/game/plugins/dice/doublePace.ts b/src/game/plugins/dice/doublePace.ts new file mode 100644 index 0000000..59a8e29 --- /dev/null +++ b/src/game/plugins/dice/doublePace.ts @@ -0,0 +1,91 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import Pace from '../pace'; +import { GameEvent as GameEventType } from '../../../types'; +import { round } from '../../../utils'; +import { PLUGIN_ID, paceState, settings, setBusy, DiceOutcome } from './types'; +import { doRandomPace } from './randomPace'; + +const ev = { + doublePace: getEventKey(PLUGIN_ID, 'doublePace'), + step2: getEventKey(PLUGIN_ID, 'doublePace.2'), + step3: getEventKey(PLUGIN_ID, 'doublePace.3'), + done: getEventKey(PLUGIN_ID, 'doublePace.done'), +}; + +const sched = { + step2: getScheduleKey(PLUGIN_ID, 'doublePace.2'), + step3: getScheduleKey(PLUGIN_ID, 'doublePace.3'), + done: getScheduleKey(PLUGIN_ID, 'doublePace.done'), +}; + +export const doublePaceOutcome: DiceOutcome = { + id: GameEventType.doublePace, + check: intensity => intensity >= 20, + scheduleKeys: Object.values(sched), + pipes: Composer.pipe( + Events.handle(ev.doublePace, () => + Composer.do(({ get, pipe }) => { + const pace = get(paceState)?.pace ?? 1; + const s = get(settings); + if (!s) return; + const newPace = Math.min(round(pace * 2), s.maxPace); + pipe(Pace.setPace(newPace)); + pipe( + Messages.send({ + id: GameEventType.doublePace, + title: 'Double pace!', + description: '3...', + }) + ); + pipe( + Scheduler.schedule({ + id: sched.step2, + duration: 3000, + event: { type: ev.step2 }, + }) + ); + }) + ), + Events.handle(ev.step2, () => + Composer.pipe( + Messages.send({ + id: GameEventType.doublePace, + description: '2...', + }), + Scheduler.schedule({ + id: sched.step3, + duration: 3000, + event: { type: ev.step3 }, + }) + ) + ), + Events.handle(ev.step3, () => + Composer.pipe( + Messages.send({ + id: GameEventType.doublePace, + description: '1...', + }), + Scheduler.schedule({ + id: sched.done, + duration: 3000, + event: { type: ev.done }, + }) + ) + ), + Events.handle(ev.done, () => + Composer.pipe( + Messages.send({ + id: GameEventType.doublePace, + title: 'Done! Back to normal pace', + description: undefined, + duration: 5000, + }), + doRandomPace(), + setBusy(false) + ) + ) + ), +}; diff --git a/src/game/plugins/dice/edge.ts b/src/game/plugins/dice/edge.ts new file mode 100644 index 0000000..396469e --- /dev/null +++ b/src/game/plugins/dice/edge.ts @@ -0,0 +1,47 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import Pace from '../pace'; +import { GameEvent as GameEventType } from '../../../types'; +import { PLUGIN_ID, dice, settings, setBusy, DiceOutcome } from './types'; + +const ev = { + edge: getEventKey(PLUGIN_ID, 'edge'), + done: getEventKey(PLUGIN_ID, 'edge.done'), +}; + +const sched = { + edge: getScheduleKey(PLUGIN_ID, 'edge'), +}; + +export const edgeOutcome: DiceOutcome = { + id: GameEventType.edge, + check: (intensity, edged) => intensity >= 90 && !edged, + scheduleKeys: Object.values(sched), + pipes: Composer.pipe( + Events.handle(ev.edge, () => + Composer.do(({ get, set, pipe }) => { + const s = get(settings); + if (!s) return; + set(dice.state.edged, true); + pipe(Pace.setPace(s.minPace)); + pipe( + Messages.send({ + id: GameEventType.edge, + title: `You should be getting close to the edge. Don't cum yet.`, + duration: 10000, + }) + ); + pipe( + Scheduler.schedule({ + id: sched.edge, + duration: 10000, + event: { type: ev.done }, + }) + ); + }) + ), + Events.handle(ev.done, () => setBusy(false)) + ), +}; diff --git a/src/game/plugins/dice/emergencyStop.ts b/src/game/plugins/dice/emergencyStop.ts new file mode 100644 index 0000000..9756c50 --- /dev/null +++ b/src/game/plugins/dice/emergencyStop.ts @@ -0,0 +1,103 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { Pipe } from '../../../engine/State'; +import Phase, { GamePhase } from '../phase'; +import Pace from '../pace'; +import { IntensityState } from '../intensity'; +import { PLUGIN_ID, intensityState, settings, setBusy } from './types'; + +const ev = { + emergencyStop: getEventKey(PLUGIN_ID, 'emergencyStop'), + countdown: getEventKey(PLUGIN_ID, 'emergency.countdown'), + resume: getEventKey(PLUGIN_ID, 'emergency.resume'), +}; + +const sched = { + calm: getScheduleKey(PLUGIN_ID, 'emergency.calm'), + countdown: getScheduleKey(PLUGIN_ID, 'emergency.countdown'), + resume: getScheduleKey(PLUGIN_ID, 'emergency.resume'), +}; + +export const emergencyStopScheduleKeys: string[] = Object.values(sched); + +export const emergencyStopPipes: Pipe = Composer.pipe( + Events.handle(ev.emergencyStop, () => + Composer.do(({ get, pipe }) => { + const s = get(settings); + const i = (get(intensityState)?.intensity ?? 0) * 100; + if (!s) return; + + const timeToCalmDown = Math.ceil((i * 500 + 10000) / 1000); + + pipe(Phase.setPhase(GamePhase.break)); + pipe( + Messages.send({ + id: 'emergency-stop', + title: 'Calm down with your $hands off.', + }) + ); + pipe( + Composer.over(intensityState, (st: IntensityState) => ({ + intensity: Math.max(0, st.intensity - 0.3), + })) + ); + pipe(Pace.setPace(s.minPace)); + pipe( + Scheduler.schedule({ + id: sched.calm, + duration: 5000, + event: { + type: ev.countdown, + payload: { remaining: timeToCalmDown }, + }, + }) + ); + }) + ), + + Events.handle(ev.countdown, event => + Composer.do(({ pipe }) => { + const { remaining } = event.payload; + if (remaining <= 0) { + pipe( + Messages.send({ + id: 'emergency-stop', + title: 'Put your $hands back.', + description: undefined, + duration: 5000, + }) + ); + pipe( + Scheduler.schedule({ + id: sched.resume, + duration: 2000, + event: { type: ev.resume }, + }) + ); + } else { + pipe( + Messages.send({ + id: 'emergency-stop', + description: `${remaining}...`, + }) + ); + pipe( + Scheduler.schedule({ + id: sched.countdown, + duration: 1000, + event: { + type: ev.countdown, + payload: { remaining: remaining - 1 }, + }, + }) + ); + } + }) + ), + + Events.handle(ev.resume, () => + Composer.pipe(Phase.setPhase(GamePhase.active), setBusy(false)) + ) +); diff --git a/src/game/plugins/dice/halfPace.ts b/src/game/plugins/dice/halfPace.ts new file mode 100644 index 0000000..ad97ed7 --- /dev/null +++ b/src/game/plugins/dice/halfPace.ts @@ -0,0 +1,93 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import Pace from '../pace'; +import { GameEvent as GameEventType } from '../../../types'; +import { round } from '../../../utils'; +import { PLUGIN_ID, paceState, settings, setBusy, DiceOutcome } from './types'; +import { doRandomPace } from './randomPace'; + +const ev = { + halfPace: getEventKey(PLUGIN_ID, 'halfPace'), + step2: getEventKey(PLUGIN_ID, 'halfPace.2'), + step3: getEventKey(PLUGIN_ID, 'halfPace.3'), + done: getEventKey(PLUGIN_ID, 'halfPace.done'), +}; + +const sched = { + step2: getScheduleKey(PLUGIN_ID, 'halfPace.2'), + step3: getScheduleKey(PLUGIN_ID, 'halfPace.3'), + done: getScheduleKey(PLUGIN_ID, 'halfPace.done'), +}; + +export const halfPaceOutcome: DiceOutcome = { + id: GameEventType.halfPace, + check: intensity => intensity >= 10 && intensity <= 50, + scheduleKeys: Object.values(sched), + pipes: Composer.pipe( + Events.handle(ev.halfPace, () => + Composer.do(({ get, pipe }) => { + const pace = get(paceState)?.pace ?? 1; + const s = get(settings); + if (!s) return; + const newPace = Math.max(round(pace / 2), s.minPace); + pipe(Pace.setPace(newPace)); + const duration = Math.ceil(Math.random() * 20000) + 12000; + const portion = duration / 3; + pipe( + Messages.send({ + id: GameEventType.halfPace, + title: 'Half pace!', + description: '3...', + }) + ); + pipe( + Scheduler.schedule({ + id: sched.step2, + duration: portion, + event: { type: ev.step2, payload: { portion } }, + }) + ); + }) + ), + Events.handle(ev.step2, event => + Composer.pipe( + Messages.send({ + id: GameEventType.halfPace, + description: '2...', + }), + Scheduler.schedule({ + id: sched.step3, + duration: event.payload.portion, + event: { type: ev.step3, payload: event.payload }, + }) + ) + ), + Events.handle(ev.step3, event => + Composer.pipe( + Messages.send({ + id: GameEventType.halfPace, + description: '1...', + }), + Scheduler.schedule({ + id: sched.done, + duration: event.payload.portion, + event: { type: ev.done }, + }) + ) + ), + Events.handle(ev.done, () => + Composer.pipe( + Messages.send({ + id: GameEventType.halfPace, + title: 'Done! Back to normal pace', + description: undefined, + duration: 5000, + }), + doRandomPace(), + setBusy(false) + ) + ) + ), +}; diff --git a/src/game/plugins/dice/pause.ts b/src/game/plugins/dice/pause.ts new file mode 100644 index 0000000..1006678 --- /dev/null +++ b/src/game/plugins/dice/pause.ts @@ -0,0 +1,55 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import Phase, { GamePhase } from '../phase'; +import { GameEvent as GameEventType } from '../../../types'; +import { PLUGIN_ID, intensityState, setBusy, DiceOutcome } from './types'; + +const ev = { + pause: getEventKey(PLUGIN_ID, 'pause'), + resume: getEventKey(PLUGIN_ID, 'pause.resume'), +}; + +const sched = { + pause: getScheduleKey(PLUGIN_ID, 'pause'), +}; + +export const pauseOutcome: DiceOutcome = { + id: GameEventType.pause, + check: intensity => intensity >= 15, + scheduleKeys: Object.values(sched), + pipes: Composer.pipe( + Events.handle(ev.pause, () => + Composer.do(({ get, pipe }) => { + const i = (get(intensityState)?.intensity ?? 0) * 100; + pipe( + Messages.send({ + id: GameEventType.pause, + title: 'Stop stroking!', + }) + ); + pipe(Phase.setPhase(GamePhase.break)); + const duration = Math.ceil(-100 * i + 12000); + pipe( + Scheduler.schedule({ + id: sched.pause, + duration, + event: { type: ev.resume }, + }) + ); + }) + ), + Events.handle(ev.resume, () => + Composer.pipe( + Messages.send({ + id: GameEventType.pause, + title: 'Start stroking again!', + duration: 5000, + }), + Phase.setPhase(GamePhase.active), + setBusy(false) + ) + ) + ), +}; diff --git a/src/game/plugins/dice/randomGrip.ts b/src/game/plugins/dice/randomGrip.ts new file mode 100644 index 0000000..03aadf8 --- /dev/null +++ b/src/game/plugins/dice/randomGrip.ts @@ -0,0 +1,60 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { GameEvent as GameEventType } from '../../../types'; +import { + PLUGIN_ID, + dice, + Paws, + PawLabels, + setBusy, + DiceOutcome, +} from './types'; + +const ev = { + randomGrip: getEventKey(PLUGIN_ID, 'randomGrip'), + done: getEventKey(PLUGIN_ID, 'randomGrip.done'), +}; + +const sched = { + done: getScheduleKey(PLUGIN_ID, 'randomGrip'), +}; + +export const randomGripOutcome: DiceOutcome = { + id: GameEventType.randomGrip, + check: () => true, + scheduleKeys: Object.values(sched), + pipes: Composer.pipe( + Events.handle(ev.randomGrip, () => + Composer.do(({ get, set, pipe }) => { + const state = get(dice.state); + if (!state) return; + const currentPaws = state.paws; + let newPaws: Paws; + const seed = Math.random(); + if (seed < 0.33) + newPaws = currentPaws === Paws.both ? Paws.left : Paws.both; + else if (seed < 0.66) + newPaws = currentPaws === Paws.left ? Paws.right : Paws.left; + else newPaws = currentPaws === Paws.right ? Paws.both : Paws.right; + set(dice.state.paws, newPaws); + pipe( + Messages.send({ + id: GameEventType.randomGrip, + title: `Grip changed to ${PawLabels[newPaws]}!`, + duration: 5000, + }) + ); + pipe( + Scheduler.schedule({ + id: sched.done, + duration: 10000, + event: { type: ev.done }, + }) + ); + }) + ), + Events.handle(ev.done, () => setBusy(false)) + ), +}; diff --git a/src/game/plugins/dice/randomPace.ts b/src/game/plugins/dice/randomPace.ts new file mode 100644 index 0000000..0a8f8ff --- /dev/null +++ b/src/game/plugins/dice/randomPace.ts @@ -0,0 +1,65 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { Pipe } from '../../../engine/State'; +import Pace from '../pace'; +import { GameEvent as GameEventType } from '../../../types'; +import { intensityToPaceRange, round } from '../../../utils'; +import { + PLUGIN_ID, + intensityState, + settings, + setBusy, + DiceOutcome, +} from './types'; + +const ev = { + randomPace: getEventKey(PLUGIN_ID, 'randomPace'), + done: getEventKey(PLUGIN_ID, 'randomPace.done'), +}; + +const sched = { + randomPace: getScheduleKey(PLUGIN_ID, 'randomPace'), +}; + +export const doRandomPace = (): Pipe => + Composer.do(({ get, pipe }) => { + const i = get(intensityState)?.intensity ?? 0; + const s = get(settings); + if (!s) return; + const { min, max } = intensityToPaceRange( + i * 100, + s.steepness, + s.timeshift, + { min: s.minPace, max: s.maxPace } + ); + const newPace = round(Math.random() * (max - min) + min); + pipe(Pace.setPace(newPace)); + pipe( + Messages.send({ + id: GameEventType.randomPace, + title: `Pace changed to ${newPace}!`, + duration: 5000, + }) + ); + }); + +export const randomPaceOutcome: DiceOutcome = { + id: GameEventType.randomPace, + check: () => true, + scheduleKeys: Object.values(sched), + pipes: Composer.pipe( + Events.handle(ev.randomPace, () => + Composer.pipe( + doRandomPace(), + Scheduler.schedule({ + id: sched.randomPace, + duration: 9000, + event: { type: ev.done }, + }) + ) + ), + Events.handle(ev.done, () => setBusy(false)) + ), +}; diff --git a/src/game/plugins/dice/risingPace.ts b/src/game/plugins/dice/risingPace.ts new file mode 100644 index 0000000..7f02d73 --- /dev/null +++ b/src/game/plugins/dice/risingPace.ts @@ -0,0 +1,127 @@ +import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Messages } from '../../../engine/pipes/Messages'; +import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import Pace from '../pace'; +import { GameEvent as GameEventType } from '../../../types'; +import { intensityToPaceRange, round } from '../../../utils'; +import { + PLUGIN_ID, + intensityState, + settings, + setBusy, + DiceOutcome, +} from './types'; +import { doRandomPace } from './randomPace'; + +const ev = { + risingPace: getEventKey(PLUGIN_ID, 'risingPace'), + step: getEventKey(PLUGIN_ID, 'risingPace.step'), + hold: getEventKey(PLUGIN_ID, 'risingPace.hold'), + done: getEventKey(PLUGIN_ID, 'risingPace.done'), +}; + +const sched = { + step: getScheduleKey(PLUGIN_ID, 'risingPace.step'), + hold: getScheduleKey(PLUGIN_ID, 'risingPace.hold'), + done: getScheduleKey(PLUGIN_ID, 'risingPace.done'), +}; + +export const risingPaceOutcome: DiceOutcome = { + id: GameEventType.risingPace, + check: intensity => intensity >= 30, + scheduleKeys: Object.values(sched), + pipes: Composer.pipe( + Events.handle(ev.risingPace, () => + Composer.do(({ get, pipe }) => { + const i = (get(intensityState)?.intensity ?? 0) * 100; + const s = get(settings); + if (!s) return; + + pipe( + Messages.send({ + id: GameEventType.risingPace, + title: 'Rising pace strokes!', + }) + ); + + const acceleration = Math.round(100 / Math.min(i, 35)); + const { max } = intensityToPaceRange(i, s.steepness, s.timeshift, { + min: s.minPace, + max: s.maxPace, + }); + const portion = (max - s.minPace) / acceleration; + + pipe(Pace.setPace(s.minPace)); + pipe( + Scheduler.schedule({ + id: sched.step, + duration: 10000, + event: { + type: ev.step, + payload: { + current: s.minPace, + portion, + remaining: acceleration, + }, + }, + }) + ); + }) + ), + Events.handle(ev.step, event => + Composer.do(({ pipe }) => { + const { current, portion, remaining } = event.payload; + const newPace = round(current + portion); + pipe(Pace.setPace(newPace)); + pipe( + Messages.send({ + id: GameEventType.risingPace, + title: `Pace rising to ${newPace}!`, + duration: 5000, + }) + ); + + if (remaining <= 1) { + pipe( + Scheduler.schedule({ + id: sched.hold, + duration: 10000, + event: { type: ev.hold }, + }) + ); + } else { + pipe( + Scheduler.schedule({ + id: sched.step, + duration: 10000, + event: { + type: ev.step, + payload: { + current: newPace, + portion, + remaining: remaining - 1, + }, + }, + }) + ); + } + }) + ), + Events.handle(ev.hold, () => + Composer.pipe( + Messages.send({ + id: GameEventType.risingPace, + title: 'Stay at this pace for a bit', + duration: 5000, + }), + Scheduler.schedule({ + id: sched.done, + duration: 15000, + event: { type: ev.done }, + }) + ) + ), + Events.handle(ev.done, () => Composer.pipe(doRandomPace(), setBusy(false))) + ), +}; diff --git a/src/game/plugins/dice/types.ts b/src/game/plugins/dice/types.ts new file mode 100644 index 0000000..10678df --- /dev/null +++ b/src/game/plugins/dice/types.ts @@ -0,0 +1,55 @@ +import { Pipe } from '../../../engine/State'; +import { Composer } from '../../../engine/Composer'; +import { pluginPaths } from '../../../engine/plugins/Plugins'; +import { typedPath } from '../../../engine/Lens'; +import { GameContext } from '../../../engine'; +import { IntensityState } from '../intensity'; +import { PaceState } from '../pace'; +import { Settings } from '../../../settings'; +import { PhaseState } from '../phase'; +import { GameEvent as GameEventType } from '../../../types'; + +export const PLUGIN_ID = 'core.dice'; + +export enum Paws { + left = 'left', + right = 'right', + both = 'both', +} + +export const PawLabels: Record = { + left: 'Left', + right: 'Right', + both: 'Both', +}; + +export type DiceState = { + edged: boolean; + paws: Paws; + busy: boolean; + rollTimer: number; +}; + +export const dice = pluginPaths(PLUGIN_ID); +export const gameContext = typedPath(['context']); +export const phaseState = typedPath(['state', 'core.phase']); +export const paceState = typedPath(['state', 'core.pace']); +export const intensityState = typedPath([ + 'state', + 'core.intensity', +]); +export const settings = typedPath(['context', 'settings']); + +export const setBusy = (val: boolean): Pipe => + Composer.set(dice.state.busy, val); + +export type DiceOutcome = { + id: GameEventType; + check: ( + intensity: number, + edged: boolean, + events: GameEventType[] + ) => boolean; + pipes: Pipe; + scheduleKeys: string[]; +}; diff --git a/src/game/plugins/hypno.ts b/src/game/plugins/hypno.ts new file mode 100644 index 0000000..b732c72 --- /dev/null +++ b/src/game/plugins/hypno.ts @@ -0,0 +1,75 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine/Composer'; +import { pluginPaths } from '../../engine/plugins/Plugins'; +import { typedPath } from '../../engine/Lens'; +import { GameContext } from '../../engine'; +import { GamePhase, PhaseState } from './phase'; +import Pause from './pause'; +import { IntensityState } from './intensity'; +import { Settings } from '../../settings'; +import { GameHypnoType, HypnoPhrases } from '../../types'; + +const PLUGIN_ID = 'core.hypno'; + +export type HypnoState = { + currentPhrase: number; + timer: number; +}; + +const hypno = pluginPaths(PLUGIN_ID); +const gameContext = typedPath(['context']); +const phaseState = typedPath(['state', 'core.phase']); +const intensityState = typedPath(['state', 'core.intensity']); +const settings = typedPath(['context', 'settings']); + +export default class Hypno { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Hypno', + }, + + activate: Composer.set(hypno.state, { + currentPhrase: 0, + timer: 0, + }), + + update: Pause.whenPlaying( + Composer.do(({ get, set }) => { + const phase = get(phaseState)?.current; + if (phase !== GamePhase.active) return; + + const s = get(settings); + if (!s || s.hypno === GameHypnoType.off) return; + + const i = (get(intensityState)?.intensity ?? 0) * 100; + const delay = 3000 - i * 29; + if (delay <= 0) return; + + const delta = get(gameContext.deltaTime); + const state = get(hypno.state); + if (!state) return; + + const elapsed = state.timer + delta; + if (elapsed < delay) { + set(hypno.state.timer, elapsed); + return; + } + + const phrases = HypnoPhrases[s.hypno]; + if (phrases.length <= 0) return; + + set(hypno.state, { + currentPhrase: Math.floor(Math.random() * phrases.length), + timer: 0, + }); + }) + ), + + deactivate: Composer.set(hypno.state, undefined), + }; + + static get paths() { + return hypno; + } +} diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index 0972b82..02fa76a 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -10,12 +10,18 @@ import PerfOverlay from './perf'; import Image from './image'; import RandomImages from './randomImages'; import Warmup from './warmup'; +import Stroke from './stroke'; +import Dealer from './dealer'; +import Hypno from './hypno'; const plugins = [ Pause, Phase, Pace, Intensity, + Stroke, + Dealer, + Hypno, Image, RandomImages, Warmup, diff --git a/src/game/plugins/intensity.ts b/src/game/plugins/intensity.ts index 2f0fd3d..94c159e 100644 --- a/src/game/plugins/intensity.ts +++ b/src/game/plugins/intensity.ts @@ -1,13 +1,12 @@ import type { Plugin } from '../../engine/plugins/Plugins'; -import { sdk } from '../../engine/sdk'; +import { Composer } from '../../engine/Composer'; +import { pluginPaths } from '../../engine/plugins/Plugins'; import { typedPath } from '../../engine/Lens'; import { Settings } from '../../settings'; import Phase, { GamePhase } from './phase'; import Pause from './pause'; import { GameContext } from '../../engine'; -const { Composer, pluginPaths } = sdk; - declare module '../../engine/sdk' { interface PluginSDK { Intensity: typeof Intensity; diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index dc17257..23bf7e2 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -1,9 +1,8 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { Pipe, PipeTransformer } from '../../engine/State'; -import { sdk } from '../../engine/sdk'; -import { getEventKey } from '../../engine/pipes/Events'; - -const { Composer, Events, pluginPaths } = sdk; +import { Composer } from '../../engine/Composer'; +import { Events, getEventKey } from '../../engine/pipes/Events'; +import { pluginPaths } from '../../engine/plugins/Plugins'; declare module '../../engine/sdk' { interface PluginSDK { diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts index ed2c351..d62ab9a 100644 --- a/src/game/plugins/perf.ts +++ b/src/game/plugins/perf.ts @@ -1,8 +1,8 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import type { PerfMetrics, PluginHookPhase } from '../../engine/pipes/Perf'; -import { sdk } from '../../engine/sdk'; - -const { Composer, Perf, pluginPaths } = sdk; +import { Composer } from '../../engine/Composer'; +import { Perf } from '../../engine/pipes/Perf'; +import { pluginPaths } from '../../engine/plugins/Plugins'; const PLUGIN_ID = 'core.perf_overlay'; const ELEMENT_ATTR = 'data-plugin-id'; diff --git a/src/game/plugins/stroke.ts b/src/game/plugins/stroke.ts new file mode 100644 index 0000000..cdf653a --- /dev/null +++ b/src/game/plugins/stroke.ts @@ -0,0 +1,80 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine/Composer'; +import { pluginPaths } from '../../engine/plugins/Plugins'; +import { typedPath } from '../../engine/Lens'; +import { GameContext } from '../../engine'; +import { PhaseState, GamePhase } from './phase'; +import Pause from './pause'; +import { PaceState } from './pace'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Stroke: typeof Stroke; + } +} + +const PLUGIN_ID = 'core.stroke'; + +export enum StrokeDirection { + up = 'up', + down = 'down', +} + +export type StrokeState = { + stroke: StrokeDirection; + timer: number; +}; + +const stroke = pluginPaths(PLUGIN_ID); +const gameContext = typedPath(['context']); +const phaseState = typedPath(['state', 'core.phase']); +const paceState = typedPath(['state', 'core.pace']); + +export default class Stroke { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Stroke', + }, + + activate: Composer.set(stroke.state, { + stroke: StrokeDirection.up, + timer: 0, + }), + + update: Pause.whenPlaying( + Composer.do(({ get, set }) => { + const phase = get(phaseState)?.current; + if (phase !== GamePhase.active && phase !== GamePhase.finale) return; + + const pace = get(paceState)?.pace; + if (!pace || pace <= 0) return; + + const delta = get(gameContext.deltaTime); + const state = get(stroke.state); + if (!state) return; + + const interval = (1 / pace) * 1000; + const elapsed = state.timer + delta; + + if (elapsed >= interval) { + set(stroke.state, { + stroke: + state.stroke === StrokeDirection.up + ? StrokeDirection.down + : StrokeDirection.up, + timer: elapsed - interval, + }); + } else { + set(stroke.state.timer, elapsed); + } + }) + ), + + deactivate: Composer.set(stroke.state, undefined), + }; + + static get paths() { + return stroke; + } +} diff --git a/src/game/test.ts b/src/game/test.ts deleted file mode 100644 index d0f20a0..0000000 --- a/src/game/test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Pipe } from '../engine'; -import { Composer } from '../engine/Composer'; -import { EventContext, getEventKey } from '../engine/pipes/Events'; -import { MessageContext } from '../engine/pipes/Messages'; -import { SchedulerContext } from '../engine/pipes/Scheduler'; - -const MSG_TEST_NAMESPACE = 'core.message_test'; -const messageId = 'test-message'; -const followupId = 'followup-message'; - -export const messageTestPipe: Pipe = Composer.chain(c => { - const { sendMessage } = c.get([ - 'context', - 'core', - 'messages', - ]); - const { handle } = c.get(['context', 'core', 'events']); - const { schedule } = c.get([ - 'context', - 'core', - 'scheduler', - ]); - - return c - .bind(['state', MSG_TEST_NAMESPACE, 'sent'], sent => - Composer.unless(sent, c => - c - .pipe( - sendMessage({ - id: messageId, - title: 'Test Message', - prompts: [ - { - title: 'Acknowledge', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), - }, - }, - { - title: 'Dismiss', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - payload: { id: messageId }, - }, - }, - ], - }) - ) - - .set(['state', MSG_TEST_NAMESPACE, 'sent'], true) - ) - ) - - .pipe( - handle(getEventKey(MSG_TEST_NAMESPACE, 'acknowledgeMessage'), () => - Composer.pipe( - schedule({ - duration: 2000, - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), - }, - }), - sendMessage({ - id: messageId, - duration: 0, - }) - ) - ) - ) - - .pipe( - handle(getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), event => - Composer.pipe(sendMessage({ id: event.payload.id, duration: 0 })) - ) - ) - - .pipe( - handle(getEventKey(MSG_TEST_NAMESPACE, 'followupMessage'), () => - Composer.pipe( - sendMessage({ - id: followupId, - title: 'Follow-up Message', - description: 'Ready', - prompts: [ - { - title: 'Close', - event: { - type: getEventKey(MSG_TEST_NAMESPACE, 'dismissMessage'), - payload: { id: followupId }, - }, - }, - ], - }) - ) - ) - ); -}); From b7aae91ab2827ac20ee6792b6d936fec7b2bceb8 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 13 Feb 2026 22:45:49 +0100 Subject: [PATCH 47/90] added event sequencer --- src/engine/pipes/Scheduler.ts | 69 +++++ src/game/Sequence.ts | 65 +++++ src/game/components/GameEmergencyStop.tsx | 2 +- src/game/components/GameInstructions.tsx | 4 +- src/game/plugins/dealer.ts | 131 ++++----- src/game/plugins/dice/cleanUp.ts | 51 ++-- src/game/plugins/dice/climax.ts | 325 +++++++--------------- src/game/plugins/dice/doublePace.ts | 96 +++---- src/game/plugins/dice/edge.ts | 61 ++-- src/game/plugins/dice/emergencyStop.ts | 103 ------- src/game/plugins/dice/halfPace.ts | 102 +++---- src/game/plugins/dice/pause.ts | 51 +--- src/game/plugins/dice/randomGrip.ts | 77 +++-- src/game/plugins/dice/randomPace.ts | 69 ++--- src/game/plugins/dice/risingPace.ts | 146 +++------- src/game/plugins/dice/types.ts | 31 +-- src/game/plugins/emergencyStop.ts | 75 +++++ src/game/plugins/index.ts | 2 + 18 files changed, 599 insertions(+), 861 deletions(-) create mode 100644 src/game/Sequence.ts delete mode 100644 src/game/plugins/dice/emergencyStop.ts create mode 100644 src/game/plugins/emergencyStop.ts diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 33fc7cb..a07214b 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -25,6 +25,9 @@ export type SchedulerContext = { cancel: PipeTransformer<[string]>; hold: PipeTransformer<[string]>; release: PipeTransformer<[string]>; + holdByPrefix: PipeTransformer<[string]>; + releaseByPrefix: PipeTransformer<[string]>; + cancelByPrefix: PipeTransformer<[string]>; }; export class Scheduler { @@ -55,6 +58,27 @@ export class Scheduler { ({ release }) => release(id) ); } + + static holdByPrefix(prefix: string): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ holdByPrefix }) => holdByPrefix(prefix) + ); + } + + static releaseByPrefix(prefix: string): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ releaseByPrefix }) => releaseByPrefix(prefix) + ); + } + + static cancelByPrefix(prefix: string): Pipe { + return Composer.bind( + ['context', PLUGIN_NAMESPACE], + ({ cancelByPrefix }) => cancelByPrefix(prefix) + ); + } } export const schedulerPipe: Pipe = Composer.pipe( @@ -111,6 +135,24 @@ export const schedulerPipe: Pipe = Composer.pipe( type: getEventKey(PLUGIN_NAMESPACE, 'release'), payload: id, }), + + holdByPrefix: (prefix: string) => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'holdByPrefix'), + payload: prefix, + }), + + releaseByPrefix: (prefix: string) => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'releaseByPrefix'), + payload: prefix, + }), + + cancelByPrefix: (prefix: string) => + Events.dispatch({ + type: getEventKey(PLUGIN_NAMESPACE, 'cancelByPrefix'), + payload: prefix, + }), }), Events.handle(getEventKey(PLUGIN_NAMESPACE, 'schedule'), event => @@ -144,5 +186,32 @@ export const schedulerPipe: Pipe = Composer.pipe( (list = []) => list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) ) + ), + + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'holdByPrefix'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE, 'scheduled'], + (list = []) => + list.map(s => + s.id?.startsWith(event.payload) ? { ...s, held: true } : s + ) + ) + ), + + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'releaseByPrefix'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE, 'scheduled'], + (list = []) => + list.map(s => + s.id?.startsWith(event.payload) ? { ...s, held: false } : s + ) + ) + ), + + Events.handle(getEventKey(PLUGIN_NAMESPACE, 'cancelByPrefix'), event => + Composer.over( + ['state', PLUGIN_NAMESPACE, 'scheduled'], + (list = []) => list.filter(s => !s.id?.startsWith(event.payload)) + ) ) ); diff --git a/src/game/Sequence.ts b/src/game/Sequence.ts new file mode 100644 index 0000000..7ceaa27 --- /dev/null +++ b/src/game/Sequence.ts @@ -0,0 +1,65 @@ +import { Pipe } from '../engine/State'; +import { + Events, + getEventKey, + Messages, + Scheduler, + getScheduleKey, +} from '../engine/pipes'; + +type EventHandler = Parameters[1]; +type MessageInput = Omit[0], 'id'>; +type MessagePrompt = NonNullable[number]; + +export type SequenceScope = { + messageId: string; + on(handler: EventHandler): Pipe; + on(name: string, handler: EventHandler): Pipe; + message(msg: MessageInput): Pipe; + after(duration: number, target: string, payload?: any): Pipe; + prompt(title: string, target: string, payload?: any): MessagePrompt; + dispatch(target: string, payload?: any): Pipe; + eventKey(target: string): string; + scheduleKey(target: string): string; +}; + +export class Sequence { + static for(namespace: string, name: string): SequenceScope { + const rootKey = getEventKey(namespace, name); + const nodeKey = (n: string) => getEventKey(namespace, `${name}.${n}`); + const schedKey = (n: string) => getScheduleKey(namespace, `${name}.${n}`); + + return { + messageId: name, + on(nameOrHandler: any, handler?: any) { + if (typeof nameOrHandler === 'function') + return Events.handle(rootKey, nameOrHandler); + return Events.handle(nodeKey(nameOrHandler), handler); + }, + message: msg => Messages.send({ ...msg, id: name }), + after: (duration, target, payload) => + Scheduler.schedule({ + id: schedKey(target), + duration, + event: { + type: nodeKey(target), + ...(payload !== undefined && { payload }), + }, + }), + prompt: (title, target, payload) => ({ + title, + event: { + type: nodeKey(target), + ...(payload !== undefined && { payload }), + }, + }), + dispatch: (target, payload) => + Events.dispatch({ + type: target ? nodeKey(target) : rootKey, + ...(payload !== undefined && { payload }), + }), + eventKey: nodeKey, + scheduleKey: schedKey, + }; + } +} diff --git a/src/game/components/GameEmergencyStop.tsx b/src/game/components/GameEmergencyStop.tsx index 5a8902a..abb4885 100644 --- a/src/game/components/GameEmergencyStop.tsx +++ b/src/game/components/GameEmergencyStop.tsx @@ -11,7 +11,7 @@ export const GameEmergencyStop = () => { const onStop = useCallback(() => { injectImpulse( - dispatchEvent({ type: getEventKey('core.dice', 'emergencyStop') }) + dispatchEvent({ type: getEventKey('core.emergencyStop', 'stop') }) ); }, [injectImpulse]); diff --git a/src/game/components/GameInstructions.tsx b/src/game/components/GameInstructions.tsx index b5cab27..54411aa 100644 --- a/src/game/components/GameInstructions.tsx +++ b/src/game/components/GameInstructions.tsx @@ -15,7 +15,7 @@ import { WaDivider } from '@awesome.me/webawesome/dist/react'; import { useGameState } from '../hooks'; import { PaceState } from '../plugins/pace'; import { IntensityState } from '../plugins/intensity'; -import { DiceState, Paws, PawLabels } from '../plugins/dealer'; +import { Paws, PawLabels } from '../plugins/dealer'; const StyledGameInstructions = styled.div` display: flex; @@ -67,7 +67,7 @@ export const GameInstructions = () => { const { pace = 0 } = useGameState(['core.pace']) ?? {}; const { intensity = 0 } = useGameState(['core.intensity']) ?? {}; - const { paws = Paws.both } = useGameState(['core.dice']) ?? {}; + const paws = useGameState(['core.dice', 'paws']) ?? Paws.both; const [maxPace] = useSetting('maxPace'); const paceSection = useMemo(() => maxPace / 3, [maxPace]); diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index 0aeece5..5731616 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -2,19 +2,11 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine/Composer'; import { Events, getEventKey } from '../../engine/pipes/Events'; import { Scheduler } from '../../engine/pipes/Scheduler'; -import { GamePhase } from './phase'; +import { Sequence } from '../Sequence'; +import Phase, { GamePhase } from './phase'; import Pause from './pause'; import { GameEvent as GameEventType } from '../../types'; -import { - PLUGIN_ID, - dice, - phaseState, - intensityState, - settings, - gameContext, - Paws, - DiceOutcome, -} from './dice/types'; +import { PLUGIN_ID, dice, settings, DiceOutcome } from './dice/types'; import { edgeOutcome } from './dice/edge'; import { pauseOutcome } from './dice/pause'; import { randomPaceOutcome } from './dice/randomPace'; @@ -24,10 +16,6 @@ import { risingPaceOutcome } from './dice/risingPace'; import { randomGripOutcome } from './dice/randomGrip'; import { cleanUpOutcome } from './dice/cleanUp'; import { climaxOutcome } from './dice/climax'; -import { - emergencyStopPipes, - emergencyStopScheduleKeys, -} from './dice/emergencyStop'; declare module '../../engine/sdk' { interface PluginSDK { @@ -60,10 +48,7 @@ const rollChances: Record = { const eventKeyForOutcome = (id: GameEventType): string => getEventKey(PLUGIN_ID, id); -const allScheduleKeys = [ - ...outcomes.flatMap(o => o.scheduleKeys), - ...emergencyStopScheduleKeys, -]; +const roll = Sequence.for(PLUGIN_ID, 'roll'); export default class Dealer { static plugin: Plugin = { @@ -72,71 +57,58 @@ export default class Dealer { name: 'Dealer', }, - activate: Composer.do(({ get, set }) => { - set(dice.state, { - edged: false, - paws: Paws.both, - busy: false, - rollTimer: 0, - }); - - const s = get(settings); - if (s?.events?.includes(GameEventType.randomGrip)) { - const seed = Math.random(); - let paws = Paws.both; - if (seed < 0.33) paws = Paws.left; - else if (seed < 0.66) paws = Paws.right; - set(dice.state.paws, paws); - } - }), + activate: Composer.pipe( + Composer.set(dice.state, { busy: false }), + Composer.bind(settings, s => + Composer.pipe( + ...outcomes.flatMap(o => + o.activate && s?.events.includes(o.id) ? [o.activate] : [] + ) + ) + ), + roll.after(1000, 'check') + ), update: Composer.pipe( - Pause.whenPlaying( - Composer.do(({ get, set, pipe }) => { - const phase = get(phaseState)?.current; - if (phase !== GamePhase.active) return; - - const state = get(dice.state); - if (!state || state.busy) return; - - const delta = get(gameContext.deltaTime); - const elapsed = state.rollTimer + delta; - - if (elapsed < 1000) { - set(dice.state.rollTimer, elapsed); - return; - } - - set(dice.state.rollTimer, 0); - - const i = (get(intensityState)?.intensity ?? 0) * 100; - const s = get(settings); - if (!s) return; - - for (const outcome of outcomes) { - if (!s.events.includes(outcome.id)) continue; - if (!outcome.check(i, state.edged, s.events)) continue; - - const chance = rollChances[outcome.id]; - if (chance && Math.floor(Math.random() * chance) !== 0) continue; - - set(dice.state.busy, true); - pipe(Events.dispatch({ type: eventKeyForOutcome(outcome.id) })); - return; - } - }) + roll.on('check', () => + Composer.pipe( + Phase.whenPhase( + GamePhase.active, + Composer.do(({ get, set, pipe }) => { + const state = get(dice.state); + if (!state || state.busy) return; + + const s = get(settings); + if (!s) return; + + const frame = get(); + + for (const outcome of outcomes) { + if (!s.events.includes(outcome.id)) continue; + if (outcome.check && !outcome.check(frame)) continue; + + const chance = rollChances[outcome.id]; + if (chance && Math.floor(Math.random() * chance) !== 0) + continue; + + set(dice.state.busy, true); + pipe(Events.dispatch({ type: eventKeyForOutcome(outcome.id) })); + return; + } + }) + ), + roll.after(1000, 'check') + ) ), - ...outcomes.map(o => o.pipes), - - emergencyStopPipes, + ...outcomes.map(o => o.update), - Pause.onPause(() => - Composer.pipe(...allScheduleKeys.map(id => Scheduler.hold(id))) + Phase.onLeave(GamePhase.active, () => + Composer.set(dice.state.busy, false) ), - Pause.onResume(() => - Composer.pipe(...allScheduleKeys.map(id => Scheduler.release(id))) - ) + + Pause.onPause(() => Scheduler.holdByPrefix(PLUGIN_ID)), + Pause.onResume(() => Scheduler.releaseByPrefix(PLUGIN_ID)) ), deactivate: Composer.set(dice.state, undefined), @@ -147,4 +119,5 @@ export default class Dealer { } } -export { Paws, PawLabels, type DiceState } from './dice/types'; +export { Paws, PawLabels } from './dice/randomGrip'; +export { type DiceState } from './dice/types'; diff --git a/src/game/plugins/dice/cleanUp.ts b/src/game/plugins/dice/cleanUp.ts index 8f192fe..55ed3c6 100644 --- a/src/game/plugins/dice/cleanUp.ts +++ b/src/game/plugins/dice/cleanUp.ts @@ -1,47 +1,40 @@ import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; +import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; import { GameEvent as GameEventType, CleanUpDescriptions, } from '../../../types'; -import { PLUGIN_ID, settings, setBusy, DiceOutcome } from './types'; +import { + PLUGIN_ID, + intensityState, + settings, + setBusy, + DiceOutcome, +} from './types'; -const ev = { - cleanUp: getEventKey(PLUGIN_ID, 'cleanUp'), - done: getEventKey(PLUGIN_ID, 'cleanUp.done'), -}; +const seq = Sequence.for(PLUGIN_ID, 'cleanUp'); export const cleanUpOutcome: DiceOutcome = { id: GameEventType.cleanUp, - check: intensity => intensity >= 75, - scheduleKeys: [], - pipes: Composer.pipe( - Events.handle(ev.cleanUp, () => - Composer.do(({ get, pipe }) => { - const s = get(settings); - if (!s) return; - pipe(Phase.setPhase(GamePhase.break)); - pipe( - Messages.send({ - id: GameEventType.cleanUp, + check: frame => + (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 75, + update: Composer.pipe( + seq.on(() => + Composer.bind(settings, s => + Composer.pipe( + Phase.setPhase(GamePhase.break), + seq.message({ title: `Lick up any ${CleanUpDescriptions[s.body]}`, duration: undefined, - prompts: [ - { - title: `I'm done, $master`, - event: { type: ev.done }, - }, - ], + prompts: [seq.prompt(`I'm done, $master`, 'done')], }) - ); - }) + ) + ) ), - Events.handle(ev.done, () => + seq.on('done', () => Composer.pipe( - Messages.send({ - id: GameEventType.cleanUp, + seq.message({ title: 'Good $player', duration: 5000, prompts: undefined, diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts index 5473e97..49aac09 100644 --- a/src/game/plugins/dice/climax.ts +++ b/src/game/plugins/dice/climax.ts @@ -1,7 +1,5 @@ import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; -import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; import Pace from '../pace'; import { GameEvent as GameEventType } from '../../../types'; @@ -13,277 +11,150 @@ import { setBusy, DiceOutcome, } from './types'; +import { edged } from './edge'; -const ev = { - climax: getEventKey(PLUGIN_ID, 'climax'), - edging: getEventKey(PLUGIN_ID, 'climax.edging'), - cant: getEventKey(PLUGIN_ID, 'climax.cant'), - countdown3: getEventKey(PLUGIN_ID, 'climax.countdown3'), - countdown2: getEventKey(PLUGIN_ID, 'climax.countdown2'), - countdown1: getEventKey(PLUGIN_ID, 'climax.countdown1'), - resolve: getEventKey(PLUGIN_ID, 'climax.resolve'), - end: getEventKey(PLUGIN_ID, 'climax.end'), - cantResume: getEventKey(PLUGIN_ID, 'climax.cantResume'), - cantEnd: getEventKey(PLUGIN_ID, 'climax.cantEnd'), - leave: getEventKey(PLUGIN_ID, 'leave'), -}; - -const sched = { - countdown3: getScheduleKey(PLUGIN_ID, 'climax.countdown3'), - countdown2: getScheduleKey(PLUGIN_ID, 'climax.countdown2'), - countdown1: getScheduleKey(PLUGIN_ID, 'climax.countdown1'), - resolve: getScheduleKey(PLUGIN_ID, 'climax.resolve'), - end: getScheduleKey(PLUGIN_ID, 'climax.end'), - cantResume: getScheduleKey(PLUGIN_ID, 'climax.cantResume'), - cantEnd: getScheduleKey(PLUGIN_ID, 'climax.cantEnd'), -}; +const seq = Sequence.for(PLUGIN_ID, 'climax'); export const climaxOutcome: DiceOutcome = { id: GameEventType.climax, - check: (intensity, edged, events) => - intensity >= 100 && (!events.includes(GameEventType.edge) || edged), - scheduleKeys: Object.values(sched), - pipes: Composer.pipe( - Events.handle(ev.climax, () => + check: frame => { + const i = (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100; + const s = Composer.get(settings)(frame); + return ( + i >= 100 && + (!s?.events.includes(GameEventType.edge) || !!Composer.get(edged)(frame)) + ); + }, + update: Composer.pipe( + seq.on(() => Composer.pipe( Phase.setPhase(GamePhase.finale), - Messages.send({ - id: GameEventType.climax, + seq.message({ title: 'Are you edging?', prompts: [ - { - title: "I'm edging, $master", - event: { type: ev.edging }, - }, - { - title: "I can't", - event: { type: ev.cant }, - }, + seq.prompt("I'm edging, $master", 'edging'), + seq.prompt("I can't", 'cant'), ], }) ) ), - Events.handle(ev.edging, () => - Composer.do(({ get, pipe }) => { - const s = get(settings); - if (!s) return; - pipe( - Messages.send({ - id: GameEventType.climax, + seq.on('edging', () => + Composer.bind(settings, s => + Composer.pipe( + seq.message({ title: 'Stay on the edge, $player', prompts: undefined, - }) - ); - pipe(Pace.setPace(s.minPace)); - pipe( - Scheduler.schedule({ - id: sched.countdown3, - duration: 3000, - event: { type: ev.countdown3 }, - }) - ); - }) + }), + Pace.setPace(s.minPace), + seq.after(3000, 'countdown3') + ) + ) ), - Events.handle(ev.countdown3, () => + seq.on('countdown3', () => Composer.pipe( - Messages.send({ id: GameEventType.climax, description: '3...' }), - Scheduler.schedule({ - id: sched.countdown2, - duration: 5000, - event: { type: ev.countdown2 }, - }) + seq.message({ description: '3...' }), + seq.after(5000, 'countdown2') ) ), - Events.handle(ev.countdown2, () => + seq.on('countdown2', () => Composer.pipe( - Messages.send({ id: GameEventType.climax, description: '2...' }), - Scheduler.schedule({ - id: sched.countdown1, - duration: 5000, - event: { type: ev.countdown1 }, - }) + seq.message({ description: '2...' }), + seq.after(5000, 'countdown1') ) ), - Events.handle(ev.countdown1, () => + seq.on('countdown1', () => Composer.pipe( - Messages.send({ id: GameEventType.climax, description: '1...' }), - Scheduler.schedule({ - id: sched.resolve, - duration: 5000, - event: { type: ev.resolve }, - }) + seq.message({ description: '1...' }), + seq.after(5000, 'resolve') ) ), - Events.handle(ev.resolve, () => - Composer.do(({ get, pipe }) => { - const s = get(settings); - if (!s) return; - + seq.on('resolve', () => + Composer.bind(settings, s => { if (Math.random() * 100 <= s.climaxChance) { const ruin = Math.random() * 100 <= s.ruinChance; - if (ruin) { - pipe(Phase.setPhase(GamePhase.break)); - pipe( - Messages.send({ - id: GameEventType.climax, - title: '$HANDS OFF! Ruin your orgasm!', - description: undefined, - }) - ); - } else { - pipe(Phase.setPhase(GamePhase.climax)); - pipe( - Messages.send({ - id: GameEventType.climax, - title: 'Cum!', - description: undefined, - }) - ); - } - pipe( - Scheduler.schedule({ - id: sched.end, - duration: 3000, - event: { - type: ev.end, - payload: { countdown: 10, ruin }, - }, - }) - ); - } else { - pipe(Phase.setPhase(GamePhase.break)); - pipe( - Messages.send({ - id: GameEventType.climax, - title: '$HANDS OFF! Do not cum!', + return Composer.pipe( + Phase.setPhase(ruin ? GamePhase.break : GamePhase.climax), + seq.message({ + title: ruin ? '$HANDS OFF! Ruin your orgasm!' : 'Cum!', description: undefined, - }) - ); - pipe( - Scheduler.schedule({ - id: sched.end, - duration: 1000, - event: { - type: ev.end, - payload: { countdown: 5, denied: true }, - }, - }) + }), + seq.after(3000, 'end', { countdown: 10, ruin }) ); } + return Composer.pipe( + Phase.setPhase(GamePhase.break), + seq.message({ + title: '$HANDS OFF! Do not cum!', + description: undefined, + }), + seq.after(1000, 'end', { countdown: 5, denied: true }) + ); }) ), - Events.handle(ev.end, event => - Composer.do(({ pipe }) => { - const { countdown, denied, ruin } = event.payload; - - pipe( - Composer.over(intensityState, (s: IntensityState) => ({ - intensity: Math.max(0, s.intensity - (denied ? 0.2 : 0.1)), - })) - ); - - if (countdown <= 1) { - if (denied) { - pipe( - Messages.send({ - id: GameEventType.climax, - title: 'Good $player. Let yourself cool off', - }) - ); - pipe( - Scheduler.schedule({ - id: sched.cantEnd, - duration: 5000, - event: { type: ev.cantEnd }, - }) - ); - } else { - pipe( - Messages.send({ - id: GameEventType.climax, - title: ruin ? 'Clench in desperation' : 'Good job, $player', - prompts: [ - { - title: 'Leave', - event: { type: ev.leave }, - }, - ], - }) - ); - } - } else { - pipe( - Scheduler.schedule({ - id: sched.end, - duration: 1000, - event: { - type: ev.end, - payload: { countdown: countdown - 1, denied, ruin }, - }, - }) + seq.on('end', event => { + const { countdown, denied, ruin } = event.payload; + const decay = Composer.over(intensityState, (s: IntensityState) => ({ + intensity: Math.max(0, s.intensity - (denied ? 0.2 : 0.1)), + })); + if (countdown <= 1) { + if (denied) { + return Composer.pipe( + decay, + seq.message({ title: 'Good $player. Let yourself cool off' }), + seq.after(5000, 'cantEnd') ); } - }) - ), - - Events.handle(ev.cantEnd, () => - Messages.send({ - id: GameEventType.climax, + return Composer.pipe( + decay, + seq.message({ + title: ruin ? 'Clench in desperation' : 'Good job, $player', + prompts: [seq.prompt('Leave', 'leave')], + }) + ); + } + return Composer.pipe( + decay, + seq.after(1000, 'end', { countdown: countdown - 1, denied, ruin }) + ); + }), + + seq.on('cantEnd', () => + seq.message({ title: 'Leave now.', - prompts: [ - { - title: 'Leave', - event: { type: ev.leave }, - }, - ], + prompts: [seq.prompt('Leave', 'leave')], }) ), - Events.handle(ev.cant, () => - Composer.do(({ pipe }) => { - pipe( - Messages.send({ - id: GameEventType.climax, - title: "You're pathetic. Stop for a moment", - prompts: undefined, - }) - ); - pipe(Phase.setPhase(GamePhase.break)); - pipe(Composer.set(intensityState.intensity, 0)); - pipe( - Scheduler.schedule({ - id: sched.cantResume, - duration: 20000, - event: { type: ev.cantResume }, - }) - ); - }) + seq.on('cant', () => + Composer.pipe( + seq.message({ + title: "You're pathetic. Stop for a moment", + prompts: undefined, + }), + Phase.setPhase(GamePhase.break), + Composer.set(intensityState.intensity, 0), + seq.after(20000, 'cantResume') + ) ), - Events.handle(ev.cantResume, () => - Composer.do(({ get, pipe }) => { - const s = get(settings); - if (!s) return; - pipe( - Messages.send({ - id: GameEventType.climax, - title: 'Start to $stroke again', - duration: 5000, - }) - ); - pipe(Pace.setPace(s.minPace)); - pipe(Phase.setPhase(GamePhase.active)); - pipe(setBusy(false)); - }) + seq.on('cantResume', () => + Composer.bind(settings, s => + Composer.pipe( + seq.message({ title: 'Start to $stroke again', duration: 5000 }), + Pace.setPace(s.minPace), + Phase.setPhase(GamePhase.active), + setBusy(false) + ) + ) ), - Events.handle(ev.leave, () => + seq.on('leave', () => Composer.do(() => { window.location.href = '/'; }) diff --git a/src/game/plugins/dice/doublePace.ts b/src/game/plugins/dice/doublePace.ts index 59a8e29..d0338c2 100644 --- a/src/game/plugins/dice/doublePace.ts +++ b/src/game/plugins/dice/doublePace.ts @@ -1,84 +1,52 @@ import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; -import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { Sequence } from '../../Sequence'; import Pace from '../pace'; import { GameEvent as GameEventType } from '../../../types'; import { round } from '../../../utils'; -import { PLUGIN_ID, paceState, settings, setBusy, DiceOutcome } from './types'; +import { + PLUGIN_ID, + paceState, + intensityState, + settings, + setBusy, + DiceOutcome, +} from './types'; import { doRandomPace } from './randomPace'; -const ev = { - doublePace: getEventKey(PLUGIN_ID, 'doublePace'), - step2: getEventKey(PLUGIN_ID, 'doublePace.2'), - step3: getEventKey(PLUGIN_ID, 'doublePace.3'), - done: getEventKey(PLUGIN_ID, 'doublePace.done'), -}; - -const sched = { - step2: getScheduleKey(PLUGIN_ID, 'doublePace.2'), - step3: getScheduleKey(PLUGIN_ID, 'doublePace.3'), - done: getScheduleKey(PLUGIN_ID, 'doublePace.done'), -}; +const seq = Sequence.for(PLUGIN_ID, 'doublePace'); export const doublePaceOutcome: DiceOutcome = { id: GameEventType.doublePace, - check: intensity => intensity >= 20, - scheduleKeys: Object.values(sched), - pipes: Composer.pipe( - Events.handle(ev.doublePace, () => - Composer.do(({ get, pipe }) => { - const pace = get(paceState)?.pace ?? 1; - const s = get(settings); - if (!s) return; - const newPace = Math.min(round(pace * 2), s.maxPace); - pipe(Pace.setPace(newPace)); - pipe( - Messages.send({ - id: GameEventType.doublePace, - title: 'Double pace!', - description: '3...', - }) - ); - pipe( - Scheduler.schedule({ - id: sched.step2, - duration: 3000, - event: { type: ev.step2 }, - }) - ); - }) + check: frame => + (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 20, + update: Composer.pipe( + seq.on(() => + Composer.bind(paceState, pace => + Composer.bind(settings, s => { + const newPace = Math.min(round((pace?.pace ?? 1) * 2), s.maxPace); + return Composer.pipe( + Pace.setPace(newPace), + seq.message({ title: 'Double pace!', description: '3...' }), + seq.after(3000, 'step2') + ); + }) + ) ), - Events.handle(ev.step2, () => + seq.on('step2', () => Composer.pipe( - Messages.send({ - id: GameEventType.doublePace, - description: '2...', - }), - Scheduler.schedule({ - id: sched.step3, - duration: 3000, - event: { type: ev.step3 }, - }) + seq.message({ description: '2...' }), + seq.after(3000, 'step3') ) ), - Events.handle(ev.step3, () => + seq.on('step3', () => Composer.pipe( - Messages.send({ - id: GameEventType.doublePace, - description: '1...', - }), - Scheduler.schedule({ - id: sched.done, - duration: 3000, - event: { type: ev.done }, - }) + seq.message({ description: '1...' }), + seq.after(3000, 'done') ) ), - Events.handle(ev.done, () => + seq.on('done', () => Composer.pipe( - Messages.send({ - id: GameEventType.doublePace, + seq.message({ title: 'Done! Back to normal pace', description: undefined, duration: 5000, diff --git a/src/game/plugins/dice/edge.ts b/src/game/plugins/dice/edge.ts index 396469e..b79e06b 100644 --- a/src/game/plugins/dice/edge.ts +++ b/src/game/plugins/dice/edge.ts @@ -1,47 +1,40 @@ import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; -import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { typedPath } from '../../../engine/Lens'; +import { Sequence } from '../../Sequence'; import Pace from '../pace'; import { GameEvent as GameEventType } from '../../../types'; -import { PLUGIN_ID, dice, settings, setBusy, DiceOutcome } from './types'; +import { + PLUGIN_ID, + intensityState, + settings, + setBusy, + DiceOutcome, +} from './types'; -const ev = { - edge: getEventKey(PLUGIN_ID, 'edge'), - done: getEventKey(PLUGIN_ID, 'edge.done'), -}; +export const edged = typedPath(['state', PLUGIN_ID, 'edged']); -const sched = { - edge: getScheduleKey(PLUGIN_ID, 'edge'), -}; +const seq = Sequence.for(PLUGIN_ID, 'edge'); export const edgeOutcome: DiceOutcome = { id: GameEventType.edge, - check: (intensity, edged) => intensity >= 90 && !edged, - scheduleKeys: Object.values(sched), - pipes: Composer.pipe( - Events.handle(ev.edge, () => - Composer.do(({ get, set, pipe }) => { - const s = get(settings); - if (!s) return; - set(dice.state.edged, true); - pipe(Pace.setPace(s.minPace)); - pipe( - Messages.send({ - id: GameEventType.edge, + check: frame => { + const i = (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100; + return i >= 90 && !Composer.get(edged)(frame); + }, + update: Composer.pipe( + seq.on(() => + Composer.bind(settings, s => + Composer.pipe( + Composer.set(edged, true), + Pace.setPace(s.minPace), + seq.message({ title: `You should be getting close to the edge. Don't cum yet.`, duration: 10000, - }) - ); - pipe( - Scheduler.schedule({ - id: sched.edge, - duration: 10000, - event: { type: ev.done }, - }) - ); - }) + }), + seq.after(10000, 'done') + ) + ) ), - Events.handle(ev.done, () => setBusy(false)) + seq.on('done', () => setBusy(false)) ), }; diff --git a/src/game/plugins/dice/emergencyStop.ts b/src/game/plugins/dice/emergencyStop.ts deleted file mode 100644 index 9756c50..0000000 --- a/src/game/plugins/dice/emergencyStop.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; -import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; -import { Pipe } from '../../../engine/State'; -import Phase, { GamePhase } from '../phase'; -import Pace from '../pace'; -import { IntensityState } from '../intensity'; -import { PLUGIN_ID, intensityState, settings, setBusy } from './types'; - -const ev = { - emergencyStop: getEventKey(PLUGIN_ID, 'emergencyStop'), - countdown: getEventKey(PLUGIN_ID, 'emergency.countdown'), - resume: getEventKey(PLUGIN_ID, 'emergency.resume'), -}; - -const sched = { - calm: getScheduleKey(PLUGIN_ID, 'emergency.calm'), - countdown: getScheduleKey(PLUGIN_ID, 'emergency.countdown'), - resume: getScheduleKey(PLUGIN_ID, 'emergency.resume'), -}; - -export const emergencyStopScheduleKeys: string[] = Object.values(sched); - -export const emergencyStopPipes: Pipe = Composer.pipe( - Events.handle(ev.emergencyStop, () => - Composer.do(({ get, pipe }) => { - const s = get(settings); - const i = (get(intensityState)?.intensity ?? 0) * 100; - if (!s) return; - - const timeToCalmDown = Math.ceil((i * 500 + 10000) / 1000); - - pipe(Phase.setPhase(GamePhase.break)); - pipe( - Messages.send({ - id: 'emergency-stop', - title: 'Calm down with your $hands off.', - }) - ); - pipe( - Composer.over(intensityState, (st: IntensityState) => ({ - intensity: Math.max(0, st.intensity - 0.3), - })) - ); - pipe(Pace.setPace(s.minPace)); - pipe( - Scheduler.schedule({ - id: sched.calm, - duration: 5000, - event: { - type: ev.countdown, - payload: { remaining: timeToCalmDown }, - }, - }) - ); - }) - ), - - Events.handle(ev.countdown, event => - Composer.do(({ pipe }) => { - const { remaining } = event.payload; - if (remaining <= 0) { - pipe( - Messages.send({ - id: 'emergency-stop', - title: 'Put your $hands back.', - description: undefined, - duration: 5000, - }) - ); - pipe( - Scheduler.schedule({ - id: sched.resume, - duration: 2000, - event: { type: ev.resume }, - }) - ); - } else { - pipe( - Messages.send({ - id: 'emergency-stop', - description: `${remaining}...`, - }) - ); - pipe( - Scheduler.schedule({ - id: sched.countdown, - duration: 1000, - event: { - type: ev.countdown, - payload: { remaining: remaining - 1 }, - }, - }) - ); - } - }) - ), - - Events.handle(ev.resume, () => - Composer.pipe(Phase.setPhase(GamePhase.active), setBusy(false)) - ) -); diff --git a/src/game/plugins/dice/halfPace.ts b/src/game/plugins/dice/halfPace.ts index ad97ed7..d453386 100644 --- a/src/game/plugins/dice/halfPace.ts +++ b/src/game/plugins/dice/halfPace.ts @@ -1,86 +1,56 @@ import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; -import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { Sequence } from '../../Sequence'; import Pace from '../pace'; import { GameEvent as GameEventType } from '../../../types'; import { round } from '../../../utils'; -import { PLUGIN_ID, paceState, settings, setBusy, DiceOutcome } from './types'; +import { + PLUGIN_ID, + paceState, + intensityState, + settings, + setBusy, + DiceOutcome, +} from './types'; import { doRandomPace } from './randomPace'; -const ev = { - halfPace: getEventKey(PLUGIN_ID, 'halfPace'), - step2: getEventKey(PLUGIN_ID, 'halfPace.2'), - step3: getEventKey(PLUGIN_ID, 'halfPace.3'), - done: getEventKey(PLUGIN_ID, 'halfPace.done'), -}; - -const sched = { - step2: getScheduleKey(PLUGIN_ID, 'halfPace.2'), - step3: getScheduleKey(PLUGIN_ID, 'halfPace.3'), - done: getScheduleKey(PLUGIN_ID, 'halfPace.done'), -}; +const seq = Sequence.for(PLUGIN_ID, 'halfPace'); export const halfPaceOutcome: DiceOutcome = { id: GameEventType.halfPace, - check: intensity => intensity >= 10 && intensity <= 50, - scheduleKeys: Object.values(sched), - pipes: Composer.pipe( - Events.handle(ev.halfPace, () => - Composer.do(({ get, pipe }) => { - const pace = get(paceState)?.pace ?? 1; - const s = get(settings); - if (!s) return; - const newPace = Math.max(round(pace / 2), s.minPace); - pipe(Pace.setPace(newPace)); - const duration = Math.ceil(Math.random() * 20000) + 12000; - const portion = duration / 3; - pipe( - Messages.send({ - id: GameEventType.halfPace, - title: 'Half pace!', - description: '3...', - }) - ); - pipe( - Scheduler.schedule({ - id: sched.step2, - duration: portion, - event: { type: ev.step2, payload: { portion } }, - }) - ); - }) + check: frame => { + const i = (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100; + return i >= 10 && i <= 50; + }, + update: Composer.pipe( + seq.on(() => + Composer.bind(paceState, pace => + Composer.bind(settings, s => { + const newPace = Math.max(round((pace?.pace ?? 1) / 2), s.minPace); + const duration = Math.ceil(Math.random() * 20000) + 12000; + const portion = duration / 3; + return Composer.pipe( + Pace.setPace(newPace), + seq.message({ title: 'Half pace!', description: '3...' }), + seq.after(portion, 'step2', { portion }) + ); + }) + ) ), - Events.handle(ev.step2, event => + seq.on('step2', event => Composer.pipe( - Messages.send({ - id: GameEventType.halfPace, - description: '2...', - }), - Scheduler.schedule({ - id: sched.step3, - duration: event.payload.portion, - event: { type: ev.step3, payload: event.payload }, - }) + seq.message({ description: '2...' }), + seq.after(event.payload.portion, 'step3', event.payload) ) ), - Events.handle(ev.step3, event => + seq.on('step3', event => Composer.pipe( - Messages.send({ - id: GameEventType.halfPace, - description: '1...', - }), - Scheduler.schedule({ - id: sched.done, - duration: event.payload.portion, - event: { type: ev.done }, - }) + seq.message({ description: '1...' }), + seq.after(event.payload.portion, 'done') ) ), - Events.handle(ev.done, () => + seq.on('done', () => Composer.pipe( - Messages.send({ - id: GameEventType.halfPace, + seq.message({ title: 'Done! Back to normal pace', description: undefined, duration: 5000, diff --git a/src/game/plugins/dice/pause.ts b/src/game/plugins/dice/pause.ts index 1006678..c443c71 100644 --- a/src/game/plugins/dice/pause.ts +++ b/src/game/plugins/dice/pause.ts @@ -1,52 +1,29 @@ import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; -import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; import { GameEvent as GameEventType } from '../../../types'; import { PLUGIN_ID, intensityState, setBusy, DiceOutcome } from './types'; -const ev = { - pause: getEventKey(PLUGIN_ID, 'pause'), - resume: getEventKey(PLUGIN_ID, 'pause.resume'), -}; - -const sched = { - pause: getScheduleKey(PLUGIN_ID, 'pause'), -}; +const seq = Sequence.for(PLUGIN_ID, 'pause'); export const pauseOutcome: DiceOutcome = { id: GameEventType.pause, - check: intensity => intensity >= 15, - scheduleKeys: Object.values(sched), - pipes: Composer.pipe( - Events.handle(ev.pause, () => - Composer.do(({ get, pipe }) => { - const i = (get(intensityState)?.intensity ?? 0) * 100; - pipe( - Messages.send({ - id: GameEventType.pause, - title: 'Stop stroking!', - }) - ); - pipe(Phase.setPhase(GamePhase.break)); - const duration = Math.ceil(-100 * i + 12000); - pipe( - Scheduler.schedule({ - id: sched.pause, - duration, - event: { type: ev.resume }, - }) + check: frame => + (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 15, + update: Composer.pipe( + seq.on(() => + Composer.bind(intensityState, ist => { + const i = (ist?.intensity ?? 0) * 100; + return Composer.pipe( + seq.message({ title: 'Stop stroking!' }), + Phase.setPhase(GamePhase.break), + seq.after(Math.ceil(-100 * i + 12000), 'resume') ); }) ), - Events.handle(ev.resume, () => + seq.on('resume', () => Composer.pipe( - Messages.send({ - id: GameEventType.pause, - title: 'Start stroking again!', - duration: 5000, - }), + seq.message({ title: 'Start stroking again!', duration: 5000 }), Phase.setPhase(GamePhase.active), setBusy(false) ) diff --git a/src/game/plugins/dice/randomGrip.ts b/src/game/plugins/dice/randomGrip.ts index 03aadf8..856689b 100644 --- a/src/game/plugins/dice/randomGrip.ts +++ b/src/game/plugins/dice/randomGrip.ts @@ -1,60 +1,49 @@ import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; -import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { typedPath } from '../../../engine/Lens'; +import { Sequence } from '../../Sequence'; import { GameEvent as GameEventType } from '../../../types'; -import { - PLUGIN_ID, - dice, - Paws, - PawLabels, - setBusy, - DiceOutcome, -} from './types'; +import { PLUGIN_ID, setBusy, DiceOutcome } from './types'; -const ev = { - randomGrip: getEventKey(PLUGIN_ID, 'randomGrip'), - done: getEventKey(PLUGIN_ID, 'randomGrip.done'), +export enum Paws { + left = 'left', + right = 'right', + both = 'both', +} + +export const PawLabels: Record = { + left: 'Left', + right: 'Right', + both: 'Both', }; -const sched = { - done: getScheduleKey(PLUGIN_ID, 'randomGrip'), +export const pawsPath = typedPath(['state', PLUGIN_ID, 'paws']); + +const allPaws = Object.values(Paws); + +const randomPaw = (exclude?: Paws): Paws => { + const options = exclude ? allPaws.filter(p => p !== exclude) : allPaws; + return options[Math.floor(Math.random() * options.length)]; }; +const seq = Sequence.for(PLUGIN_ID, 'randomGrip'); + export const randomGripOutcome: DiceOutcome = { id: GameEventType.randomGrip, - check: () => true, - scheduleKeys: Object.values(sched), - pipes: Composer.pipe( - Events.handle(ev.randomGrip, () => - Composer.do(({ get, set, pipe }) => { - const state = get(dice.state); - if (!state) return; - const currentPaws = state.paws; - let newPaws: Paws; - const seed = Math.random(); - if (seed < 0.33) - newPaws = currentPaws === Paws.both ? Paws.left : Paws.both; - else if (seed < 0.66) - newPaws = currentPaws === Paws.left ? Paws.right : Paws.left; - else newPaws = currentPaws === Paws.right ? Paws.both : Paws.right; - set(dice.state.paws, newPaws); - pipe( - Messages.send({ - id: GameEventType.randomGrip, + activate: Composer.over(pawsPath, () => randomPaw()), + update: Composer.pipe( + seq.on(() => + Composer.bind(pawsPath, currentPaws => { + const newPaws = randomPaw(currentPaws); + return Composer.pipe( + Composer.set(pawsPath, newPaws), + seq.message({ title: `Grip changed to ${PawLabels[newPaws]}!`, duration: 5000, - }) - ); - pipe( - Scheduler.schedule({ - id: sched.done, - duration: 10000, - event: { type: ev.done }, - }) + }), + seq.after(10000, 'done') ); }) ), - Events.handle(ev.done, () => setBusy(false)) + seq.on('done', () => setBusy(false)) ), }; diff --git a/src/game/plugins/dice/randomPace.ts b/src/game/plugins/dice/randomPace.ts index 0a8f8ff..ec453d5 100644 --- a/src/game/plugins/dice/randomPace.ts +++ b/src/game/plugins/dice/randomPace.ts @@ -1,7 +1,5 @@ import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; -import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { Sequence } from '../../Sequence'; import { Pipe } from '../../../engine/State'; import Pace from '../pace'; import { GameEvent as GameEventType } from '../../../types'; @@ -14,52 +12,33 @@ import { DiceOutcome, } from './types'; -const ev = { - randomPace: getEventKey(PLUGIN_ID, 'randomPace'), - done: getEventKey(PLUGIN_ID, 'randomPace.done'), -}; - -const sched = { - randomPace: getScheduleKey(PLUGIN_ID, 'randomPace'), -}; +const seq = Sequence.for(PLUGIN_ID, 'randomPace'); export const doRandomPace = (): Pipe => - Composer.do(({ get, pipe }) => { - const i = get(intensityState)?.intensity ?? 0; - const s = get(settings); - if (!s) return; - const { min, max } = intensityToPaceRange( - i * 100, - s.steepness, - s.timeshift, - { min: s.minPace, max: s.maxPace } - ); - const newPace = round(Math.random() * (max - min) + min); - pipe(Pace.setPace(newPace)); - pipe( - Messages.send({ - id: GameEventType.randomPace, - title: `Pace changed to ${newPace}!`, - duration: 5000, - }) - ); - }); + Composer.bind(intensityState, ist => + Composer.bind(settings, s => { + const i = ist?.intensity ?? 0; + const { min, max } = intensityToPaceRange( + i * 100, + s.steepness, + s.timeshift, + { min: s.minPace, max: s.maxPace } + ); + const newPace = round(Math.random() * (max - min) + min); + return Composer.pipe( + Pace.setPace(newPace), + seq.message({ + title: `Pace changed to ${newPace}!`, + duration: 5000, + }) + ); + }) + ); export const randomPaceOutcome: DiceOutcome = { id: GameEventType.randomPace, - check: () => true, - scheduleKeys: Object.values(sched), - pipes: Composer.pipe( - Events.handle(ev.randomPace, () => - Composer.pipe( - doRandomPace(), - Scheduler.schedule({ - id: sched.randomPace, - duration: 9000, - event: { type: ev.done }, - }) - ) - ), - Events.handle(ev.done, () => setBusy(false)) + update: Composer.pipe( + seq.on(() => Composer.pipe(doRandomPace(), seq.after(9000, 'done'))), + seq.on('done', () => setBusy(false)) ), }; diff --git a/src/game/plugins/dice/risingPace.ts b/src/game/plugins/dice/risingPace.ts index 7f02d73..4346be8 100644 --- a/src/game/plugins/dice/risingPace.ts +++ b/src/game/plugins/dice/risingPace.ts @@ -1,7 +1,5 @@ import { Composer } from '../../../engine/Composer'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; -import { Messages } from '../../../engine/pipes/Messages'; -import { Scheduler, getScheduleKey } from '../../../engine/pipes/Scheduler'; +import { Sequence } from '../../Sequence'; import Pace from '../pace'; import { GameEvent as GameEventType } from '../../../types'; import { intensityToPaceRange, round } from '../../../utils'; @@ -14,114 +12,56 @@ import { } from './types'; import { doRandomPace } from './randomPace'; -const ev = { - risingPace: getEventKey(PLUGIN_ID, 'risingPace'), - step: getEventKey(PLUGIN_ID, 'risingPace.step'), - hold: getEventKey(PLUGIN_ID, 'risingPace.hold'), - done: getEventKey(PLUGIN_ID, 'risingPace.done'), -}; - -const sched = { - step: getScheduleKey(PLUGIN_ID, 'risingPace.step'), - hold: getScheduleKey(PLUGIN_ID, 'risingPace.hold'), - done: getScheduleKey(PLUGIN_ID, 'risingPace.done'), -}; +const seq = Sequence.for(PLUGIN_ID, 'risingPace'); export const risingPaceOutcome: DiceOutcome = { id: GameEventType.risingPace, - check: intensity => intensity >= 30, - scheduleKeys: Object.values(sched), - pipes: Composer.pipe( - Events.handle(ev.risingPace, () => - Composer.do(({ get, pipe }) => { - const i = (get(intensityState)?.intensity ?? 0) * 100; - const s = get(settings); - if (!s) return; - - pipe( - Messages.send({ - id: GameEventType.risingPace, - title: 'Rising pace strokes!', - }) - ); - - const acceleration = Math.round(100 / Math.min(i, 35)); - const { max } = intensityToPaceRange(i, s.steepness, s.timeshift, { - min: s.minPace, - max: s.maxPace, - }); - const portion = (max - s.minPace) / acceleration; - - pipe(Pace.setPace(s.minPace)); - pipe( - Scheduler.schedule({ - id: sched.step, - duration: 10000, - event: { - type: ev.step, - payload: { - current: s.minPace, - portion, - remaining: acceleration, - }, - }, - }) - ); - }) - ), - Events.handle(ev.step, event => - Composer.do(({ pipe }) => { - const { current, portion, remaining } = event.payload; - const newPace = round(current + portion); - pipe(Pace.setPace(newPace)); - pipe( - Messages.send({ - id: GameEventType.risingPace, - title: `Pace rising to ${newPace}!`, - duration: 5000, - }) - ); - - if (remaining <= 1) { - pipe( - Scheduler.schedule({ - id: sched.hold, - duration: 10000, - event: { type: ev.hold }, - }) - ); - } else { - pipe( - Scheduler.schedule({ - id: sched.step, - duration: 10000, - event: { - type: ev.step, - payload: { - current: newPace, - portion, - remaining: remaining - 1, - }, - }, + check: frame => + (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 30, + update: Composer.pipe( + seq.on(() => + Composer.bind(intensityState, ist => + Composer.bind(settings, s => { + const i = (ist?.intensity ?? 0) * 100; + const acceleration = Math.round(100 / Math.min(i, 35)); + const { max } = intensityToPaceRange(i, s.steepness, s.timeshift, { + min: s.minPace, + max: s.maxPace, + }); + const portion = (max - s.minPace) / acceleration; + return Composer.pipe( + seq.message({ title: 'Rising pace strokes!' }), + Pace.setPace(s.minPace), + seq.after(10000, 'step', { + current: s.minPace, + portion, + remaining: acceleration, }) ); - } - }) + }) + ) ), - Events.handle(ev.hold, () => + seq.on('step', event => { + const { current, portion, remaining } = event.payload; + const newPace = round(current + portion); + return Composer.pipe( + Pace.setPace(newPace), + seq.message({ title: `Pace rising to ${newPace}!`, duration: 5000 }), + remaining <= 1 + ? seq.after(10000, 'hold') + : seq.after(10000, 'step', { + current: newPace, + portion, + remaining: remaining - 1, + }) + ); + }), + seq.on('hold', () => Composer.pipe( - Messages.send({ - id: GameEventType.risingPace, - title: 'Stay at this pace for a bit', - duration: 5000, - }), - Scheduler.schedule({ - id: sched.done, - duration: 15000, - event: { type: ev.done }, - }) + seq.message({ title: 'Stay at this pace for a bit', duration: 5000 }), + seq.after(15000, 'done') ) ), - Events.handle(ev.done, () => Composer.pipe(doRandomPace(), setBusy(false))) + seq.on('done', () => Composer.pipe(doRandomPace(), setBusy(false))) ), }; diff --git a/src/game/plugins/dice/types.ts b/src/game/plugins/dice/types.ts index 10678df..703df8e 100644 --- a/src/game/plugins/dice/types.ts +++ b/src/game/plugins/dice/types.ts @@ -1,38 +1,19 @@ -import { Pipe } from '../../../engine/State'; +import { Pipe, GameFrame } from '../../../engine/State'; import { Composer } from '../../../engine/Composer'; import { pluginPaths } from '../../../engine/plugins/Plugins'; import { typedPath } from '../../../engine/Lens'; -import { GameContext } from '../../../engine'; import { IntensityState } from '../intensity'; import { PaceState } from '../pace'; import { Settings } from '../../../settings'; -import { PhaseState } from '../phase'; import { GameEvent as GameEventType } from '../../../types'; export const PLUGIN_ID = 'core.dice'; -export enum Paws { - left = 'left', - right = 'right', - both = 'both', -} - -export const PawLabels: Record = { - left: 'Left', - right: 'Right', - both: 'Both', -}; - export type DiceState = { - edged: boolean; - paws: Paws; busy: boolean; - rollTimer: number; }; export const dice = pluginPaths(PLUGIN_ID); -export const gameContext = typedPath(['context']); -export const phaseState = typedPath(['state', 'core.phase']); export const paceState = typedPath(['state', 'core.pace']); export const intensityState = typedPath([ 'state', @@ -45,11 +26,7 @@ export const setBusy = (val: boolean): Pipe => export type DiceOutcome = { id: GameEventType; - check: ( - intensity: number, - edged: boolean, - events: GameEventType[] - ) => boolean; - pipes: Pipe; - scheduleKeys: string[]; + check?: (frame: GameFrame) => boolean; + activate?: Pipe; + update: Pipe; }; diff --git a/src/game/plugins/emergencyStop.ts b/src/game/plugins/emergencyStop.ts new file mode 100644 index 0000000..d6b246c --- /dev/null +++ b/src/game/plugins/emergencyStop.ts @@ -0,0 +1,75 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine/Composer'; +import { Scheduler } from '../../engine/pipes/Scheduler'; +import { typedPath } from '../../engine/Lens'; +import { Sequence } from '../Sequence'; +import Phase, { GamePhase } from './phase'; +import Pause from './pause'; +import Pace from './pace'; +import { IntensityState } from './intensity'; +import { Settings } from '../../settings'; + +const PLUGIN_ID = 'core.emergencyStop'; + +const intensityState = typedPath(['state', 'core.intensity']); +const settings = typedPath(['context', 'settings']); + +const seq = Sequence.for(PLUGIN_ID, 'stop'); + +declare module '../../engine/sdk' { + interface PluginSDK { + EmergencyStop: typeof EmergencyStop; + } +} + +export default class EmergencyStop { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'EmergencyStop', + }, + + update: Composer.pipe( + seq.on(() => + Composer.bind(intensityState, ist => + Composer.bind(settings, s => { + const i = (ist?.intensity ?? 0) * 100; + const timeToCalmDown = Math.ceil((i * 500 + 10000) / 1000); + return Composer.pipe( + Phase.setPhase(GamePhase.break), + seq.message({ title: 'Calm down with your $hands off.' }), + Composer.over(intensityState, (st: IntensityState) => ({ + intensity: Math.max(0, st.intensity - 0.3), + })), + Pace.setPace(s.minPace), + seq.after(5000, 'countdown', { remaining: timeToCalmDown }) + ); + }) + ) + ), + + seq.on('countdown', event => { + const { remaining } = event.payload; + if (remaining <= 0) { + return Composer.pipe( + seq.message({ + title: 'Put your $hands back.', + description: undefined, + duration: 5000, + }), + seq.after(2000, 'resume') + ); + } + return Composer.pipe( + seq.message({ description: `${remaining}...` }), + seq.after(1000, 'countdown', { remaining: remaining - 1 }) + ); + }), + + seq.on('resume', () => Phase.setPhase(GamePhase.active)), + + Pause.onPause(() => Scheduler.holdByPrefix(PLUGIN_ID)), + Pause.onResume(() => Scheduler.releaseByPrefix(PLUGIN_ID)) + ), + }; +} diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index 02fa76a..879b68f 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -12,6 +12,7 @@ import RandomImages from './randomImages'; import Warmup from './warmup'; import Stroke from './stroke'; import Dealer from './dealer'; +import EmergencyStop from './emergencyStop'; import Hypno from './hypno'; const plugins = [ @@ -21,6 +22,7 @@ const plugins = [ Intensity, Stroke, Dealer, + EmergencyStop, Hypno, Image, RandomImages, From bcfa964ea7a7bd4c8124947c2a54691f75f8c486 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 13 Feb 2026 22:56:43 +0100 Subject: [PATCH 48/90] improved performance monitor --- src/engine/pipes/Perf.ts | 2 +- src/game/plugins/perf.ts | 45 +++++++++++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index 08db4e2..7601e6b 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -33,7 +33,7 @@ const SAMPLE_SIZE = 60; const EXPIRY_TICKS = 900; const DEFAULT_CONFIG: PerfConfig = { - pluginBudget: 4, + pluginBudget: 1, }; const eventType = { diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts index d62ab9a..24decf8 100644 --- a/src/game/plugins/perf.ts +++ b/src/game/plugins/perf.ts @@ -14,26 +14,39 @@ type PerfOverlayContext = { const po = pluginPaths(PLUGIN_ID); +const COLOR_OK = [0x4a, 0xde, 0x80] as const; +const COLOR_WARN = [0xfa, 0xcc, 0x15] as const; +const COLOR_OVER = [0xf8, 0x71, 0x71] as const; + +function lerpRgb( + a: readonly number[], + b: readonly number[], + t: number +): string { + const r = Math.round(a[0] + (b[0] - a[0]) * t); + const g = Math.round(a[1] + (b[1] - a[1]) * t); + const bl = Math.round(a[2] + (b[2] - a[2]) * t); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`; +} + function budgetColor(duration: number, budget: number): string { const ratio = duration / budget; - if (ratio < 0.5) return '#4ade80'; - if (ratio < 1.0) return '#facc15'; - return '#f87171'; + if (ratio <= 0) return lerpRgb(COLOR_OK, COLOR_OK, 0); + if (ratio < 1) return lerpRgb(COLOR_OK, COLOR_WARN, ratio); + return lerpRgb(COLOR_WARN, COLOR_OVER, Math.min(ratio - 1, 1)); } function formatLine( id: string, phase: PluginHookPhase, - last: number, avg: number, budget: number ): string { const name = id.padEnd(24); const ph = phase.padEnd(11); - const l = `${last.toFixed(2)}ms`.padStart(8); - const a = `avg ${avg.toFixed(2)}ms`.padStart(12); + const a = `${avg.toFixed(2)}ms`.padStart(8); const color = budgetColor(avg, budget); - return `${name}${ph}${l}${a}`; + return `${name}${ph}${a}`; } export default class PerfOverlay { @@ -93,17 +106,29 @@ export default class PerfOverlay { 'deactivate', ]; + let totalAvg = 0; + for (const phase of phaseOrder) { for (const [id, phases] of Object.entries(plugins as PerfMetrics)) { if (id === PLUGIN_ID) continue; const entry = phases[phase]; if (!entry) continue; - lines.push( - formatLine(id, phase, entry.last, entry.avg, config.pluginBudget) - ); + totalAvg += entry.avg; + lines.push(formatLine(id, phase, entry.avg, config.pluginBudget)); } } + if (lines.length > 0) { + const totalColor = budgetColor( + totalAvg, + config.pluginBudget * lines.length + ); + lines.push(''); + lines.push( + `${'frame'.padEnd(35)}${`${totalAvg.toFixed(2)}ms`.padStart(8)}` + ); + } + el.innerHTML = lines.length > 0 ? lines.join('\n') From 39da6960a421215ab8a82296a92c8186eb1aa89f Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 08:36:00 +0100 Subject: [PATCH 49/90] removed pause button --- src/game/GamePage.tsx | 2 -- src/game/components/Pause.tsx | 56 ----------------------------------- 2 files changed, 58 deletions(-) delete mode 100644 src/game/components/Pause.tsx diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 64f66f9..7e53c01 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -2,7 +2,6 @@ import styled from 'styled-components'; import { GameEngineProvider } from './GameProvider'; import { messagesPipe } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; -import { PauseButton } from './components/Pause'; import { useSettingsPipe } from './pipes'; import { GameImages } from './components/GameImages'; import { pluginInstallerPipe } from '../engine/plugins/PluginInstaller'; @@ -102,7 +101,6 @@ export const GamePage = () => { - diff --git a/src/game/components/Pause.tsx b/src/game/components/Pause.tsx deleted file mode 100644 index 257d0a1..0000000 --- a/src/game/components/Pause.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import styled from 'styled-components'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; -import { useGameEngine } from '../hooks/UseGameEngine'; -import { useGameState } from '../hooks/UseGameValue'; -import Pause, { type PauseState } from '../plugins/pause'; - -const PauseContainer = styled.div` - position: absolute; - bottom: 16px; - right: 16px; - z-index: 9999; -`; - -const PauseIconButton = styled.button` - display: flex; - align-items: center; - padding: 16px 12px; - gap: 8px; - border-radius: var(--border-radius) 0 0 0; - opacity: 0.8; - background: #a52727; - color: #fff; - font-size: 1rem; - cursor: pointer; - transition: filter 0.2s; - - &:hover { - filter: brightness(1.4); - } -`; - -export const PauseButton = () => { - const { injectImpulse } = useGameEngine(); - const pauseState = useGameState('core.pause'); - const paused = pauseState?.paused ?? false; - - const togglePause = () => { - injectImpulse(Pause.togglePause); - }; - - return ( - - -

{paused ? 'Resume' : 'Pause'}

- -
-
- ); -}; From 0a7d8a70a1430e5453dd91fd51688263f73e6a3b Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 09:34:40 +0100 Subject: [PATCH 50/90] fixed unpausing the game --- src/engine/pipes/Perf.test.ts | 9 -- src/game/Sequence.ts | 8 + src/game/components/GameSettings.tsx | 54 +------ src/game/plugins/pause.test.ts | 230 +++++++++++++++++++++++++++ src/game/plugins/pause.ts | 59 ++++++- 5 files changed, 291 insertions(+), 69 deletions(-) create mode 100644 src/game/plugins/pause.test.ts diff --git a/src/engine/pipes/Perf.test.ts b/src/engine/pipes/Perf.test.ts index 6496257..b959eb6 100644 --- a/src/engine/pipes/Perf.test.ts +++ b/src/engine/pipes/Perf.test.ts @@ -40,15 +40,6 @@ const getEntry = ( describe('Perf', () => { describe('perfPipe', () => { - it('should initialize perf context with defaults', () => { - const result = basePipe(makeFrame()); - const ctx = getPerfCtx(result); - - expect(ctx).toBeDefined(); - expect(ctx!.plugins).toEqual({}); - expect(ctx!.config.pluginBudget).toBe(4); - }); - it('should preserve existing metrics across frames', () => { const frame0 = basePipe(makeFrame()); diff --git a/src/game/Sequence.ts b/src/game/Sequence.ts index 7ceaa27..7345bfc 100644 --- a/src/game/Sequence.ts +++ b/src/game/Sequence.ts @@ -18,6 +18,8 @@ export type SequenceScope = { message(msg: MessageInput): Pipe; after(duration: number, target: string, payload?: any): Pipe; prompt(title: string, target: string, payload?: any): MessagePrompt; + start(payload?: any): Pipe; + cancel(): Pipe; dispatch(target: string, payload?: any): Pipe; eventKey(target: string): string; scheduleKey(target: string): string; @@ -53,6 +55,12 @@ export class Sequence { ...(payload !== undefined && { payload }), }, }), + start: payload => + Events.dispatch({ + type: rootKey, + ...(payload !== undefined && { payload }), + }), + cancel: () => Scheduler.cancelByPrefix(getScheduleKey(namespace, name)), dispatch: (target, payload) => Events.dispatch({ type: target ? nodeKey(target) : rootKey, diff --git a/src/game/components/GameSettings.tsx b/src/game/components/GameSettings.tsx index ac173ca..a94003c 100644 --- a/src/game/components/GameSettings.tsx +++ b/src/game/components/GameSettings.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; import { BoardSettings, ClimaxSettings, @@ -21,8 +21,6 @@ import { import { useGameEngine, useGameState } from '../hooks'; import { GamePhase, PhaseState } from '../plugins/phase'; import Pause from '../plugins/pause'; -import { Messages } from '../../engine/pipes/Messages'; -import { Composer } from '../../engine'; const StyledGameSettings = styled.div` display: flex; @@ -59,32 +57,23 @@ const GameSettingsDialogContent = memo(() => ( )); -const MESSAGE_ID = 'game-settings'; - export const GameSettings = () => { const [open, setOpen] = useState(false); const { current: phase } = useGameState(['core.phase']) ?? {}; const [fullscreen, setFullscreen] = useFullscreen(); const { injectImpulse } = useGameEngine(); - const timerRef = useRef(undefined); - const [countdown, setCountdown] = useState(undefined); const wasActiveRef = useRef(false); const onOpen = useCallback( (opening: boolean) => { if (opening) { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = undefined; - } - setCountdown(undefined); wasActiveRef.current = phase === GamePhase.active; if (phase === GamePhase.active) { injectImpulse(Pause.setPaused(true)); } } else { if (wasActiveRef.current) { - setCountdown(3000); + injectImpulse(Pause.setPaused(false)); } } setOpen(opening); @@ -92,45 +81,6 @@ export const GameSettings = () => { [injectImpulse, phase] ); - useEffect(() => { - if (countdown === undefined || open) return; - - if (countdown <= 0) { - injectImpulse( - Composer.pipe( - Messages.send({ - id: MESSAGE_ID, - title: 'Continue.', - description: undefined, - duration: 1500, - }), - Pause.setPaused(false) - ) - ); - setCountdown(undefined); - return; - } - - injectImpulse( - Messages.send({ - id: MESSAGE_ID, - title: 'Get ready to continue.', - description: `${countdown * 0.001}...`, - }) - ); - - timerRef.current = window.setTimeout(() => { - setCountdown(c => (c !== undefined ? c - 1000 : undefined)); - }, 1000); - - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = undefined; - } - }; - }, [countdown, open, injectImpulse]); - return ( onOpen(true)}> diff --git a/src/game/plugins/pause.test.ts b/src/game/plugins/pause.test.ts new file mode 100644 index 0000000..f9b7db1 --- /dev/null +++ b/src/game/plugins/pause.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Composer } from '../../engine/Composer'; +import { eventPipe } from '../../engine/pipes/Events'; +import { + schedulerPipe, + Scheduler, + ScheduledEvent, +} from '../../engine/pipes/Scheduler'; +import { messagesPipe } from '../../engine/pipes/Messages'; +import { + pluginManagerPipe, + PluginManager, +} from '../../engine/plugins/PluginManager'; +import { GameFrame, Pipe } from '../../engine/State'; +import { PluginClass } from '../../engine/plugins/Plugins'; +import Pause, { PauseState } from './pause'; + +const makeFrame = (): GameFrame => ({ + state: {}, + context: { tick: 0, deltaTime: 16, elapsedTime: 0 }, +}); + +const tick = (frame: GameFrame, dt = 16): GameFrame => ({ + ...frame, + context: { + ...frame.context, + tick: frame.context.tick + 1, + deltaTime: dt, + elapsedTime: frame.context.elapsedTime + dt, + }, +}); + +const gamePipe: Pipe = Composer.pipe( + eventPipe, + schedulerPipe, + messagesPipe, + pluginManagerPipe +); + +const withImpulse = (impulse: Pipe): Pipe => Composer.pipe(impulse, gamePipe); + +const makePluginClass = (plugin: { + id: string; + [k: string]: any; +}): PluginClass => ({ + plugin, + name: plugin.id, +}); + +const DEALER_ID = 'test.dealer'; + +function bootstrap(): GameFrame { + const dealerPlugin = { + id: DEALER_ID, + update: Composer.pipe( + Pause.onPause(() => Scheduler.holdByPrefix(DEALER_ID)), + Pause.onResume(() => Scheduler.releaseByPrefix(DEALER_ID)) + ), + }; + + let frame = gamePipe(makeFrame()); + frame = PluginManager.register(makePluginClass(Pause.plugin))(frame); + frame = PluginManager.register(makePluginClass(dealerPlugin))(frame); + frame = gamePipe(tick(frame)); + return frame; +} + +function getScheduled(frame: GameFrame): ScheduledEvent[] { + return (frame.state as any)?.core?.scheduler?.scheduled ?? []; +} + +function getPauseState(frame: GameFrame): PauseState | undefined { + return (frame.state as any)?.core?.pause; +} + +function getMessages(frame: GameFrame): any[] { + return (frame.state as any)?.core?.messages?.messages ?? []; +} + +function getDealerScheduled(frame: GameFrame): ScheduledEvent[] { + return getScheduled(frame).filter(s => s.id?.startsWith(DEALER_ID)); +} + +describe('Pause + Scheduler resume', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('should schedule an event and have it tick down', () => { + let frame = bootstrap(); + + frame = withImpulse( + Scheduler.schedule({ + id: `${DEALER_ID}/schedule/check`, + duration: 1000, + event: { type: `${DEALER_ID}/roll.check` }, + }) + )(tick(frame)); + + const before = getScheduled(frame).find(s => + s.id?.includes('check') + )?.duration; + expect(before).toBeDefined(); + + frame = gamePipe(tick(frame)); + const after = getScheduled(frame).find(s => + s.id?.includes('check') + )?.duration; + + expect(after).toBeLessThan(before!); + }); + + it('should hold dealer events when paused', () => { + let frame = bootstrap(); + + frame = withImpulse( + Scheduler.schedule({ + id: `${DEALER_ID}/schedule/check`, + duration: 1000, + event: { type: `${DEALER_ID}/roll.check` }, + }) + )(tick(frame)); + frame = gamePipe(tick(frame)); + + frame = withImpulse(Pause.setPaused(true))(tick(frame)); + + for (let i = 0; i < 5; i++) { + frame = gamePipe(tick(frame)); + } + + const dealerEvent = getDealerScheduled(frame)[0]; + expect(dealerEvent?.held).toBe(true); + + const durationAfterHold = dealerEvent?.duration; + + for (let i = 0; i < 10; i++) { + frame = gamePipe(tick(frame)); + } + + expect(getDealerScheduled(frame)[0]?.duration).toBe(durationAfterHold); + }); + + it('should release dealer events after resume countdown', () => { + let frame = bootstrap(); + + frame = withImpulse( + Scheduler.schedule({ + id: `${DEALER_ID}/schedule/check`, + duration: 50000, + event: { type: `${DEALER_ID}/roll.check` }, + }) + )(tick(frame)); + frame = gamePipe(tick(frame)); + + frame = withImpulse(Pause.setPaused(true))(tick(frame)); + for (let i = 0; i < 5; i++) { + frame = gamePipe(tick(frame)); + } + expect(getDealerScheduled(frame)[0]?.held).toBe(true); + + const durationBeforeResume = getDealerScheduled(frame)[0]?.duration; + + frame = withImpulse(Pause.setPaused(false))(tick(frame)); + + for (let i = 0; i < 300; i++) { + frame = gamePipe(tick(frame, 100)); + } + + const dealerEvent = getDealerScheduled(frame)[0]; + const pauseState = getPauseState(frame); + + expect(pauseState?.paused).toBe(false); + expect(dealerEvent?.held).toBe(false); + expect(dealerEvent?.duration).toBeLessThan(durationBeforeResume!); + }); + + it('should show countdown messages during resume', () => { + let frame = bootstrap(); + + frame = withImpulse(Pause.setPaused(true))(tick(frame)); + for (let i = 0; i < 5; i++) { + frame = gamePipe(tick(frame)); + } + + frame = withImpulse(Pause.setPaused(false))(tick(frame)); + + const seenDescriptions: string[] = []; + + for (let i = 0; i < 300; i++) { + frame = gamePipe(tick(frame, 100)); + const msg = getMessages(frame).find(m => m.id === 'resume'); + if (msg?.description && !seenDescriptions.includes(msg.description)) { + seenDescriptions.push(msg.description); + } + } + + expect(seenDescriptions).toContain('3...'); + expect(seenDescriptions).toContain('2...'); + expect(seenDescriptions).toContain('1...'); + expect(getPauseState(frame)?.paused).toBe(false); + }); + + it('should cancel resume countdown when re-paused', () => { + let frame = bootstrap(); + + frame = withImpulse(Pause.setPaused(true))(tick(frame)); + for (let i = 0; i < 5; i++) { + frame = gamePipe(tick(frame)); + } + + frame = withImpulse(Pause.setPaused(false))(tick(frame)); + + for (let i = 0; i < 10; i++) { + frame = gamePipe(tick(frame, 100)); + } + + frame = withImpulse(Pause.setPaused(true))(tick(frame)); + for (let i = 0; i < 5; i++) { + frame = gamePipe(tick(frame)); + } + + expect(getPauseState(frame)?.paused).toBe(true); + + for (let i = 0; i < 300; i++) { + frame = gamePipe(tick(frame, 100)); + } + + expect(getPauseState(frame)?.paused).toBe(true); + }); +}); diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index 23bf7e2..e6db56d 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -3,6 +3,7 @@ import { Pipe, PipeTransformer } from '../../engine/State'; import { Composer } from '../../engine/Composer'; import { Events, getEventKey } from '../../engine/pipes/Events'; import { pluginPaths } from '../../engine/plugins/Plugins'; +import { Sequence } from '../Sequence'; declare module '../../engine/sdk' { interface PluginSDK { @@ -29,6 +30,8 @@ const eventType = { off: getEventKey(PLUGIN_ID, 'off'), }; +const resume = Sequence.for(PLUGIN_ID, 'resume'); + export default class Pause { static setPaused(val: boolean): Pipe { return Composer.call(pause.context.setPaused, val); @@ -67,19 +70,59 @@ export default class Pause { activate: Composer.do(({ set }) => { set(pause.state, { paused: false, prev: false }); set(pause.context, { - setPaused: val => Composer.set(pause.state.paused, val), + setPaused: val => + Composer.when( + val, + Composer.pipe( + resume.cancel(), + Composer.set(pause.state.paused, true) + ), + resume.start() + ), togglePause: Composer.bind(pause.state, state => - Composer.set(pause.state.paused, !state?.paused) + Pause.setPaused(!state?.paused) ), }); }), - update: Composer.do(({ get, set, pipe }) => { - const { paused, prev } = get(pause.state); - if (paused === prev) return; - set(pause.state.prev, paused); - pipe(Events.dispatch({ type: paused ? eventType.on : eventType.off })); - }), + update: Composer.pipe( + Composer.do(({ get, set, pipe }) => { + const { paused, prev } = get(pause.state); + if (paused === prev) return; + set(pause.state.prev, paused); + pipe(Events.dispatch({ type: paused ? eventType.on : eventType.off })); + }), + + resume.on(() => + Composer.pipe( + resume.message({ + title: 'Get ready to continue.', + description: '3...', + }), + resume.after(1000, 'countdown', { remaining: 2 }) + ) + ), + + resume.on('countdown', event => + Composer.when( + event.payload.remaining <= 0, + Composer.pipe( + resume.message({ + title: 'Continue.', + description: undefined, + duration: 1500, + }), + Composer.set(pause.state.paused, false) + ), + Composer.pipe( + resume.message({ description: `${event.payload.remaining}...` }), + resume.after(1000, 'countdown', { + remaining: event.payload.remaining - 1, + }) + ) + ) + ) + ), deactivate: Composer.pipe( Composer.set(pause.state, undefined), From 629ff14b875f3d135cfe9b83c4600629d20f01d4 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 10:34:30 +0100 Subject: [PATCH 51/90] moved messaging to plugin --- src/engine/pipes/Messages.ts | 109 ----------------------- src/engine/pipes/index.ts | 1 - src/engine/sdk.ts | 3 - src/game/GamePage.tsx | 2 - src/game/Sequence.ts | 2 +- src/game/components/GameMessages.tsx | 2 +- src/game/plugins/index.ts | 2 + src/game/plugins/messages.ts | 128 +++++++++++++++++++++++++++ src/game/plugins/pause.test.ts | 4 +- src/game/plugins/warmup.ts | 2 +- 10 files changed, 135 insertions(+), 120 deletions(-) delete mode 100644 src/engine/pipes/Messages.ts create mode 100644 src/game/plugins/messages.ts diff --git a/src/engine/pipes/Messages.ts b/src/engine/pipes/Messages.ts deleted file mode 100644 index c7f0785..0000000 --- a/src/engine/pipes/Messages.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Pipe, PipeTransformer } from '../State'; -import { Composer } from '../Composer'; -import { getEventKey, GameEvent, Events } from './Events'; -import { getScheduleKey, Scheduler } from './Scheduler'; - -export interface GameMessagePrompt { - title: string; - event: GameEvent; -} - -export interface GameMessage { - id: string; - title: string; - description?: string; - prompts?: GameMessagePrompt[]; - duration?: number; -} - -export type PartialGameMessage = Partial & Pick; - -const PLUGIN_NAMESPACE = 'core.messages'; - -export type MessageContext = { - sendMessage: PipeTransformer<[PartialGameMessage]>; -}; - -export type MessageState = { - messages: GameMessage[]; -}; - -export class Messages { - static send(message: PartialGameMessage): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ sendMessage }) => sendMessage(message) - ); - } -} - -export const messagesPipe: Pipe = Composer.pipe( - Composer.set(['context', PLUGIN_NAMESPACE], { - sendMessage: msg => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), - payload: msg, - }), - }), - - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'sendMessage'), event => - Composer.pipe( - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => { - const patch = event.payload as GameMessage; - const index = messages.findIndex(m => m.id === patch.id); - const existing = messages[index]; - - if (!existing && !patch.title) return { messages }; - - const updated = [...messages]; - updated[index < 0 ? updated.length : index] = { - ...existing, - ...patch, - }; - - return { messages: updated }; - } - ), - - Composer.chain(c => { - const { messages = [] } = c.get([ - 'state', - PLUGIN_NAMESPACE, - ]); - const messageId = (event.payload as GameMessage).id; - const updated = messages.find(m => m.id === messageId); - const scheduleId = getScheduleKey( - PLUGIN_NAMESPACE, - `message/${messageId}` - ); - - if (updated?.duration !== undefined) { - const eventType = getEventKey(PLUGIN_NAMESPACE, 'expireMessage'); - return c.pipe( - Scheduler.schedule({ - id: scheduleId, - duration: updated.duration, - event: { - type: eventType, - payload: updated.id, - }, - }) - ); - } else { - return c.pipe(Scheduler.cancel(scheduleId)); - } - }) - ) - ), - - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'expireMessage'), event => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ messages = [] }) => ({ - messages: messages.filter(m => m.id !== event.payload), - }) - ) - ) -); diff --git a/src/engine/pipes/index.ts b/src/engine/pipes/index.ts index 6040f39..3b3f416 100644 --- a/src/engine/pipes/index.ts +++ b/src/engine/pipes/index.ts @@ -1,6 +1,5 @@ export * from './Events'; export * from './Fps'; -export * from './Messages'; export * from '../plugins/PluginInstaller'; export * from '../plugins/PluginManager'; export * from '../plugins/Plugins'; diff --git a/src/engine/sdk.ts b/src/engine/sdk.ts index c83e43a..1775545 100644 --- a/src/engine/sdk.ts +++ b/src/engine/sdk.ts @@ -1,6 +1,5 @@ import { Composer } from './Composer'; import { Events } from './pipes/Events'; -import { Messages } from './pipes/Messages'; import { Scheduler } from './pipes/Scheduler'; import { Storage } from './pipes/Storage'; import { PluginManager } from './plugins/PluginManager'; @@ -14,7 +13,6 @@ export interface PluginSDK {} export interface SDK extends PluginSDK { Composer: typeof Composer; Events: typeof Events; - Messages: typeof Messages; Scheduler: typeof Scheduler; Storage: typeof Storage; PluginManager: typeof PluginManager; @@ -26,7 +24,6 @@ export interface SDK extends PluginSDK { export const sdk: SDK = { Composer, Events, - Messages, Scheduler, Storage, PluginManager, diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 7e53c01..2f50e0b 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -1,6 +1,5 @@ import styled from 'styled-components'; import { GameEngineProvider } from './GameProvider'; -import { messagesPipe } from '../engine/pipes/Messages'; import { GameMessages } from './components/GameMessages'; import { useSettingsPipe } from './pipes'; import { GameImages } from './components/GameImages'; @@ -81,7 +80,6 @@ export const GamePage = () => { return ( [1]; type MessageInput = Omit[0], 'id'>; diff --git a/src/game/components/GameMessages.tsx b/src/game/components/GameMessages.tsx index b3325f6..b5c0140 100644 --- a/src/game/components/GameMessages.tsx +++ b/src/game/components/GameMessages.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components'; import { useTranslate } from '../../settings'; import { defaultTransition, playTone } from '../../utils'; -import { GameMessage, MessageState } from '../../engine/pipes/Messages'; +import { GameMessage, MessageState } from '../plugins/messages'; import { useGameState } from '../hooks/UseGameValue'; import _ from 'lodash'; diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index 879b68f..8e9f50b 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -14,8 +14,10 @@ import Stroke from './stroke'; import Dealer from './dealer'; import EmergencyStop from './emergencyStop'; import Hypno from './hypno'; +import Messages from './messages'; const plugins = [ + Messages, Pause, Phase, Pace, diff --git a/src/game/plugins/messages.ts b/src/game/plugins/messages.ts new file mode 100644 index 0000000..4295754 --- /dev/null +++ b/src/game/plugins/messages.ts @@ -0,0 +1,128 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { pluginPaths } from '../../engine/plugins/Plugins'; +import { Pipe, PipeTransformer } from '../../engine/State'; +import { Composer } from '../../engine/Composer'; +import { Events, GameEvent, getEventKey } from '../../engine/pipes/Events'; +import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Messages: typeof Messages; + } +} + +export interface GameMessagePrompt { + title: string; + event: GameEvent; +} + +export interface GameMessage { + id: string; + title: string; + description?: string; + prompts?: GameMessagePrompt[]; + duration?: number; +} + +export type PartialGameMessage = Partial & Pick; + +export type MessageContext = { + sendMessage: PipeTransformer<[PartialGameMessage]>; +}; + +export type MessageState = { + messages: GameMessage[]; +}; + +const PLUGIN_ID = 'core.messages'; + +const paths = pluginPaths(PLUGIN_ID); + +const eventType = { + send: getEventKey(PLUGIN_ID, 'sendMessage'), + expire: getEventKey(PLUGIN_ID, 'expireMessage'), +}; + +export default class Messages { + static send(message: PartialGameMessage): Pipe { + return Composer.bind(paths.context, ({ sendMessage }) => + sendMessage(message) + ); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Messages', + }, + + activate: Composer.do(({ set }) => { + set(paths.state, { messages: [] }); + set(paths.context, { + sendMessage: msg => + Events.dispatch({ + type: eventType.send, + payload: msg, + }), + }); + }), + + update: Composer.pipe( + Events.handle(eventType.send, event => + Composer.pipe( + Composer.over(paths.state, ({ messages = [] }) => { + const patch = event.payload as GameMessage; + const index = messages.findIndex(m => m.id === patch.id); + const existing = messages[index]; + + if (!existing && !patch.title) return { messages }; + + const updated = [...messages]; + updated[index < 0 ? updated.length : index] = { + ...existing, + ...patch, + }; + + return { messages: updated }; + }), + + Composer.do(({ get, pipe }) => { + const { messages = [] } = get(paths.state); + const messageId = (event.payload as GameMessage).id; + const updated = messages.find(m => m.id === messageId); + const scheduleId = getScheduleKey( + PLUGIN_ID, + `message/${messageId}` + ); + + if (updated?.duration !== undefined) { + return pipe( + Scheduler.schedule({ + id: scheduleId, + duration: updated.duration, + event: { + type: eventType.expire, + payload: updated.id, + }, + }) + ); + } else { + return pipe(Scheduler.cancel(scheduleId)); + } + }) + ) + ), + + Events.handle(eventType.expire, event => + Composer.over(paths.state, ({ messages = [] }) => ({ + messages: messages.filter(m => m.id !== event.payload), + })) + ) + ), + + deactivate: Composer.pipe( + Composer.set(paths.state, undefined), + Composer.set(paths.context, undefined) + ), + }; +} diff --git a/src/game/plugins/pause.test.ts b/src/game/plugins/pause.test.ts index f9b7db1..592dbc1 100644 --- a/src/game/plugins/pause.test.ts +++ b/src/game/plugins/pause.test.ts @@ -6,13 +6,13 @@ import { Scheduler, ScheduledEvent, } from '../../engine/pipes/Scheduler'; -import { messagesPipe } from '../../engine/pipes/Messages'; import { pluginManagerPipe, PluginManager, } from '../../engine/plugins/PluginManager'; import { GameFrame, Pipe } from '../../engine/State'; import { PluginClass } from '../../engine/plugins/Plugins'; +import Messages from './messages'; import Pause, { PauseState } from './pause'; const makeFrame = (): GameFrame => ({ @@ -33,7 +33,6 @@ const tick = (frame: GameFrame, dt = 16): GameFrame => ({ const gamePipe: Pipe = Composer.pipe( eventPipe, schedulerPipe, - messagesPipe, pluginManagerPipe ); @@ -59,6 +58,7 @@ function bootstrap(): GameFrame { }; let frame = gamePipe(makeFrame()); + frame = PluginManager.register(makePluginClass(Messages.plugin))(frame); frame = PluginManager.register(makePluginClass(Pause.plugin))(frame); frame = PluginManager.register(makePluginClass(dealerPlugin))(frame); frame = gamePipe(tick(frame)); diff --git a/src/game/plugins/warmup.ts b/src/game/plugins/warmup.ts index f415223..3eda47f 100644 --- a/src/game/plugins/warmup.ts +++ b/src/game/plugins/warmup.ts @@ -1,7 +1,7 @@ import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine'; import { Events, getEventKey } from '../../engine/pipes/Events'; -import { Messages } from '../../engine/pipes/Messages'; +import Messages from './messages'; import { Scheduler, getScheduleKey } from '../../engine/pipes/Scheduler'; import { typedPath } from '../../engine/Lens'; import { Settings } from '../../settings'; From 1a4be75d5204b5c7429a402e50ccd06e963315dc Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 10:42:58 +0100 Subject: [PATCH 52/90] removed old fps pipe --- src/engine/pipes/Fps.ts | 31 ------------------------------ src/engine/pipes/index.ts | 1 - src/game/components/FpsDisplay.tsx | 28 --------------------------- 3 files changed, 60 deletions(-) delete mode 100644 src/engine/pipes/Fps.ts delete mode 100644 src/game/components/FpsDisplay.tsx diff --git a/src/engine/pipes/Fps.ts b/src/engine/pipes/Fps.ts deleted file mode 100644 index a4ecf3b..0000000 --- a/src/engine/pipes/Fps.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Pipe } from '../State'; -import { Composer } from '../Composer'; - -export type FpsContext = { - value: number; - history: number[]; -}; - -const PLUGIN_NAMESPACE = 'core.fps'; -const HISTORY_SIZE = 30; - -export const fpsPipe: Pipe = Composer.bind( - ['context', 'deltaTime'], - delta => - Composer.bind(['context', PLUGIN_NAMESPACE], existingFps => - Composer.chain(frame => { - const currentFps = delta > 0 ? 1000 / delta : 0; - const history = existingFps?.history ?? []; - - const newHistory = [...history, currentFps].slice(-HISTORY_SIZE); - const newFpsData: FpsContext = { - value: currentFps, - history: newHistory, - }; - - return frame.zoom(['context'], context => - context.set(PLUGIN_NAMESPACE, newFpsData) - ); - }) - ) -); diff --git a/src/engine/pipes/index.ts b/src/engine/pipes/index.ts index 3b3f416..23aba7f 100644 --- a/src/engine/pipes/index.ts +++ b/src/engine/pipes/index.ts @@ -1,5 +1,4 @@ export * from './Events'; -export * from './Fps'; export * from '../plugins/PluginInstaller'; export * from '../plugins/PluginManager'; export * from '../plugins/Plugins'; diff --git a/src/game/components/FpsDisplay.tsx b/src/game/components/FpsDisplay.tsx deleted file mode 100644 index e0bf7b4..0000000 --- a/src/game/components/FpsDisplay.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FpsContext } from '../../engine'; -import { useGameContext } from '../hooks'; - -export const FpsDisplay = () => { - const { history, value } = useGameContext('core.fps'); - - const fps = Math.round( - history?.length > 0 - ? history.reduce((sum, fps) => sum + fps, 0) / history.length - : value ?? 0 - ); - - return ( -
- FPS: {fps || '...'} -
- ); -}; From 460d33f08caf229f819d6056734bd9cc2154a26b Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 11:00:27 +0100 Subject: [PATCH 53/90] moved completing dice state into dealer --- src/game/plugins/dealer.ts | 6 +++++- src/game/plugins/dice/cleanUp.ts | 4 ++-- src/game/plugins/dice/climax.ts | 4 ++-- src/game/plugins/dice/doublePace.ts | 4 ++-- src/game/plugins/dice/edge.ts | 4 ++-- src/game/plugins/dice/halfPace.ts | 4 ++-- src/game/plugins/dice/pause.ts | 4 ++-- src/game/plugins/dice/randomGrip.ts | 4 ++-- src/game/plugins/dice/randomPace.ts | 4 ++-- src/game/plugins/dice/risingPace.ts | 4 ++-- src/game/plugins/dice/types.ts | 6 +++--- 11 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index 5731616..8c15d89 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -6,7 +6,7 @@ import { Sequence } from '../Sequence'; import Phase, { GamePhase } from './phase'; import Pause from './pause'; import { GameEvent as GameEventType } from '../../types'; -import { PLUGIN_ID, dice, settings, DiceOutcome } from './dice/types'; +import { PLUGIN_ID, dice, settings, OUTCOME_DONE, DiceOutcome } from './dice/types'; import { edgeOutcome } from './dice/edge'; import { pauseOutcome } from './dice/pause'; import { randomPaceOutcome } from './dice/randomPace'; @@ -103,6 +103,10 @@ export default class Dealer { ...outcomes.map(o => o.update), + Events.handle(OUTCOME_DONE, () => + Composer.set(dice.state.busy, false) + ), + Phase.onLeave(GamePhase.active, () => Composer.set(dice.state.busy, false) ), diff --git a/src/game/plugins/dice/cleanUp.ts b/src/game/plugins/dice/cleanUp.ts index 55ed3c6..f428ed3 100644 --- a/src/game/plugins/dice/cleanUp.ts +++ b/src/game/plugins/dice/cleanUp.ts @@ -9,7 +9,7 @@ import { PLUGIN_ID, intensityState, settings, - setBusy, + outcomeDone, DiceOutcome, } from './types'; @@ -40,7 +40,7 @@ export const cleanUpOutcome: DiceOutcome = { prompts: undefined, }), Phase.setPhase(GamePhase.active), - setBusy(false) + outcomeDone() ) ) ), diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts index 49aac09..03c1f99 100644 --- a/src/game/plugins/dice/climax.ts +++ b/src/game/plugins/dice/climax.ts @@ -8,7 +8,7 @@ import { PLUGIN_ID, intensityState, settings, - setBusy, + outcomeDone, DiceOutcome, } from './types'; import { edged } from './edge'; @@ -149,7 +149,7 @@ export const climaxOutcome: DiceOutcome = { seq.message({ title: 'Start to $stroke again', duration: 5000 }), Pace.setPace(s.minPace), Phase.setPhase(GamePhase.active), - setBusy(false) + outcomeDone() ) ) ), diff --git a/src/game/plugins/dice/doublePace.ts b/src/game/plugins/dice/doublePace.ts index d0338c2..c08b20f 100644 --- a/src/game/plugins/dice/doublePace.ts +++ b/src/game/plugins/dice/doublePace.ts @@ -8,7 +8,7 @@ import { paceState, intensityState, settings, - setBusy, + outcomeDone, DiceOutcome, } from './types'; import { doRandomPace } from './randomPace'; @@ -52,7 +52,7 @@ export const doublePaceOutcome: DiceOutcome = { duration: 5000, }), doRandomPace(), - setBusy(false) + outcomeDone() ) ) ), diff --git a/src/game/plugins/dice/edge.ts b/src/game/plugins/dice/edge.ts index b79e06b..ceff5ae 100644 --- a/src/game/plugins/dice/edge.ts +++ b/src/game/plugins/dice/edge.ts @@ -7,7 +7,7 @@ import { PLUGIN_ID, intensityState, settings, - setBusy, + outcomeDone, DiceOutcome, } from './types'; @@ -35,6 +35,6 @@ export const edgeOutcome: DiceOutcome = { ) ) ), - seq.on('done', () => setBusy(false)) + seq.on('done', () => outcomeDone()) ), }; diff --git a/src/game/plugins/dice/halfPace.ts b/src/game/plugins/dice/halfPace.ts index d453386..3a39002 100644 --- a/src/game/plugins/dice/halfPace.ts +++ b/src/game/plugins/dice/halfPace.ts @@ -8,7 +8,7 @@ import { paceState, intensityState, settings, - setBusy, + outcomeDone, DiceOutcome, } from './types'; import { doRandomPace } from './randomPace'; @@ -56,7 +56,7 @@ export const halfPaceOutcome: DiceOutcome = { duration: 5000, }), doRandomPace(), - setBusy(false) + outcomeDone() ) ) ), diff --git a/src/game/plugins/dice/pause.ts b/src/game/plugins/dice/pause.ts index c443c71..faae261 100644 --- a/src/game/plugins/dice/pause.ts +++ b/src/game/plugins/dice/pause.ts @@ -2,7 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; import { GameEvent as GameEventType } from '../../../types'; -import { PLUGIN_ID, intensityState, setBusy, DiceOutcome } from './types'; +import { PLUGIN_ID, intensityState, outcomeDone, DiceOutcome } from './types'; const seq = Sequence.for(PLUGIN_ID, 'pause'); @@ -25,7 +25,7 @@ export const pauseOutcome: DiceOutcome = { Composer.pipe( seq.message({ title: 'Start stroking again!', duration: 5000 }), Phase.setPhase(GamePhase.active), - setBusy(false) + outcomeDone() ) ) ), diff --git a/src/game/plugins/dice/randomGrip.ts b/src/game/plugins/dice/randomGrip.ts index 856689b..a64fd3f 100644 --- a/src/game/plugins/dice/randomGrip.ts +++ b/src/game/plugins/dice/randomGrip.ts @@ -2,7 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { typedPath } from '../../../engine/Lens'; import { Sequence } from '../../Sequence'; import { GameEvent as GameEventType } from '../../../types'; -import { PLUGIN_ID, setBusy, DiceOutcome } from './types'; +import { PLUGIN_ID, outcomeDone, DiceOutcome } from './types'; export enum Paws { left = 'left', @@ -44,6 +44,6 @@ export const randomGripOutcome: DiceOutcome = { ); }) ), - seq.on('done', () => setBusy(false)) + seq.on('done', () => outcomeDone()) ), }; diff --git a/src/game/plugins/dice/randomPace.ts b/src/game/plugins/dice/randomPace.ts index ec453d5..2429601 100644 --- a/src/game/plugins/dice/randomPace.ts +++ b/src/game/plugins/dice/randomPace.ts @@ -8,7 +8,7 @@ import { PLUGIN_ID, intensityState, settings, - setBusy, + outcomeDone, DiceOutcome, } from './types'; @@ -39,6 +39,6 @@ export const randomPaceOutcome: DiceOutcome = { id: GameEventType.randomPace, update: Composer.pipe( seq.on(() => Composer.pipe(doRandomPace(), seq.after(9000, 'done'))), - seq.on('done', () => setBusy(false)) + seq.on('done', () => outcomeDone()) ), }; diff --git a/src/game/plugins/dice/risingPace.ts b/src/game/plugins/dice/risingPace.ts index 4346be8..90f0dd0 100644 --- a/src/game/plugins/dice/risingPace.ts +++ b/src/game/plugins/dice/risingPace.ts @@ -7,7 +7,7 @@ import { PLUGIN_ID, intensityState, settings, - setBusy, + outcomeDone, DiceOutcome, } from './types'; import { doRandomPace } from './randomPace'; @@ -62,6 +62,6 @@ export const risingPaceOutcome: DiceOutcome = { seq.after(15000, 'done') ) ), - seq.on('done', () => Composer.pipe(doRandomPace(), setBusy(false))) + seq.on('done', () => Composer.pipe(doRandomPace(), outcomeDone())) ), }; diff --git a/src/game/plugins/dice/types.ts b/src/game/plugins/dice/types.ts index 703df8e..e5d2e9a 100644 --- a/src/game/plugins/dice/types.ts +++ b/src/game/plugins/dice/types.ts @@ -1,5 +1,5 @@ import { Pipe, GameFrame } from '../../../engine/State'; -import { Composer } from '../../../engine/Composer'; +import { Events, getEventKey } from '../../../engine/pipes/Events'; import { pluginPaths } from '../../../engine/plugins/Plugins'; import { typedPath } from '../../../engine/Lens'; import { IntensityState } from '../intensity'; @@ -21,8 +21,8 @@ export const intensityState = typedPath([ ]); export const settings = typedPath(['context', 'settings']); -export const setBusy = (val: boolean): Pipe => - Composer.set(dice.state.busy, val); +export const OUTCOME_DONE = getEventKey(PLUGIN_ID, 'outcome.done'); +export const outcomeDone = (): Pipe => Events.dispatch({ type: OUTCOME_DONE }); export type DiceOutcome = { id: GameEventType; From 5de09a0b17b7e0dccef7bcacaf4b07d185bfdd5b Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 11:39:03 +0100 Subject: [PATCH 54/90] added freezing game on tab or minimize --- src/game/GameProvider.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 62c5b78..00ca6ed 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -58,6 +58,12 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { const loop = (time: number) => { if (!engineRef.current) return; + if (document.hidden) { + lastTimeRef.current = null; + frameId = requestAnimationFrame(loop); + return; + } + if (lastTimeRef.current == null) { lastTimeRef.current = time; frameId = requestAnimationFrame(loop); From 5b976ea5692b33d8c5034e6f8c46a5be478b6f8a Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 12:06:32 +0100 Subject: [PATCH 55/90] applied formatter --- package.json | 2 +- src/game/plugins/dealer.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 9bea6a5..1f153a0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "format": "prettier --write .", + "format": "prettier --write src/", "lint": "eslint . --ext ts,tsx --max-warnings 0", "preview": "vite preview", "test": "vitest run", diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index 8c15d89..e519d8f 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -6,7 +6,13 @@ import { Sequence } from '../Sequence'; import Phase, { GamePhase } from './phase'; import Pause from './pause'; import { GameEvent as GameEventType } from '../../types'; -import { PLUGIN_ID, dice, settings, OUTCOME_DONE, DiceOutcome } from './dice/types'; +import { + PLUGIN_ID, + dice, + settings, + OUTCOME_DONE, + DiceOutcome, +} from './dice/types'; import { edgeOutcome } from './dice/edge'; import { pauseOutcome } from './dice/pause'; import { randomPaceOutcome } from './dice/randomPace'; @@ -103,9 +109,7 @@ export default class Dealer { ...outcomes.map(o => o.update), - Events.handle(OUTCOME_DONE, () => - Composer.set(dice.state.busy, false) - ), + Events.handle(OUTCOME_DONE, () => Composer.set(dice.state.busy, false)), Phase.onLeave(GamePhase.active, () => Composer.set(dice.state.busy, false) From 5d5fdb361c60d455806638af9e83ff9f25400da5 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 13:35:50 +0100 Subject: [PATCH 56/90] improved performance and typing of state access in ui --- src/game/GameProvider.tsx | 7 +- src/game/components/GameEmergencyStop.tsx | 16 ++- src/game/components/GameHypno.tsx | 8 +- src/game/components/GameImages.tsx | 9 +- src/game/components/GameInstructions.tsx | 127 ++++++++++++---------- src/game/components/GameIntensity.tsx | 3 +- src/game/components/GameMessages.tsx | 5 +- src/game/components/GameMeter.tsx | 12 +- src/game/components/GamePace.tsx | 4 +- src/game/components/GameSettings.tsx | 15 +-- src/game/components/GameSound.tsx | 8 +- src/game/components/GameVibrator.tsx | 18 +-- src/game/hooks/UseDispatchEvent.tsx | 14 ++- src/game/hooks/UseGameContext.tsx | 19 ++-- src/game/hooks/UseGameEngine.tsx | 2 +- src/game/hooks/UseGameValue.tsx | 19 ++-- src/game/plugins/dealer.ts | 2 +- src/game/plugins/image.ts | 4 + src/game/plugins/intensity.ts | 4 + src/game/plugins/messages.ts | 4 + src/game/plugins/pace.ts | 4 + src/game/plugins/pause.ts | 4 + src/game/plugins/phase.ts | 4 + 23 files changed, 182 insertions(+), 130 deletions(-) diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 00ca6ed..09c7edf 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -1,4 +1,5 @@ -import { createContext, useEffect, useRef, useState, ReactNode } from 'react'; +import { useEffect, useRef, useState, ReactNode, useCallback } from 'react'; +import { createContext } from 'use-context-selector'; import { GameEngine, GameState, Pipe, GameContext } from '../engine'; import { eventPipe } from '../engine/pipes/Events'; import { schedulerPipe } from '../engine/pipes/Scheduler'; @@ -95,9 +96,9 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { }; }, [pipes]); - const injectImpulse = (pipe: Pipe) => { + const injectImpulse = useCallback((pipe: Pipe) => { pendingImpulseRef.current.push(pipe); - }; + }, []); return ( diff --git a/src/game/components/GameEmergencyStop.tsx b/src/game/components/GameEmergencyStop.tsx index abb4885..32e799a 100644 --- a/src/game/components/GameEmergencyStop.tsx +++ b/src/game/components/GameEmergencyStop.tsx @@ -1,19 +1,17 @@ import { useCallback } from 'react'; import { WaButton, WaIcon } from '@awesome.me/webawesome/dist/react'; -import { useGameEngine, useGameState } from '../hooks'; -import { GamePhase, PhaseState } from '../plugins/phase'; -import { dispatchEvent } from '../../engine/pipes/Events'; +import { useGameState } from '../hooks'; +import { useDispatchEvent } from '../hooks/UseDispatchEvent'; +import Phase, { GamePhase } from '../plugins/phase'; import { getEventKey } from '../../engine/pipes/Events'; export const GameEmergencyStop = () => { - const { current: phase } = useGameState(['core.phase']) ?? {}; - const { injectImpulse } = useGameEngine(); + const phase = useGameState(Phase.paths.state.current) ?? ''; + const { dispatchEvent } = useDispatchEvent(); const onStop = useCallback(() => { - injectImpulse( - dispatchEvent({ type: getEventKey('core.emergencyStop', 'stop') }) - ); - }, [injectImpulse]); + dispatchEvent({ type: getEventKey('core.emergencyStop', 'stop') }); + }, [dispatchEvent]); return ( <> diff --git a/src/game/components/GameHypno.tsx b/src/game/components/GameHypno.tsx index 5b42a3e..2eedd64 100644 --- a/src/game/components/GameHypno.tsx +++ b/src/game/components/GameHypno.tsx @@ -4,8 +4,8 @@ import { GameHypnoType, HypnoPhrases } from '../../types'; import { useMemo } from 'react'; import { motion } from 'framer-motion'; import { useGameState } from '../hooks'; -import { IntensityState } from '../plugins/intensity'; -import { HypnoState } from '../plugins/hypno'; +import Intensity from '../plugins/intensity'; +import Hypno from '../plugins/hypno'; const StyledGameHypno = motion.create(styled.div` pointer-events: none; @@ -16,9 +16,9 @@ const StyledGameHypno = motion.create(styled.div` export const GameHypno = () => { const [hypno] = useSetting('hypno'); - const { currentPhrase = 0 } = useGameState(['core.hypno']) ?? {}; + const { currentPhrase = 0 } = useGameState(Hypno.paths.state) ?? {}; const { intensity = 0 } = - useGameState(['core.intensity']) ?? {}; + useGameState(Intensity.paths.state) ?? {}; const translate = useTranslate(); const phrase = useMemo(() => { diff --git a/src/game/components/GameImages.tsx b/src/game/components/GameImages.tsx index 7aaf477..190bc02 100644 --- a/src/game/components/GameImages.tsx +++ b/src/game/components/GameImages.tsx @@ -5,7 +5,8 @@ import { JoiImage } from '../../common'; import { useImagePreloader } from '../../utils'; import { ImageSize, ImageType } from '../../types'; import { useGameState } from '../hooks'; -import { ImageState } from '../plugins/image'; +import Image from '../plugins/image'; +import Intensity from '../plugins/intensity'; const StyledGameImages = styled.div` position: absolute; @@ -44,10 +45,8 @@ const StyledBackgroundImage = motion.create(styled.div` `); export const GameImages = () => { - const { currentImage, nextImages = [] } = useGameState([ - 'core.images', - ]); - const { intensity } = useGameState(['core.intensity']); + const { currentImage, nextImages = [] } = useGameState(Image.paths.state); + const { intensity } = useGameState(Intensity.paths.state); const [videoSound] = useSetting('videoSound'); const [highRes] = useSetting('highRes'); diff --git a/src/game/components/GameInstructions.tsx b/src/game/components/GameInstructions.tsx index 54411aa..c6f4203 100644 --- a/src/game/components/GameInstructions.tsx +++ b/src/game/components/GameInstructions.tsx @@ -13,9 +13,9 @@ import { GameEvent as GameEventType } from '../../types'; import { ProgressBar } from '../../common'; import { WaDivider } from '@awesome.me/webawesome/dist/react'; import { useGameState } from '../hooks'; -import { PaceState } from '../plugins/pace'; -import { IntensityState } from '../plugins/intensity'; -import { Paws, PawLabels } from '../plugins/dealer'; +import Pace from '../plugins/pace'; +import Intensity from '../plugins/intensity'; +import { Paws, PawLabels, pawsPath } from '../plugins/dealer'; const StyledGameInstructions = styled.div` display: flex; @@ -63,75 +63,90 @@ const StyledIntensityMeter = styled.div` gap: 4px; `; -export const GameInstructions = () => { - const { pace = 0 } = useGameState(['core.pace']) ?? {}; - const { intensity = 0 } = - useGameState(['core.intensity']) ?? {}; - const paws = useGameState(['core.dice', 'paws']) ?? Paws.both; +const PaceDisplay = () => { + const { pace = 0 } = useGameState(Pace.paths.state) ?? {}; const [maxPace] = useSetting('maxPace'); const paceSection = useMemo(() => maxPace / 3, [maxPace]); + return ( + + + + + paceSection && pace <= paceSection * 2} + > + + + paceSection * 2}> + + + + {pace} b/s + + + ); +}; + +const GripDisplay = () => { + const paws = useGameState(pawsPath) ?? Paws.both; + + return ( + + + + + + + + + {PawLabels[paws]} + + + ); +}; + +const IntensityDisplay = () => { + const { intensity = 0 } = + useGameState(Intensity.paths.state) ?? {}; + const intensityPct = Math.round(intensity * 100); + + return ( + + + + + ); +}; + +export const GameInstructions = () => { const [events] = useSetting('events'); const useRandomGrip = useMemo( () => events.includes(GameEventType.randomGrip), [events] ); - const intensityPct = Math.round(intensity * 100); - return ( - - - - - paceSection && pace <= paceSection * 2} - > - - - paceSection * 2}> - - - - {pace} b/s - - + {useRandomGrip && ( <> - - - - - - - - - {PawLabels[paws]} - - + )} - - - - + ); }; diff --git a/src/game/components/GameIntensity.tsx b/src/game/components/GameIntensity.tsx index 727cccd..d0c6e17 100644 --- a/src/game/components/GameIntensity.tsx +++ b/src/game/components/GameIntensity.tsx @@ -1,4 +1,5 @@ import { useGameState } from '../hooks'; +import Intensity from '../plugins/intensity'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faFire } from '@fortawesome/free-solid-svg-icons'; import { ProgressBar } from '../../common'; @@ -13,7 +14,7 @@ const StyledIntensityMeter = styled.div` `; export const GameIntensity = () => { - const { intensity } = useGameState(['core.intensity']); + const { intensity } = useGameState(Intensity.paths.state); return ( diff --git a/src/game/components/GameMessages.tsx b/src/game/components/GameMessages.tsx index b5c0140..dcd7f2d 100644 --- a/src/game/components/GameMessages.tsx +++ b/src/game/components/GameMessages.tsx @@ -4,7 +4,8 @@ import styled from 'styled-components'; import { useTranslate } from '../../settings'; import { defaultTransition, playTone } from '../../utils'; -import { GameMessage, MessageState } from '../plugins/messages'; +import { GameMessage } from '../plugins/messages'; +import Messages from '../plugins/messages'; import { useGameState } from '../hooks/UseGameValue'; import _ from 'lodash'; @@ -59,7 +60,7 @@ const StyledGameMessageButton = motion.create(styled.button` `); export const GameMessages = () => { - const { messages } = useGameState('core.messages'); + const { messages } = useGameState(Messages.paths.state); const { dispatchEvent } = useDispatchEvent(); const translate = useTranslate(); diff --git a/src/game/components/GameMeter.tsx b/src/game/components/GameMeter.tsx index a1ddcc9..24b929a 100644 --- a/src/game/components/GameMeter.tsx +++ b/src/game/components/GameMeter.tsx @@ -3,9 +3,9 @@ import { useMemo } from 'react'; import { motion } from 'framer-motion'; import { defaultTransition } from '../../utils'; import { useGameState } from '../hooks'; -import { GamePhase, PhaseState } from '../plugins/phase'; -import { StrokeDirection, StrokeState } from '../plugins/stroke'; -import { PaceState } from '../plugins/pace'; +import Phase, { GamePhase } from '../plugins/phase'; +import Stroke, { StrokeDirection } from '../plugins/stroke'; +import Pace from '../plugins/pace'; const StyledGameMeter = styled.div` pointer-events: none; @@ -25,9 +25,9 @@ enum MeterColor { } export const GameMeter = () => { - const { stroke } = useGameState(['core.stroke']) ?? {}; - const { current: phase } = useGameState(['core.phase']) ?? {}; - const { pace } = useGameState(['core.pace']) ?? {}; + const { stroke } = useGameState(Stroke.paths.state) ?? {}; + const { current: phase } = useGameState(Phase.paths.state) ?? {}; + const { pace } = useGameState(Pace.paths.state) ?? {}; const switchDuration = useMemo(() => { if (!pace || pace === 0) return 0; diff --git a/src/game/components/GamePace.tsx b/src/game/components/GamePace.tsx index 1d82c1b..e3ce00e 100644 --- a/src/game/components/GamePace.tsx +++ b/src/game/components/GamePace.tsx @@ -1,11 +1,11 @@ import { useEffect } from 'react'; import { useGameContext } from '../hooks'; import { useSetting } from '../../settings'; -import { PaceContext } from '../plugins/pace'; +import Pace from '../plugins/pace'; export const GamePace = () => { const [minPace] = useSetting('minPace'); - const { resetPace } = useGameContext(['core.pace']); + const { resetPace } = useGameContext(Pace.paths.context); useEffect(() => { resetPace(); diff --git a/src/game/components/GameSettings.tsx b/src/game/components/GameSettings.tsx index a94003c..ef36854 100644 --- a/src/game/components/GameSettings.tsx +++ b/src/game/components/GameSettings.tsx @@ -18,8 +18,9 @@ import { WaDivider, WaIcon, } from '@awesome.me/webawesome/dist/react'; -import { useGameEngine, useGameState } from '../hooks'; -import { GamePhase, PhaseState } from '../plugins/phase'; +import { useGameState } from '../hooks'; +import { useDispatchEvent } from '../hooks/UseDispatchEvent'; +import Phase, { GamePhase } from '../plugins/phase'; import Pause from '../plugins/pause'; const StyledGameSettings = styled.div` @@ -59,9 +60,9 @@ const GameSettingsDialogContent = memo(() => ( export const GameSettings = () => { const [open, setOpen] = useState(false); - const { current: phase } = useGameState(['core.phase']) ?? {}; + const { current: phase } = useGameState(Phase.paths.state) ?? {}; const [fullscreen, setFullscreen] = useFullscreen(); - const { injectImpulse } = useGameEngine(); + const { inject } = useDispatchEvent(); const wasActiveRef = useRef(false); const onOpen = useCallback( @@ -69,16 +70,16 @@ export const GameSettings = () => { if (opening) { wasActiveRef.current = phase === GamePhase.active; if (phase === GamePhase.active) { - injectImpulse(Pause.setPaused(true)); + inject(Pause.setPaused(true)); } } else { if (wasActiveRef.current) { - injectImpulse(Pause.setPaused(false)); + inject(Pause.setPaused(false)); } } setOpen(opening); }, - [injectImpulse, phase] + [inject, phase] ); return ( diff --git a/src/game/components/GameSound.tsx b/src/game/components/GameSound.tsx index 00d4cfc..4da6266 100644 --- a/src/game/components/GameSound.tsx +++ b/src/game/components/GameSound.tsx @@ -2,12 +2,12 @@ import { useEffect, useState } from 'react'; import { playTone } from '../../utils/sound'; import { wait } from '../../utils'; import { useGameState } from '../hooks'; -import { GamePhase, PhaseState } from '../plugins/phase'; -import { StrokeDirection, StrokeState } from '../plugins/stroke'; +import Phase, { GamePhase } from '../plugins/phase'; +import Stroke, { StrokeDirection } from '../plugins/stroke'; export const GameSound = () => { - const { stroke } = useGameState(['core.stroke']) ?? {}; - const { current: phase } = useGameState(['core.phase']) ?? {}; + const { stroke } = useGameState(Stroke.paths.state) ?? {}; + const { current: phase } = useGameState(Phase.paths.state) ?? {}; const [currentPhase, setCurrentPhase] = useState(phase); diff --git a/src/game/components/GameVibrator.tsx b/src/game/components/GameVibrator.tsx index 9d81bdc..3729ddf 100644 --- a/src/game/components/GameVibrator.tsx +++ b/src/game/components/GameVibrator.tsx @@ -2,16 +2,18 @@ import { useEffect, useState } from 'react'; import { useAutoRef, useVibratorValue, VibrationMode, wait } from '../../utils'; import { useSetting } from '../../settings'; import { useGameState } from '../hooks'; -import { GamePhase, PhaseState } from '../plugins/phase'; -import { StrokeDirection, StrokeState } from '../plugins/stroke'; -import { PaceState } from '../plugins/pace'; -import { IntensityState } from '../plugins/intensity'; +import { GamePhase } from '../plugins/phase'; +import Phase from '../plugins/phase'; +import { StrokeDirection } from '../plugins/stroke'; +import Stroke from '../plugins/stroke'; +import Pace from '../plugins/pace'; +import Intensity from '../plugins/intensity'; export const GameVibrator = () => { - const { stroke } = useGameState(['core.stroke']) ?? {}; - const { intensity } = useGameState(['core.intensity']) ?? {}; - const { pace } = useGameState(['core.pace']) ?? {}; - const { current: phase } = useGameState(['core.phase']) ?? {}; + const { stroke } = useGameState(Stroke.paths.state) ?? {}; + const { intensity } = useGameState(Intensity.paths.state) ?? {}; + const { pace } = useGameState(Pace.paths.state) ?? {}; + const { current: phase } = useGameState(Phase.paths.state) ?? {}; const [mode] = useSetting('vibrations'); const [devices] = useVibratorValue('devices'); diff --git a/src/game/hooks/UseDispatchEvent.tsx b/src/game/hooks/UseDispatchEvent.tsx index 39acb1a..53d92c4 100644 --- a/src/game/hooks/UseDispatchEvent.tsx +++ b/src/game/hooks/UseDispatchEvent.tsx @@ -1,14 +1,22 @@ import { useMemo } from 'react'; +import { useContextSelector } from 'use-context-selector'; import { dispatchEvent, GameEvent } from '../../engine/pipes/Events'; -import { useGameEngine } from './UseGameEngine'; +import { Pipe } from '../../engine/State'; +import { GameEngineContext } from '../GameProvider'; export function useDispatchEvent() { - const { injectImpulse } = useGameEngine(); + const injectImpulse = useContextSelector( + GameEngineContext, + ctx => ctx?.injectImpulse + ); return useMemo( () => ({ + inject: (pipe: Pipe) => { + injectImpulse?.(pipe); + }, dispatchEvent: (event: GameEvent) => { - injectImpulse(dispatchEvent(event)); + injectImpulse?.(dispatchEvent(event)); }, }), [injectImpulse] diff --git a/src/game/hooks/UseGameContext.tsx b/src/game/hooks/UseGameContext.tsx index 2b7af77..c06cdfc 100644 --- a/src/game/hooks/UseGameContext.tsx +++ b/src/game/hooks/UseGameContext.tsx @@ -1,11 +1,12 @@ -import { Composer } from '../../engine/Composer'; -import { Path } from '../../engine/Lens'; -import { useGameEngine } from './UseGameEngine'; +import { useContextSelector } from 'use-context-selector'; +import { lensFromPath, normalizePath, Path } from '../../engine/Lens'; +import { GameEngineContext } from '../GameProvider'; -export const useGameContext = (path: Path): T => { - const { context } = useGameEngine(); - if (!context) { - return {} as T; - } - return new Composer(context).get(path) ?? ({} as T); +export const useGameContext = (path: Path): T => { + return useContextSelector(GameEngineContext, ctx => { + if (!ctx?.context) return {} as T; + const segments = normalizePath(path); + const effective = segments[0] === 'context' ? segments.slice(1) : segments; + return lensFromPath(effective).get(ctx.context) ?? ({} as T); + }); }; diff --git a/src/game/hooks/UseGameEngine.tsx b/src/game/hooks/UseGameEngine.tsx index 8298025..be226ac 100644 --- a/src/game/hooks/UseGameEngine.tsx +++ b/src/game/hooks/UseGameEngine.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext } from 'use-context-selector'; import { GameEngineContext } from '../GameProvider'; export function useGameEngine() { diff --git a/src/game/hooks/UseGameValue.tsx b/src/game/hooks/UseGameValue.tsx index e5a7507..4b77f42 100644 --- a/src/game/hooks/UseGameValue.tsx +++ b/src/game/hooks/UseGameValue.tsx @@ -1,11 +1,12 @@ -import { Composer } from '../../engine/Composer'; -import { Path } from '../../engine/Lens'; -import { useGameEngine } from './UseGameEngine'; +import { useContextSelector } from 'use-context-selector'; +import { lensFromPath, normalizePath, Path } from '../../engine/Lens'; +import { GameEngineContext } from '../GameProvider'; -export const useGameState = (path: Path): T => { - const { state } = useGameEngine(); - if (!state) { - return {} as T; - } - return new Composer(state).get(path) ?? ({} as T); +export const useGameState = (path: Path): T => { + return useContextSelector(GameEngineContext, ctx => { + if (!ctx?.state) return {} as T; + const segments = normalizePath(path); + const effective = segments[0] === 'state' ? segments.slice(1) : segments; + return lensFromPath(effective).get(ctx.state) ?? ({} as T); + }); }; diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index e519d8f..c8e6f39 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -127,5 +127,5 @@ export default class Dealer { } } -export { Paws, PawLabels } from './dice/randomGrip'; +export { Paws, PawLabels, pawsPath } from './dice/randomGrip'; export { type DiceState } from './dice/types'; diff --git a/src/game/plugins/image.ts b/src/game/plugins/image.ts index 5a492e6..e3ed2ef 100644 --- a/src/game/plugins/image.ts +++ b/src/game/plugins/image.ts @@ -120,4 +120,8 @@ export default class Image { Composer.set(image.context, undefined) ), }; + + static get paths() { + return image; + } } diff --git a/src/game/plugins/intensity.ts b/src/game/plugins/intensity.ts index 94c159e..55be295 100644 --- a/src/game/plugins/intensity.ts +++ b/src/game/plugins/intensity.ts @@ -47,4 +47,8 @@ export default class Intensity { deactivate: Composer.set(intensity.state, undefined), }; + + static get paths() { + return intensity; + } } diff --git a/src/game/plugins/messages.ts b/src/game/plugins/messages.ts index 4295754..243da3b 100644 --- a/src/game/plugins/messages.ts +++ b/src/game/plugins/messages.ts @@ -125,4 +125,8 @@ export default class Messages { Composer.set(paths.context, undefined) ), }; + + static get paths() { + return paths; + } } diff --git a/src/game/plugins/pace.ts b/src/game/plugins/pace.ts index c6dca89..b254b33 100644 --- a/src/game/plugins/pace.ts +++ b/src/game/plugins/pace.ts @@ -57,4 +57,8 @@ export default class Pace { Composer.set(pace.context, undefined) ), }; + + static get paths() { + return pace; + } } diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index e6db56d..ec0066e 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -129,4 +129,8 @@ export default class Pause { Composer.set(pause.context, undefined) ), }; + + static get paths() { + return pause; + } } diff --git a/src/game/plugins/phase.ts b/src/game/plugins/phase.ts index 32def9c..de90348 100644 --- a/src/game/plugins/phase.ts +++ b/src/game/plugins/phase.ts @@ -80,4 +80,8 @@ export default class Phase { Composer.set(phase.context, undefined) ), }; + + static get paths() { + return phase; + } } From b6408bf4d37e648a7af62bd9afad14fd5d3d62b5 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 15:38:23 +0100 Subject: [PATCH 57/90] optimized composer invocations --- src/engine/Composer.ts | 63 +++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index a8c10d0..a20281e 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -19,6 +19,30 @@ export type ComposerScope = { : never]: Composer[K]; }; +function _pipe(obj: T, pipes: ((t: T) => T)[]): T { + let result = obj; + for (const p of pipes) result = p(result); + return result; +} + +function _set(obj: T, path: Path
, value: A): T { + return lensFromPath(path).set(value)(obj); +} + +function _over(obj: T, path: Path, fn: (a: A) => A): T { + return lensFromPath(path).over(fn)(obj); +} + +function _bind(obj: T, path: Path, fn: Transformer<[A], T>): T { + const value = lensFromPath(path).get(obj); + return fn(value)(obj); +} + +function _zoom(obj: T, path: Path, fn: (a: A) => A): T { + const lens = lensFromPath(path); + return lens.set(fn(lens.get(obj)))(obj); +} + /** * A generalized object manipulation utility * in a functional chaining style. @@ -48,7 +72,7 @@ export class Composer { * Applies a series of mapping functions to the current object. */ pipe(...pipes: ((t: T) => T)[]): this { - for (const p of pipes) this.obj = p(this.obj); + this.obj = _pipe(this.obj, pipes); return this; } @@ -56,7 +80,7 @@ export class Composer { * Shorthand for building a composer that applies a series of mapping functions to the current object. */ static pipe(...pipes: ((t: T) => T)[]): (obj: T) => T { - return Composer.chain(composer => composer.pipe(...pipes)); + return (obj: T): T => _pipe(obj, pipes); } /** @@ -95,9 +119,7 @@ export class Composer { if (maybeValue === undefined) { this.obj = pathOrValue as T; } else { - this.obj = lensFromPath(pathOrValue as Path).set(maybeValue)( - this.obj - ); + this.obj = _set(this.obj, pathOrValue as Path, maybeValue); } return this; } @@ -106,8 +128,7 @@ export class Composer { * Shorthand for building a composer that sets a path. */ static set(path: Path, value: A) { - return (obj: T): T => - Composer.chain(c => c.set(path, value))(obj); + return (obj: T): T => _set(obj, path, value); } /** @@ -126,15 +147,14 @@ export class Composer { * Shorthand for building a composer that zooms into a path */ static zoom(path: Path, fn: (a: A) => A) { - return (obj: T): T => - Composer.chain(c => c.zoom(path, c => c.pipe(fn)))(obj); + return (obj: T): T => _zoom(obj, path, fn); } /** * Updates the value at the specified path with the mapping function. */ over(path: Path, fn: (a: A) => A): this { - this.obj = lensFromPath(path).over(fn)(this.obj); + this.obj = _over(this.obj, path, fn); return this; } @@ -142,16 +162,14 @@ export class Composer { * Shorthand for building a composer that updates a path. */ static over(path: Path, fn: (a: A) => A) { - return (obj: T): T => - Composer.chain(c => c.over(path, fn))(obj); + return (obj: T): T => _over(obj, path, fn); } /** * Runs a composer function with the value at the specified path. */ bind(path: Path, fn: Transformer<[A], T>): this { - const value = lensFromPath(path).get(this.obj); - this.obj = fn(value)(this.obj); + this.obj = _bind(this.obj, path, fn); return this; } @@ -159,15 +177,15 @@ export class Composer { * Shorthand for building a composer that reads a value at a path and applies a transformer. */ static bind(path: Path, fn: Transformer<[A], any>) { - return (obj: T): T => - Composer.chain(c => c.bind(path, fn))(obj); + return (obj: T): T => _bind(obj, path, fn); } call (obj: any) => any>( path: Path, ...args: Parameters ): this { - return this.bind(path, (fn: A) => fn(...args)); + this.obj = _bind(this.obj, path, (fn: A) => fn(...args)); + return this; } static call (obj: any) => any>( @@ -175,7 +193,7 @@ export class Composer { ...args: Parameters ) { return (obj: T): T => - Composer.chain(c => c.call(path, ...args))(obj); + _bind(obj, path, (fn: A) => fn(...args)); } /** @@ -198,13 +216,8 @@ export class Composer { fn: (obj: T) => T, elseFn?: (obj: T) => T ): (obj: T) => T { - return Composer.chain(c => - c.when( - condition, - c => c.pipe(fn), - elseFn ? c => c.pipe(elseFn) : undefined - ) - ); + if (condition) return fn; + return elseFn ?? ((obj: T) => obj); } /** From df99b9183964c3756ecf863781740c5f9c356008 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 16:12:39 +0100 Subject: [PATCH 58/90] removed old gamepace component --- src/game/components/GamePace.tsx | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 src/game/components/GamePace.tsx diff --git a/src/game/components/GamePace.tsx b/src/game/components/GamePace.tsx deleted file mode 100644 index e3ce00e..0000000 --- a/src/game/components/GamePace.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useEffect } from 'react'; -import { useGameContext } from '../hooks'; -import { useSetting } from '../../settings'; -import Pace from '../plugins/pace'; - -export const GamePace = () => { - const [minPace] = useSetting('minPace'); - const { resetPace } = useGameContext(Pace.paths.context); - - useEffect(() => { - resetPace(); - }, [minPace, resetPace]); - - return null; -}; From d7c7489cc7ac98ab1f0c335e17714a1d7443561e Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 16:13:48 +0100 Subject: [PATCH 59/90] deleted context api injections in favour of static apis --- src/engine/pipes/Events.ts | 77 ++++++++++------------- src/engine/pipes/Scheduler.ts | 111 +++++++--------------------------- src/game/components/index.ts | 1 - src/game/plugins/image.ts | 41 ++++--------- src/game/plugins/messages.ts | 31 +++------- src/game/plugins/pace.ts | 42 ++++++------- src/game/plugins/pause.ts | 45 +++++--------- src/game/plugins/phase.ts | 22 ++----- 8 files changed, 112 insertions(+), 258 deletions(-) diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index ba1abe8..b8a4e87 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -1,5 +1,6 @@ +import { lensFromPath } from '../Lens'; import { Composer } from '../Composer'; -import { GameContext, GameState, Pipe, PipeTransformer } from '../State'; +import { GameFrame, GameState, Pipe, PipeTransformer } from '../State'; export type GameEvent = { type: string; @@ -11,13 +12,11 @@ export type EventState = { current: GameEvent[]; }; -export type EventContext = { - dispatch: PipeTransformer<[GameEvent]>; - handle: PipeTransformer<[string, PipeTransformer<[GameEvent]>]>; -}; - const PLUGIN_NAMESPACE = 'core.events'; +const eventStateLens = lensFromPath(['state', PLUGIN_NAMESPACE]); +const pendingLens = lensFromPath(['state', PLUGIN_NAMESPACE, 'pending']); + export const getEventKey = (namespace: string, key: string): string => { return `${namespace}/${key}`; }; @@ -36,39 +35,36 @@ export const readEventKey = ( }; export const dispatchEvent: PipeTransformer<[GameEvent]> = event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'pending'], - (pending = []) => [...pending, event] - ); + pendingLens.over((pending = []) => [...pending, event]); export const handleEvent: PipeTransformer< [string, (event: GameEvent) => Pipe] -> = (type, fn) => - Composer.bind(['state', PLUGIN_NAMESPACE], ({ current = [] }) => - Composer.pipe( - ...current - .filter(event => { - const { namespace: ns, key: k } = readEventKey(event.type); - const { namespace, key } = readEventKey(type); - return key === '*' ? ns === namespace : ns === namespace && k === key; - }) - .map(fn) - ) - ); +> = (type, fn) => { + const { namespace, key } = readEventKey(type); + const isWildcard = key === '*'; + const prefix = namespace + '/'; + + return (obj: GameFrame): GameFrame => { + const state = eventStateLens.get(obj); + const current = state?.current; + if (!current || current.length === 0) return obj; + let result: GameFrame = obj; + for (const event of current) { + if (isWildcard ? event.type.startsWith(prefix) : event.type === type) { + result = fn(event)(result); + } + } + return result; + }; +}; export class Events { static dispatch(event: GameEvent): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ dispatch }) => dispatch(event) - ); + return dispatchEvent(event); } static handle(type: string, fn: (event: GameEvent) => Pipe): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ handle }) => handle(type, fn) - ); + return handleEvent(type, fn); } } @@ -77,19 +73,10 @@ export class Events { * This prevents events from being processed during the same frame they are created. * This is important because pipes later in the pipeline may add new events. */ -export const eventPipe: Pipe = Composer.pipe( - Composer.zoom( - 'state', - Composer.over(PLUGIN_NAMESPACE, ({ pending = [] }) => ({ - pending: [], - current: pending, - })) - ), - Composer.zoom( - 'context', - Composer.set(PLUGIN_NAMESPACE, { - dispatch: dispatchEvent, - handle: handleEvent, - }) - ) +export const eventPipe: Pipe = Composer.zoom( + 'state', + Composer.over(PLUGIN_NAMESPACE, ({ pending = [] }) => ({ + pending: [], + current: pending, + })) ); diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index a07214b..1c2e852 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -1,5 +1,5 @@ import { Composer } from '../Composer'; -import { Pipe, PipeTransformer } from '../State'; +import { Pipe } from '../State'; import { Events, GameEvent, getEventKey } from './Events'; const PLUGIN_NAMESPACE = 'core.scheduler'; @@ -20,64 +20,43 @@ type SchedulerState = { current: GameEvent[]; }; -export type SchedulerContext = { - schedule: PipeTransformer<[ScheduledEvent]>; - cancel: PipeTransformer<[string]>; - hold: PipeTransformer<[string]>; - release: PipeTransformer<[string]>; - holdByPrefix: PipeTransformer<[string]>; - releaseByPrefix: PipeTransformer<[string]>; - cancelByPrefix: PipeTransformer<[string]>; +const eventType = { + schedule: getEventKey(PLUGIN_NAMESPACE, 'schedule'), + cancel: getEventKey(PLUGIN_NAMESPACE, 'cancel'), + hold: getEventKey(PLUGIN_NAMESPACE, 'hold'), + release: getEventKey(PLUGIN_NAMESPACE, 'release'), + holdByPrefix: getEventKey(PLUGIN_NAMESPACE, 'holdByPrefix'), + releaseByPrefix: getEventKey(PLUGIN_NAMESPACE, 'releaseByPrefix'), + cancelByPrefix: getEventKey(PLUGIN_NAMESPACE, 'cancelByPrefix'), }; export class Scheduler { static schedule(event: ScheduledEvent): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ schedule }) => schedule(event) - ); + return Events.dispatch({ type: eventType.schedule, payload: event }); } static cancel(id: string): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ cancel }) => cancel(id) - ); + return Events.dispatch({ type: eventType.cancel, payload: id }); } static hold(id: string): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ hold }) => hold(id) - ); + return Events.dispatch({ type: eventType.hold, payload: id }); } static release(id: string): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ release }) => release(id) - ); + return Events.dispatch({ type: eventType.release, payload: id }); } static holdByPrefix(prefix: string): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ holdByPrefix }) => holdByPrefix(prefix) - ); + return Events.dispatch({ type: eventType.holdByPrefix, payload: prefix }); } static releaseByPrefix(prefix: string): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ releaseByPrefix }) => releaseByPrefix(prefix) - ); + return Events.dispatch({ type: eventType.releaseByPrefix, payload: prefix }); } static cancelByPrefix(prefix: string): Pipe { - return Composer.bind( - ['context', PLUGIN_NAMESPACE], - ({ cancelByPrefix }) => cancelByPrefix(prefix) - ); + return Events.dispatch({ type: eventType.cancelByPrefix, payload: prefix }); } } @@ -111,51 +90,7 @@ export const schedulerPipe: Pipe = Composer.pipe( Composer.pipe(...events.map(Events.dispatch)) ), - Composer.set(['context', PLUGIN_NAMESPACE], { - schedule: (e: ScheduledEvent) => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'schedule'), - payload: e, - }), - - cancel: (id: string) => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'cancel'), - payload: id, - }), - - hold: (id: string) => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'hold'), - payload: id, - }), - - release: (id: string) => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'release'), - payload: id, - }), - - holdByPrefix: (prefix: string) => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'holdByPrefix'), - payload: prefix, - }), - - releaseByPrefix: (prefix: string) => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'releaseByPrefix'), - payload: prefix, - }), - - cancelByPrefix: (prefix: string) => - Events.dispatch({ - type: getEventKey(PLUGIN_NAMESPACE, 'cancelByPrefix'), - payload: prefix, - }), - }), - - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'schedule'), event => + Events.handle(eventType.schedule, event => Composer.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => [ @@ -165,14 +100,14 @@ export const schedulerPipe: Pipe = Composer.pipe( ) ), - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'cancel'), event => + Events.handle(eventType.cancel, event => Composer.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => list.filter(s => s.id !== event.payload) ) ), - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'hold'), event => + Events.handle(eventType.hold, event => Composer.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => @@ -180,7 +115,7 @@ export const schedulerPipe: Pipe = Composer.pipe( ) ), - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'release'), event => + Events.handle(eventType.release, event => Composer.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => @@ -188,7 +123,7 @@ export const schedulerPipe: Pipe = Composer.pipe( ) ), - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'holdByPrefix'), event => + Events.handle(eventType.holdByPrefix, event => Composer.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => @@ -198,7 +133,7 @@ export const schedulerPipe: Pipe = Composer.pipe( ) ), - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'releaseByPrefix'), event => + Events.handle(eventType.releaseByPrefix, event => Composer.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => @@ -208,7 +143,7 @@ export const schedulerPipe: Pipe = Composer.pipe( ) ), - Events.handle(getEventKey(PLUGIN_NAMESPACE, 'cancelByPrefix'), event => + Events.handle(eventType.cancelByPrefix, event => Composer.over( ['state', PLUGIN_NAMESPACE, 'scheduled'], (list = []) => list.filter(s => !s.id?.startsWith(event.payload)) diff --git a/src/game/components/index.ts b/src/game/components/index.ts index 4bde389..9f17d6b 100644 --- a/src/game/components/index.ts +++ b/src/game/components/index.ts @@ -5,7 +5,6 @@ export * from './GameInstructions'; export * from './GameIntensity'; export * from './GameMessages'; export * from './GameMeter'; -export * from './GamePace'; export * from './GameSettings'; export * from './GameSound'; export * from './GameVibrator'; diff --git a/src/game/plugins/image.ts b/src/game/plugins/image.ts index e3ed2ef..20b05ce 100644 --- a/src/game/plugins/image.ts +++ b/src/game/plugins/image.ts @@ -1,5 +1,5 @@ import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; -import { Pipe, PipeTransformer } from '../../engine/State'; +import { Pipe } from '../../engine/State'; import { Composer } from '../../engine'; import { Events, getEventKey } from '../../engine/pipes/Events'; import { ImageItem } from '../../types'; @@ -18,13 +18,7 @@ export type ImageState = { nextImages: ImageItem[]; }; -type ImageContext = { - pushNextImage: PipeTransformer<[ImageItem]>; - setCurrentImage: PipeTransformer<[ImageItem | undefined]>; - setNextImages: PipeTransformer<[ImageItem[]]>; -}; - -const image = pluginPaths(PLUGIN_ID); +const image = pluginPaths(PLUGIN_ID); const eventType = { pushNext: getEventKey(PLUGIN_ID, 'pushNext'), @@ -34,15 +28,15 @@ const eventType = { export default class Image { static pushNextImage(img: ImageItem): Pipe { - return Composer.call(image.context.pushNextImage, img); + return Events.dispatch({ type: eventType.pushNext, payload: img }); } static setCurrentImage(img: ImageItem | undefined): Pipe { - return Composer.call(image.context.setCurrentImage, img); + return Events.dispatch({ type: eventType.setImage, payload: img }); } static setNextImages(imgs: ImageItem[]): Pipe { - return Composer.call(image.context.setNextImages, imgs); + return Events.dispatch({ type: eventType.setNextImages, payload: imgs }); } static plugin: Plugin = { @@ -51,21 +45,11 @@ export default class Image { name: 'Image', }, - activate: Composer.pipe( - Composer.set(image.state, { - currentImage: undefined, - seenImages: [], - nextImages: [], - }), - Composer.set(image.context, { - pushNextImage: (img: ImageItem) => - Events.dispatch({ type: eventType.pushNext, payload: img }), - setCurrentImage: (img: ImageItem | undefined) => - Events.dispatch({ type: eventType.setImage, payload: img }), - setNextImages: (imgs: ImageItem[]) => - Events.dispatch({ type: eventType.setNextImages, payload: imgs }), - }) - ), + activate: Composer.set(image.state, { + currentImage: undefined, + seenImages: [], + nextImages: [], + }), update: Composer.pipe( Events.handle(eventType.pushNext, event => @@ -115,10 +99,7 @@ export default class Image { ) ), - deactivate: Composer.pipe( - Composer.set(image.state, undefined), - Composer.set(image.context, undefined) - ), + deactivate: Composer.set(image.state, undefined), }; static get paths() { diff --git a/src/game/plugins/messages.ts b/src/game/plugins/messages.ts index 243da3b..906bd59 100644 --- a/src/game/plugins/messages.ts +++ b/src/game/plugins/messages.ts @@ -1,6 +1,6 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { pluginPaths } from '../../engine/plugins/Plugins'; -import { Pipe, PipeTransformer } from '../../engine/State'; +import { Pipe } from '../../engine/State'; import { Composer } from '../../engine/Composer'; import { Events, GameEvent, getEventKey } from '../../engine/pipes/Events'; import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; @@ -26,17 +26,13 @@ export interface GameMessage { export type PartialGameMessage = Partial & Pick; -export type MessageContext = { - sendMessage: PipeTransformer<[PartialGameMessage]>; -}; - export type MessageState = { messages: GameMessage[]; }; const PLUGIN_ID = 'core.messages'; -const paths = pluginPaths(PLUGIN_ID); +const paths = pluginPaths(PLUGIN_ID); const eventType = { send: getEventKey(PLUGIN_ID, 'sendMessage'), @@ -45,9 +41,10 @@ const eventType = { export default class Messages { static send(message: PartialGameMessage): Pipe { - return Composer.bind(paths.context, ({ sendMessage }) => - sendMessage(message) - ); + return Events.dispatch({ + type: eventType.send, + payload: message, + }); } static plugin: Plugin = { @@ -56,16 +53,7 @@ export default class Messages { name: 'Messages', }, - activate: Composer.do(({ set }) => { - set(paths.state, { messages: [] }); - set(paths.context, { - sendMessage: msg => - Events.dispatch({ - type: eventType.send, - payload: msg, - }), - }); - }), + activate: Composer.set(paths.state, { messages: [] }), update: Composer.pipe( Events.handle(eventType.send, event => @@ -120,10 +108,7 @@ export default class Messages { ) ), - deactivate: Composer.pipe( - Composer.set(paths.state, undefined), - Composer.set(paths.context, undefined) - ), + deactivate: Composer.set(paths.state, undefined), }; static get paths() { diff --git a/src/game/plugins/pace.ts b/src/game/plugins/pace.ts index b254b33..756aa82 100644 --- a/src/game/plugins/pace.ts +++ b/src/game/plugins/pace.ts @@ -1,5 +1,5 @@ import type { Plugin } from '../../engine/plugins/Plugins'; -import { Pipe, PipeTransformer } from '../../engine/State'; +import { Pipe } from '../../engine/State'; import { typedPath } from '../../engine/Lens'; import { Settings } from '../../settings'; import { Composer, pluginPaths } from '../../engine'; @@ -14,23 +14,21 @@ const PLUGIN_ID = 'core.pace'; export type PaceState = { pace: number; + prevMinPace: number; }; -export type PaceContext = { - setPace: PipeTransformer<[number]>; - resetPace: PipeTransformer<[]>; -}; - -const pace = pluginPaths(PLUGIN_ID); +const pace = pluginPaths(PLUGIN_ID); const settings = typedPath(['context', 'settings']); export default class Pace { static setPace(val: number): Pipe { - return Composer.call(pace.context.setPace, val); + return Composer.set(pace.state.pace, val); } static resetPace(): Pipe { - return Composer.call(pace.context.resetPace); + return Composer.bind(settings, s => + Composer.set(pace.state.pace, s.minPace) + ); } static plugin: Plugin = { @@ -39,23 +37,19 @@ export default class Pace { name: 'Pace', }, - activate: Composer.pipe( - Composer.bind(settings, s => - Composer.set(pace.state, { pace: s.minPace }) - ), - Composer.set(pace.context, { - setPace: (val: number) => Composer.set(pace.state.pace, val), - resetPace: () => - Composer.bind(settings, s => - Composer.set(pace.state.pace, s.minPace) - ), - }) + activate: Composer.bind(settings, s => + Composer.set(pace.state, { pace: s.minPace, prevMinPace: s.minPace }) ), - deactivate: Composer.pipe( - Composer.set(pace.state, undefined), - Composer.set(pace.context, undefined) - ), + update: Composer.do(({ get, set }) => { + const { prevMinPace } = get(pace.state); + const { minPace } = get(settings); + if (minPace === prevMinPace) return; + set(pace.state.prevMinPace, minPace); + set(pace.state.pace, minPace); + }), + + deactivate: Composer.set(pace.state, undefined), }; static get paths() { diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index ec0066e..15bfa23 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -1,5 +1,5 @@ import type { Plugin } from '../../engine/plugins/Plugins'; -import { Pipe, PipeTransformer } from '../../engine/State'; +import { Pipe } from '../../engine/State'; import { Composer } from '../../engine/Composer'; import { Events, getEventKey } from '../../engine/pipes/Events'; import { pluginPaths } from '../../engine/plugins/Plugins'; @@ -18,12 +18,7 @@ export type PauseState = { prev: boolean; }; -type PauseContext = { - setPaused: PipeTransformer<[boolean]>; - togglePause: Pipe; -}; - -const pause = pluginPaths(PLUGIN_ID); +const pause = pluginPaths(PLUGIN_ID); const eventType = { on: getEventKey(PLUGIN_ID, 'on'), @@ -34,11 +29,20 @@ const resume = Sequence.for(PLUGIN_ID, 'resume'); export default class Pause { static setPaused(val: boolean): Pipe { - return Composer.call(pause.context.setPaused, val); + return Composer.when( + val, + Composer.pipe( + resume.cancel(), + Composer.set(pause.state.paused, true) + ), + resume.start() + ); } static get togglePause(): Pipe { - return Composer.bind(pause.context.togglePause, fn => fn); + return Composer.bind(pause.state, state => + Pause.setPaused(!state?.paused) + ); } static whenPaused(pipe: Pipe): Pipe { @@ -67,23 +71,7 @@ export default class Pause { name: 'Pause', }, - activate: Composer.do(({ set }) => { - set(pause.state, { paused: false, prev: false }); - set(pause.context, { - setPaused: val => - Composer.when( - val, - Composer.pipe( - resume.cancel(), - Composer.set(pause.state.paused, true) - ), - resume.start() - ), - togglePause: Composer.bind(pause.state, state => - Pause.setPaused(!state?.paused) - ), - }); - }), + activate: Composer.set(pause.state, { paused: false, prev: false }), update: Composer.pipe( Composer.do(({ get, set, pipe }) => { @@ -124,10 +112,7 @@ export default class Pause { ) ), - deactivate: Composer.pipe( - Composer.set(pause.state, undefined), - Composer.set(pause.context, undefined) - ), + deactivate: Composer.set(pause.state, undefined), }; static get paths() { diff --git a/src/game/plugins/phase.ts b/src/game/plugins/phase.ts index de90348..0a4e7af 100644 --- a/src/game/plugins/phase.ts +++ b/src/game/plugins/phase.ts @@ -1,5 +1,5 @@ import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; -import { Pipe, PipeTransformer } from '../../engine/State'; +import { Pipe } from '../../engine/State'; import { Events, getEventKey } from '../../engine/pipes/Events'; import { Composer } from '../../engine'; @@ -24,11 +24,7 @@ export type PhaseState = { prev: string; }; -type PhaseContext = { - setPhase: PipeTransformer<[string]>; -}; - -const phase = pluginPaths(PLUGIN_ID); +const phase = pluginPaths(PLUGIN_ID); const eventType = { enter: (p: string) => getEventKey(PLUGIN_ID, `enter.${p}`), @@ -37,7 +33,7 @@ const eventType = { export default class Phase { static setPhase(p: string): Pipe { - return Composer.call(phase.context.setPhase, p); + return Composer.set(phase.state.current, p); } static whenPhase(p: string, pipe: Pipe): Pipe { @@ -60,12 +56,7 @@ export default class Phase { name: 'Phase', }, - activate: Composer.do(({ set }) => { - set(phase.state, { current: GamePhase.warmup, prev: GamePhase.warmup }); - set(phase.context, { - setPhase: p => Composer.set(phase.state.current, p), - }); - }), + activate: Composer.set(phase.state, { current: GamePhase.warmup, prev: GamePhase.warmup }), update: Composer.do(({ get, set, pipe }) => { const { current, prev } = get(phase.state); @@ -75,10 +66,7 @@ export default class Phase { pipe(Events.dispatch({ type: eventType.enter(current) })); }), - deactivate: Composer.pipe( - Composer.set(phase.state, undefined), - Composer.set(phase.context, undefined) - ), + deactivate: Composer.set(phase.state, undefined), }; static get paths() { From a1285500e3d9d19c1f1797525c4ed76d50b009de Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 16:34:29 +0100 Subject: [PATCH 60/90] refactored old pipes to use typed paths --- src/engine/pipes/Events.ts | 19 +++---- src/engine/pipes/Scheduler.ts | 101 +++++++++++++++------------------- src/engine/pipes/Storage.ts | 29 ++++++---- 3 files changed, 71 insertions(+), 78 deletions(-) diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index b8a4e87..fe99a70 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -1,6 +1,7 @@ import { lensFromPath } from '../Lens'; import { Composer } from '../Composer'; -import { GameFrame, GameState, Pipe, PipeTransformer } from '../State'; +import { pluginPaths } from '../plugins/Plugins'; +import { GameFrame, Pipe, PipeTransformer } from '../State'; export type GameEvent = { type: string; @@ -14,8 +15,9 @@ export type EventState = { const PLUGIN_NAMESPACE = 'core.events'; -const eventStateLens = lensFromPath(['state', PLUGIN_NAMESPACE]); -const pendingLens = lensFromPath(['state', PLUGIN_NAMESPACE, 'pending']); +const events = pluginPaths(PLUGIN_NAMESPACE); +const eventStateLens = lensFromPath(events.state); +const pendingLens = lensFromPath(events.state.pending); export const getEventKey = (namespace: string, key: string): string => { return `${namespace}/${key}`; @@ -73,10 +75,7 @@ export class Events { * This prevents events from being processed during the same frame they are created. * This is important because pipes later in the pipeline may add new events. */ -export const eventPipe: Pipe = Composer.zoom( - 'state', - Composer.over(PLUGIN_NAMESPACE, ({ pending = [] }) => ({ - pending: [], - current: pending, - })) -); +export const eventPipe: Pipe = Composer.over(events.state, ({ pending = [] }) => ({ + pending: [], + current: pending, +})); diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 1c2e852..1913edb 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -1,5 +1,7 @@ import { Composer } from '../Composer'; -import { Pipe } from '../State'; +import { typedPath } from '../Lens'; +import { pluginPaths } from '../plugins/Plugins'; +import { GameTiming, Pipe } from '../State'; import { Events, GameEvent, getEventKey } from './Events'; const PLUGIN_NAMESPACE = 'core.scheduler'; @@ -20,6 +22,9 @@ type SchedulerState = { current: GameEvent[]; }; +const scheduler = pluginPaths(PLUGIN_NAMESPACE); +const timing = typedPath(['context']); + const eventType = { schedule: getEventKey(PLUGIN_NAMESPACE, 'schedule'), cancel: getEventKey(PLUGIN_NAMESPACE, 'cancel'), @@ -61,92 +66,76 @@ export class Scheduler { } export const schedulerPipe: Pipe = Composer.pipe( - Composer.bind(['context', 'deltaTime'], delta => - Composer.over( - ['state', PLUGIN_NAMESPACE], - ({ scheduled = [] }) => { - const remaining: ScheduledEvent[] = []; - const current: GameEvent[] = []; - - for (const entry of scheduled) { - if (entry.held) { - remaining.push(entry); - continue; - } - const time = entry.duration - delta; - if (time <= 0) { - current.push(entry.event); - } else { - remaining.push({ ...entry, duration: time }); - } + Composer.bind(timing.deltaTime, delta => + Composer.over(scheduler.state, ({ scheduled = [] }) => { + const remaining: ScheduledEvent[] = []; + const current: GameEvent[] = []; + + for (const entry of scheduled) { + if (entry.held) { + remaining.push(entry); + continue; + } + const time = entry.duration - delta; + if (time <= 0) { + current.push(entry.event); + } else { + remaining.push({ ...entry, duration: time }); } - - return { scheduled: remaining, current }; } - ) + + return { scheduled: remaining, current }; + }) ), - Composer.bind(['state', PLUGIN_NAMESPACE, 'current'], events => + Composer.bind(scheduler.state.current, events => Composer.pipe(...events.map(Events.dispatch)) ), Events.handle(eventType.schedule, event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => [ - ...list.filter(e => e.id !== event.payload.id), - event.payload, - ] - ) + Composer.over(scheduler.state.scheduled, (list = []) => [ + ...list.filter(e => e.id !== event.payload.id), + event.payload, + ]) ), Events.handle(eventType.cancel, event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => list.filter(s => s.id !== event.payload) + Composer.over(scheduler.state.scheduled, (list = []) => + list.filter(s => s.id !== event.payload) ) ), Events.handle(eventType.hold, event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => - list.map(s => (s.id === event.payload ? { ...s, held: true } : s)) + Composer.over(scheduler.state.scheduled, (list = []) => + list.map(s => (s.id === event.payload ? { ...s, held: true } : s)) ) ), Events.handle(eventType.release, event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => - list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) + Composer.over(scheduler.state.scheduled, (list = []) => + list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) ) ), Events.handle(eventType.holdByPrefix, event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => - list.map(s => - s.id?.startsWith(event.payload) ? { ...s, held: true } : s - ) + Composer.over(scheduler.state.scheduled, (list = []) => + list.map(s => + s.id?.startsWith(event.payload) ? { ...s, held: true } : s + ) ) ), Events.handle(eventType.releaseByPrefix, event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => - list.map(s => - s.id?.startsWith(event.payload) ? { ...s, held: false } : s - ) + Composer.over(scheduler.state.scheduled, (list = []) => + list.map(s => + s.id?.startsWith(event.payload) ? { ...s, held: false } : s + ) ) ), Events.handle(eventType.cancelByPrefix, event => - Composer.over( - ['state', PLUGIN_NAMESPACE, 'scheduled'], - (list = []) => list.filter(s => !s.id?.startsWith(event.payload)) + Composer.over(scheduler.state.scheduled, (list = []) => + list.filter(s => !s.id?.startsWith(event.payload)) ) ) ); diff --git a/src/engine/pipes/Storage.ts b/src/engine/pipes/Storage.ts index 76a83a7..2303a31 100644 --- a/src/engine/pipes/Storage.ts +++ b/src/engine/pipes/Storage.ts @@ -12,7 +12,9 @@ */ import { Composer } from '../Composer'; -import { Pipe } from '../State'; +import { typedPath } from '../Lens'; +import { pluginPaths } from '../plugins/Plugins'; +import { GameTiming, Pipe } from '../State'; export const STORAGE_NAMESPACE = 'how.joi.storage'; const CACHE_TTL = 30000; // 30 seconds of game time (deltaTime sum) @@ -26,13 +28,16 @@ export type StorageContext = { cache: { [key: string]: CacheEntry }; }; +const storage = pluginPaths(STORAGE_NAMESPACE); +const timing = typedPath(['context']); + /** * Storage pipe - updates cache expiry based on deltaTime */ export const storagePipe: Pipe = Composer.pipe( // Clean up expired entries - Composer.bind(['context', 'elapsedTime'], elapsedTime => - Composer.over(['context', STORAGE_NAMESPACE], ctx => { + Composer.bind(timing.elapsedTime, elapsedTime => + Composer.over(storage.context, ctx => { const newCache: { [key: string]: CacheEntry } = {}; // Keep only non-expired entries @@ -70,8 +75,8 @@ export class Storage { * Gets a value, using cache or loading from localStorage */ static bind(key: string, fn: (value: T | undefined) => Pipe): Pipe { - return Composer.bind( - ['context', STORAGE_NAMESPACE], + return Composer.bind( + storage.context, ctx => { const cache = ctx?.cache || {}; const cached = cache[key]; @@ -86,9 +91,9 @@ export class Storage { // Cache it (will be set with expiry in the next pipe) return Composer.pipe( - Composer.bind(['context', 'elapsedTime'], elapsedTime => - Composer.over( - ['context', STORAGE_NAMESPACE], + Composer.bind(timing.elapsedTime, elapsedTime => + Composer.over( + storage.context, ctx => ({ cache: { ...(ctx?.cache || {}), @@ -119,8 +124,8 @@ export class Storage { } // Update cache - return Composer.bind(['context', 'elapsedTime'], elapsedTime => - Composer.over(['context', STORAGE_NAMESPACE], ctx => ({ + return Composer.bind(timing.elapsedTime, elapsedTime => + Composer.over(storage.context, ctx => ({ cache: { ...(ctx?.cache || {}), [key]: { @@ -146,8 +151,8 @@ export class Storage { } // Remove from cache - return Composer.over( - ['context', STORAGE_NAMESPACE], + return Composer.over( + storage.context, ctx => { const newCache = { ...(ctx?.cache || {}) }; delete newCache[key]; From 6eaa94009155e4b91eccb194a44f68e1deef6a78 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 16:56:37 +0100 Subject: [PATCH 61/90] updated event pipe dsl --- src/engine/pipes/Events.ts | 99 +++++++++++----------- src/engine/pipes/Perf.test.ts | 6 +- src/engine/pipes/Perf.ts | 7 +- src/engine/pipes/Scheduler.ts | 21 ++--- src/engine/plugins/PluginInstaller.test.ts | 4 +- src/engine/plugins/PluginManager.test.ts | 4 +- src/engine/plugins/PluginManager.ts | 15 ++-- src/game/GameProvider.tsx | 4 +- src/game/Sequence.ts | 11 +-- src/game/components/GameEmergencyStop.tsx | 4 +- src/game/hooks/UseDispatchEvent.tsx | 4 +- src/game/plugins/dealer.ts | 4 +- src/game/plugins/dice/types.ts | 4 +- src/game/plugins/image.ts | 8 +- src/game/plugins/messages.ts | 15 ++-- src/game/plugins/pause.test.ts | 4 +- src/game/plugins/pause.ts | 7 +- src/game/plugins/phase.ts | 6 +- src/game/plugins/randomImages.ts | 6 +- src/game/plugins/warmup.ts | 6 +- src/utils/case.ts | 6 ++ 21 files changed, 115 insertions(+), 130 deletions(-) create mode 100644 src/utils/case.ts diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index fe99a70..11a6c8e 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -1,7 +1,8 @@ import { lensFromPath } from '../Lens'; import { Composer } from '../Composer'; import { pluginPaths } from '../plugins/Plugins'; -import { GameFrame, Pipe, PipeTransformer } from '../State'; +import { GameFrame, Pipe } from '../State'; +import { CamelCase, toCamel } from '../../utils/case'; export type GameEvent = { type: string; @@ -19,63 +20,61 @@ const events = pluginPaths(PLUGIN_NAMESPACE); const eventStateLens = lensFromPath(events.state); const pendingLens = lensFromPath(events.state.pending); -export const getEventKey = (namespace: string, key: string): string => { - return `${namespace}/${key}`; -}; - -export const readEventKey = ( - key: string -): { namespace: string; key: string } => { - const index = key.indexOf('/'); - if (index === -1) { - throw new Error(`Invalid event key: "${key}"`); +export class Events { + static getKey(namespace: string, key: string): string { + return `${namespace}/${key}`; } - return { - namespace: key.slice(0, index), - key: key.slice(index + 1), - }; -}; -export const dispatchEvent: PipeTransformer<[GameEvent]> = event => - pendingLens.over((pending = []) => [...pending, event]); - -export const handleEvent: PipeTransformer< - [string, (event: GameEvent) => Pipe] -> = (type, fn) => { - const { namespace, key } = readEventKey(type); - const isWildcard = key === '*'; - const prefix = namespace + '/'; + static getKeys( + namespace: string, + ...keys: K[] + ): { [P in K as CamelCase

]: string } { + return Object.fromEntries( + keys.map(k => [toCamel(k), Events.getKey(namespace, k)]) + ) as { [P in K as CamelCase

]: string }; + } - return (obj: GameFrame): GameFrame => { - const state = eventStateLens.get(obj); - const current = state?.current; - if (!current || current.length === 0) return obj; - let result: GameFrame = obj; - for (const event of current) { - if (isWildcard ? event.type.startsWith(prefix) : event.type === type) { - result = fn(event)(result); - } + static parseKey(key: string): { namespace: string; key: string } { + const index = key.indexOf('/'); + if (index === -1) { + throw new Error(`Invalid event key: "${key}"`); } - return result; - }; -}; + return { + namespace: key.slice(0, index), + key: key.slice(index + 1), + }; + } -export class Events { static dispatch(event: GameEvent): Pipe { - return dispatchEvent(event); + return pendingLens.over((pending = []) => [...pending, event]); } static handle(type: string, fn: (event: GameEvent) => Pipe): Pipe { - return handleEvent(type, fn); + const { namespace, key } = Events.parseKey(type); + const isWildcard = key === '*'; + const prefix = namespace + '/'; + + return (obj: GameFrame): GameFrame => { + const state = eventStateLens.get(obj); + const current = state?.current; + if (!current || current.length === 0) return obj; + let result: GameFrame = obj; + for (const event of current) { + if (isWildcard ? event.type.startsWith(prefix) : event.type === type) { + result = fn(event)(result); + } + } + return result; + }; } -} -/** - * Moves events from pending to current. - * This prevents events from being processed during the same frame they are created. - * This is important because pipes later in the pipeline may add new events. - */ -export const eventPipe: Pipe = Composer.over(events.state, ({ pending = [] }) => ({ - pending: [], - current: pending, -})); + /** + * Moves events from pending to current. + * This prevents events from being processed during the same frame they are created. + * This is important because pipes later in the pipeline may add new events. + */ + static pipe: Pipe = Composer.over(events.state, ({ pending = [] }) => ({ + pending: [], + current: pending, + })); +} diff --git a/src/engine/pipes/Perf.test.ts b/src/engine/pipes/Perf.test.ts index b959eb6..e4fb2d0 100644 --- a/src/engine/pipes/Perf.test.ts +++ b/src/engine/pipes/Perf.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { Composer } from '../Composer'; import { GameFrame, Pipe } from '../State'; -import { eventPipe } from './Events'; +import { Events } from './Events'; import { perfPipe, withTiming, @@ -26,7 +26,7 @@ const tick = (frame: GameFrame): GameFrame => ({ }, }); -const basePipe: Pipe = Composer.pipe(eventPipe, perfPipe); +const basePipe: Pipe = Composer.pipe(Events.pipe, perfPipe); const getPerfCtx = (frame: GameFrame): PerfContext | undefined => (frame.context as any)?.core?.perf; @@ -160,7 +160,7 @@ describe('Perf', () => { it('should work without perfPipe (graceful fallback)', () => { const noop: Pipe = frame => frame; const pipe = Composer.pipe( - eventPipe, + Events.pipe, withTiming('test.plugin', 'update', noop) ); diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index 7601e6b..0a3f392 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -1,6 +1,6 @@ import { Composer } from '../Composer'; import { GameContext, GameFrame, Pipe } from '../State'; -import { Events, getEventKey, GameEvent } from './Events'; +import { Events, GameEvent } from './Events'; import { pluginPaths, PluginId } from '../plugins/Plugins'; import { typedPath } from '../Lens'; @@ -36,10 +36,7 @@ const DEFAULT_CONFIG: PerfConfig = { pluginBudget: 1, }; -const eventType = { - overBudget: getEventKey(PLUGIN_NAMESPACE, 'over_budget'), - configure: getEventKey(PLUGIN_NAMESPACE, 'configure'), -}; +const eventType = Events.getKeys(PLUGIN_NAMESPACE, 'over_budget', 'configure'); const perf = pluginPaths(PLUGIN_NAMESPACE); const gameContext = typedPath(['context']); diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 1913edb..5d7159e 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -2,7 +2,7 @@ import { Composer } from '../Composer'; import { typedPath } from '../Lens'; import { pluginPaths } from '../plugins/Plugins'; import { GameTiming, Pipe } from '../State'; -import { Events, GameEvent, getEventKey } from './Events'; +import { Events, GameEvent } from './Events'; const PLUGIN_NAMESPACE = 'core.scheduler'; @@ -25,15 +25,16 @@ type SchedulerState = { const scheduler = pluginPaths(PLUGIN_NAMESPACE); const timing = typedPath(['context']); -const eventType = { - schedule: getEventKey(PLUGIN_NAMESPACE, 'schedule'), - cancel: getEventKey(PLUGIN_NAMESPACE, 'cancel'), - hold: getEventKey(PLUGIN_NAMESPACE, 'hold'), - release: getEventKey(PLUGIN_NAMESPACE, 'release'), - holdByPrefix: getEventKey(PLUGIN_NAMESPACE, 'holdByPrefix'), - releaseByPrefix: getEventKey(PLUGIN_NAMESPACE, 'releaseByPrefix'), - cancelByPrefix: getEventKey(PLUGIN_NAMESPACE, 'cancelByPrefix'), -}; +const eventType = Events.getKeys( + PLUGIN_NAMESPACE, + 'schedule', + 'cancel', + 'hold', + 'release', + 'hold_by_prefix', + 'release_by_prefix', + 'cancel_by_prefix' +); export class Scheduler { static schedule(event: ScheduledEvent): Pipe { diff --git a/src/engine/plugins/PluginInstaller.test.ts b/src/engine/plugins/PluginInstaller.test.ts index 8b4f1ad..681e490 100644 --- a/src/engine/plugins/PluginInstaller.test.ts +++ b/src/engine/plugins/PluginInstaller.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { pluginInstallerPipe } from './PluginInstaller'; import { pluginManagerPipe } from './PluginManager'; -import { eventPipe } from '../pipes/Events'; +import { Events } from '../pipes/Events'; import { Composer } from '../Composer'; import { GameFrame, Pipe } from '../State'; import { sdk } from '../sdk'; @@ -26,7 +26,7 @@ const tick = (frame: GameFrame): GameFrame => ({ }); const fullPipe: Pipe = Composer.pipe( - eventPipe, + Events.pipe, pluginManagerPipe, pluginInstallerPipe ); diff --git a/src/engine/plugins/PluginManager.test.ts b/src/engine/plugins/PluginManager.test.ts index 143ed26..a5a5a7a 100644 --- a/src/engine/plugins/PluginManager.test.ts +++ b/src/engine/plugins/PluginManager.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Plugin, PluginClass, EnabledMap } from './Plugins'; import { PluginManager, pluginManagerPipe } from './PluginManager'; -import { eventPipe } from '../pipes/Events'; +import { Events } from '../pipes/Events'; import { Composer } from '../Composer'; import { Pipe, GameFrame } from '../State'; @@ -23,7 +23,7 @@ const tick = (frame: GameFrame, n = 1): GameFrame => ({ }, }); -const gamePipe: Pipe = Composer.pipe(eventPipe, pluginManagerPipe); +const gamePipe: Pipe = Composer.pipe(Events.pipe, pluginManagerPipe); const getLoadedIds = (frame: GameFrame): string[] => (frame.state as any)?.core?.plugin_manager?.loaded ?? []; diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index 52aac6d..39ed0a2 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -6,7 +6,7 @@ import { flushDOMOperations, } from '../DOMBatcher'; import { Storage } from '../pipes/Storage'; -import { Events, getEventKey } from '../pipes/Events'; +import { Events } from '../pipes/Events'; import { pluginPaths, type PluginId, @@ -19,12 +19,13 @@ import { sdk } from '../sdk'; const PLUGIN_NAMESPACE = 'core.plugin_manager'; -const eventType = { - register: getEventKey(PLUGIN_NAMESPACE, 'register'), - unregister: getEventKey(PLUGIN_NAMESPACE, 'unregister'), - enable: getEventKey(PLUGIN_NAMESPACE, 'enable'), - disable: getEventKey(PLUGIN_NAMESPACE, 'disable'), -}; +const eventType = Events.getKeys( + PLUGIN_NAMESPACE, + 'register', + 'unregister', + 'enable', + 'disable' +); const storageKey = { enabled: `${PLUGIN_NAMESPACE}.enabled`, diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 09c7edf..f38f843 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, ReactNode, useCallback } from 'react'; import { createContext } from 'use-context-selector'; import { GameEngine, GameState, Pipe, GameContext } from '../engine'; -import { eventPipe } from '../engine/pipes/Events'; +import { Events } from '../engine/pipes/Events'; import { schedulerPipe } from '../engine/pipes/Scheduler'; import { perfPipe } from '../engine/pipes/Perf'; import { Piper } from '../engine/Piper'; @@ -51,7 +51,7 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { engineRef.current = new GameEngine( {}, - Piper([impulsePipe, eventPipe, schedulerPipe, perfPipe, ...pipes]) + Piper([impulsePipe, Events.pipe, schedulerPipe, perfPipe, ...pipes]) ); let frameId: number; diff --git a/src/game/Sequence.ts b/src/game/Sequence.ts index 33c87a7..eb6ff15 100644 --- a/src/game/Sequence.ts +++ b/src/game/Sequence.ts @@ -1,10 +1,5 @@ import { Pipe } from '../engine/State'; -import { - Events, - getEventKey, - Scheduler, - getScheduleKey, -} from '../engine/pipes'; +import { Events, Scheduler, getScheduleKey } from '../engine/pipes'; import Messages from './plugins/messages'; type EventHandler = Parameters[1]; @@ -27,8 +22,8 @@ export type SequenceScope = { export class Sequence { static for(namespace: string, name: string): SequenceScope { - const rootKey = getEventKey(namespace, name); - const nodeKey = (n: string) => getEventKey(namespace, `${name}.${n}`); + const rootKey = Events.getKey(namespace, name); + const nodeKey = (n: string) => Events.getKey(namespace, `${name}.${n}`); const schedKey = (n: string) => getScheduleKey(namespace, `${name}.${n}`); return { diff --git a/src/game/components/GameEmergencyStop.tsx b/src/game/components/GameEmergencyStop.tsx index 32e799a..838696f 100644 --- a/src/game/components/GameEmergencyStop.tsx +++ b/src/game/components/GameEmergencyStop.tsx @@ -3,14 +3,14 @@ import { WaButton, WaIcon } from '@awesome.me/webawesome/dist/react'; import { useGameState } from '../hooks'; import { useDispatchEvent } from '../hooks/UseDispatchEvent'; import Phase, { GamePhase } from '../plugins/phase'; -import { getEventKey } from '../../engine/pipes/Events'; +import { Events } from '../../engine/pipes/Events'; export const GameEmergencyStop = () => { const phase = useGameState(Phase.paths.state.current) ?? ''; const { dispatchEvent } = useDispatchEvent(); const onStop = useCallback(() => { - dispatchEvent({ type: getEventKey('core.emergencyStop', 'stop') }); + dispatchEvent({ type: Events.getKey('core.emergencyStop', 'stop') }); }, [dispatchEvent]); return ( diff --git a/src/game/hooks/UseDispatchEvent.tsx b/src/game/hooks/UseDispatchEvent.tsx index 53d92c4..6a5ef4e 100644 --- a/src/game/hooks/UseDispatchEvent.tsx +++ b/src/game/hooks/UseDispatchEvent.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useContextSelector } from 'use-context-selector'; -import { dispatchEvent, GameEvent } from '../../engine/pipes/Events'; +import { Events, GameEvent } from '../../engine/pipes/Events'; import { Pipe } from '../../engine/State'; import { GameEngineContext } from '../GameProvider'; @@ -16,7 +16,7 @@ export function useDispatchEvent() { injectImpulse?.(pipe); }, dispatchEvent: (event: GameEvent) => { - injectImpulse?.(dispatchEvent(event)); + injectImpulse?.(Events.dispatch(event)); }, }), [injectImpulse] diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index c8e6f39..2ec0be5 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -1,6 +1,6 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine/Composer'; -import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Events } from '../../engine/pipes/Events'; import { Scheduler } from '../../engine/pipes/Scheduler'; import { Sequence } from '../Sequence'; import Phase, { GamePhase } from './phase'; @@ -52,7 +52,7 @@ const rollChances: Record = { }; const eventKeyForOutcome = (id: GameEventType): string => - getEventKey(PLUGIN_ID, id); + Events.getKey(PLUGIN_ID, id); const roll = Sequence.for(PLUGIN_ID, 'roll'); diff --git a/src/game/plugins/dice/types.ts b/src/game/plugins/dice/types.ts index e5d2e9a..6bf91f7 100644 --- a/src/game/plugins/dice/types.ts +++ b/src/game/plugins/dice/types.ts @@ -1,5 +1,5 @@ import { Pipe, GameFrame } from '../../../engine/State'; -import { Events, getEventKey } from '../../../engine/pipes/Events'; +import { Events } from '../../../engine/pipes/Events'; import { pluginPaths } from '../../../engine/plugins/Plugins'; import { typedPath } from '../../../engine/Lens'; import { IntensityState } from '../intensity'; @@ -21,7 +21,7 @@ export const intensityState = typedPath([ ]); export const settings = typedPath(['context', 'settings']); -export const OUTCOME_DONE = getEventKey(PLUGIN_ID, 'outcome.done'); +export const OUTCOME_DONE = Events.getKey(PLUGIN_ID, 'outcome.done'); export const outcomeDone = (): Pipe => Events.dispatch({ type: OUTCOME_DONE }); export type DiceOutcome = { diff --git a/src/game/plugins/image.ts b/src/game/plugins/image.ts index 20b05ce..99dd158 100644 --- a/src/game/plugins/image.ts +++ b/src/game/plugins/image.ts @@ -1,7 +1,7 @@ import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; import { Pipe } from '../../engine/State'; import { Composer } from '../../engine'; -import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Events } from '../../engine/pipes/Events'; import { ImageItem } from '../../types'; declare module '../../engine/sdk' { @@ -20,11 +20,7 @@ export type ImageState = { const image = pluginPaths(PLUGIN_ID); -const eventType = { - pushNext: getEventKey(PLUGIN_ID, 'pushNext'), - setImage: getEventKey(PLUGIN_ID, 'setImage'), - setNextImages: getEventKey(PLUGIN_ID, 'setNextImages'), -}; +const eventType = Events.getKeys(PLUGIN_ID, 'push_next', 'set_image', 'set_next_images'); export default class Image { static pushNextImage(img: ImageItem): Pipe { diff --git a/src/game/plugins/messages.ts b/src/game/plugins/messages.ts index 906bd59..df096c2 100644 --- a/src/game/plugins/messages.ts +++ b/src/game/plugins/messages.ts @@ -2,7 +2,7 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { pluginPaths } from '../../engine/plugins/Plugins'; import { Pipe } from '../../engine/State'; import { Composer } from '../../engine/Composer'; -import { Events, GameEvent, getEventKey } from '../../engine/pipes/Events'; +import { Events, GameEvent } from '../../engine/pipes/Events'; import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; declare module '../../engine/sdk' { @@ -34,15 +34,12 @@ const PLUGIN_ID = 'core.messages'; const paths = pluginPaths(PLUGIN_ID); -const eventType = { - send: getEventKey(PLUGIN_ID, 'sendMessage'), - expire: getEventKey(PLUGIN_ID, 'expireMessage'), -}; +const eventType = Events.getKeys(PLUGIN_ID, 'send_message', 'expire_message'); export default class Messages { static send(message: PartialGameMessage): Pipe { return Events.dispatch({ - type: eventType.send, + type: eventType.sendMessage, payload: message, }); } @@ -56,7 +53,7 @@ export default class Messages { activate: Composer.set(paths.state, { messages: [] }), update: Composer.pipe( - Events.handle(eventType.send, event => + Events.handle(eventType.sendMessage, event => Composer.pipe( Composer.over(paths.state, ({ messages = [] }) => { const patch = event.payload as GameMessage; @@ -89,7 +86,7 @@ export default class Messages { id: scheduleId, duration: updated.duration, event: { - type: eventType.expire, + type: eventType.expireMessage, payload: updated.id, }, }) @@ -101,7 +98,7 @@ export default class Messages { ) ), - Events.handle(eventType.expire, event => + Events.handle(eventType.expireMessage, event => Composer.over(paths.state, ({ messages = [] }) => ({ messages: messages.filter(m => m.id !== event.payload), })) diff --git a/src/game/plugins/pause.test.ts b/src/game/plugins/pause.test.ts index 592dbc1..022a2ae 100644 --- a/src/game/plugins/pause.test.ts +++ b/src/game/plugins/pause.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Composer } from '../../engine/Composer'; -import { eventPipe } from '../../engine/pipes/Events'; +import { Events } from '../../engine/pipes/Events'; import { schedulerPipe, Scheduler, @@ -31,7 +31,7 @@ const tick = (frame: GameFrame, dt = 16): GameFrame => ({ }); const gamePipe: Pipe = Composer.pipe( - eventPipe, + Events.pipe, schedulerPipe, pluginManagerPipe ); diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index 15bfa23..fe7635f 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -1,7 +1,7 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { Pipe } from '../../engine/State'; import { Composer } from '../../engine/Composer'; -import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Events } from '../../engine/pipes/Events'; import { pluginPaths } from '../../engine/plugins/Plugins'; import { Sequence } from '../Sequence'; @@ -20,10 +20,7 @@ export type PauseState = { const pause = pluginPaths(PLUGIN_ID); -const eventType = { - on: getEventKey(PLUGIN_ID, 'on'), - off: getEventKey(PLUGIN_ID, 'off'), -}; +const eventType = Events.getKeys(PLUGIN_ID, 'on', 'off'); const resume = Sequence.for(PLUGIN_ID, 'resume'); diff --git a/src/game/plugins/phase.ts b/src/game/plugins/phase.ts index 0a4e7af..fbf755a 100644 --- a/src/game/plugins/phase.ts +++ b/src/game/plugins/phase.ts @@ -1,6 +1,6 @@ import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; import { Pipe } from '../../engine/State'; -import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Events } from '../../engine/pipes/Events'; import { Composer } from '../../engine'; declare module '../../engine/sdk' { @@ -27,8 +27,8 @@ export type PhaseState = { const phase = pluginPaths(PLUGIN_ID); const eventType = { - enter: (p: string) => getEventKey(PLUGIN_ID, `enter.${p}`), - leave: (p: string) => getEventKey(PLUGIN_ID, `leave.${p}`), + enter: (p: string) => Events.getKey(PLUGIN_ID, `enter.${p}`), + leave: (p: string) => Events.getKey(PLUGIN_ID, `leave.${p}`), }; export default class Phase { diff --git a/src/game/plugins/randomImages.ts b/src/game/plugins/randomImages.ts index a3c4ad9..33ff4bb 100644 --- a/src/game/plugins/randomImages.ts +++ b/src/game/plugins/randomImages.ts @@ -1,6 +1,6 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine'; -import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Events } from '../../engine/pipes/Events'; import { Scheduler, getScheduleKey } from '../../engine/pipes/Scheduler'; import { typedPath } from '../../engine/Lens'; import { ImageItem } from '../../types'; @@ -19,9 +19,7 @@ const images = typedPath(['context', 'images']); const intensityState = typedPath(['state', 'core.intensity']); const imageState = typedPath(['state', 'core.images']); -const eventType = { - scheduleNext: getEventKey(PLUGIN_ID, 'scheduleNext'), -}; +const eventType = Events.getKeys(PLUGIN_ID, 'schedule_next'); const scheduleId = getScheduleKey(PLUGIN_ID, 'randomImageSwitch'); diff --git a/src/game/plugins/warmup.ts b/src/game/plugins/warmup.ts index 3eda47f..5f12482 100644 --- a/src/game/plugins/warmup.ts +++ b/src/game/plugins/warmup.ts @@ -1,6 +1,6 @@ import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine'; -import { Events, getEventKey } from '../../engine/pipes/Events'; +import { Events } from '../../engine/pipes/Events'; import Messages from './messages'; import { Scheduler, getScheduleKey } from '../../engine/pipes/Scheduler'; import { typedPath } from '../../engine/Lens'; @@ -27,9 +27,7 @@ const settings = typedPath(gameContext.settings); const AUTOSTART_KEY = getScheduleKey(PLUGIN_ID, 'autoStart'); -const eventType = { - startGame: getEventKey(PLUGIN_ID, 'startGame'), -}; +const eventType = Events.getKeys(PLUGIN_ID, 'start_game'); export default class Warmup { static plugin: Plugin = { diff --git a/src/utils/case.ts b/src/utils/case.ts new file mode 100644 index 0000000..e9cd875 --- /dev/null +++ b/src/utils/case.ts @@ -0,0 +1,6 @@ +export type CamelCase = S extends `${infer H}_${infer T}` + ? `${H}${Capitalize>}` + : S; + +export const toCamel = (s: string) => + s.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); From 5a3d10fcd301357255fa90dcf75471aaf87b8ec1 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 17:08:51 +0100 Subject: [PATCH 62/90] updated primitive pipes to plugin pattern --- src/engine/pipes/Perf.test.ts | 32 ++--- src/engine/pipes/Perf.ts | 216 ++++++++++++++-------------- src/engine/pipes/Scheduler.ts | 140 +++++++++--------- src/engine/pipes/Storage.test.ts | 5 +- src/engine/pipes/Storage.ts | 40 +++--- src/engine/plugins/PluginManager.ts | 8 +- src/game/GameProvider.tsx | 6 +- src/game/Sequence.ts | 6 +- src/game/plugins/messages.ts | 4 +- src/game/plugins/pause.test.ts | 3 +- src/game/plugins/randomImages.ts | 4 +- src/game/plugins/warmup.ts | 4 +- 12 files changed, 231 insertions(+), 237 deletions(-) diff --git a/src/engine/pipes/Perf.test.ts b/src/engine/pipes/Perf.test.ts index e4fb2d0..a8a1d09 100644 --- a/src/engine/pipes/Perf.test.ts +++ b/src/engine/pipes/Perf.test.ts @@ -3,8 +3,6 @@ import { Composer } from '../Composer'; import { GameFrame, Pipe } from '../State'; import { Events } from './Events'; import { - perfPipe, - withTiming, Perf, type PerfContext, type PluginPerfEntry, @@ -26,7 +24,7 @@ const tick = (frame: GameFrame): GameFrame => ({ }, }); -const basePipe: Pipe = Composer.pipe(Events.pipe, perfPipe); +const basePipe: Pipe = Composer.pipe(Events.pipe, Perf.pipe); const getPerfCtx = (frame: GameFrame): PerfContext | undefined => (frame.context as any)?.core?.perf; @@ -39,12 +37,12 @@ const getEntry = ( (getPerfCtx(frame)?.plugins as any)?.[pluginId]?.[phase]; describe('Perf', () => { - describe('perfPipe', () => { + describe('Perf.pipe', () => { it('should preserve existing metrics across frames', () => { const frame0 = basePipe(makeFrame()); const noop: Pipe = frame => frame; - const timed = withTiming('test.plugin', 'update', noop); + const timed = Perf.withTiming('test.plugin', 'update', noop); const frame1 = Composer.pipe(basePipe, timed)(tick(frame0)); expect(getEntry(frame1, 'test.plugin', 'update')).toBeDefined(); @@ -59,7 +57,7 @@ describe('Perf', () => { const noop: Pipe = frame => frame; const pipe = Composer.pipe( basePipe, - withTiming('test.plugin', 'update', noop) + Perf.withTiming('test.plugin', 'update', noop) ); const result = pipe(makeFrame()); @@ -76,7 +74,7 @@ describe('Perf', () => { const noop: Pipe = frame => frame; const pipe = Composer.pipe( basePipe, - withTiming('test.plugin', 'update', noop) + Perf.withTiming('test.plugin', 'update', noop) ); let frame = makeFrame(); @@ -92,7 +90,7 @@ describe('Perf', () => { const noop: Pipe = frame => frame; const pipe = Composer.pipe( basePipe, - withTiming('test.plugin', 'update', noop) + Perf.withTiming('test.plugin', 'update', noop) ); let frame = makeFrame(); @@ -117,7 +115,7 @@ describe('Perf', () => { const pipe = Composer.pipe( basePipe, - withTiming('test.plugin', 'update', sometimesSlow) + Perf.withTiming('test.plugin', 'update', sometimesSlow) ); let frame = pipe(makeFrame()); @@ -133,8 +131,8 @@ describe('Perf', () => { const noop: Pipe = frame => frame; const pipe = Composer.pipe( basePipe, - withTiming('test.plugin', 'activate', noop), - withTiming('test.plugin', 'update', noop) + Perf.withTiming('test.plugin', 'activate', noop), + Perf.withTiming('test.plugin', 'update', noop) ); const result = pipe(makeFrame()); @@ -147,8 +145,8 @@ describe('Perf', () => { const noop: Pipe = frame => frame; const pipe = Composer.pipe( basePipe, - withTiming('plugin.a', 'update', noop), - withTiming('plugin.b', 'update', noop) + Perf.withTiming('plugin.a', 'update', noop), + Perf.withTiming('plugin.b', 'update', noop) ); const result = pipe(makeFrame()); @@ -157,11 +155,11 @@ describe('Perf', () => { expect(getEntry(result, 'plugin.b', 'update')).toBeDefined(); }); - it('should work without perfPipe (graceful fallback)', () => { + it('should work without Perf.pipe (graceful fallback)', () => { const noop: Pipe = frame => frame; const pipe = Composer.pipe( Events.pipe, - withTiming('test.plugin', 'update', noop) + Perf.withTiming('test.plugin', 'update', noop) ); const result = pipe(makeFrame()); @@ -182,7 +180,7 @@ describe('Perf', () => { const pipe = Composer.pipe( basePipe, - withTiming('test.plugin', 'update', slow) + Perf.withTiming('test.plugin', 'update', slow) ); const frame1 = pipe(makeFrame()); @@ -201,7 +199,7 @@ describe('Perf', () => { const noop: Pipe = frame => frame; const pipe = Composer.pipe( basePipe, - withTiming('test.plugin', 'update', noop) + Perf.withTiming('test.plugin', 'update', noop) ); const frame1 = pipe(makeFrame()); diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index 0a3f392..566e895 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -1,5 +1,5 @@ import { Composer } from '../Composer'; -import { GameContext, GameFrame, Pipe } from '../State'; +import { GameContext, Pipe } from '../State'; import { Events, GameEvent } from './Events'; import { pluginPaths, PluginId } from '../plugins/Plugins'; import { typedPath } from '../Lens'; @@ -41,71 +41,69 @@ const eventType = Events.getKeys(PLUGIN_NAMESPACE, 'over_budget', 'configure'); const perf = pluginPaths(PLUGIN_NAMESPACE); const gameContext = typedPath(['context']); -export function withTiming( - id: PluginId, - phase: PluginHookPhase, - pluginPipe: Pipe -): Pipe { - return Composer.do(({ get, set, pipe }) => { - const before = performance.now(); - pipe(pluginPipe); - const after = performance.now(); - const duration = after - before; - - const tick = get(gameContext.tick) ?? 0; - const ctx = get(perf.context) ?? { plugins: {}, config: DEFAULT_CONFIG }; - const pluginMetrics = ctx.plugins[id] ?? {}; - const entry = pluginMetrics[phase]; - - const samples = entry - ? [...entry.samples, duration].slice(-SAMPLE_SIZE) - : [duration]; - - const avg = - samples.length > 0 - ? samples.reduce((sum, v) => sum + v, 0) / samples.length - : duration; - - const max = entry ? Math.max(entry.max, duration) : duration; - - const newEntry: PluginPerfEntry = { - last: duration, - avg, - max, - samples, - lastTick: tick, - }; - - set(perf.context, { - ...ctx, - plugins: { - ...ctx.plugins, - [id]: { - ...pluginMetrics, - [phase]: newEntry, +export class Perf { + static withTiming( + id: PluginId, + phase: PluginHookPhase, + pluginPipe: Pipe + ): Pipe { + return Composer.do(({ get, set, pipe }) => { + const before = performance.now(); + pipe(pluginPipe); + const after = performance.now(); + const duration = after - before; + + const tick = get(gameContext.tick) ?? 0; + const ctx = get(perf.context) ?? { plugins: {}, config: DEFAULT_CONFIG }; + const pluginMetrics = ctx.plugins[id] ?? {}; + const entry = pluginMetrics[phase]; + + const samples = entry + ? [...entry.samples, duration].slice(-SAMPLE_SIZE) + : [duration]; + + const avg = + samples.length > 0 + ? samples.reduce((sum, v) => sum + v, 0) / samples.length + : duration; + + const max = entry ? Math.max(entry.max, duration) : duration; + + const newEntry: PluginPerfEntry = { + last: duration, + avg, + max, + samples, + lastTick: tick, + }; + + set(perf.context, { + ...ctx, + plugins: { + ...ctx.plugins, + [id]: { + ...pluginMetrics, + [phase]: newEntry, + }, }, - }, - }); - - const budget = ctx.config.pluginBudget; - if (duration > budget) { - if (import.meta.env.DEV) { - console.warn( - `[perf] ${id} ${phase} took ${duration.toFixed(2)}ms (budget: ${budget}ms)` + }); + + const budget = ctx.config.pluginBudget; + if (duration > budget) { + if (import.meta.env.DEV) { + console.warn( + `[perf] ${id} ${phase} took ${duration.toFixed(2)}ms (budget: ${budget}ms)` + ); + } + pipe( + Events.dispatch({ + type: eventType.overBudget, + payload: { id, phase, duration, budget }, + }) ); } - pipe( - Events.dispatch({ - type: eventType.overBudget, - payload: { id, phase, duration, budget }, - }) - ); - } - }); -} - -export class Perf { - static paths = perf; + }); + } static configure(config: Partial): Pipe { return Events.dispatch({ @@ -117,54 +115,56 @@ export class Perf { static onOverBudget(fn: (event: GameEvent) => Pipe): Pipe { return Events.handle(eventType.overBudget, fn); } -} -export const perfPipe: Pipe = Composer.pipe( - Composer.over( - perf.context, - (ctx = { plugins: {}, config: DEFAULT_CONFIG }) => ({ - ...ctx, - plugins: ctx.plugins ?? {}, - config: ctx.config ?? DEFAULT_CONFIG, - }) - ), - - Composer.do(({ get, set }) => { - const tick = get(gameContext.tick) ?? 0; - const ctx = get(perf.context); - if (!ctx) return; - - let dirty = false; - const pruned: PerfMetrics = {}; - - for (const [id, phases] of Object.entries(ctx.plugins)) { - const kept: Partial> = {}; - for (const [phase, entry] of Object.entries(phases)) { - if (entry && tick - entry.lastTick <= EXPIRY_TICKS) { - kept[phase as PluginHookPhase] = entry; + static pipe: Pipe = Composer.pipe( + Composer.over(perf.context, (ctx = { plugins: {}, config: DEFAULT_CONFIG }) => ({ + ...ctx, + plugins: ctx.plugins ?? {}, + config: ctx.config ?? DEFAULT_CONFIG, + }) + ), + + Composer.do(({ get, set }) => { + const tick = get(gameContext.tick) ?? 0; + const ctx = get(perf.context); + if (!ctx) return; + + let dirty = false; + const pruned: PerfMetrics = {}; + + for (const [id, phases] of Object.entries(ctx.plugins)) { + const kept: Partial> = {}; + for (const [phase, entry] of Object.entries(phases)) { + if (entry && tick - entry.lastTick <= EXPIRY_TICKS) { + kept[phase as PluginHookPhase] = entry; + } else { + dirty = true; + } + } + if (Object.keys(kept).length > 0) { + pruned[id] = kept; } else { dirty = true; } } - if (Object.keys(kept).length > 0) { - pruned[id] = kept; - } else { - dirty = true; + + if (dirty) { + set(perf.context, { ...ctx, plugins: pruned }); } - } - - if (dirty) { - set(perf.context, { ...ctx, plugins: pruned }); - } - }), - - Events.handle(eventType.configure, event => - Composer.over(perf.context, ctx => ({ - ...ctx, - config: { - ...ctx.config, - ...(event.payload as Partial), - }, - })) - ) -); + }), + + Events.handle(eventType.configure, event => + Composer.over(perf.context, ctx => ({ + ...ctx, + config: { + ...ctx.config, + ...(event.payload as Partial), + }, + })) + ) + ); + + static get paths() { + return perf; + } +} diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 5d7159e..74504a6 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -6,10 +6,6 @@ import { Events, GameEvent } from './Events'; const PLUGIN_NAMESPACE = 'core.scheduler'; -export const getScheduleKey = (namespace: string, key: string): string => { - return `${namespace}/schedule/${key}`; -}; - export type ScheduledEvent = { id?: string; duration: number; @@ -37,6 +33,10 @@ const eventType = Events.getKeys( ); export class Scheduler { + static getKey(namespace: string, key: string): string { + return `${namespace}/schedule/${key}`; + } + static schedule(event: ScheduledEvent): Pipe { return Events.dispatch({ type: eventType.schedule, payload: event }); } @@ -64,79 +64,79 @@ export class Scheduler { static cancelByPrefix(prefix: string): Pipe { return Events.dispatch({ type: eventType.cancelByPrefix, payload: prefix }); } -} - -export const schedulerPipe: Pipe = Composer.pipe( - Composer.bind(timing.deltaTime, delta => - Composer.over(scheduler.state, ({ scheduled = [] }) => { - const remaining: ScheduledEvent[] = []; - const current: GameEvent[] = []; - for (const entry of scheduled) { - if (entry.held) { - remaining.push(entry); - continue; + static pipe: Pipe = Composer.pipe( + Composer.bind(timing.deltaTime, delta => + Composer.over(scheduler.state, ({ scheduled = [] }) => { + const remaining: ScheduledEvent[] = []; + const current: GameEvent[] = []; + + for (const entry of scheduled) { + if (entry.held) { + remaining.push(entry); + continue; + } + const time = entry.duration - delta; + if (time <= 0) { + current.push(entry.event); + } else { + remaining.push({ ...entry, duration: time }); + } } - const time = entry.duration - delta; - if (time <= 0) { - current.push(entry.event); - } else { - remaining.push({ ...entry, duration: time }); - } - } - - return { scheduled: remaining, current }; - }) - ), - - Composer.bind(scheduler.state.current, events => - Composer.pipe(...events.map(Events.dispatch)) - ), - - Events.handle(eventType.schedule, event => - Composer.over(scheduler.state.scheduled, (list = []) => [ - ...list.filter(e => e.id !== event.payload.id), - event.payload, - ]) - ), - - Events.handle(eventType.cancel, event => - Composer.over(scheduler.state.scheduled, (list = []) => - list.filter(s => s.id !== event.payload) - ) - ), - Events.handle(eventType.hold, event => - Composer.over(scheduler.state.scheduled, (list = []) => - list.map(s => (s.id === event.payload ? { ...s, held: true } : s)) - ) - ), + return { scheduled: remaining, current }; + }) + ), - Events.handle(eventType.release, event => - Composer.over(scheduler.state.scheduled, (list = []) => - list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) - ) - ), + Composer.bind(scheduler.state.current, events => + Composer.pipe(...events.map(Events.dispatch)) + ), + + Events.handle(eventType.schedule, event => + Composer.over(scheduler.state.scheduled, (list = []) => [ + ...list.filter(e => e.id !== event.payload.id), + event.payload, + ]) + ), - Events.handle(eventType.holdByPrefix, event => - Composer.over(scheduler.state.scheduled, (list = []) => - list.map(s => - s.id?.startsWith(event.payload) ? { ...s, held: true } : s + Events.handle(eventType.cancel, event => + Composer.over(scheduler.state.scheduled, (list = []) => + list.filter(s => s.id !== event.payload) ) - ) - ), + ), - Events.handle(eventType.releaseByPrefix, event => - Composer.over(scheduler.state.scheduled, (list = []) => - list.map(s => - s.id?.startsWith(event.payload) ? { ...s, held: false } : s + Events.handle(eventType.hold, event => + Composer.over(scheduler.state.scheduled, (list = []) => + list.map(s => (s.id === event.payload ? { ...s, held: true } : s)) ) - ) - ), + ), + + Events.handle(eventType.release, event => + Composer.over(scheduler.state.scheduled, (list = []) => + list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) + ) + ), + + Events.handle(eventType.holdByPrefix, event => + Composer.over(scheduler.state.scheduled, (list = []) => + list.map(s => + s.id?.startsWith(event.payload) ? { ...s, held: true } : s + ) + ) + ), - Events.handle(eventType.cancelByPrefix, event => - Composer.over(scheduler.state.scheduled, (list = []) => - list.filter(s => !s.id?.startsWith(event.payload)) + Events.handle(eventType.releaseByPrefix, event => + Composer.over(scheduler.state.scheduled, (list = []) => + list.map(s => + s.id?.startsWith(event.payload) ? { ...s, held: false } : s + ) + ) + ), + + Events.handle(eventType.cancelByPrefix, event => + Composer.over(scheduler.state.scheduled, (list = []) => + list.filter(s => !s.id?.startsWith(event.payload)) + ) ) - ) -); + ); +} diff --git a/src/engine/pipes/Storage.test.ts b/src/engine/pipes/Storage.test.ts index c9d72ce..0e3723e 100644 --- a/src/engine/pipes/Storage.test.ts +++ b/src/engine/pipes/Storage.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Storage, - storagePipe, STORAGE_NAMESPACE, StorageContext, } from './Storage'; @@ -175,7 +174,7 @@ describe('Storage', () => { }); }); - describe('storagePipe', () => { + describe('Storage.pipe', () => { it('should clean up expired cache entries', () => { let frame: GameFrame = { state: {}, @@ -193,7 +192,7 @@ describe('Storage', () => { }) )(frame); - const result = storagePipe(frame); + const result = Storage.pipe(frame); const storage = result.context.how.joi.storage; expect(storage.cache['expired.key']).toBeUndefined(); diff --git a/src/engine/pipes/Storage.ts b/src/engine/pipes/Storage.ts index 2303a31..a229ed3 100644 --- a/src/engine/pipes/Storage.ts +++ b/src/engine/pipes/Storage.ts @@ -31,27 +31,6 @@ export type StorageContext = { const storage = pluginPaths(STORAGE_NAMESPACE); const timing = typedPath(['context']); -/** - * Storage pipe - updates cache expiry based on deltaTime - */ -export const storagePipe: Pipe = Composer.pipe( - // Clean up expired entries - Composer.bind(timing.elapsedTime, elapsedTime => - Composer.over(storage.context, ctx => { - const newCache: { [key: string]: CacheEntry } = {}; - - // Keep only non-expired entries - for (const [key, entry] of Object.entries(ctx?.cache || {})) { - if (entry.expiry > elapsedTime) { - newCache[key] = entry; - } - } - - return { cache: newCache }; - }) - ) -); - /** * Storage API for reading and writing to localStorage */ @@ -161,4 +140,23 @@ export class Storage { )(frame); }; } + + /** + * Storage pipe - updates cache expiry based on deltaTime + */ + static pipe: Pipe = Composer.pipe( + Composer.bind(timing.elapsedTime, elapsedTime => + Composer.over(storage.context, ctx => { + const newCache: { [key: string]: CacheEntry } = {}; + + for (const [key, entry] of Object.entries(ctx?.cache || {})) { + if (entry.expiry > elapsedTime) { + newCache[key] = entry; + } + } + + return { cache: newCache }; + }) + ) + ); } diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index 39ed0a2..449a272 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -14,7 +14,7 @@ import { type PluginRegistry, type EnabledMap, } from './Plugins'; -import { withTiming } from '../pipes/Perf'; +import { Perf } from '../pipes/Perf'; import { sdk } from '../sdk'; const PLUGIN_NAMESPACE = 'core.plugin_manager'; @@ -205,14 +205,14 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const deactivates = toUnload .map(id => { const p = (loadedRefs[id] ?? registry[id])?.plugin.deactivate; - return p ? withTiming(id, 'deactivate', p) : undefined; + return p ? Perf.withTiming(id, 'deactivate', p) : undefined; }) .filter(Boolean) as Pipe[]; const activates = toLoad .map(id => { const p = registry[id]?.plugin.activate; - return p ? withTiming(id, 'activate', p) : undefined; + return p ? Perf.withTiming(id, 'activate', p) : undefined; }) .filter(Boolean) as Pipe[]; @@ -224,7 +224,7 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const updates = activeIds .map(id => { const p = (loadedRefs[id] ?? registry[id])?.plugin.update; - return p ? withTiming(id, 'update', p) : undefined; + return p ? Perf.withTiming(id, 'update', p) : undefined; }) .filter(Boolean) as Pipe[]; diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index f38f843..aa53ba9 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef, useState, ReactNode, useCallback } from 'react'; import { createContext } from 'use-context-selector'; import { GameEngine, GameState, Pipe, GameContext } from '../engine'; import { Events } from '../engine/pipes/Events'; -import { schedulerPipe } from '../engine/pipes/Scheduler'; -import { perfPipe } from '../engine/pipes/Perf'; +import { Scheduler } from '../engine/pipes/Scheduler'; +import { Perf } from '../engine/pipes/Perf'; import { Piper } from '../engine/Piper'; import { Composer } from '../engine/Composer'; @@ -51,7 +51,7 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { engineRef.current = new GameEngine( {}, - Piper([impulsePipe, Events.pipe, schedulerPipe, perfPipe, ...pipes]) + Piper([impulsePipe, Events.pipe, Scheduler.pipe, Perf.pipe, ...pipes]) ); let frameId: number; diff --git a/src/game/Sequence.ts b/src/game/Sequence.ts index eb6ff15..41184ee 100644 --- a/src/game/Sequence.ts +++ b/src/game/Sequence.ts @@ -1,5 +1,5 @@ import { Pipe } from '../engine/State'; -import { Events, Scheduler, getScheduleKey } from '../engine/pipes'; +import { Events, Scheduler } from '../engine/pipes'; import Messages from './plugins/messages'; type EventHandler = Parameters[1]; @@ -24,7 +24,7 @@ export class Sequence { static for(namespace: string, name: string): SequenceScope { const rootKey = Events.getKey(namespace, name); const nodeKey = (n: string) => Events.getKey(namespace, `${name}.${n}`); - const schedKey = (n: string) => getScheduleKey(namespace, `${name}.${n}`); + const schedKey = (n: string) => Scheduler.getKey(namespace, `${name}.${n}`); return { messageId: name, @@ -55,7 +55,7 @@ export class Sequence { type: rootKey, ...(payload !== undefined && { payload }), }), - cancel: () => Scheduler.cancelByPrefix(getScheduleKey(namespace, name)), + cancel: () => Scheduler.cancelByPrefix(Scheduler.getKey(namespace, name)), dispatch: (target, payload) => Events.dispatch({ type: target ? nodeKey(target) : rootKey, diff --git a/src/game/plugins/messages.ts b/src/game/plugins/messages.ts index df096c2..4b64ac2 100644 --- a/src/game/plugins/messages.ts +++ b/src/game/plugins/messages.ts @@ -3,7 +3,7 @@ import { pluginPaths } from '../../engine/plugins/Plugins'; import { Pipe } from '../../engine/State'; import { Composer } from '../../engine/Composer'; import { Events, GameEvent } from '../../engine/pipes/Events'; -import { getScheduleKey, Scheduler } from '../../engine/pipes/Scheduler'; +import { Scheduler } from '../../engine/pipes/Scheduler'; declare module '../../engine/sdk' { interface PluginSDK { @@ -75,7 +75,7 @@ export default class Messages { const { messages = [] } = get(paths.state); const messageId = (event.payload as GameMessage).id; const updated = messages.find(m => m.id === messageId); - const scheduleId = getScheduleKey( + const scheduleId = Scheduler.getKey( PLUGIN_ID, `message/${messageId}` ); diff --git a/src/game/plugins/pause.test.ts b/src/game/plugins/pause.test.ts index 022a2ae..d29b49e 100644 --- a/src/game/plugins/pause.test.ts +++ b/src/game/plugins/pause.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Composer } from '../../engine/Composer'; import { Events } from '../../engine/pipes/Events'; import { - schedulerPipe, Scheduler, ScheduledEvent, } from '../../engine/pipes/Scheduler'; @@ -32,7 +31,7 @@ const tick = (frame: GameFrame, dt = 16): GameFrame => ({ const gamePipe: Pipe = Composer.pipe( Events.pipe, - schedulerPipe, + Scheduler.pipe, pluginManagerPipe ); diff --git a/src/game/plugins/randomImages.ts b/src/game/plugins/randomImages.ts index 33ff4bb..6af32c5 100644 --- a/src/game/plugins/randomImages.ts +++ b/src/game/plugins/randomImages.ts @@ -1,7 +1,7 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine'; import { Events } from '../../engine/pipes/Events'; -import { Scheduler, getScheduleKey } from '../../engine/pipes/Scheduler'; +import { Scheduler } from '../../engine/pipes/Scheduler'; import { typedPath } from '../../engine/Lens'; import { ImageItem } from '../../types'; import Image, { ImageState } from './image'; @@ -21,7 +21,7 @@ const imageState = typedPath(['state', 'core.images']); const eventType = Events.getKeys(PLUGIN_ID, 'schedule_next'); -const scheduleId = getScheduleKey(PLUGIN_ID, 'randomImageSwitch'); +const scheduleId = Scheduler.getKey(PLUGIN_ID, 'randomImageSwitch'); const getImageSwitchDuration = (intensity: number): number => { return Math.max((100 - intensity * 100) * 80, 2000); diff --git a/src/game/plugins/warmup.ts b/src/game/plugins/warmup.ts index 5f12482..b35d9b3 100644 --- a/src/game/plugins/warmup.ts +++ b/src/game/plugins/warmup.ts @@ -2,7 +2,7 @@ import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine'; import { Events } from '../../engine/pipes/Events'; import Messages from './messages'; -import { Scheduler, getScheduleKey } from '../../engine/pipes/Scheduler'; +import { Scheduler } from '../../engine/pipes/Scheduler'; import { typedPath } from '../../engine/Lens'; import { Settings } from '../../settings'; import { GameContext } from '../../engine'; @@ -25,7 +25,7 @@ const warmup = pluginPaths(PLUGIN_ID); const gameContext = typedPath(['context']); const settings = typedPath(gameContext.settings); -const AUTOSTART_KEY = getScheduleKey(PLUGIN_ID, 'autoStart'); +const AUTOSTART_KEY = Scheduler.getKey(PLUGIN_ID, 'autoStart'); const eventType = Events.getKeys(PLUGIN_ID, 'start_game'); From c565645845b5b312f6509332925206797070da57 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 17:11:11 +0100 Subject: [PATCH 63/90] applied formatting --- src/engine/pipes/Perf.test.ts | 6 +- src/engine/pipes/Perf.ts | 4 +- src/engine/pipes/Scheduler.ts | 5 +- src/engine/pipes/Storage.test.ts | 6 +- src/engine/pipes/Storage.ts | 71 +++++++++++------------- src/game/components/GameHypno.tsx | 3 +- src/game/components/GameInstructions.tsx | 12 +--- src/game/plugins/image.ts | 7 ++- src/game/plugins/pause.test.ts | 5 +- src/game/plugins/pause.ts | 9 +-- src/game/plugins/phase.ts | 5 +- 11 files changed, 57 insertions(+), 76 deletions(-) diff --git a/src/engine/pipes/Perf.test.ts b/src/engine/pipes/Perf.test.ts index a8a1d09..e08c75c 100644 --- a/src/engine/pipes/Perf.test.ts +++ b/src/engine/pipes/Perf.test.ts @@ -2,11 +2,7 @@ import { describe, it, expect } from 'vitest'; import { Composer } from '../Composer'; import { GameFrame, Pipe } from '../State'; import { Events } from './Events'; -import { - Perf, - type PerfContext, - type PluginPerfEntry, -} from './Perf'; +import { Perf, type PerfContext, type PluginPerfEntry } from './Perf'; const makeFrame = (overrides?: Partial): GameFrame => ({ state: {}, diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index 566e895..922bab5 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -117,7 +117,9 @@ export class Perf { } static pipe: Pipe = Composer.pipe( - Composer.over(perf.context, (ctx = { plugins: {}, config: DEFAULT_CONFIG }) => ({ + Composer.over( + perf.context, + (ctx = { plugins: {}, config: DEFAULT_CONFIG }) => ({ ...ctx, plugins: ctx.plugins ?? {}, config: ctx.config ?? DEFAULT_CONFIG, diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 74504a6..a9e01b1 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -58,7 +58,10 @@ export class Scheduler { } static releaseByPrefix(prefix: string): Pipe { - return Events.dispatch({ type: eventType.releaseByPrefix, payload: prefix }); + return Events.dispatch({ + type: eventType.releaseByPrefix, + payload: prefix, + }); } static cancelByPrefix(prefix: string): Pipe { diff --git a/src/engine/pipes/Storage.test.ts b/src/engine/pipes/Storage.test.ts index 0e3723e..2a25365 100644 --- a/src/engine/pipes/Storage.test.ts +++ b/src/engine/pipes/Storage.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { - Storage, - STORAGE_NAMESPACE, - StorageContext, -} from './Storage'; +import { Storage, STORAGE_NAMESPACE, StorageContext } from './Storage'; import { GameFrame } from '../State'; import { Composer } from '../Composer'; diff --git a/src/engine/pipes/Storage.ts b/src/engine/pipes/Storage.ts index a229ed3..5d440b1 100644 --- a/src/engine/pipes/Storage.ts +++ b/src/engine/pipes/Storage.ts @@ -54,40 +54,34 @@ export class Storage { * Gets a value, using cache or loading from localStorage */ static bind(key: string, fn: (value: T | undefined) => Pipe): Pipe { - return Composer.bind( - storage.context, - ctx => { - const cache = ctx?.cache || {}; - const cached = cache[key]; - - // Return cached value if available and not expired - if (cached) { - return fn(cached.value as T | undefined); - } + return Composer.bind(storage.context, ctx => { + const cache = ctx?.cache || {}; + const cached = cache[key]; - // Load from localStorage - const value = Storage.load(key); - - // Cache it (will be set with expiry in the next pipe) - return Composer.pipe( - Composer.bind(timing.elapsedTime, elapsedTime => - Composer.over( - storage.context, - ctx => ({ - cache: { - ...(ctx?.cache || {}), - [key]: { - value, - expiry: elapsedTime + CACHE_TTL, - }, - }, - }) - ) - ), - fn(value) - ); + // Return cached value if available and not expired + if (cached) { + return fn(cached.value as T | undefined); } - ); + + // Load from localStorage + const value = Storage.load(key); + + // Cache it (will be set with expiry in the next pipe) + return Composer.pipe( + Composer.bind(timing.elapsedTime, elapsedTime => + Composer.over(storage.context, ctx => ({ + cache: { + ...(ctx?.cache || {}), + [key]: { + value, + expiry: elapsedTime + CACHE_TTL, + }, + }, + })) + ), + fn(value) + ); + }); } /** @@ -130,14 +124,11 @@ export class Storage { } // Remove from cache - return Composer.over( - storage.context, - ctx => { - const newCache = { ...(ctx?.cache || {}) }; - delete newCache[key]; - return { cache: newCache }; - } - )(frame); + return Composer.over(storage.context, ctx => { + const newCache = { ...(ctx?.cache || {}) }; + delete newCache[key]; + return { cache: newCache }; + })(frame); }; } diff --git a/src/game/components/GameHypno.tsx b/src/game/components/GameHypno.tsx index 2eedd64..6045968 100644 --- a/src/game/components/GameHypno.tsx +++ b/src/game/components/GameHypno.tsx @@ -17,8 +17,7 @@ const StyledGameHypno = motion.create(styled.div` export const GameHypno = () => { const [hypno] = useSetting('hypno'); const { currentPhrase = 0 } = useGameState(Hypno.paths.state) ?? {}; - const { intensity = 0 } = - useGameState(Intensity.paths.state) ?? {}; + const { intensity = 0 } = useGameState(Intensity.paths.state) ?? {}; const translate = useTranslate(); const phrase = useMemo(() => { diff --git a/src/game/components/GameInstructions.tsx b/src/game/components/GameInstructions.tsx index c6f4203..083f0e4 100644 --- a/src/game/components/GameInstructions.tsx +++ b/src/game/components/GameInstructions.tsx @@ -73,9 +73,7 @@ const PaceDisplay = () => { - paceSection && pace <= paceSection * 2} - > + paceSection && pace <= paceSection * 2}> paceSection * 2}> @@ -94,10 +92,7 @@ const GripDisplay = () => { return ( - + @@ -110,8 +105,7 @@ const GripDisplay = () => { }; const IntensityDisplay = () => { - const { intensity = 0 } = - useGameState(Intensity.paths.state) ?? {}; + const { intensity = 0 } = useGameState(Intensity.paths.state) ?? {}; const intensityPct = Math.round(intensity * 100); return ( diff --git a/src/game/plugins/image.ts b/src/game/plugins/image.ts index 99dd158..2360cba 100644 --- a/src/game/plugins/image.ts +++ b/src/game/plugins/image.ts @@ -20,7 +20,12 @@ export type ImageState = { const image = pluginPaths(PLUGIN_ID); -const eventType = Events.getKeys(PLUGIN_ID, 'push_next', 'set_image', 'set_next_images'); +const eventType = Events.getKeys( + PLUGIN_ID, + 'push_next', + 'set_image', + 'set_next_images' +); export default class Image { static pushNextImage(img: ImageItem): Pipe { diff --git a/src/game/plugins/pause.test.ts b/src/game/plugins/pause.test.ts index d29b49e..34a4e67 100644 --- a/src/game/plugins/pause.test.ts +++ b/src/game/plugins/pause.test.ts @@ -1,10 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Composer } from '../../engine/Composer'; import { Events } from '../../engine/pipes/Events'; -import { - Scheduler, - ScheduledEvent, -} from '../../engine/pipes/Scheduler'; +import { Scheduler, ScheduledEvent } from '../../engine/pipes/Scheduler'; import { pluginManagerPipe, PluginManager, diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index fe7635f..0b50240 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -28,18 +28,13 @@ export default class Pause { static setPaused(val: boolean): Pipe { return Composer.when( val, - Composer.pipe( - resume.cancel(), - Composer.set(pause.state.paused, true) - ), + Composer.pipe(resume.cancel(), Composer.set(pause.state.paused, true)), resume.start() ); } static get togglePause(): Pipe { - return Composer.bind(pause.state, state => - Pause.setPaused(!state?.paused) - ); + return Composer.bind(pause.state, state => Pause.setPaused(!state?.paused)); } static whenPaused(pipe: Pipe): Pipe { diff --git a/src/game/plugins/phase.ts b/src/game/plugins/phase.ts index fbf755a..75a013d 100644 --- a/src/game/plugins/phase.ts +++ b/src/game/plugins/phase.ts @@ -56,7 +56,10 @@ export default class Phase { name: 'Phase', }, - activate: Composer.set(phase.state, { current: GamePhase.warmup, prev: GamePhase.warmup }), + activate: Composer.set(phase.state, { + current: GamePhase.warmup, + prev: GamePhase.warmup, + }), update: Composer.do(({ get, set, pipe }) => { const { current, prev } = get(phase.state); From 363125fe7f97b1441bc3691bc40ddd9012821f8b Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 17:15:08 +0100 Subject: [PATCH 64/90] refactored performance phase parameter to freeform string --- src/engine/pipes/Perf.ts | 10 ++++------ src/game/plugins/perf.ts | 16 +++++----------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index 922bab5..ae8bd31 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -4,8 +4,6 @@ import { Events, GameEvent } from './Events'; import { pluginPaths, PluginId } from '../plugins/Plugins'; import { typedPath } from '../Lens'; -export type PluginHookPhase = 'activate' | 'update' | 'deactivate'; - export type PluginPerfEntry = { last: number; avg: number; @@ -16,7 +14,7 @@ export type PluginPerfEntry = { export type PerfMetrics = Record< PluginId, - Partial> + Record >; export type PerfConfig = { @@ -44,7 +42,7 @@ const gameContext = typedPath(['context']); export class Perf { static withTiming( id: PluginId, - phase: PluginHookPhase, + phase: string, pluginPipe: Pipe ): Pipe { return Composer.do(({ get, set, pipe }) => { @@ -135,10 +133,10 @@ export class Perf { const pruned: PerfMetrics = {}; for (const [id, phases] of Object.entries(ctx.plugins)) { - const kept: Partial> = {}; + const kept: Record = {}; for (const [phase, entry] of Object.entries(phases)) { if (entry && tick - entry.lastTick <= EXPIRY_TICKS) { - kept[phase as PluginHookPhase] = entry; + kept[phase] = entry; } else { dirty = true; } diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts index 24decf8..e9bfb8b 100644 --- a/src/game/plugins/perf.ts +++ b/src/game/plugins/perf.ts @@ -1,5 +1,5 @@ import type { Plugin } from '../../engine/plugins/Plugins'; -import type { PerfMetrics, PluginHookPhase } from '../../engine/pipes/Perf'; +import type { PerfMetrics } from '../../engine/pipes/Perf'; import { Composer } from '../../engine/Composer'; import { Perf } from '../../engine/pipes/Perf'; import { pluginPaths } from '../../engine/plugins/Plugins'; @@ -38,7 +38,7 @@ function budgetColor(duration: number, budget: number): string { function formatLine( id: string, - phase: PluginHookPhase, + phase: string, avg: number, budget: number ): string { @@ -100,18 +100,12 @@ export default class PerfOverlay { const { plugins, config } = ctx; const lines: string[] = []; - const phaseOrder: PluginHookPhase[] = [ - 'activate', - 'update', - 'deactivate', - ]; let totalAvg = 0; - for (const phase of phaseOrder) { - for (const [id, phases] of Object.entries(plugins as PerfMetrics)) { - if (id === PLUGIN_ID) continue; - const entry = phases[phase]; + for (const [id, phases] of Object.entries(plugins as PerfMetrics)) { + if (id === PLUGIN_ID) continue; + for (const [phase, entry] of Object.entries(phases)) { if (!entry) continue; totalAvg += entry.avg; lines.push(formatLine(id, phase, entry.avg, config.pluginBudget)); From a9c121f2cbee21c296dc06b7edbfa21c169b928d Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 17:52:37 +0100 Subject: [PATCH 65/90] added sdk.debug prop --- src/engine/pipes/Perf.test.ts | 9 ++++- src/engine/pipes/Perf.ts | 8 ++++- src/engine/sdk.ts | 2 ++ src/game/plugins/debug.ts | 66 +++++++++++++++++++++++++++++++++++ src/game/plugins/fps.ts | 18 +++++++--- src/game/plugins/index.ts | 2 ++ src/game/plugins/perf.ts | 16 ++++++--- 7 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 src/game/plugins/debug.ts diff --git a/src/engine/pipes/Perf.test.ts b/src/engine/pipes/Perf.test.ts index e08c75c..2ba9039 100644 --- a/src/engine/pipes/Perf.test.ts +++ b/src/engine/pipes/Perf.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Composer } from '../Composer'; import { GameFrame, Pipe } from '../State'; import { Events } from './Events'; import { Perf, type PerfContext, type PluginPerfEntry } from './Perf'; +import { sdk } from '../sdk'; const makeFrame = (overrides?: Partial): GameFrame => ({ state: {}, @@ -33,6 +34,12 @@ const getEntry = ( (getPerfCtx(frame)?.plugins as any)?.[pluginId]?.[phase]; describe('Perf', () => { + beforeEach(() => { + sdk.debug = true; + }); + afterEach(() => { + sdk.debug = false; + }); describe('Perf.pipe', () => { it('should preserve existing metrics across frames', () => { const frame0 = basePipe(makeFrame()); diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index ae8bd31..9fdf966 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -3,6 +3,7 @@ import { GameContext, Pipe } from '../State'; import { Events, GameEvent } from './Events'; import { pluginPaths, PluginId } from '../plugins/Plugins'; import { typedPath } from '../Lens'; +import { sdk } from '../sdk'; export type PluginPerfEntry = { last: number; @@ -46,6 +47,11 @@ export class Perf { pluginPipe: Pipe ): Pipe { return Composer.do(({ get, set, pipe }) => { + if (!sdk.debug) { + pipe(pluginPipe); + return; + } + const before = performance.now(); pipe(pluginPipe); const after = performance.now(); @@ -88,7 +94,7 @@ export class Perf { const budget = ctx.config.pluginBudget; if (duration > budget) { - if (import.meta.env.DEV) { + if (sdk.debug) { console.warn( `[perf] ${id} ${phase} took ${duration.toFixed(2)}ms (budget: ${budget}ms)` ); diff --git a/src/engine/sdk.ts b/src/engine/sdk.ts index 1775545..04b590d 100644 --- a/src/engine/sdk.ts +++ b/src/engine/sdk.ts @@ -11,6 +11,7 @@ import { Perf } from './pipes/Perf'; export interface PluginSDK {} export interface SDK extends PluginSDK { + debug: boolean; Composer: typeof Composer; Events: typeof Events; Scheduler: typeof Scheduler; @@ -22,6 +23,7 @@ export interface SDK extends PluginSDK { } export const sdk: SDK = { + debug: false, Composer, Events, Scheduler, diff --git a/src/game/plugins/debug.ts b/src/game/plugins/debug.ts new file mode 100644 index 0000000..5718c7f --- /dev/null +++ b/src/game/plugins/debug.ts @@ -0,0 +1,66 @@ +import { Composer, pluginPaths } from '../../engine'; +import { sdk } from '../../engine/sdk'; +import type { Pipe } from '../../engine/State'; +import type { Plugin } from '../../engine/plugins/Plugins'; + +const PLUGIN_ID = 'core.debug'; + +type DebugState = { + visible: boolean; +}; + +const debug = pluginPaths(PLUGIN_ID); + +let pendingToggle = false; +let handler: ((e: KeyboardEvent) => void) | null = null; + +export default class Debug { + static paths = debug; + + static whenDebug(pipe: Pipe): Pipe { + return frame => (sdk.debug ? pipe(frame) : frame); + } + + static whenVisible(pipe: Pipe): Pipe { + return Composer.bind(debug.state, state => + Composer.when(!!state?.visible, pipe) + ); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Debug', + }, + + activate: Composer.do(({ set }) => { + handler = (e: KeyboardEvent) => { + if (e.key === 'F3') { + e.preventDefault(); + pendingToggle = true; + } + }; + window.addEventListener('keydown', handler); + sdk.debug = false; + set(debug.state.visible, false); + }), + + update: Composer.do(({ get, set }) => { + if (!pendingToggle) return; + pendingToggle = false; + const current = get(debug.state.visible); + sdk.debug = !current; + set(debug.state.visible, !current); + }), + + deactivate: Composer.do(({ set }) => { + if (handler) { + window.removeEventListener('keydown', handler); + handler = null; + } + pendingToggle = false; + sdk.debug = false; + set(debug.state, undefined); + }), + }; +} diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts index 719673e..eb38ad2 100644 --- a/src/game/plugins/fps.ts +++ b/src/game/plugins/fps.ts @@ -1,5 +1,6 @@ import { Composer, GameContext, pluginPaths, typedPath } from '../../engine'; import type { Plugin } from '../../engine/plugins/Plugins'; +import Debug from './debug'; const PLUGIN_ID = 'core.fps'; const ELEMENT_ATTR = 'data-plugin-id'; @@ -21,7 +22,7 @@ export default class Fps { name: 'FPS Counter', }, - activate: frame => { + activate: Composer.do(({ get, set }) => { const style = document.getElementById(STYLE_ID) ?? document.createElement('style'); style.id = STYLE_ID; @@ -48,16 +49,23 @@ export default class Fps { const el = document.createElement('div'); el.setAttribute(ELEMENT_ATTR, PLUGIN_ID); - document.querySelector('.game-page')?.appendChild(el); - return Composer.set(fps.context, { el, history: [] })(frame); - }, + const visible = get(Debug.paths.state.visible); + el.style.display = visible ? '' : 'none'; + + document.querySelector('.game-page')?.appendChild(el); + set(fps.context, { el, history: [] }); + }), update: Composer.do(({ get, set }) => { - const delta = get(gameContext.deltaTime); const ctx = get(fps.context); if (!ctx) return; + const visible = get(Debug.paths.state.visible); + if (ctx.el) ctx.el.style.display = visible ? '' : 'none'; + if (!visible) return; + + const delta = get(gameContext.deltaTime); const current = delta > 0 ? 1000 / delta : 0; const history = [...ctx.history, current].slice(-HISTORY_SIZE); const avg = diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index 8e9f50b..0b815a3 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -1,6 +1,7 @@ import { PluginManager } from '../../engine/plugins/PluginManager'; import { Composer } from '../../engine/Composer'; import { Pipe } from '../../engine/State'; +import Debug from './debug'; import Fps from './fps'; import Intensity from './intensity'; import Pause from './pause'; @@ -29,6 +30,7 @@ const plugins = [ Image, RandomImages, Warmup, + Debug, Fps, PerfOverlay, ]; diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts index e9bfb8b..a8af6e4 100644 --- a/src/game/plugins/perf.ts +++ b/src/game/plugins/perf.ts @@ -3,6 +3,7 @@ import type { PerfMetrics } from '../../engine/pipes/Perf'; import { Composer } from '../../engine/Composer'; import { Perf } from '../../engine/pipes/Perf'; import { pluginPaths } from '../../engine/plugins/Plugins'; +import Debug from './debug'; const PLUGIN_ID = 'core.perf_overlay'; const ELEMENT_ATTR = 'data-plugin-id'; @@ -56,7 +57,7 @@ export default class PerfOverlay { name: 'Performance Overlay', }, - activate: frame => { + activate: Composer.do(({ get, set }) => { const style = document.getElementById(STYLE_ID) ?? document.createElement('style'); style.id = STYLE_ID; @@ -86,15 +87,22 @@ export default class PerfOverlay { const el = document.createElement('div'); el.setAttribute(ELEMENT_ATTR, PLUGIN_ID); - document.querySelector('.game-page')?.appendChild(el); - return Composer.set(po.context, { el })(frame); - }, + const visible = get(Debug.paths.state.visible); + el.style.display = visible ? '' : 'none'; + + document.querySelector('.game-page')?.appendChild(el); + set(po.context, { el }); + }), update: Composer.do(({ get }) => { const el = get(po.context)?.el; if (!el) return; + const visible = get(Debug.paths.state.visible); + el.style.display = visible ? '' : 'none'; + if (!visible) return; + const ctx = get(Perf.paths.context); if (!ctx) return; From 2e3f54a3a80ee033ed134508e1f39092b6ec70fd Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 18:31:36 +0100 Subject: [PATCH 66/90] added types to events --- src/engine/pipes/Events.ts | 13 ++++++------- src/engine/pipes/Perf.ts | 12 +++++++----- src/engine/pipes/Scheduler.ts | 14 +++++++------- src/engine/plugins/PluginManager.ts | 23 ++++++++++------------- src/game/Sequence.ts | 19 +++++++++---------- src/game/components/GameInstructions.tsx | 4 ++-- src/game/plugins/dealer.ts | 18 +++++++++--------- src/game/plugins/dice/cleanUp.ts | 4 ++-- src/game/plugins/dice/climax.ts | 10 ++++++---- src/game/plugins/dice/doublePace.ts | 4 ++-- src/game/plugins/dice/edge.ts | 4 ++-- src/game/plugins/dice/halfPace.ts | 10 ++++++---- src/game/plugins/dice/pause.ts | 4 ++-- src/game/plugins/dice/randomGrip.ts | 4 ++-- src/game/plugins/dice/randomPace.ts | 4 ++-- src/game/plugins/dice/risingPace.ts | 8 +++++--- src/game/plugins/dice/types.ts | 4 ++-- src/game/plugins/emergencyStop.ts | 4 +++- src/game/plugins/image.ts | 6 +++--- src/game/plugins/messages.ts | 8 ++++---- src/game/plugins/pause.ts | 4 +++- src/settings/SettingsProvider.tsx | 6 +++--- src/settings/components/EventSettings.tsx | 12 ++++++------ src/types/event.ts | 6 +++--- 24 files changed, 106 insertions(+), 99 deletions(-) diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 11a6c8e..809f053 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -4,10 +4,9 @@ import { pluginPaths } from '../plugins/Plugins'; import { GameFrame, Pipe } from '../State'; import { CamelCase, toCamel } from '../../utils/case'; -export type GameEvent = { - type: string; - payload?: any; -}; +export type GameEvent = { type: string } & (unknown extends T + ? { payload?: T } + : { payload: T }); export type EventState = { pending: GameEvent[]; @@ -45,11 +44,11 @@ export class Events { }; } - static dispatch(event: GameEvent): Pipe { + static dispatch(event: GameEvent): Pipe { return pendingLens.over((pending = []) => [...pending, event]); } - static handle(type: string, fn: (event: GameEvent) => Pipe): Pipe { + static handle(type: string, fn: (event: GameEvent) => Pipe): Pipe { const { namespace, key } = Events.parseKey(type); const isWildcard = key === '*'; const prefix = namespace + '/'; @@ -61,7 +60,7 @@ export class Events { let result: GameFrame = obj; for (const event of current) { if (isWildcard ? event.type.startsWith(prefix) : event.type === type) { - result = fn(event)(result); + result = fn(event as GameEvent)(result); } } return result; diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index 9fdf966..8f5c2e9 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -1,6 +1,6 @@ import { Composer } from '../Composer'; import { GameContext, Pipe } from '../State'; -import { Events, GameEvent } from './Events'; +import { Events, type GameEvent } from './Events'; import { pluginPaths, PluginId } from '../plugins/Plugins'; import { typedPath } from '../Lens'; import { sdk } from '../sdk'; @@ -35,6 +35,8 @@ const DEFAULT_CONFIG: PerfConfig = { pluginBudget: 1, }; +type OverBudgetPayload = { id: string; phase: string; duration: number; budget: number }; + const eventType = Events.getKeys(PLUGIN_NAMESPACE, 'over_budget', 'configure'); const perf = pluginPaths(PLUGIN_NAMESPACE); @@ -116,8 +118,8 @@ export class Perf { }); } - static onOverBudget(fn: (event: GameEvent) => Pipe): Pipe { - return Events.handle(eventType.overBudget, fn); + static onOverBudget(fn: (event: GameEvent) => Pipe): Pipe { + return Events.handle(eventType.overBudget, fn); } static pipe: Pipe = Composer.pipe( @@ -159,12 +161,12 @@ export class Perf { } }), - Events.handle(eventType.configure, event => + Events.handle>(eventType.configure, event => Composer.over(perf.context, ctx => ({ ...ctx, config: { ...ctx.config, - ...(event.payload as Partial), + ...event.payload, }, })) ) diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index a9e01b1..8c44b8a 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -95,32 +95,32 @@ export class Scheduler { Composer.pipe(...events.map(Events.dispatch)) ), - Events.handle(eventType.schedule, event => + Events.handle(eventType.schedule, event => Composer.over(scheduler.state.scheduled, (list = []) => [ ...list.filter(e => e.id !== event.payload.id), event.payload, ]) ), - Events.handle(eventType.cancel, event => + Events.handle(eventType.cancel, event => Composer.over(scheduler.state.scheduled, (list = []) => list.filter(s => s.id !== event.payload) ) ), - Events.handle(eventType.hold, event => + Events.handle(eventType.hold, event => Composer.over(scheduler.state.scheduled, (list = []) => list.map(s => (s.id === event.payload ? { ...s, held: true } : s)) ) ), - Events.handle(eventType.release, event => + Events.handle(eventType.release, event => Composer.over(scheduler.state.scheduled, (list = []) => list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) ) ), - Events.handle(eventType.holdByPrefix, event => + Events.handle(eventType.holdByPrefix, event => Composer.over(scheduler.state.scheduled, (list = []) => list.map(s => s.id?.startsWith(event.payload) ? { ...s, held: true } : s @@ -128,7 +128,7 @@ export class Scheduler { ) ), - Events.handle(eventType.releaseByPrefix, event => + Events.handle(eventType.releaseByPrefix, event => Composer.over(scheduler.state.scheduled, (list = []) => list.map(s => s.id?.startsWith(event.payload) ? { ...s, held: false } : s @@ -136,7 +136,7 @@ export class Scheduler { ) ), - Events.handle(eventType.cancelByPrefix, event => + Events.handle(eventType.cancelByPrefix, event => Composer.over(scheduler.state.scheduled, (list = []) => list.filter(s => !s.id?.startsWith(event.payload)) ) diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index 449a272..a3cf31e 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -109,39 +109,37 @@ const apiPipe: Pipe = Composer.over(pm.context, ctx => ({ // TODO: enable/disable plugin storage should probably live elsewhere. const enableDisablePipe: Pipe = Composer.pipe( - Events.handle(eventType.enable, event => + Events.handle(eventType.enable, event => Storage.bind(storageKey.enabled, (map = {}) => Storage.set(storageKey.enabled, { ...map, - [event.payload as PluginId]: true, + [event.payload]: true, }) ) ), - Events.handle(eventType.disable, event => + Events.handle(eventType.disable, event => Storage.bind(storageKey.enabled, (map = {}) => Storage.set(storageKey.enabled, { ...map, - [event.payload as PluginId]: false, + [event.payload]: false, }) ) ) ); const reconcilePipe: Pipe = Composer.pipe( - Events.handle(eventType.register, event => + Events.handle(eventType.register, event => Composer.do(({ over }) => { - const cls = event.payload as PluginClass; over(pm.context.registry, registry => ({ ...registry, - [cls.plugin.id]: cls, + [event.payload.plugin.id]: event.payload, })); }) ), - Events.handle(eventType.unregister, event => + Events.handle(eventType.unregister, event => Composer.do(({ over }) => { - const id = event.payload as PluginId; over(pm.context.toUnload, (ids = []) => - Array.isArray(ids) ? [...ids, id] : [id] + Array.isArray(ids) ? [...ids, event.payload] : [event.payload] ); }) ), @@ -238,12 +236,11 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { }); const finalizePipe: Pipe = Composer.pipe( - Events.handle(eventType.unregister, event => + Events.handle(eventType.unregister, event => Composer.do(({ over }) => { - const id = event.payload as PluginId; over(pm.context.registry, registry => { const next = { ...registry }; - delete next[id]; + delete next[event.payload]; return next; }); }) diff --git a/src/game/Sequence.ts b/src/game/Sequence.ts index 41184ee..d115f23 100644 --- a/src/game/Sequence.ts +++ b/src/game/Sequence.ts @@ -1,21 +1,20 @@ import { Pipe } from '../engine/State'; -import { Events, Scheduler } from '../engine/pipes'; +import { Events, GameEvent, Scheduler } from '../engine/pipes'; import Messages from './plugins/messages'; -type EventHandler = Parameters[1]; type MessageInput = Omit[0], 'id'>; type MessagePrompt = NonNullable[number]; export type SequenceScope = { messageId: string; - on(handler: EventHandler): Pipe; - on(name: string, handler: EventHandler): Pipe; + on(handler: (event: GameEvent) => Pipe): Pipe; + on(name: string, handler: (event: GameEvent) => Pipe): Pipe; message(msg: MessageInput): Pipe; - after(duration: number, target: string, payload?: any): Pipe; - prompt(title: string, target: string, payload?: any): MessagePrompt; - start(payload?: any): Pipe; + after(duration: number, target: string, payload?: T): Pipe; + prompt(title: string, target: string, payload?: T): MessagePrompt; + start(payload?: T): Pipe; cancel(): Pipe; - dispatch(target: string, payload?: any): Pipe; + dispatch(target: string, payload?: T): Pipe; eventKey(target: string): string; scheduleKey(target: string): string; }; @@ -54,13 +53,13 @@ export class Sequence { Events.dispatch({ type: rootKey, ...(payload !== undefined && { payload }), - }), + } as GameEvent), cancel: () => Scheduler.cancelByPrefix(Scheduler.getKey(namespace, name)), dispatch: (target, payload) => Events.dispatch({ type: target ? nodeKey(target) : rootKey, ...(payload !== undefined && { payload }), - }), + } as GameEvent), eventKey: nodeKey, scheduleKey: schedKey, }; diff --git a/src/game/components/GameInstructions.tsx b/src/game/components/GameInstructions.tsx index 083f0e4..599bf21 100644 --- a/src/game/components/GameInstructions.tsx +++ b/src/game/components/GameInstructions.tsx @@ -9,7 +9,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { useSetting } from '../../settings'; import { useMemo } from 'react'; -import { GameEvent as GameEventType } from '../../types'; +import { DiceEvent } from '../../types'; import { ProgressBar } from '../../common'; import { WaDivider } from '@awesome.me/webawesome/dist/react'; import { useGameState } from '../hooks'; @@ -127,7 +127,7 @@ const IntensityDisplay = () => { export const GameInstructions = () => { const [events] = useSetting('events'); const useRandomGrip = useMemo( - () => events.includes(GameEventType.randomGrip), + () => events.includes(DiceEvent.randomGrip), [events] ); diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index 2ec0be5..01ab535 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -5,7 +5,7 @@ import { Scheduler } from '../../engine/pipes/Scheduler'; import { Sequence } from '../Sequence'; import Phase, { GamePhase } from './phase'; import Pause from './pause'; -import { GameEvent as GameEventType } from '../../types'; +import { DiceEvent } from '../../types'; import { PLUGIN_ID, dice, @@ -42,16 +42,16 @@ const outcomes: DiceOutcome[] = [ ]; const rollChances: Record = { - [GameEventType.randomPace]: 10, - [GameEventType.cleanUp]: 25, - [GameEventType.randomGrip]: 50, - [GameEventType.doublePace]: 50, - [GameEventType.halfPace]: 50, - [GameEventType.pause]: 50, - [GameEventType.risingPace]: 30, + [DiceEvent.randomPace]: 10, + [DiceEvent.cleanUp]: 25, + [DiceEvent.randomGrip]: 50, + [DiceEvent.doublePace]: 50, + [DiceEvent.halfPace]: 50, + [DiceEvent.pause]: 50, + [DiceEvent.risingPace]: 30, }; -const eventKeyForOutcome = (id: GameEventType): string => +const eventKeyForOutcome = (id: DiceEvent): string => Events.getKey(PLUGIN_ID, id); const roll = Sequence.for(PLUGIN_ID, 'roll'); diff --git a/src/game/plugins/dice/cleanUp.ts b/src/game/plugins/dice/cleanUp.ts index f428ed3..0a355f5 100644 --- a/src/game/plugins/dice/cleanUp.ts +++ b/src/game/plugins/dice/cleanUp.ts @@ -2,7 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; import { - GameEvent as GameEventType, + DiceEvent, CleanUpDescriptions, } from '../../../types'; import { @@ -16,7 +16,7 @@ import { const seq = Sequence.for(PLUGIN_ID, 'cleanUp'); export const cleanUpOutcome: DiceOutcome = { - id: GameEventType.cleanUp, + id: DiceEvent.cleanUp, check: frame => (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 75, update: Composer.pipe( diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts index 03c1f99..c92b8a7 100644 --- a/src/game/plugins/dice/climax.ts +++ b/src/game/plugins/dice/climax.ts @@ -2,7 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; import Pace from '../pace'; -import { GameEvent as GameEventType } from '../../../types'; +import { DiceEvent } from '../../../types'; import { IntensityState } from '../intensity'; import { PLUGIN_ID, @@ -13,16 +13,18 @@ import { } from './types'; import { edged } from './edge'; +type ClimaxEndPayload = { countdown: number; denied?: boolean; ruin?: boolean }; + const seq = Sequence.for(PLUGIN_ID, 'climax'); export const climaxOutcome: DiceOutcome = { - id: GameEventType.climax, + id: DiceEvent.climax, check: frame => { const i = (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100; const s = Composer.get(settings)(frame); return ( i >= 100 && - (!s?.events.includes(GameEventType.edge) || !!Composer.get(edged)(frame)) + (!s?.events.includes(DiceEvent.edge) || !!Composer.get(edged)(frame)) ); }, update: Composer.pipe( @@ -97,7 +99,7 @@ export const climaxOutcome: DiceOutcome = { }) ), - seq.on('end', event => { + seq.on('end', event => { const { countdown, denied, ruin } = event.payload; const decay = Composer.over(intensityState, (s: IntensityState) => ({ intensity: Math.max(0, s.intensity - (denied ? 0.2 : 0.1)), diff --git a/src/game/plugins/dice/doublePace.ts b/src/game/plugins/dice/doublePace.ts index c08b20f..ebc3a42 100644 --- a/src/game/plugins/dice/doublePace.ts +++ b/src/game/plugins/dice/doublePace.ts @@ -1,7 +1,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Pace from '../pace'; -import { GameEvent as GameEventType } from '../../../types'; +import { DiceEvent } from '../../../types'; import { round } from '../../../utils'; import { PLUGIN_ID, @@ -16,7 +16,7 @@ import { doRandomPace } from './randomPace'; const seq = Sequence.for(PLUGIN_ID, 'doublePace'); export const doublePaceOutcome: DiceOutcome = { - id: GameEventType.doublePace, + id: DiceEvent.doublePace, check: frame => (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 20, update: Composer.pipe( diff --git a/src/game/plugins/dice/edge.ts b/src/game/plugins/dice/edge.ts index ceff5ae..e010bd1 100644 --- a/src/game/plugins/dice/edge.ts +++ b/src/game/plugins/dice/edge.ts @@ -2,7 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { typedPath } from '../../../engine/Lens'; import { Sequence } from '../../Sequence'; import Pace from '../pace'; -import { GameEvent as GameEventType } from '../../../types'; +import { DiceEvent } from '../../../types'; import { PLUGIN_ID, intensityState, @@ -16,7 +16,7 @@ export const edged = typedPath(['state', PLUGIN_ID, 'edged']); const seq = Sequence.for(PLUGIN_ID, 'edge'); export const edgeOutcome: DiceOutcome = { - id: GameEventType.edge, + id: DiceEvent.edge, check: frame => { const i = (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100; return i >= 90 && !Composer.get(edged)(frame); diff --git a/src/game/plugins/dice/halfPace.ts b/src/game/plugins/dice/halfPace.ts index 3a39002..823b405 100644 --- a/src/game/plugins/dice/halfPace.ts +++ b/src/game/plugins/dice/halfPace.ts @@ -1,7 +1,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Pace from '../pace'; -import { GameEvent as GameEventType } from '../../../types'; +import { DiceEvent } from '../../../types'; import { round } from '../../../utils'; import { PLUGIN_ID, @@ -13,10 +13,12 @@ import { } from './types'; import { doRandomPace } from './randomPace'; +type HalfPacePayload = { portion: number }; + const seq = Sequence.for(PLUGIN_ID, 'halfPace'); export const halfPaceOutcome: DiceOutcome = { - id: GameEventType.halfPace, + id: DiceEvent.halfPace, check: frame => { const i = (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100; return i >= 10 && i <= 50; @@ -36,13 +38,13 @@ export const halfPaceOutcome: DiceOutcome = { }) ) ), - seq.on('step2', event => + seq.on('step2', event => Composer.pipe( seq.message({ description: '2...' }), seq.after(event.payload.portion, 'step3', event.payload) ) ), - seq.on('step3', event => + seq.on('step3', event => Composer.pipe( seq.message({ description: '1...' }), seq.after(event.payload.portion, 'done') diff --git a/src/game/plugins/dice/pause.ts b/src/game/plugins/dice/pause.ts index faae261..e021a30 100644 --- a/src/game/plugins/dice/pause.ts +++ b/src/game/plugins/dice/pause.ts @@ -1,13 +1,13 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; -import { GameEvent as GameEventType } from '../../../types'; +import { DiceEvent } from '../../../types'; import { PLUGIN_ID, intensityState, outcomeDone, DiceOutcome } from './types'; const seq = Sequence.for(PLUGIN_ID, 'pause'); export const pauseOutcome: DiceOutcome = { - id: GameEventType.pause, + id: DiceEvent.pause, check: frame => (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 15, update: Composer.pipe( diff --git a/src/game/plugins/dice/randomGrip.ts b/src/game/plugins/dice/randomGrip.ts index a64fd3f..1aab6aa 100644 --- a/src/game/plugins/dice/randomGrip.ts +++ b/src/game/plugins/dice/randomGrip.ts @@ -1,7 +1,7 @@ import { Composer } from '../../../engine/Composer'; import { typedPath } from '../../../engine/Lens'; import { Sequence } from '../../Sequence'; -import { GameEvent as GameEventType } from '../../../types'; +import { DiceEvent } from '../../../types'; import { PLUGIN_ID, outcomeDone, DiceOutcome } from './types'; export enum Paws { @@ -28,7 +28,7 @@ const randomPaw = (exclude?: Paws): Paws => { const seq = Sequence.for(PLUGIN_ID, 'randomGrip'); export const randomGripOutcome: DiceOutcome = { - id: GameEventType.randomGrip, + id: DiceEvent.randomGrip, activate: Composer.over(pawsPath, () => randomPaw()), update: Composer.pipe( seq.on(() => diff --git a/src/game/plugins/dice/randomPace.ts b/src/game/plugins/dice/randomPace.ts index 2429601..ab3ba9d 100644 --- a/src/game/plugins/dice/randomPace.ts +++ b/src/game/plugins/dice/randomPace.ts @@ -2,7 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import { Pipe } from '../../../engine/State'; import Pace from '../pace'; -import { GameEvent as GameEventType } from '../../../types'; +import { DiceEvent } from '../../../types'; import { intensityToPaceRange, round } from '../../../utils'; import { PLUGIN_ID, @@ -36,7 +36,7 @@ export const doRandomPace = (): Pipe => ); export const randomPaceOutcome: DiceOutcome = { - id: GameEventType.randomPace, + id: DiceEvent.randomPace, update: Composer.pipe( seq.on(() => Composer.pipe(doRandomPace(), seq.after(9000, 'done'))), seq.on('done', () => outcomeDone()) diff --git a/src/game/plugins/dice/risingPace.ts b/src/game/plugins/dice/risingPace.ts index 90f0dd0..e34d5d8 100644 --- a/src/game/plugins/dice/risingPace.ts +++ b/src/game/plugins/dice/risingPace.ts @@ -1,7 +1,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Pace from '../pace'; -import { GameEvent as GameEventType } from '../../../types'; +import { DiceEvent } from '../../../types'; import { intensityToPaceRange, round } from '../../../utils'; import { PLUGIN_ID, @@ -12,10 +12,12 @@ import { } from './types'; import { doRandomPace } from './randomPace'; +type RisingPacePayload = { current: number; portion: number; remaining: number }; + const seq = Sequence.for(PLUGIN_ID, 'risingPace'); export const risingPaceOutcome: DiceOutcome = { - id: GameEventType.risingPace, + id: DiceEvent.risingPace, check: frame => (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 30, update: Composer.pipe( @@ -41,7 +43,7 @@ export const risingPaceOutcome: DiceOutcome = { }) ) ), - seq.on('step', event => { + seq.on('step', event => { const { current, portion, remaining } = event.payload; const newPace = round(current + portion); return Composer.pipe( diff --git a/src/game/plugins/dice/types.ts b/src/game/plugins/dice/types.ts index 6bf91f7..d1c4d4e 100644 --- a/src/game/plugins/dice/types.ts +++ b/src/game/plugins/dice/types.ts @@ -5,7 +5,7 @@ import { typedPath } from '../../../engine/Lens'; import { IntensityState } from '../intensity'; import { PaceState } from '../pace'; import { Settings } from '../../../settings'; -import { GameEvent as GameEventType } from '../../../types'; +import { DiceEvent } from '../../../types'; export const PLUGIN_ID = 'core.dice'; @@ -25,7 +25,7 @@ export const OUTCOME_DONE = Events.getKey(PLUGIN_ID, 'outcome.done'); export const outcomeDone = (): Pipe => Events.dispatch({ type: OUTCOME_DONE }); export type DiceOutcome = { - id: GameEventType; + id: DiceEvent; check?: (frame: GameFrame) => boolean; activate?: Pipe; update: Pipe; diff --git a/src/game/plugins/emergencyStop.ts b/src/game/plugins/emergencyStop.ts index d6b246c..8e48591 100644 --- a/src/game/plugins/emergencyStop.ts +++ b/src/game/plugins/emergencyStop.ts @@ -14,6 +14,8 @@ const PLUGIN_ID = 'core.emergencyStop'; const intensityState = typedPath(['state', 'core.intensity']); const settings = typedPath(['context', 'settings']); +type CountdownPayload = { remaining: number }; + const seq = Sequence.for(PLUGIN_ID, 'stop'); declare module '../../engine/sdk' { @@ -48,7 +50,7 @@ export default class EmergencyStop { ) ), - seq.on('countdown', event => { + seq.on('countdown', event => { const { remaining } = event.payload; if (remaining <= 0) { return Composer.pipe( diff --git a/src/game/plugins/image.ts b/src/game/plugins/image.ts index 2360cba..fdb05c8 100644 --- a/src/game/plugins/image.ts +++ b/src/game/plugins/image.ts @@ -53,7 +53,7 @@ export default class Image { }), update: Composer.pipe( - Events.handle(eventType.pushNext, event => + Events.handle(eventType.pushNext, event => Composer.over( image.state, ({ currentImage, seenImages = [], nextImages = [] }) => { @@ -85,14 +85,14 @@ export default class Image { ) ), - Events.handle(eventType.setNextImages, event => + Events.handle(eventType.setNextImages, event => Composer.over(image.state, state => ({ ...state, nextImages: event.payload, })) ), - Events.handle(eventType.setImage, event => + Events.handle(eventType.setImage, event => Composer.over(image.state, state => ({ ...state, currentImage: event.payload, diff --git a/src/game/plugins/messages.ts b/src/game/plugins/messages.ts index 4b64ac2..0352769 100644 --- a/src/game/plugins/messages.ts +++ b/src/game/plugins/messages.ts @@ -53,10 +53,10 @@ export default class Messages { activate: Composer.set(paths.state, { messages: [] }), update: Composer.pipe( - Events.handle(eventType.sendMessage, event => + Events.handle(eventType.sendMessage, event => Composer.pipe( Composer.over(paths.state, ({ messages = [] }) => { - const patch = event.payload as GameMessage; + const patch = event.payload; const index = messages.findIndex(m => m.id === patch.id); const existing = messages[index]; @@ -73,7 +73,7 @@ export default class Messages { Composer.do(({ get, pipe }) => { const { messages = [] } = get(paths.state); - const messageId = (event.payload as GameMessage).id; + const messageId = event.payload.id; const updated = messages.find(m => m.id === messageId); const scheduleId = Scheduler.getKey( PLUGIN_ID, @@ -98,7 +98,7 @@ export default class Messages { ) ), - Events.handle(eventType.expireMessage, event => + Events.handle(eventType.expireMessage, event => Composer.over(paths.state, ({ messages = [] }) => ({ messages: messages.filter(m => m.id !== event.payload), })) diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index 0b50240..e14112d 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -22,6 +22,8 @@ const pause = pluginPaths(PLUGIN_ID); const eventType = Events.getKeys(PLUGIN_ID, 'on', 'off'); +type CountdownPayload = { remaining: number }; + const resume = Sequence.for(PLUGIN_ID, 'resume'); export default class Pause { @@ -83,7 +85,7 @@ export default class Pause { ) ), - resume.on('countdown', event => + resume.on('countdown', event => Composer.when( event.payload.remaining <= 0, Composer.pipe( diff --git a/src/settings/SettingsProvider.tsx b/src/settings/SettingsProvider.tsx index 390d714..aa44759 100644 --- a/src/settings/SettingsProvider.tsx +++ b/src/settings/SettingsProvider.tsx @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { GameEvent, GameHypnoType, PlayerBody, PlayerGender } from '../types'; +import { DiceEvent, GameHypnoType, PlayerBody, PlayerGender } from '../types'; import { createLocalStorageProvider, VibrationMode } from '../utils'; import { interpolateWith } from '../utils/translate'; @@ -12,7 +12,7 @@ export interface Settings { maxPace: number; steepness: number; timeshift: number; - events: GameEvent[]; + events: DiceEvent[]; hypno: GameHypnoType; gender: PlayerGender; body: PlayerBody; @@ -32,7 +32,7 @@ export const defaultSettings: Settings = { maxPace: 5, steepness: 0.5, timeshift: 0.5, - events: Object.values(GameEvent), + events: Object.values(DiceEvent), hypno: GameHypnoType.joi, gender: PlayerGender.male, body: PlayerBody.penis, diff --git a/src/settings/components/EventSettings.tsx b/src/settings/components/EventSettings.tsx index e9e4e8b..0308d1f 100644 --- a/src/settings/components/EventSettings.tsx +++ b/src/settings/components/EventSettings.tsx @@ -1,4 +1,4 @@ -import { GameEvent, GameEventDescriptions, GameEventLabels } from '../../types'; +import { DiceEvent, DiceEventDescriptions, DiceEventLabels } from '../../types'; import { useCallback } from 'react'; import { Fields, JoiToggleTile, SettingsDescription } from '../../common'; import { useSetting } from '../SettingsProvider'; @@ -7,7 +7,7 @@ export const EventSettings = () => { const [events, setEvents] = useSetting('events'); const toggleEvent = useCallback( - (event: GameEvent) => { + (event: DiceEvent) => { if (events.includes(event)) { setEvents(events.filter(e => e !== event)); } else { @@ -22,8 +22,8 @@ export const EventSettings = () => { Check the events you want to occur during the game - {Object.keys(GameEvent).map(key => { - const event = GameEvent[key as keyof typeof GameEvent]; + {Object.keys(DiceEvent).map(key => { + const event = DiceEvent[key as keyof typeof DiceEvent]; return ( { onClick={() => toggleEvent(event)} type={'check'} > -

{GameEventLabels[event]}
-

{GameEventDescriptions[event]}

+
{DiceEventLabels[event]}
+

{DiceEventDescriptions[event]}

); })} diff --git a/src/types/event.ts b/src/types/event.ts index 39e171c..0a04bec 100644 --- a/src/types/event.ts +++ b/src/types/event.ts @@ -1,6 +1,6 @@ import { PlayerBody } from './body'; -export enum GameEvent { +export enum DiceEvent { randomPace = 'randomPace', halfPace = 'halfPace', doublePace = 'doublePace', @@ -12,7 +12,7 @@ export enum GameEvent { cleanUp = 'cleanUp', } -export const GameEventLabels: Record = { +export const DiceEventLabels: Record = { randomPace: 'Random Pace', halfPace: 'Half Pace', doublePace: 'Double Pace', @@ -24,7 +24,7 @@ export const GameEventLabels: Record = { cleanUp: 'Clean Up Mess', }; -export const GameEventDescriptions: Record = { +export const DiceEventDescriptions: Record = { randomPace: 'Randomly select a new pace', halfPace: 'Paw at half the current pace for a few seconds', doublePace: 'Paw at twice the current pace for a few seconds', From 6c0d3af64050b037840bfb49fd4bf32119e11a11 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 19:11:57 +0100 Subject: [PATCH 67/90] replaced delta time with fixed ticks --- src/engine/Engine.test.ts | 40 ++++++++++-------- src/engine/Engine.ts | 23 +++++++---- src/engine/State.ts | 4 +- src/engine/pipes/Perf.test.ts | 6 +-- src/engine/pipes/Scheduler.ts | 2 +- src/engine/pipes/Storage.test.ts | 18 ++++---- src/engine/pipes/Storage.ts | 12 +++--- src/engine/plugins/PluginInstaller.test.ts | 6 +-- src/engine/plugins/PluginManager.test.ts | 6 +-- src/game/GameProvider.tsx | 48 ++++++++++++++-------- src/game/plugins/fps.ts | 34 ++++++++++----- src/game/plugins/hypno.ts | 2 +- src/game/plugins/intensity.ts | 2 +- src/game/plugins/pause.test.ts | 6 +-- src/game/plugins/stroke.ts | 2 +- 15 files changed, 127 insertions(+), 84 deletions(-) diff --git a/src/engine/Engine.test.ts b/src/engine/Engine.test.ts index 664c34b..bd32f17 100644 --- a/src/engine/Engine.test.ts +++ b/src/engine/Engine.test.ts @@ -14,31 +14,39 @@ describe('GameEngine', () => { it('should increment tick on each tick', () => { const engine = new GameEngine({}, frame => frame); - engine.tick(16); + engine.tick(); expect(engine.getContext().tick).toBe(1); - engine.tick(16); + engine.tick(); expect(engine.getContext().tick).toBe(2); }); - it('should accumulate elapsed time', () => { + it('should accumulate time by fixed step', () => { const engine = new GameEngine({}, frame => frame); - engine.tick(16); - expect(engine.getContext().elapsedTime).toBe(16); + engine.tick(); + expect(engine.getContext().time).toBe(16); - engine.tick(20); - expect(engine.getContext().elapsedTime).toBe(36); + engine.tick(); + expect(engine.getContext().time).toBe(32); }); - it('should update deltaTime on each tick', () => { + it('should use default step of 16ms', () => { const engine = new GameEngine({}, frame => frame); - engine.tick(16); - expect(engine.getContext().deltaTime).toBe(16); + engine.tick(); + expect(engine.getContext().step).toBe(16); + }); + + it('should accept custom step size', () => { + const engine = new GameEngine({}, frame => frame, { step: 8 }); + + engine.tick(); + expect(engine.getContext().step).toBe(8); + expect(engine.getContext().time).toBe(8); - engine.tick(33); - expect(engine.getContext().deltaTime).toBe(33); + engine.tick(); + expect(engine.getContext().time).toBe(16); }); it('should pass state through pipe', () => { @@ -48,7 +56,7 @@ describe('GameEngine', () => { }); const engine = new GameEngine({}, pipe); - engine.tick(16); + engine.tick(); expect(engine.getState()).toEqual({ modified: true }); }); @@ -60,10 +68,10 @@ describe('GameEngine', () => { }); const engine = new GameEngine({}, pipe); - engine.tick(16); + engine.tick(); const state1 = engine.getState(); - engine.tick(16); + engine.tick(); const state2 = engine.getState(); expect(state1.nested).not.toBe(state2.nested); @@ -77,7 +85,7 @@ describe('GameEngine', () => { }); const engine = new GameEngine({}, pipe); - engine.tick(16); + engine.tick(); const state = engine.getState(); expect(() => { diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index db420a4..37df1e3 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -1,14 +1,19 @@ import { GameState, GameContext, Pipe, GameTiming, GameFrame } from './State'; import { deepFreeze } from './freeze'; +export type GameEngineOptions = { + step?: number; +}; + export class GameEngine { - constructor(initial: GameState, pipe: Pipe) { + constructor(initial: GameState, pipe: Pipe, options?: GameEngineOptions) { this.state = { ...initial }; this.pipe = pipe; + this.step = options?.step ?? 16; this.timing = { tick: 0, - deltaTime: 0, - elapsedTime: 0, + step: this.step, + time: 0, }; this.context = this.timing; } @@ -24,6 +29,11 @@ export class GameEngine { */ private pipe: Pipe; + /** + * The fixed time step per tick in milliseconds. + */ + private step: number; + /** * The context of the engine. May contain any ephemeral information of any plugin, however it is to be noted; * Context may be discarded at any time, so it may not contain information necessary to restore the game state. @@ -54,12 +64,11 @@ export class GameEngine { } /** - * Runs the game engine for a single tick, passing the delta time since the last tick. + * Runs the game engine for a single fixed-step tick. */ - public tick(deltaTime: number): GameState { + public tick(): GameState { this.timing.tick += 1; - this.timing.deltaTime = deltaTime; - this.timing.elapsedTime += deltaTime; + this.timing.time += this.step; const frame: GameFrame = { state: this.state, diff --git a/src/engine/State.ts b/src/engine/State.ts index cb1004c..c53643c 100644 --- a/src/engine/State.ts +++ b/src/engine/State.ts @@ -8,8 +8,8 @@ export type GameContext = { export type GameTiming = { tick: number; - deltaTime: number; - elapsedTime: number; + step: number; + time: number; }; export type GameFrame = { diff --git a/src/engine/pipes/Perf.test.ts b/src/engine/pipes/Perf.test.ts index 2ba9039..89da029 100644 --- a/src/engine/pipes/Perf.test.ts +++ b/src/engine/pipes/Perf.test.ts @@ -7,7 +7,7 @@ import { sdk } from '../sdk'; const makeFrame = (overrides?: Partial): GameFrame => ({ state: {}, - context: { tick: 0, deltaTime: 16, elapsedTime: 0 }, + context: { tick: 0, step: 16, time: 0 }, ...overrides, }); @@ -16,8 +16,8 @@ const tick = (frame: GameFrame): GameFrame => ({ context: { ...frame.context, tick: frame.context.tick + 1, - deltaTime: 16, - elapsedTime: frame.context.elapsedTime + 16, + step: 16, + time: frame.context.time + 16, }, }); diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 8c44b8a..dfbb3dc 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -69,7 +69,7 @@ export class Scheduler { } static pipe: Pipe = Composer.pipe( - Composer.bind(timing.deltaTime, delta => + Composer.bind(timing.step, delta => Composer.over(scheduler.state, ({ scheduled = [] }) => { const remaining: ScheduledEvent[] = []; const current: GameEvent[] = []; diff --git a/src/engine/pipes/Storage.test.ts b/src/engine/pipes/Storage.test.ts index 2a25365..f909338 100644 --- a/src/engine/pipes/Storage.test.ts +++ b/src/engine/pipes/Storage.test.ts @@ -21,7 +21,7 @@ describe('Storage', () => { const frame: GameFrame = { state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + context: { tick: 0, step: 0, time: 0 }, }; pipe(frame); @@ -37,7 +37,7 @@ describe('Storage', () => { const frame: GameFrame = { state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + context: { tick: 0, step: 0, time: 0 }, }; pipe(frame); @@ -53,7 +53,7 @@ describe('Storage', () => { let frame1: GameFrame = { state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 1000 }, + context: { tick: 0, step: 0, time: 1000 }, }; frame1 = Composer.over>( @@ -69,7 +69,7 @@ describe('Storage', () => { let frame2: GameFrame = { state: {}, - context: { tick: 1, deltaTime: 16, elapsedTime: 1016 }, + context: { tick: 1, step: 16, time: 1016 }, }; frame2 = Composer.over>( @@ -96,7 +96,7 @@ describe('Storage', () => { const frame: GameFrame = { state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + context: { tick: 0, step: 0, time: 0 }, }; pipe(frame); @@ -110,7 +110,7 @@ describe('Storage', () => { let frame: GameFrame = { state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 1000 }, + context: { tick: 0, step: 0, time: 1000 }, }; frame = Composer.over>( @@ -137,7 +137,7 @@ describe('Storage', () => { const frame: GameFrame = { state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + context: { tick: 0, step: 0, time: 0 }, }; pipe(frame); @@ -150,7 +150,7 @@ describe('Storage', () => { let frame: GameFrame = { state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 0 }, + context: { tick: 0, step: 0, time: 0 }, }; frame = Composer.over>( @@ -174,7 +174,7 @@ describe('Storage', () => { it('should clean up expired cache entries', () => { let frame: GameFrame = { state: {}, - context: { tick: 0, deltaTime: 0, elapsedTime: 20000 }, + context: { tick: 0, step: 0, time: 20000 }, }; frame = Composer.over>( diff --git a/src/engine/pipes/Storage.ts b/src/engine/pipes/Storage.ts index 5d440b1..d564cc6 100644 --- a/src/engine/pipes/Storage.ts +++ b/src/engine/pipes/Storage.ts @@ -6,7 +6,7 @@ * * Architecture: * - Settings are loaded on-demand from localStorage - * - Cached in context with expiry time (measured in deltaTime) + * - Cached in context with expiry time (measured in game time) * - Hot cache expires after inactivity to save memory * - Writes are immediate to localStorage and update cache */ @@ -17,7 +17,7 @@ import { pluginPaths } from '../plugins/Plugins'; import { GameTiming, Pipe } from '../State'; export const STORAGE_NAMESPACE = 'how.joi.storage'; -const CACHE_TTL = 30000; // 30 seconds of game time (deltaTime sum) +const CACHE_TTL = 30000; // 30 seconds of game time export type CacheEntry = { value: any; @@ -68,7 +68,7 @@ export class Storage { // Cache it (will be set with expiry in the next pipe) return Composer.pipe( - Composer.bind(timing.elapsedTime, elapsedTime => + Composer.bind(timing.time, elapsedTime => Composer.over(storage.context, ctx => ({ cache: { ...(ctx?.cache || {}), @@ -97,7 +97,7 @@ export class Storage { } // Update cache - return Composer.bind(timing.elapsedTime, elapsedTime => + return Composer.bind(timing.time, elapsedTime => Composer.over(storage.context, ctx => ({ cache: { ...(ctx?.cache || {}), @@ -133,10 +133,10 @@ export class Storage { } /** - * Storage pipe - updates cache expiry based on deltaTime + * Storage pipe - evicts expired cache entries */ static pipe: Pipe = Composer.pipe( - Composer.bind(timing.elapsedTime, elapsedTime => + Composer.bind(timing.time, elapsedTime => Composer.over(storage.context, ctx => { const newCache: { [key: string]: CacheEntry } = {}; diff --git a/src/engine/plugins/PluginInstaller.test.ts b/src/engine/plugins/PluginInstaller.test.ts index 681e490..5651439 100644 --- a/src/engine/plugins/PluginInstaller.test.ts +++ b/src/engine/plugins/PluginInstaller.test.ts @@ -11,7 +11,7 @@ const PLUGIN_NAMESPACE = 'core.plugin_installer'; const makeFrame = (overrides?: Partial): GameFrame => ({ state: {}, - context: { tick: 0, deltaTime: 16, elapsedTime: 0 }, + context: { tick: 0, step: 16, time: 0 }, ...overrides, }); @@ -20,8 +20,8 @@ const tick = (frame: GameFrame): GameFrame => ({ context: { ...frame.context, tick: frame.context.tick + 1, - deltaTime: 16, - elapsedTime: frame.context.elapsedTime + 16, + step: 16, + time: frame.context.time + 16, }, }); diff --git a/src/engine/plugins/PluginManager.test.ts b/src/engine/plugins/PluginManager.test.ts index a5a5a7a..96aaac2 100644 --- a/src/engine/plugins/PluginManager.test.ts +++ b/src/engine/plugins/PluginManager.test.ts @@ -9,7 +9,7 @@ const PLUGIN_NAMESPACE = 'core.plugin_manager'; const makeFrame = (overrides?: Partial): GameFrame => ({ state: {}, - context: { tick: 0, deltaTime: 16, elapsedTime: 0 }, + context: { tick: 0, step: 16, time: 0 }, ...overrides, }); @@ -18,8 +18,8 @@ const tick = (frame: GameFrame, n = 1): GameFrame => ({ context: { ...frame.context, tick: frame.context.tick + n, - deltaTime: 16, - elapsedTime: frame.context.elapsedTime + 16 * n, + step: 16, + time: frame.context.time + 16 * n, }, }); diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index aa53ba9..82bb40b 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -34,7 +34,6 @@ type Props = { export function GameEngineProvider({ children, pipes = [] }: Props) { const engineRef = useRef(null); - const lastTimeRef = useRef(null); const [state, setState] = useState(null); const [context, setContext] = useState(null); @@ -54,33 +53,49 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { Piper([impulsePipe, Events.pipe, Scheduler.pipe, Perf.pipe, ...pipes]) ); + const STEP = 16; + const MAX_TICKS_PER_FRAME = 4; + let accumulator = 0; + let lastWallTime: number | null = null; let frameId: number; - const loop = (time: number) => { + const loop = () => { if (!engineRef.current) return; - if (document.hidden) { - lastTimeRef.current = null; - frameId = requestAnimationFrame(loop); - return; - } + const now = performance.now(); - if (lastTimeRef.current == null) { - lastTimeRef.current = time; + if (document.hidden || lastWallTime === null) { + lastWallTime = now; frameId = requestAnimationFrame(loop); return; } - const deltaTime = time - lastTimeRef.current; - lastTimeRef.current = time; + accumulator += now - lastWallTime; + lastWallTime = now; - activeImpulseRef.current = pendingImpulseRef.current; - pendingImpulseRef.current = []; + let ticked = false; + let ticks = 0; - engineRef.current.tick(deltaTime); + while (accumulator >= STEP && ticks < MAX_TICKS_PER_FRAME) { + if (!ticked) { + activeImpulseRef.current = pendingImpulseRef.current; + pendingImpulseRef.current = []; + ticked = true; + } - setState(engineRef.current.getState()); - setContext(engineRef.current.getContext()); + engineRef.current.tick(); + accumulator -= STEP; + ticks++; + } + + if (ticks >= MAX_TICKS_PER_FRAME) { + accumulator = 0; + } + + if (ticked) { + setState(engineRef.current.getState()); + setContext(engineRef.current.getContext()); + } frameId = requestAnimationFrame(loop); }; @@ -89,7 +104,6 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { return () => { cancelAnimationFrame(frameId); - lastTimeRef.current = null; engineRef.current = null; pendingImpulseRef.current = []; activeImpulseRef.current = []; diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts index eb38ad2..e2064c1 100644 --- a/src/game/plugins/fps.ts +++ b/src/game/plugins/fps.ts @@ -9,7 +9,9 @@ const HISTORY_SIZE = 30; type FpsContext = { el: HTMLElement; - history: number[]; + fpsHistory: number[]; + tpsHistory: number[]; + lastWallTime: number; }; const fps = pluginPaths(PLUGIN_ID); @@ -54,7 +56,7 @@ export default class Fps { el.style.display = visible ? '' : 'none'; document.querySelector('.game-page')?.appendChild(el); - set(fps.context, { el, history: [] }); + set(fps.context, { el, fpsHistory: [], tpsHistory: [], lastWallTime: performance.now() }); }), update: Composer.do(({ get, set }) => { @@ -65,17 +67,27 @@ export default class Fps { if (ctx.el) ctx.el.style.display = visible ? '' : 'none'; if (!visible) return; - const delta = get(gameContext.deltaTime); - const current = delta > 0 ? 1000 / delta : 0; - const history = [...ctx.history, current].slice(-HISTORY_SIZE); - const avg = - history.length > 0 - ? history.reduce((sum, v) => sum + v, 0) / history.length - : current; + const now = performance.now(); + const wallDelta = now - ctx.lastWallTime; - if (ctx.el) ctx.el.textContent = `${Math.round(avg)} FPS`; + const currentFps = wallDelta > 0 ? 1000 / wallDelta : 0; + const fpsHistory = [...ctx.fpsHistory, currentFps].slice(-HISTORY_SIZE); + const avgFps = + fpsHistory.length > 0 + ? fpsHistory.reduce((sum, v) => sum + v, 0) / fpsHistory.length + : currentFps; - set(fps.context, { ...ctx, history }); + const step = get(gameContext.step); + const currentTps = step > 0 ? 1000 / step : 0; + const tpsHistory = [...ctx.tpsHistory, currentTps].slice(-HISTORY_SIZE); + const avgTps = + tpsHistory.length > 0 + ? tpsHistory.reduce((sum, v) => sum + v, 0) / tpsHistory.length + : currentTps; + + if (ctx.el) ctx.el.textContent = `${Math.round(avgFps)} FPS / ${Math.round(avgTps)} TPS`; + + set(fps.context, { ...ctx, fpsHistory, tpsHistory, lastWallTime: now }); }), deactivate: Composer.do(({ get, set }) => { diff --git a/src/game/plugins/hypno.ts b/src/game/plugins/hypno.ts index b732c72..5a67989 100644 --- a/src/game/plugins/hypno.ts +++ b/src/game/plugins/hypno.ts @@ -46,7 +46,7 @@ export default class Hypno { const delay = 3000 - i * 29; if (delay <= 0) return; - const delta = get(gameContext.deltaTime); + const delta = get(gameContext.step); const state = get(hypno.state); if (!state) return; diff --git a/src/game/plugins/intensity.ts b/src/game/plugins/intensity.ts index 55be295..124909c 100644 --- a/src/game/plugins/intensity.ts +++ b/src/game/plugins/intensity.ts @@ -36,7 +36,7 @@ export default class Intensity { Phase.whenPhase( GamePhase.active, Composer.do(({ get, over }) => { - const delta = get(gameContext.deltaTime); + const delta = get(gameContext.step); const s = get(settings); over(intensity.state, ({ intensity: i = 0 }) => ({ intensity: Math.min(1, i + delta / (s.gameDuration * 1000)), diff --git a/src/game/plugins/pause.test.ts b/src/game/plugins/pause.test.ts index 34a4e67..0eb6461 100644 --- a/src/game/plugins/pause.test.ts +++ b/src/game/plugins/pause.test.ts @@ -13,7 +13,7 @@ import Pause, { PauseState } from './pause'; const makeFrame = (): GameFrame => ({ state: {}, - context: { tick: 0, deltaTime: 16, elapsedTime: 0 }, + context: { tick: 0, step: 16, time: 0 }, }); const tick = (frame: GameFrame, dt = 16): GameFrame => ({ @@ -21,8 +21,8 @@ const tick = (frame: GameFrame, dt = 16): GameFrame => ({ context: { ...frame.context, tick: frame.context.tick + 1, - deltaTime: dt, - elapsedTime: frame.context.elapsedTime + dt, + step: dt, + time: frame.context.time + dt, }, }); diff --git a/src/game/plugins/stroke.ts b/src/game/plugins/stroke.ts index cdf653a..accbf26 100644 --- a/src/game/plugins/stroke.ts +++ b/src/game/plugins/stroke.ts @@ -50,7 +50,7 @@ export default class Stroke { const pace = get(paceState)?.pace; if (!pace || pace <= 0) return; - const delta = get(gameContext.deltaTime); + const delta = get(gameContext.step); const state = get(stroke.state); if (!state) return; From d756cb110be4c94c464a604e241f1d2efd4eafaa Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 20:29:50 +0100 Subject: [PATCH 68/90] replaced math random with randomness plugin --- src/engine/Random.ts | 36 ------------ src/engine/index.ts | 1 - src/engine/sdk.ts | 3 - src/game/plugins/dealer.ts | 46 +++++++++++---- src/game/plugins/dice/climax.ts | 47 ++++++++------- src/game/plugins/dice/halfPace.ts | 29 +++++---- src/game/plugins/dice/randomGrip.ts | 33 +++++------ src/game/plugins/dice/randomPace.ts | 42 +++++++------ src/game/plugins/hypno.ts | 15 +++-- src/game/plugins/index.ts | 2 + src/game/plugins/rand.ts | 91 +++++++++++++++++++++++++++++ src/game/plugins/randomImages.ts | 55 +++++++++-------- 12 files changed, 251 insertions(+), 149 deletions(-) delete mode 100644 src/engine/Random.ts create mode 100644 src/game/plugins/rand.ts diff --git a/src/engine/Random.ts b/src/engine/Random.ts deleted file mode 100644 index fc06aa9..0000000 --- a/src/engine/Random.ts +++ /dev/null @@ -1,36 +0,0 @@ -export class Random { - private s: number; - - constructor(seed: string) { - this.s = Random.stringToSeed(seed); - } - - private static stringToSeed(str: string): number { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = (Math.imul(31, hash) + str.charCodeAt(i)) | 0; - } - return hash >>> 0; - } - - next(): number { - this.s = Math.imul(48271, this.s) % 0x7fffffff; - return (this.s & 0x7fffffff) / 0x7fffffff; - } - - nextInt(max: number): number { - return Math.floor(this.next() * max); - } - - nextFloatRange(min: number, max: number): number { - return this.next() * (max - min) + min; - } - - nextBool(prob = 0.5): boolean { - return this.next() < prob; - } - - pick(arr: T[]): T { - return arr[this.nextInt(arr.length)]; - } -} diff --git a/src/engine/index.ts b/src/engine/index.ts index 9e74c66..1db5210 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -4,5 +4,4 @@ export * from './DOMBatcher'; export * from './Engine'; export * from './Lens'; export * from './Piper'; -export * from './Random'; export * from './State'; diff --git a/src/engine/sdk.ts b/src/engine/sdk.ts index 04b590d..1fafeae 100644 --- a/src/engine/sdk.ts +++ b/src/engine/sdk.ts @@ -4,7 +4,6 @@ import { Scheduler } from './pipes/Scheduler'; import { Storage } from './pipes/Storage'; import { PluginManager } from './plugins/PluginManager'; import { pluginPaths } from './plugins/Plugins'; -import { Random } from './Random'; import { Perf } from './pipes/Perf'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type @@ -18,7 +17,6 @@ export interface SDK extends PluginSDK { Storage: typeof Storage; PluginManager: typeof PluginManager; Perf: typeof Perf; - Random: typeof Random; pluginPaths: typeof pluginPaths; } @@ -30,6 +28,5 @@ export const sdk: SDK = { Storage, PluginManager, Perf, - Random, pluginPaths, } as SDK; diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index 01ab535..c4d049e 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -5,6 +5,7 @@ import { Scheduler } from '../../engine/pipes/Scheduler'; import { Sequence } from '../Sequence'; import Phase, { GamePhase } from './phase'; import Pause from './pause'; +import Rand from './rand'; import { DiceEvent } from '../../types'; import { PLUGIN_ID, @@ -42,6 +43,8 @@ const outcomes: DiceOutcome[] = [ ]; const rollChances: Record = { + [DiceEvent.climax]: 1, + [DiceEvent.edge]: 1, [DiceEvent.randomPace]: 10, [DiceEvent.cleanUp]: 25, [DiceEvent.randomGrip]: 50, @@ -80,7 +83,7 @@ export default class Dealer { Composer.pipe( Phase.whenPhase( GamePhase.active, - Composer.do(({ get, set, pipe }) => { + Composer.do(({ get, pipe }) => { const state = get(dice.state); if (!state || state.busy) return; @@ -89,18 +92,39 @@ export default class Dealer { const frame = get(); - for (const outcome of outcomes) { - if (!s.events.includes(outcome.id)) continue; - if (outcome.check && !outcome.check(frame)) continue; - - const chance = rollChances[outcome.id]; - if (chance && Math.floor(Math.random() * chance) !== 0) - continue; - - set(dice.state.busy, true); - pipe(Events.dispatch({ type: eventKeyForOutcome(outcome.id) })); + const eligible = outcomes.filter(o => { + if (!s.events.includes(o.id)) return false; + if (!rollChances[o.id]) return false; + if (o.check && !o.check(frame)) return false; + return true; + }); + + const guaranteed = eligible.find(o => rollChances[o.id] === 1); + if (guaranteed) { + pipe( + Composer.set(dice.state.busy, true), + Events.dispatch({ type: eventKeyForOutcome(guaranteed.id) }) + ); return; } + + pipe( + Rand.next(roll => { + let cumulative = 0; + for (const outcome of eligible) { + cumulative += 1 / rollChances[outcome.id]; + if (roll < cumulative) { + return Composer.pipe( + Composer.set(dice.state.busy, true), + Events.dispatch({ + type: eventKeyForOutcome(outcome.id), + }) + ); + } + } + return Composer.pipe(); + }) + ); }) ), roll.after(1000, 'check') diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts index c92b8a7..93eeded 100644 --- a/src/game/plugins/dice/climax.ts +++ b/src/game/plugins/dice/climax.ts @@ -2,6 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; import Pace from '../pace'; +import Rand from '../rand'; import { DiceEvent } from '../../../types'; import { IntensityState } from '../intensity'; import { @@ -76,27 +77,31 @@ export const climaxOutcome: DiceOutcome = { ), seq.on('resolve', () => - Composer.bind(settings, s => { - if (Math.random() * 100 <= s.climaxChance) { - const ruin = Math.random() * 100 <= s.ruinChance; - return Composer.pipe( - Phase.setPhase(ruin ? GamePhase.break : GamePhase.climax), - seq.message({ - title: ruin ? '$HANDS OFF! Ruin your orgasm!' : 'Cum!', - description: undefined, - }), - seq.after(3000, 'end', { countdown: 10, ruin }) - ); - } - return Composer.pipe( - Phase.setPhase(GamePhase.break), - seq.message({ - title: '$HANDS OFF! Do not cum!', - description: undefined, - }), - seq.after(1000, 'end', { countdown: 5, denied: true }) - ); - }) + Composer.bind(settings, s => + Rand.next(roll => { + if (roll * 100 > s.climaxChance) { + return Composer.pipe( + Phase.setPhase(GamePhase.break), + seq.message({ + title: '$HANDS OFF! Do not cum!', + description: undefined, + }), + seq.after(1000, 'end', { countdown: 5, denied: true }) + ); + } + return Rand.next(ruinRoll => { + const ruin = ruinRoll * 100 <= s.ruinChance; + return Composer.pipe( + Phase.setPhase(ruin ? GamePhase.break : GamePhase.climax), + seq.message({ + title: ruin ? '$HANDS OFF! Ruin your orgasm!' : 'Cum!', + description: undefined, + }), + seq.after(3000, 'end', { countdown: 10, ruin }) + ); + }); + }) + ) ), seq.on('end', event => { diff --git a/src/game/plugins/dice/halfPace.ts b/src/game/plugins/dice/halfPace.ts index 823b405..8289349 100644 --- a/src/game/plugins/dice/halfPace.ts +++ b/src/game/plugins/dice/halfPace.ts @@ -1,6 +1,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Pace from '../pace'; +import Rand from '../rand'; import { DiceEvent } from '../../../types'; import { round } from '../../../utils'; import { @@ -25,18 +26,22 @@ export const halfPaceOutcome: DiceOutcome = { }, update: Composer.pipe( seq.on(() => - Composer.bind(paceState, pace => - Composer.bind(settings, s => { - const newPace = Math.max(round((pace?.pace ?? 1) / 2), s.minPace); - const duration = Math.ceil(Math.random() * 20000) + 12000; - const portion = duration / 3; - return Composer.pipe( - Pace.setPace(newPace), - seq.message({ title: 'Half pace!', description: '3...' }), - seq.after(portion, 'step2', { portion }) - ); - }) - ) + Composer.do(({ get, pipe }) => { + const pace = get(paceState); + const s = get(settings); + const newPace = Math.max(round((pace?.pace ?? 1) / 2), s.minPace); + pipe( + Rand.next(v => { + const duration = Math.ceil(v * 20000) + 12000; + const portion = duration / 3; + return Composer.pipe( + Pace.setPace(newPace), + seq.message({ title: 'Half pace!', description: '3...' }), + seq.after(portion, 'step2', { portion }) + ); + }) + ); + }) ), seq.on('step2', event => Composer.pipe( diff --git a/src/game/plugins/dice/randomGrip.ts b/src/game/plugins/dice/randomGrip.ts index 1aab6aa..565fe43 100644 --- a/src/game/plugins/dice/randomGrip.ts +++ b/src/game/plugins/dice/randomGrip.ts @@ -2,6 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { typedPath } from '../../../engine/Lens'; import { Sequence } from '../../Sequence'; import { DiceEvent } from '../../../types'; +import Rand from '../rand'; import { PLUGIN_ID, outcomeDone, DiceOutcome } from './types'; export enum Paws { @@ -20,29 +21,27 @@ export const pawsPath = typedPath(['state', PLUGIN_ID, 'paws']); const allPaws = Object.values(Paws); -const randomPaw = (exclude?: Paws): Paws => { - const options = exclude ? allPaws.filter(p => p !== exclude) : allPaws; - return options[Math.floor(Math.random() * options.length)]; -}; - const seq = Sequence.for(PLUGIN_ID, 'randomGrip'); export const randomGripOutcome: DiceOutcome = { id: DiceEvent.randomGrip, - activate: Composer.over(pawsPath, () => randomPaw()), + activate: Rand.pick(allPaws, paw => Composer.set(pawsPath, paw)), update: Composer.pipe( seq.on(() => - Composer.bind(pawsPath, currentPaws => { - const newPaws = randomPaw(currentPaws); - return Composer.pipe( - Composer.set(pawsPath, newPaws), - seq.message({ - title: `Grip changed to ${PawLabels[newPaws]}!`, - duration: 5000, - }), - seq.after(10000, 'done') - ); - }) + Composer.bind(pawsPath, currentPaws => + Rand.pick( + allPaws.filter(p => p !== currentPaws), + newPaws => + Composer.pipe( + Composer.set(pawsPath, newPaws), + seq.message({ + title: `Grip changed to ${PawLabels[newPaws]}!`, + duration: 5000, + }), + seq.after(10000, 'done') + ) + ) + ) ), seq.on('done', () => outcomeDone()) ), diff --git a/src/game/plugins/dice/randomPace.ts b/src/game/plugins/dice/randomPace.ts index ab3ba9d..2e71743 100644 --- a/src/game/plugins/dice/randomPace.ts +++ b/src/game/plugins/dice/randomPace.ts @@ -2,6 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import { Pipe } from '../../../engine/State'; import Pace from '../pace'; +import Rand from '../rand'; import { DiceEvent } from '../../../types'; import { intensityToPaceRange, round } from '../../../utils'; import { @@ -15,25 +16,28 @@ import { const seq = Sequence.for(PLUGIN_ID, 'randomPace'); export const doRandomPace = (): Pipe => - Composer.bind(intensityState, ist => - Composer.bind(settings, s => { - const i = ist?.intensity ?? 0; - const { min, max } = intensityToPaceRange( - i * 100, - s.steepness, - s.timeshift, - { min: s.minPace, max: s.maxPace } - ); - const newPace = round(Math.random() * (max - min) + min); - return Composer.pipe( - Pace.setPace(newPace), - seq.message({ - title: `Pace changed to ${newPace}!`, - duration: 5000, - }) - ); - }) - ); + Composer.do(({ get, pipe }) => { + const i = get(intensityState)?.intensity ?? 0; + const s = get(settings); + const { min, max } = intensityToPaceRange( + i * 100, + s.steepness, + s.timeshift, + { min: s.minPace, max: s.maxPace } + ); + pipe( + Rand.nextFloatRange(min, max, v => { + const newPace = round(v); + return Composer.pipe( + Pace.setPace(newPace), + seq.message({ + title: `Pace changed to ${newPace}!`, + duration: 5000, + }) + ); + }) + ); + }); export const randomPaceOutcome: DiceOutcome = { id: DiceEvent.randomPace, diff --git a/src/game/plugins/hypno.ts b/src/game/plugins/hypno.ts index 5a67989..c35fa2c 100644 --- a/src/game/plugins/hypno.ts +++ b/src/game/plugins/hypno.ts @@ -8,6 +8,7 @@ import Pause from './pause'; import { IntensityState } from './intensity'; import { Settings } from '../../settings'; import { GameHypnoType, HypnoPhrases } from '../../types'; +import Rand from './rand'; const PLUGIN_ID = 'core.hypno'; @@ -35,7 +36,7 @@ export default class Hypno { }), update: Pause.whenPlaying( - Composer.do(({ get, set }) => { + Composer.do(({ get, set, pipe }) => { const phase = get(phaseState)?.current; if (phase !== GamePhase.active) return; @@ -59,10 +60,14 @@ export default class Hypno { const phrases = HypnoPhrases[s.hypno]; if (phrases.length <= 0) return; - set(hypno.state, { - currentPhrase: Math.floor(Math.random() * phrases.length), - timer: 0, - }); + pipe( + Rand.nextInt(phrases.length, idx => + Composer.set(hypno.state, { + currentPhrase: idx, + timer: 0, + }) + ) + ); }) ), diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index 0b815a3..4713881 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -12,6 +12,7 @@ import Image from './image'; import RandomImages from './randomImages'; import Warmup from './warmup'; import Stroke from './stroke'; +import Rand from './rand'; import Dealer from './dealer'; import EmergencyStop from './emergencyStop'; import Hypno from './hypno'; @@ -24,6 +25,7 @@ const plugins = [ Pace, Intensity, Stroke, + Rand, Dealer, EmergencyStop, Hypno, diff --git a/src/game/plugins/rand.ts b/src/game/plugins/rand.ts new file mode 100644 index 0000000..ae7a8ac --- /dev/null +++ b/src/game/plugins/rand.ts @@ -0,0 +1,91 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { pluginPaths } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine/Composer'; +import { Pipe } from '../../engine/State'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Rand: typeof Rand; + } +} + +const PLUGIN_ID = 'core.rand'; + +type RandState = { + seed: string; + cursor: number; +}; + +const paths = pluginPaths(PLUGIN_ID); + +function stringToSeed(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (Math.imul(31, hash) + str.charCodeAt(i)) | 0; + } + return hash >>> 0; +} + +function advance(cursor: number): [number, number] { + const next = Math.imul(48271, cursor) % 0x7fffffff; + return [next, (next & 0x7fffffff) / 0x7fffffff]; +} + +export default class Rand { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Rand', + }, + + activate: Composer.do(({ set }) => { + const seed = Date.now().toString(); + set(paths.state, { seed, cursor: stringToSeed(seed) }); + }), + + deactivate: Composer.set(paths.state, undefined), + }; + + static next(fn: (value: number) => Pipe): Pipe { + return Composer.do(({ get, set, pipe }) => { + const [cursor, value] = advance(get(paths.state.cursor)); + set(paths.state.cursor, cursor); + pipe(fn(value)); + }); + } + + static nextInt(max: number, fn: (value: number) => Pipe): Pipe { + return Rand.next(v => fn(Math.floor(v * max))); + } + + static nextFloatRange( + min: number, + max: number, + fn: (value: number) => Pipe + ): Pipe { + return Rand.next(v => fn(v * (max - min) + min)); + } + + static pick(arr: T[], fn: (value: T) => Pipe): Pipe { + return Rand.nextInt(arr.length, i => fn(arr[i])); + } + + static shuffle(arr: T[], fn: (shuffled: T[]) => Pipe): Pipe { + return Composer.do(({ get, set, pipe }) => { + let cursor = get(paths.state.cursor); + const shuffled = [...arr]; + for (let i = shuffled.length - 1; i > 0; i--) { + const [c, v] = advance(cursor); + cursor = c; + const j = Math.floor(v * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + set(paths.state.cursor, cursor); + pipe(fn(shuffled)); + }); + } + + static get paths() { + return paths; + } +} diff --git a/src/game/plugins/randomImages.ts b/src/game/plugins/randomImages.ts index 6af32c5..36b0f62 100644 --- a/src/game/plugins/randomImages.ts +++ b/src/game/plugins/randomImages.ts @@ -6,6 +6,7 @@ import { typedPath } from '../../engine/Lens'; import { ImageItem } from '../../types'; import Image, { ImageState } from './image'; import { IntensityState } from './intensity'; +import Rand from './rand'; declare module '../../engine/sdk' { interface PluginSDK { @@ -38,13 +39,15 @@ export default class RandomImages { const imgs = get(images); if (!imgs || imgs.length === 0) return; - const shuffled = [...imgs].sort(() => Math.random() - 0.5); - const initial = shuffled.slice(0, Math.min(3, imgs.length)); - - for (const img of initial) { - pipe(Image.pushNextImage(img)); - } - pipe(Events.dispatch({ type: eventType.scheduleNext })); + pipe( + Rand.shuffle(imgs, shuffled => { + const initial = shuffled.slice(0, Math.min(3, imgs.length)); + return Composer.pipe( + ...initial.map(img => Image.pushNextImage(img)), + Events.dispatch({ type: eventType.scheduleNext }) + ); + }) + ); }), update: Events.handle(eventType.scheduleNext, () => @@ -68,24 +71,28 @@ export default class RandomImages { ); const totalWeight = weights.reduce((sum, w) => sum + w, 0); - let random = Math.random() * totalWeight; - let selectedIndex = 0; - for (let i = 0; i < weights.length; i++) { - random -= weights[i]; - if (random <= 0) { - selectedIndex = i; - break; - } - } - - const randomImage = imagesWithDistance[selectedIndex].image; - - pipe(Image.pushNextImage(randomImage)); pipe( - Scheduler.schedule({ - id: scheduleId, - duration: getImageSwitchDuration(intensity), - event: { type: eventType.scheduleNext }, + Rand.next(roll => { + let remaining = roll * totalWeight; + let selectedIndex = 0; + for (let i = 0; i < weights.length; i++) { + remaining -= weights[i]; + if (remaining <= 0) { + selectedIndex = i; + break; + } + } + + const randomImage = imagesWithDistance[selectedIndex].image; + + return Composer.pipe( + Image.pushNextImage(randomImage), + Scheduler.schedule({ + id: scheduleId, + duration: getImageSwitchDuration(intensity), + event: { type: eventType.scheduleNext }, + }) + ); }) ); }) From 620ba684ca6bd1661a666ea313da59939a7d11e7 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 20:30:15 +0100 Subject: [PATCH 69/90] formatted files --- src/engine/pipes/Events.ts | 5 ++++- src/engine/pipes/Perf.ts | 18 ++++++++---------- src/game/plugins/dice/cleanUp.ts | 5 +---- src/game/plugins/dice/risingPace.ts | 6 +++++- src/game/plugins/fps.ts | 10 ++++++++-- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 809f053..f952ee4 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -48,7 +48,10 @@ export class Events { return pendingLens.over((pending = []) => [...pending, event]); } - static handle(type: string, fn: (event: GameEvent) => Pipe): Pipe { + static handle( + type: string, + fn: (event: GameEvent) => Pipe + ): Pipe { const { namespace, key } = Events.parseKey(type); const isWildcard = key === '*'; const prefix = namespace + '/'; diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index 8f5c2e9..cc80b3f 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -13,10 +13,7 @@ export type PluginPerfEntry = { lastTick: number; }; -export type PerfMetrics = Record< - PluginId, - Record ->; +export type PerfMetrics = Record>; export type PerfConfig = { pluginBudget: number; @@ -35,7 +32,12 @@ const DEFAULT_CONFIG: PerfConfig = { pluginBudget: 1, }; -type OverBudgetPayload = { id: string; phase: string; duration: number; budget: number }; +type OverBudgetPayload = { + id: string; + phase: string; + duration: number; + budget: number; +}; const eventType = Events.getKeys(PLUGIN_NAMESPACE, 'over_budget', 'configure'); @@ -43,11 +45,7 @@ const perf = pluginPaths(PLUGIN_NAMESPACE); const gameContext = typedPath(['context']); export class Perf { - static withTiming( - id: PluginId, - phase: string, - pluginPipe: Pipe - ): Pipe { + static withTiming(id: PluginId, phase: string, pluginPipe: Pipe): Pipe { return Composer.do(({ get, set, pipe }) => { if (!sdk.debug) { pipe(pluginPipe); diff --git a/src/game/plugins/dice/cleanUp.ts b/src/game/plugins/dice/cleanUp.ts index 0a355f5..84dc55d 100644 --- a/src/game/plugins/dice/cleanUp.ts +++ b/src/game/plugins/dice/cleanUp.ts @@ -1,10 +1,7 @@ import { Composer } from '../../../engine/Composer'; import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; -import { - DiceEvent, - CleanUpDescriptions, -} from '../../../types'; +import { DiceEvent, CleanUpDescriptions } from '../../../types'; import { PLUGIN_ID, intensityState, diff --git a/src/game/plugins/dice/risingPace.ts b/src/game/plugins/dice/risingPace.ts index e34d5d8..7b1b6b6 100644 --- a/src/game/plugins/dice/risingPace.ts +++ b/src/game/plugins/dice/risingPace.ts @@ -12,7 +12,11 @@ import { } from './types'; import { doRandomPace } from './randomPace'; -type RisingPacePayload = { current: number; portion: number; remaining: number }; +type RisingPacePayload = { + current: number; + portion: number; + remaining: number; +}; const seq = Sequence.for(PLUGIN_ID, 'risingPace'); diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts index e2064c1..8d871a5 100644 --- a/src/game/plugins/fps.ts +++ b/src/game/plugins/fps.ts @@ -56,7 +56,12 @@ export default class Fps { el.style.display = visible ? '' : 'none'; document.querySelector('.game-page')?.appendChild(el); - set(fps.context, { el, fpsHistory: [], tpsHistory: [], lastWallTime: performance.now() }); + set(fps.context, { + el, + fpsHistory: [], + tpsHistory: [], + lastWallTime: performance.now(), + }); }), update: Composer.do(({ get, set }) => { @@ -85,7 +90,8 @@ export default class Fps { ? tpsHistory.reduce((sum, v) => sum + v, 0) / tpsHistory.length : currentTps; - if (ctx.el) ctx.el.textContent = `${Math.round(avgFps)} FPS / ${Math.round(avgTps)} TPS`; + if (ctx.el) + ctx.el.textContent = `${Math.round(avgFps)} FPS / ${Math.round(avgTps)} TPS`; set(fps.context, { ...ctx, fpsHistory, tpsHistory, lastWallTime: now }); }), From 4057ca9dba9c9c4ecc66cf2ed2c6995b5f2ab1c7 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 20:46:23 +0100 Subject: [PATCH 70/90] fixed updating game settings during live game --- src/game/GamePage.tsx | 19 +++++++++++-------- src/game/pipes/Settings.ts | 25 ++++++++++--------------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 2f50e0b..7f62203 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import styled from 'styled-components'; import { GameEngineProvider } from './GameProvider'; import { GameMessages } from './components/GameMessages'; @@ -76,16 +77,18 @@ const StyledBottomBar = styled.div` export const GamePage = () => { const settingsPipe = useSettingsPipe(); + const pipes = useMemo( + () => [ + pluginManagerPipe, + pluginInstallerPipe, + registerPlugins, + settingsPipe, + ], + [settingsPipe] + ); return ( - + diff --git a/src/game/pipes/Settings.ts b/src/game/pipes/Settings.ts index 2dd1d41..4bef91d 100644 --- a/src/game/pipes/Settings.ts +++ b/src/game/pipes/Settings.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { useSettings, useImages } from '../../settings'; import { Composer, Pipe } from '../../engine'; @@ -8,20 +8,15 @@ export const useSettingsPipe = (): Pipe => { const settingsRef = useRef(settings); const imagesRef = useRef(images); - useEffect(() => { - if (settingsRef.current !== settings) { - settingsRef.current = settings; - } - }, [settings]); + settingsRef.current = settings; + imagesRef.current = images; - useEffect(() => { - if (imagesRef.current !== images) { - imagesRef.current = images; - } - }, [images]); - - return Composer.pipe( - Composer.set(['context', 'settings'], settingsRef.current), - Composer.set(['context', 'images'], imagesRef.current) + return useCallback( + frame => + Composer.pipe( + Composer.set(['context', 'settings'], settingsRef.current), + Composer.set(['context', 'images'], imagesRef.current) + )(frame), + [] ); }; From 5a3f262e83a1ee9c61e0021491f736529654a0e7 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 21:32:17 +0100 Subject: [PATCH 71/90] fixed nested dialog closing --- src/game/components/GameSettings.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/game/components/GameSettings.tsx b/src/game/components/GameSettings.tsx index ef36854..6ca69c8 100644 --- a/src/game/components/GameSettings.tsx +++ b/src/game/components/GameSettings.tsx @@ -103,7 +103,17 @@ export const GameSettings = () => { onOpen(false)} + onWaHide={e => { + if ( + e.target === e.currentTarget && + (e.currentTarget as HTMLElement)?.querySelector('wa-dialog[open]') + ) + e.preventDefault(); + }} + onWaAfterHide={e => { + if (e.target !== e.currentTarget) return; + onOpen(false); + }} label={'Game Settings'} style={{ '--width': '920px', From c299c979ba155415517b79c4eedd289930358da7 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 21:37:42 +0100 Subject: [PATCH 72/90] fixed event button alignment --- src/common/ToggleTile.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/common/ToggleTile.tsx b/src/common/ToggleTile.tsx index 514ad22..46c564d 100644 --- a/src/common/ToggleTile.tsx +++ b/src/common/ToggleTile.tsx @@ -20,6 +20,7 @@ export class JoiToggleTileElement extends LitElement { display: block; width: 100%; cursor: pointer; + text-align: left; background: var(--wa-color-neutral-fill-quiet); opacity: var(--tile-inactive-opacity); From 79407e3909546720b26239e1254b55092e39805e Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 21:44:30 +0100 Subject: [PATCH 73/90] moved tests to their own folder --- {src => tests}/engine/Composer.test.ts | 2 +- {src => tests}/engine/Engine.test.ts | 4 ++-- {src => tests}/engine/Lens.test.ts | 2 +- {src => tests}/engine/pipes/Perf.test.ts | 10 +++++----- {src => tests}/engine/pipes/Storage.test.ts | 6 +++--- .../engine/plugins/PluginInstaller.test.ts | 14 +++++++------- .../engine/plugins/PluginManager.test.ts | 10 +++++----- {src => tests}/game/plugins/pause.test.ts | 16 ++++++++-------- tsconfig.json | 2 +- vite.config.ts | 2 +- 10 files changed, 34 insertions(+), 34 deletions(-) rename {src => tests}/engine/Composer.test.ts (99%) rename {src => tests}/engine/Engine.test.ts (95%) rename {src => tests}/engine/Lens.test.ts (98%) rename {src => tests}/engine/pipes/Perf.test.ts (95%) rename {src => tests}/engine/pipes/Storage.test.ts (95%) rename {src => tests}/engine/plugins/PluginInstaller.test.ts (90%) rename {src => tests}/engine/plugins/PluginManager.test.ts (94%) rename {src => tests}/game/plugins/pause.test.ts (92%) diff --git a/src/engine/Composer.test.ts b/tests/engine/Composer.test.ts similarity index 99% rename from src/engine/Composer.test.ts rename to tests/engine/Composer.test.ts index c087af8..b0b067c 100644 --- a/src/engine/Composer.test.ts +++ b/tests/engine/Composer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { Composer } from './Composer'; +import { Composer } from '../../src/engine/Composer'; describe('Composer', () => { describe('instance methods', () => { diff --git a/src/engine/Engine.test.ts b/tests/engine/Engine.test.ts similarity index 95% rename from src/engine/Engine.test.ts rename to tests/engine/Engine.test.ts index bd32f17..b692050 100644 --- a/src/engine/Engine.test.ts +++ b/tests/engine/Engine.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { GameEngine } from './Engine'; -import { GameFrame, Pipe } from './State'; +import { GameEngine } from '../../src/engine/Engine'; +import { GameFrame, Pipe } from '../../src/engine/State'; describe('GameEngine', () => { it('should initialize with given state', () => { diff --git a/src/engine/Lens.test.ts b/tests/engine/Lens.test.ts similarity index 98% rename from src/engine/Lens.test.ts rename to tests/engine/Lens.test.ts index 8e1055a..e94ced5 100644 --- a/src/engine/Lens.test.ts +++ b/tests/engine/Lens.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { lensFromPath, normalizePath } from './Lens'; +import { lensFromPath, normalizePath } from '../../src/engine/Lens'; describe('Lens', () => { describe('normalizePath', () => { diff --git a/src/engine/pipes/Perf.test.ts b/tests/engine/pipes/Perf.test.ts similarity index 95% rename from src/engine/pipes/Perf.test.ts rename to tests/engine/pipes/Perf.test.ts index 89da029..f14edc9 100644 --- a/src/engine/pipes/Perf.test.ts +++ b/tests/engine/pipes/Perf.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Composer } from '../Composer'; -import { GameFrame, Pipe } from '../State'; -import { Events } from './Events'; -import { Perf, type PerfContext, type PluginPerfEntry } from './Perf'; -import { sdk } from '../sdk'; +import { Composer } from '../../../src/engine/Composer'; +import { GameFrame, Pipe } from '../../../src/engine/State'; +import { Events } from '../../../src/engine/pipes/Events'; +import { Perf, type PerfContext, type PluginPerfEntry } from '../../../src/engine/pipes/Perf'; +import { sdk } from '../../../src/engine/sdk'; const makeFrame = (overrides?: Partial): GameFrame => ({ state: {}, diff --git a/src/engine/pipes/Storage.test.ts b/tests/engine/pipes/Storage.test.ts similarity index 95% rename from src/engine/pipes/Storage.test.ts rename to tests/engine/pipes/Storage.test.ts index f909338..3b256d7 100644 --- a/src/engine/pipes/Storage.test.ts +++ b/tests/engine/pipes/Storage.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { Storage, STORAGE_NAMESPACE, StorageContext } from './Storage'; -import { GameFrame } from '../State'; -import { Composer } from '../Composer'; +import { Storage, STORAGE_NAMESPACE, StorageContext } from '../../../src/engine/pipes/Storage'; +import { GameFrame } from '../../../src/engine/State'; +import { Composer } from '../../../src/engine/Composer'; describe('Storage', () => { beforeEach(() => { diff --git a/src/engine/plugins/PluginInstaller.test.ts b/tests/engine/plugins/PluginInstaller.test.ts similarity index 90% rename from src/engine/plugins/PluginInstaller.test.ts rename to tests/engine/plugins/PluginInstaller.test.ts index 5651439..e28f851 100644 --- a/src/engine/plugins/PluginInstaller.test.ts +++ b/tests/engine/plugins/PluginInstaller.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { pluginInstallerPipe } from './PluginInstaller'; -import { pluginManagerPipe } from './PluginManager'; -import { Events } from '../pipes/Events'; -import { Composer } from '../Composer'; -import { GameFrame, Pipe } from '../State'; -import { sdk } from '../sdk'; -import type { Plugin, PluginClass } from './Plugins'; +import { pluginInstallerPipe } from '../../../src/engine/plugins/PluginInstaller'; +import { pluginManagerPipe } from '../../../src/engine/plugins/PluginManager'; +import { Events } from '../../../src/engine/pipes/Events'; +import { Composer } from '../../../src/engine/Composer'; +import { GameFrame, Pipe } from '../../../src/engine/State'; +import { sdk } from '../../../src/engine/sdk'; +import type { Plugin, PluginClass } from '../../../src/engine/plugins/Plugins'; const PLUGIN_NAMESPACE = 'core.plugin_installer'; diff --git a/src/engine/plugins/PluginManager.test.ts b/tests/engine/plugins/PluginManager.test.ts similarity index 94% rename from src/engine/plugins/PluginManager.test.ts rename to tests/engine/plugins/PluginManager.test.ts index 96aaac2..bb1e580 100644 --- a/src/engine/plugins/PluginManager.test.ts +++ b/tests/engine/plugins/PluginManager.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { Plugin, PluginClass, EnabledMap } from './Plugins'; -import { PluginManager, pluginManagerPipe } from './PluginManager'; -import { Events } from '../pipes/Events'; -import { Composer } from '../Composer'; -import { Pipe, GameFrame } from '../State'; +import { Plugin, PluginClass, EnabledMap } from '../../../src/engine/plugins/Plugins'; +import { PluginManager, pluginManagerPipe } from '../../../src/engine/plugins/PluginManager'; +import { Events } from '../../../src/engine/pipes/Events'; +import { Composer } from '../../../src/engine/Composer'; +import { Pipe, GameFrame } from '../../../src/engine/State'; const PLUGIN_NAMESPACE = 'core.plugin_manager'; diff --git a/src/game/plugins/pause.test.ts b/tests/game/plugins/pause.test.ts similarity index 92% rename from src/game/plugins/pause.test.ts rename to tests/game/plugins/pause.test.ts index 0eb6461..1d18dcf 100644 --- a/src/game/plugins/pause.test.ts +++ b/tests/game/plugins/pause.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { Composer } from '../../engine/Composer'; -import { Events } from '../../engine/pipes/Events'; -import { Scheduler, ScheduledEvent } from '../../engine/pipes/Scheduler'; +import { Composer } from '../../../src/engine/Composer'; +import { Events } from '../../../src/engine/pipes/Events'; +import { Scheduler, ScheduledEvent } from '../../../src/engine/pipes/Scheduler'; import { pluginManagerPipe, PluginManager, -} from '../../engine/plugins/PluginManager'; -import { GameFrame, Pipe } from '../../engine/State'; -import { PluginClass } from '../../engine/plugins/Plugins'; -import Messages from './messages'; -import Pause, { PauseState } from './pause'; +} from '../../../src/engine/plugins/PluginManager'; +import { GameFrame, Pipe } from '../../../src/engine/State'; +import { PluginClass } from '../../../src/engine/plugins/Plugins'; +import Messages from '../../../src/game/plugins/messages'; +import Pause, { PauseState } from '../../../src/game/plugins/pause'; const makeFrame = (): GameFrame => ({ state: {}, diff --git a/tsconfig.json b/tsconfig.json index 278e77c..7a75965 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], + "include": ["src", "tests"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/vite.config.ts b/vite.config.ts index 51bd8d1..ed3c440 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'html', 'lcov'], include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'], + exclude: ['src/**/*.d.ts', 'tests/**/*.test.ts'], }, }, }); From d2c8dea4094b1534b11bdfe2bd838b18bdc66268 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 21:47:52 +0100 Subject: [PATCH 74/90] extracted test utilities --- tests/engine/pipes/Perf.test.ts | 17 +--------------- tests/engine/plugins/PluginInstaller.test.ts | 17 +--------------- tests/engine/plugins/PluginManager.test.ts | 21 +++----------------- tests/game/plugins/pause.test.ts | 16 +-------------- tests/utils.ts | 19 ++++++++++++++++++ 5 files changed, 25 insertions(+), 65 deletions(-) create mode 100644 tests/utils.ts diff --git a/tests/engine/pipes/Perf.test.ts b/tests/engine/pipes/Perf.test.ts index f14edc9..0b0c909 100644 --- a/tests/engine/pipes/Perf.test.ts +++ b/tests/engine/pipes/Perf.test.ts @@ -4,22 +4,7 @@ import { GameFrame, Pipe } from '../../../src/engine/State'; import { Events } from '../../../src/engine/pipes/Events'; import { Perf, type PerfContext, type PluginPerfEntry } from '../../../src/engine/pipes/Perf'; import { sdk } from '../../../src/engine/sdk'; - -const makeFrame = (overrides?: Partial): GameFrame => ({ - state: {}, - context: { tick: 0, step: 16, time: 0 }, - ...overrides, -}); - -const tick = (frame: GameFrame): GameFrame => ({ - ...frame, - context: { - ...frame.context, - tick: frame.context.tick + 1, - step: 16, - time: frame.context.time + 16, - }, -}); +import { makeFrame, tick } from '../../utils'; const basePipe: Pipe = Composer.pipe(Events.pipe, Perf.pipe); diff --git a/tests/engine/plugins/PluginInstaller.test.ts b/tests/engine/plugins/PluginInstaller.test.ts index e28f851..cef8226 100644 --- a/tests/engine/plugins/PluginInstaller.test.ts +++ b/tests/engine/plugins/PluginInstaller.test.ts @@ -6,25 +6,10 @@ import { Composer } from '../../../src/engine/Composer'; import { GameFrame, Pipe } from '../../../src/engine/State'; import { sdk } from '../../../src/engine/sdk'; import type { Plugin, PluginClass } from '../../../src/engine/plugins/Plugins'; +import { makeFrame, tick } from '../../utils'; const PLUGIN_NAMESPACE = 'core.plugin_installer'; -const makeFrame = (overrides?: Partial): GameFrame => ({ - state: {}, - context: { tick: 0, step: 16, time: 0 }, - ...overrides, -}); - -const tick = (frame: GameFrame): GameFrame => ({ - ...frame, - context: { - ...frame.context, - tick: frame.context.tick + 1, - step: 16, - time: frame.context.time + 16, - }, -}); - const fullPipe: Pipe = Composer.pipe( Events.pipe, pluginManagerPipe, diff --git a/tests/engine/plugins/PluginManager.test.ts b/tests/engine/plugins/PluginManager.test.ts index bb1e580..683b412 100644 --- a/tests/engine/plugins/PluginManager.test.ts +++ b/tests/engine/plugins/PluginManager.test.ts @@ -4,25 +4,10 @@ import { PluginManager, pluginManagerPipe } from '../../../src/engine/plugins/Pl import { Events } from '../../../src/engine/pipes/Events'; import { Composer } from '../../../src/engine/Composer'; import { Pipe, GameFrame } from '../../../src/engine/State'; +import { makeFrame, tick } from '../../utils'; const PLUGIN_NAMESPACE = 'core.plugin_manager'; -const makeFrame = (overrides?: Partial): GameFrame => ({ - state: {}, - context: { tick: 0, step: 16, time: 0 }, - ...overrides, -}); - -const tick = (frame: GameFrame, n = 1): GameFrame => ({ - ...frame, - context: { - ...frame.context, - tick: frame.context.tick + n, - step: 16, - time: frame.context.time + 16 * n, - }, -}); - const gamePipe: Pipe = Composer.pipe(Events.pipe, pluginManagerPipe); const getLoadedIds = (frame: GameFrame): string[] => @@ -140,7 +125,7 @@ describe('Plugin System', () => { const frame3 = gamePipe(tick(frame2)); const frame4 = PluginManager.enable('test.plugin')(frame3); - gamePipe(tick(frame4, 2)); + gamePipe(tick(tick(frame4))); expect(activateCount).toBe(2); }); @@ -181,7 +166,7 @@ describe('Plugin System', () => { const frame2 = PluginManager.unregister('test.plugin')(frame1); const frame3 = gamePipe(tick(frame2)); - gamePipe(tick(frame3, 2)); + gamePipe(tick(tick(frame3))); expect(activateCount).toBe(1); }); }); diff --git a/tests/game/plugins/pause.test.ts b/tests/game/plugins/pause.test.ts index 1d18dcf..64c74b1 100644 --- a/tests/game/plugins/pause.test.ts +++ b/tests/game/plugins/pause.test.ts @@ -10,21 +10,7 @@ import { GameFrame, Pipe } from '../../../src/engine/State'; import { PluginClass } from '../../../src/engine/plugins/Plugins'; import Messages from '../../../src/game/plugins/messages'; import Pause, { PauseState } from '../../../src/game/plugins/pause'; - -const makeFrame = (): GameFrame => ({ - state: {}, - context: { tick: 0, step: 16, time: 0 }, -}); - -const tick = (frame: GameFrame, dt = 16): GameFrame => ({ - ...frame, - context: { - ...frame.context, - tick: frame.context.tick + 1, - step: dt, - time: frame.context.time + dt, - }, -}); +import { makeFrame, tick } from '../../utils'; const gamePipe: Pipe = Composer.pipe( Events.pipe, diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..9bc8498 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,19 @@ +import type { GameFrame, GameContext, GameState } from '../src/engine/State'; + +export const makeFrame = (overrides?: { + state?: GameState; + context?: Partial; +}): GameFrame => ({ + state: overrides?.state ?? {}, + context: { tick: 0, step: 16, time: 0, ...overrides?.context }, +}); + +export const tick = (frame: GameFrame, step = 16): GameFrame => ({ + ...frame, + context: { + ...frame.context, + tick: frame.context.tick + 1, + step, + time: frame.context.time + step, + }, +}); From 0b9e62862fb3cd8ac4f9ce41e9784beab125e3a9 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 21:49:00 +0100 Subject: [PATCH 75/90] formatted files --- package.json | 2 +- tests/engine/pipes/Perf.test.ts | 6 +++++- tests/engine/pipes/Storage.test.ts | 6 +++++- tests/engine/plugins/PluginManager.test.ts | 11 +++++++++-- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1f153a0..7cff39e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "format": "prettier --write src/", + "format": "prettier --write src/ tests/", "lint": "eslint . --ext ts,tsx --max-warnings 0", "preview": "vite preview", "test": "vitest run", diff --git a/tests/engine/pipes/Perf.test.ts b/tests/engine/pipes/Perf.test.ts index 0b0c909..f6f27be 100644 --- a/tests/engine/pipes/Perf.test.ts +++ b/tests/engine/pipes/Perf.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Composer } from '../../../src/engine/Composer'; import { GameFrame, Pipe } from '../../../src/engine/State'; import { Events } from '../../../src/engine/pipes/Events'; -import { Perf, type PerfContext, type PluginPerfEntry } from '../../../src/engine/pipes/Perf'; +import { + Perf, + type PerfContext, + type PluginPerfEntry, +} from '../../../src/engine/pipes/Perf'; import { sdk } from '../../../src/engine/sdk'; import { makeFrame, tick } from '../../utils'; diff --git a/tests/engine/pipes/Storage.test.ts b/tests/engine/pipes/Storage.test.ts index 3b256d7..6ba7363 100644 --- a/tests/engine/pipes/Storage.test.ts +++ b/tests/engine/pipes/Storage.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { Storage, STORAGE_NAMESPACE, StorageContext } from '../../../src/engine/pipes/Storage'; +import { + Storage, + STORAGE_NAMESPACE, + StorageContext, +} from '../../../src/engine/pipes/Storage'; import { GameFrame } from '../../../src/engine/State'; import { Composer } from '../../../src/engine/Composer'; diff --git a/tests/engine/plugins/PluginManager.test.ts b/tests/engine/plugins/PluginManager.test.ts index 683b412..3e415fb 100644 --- a/tests/engine/plugins/PluginManager.test.ts +++ b/tests/engine/plugins/PluginManager.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { Plugin, PluginClass, EnabledMap } from '../../../src/engine/plugins/Plugins'; -import { PluginManager, pluginManagerPipe } from '../../../src/engine/plugins/PluginManager'; +import { + Plugin, + PluginClass, + EnabledMap, +} from '../../../src/engine/plugins/Plugins'; +import { + PluginManager, + pluginManagerPipe, +} from '../../../src/engine/plugins/PluginManager'; import { Events } from '../../../src/engine/pipes/Events'; import { Composer } from '../../../src/engine/Composer'; import { Pipe, GameFrame } from '../../../src/engine/State'; From 5c5ffcfe39c1129e0e12d0a5c9b660c2ec7c0975 Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 22:02:28 +0100 Subject: [PATCH 76/90] moved game engine to root --- src/app/App.tsx | 19 +++++----- src/game/GamePage.tsx | 61 ++++++++++++++------------------ src/game/GameShell.tsx | 25 +++++++++++++ src/game/plugins/fps.ts | 4 +-- src/game/plugins/pause.ts | 2 +- src/game/plugins/perf.ts | 4 +-- tests/game/plugins/pause.test.ts | 12 +++++-- 7 files changed, 77 insertions(+), 50 deletions(-) create mode 100644 src/game/GameShell.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index ce5230c..caf1c2d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,18 +1,21 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { HomePage } from '../home'; import { GamePage } from '../game'; +import { GameShell } from '../game/GameShell'; import '@awesome.me/webawesome/dist/styles/webawesome.css'; export const App = () => { return ( - - - } /> - } /> - - + + + + } /> + } /> + + + ); }; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 7f62203..0532ad0 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -1,12 +1,7 @@ -import { useMemo } from 'react'; +import { useEffect } from 'react'; import styled from 'styled-components'; -import { GameEngineProvider } from './GameProvider'; import { GameMessages } from './components/GameMessages'; -import { useSettingsPipe } from './pipes'; import { GameImages } from './components/GameImages'; -import { pluginInstallerPipe } from '../engine/plugins/PluginInstaller'; -import { pluginManagerPipe } from '../engine/plugins/PluginManager'; -import { registerPlugins } from './plugins'; import { GameMeter } from './components/GameMeter'; import { GameHypno } from './components/GameHypno'; import { GameSound } from './components/GameSound'; @@ -14,6 +9,8 @@ import { GameVibrator } from './components/GameVibrator'; import { GameInstructions } from './components/GameInstructions'; import { GameEmergencyStop } from './components/GameEmergencyStop'; import { GameSettings } from './components/GameSettings'; +import { useGameEngine } from './hooks/UseGameEngine'; +import Pause from './plugins/pause'; const StyledGamePage = styled.div` position: relative; @@ -76,36 +73,30 @@ const StyledBottomBar = styled.div` `; export const GamePage = () => { - const settingsPipe = useSettingsPipe(); - const pipes = useMemo( - () => [ - pluginManagerPipe, - pluginInstallerPipe, - registerPlugins, - settingsPipe, - ], - [settingsPipe] - ); + const { injectImpulse } = useGameEngine(); + + useEffect(() => { + injectImpulse(Pause.setPaused(false)); + return () => injectImpulse(Pause.setPaused(true)); + }, [injectImpulse]); return ( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ); }; diff --git a/src/game/GameShell.tsx b/src/game/GameShell.tsx new file mode 100644 index 0000000..5e03d62 --- /dev/null +++ b/src/game/GameShell.tsx @@ -0,0 +1,25 @@ +import { useMemo, ReactNode } from 'react'; +import { GameEngineProvider } from './GameProvider'; +import { useSettingsPipe } from './pipes'; +import { pluginInstallerPipe } from '../engine/plugins/PluginInstaller'; +import { pluginManagerPipe } from '../engine/plugins/PluginManager'; +import { registerPlugins } from './plugins'; + +type Props = { + children: ReactNode; +}; + +export const GameShell = ({ children }: Props) => { + const settingsPipe = useSettingsPipe(); + const pipes = useMemo( + () => [ + pluginManagerPipe, + pluginInstallerPipe, + registerPlugins, + settingsPipe, + ], + [settingsPipe] + ); + + return {children}; +}; diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts index 8d871a5..bfa7a8f 100644 --- a/src/game/plugins/fps.ts +++ b/src/game/plugins/fps.ts @@ -30,7 +30,7 @@ export default class Fps { style.id = STYLE_ID; style.textContent = ` [${ELEMENT_ATTR}="${PLUGIN_ID}"] { - position: absolute; + position: fixed; top: 8px; right: 8px; background: black; @@ -55,7 +55,7 @@ export default class Fps { const visible = get(Debug.paths.state.visible); el.style.display = visible ? '' : 'none'; - document.querySelector('.game-page')?.appendChild(el); + document.body.appendChild(el); set(fps.context, { el, fpsHistory: [], diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index e14112d..c07d3ad 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -65,7 +65,7 @@ export default class Pause { name: 'Pause', }, - activate: Composer.set(pause.state, { paused: false, prev: false }), + activate: Composer.set(pause.state, { paused: true, prev: true }), update: Composer.pipe( Composer.do(({ get, set, pipe }) => { diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts index a8af6e4..0c41b46 100644 --- a/src/game/plugins/perf.ts +++ b/src/game/plugins/perf.ts @@ -63,7 +63,7 @@ export default class PerfOverlay { style.id = STYLE_ID; style.textContent = ` [${ELEMENT_ATTR}="${PLUGIN_ID}"] { - position: absolute; + position: fixed; top: 42px; right: 8px; background: rgba(0, 0, 0, 0.8); @@ -91,7 +91,7 @@ export default class PerfOverlay { const visible = get(Debug.paths.state.visible); el.style.display = visible ? '' : 'none'; - document.querySelector('.game-page')?.appendChild(el); + document.body.appendChild(el); set(po.context, { el }); }), diff --git a/tests/game/plugins/pause.test.ts b/tests/game/plugins/pause.test.ts index 64c74b1..3efffee 100644 --- a/tests/game/plugins/pause.test.ts +++ b/tests/game/plugins/pause.test.ts @@ -63,6 +63,14 @@ function getDealerScheduled(frame: GameFrame): ScheduledEvent[] { return getScheduled(frame).filter(s => s.id?.startsWith(DEALER_ID)); } +function unpauseAndWait(frame: GameFrame): GameFrame { + frame = withImpulse(Pause.setPaused(false))(tick(frame)); + for (let i = 0; i < 300; i++) { + frame = gamePipe(tick(frame, 100)); + } + return frame; +} + describe('Pause + Scheduler resume', () => { beforeEach(() => { localStorage.clear(); @@ -93,7 +101,7 @@ describe('Pause + Scheduler resume', () => { }); it('should hold dealer events when paused', () => { - let frame = bootstrap(); + let frame = unpauseAndWait(bootstrap()); frame = withImpulse( Scheduler.schedule({ @@ -123,7 +131,7 @@ describe('Pause + Scheduler resume', () => { }); it('should release dealer events after resume countdown', () => { - let frame = bootstrap(); + let frame = unpauseAndWait(bootstrap()); frame = withImpulse( Scheduler.schedule({ From d2eeb95ceacd1020c22a565cb75ef689a3ad7a4b Mon Sep 17 00:00:00 2001 From: clragon Date: Sat, 14 Feb 2026 23:57:34 +0100 Subject: [PATCH 77/90] added pause menu --- src/engine/pipes/Events.ts | 2 +- src/game/GamePage.tsx | 6 +- src/game/components/GamePauseMenu.tsx | 126 ++++++++++++++++++++++++++ src/game/components/GameResume.tsx | 50 ++++++++++ src/game/components/GameSettings.tsx | 126 -------------------------- src/game/components/index.ts | 3 +- src/game/plugins/pause.ts | 61 ++++++------- src/utils/dialogNesting.ts | 15 +++ src/utils/index.ts | 1 + tests/game/plugins/pause.test.ts | 23 ++--- 10 files changed, 238 insertions(+), 175 deletions(-) create mode 100644 src/game/components/GamePauseMenu.tsx create mode 100644 src/game/components/GameResume.tsx delete mode 100644 src/game/components/GameSettings.tsx create mode 100644 src/utils/dialogNesting.ts diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index f952ee4..ddccc8d 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -45,7 +45,7 @@ export class Events { } static dispatch(event: GameEvent): Pipe { - return pendingLens.over((pending = []) => [...pending, event]); + return pendingLens.over(pending => [...(Array.isArray(pending) ? pending : []), event]); } static handle( diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 0532ad0..a864d7c 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -8,7 +8,8 @@ import { GameSound } from './components/GameSound'; import { GameVibrator } from './components/GameVibrator'; import { GameInstructions } from './components/GameInstructions'; import { GameEmergencyStop } from './components/GameEmergencyStop'; -import { GameSettings } from './components/GameSettings'; +import { GamePauseMenu } from './components/GamePauseMenu'; +import { GameResume } from './components/GameResume'; import { useGameEngine } from './hooks/UseGameEngine'; import Pause from './plugins/pause'; @@ -92,9 +93,10 @@ export const GamePage = () => { - + + diff --git a/src/game/components/GamePauseMenu.tsx b/src/game/components/GamePauseMenu.tsx new file mode 100644 index 0000000..84f091e --- /dev/null +++ b/src/game/components/GamePauseMenu.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import { WaButton, WaDialog, WaIcon } from '@awesome.me/webawesome/dist/react'; +import { nestedDialogProps, useFullscreen } from '../../utils'; +import { useGameState } from '../hooks'; +import { useDispatchEvent } from '../hooks/UseDispatchEvent'; +import Pause from '../plugins/pause'; + +const StyledTrigger = styled.div` + display: flex; + height: fit-content; + align-items: center; + justify-content: center; + border-radius: 0 var(--border-radius) 0 0; + background: var(--overlay-background); + color: var(--overlay-color); + padding: var(--wa-space-2xs); +`; + +const StyledDialog = styled(WaDialog)` + &::part(dialog) { + background-color: transparent; + box-shadow: none; + } + + &::part(header) { + justify-content: center; + } + + &::part(title) { + text-align: center; + } + + &::part(close-button) { + display: none; + } + + &::part(body) { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--wa-space-m); + max-width: 300px; + padding: var(--wa-space-l) 0; + margin: 0 auto; + } +`; + +const StyledMenuButton = styled(WaButton)` + &::part(base) { + padding: var(--wa-space-l) var(--wa-space-2xl); + background: var(--overlay-background); + border-radius: var(--wa-form-control-border-radius); + font-size: 1.25rem; + } + + &::part(base):hover { + background: var(--wa-color-brand); + } +`; + +export const GamePauseMenu = () => { + const { paused, countdown } = useGameState(Pause.paths.state) ?? {}; + const [fullscreen, setFullscreen] = useFullscreen(); + const { inject } = useDispatchEvent(); + const navigate = useNavigate(); + const dialogRef = useRef(null); + + const visible = !!paused && countdown == null; + + useEffect(() => { + if (dialogRef.current) dialogRef.current.open = visible; + }, [visible]); + + const onResume = useCallback(() => { + inject(Pause.setPaused(false)); + }, [inject]); + + const onEndGame = useCallback(() => { + navigate('/'); + }, [navigate]); + + const onSettings = useCallback(() => { + navigate('/'); + }, [navigate]); + + const onPause = useCallback(() => { + inject(Pause.setPaused(true)); + }, [inject]); + + return ( + <> + + + + + + { + if (countdown != null) return; + inject(Pause.setPaused(false)); + })} + > + + + Resume + + setFullscreen(fs => !fs)}> + + {fullscreen ? 'Exit Fullscreen' : 'Fullscreen'} + + + + Settings + + + + End Game + + + + ); +}; diff --git a/src/game/components/GameResume.tsx b/src/game/components/GameResume.tsx new file mode 100644 index 0000000..e79fa59 --- /dev/null +++ b/src/game/components/GameResume.tsx @@ -0,0 +1,50 @@ +import styled from 'styled-components'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useGameState } from '../hooks'; +import Pause from '../plugins/pause'; + +const StyledOverlay = styled(motion.div)` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 10; +`; + +const StyledNumber = styled(motion.div)` + font-size: clamp(3rem, 15vw, 8rem); + font-weight: bold; + color: var(--overlay-color); +`; + +const display = (countdown: number) => + countdown === 3 ? 'Ready?' : `${countdown}`; + +export const GameResume = () => { + const { countdown } = useGameState(Pause.paths.state) ?? {}; + + return ( + + {countdown != null && ( + + + {display(countdown)} + + + )} + + ); +}; diff --git a/src/game/components/GameSettings.tsx b/src/game/components/GameSettings.tsx deleted file mode 100644 index 6ca69c8..0000000 --- a/src/game/components/GameSettings.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import styled from 'styled-components'; -import { memo, useCallback, useRef, useState } from 'react'; -import { - BoardSettings, - ClimaxSettings, - DurationSettings, - EventSettings, - HypnoSettings, - ImageSettings, - PaceSettings, - PlayerSettings, - VibratorSettings, -} from '../../settings'; -import { useFullscreen } from '../../utils'; -import { - WaButton, - WaDialog, - WaDivider, - WaIcon, -} from '@awesome.me/webawesome/dist/react'; -import { useGameState } from '../hooks'; -import { useDispatchEvent } from '../hooks/UseDispatchEvent'; -import Phase, { GamePhase } from '../plugins/phase'; -import Pause from '../plugins/pause'; - -const StyledGameSettings = styled.div` - display: flex; - height: fit-content; - - align-items: center; - justify-content: center; - - border-radius: 0 var(--border-radius) 0 0; - background: var(--overlay-background); - color: var(--overlay-color); - - padding: var(--wa-space-2xs); -`; - -const StyledGameSettingsDialog = styled.div` - overflow: auto; - max-width: 920px; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), 1fr)); -`; - -const GameSettingsDialogContent = memo(() => ( - - - - - - - - - - - -)); - -export const GameSettings = () => { - const [open, setOpen] = useState(false); - const { current: phase } = useGameState(Phase.paths.state) ?? {}; - const [fullscreen, setFullscreen] = useFullscreen(); - const { inject } = useDispatchEvent(); - const wasActiveRef = useRef(false); - - const onOpen = useCallback( - (opening: boolean) => { - if (opening) { - wasActiveRef.current = phase === GamePhase.active; - if (phase === GamePhase.active) { - inject(Pause.setPaused(true)); - } - } else { - if (wasActiveRef.current) { - inject(Pause.setPaused(false)); - } - } - setOpen(opening); - }, - [inject, phase] - ); - - return ( - - onOpen(true)}> - - - - setFullscreen(fullscreen => !fullscreen)} - > - - - { - if ( - e.target === e.currentTarget && - (e.currentTarget as HTMLElement)?.querySelector('wa-dialog[open]') - ) - e.preventDefault(); - }} - onWaAfterHide={e => { - if (e.target !== e.currentTarget) return; - onOpen(false); - }} - label={'Game Settings'} - style={{ - '--width': '920px', - }} - > - {open && } - - - ); -}; diff --git a/src/game/components/index.ts b/src/game/components/index.ts index 9f17d6b..0c28243 100644 --- a/src/game/components/index.ts +++ b/src/game/components/index.ts @@ -5,6 +5,7 @@ export * from './GameInstructions'; export * from './GameIntensity'; export * from './GameMessages'; export * from './GameMeter'; -export * from './GameSettings'; +export * from './GameResume'; +export * from './GamePauseMenu'; export * from './GameSound'; export * from './GameVibrator'; diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index c07d3ad..4ae4a9d 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -16,37 +16,35 @@ const PLUGIN_ID = 'core.pause'; export type PauseState = { paused: boolean; prev: boolean; + countdown: number | null; }; -const pause = pluginPaths(PLUGIN_ID); +const paths = pluginPaths(PLUGIN_ID); const eventType = Events.getKeys(PLUGIN_ID, 'on', 'off'); +type SetPayload = { paused: boolean }; type CountdownPayload = { remaining: number }; -const resume = Sequence.for(PLUGIN_ID, 'resume'); +const seq = Sequence.for(PLUGIN_ID, 'set'); export default class Pause { static setPaused(val: boolean): Pipe { - return Composer.when( - val, - Composer.pipe(resume.cancel(), Composer.set(pause.state.paused, true)), - resume.start() - ); + return seq.start({ paused: val }); } static get togglePause(): Pipe { - return Composer.bind(pause.state, state => Pause.setPaused(!state?.paused)); + return Composer.bind(paths.state, state => Pause.setPaused(!state?.paused)); } static whenPaused(pipe: Pipe): Pipe { - return Composer.bind(pause.state, state => + return Composer.bind(paths.state, state => Composer.when(!!state?.paused, pipe) ); } static whenPlaying(pipe: Pipe): Pipe { - return Composer.bind(pause.state, state => + return Composer.bind(paths.state, state => Composer.when(!state?.paused, pipe) ); } @@ -65,40 +63,41 @@ export default class Pause { name: 'Pause', }, - activate: Composer.set(pause.state, { paused: true, prev: true }), + activate: Composer.set(paths.state, { paused: true, prev: true, countdown: null }), update: Composer.pipe( Composer.do(({ get, set, pipe }) => { - const { paused, prev } = get(pause.state); + const { paused, prev } = get(paths.state); if (paused === prev) return; - set(pause.state.prev, paused); + set(paths.state.prev, paused); pipe(Events.dispatch({ type: paused ? eventType.on : eventType.off })); }), - resume.on(() => - Composer.pipe( - resume.message({ - title: 'Get ready to continue.', - description: '3...', - }), - resume.after(1000, 'countdown', { remaining: 2 }) + seq.on(event => + Composer.when( + event.payload.paused, + Composer.pipe( + seq.cancel(), + Composer.set(paths.state.paused, true), + Composer.set(paths.state.countdown, null) + ), + Composer.pipe( + Composer.set(paths.state.countdown, 3), + seq.after(1000, 'countdown', { remaining: 2 }) + ) ) ), - resume.on('countdown', event => + seq.on('countdown', event => Composer.when( event.payload.remaining <= 0, Composer.pipe( - resume.message({ - title: 'Continue.', - description: undefined, - duration: 1500, - }), - Composer.set(pause.state.paused, false) + Composer.set(paths.state.countdown, null), + Composer.set(paths.state.paused, false) ), Composer.pipe( - resume.message({ description: `${event.payload.remaining}...` }), - resume.after(1000, 'countdown', { + Composer.set(paths.state.countdown, event.payload.remaining), + seq.after(1000, 'countdown', { remaining: event.payload.remaining - 1, }) ) @@ -106,10 +105,10 @@ export default class Pause { ) ), - deactivate: Composer.set(pause.state, undefined), + deactivate: Composer.set(paths.state, undefined), }; static get paths() { - return pause; + return paths; } } diff --git a/src/utils/dialogNesting.ts b/src/utils/dialogNesting.ts new file mode 100644 index 0000000..ca2afc5 --- /dev/null +++ b/src/utils/dialogNesting.ts @@ -0,0 +1,15 @@ +export function nestedDialogProps(onClose: () => void) { + return { + onWaHide: (e: Event) => { + if ( + e.target === e.currentTarget && + (e.currentTarget as HTMLElement)?.querySelector('wa-dialog[open]') + ) + e.preventDefault(); + }, + onWaAfterHide: (e: Event) => { + if (e.target !== e.currentTarget) return; + onClose(); + }, + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index c0c1952..9d16b50 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './animation'; +export * from './dialogNesting'; export * from './fullscreen'; export * from './images'; export * from './intensity'; diff --git a/tests/game/plugins/pause.test.ts b/tests/game/plugins/pause.test.ts index 3efffee..681ca97 100644 --- a/tests/game/plugins/pause.test.ts +++ b/tests/game/plugins/pause.test.ts @@ -8,7 +8,6 @@ import { } from '../../../src/engine/plugins/PluginManager'; import { GameFrame, Pipe } from '../../../src/engine/State'; import { PluginClass } from '../../../src/engine/plugins/Plugins'; -import Messages from '../../../src/game/plugins/messages'; import Pause, { PauseState } from '../../../src/game/plugins/pause'; import { makeFrame, tick } from '../../utils'; @@ -40,7 +39,6 @@ function bootstrap(): GameFrame { }; let frame = gamePipe(makeFrame()); - frame = PluginManager.register(makePluginClass(Messages.plugin))(frame); frame = PluginManager.register(makePluginClass(Pause.plugin))(frame); frame = PluginManager.register(makePluginClass(dealerPlugin))(frame); frame = gamePipe(tick(frame)); @@ -55,10 +53,6 @@ function getPauseState(frame: GameFrame): PauseState | undefined { return (frame.state as any)?.core?.pause; } -function getMessages(frame: GameFrame): any[] { - return (frame.state as any)?.core?.messages?.messages ?? []; -} - function getDealerScheduled(frame: GameFrame): ScheduledEvent[] { return getScheduled(frame).filter(s => s.id?.startsWith(DEALER_ID)); } @@ -164,7 +158,7 @@ describe('Pause + Scheduler resume', () => { expect(dealerEvent?.duration).toBeLessThan(durationBeforeResume!); }); - it('should show countdown messages during resume', () => { + it('should count down through pause state during resume', () => { let frame = bootstrap(); frame = withImpulse(Pause.setPaused(true))(tick(frame)); @@ -174,20 +168,21 @@ describe('Pause + Scheduler resume', () => { frame = withImpulse(Pause.setPaused(false))(tick(frame)); - const seenDescriptions: string[] = []; + const seenCountdowns: number[] = []; for (let i = 0; i < 300; i++) { frame = gamePipe(tick(frame, 100)); - const msg = getMessages(frame).find(m => m.id === 'resume'); - if (msg?.description && !seenDescriptions.includes(msg.description)) { - seenDescriptions.push(msg.description); + const countdown = getPauseState(frame)?.countdown; + if (countdown != null && !seenCountdowns.includes(countdown)) { + seenCountdowns.push(countdown); } } - expect(seenDescriptions).toContain('3...'); - expect(seenDescriptions).toContain('2...'); - expect(seenDescriptions).toContain('1...'); + expect(seenCountdowns).toContain(3); + expect(seenCountdowns).toContain(2); + expect(seenCountdowns).toContain(1); expect(getPauseState(frame)?.paused).toBe(false); + expect(getPauseState(frame)?.countdown).toBeNull(); }); it('should cancel resume countdown when re-paused', () => { From 2f87cb9a3027626ac464aa6b24aeb1ff80f0bfd3 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 15 Feb 2026 01:25:22 +0100 Subject: [PATCH 78/90] fixed sdk registration --- src/engine/pipes/Perf.ts | 8 ++++++++ src/engine/plugins/PluginInstaller.ts | 18 +++++++++++++----- src/engine/plugins/PluginManager.ts | 8 ++++++++ src/engine/sdk.ts | 8 ++------ src/game/Sequence.ts | 9 +++++++++ src/game/plugins/pause.ts | 21 ++++++++++++++------- 6 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index cc80b3f..cbe8a38 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -5,6 +5,12 @@ import { pluginPaths, PluginId } from '../plugins/Plugins'; import { typedPath } from '../Lens'; import { sdk } from '../sdk'; +declare module '../sdk' { + interface SDK { + Perf: typeof Perf; + } +} + export type PluginPerfEntry = { last: number; avg: number; @@ -174,3 +180,5 @@ export class Perf { return perf; } } + +sdk.Perf = Perf; diff --git a/src/engine/plugins/PluginInstaller.ts b/src/engine/plugins/PluginInstaller.ts index 6177007..cd7dca9 100644 --- a/src/engine/plugins/PluginInstaller.ts +++ b/src/engine/plugins/PluginInstaller.ts @@ -1,7 +1,6 @@ import { Composer } from '../Composer'; import { Pipe, GameFrame } from '../State'; import { Storage } from '../pipes/Storage'; -import { sdk } from '../sdk'; import { PluginManager } from './PluginManager'; import { pluginPaths, type PluginId, type PluginClass } from './Plugins'; @@ -15,6 +14,7 @@ type PluginLoad = { type InstallerState = { installed: PluginId[]; + failed: PluginId[]; }; type InstallerContext = { @@ -29,8 +29,6 @@ const storageKey = { }; async function load(code: string): Promise { - (globalThis as any).sdk = sdk; - const blob = new Blob([code], { type: 'text/javascript' }); const url = URL.createObjectURL(blob); @@ -58,14 +56,16 @@ const importPipe: Pipe = Storage.bind( Storage.bind(storageKey.code(id), code => Composer.do(({ get, over }) => { const installed = get(ins.state.installed) ?? []; + const failed = get(ins.state.failed) ?? []; const pending = get(ins.context.pending); - if (installed.includes(id) || pending?.has(id)) return; + if (installed.includes(id) || failed.includes(id) || pending?.has(id)) return; if (!code) { console.error( `[PluginInstaller] plugin "${id}" has no code in storage` ); + over(ins.state.failed, (ids = []) => [...(Array.isArray(ids) ? ids : []), id]); return; } @@ -96,17 +96,18 @@ const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { if (!pending?.size) return; const resolved: PluginClass[] = []; + const failed: PluginId[] = []; const remaining = new Map(); for (const [id, entry] of pending) { if (entry.result) { resolved.push(entry.result); } else if (entry.error) { - // TODO: provide state for failed plugins console.error( `[PluginInstaller] failed to load plugin "${id}":`, entry.error ); + failed.push(id); } else { remaining.set(id, entry); } @@ -120,6 +121,13 @@ const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { ]); } + if (failed.length > 0) { + over(ins.state.failed, (ids = []) => [ + ...(Array.isArray(ids) ? ids : []), + ...failed, + ]); + } + if (remaining.size !== pending.size) { set(ins.context.pending, remaining); } diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index a3cf31e..0003708 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -270,6 +270,14 @@ const finalizePipe: Pipe = Composer.pipe( }) ); +declare module '../sdk' { + interface SDK { + PluginManager: typeof PluginManager; + } +} + +sdk.PluginManager = PluginManager; + export const pluginManagerPipe: Pipe = Composer.pipe( apiPipe, enableDisablePipe, diff --git a/src/engine/sdk.ts b/src/engine/sdk.ts index 1fafeae..e11db92 100644 --- a/src/engine/sdk.ts +++ b/src/engine/sdk.ts @@ -2,9 +2,7 @@ import { Composer } from './Composer'; import { Events } from './pipes/Events'; import { Scheduler } from './pipes/Scheduler'; import { Storage } from './pipes/Storage'; -import { PluginManager } from './plugins/PluginManager'; import { pluginPaths } from './plugins/Plugins'; -import { Perf } from './pipes/Perf'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface PluginSDK {} @@ -15,8 +13,6 @@ export interface SDK extends PluginSDK { Events: typeof Events; Scheduler: typeof Scheduler; Storage: typeof Storage; - PluginManager: typeof PluginManager; - Perf: typeof Perf; pluginPaths: typeof pluginPaths; } @@ -26,7 +22,7 @@ export const sdk: SDK = { Events, Scheduler, Storage, - PluginManager, - Perf, pluginPaths, } as SDK; + +(globalThis as any).sdk = sdk; diff --git a/src/game/Sequence.ts b/src/game/Sequence.ts index d115f23..e4a68d8 100644 --- a/src/game/Sequence.ts +++ b/src/game/Sequence.ts @@ -1,5 +1,6 @@ import { Pipe } from '../engine/State'; import { Events, GameEvent, Scheduler } from '../engine/pipes'; +import { sdk } from '../engine/sdk'; import Messages from './plugins/messages'; type MessageInput = Omit[0], 'id'>; @@ -65,3 +66,11 @@ export class Sequence { }; } } + +declare module '../engine/sdk' { + interface SDK { + Sequence: typeof Sequence; + } +} + +sdk.Sequence = Sequence; diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index 4ae4a9d..5882a94 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -4,12 +4,7 @@ import { Composer } from '../../engine/Composer'; import { Events } from '../../engine/pipes/Events'; import { pluginPaths } from '../../engine/plugins/Plugins'; import { Sequence } from '../Sequence'; - -declare module '../../engine/sdk' { - interface PluginSDK { - Pause: typeof Pause; - } -} +import { sdk } from '../../engine/sdk'; const PLUGIN_ID = 'core.pause'; @@ -63,7 +58,11 @@ export default class Pause { name: 'Pause', }, - activate: Composer.set(paths.state, { paused: true, prev: true, countdown: null }), + activate: Composer.set(paths.state, { + paused: true, + prev: true, + countdown: null, + }), update: Composer.pipe( Composer.do(({ get, set, pipe }) => { @@ -112,3 +111,11 @@ export default class Pause { return paths; } } + +declare module '../../engine/sdk' { + interface PluginSDK { + Pause: typeof Pause; + } +} + +sdk.Pause = Pause; From b10a0866b1a180ec92a3a63d5b7283cac26029a4 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 15 Feb 2026 01:27:15 +0100 Subject: [PATCH 79/90] formatted files --- src/engine/pipes/Events.ts | 5 ++++- src/engine/plugins/PluginInstaller.ts | 12 ++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index ddccc8d..328d91d 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -45,7 +45,10 @@ export class Events { } static dispatch(event: GameEvent): Pipe { - return pendingLens.over(pending => [...(Array.isArray(pending) ? pending : []), event]); + return pendingLens.over(pending => [ + ...(Array.isArray(pending) ? pending : []), + event, + ]); } static handle( diff --git a/src/engine/plugins/PluginInstaller.ts b/src/engine/plugins/PluginInstaller.ts index cd7dca9..41ab41e 100644 --- a/src/engine/plugins/PluginInstaller.ts +++ b/src/engine/plugins/PluginInstaller.ts @@ -59,13 +59,21 @@ const importPipe: Pipe = Storage.bind( const failed = get(ins.state.failed) ?? []; const pending = get(ins.context.pending); - if (installed.includes(id) || failed.includes(id) || pending?.has(id)) return; + if ( + installed.includes(id) || + failed.includes(id) || + pending?.has(id) + ) + return; if (!code) { console.error( `[PluginInstaller] plugin "${id}" has no code in storage` ); - over(ins.state.failed, (ids = []) => [...(Array.isArray(ids) ? ids : []), id]); + over(ins.state.failed, (ids = []) => [ + ...(Array.isArray(ids) ? ids : []), + id, + ]); return; } From 43499a05b090d06ebbdd475de527e3f64fb83eb5 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 15 Feb 2026 16:26:46 +0100 Subject: [PATCH 80/90] added default to lens.over --- src/engine/Composer.ts | 12 ++++++------ src/engine/Lens.ts | 13 ++++++++----- src/engine/pipes/Events.ts | 5 +---- src/engine/pipes/Scheduler.ts | 14 +++++++------- src/engine/plugins/PluginManager.ts | 2 -- src/game/plugins/dealer.ts | 6 ++---- src/game/plugins/dice/cleanUp.ts | 2 +- src/game/plugins/dice/climax.ts | 4 ++-- src/game/plugins/dice/doublePace.ts | 4 ++-- src/game/plugins/dice/edge.ts | 2 +- src/game/plugins/dice/halfPace.ts | 4 ++-- src/game/plugins/dice/pause.ts | 4 ++-- src/game/plugins/dice/randomPace.ts | 2 +- src/game/plugins/dice/risingPace.ts | 4 ++-- src/game/plugins/emergencyStop.ts | 2 +- src/game/plugins/hypno.ts | 8 +++----- src/game/plugins/messages.ts | 6 +++--- src/game/plugins/randomImages.ts | 4 ++-- src/game/plugins/warmup.ts | 3 +-- tests/engine/Lens.test.ts | 11 ++++++++++- 20 files changed, 57 insertions(+), 55 deletions(-) diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts index a20281e..020079c 100644 --- a/src/engine/Composer.ts +++ b/src/engine/Composer.ts @@ -29,8 +29,8 @@ function _set(obj: T, path: Path
, value: A): T { return lensFromPath(path).set(value)(obj); } -function _over(obj: T, path: Path, fn: (a: A) => A): T { - return lensFromPath(path).over(fn)(obj); +function _over(obj: T, path: Path, fn: (a: A) => A, fallback?: A): T { + return lensFromPath(path).over(fn, fallback)(obj); } function _bind(obj: T, path: Path, fn: Transformer<[A], T>): T { @@ -153,16 +153,16 @@ export class Composer { /** * Updates the value at the specified path with the mapping function. */ - over(path: Path, fn: (a: A) => A): this { - this.obj = _over(this.obj, path, fn); + over(path: Path, fn: (a: A) => A, fallback?: A): this { + this.obj = _over(this.obj, path, fn, fallback); return this; } /** * Shorthand for building a composer that updates a path. */ - static over(path: Path, fn: (a: A) => A) { - return (obj: T): T => _over(obj, path, fn); + static over(path: Path, fn: (a: A) => A, fallback?: A) { + return (obj: T): T => _over(obj, path, fn, fallback); } /** diff --git a/src/engine/Lens.ts b/src/engine/Lens.ts index 428e8fc..d5c1f7e 100644 --- a/src/engine/Lens.ts +++ b/src/engine/Lens.ts @@ -1,7 +1,7 @@ export type Lens = { get: (source: S) => A; set: (value: A) => (source: S) => S; - over: (fn: (a: A) => A) => (source: S) => S; + over: (fn: (a: A) => A, fallback?: A) => (source: S) => S; }; export type StringPath = (string | number | symbol)[] | string; @@ -47,8 +47,11 @@ export function lensFromPath(path: Path): Lens { get: (source: S) => source as unknown as A, // eslint-disable-next-line @typescript-eslint/no-unused-vars set: (value: A) => (_source: S) => value as unknown as S, - over: (fn: (a: A) => A) => (source: S) => - fn(source as unknown as A) as unknown as S, + over: + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (fn: (a: A) => A, _fallback = {} as A) => + (source: S) => + fn(source as unknown as A) as unknown as S, }; } @@ -77,9 +80,9 @@ export function lensFromPath(path: Path): Lens { }, over: - (fn: (a: A) => A) => + (fn: (a: A) => A, fallback = {} as A) => (source: S): S => { - const current = lens.get(source) ?? ({} as A); + const current = lens.get(source) ?? fallback; return lens.set(fn(current))(source); }, }; diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 328d91d..21b1019 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -45,10 +45,7 @@ export class Events { } static dispatch(event: GameEvent): Pipe { - return pendingLens.over(pending => [ - ...(Array.isArray(pending) ? pending : []), - event, - ]); + return pendingLens.over(pending => [...pending, event], []); } static handle( diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index dfbb3dc..3666277 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -96,32 +96,32 @@ export class Scheduler { ), Events.handle(eventType.schedule, event => - Composer.over(scheduler.state.scheduled, (list = []) => [ + Composer.over(scheduler.state.scheduled, list => [ ...list.filter(e => e.id !== event.payload.id), event.payload, ]) ), Events.handle(eventType.cancel, event => - Composer.over(scheduler.state.scheduled, (list = []) => + Composer.over(scheduler.state.scheduled, list => list.filter(s => s.id !== event.payload) ) ), Events.handle(eventType.hold, event => - Composer.over(scheduler.state.scheduled, (list = []) => + Composer.over(scheduler.state.scheduled, list => list.map(s => (s.id === event.payload ? { ...s, held: true } : s)) ) ), Events.handle(eventType.release, event => - Composer.over(scheduler.state.scheduled, (list = []) => + Composer.over(scheduler.state.scheduled, list => list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) ) ), Events.handle(eventType.holdByPrefix, event => - Composer.over(scheduler.state.scheduled, (list = []) => + Composer.over(scheduler.state.scheduled, list => list.map(s => s.id?.startsWith(event.payload) ? { ...s, held: true } : s ) @@ -129,7 +129,7 @@ export class Scheduler { ), Events.handle(eventType.releaseByPrefix, event => - Composer.over(scheduler.state.scheduled, (list = []) => + Composer.over(scheduler.state.scheduled, list => list.map(s => s.id?.startsWith(event.payload) ? { ...s, held: false } : s ) @@ -137,7 +137,7 @@ export class Scheduler { ), Events.handle(eventType.cancelByPrefix, event => - Composer.over(scheduler.state.scheduled, (list = []) => + Composer.over(scheduler.state.scheduled, list => list.filter(s => !s.id?.startsWith(event.payload)) ) ) diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index 0003708..6f93506 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -182,8 +182,6 @@ const reconcilePipe: Pipe = Composer.pipe( ) ); -// TODO: lifecycle should include error handling -// TODO: OTEL spans for performance monitoring const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const toUnload = get(pm.context.toUnload) ?? []; const toLoad = get(pm.context.toLoad) ?? []; diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index c4d049e..1900948 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -71,7 +71,7 @@ export default class Dealer { Composer.bind(settings, s => Composer.pipe( ...outcomes.flatMap(o => - o.activate && s?.events.includes(o.id) ? [o.activate] : [] + o.activate && s.events.includes(o.id) ? [o.activate] : [] ) ) ), @@ -85,11 +85,9 @@ export default class Dealer { GamePhase.active, Composer.do(({ get, pipe }) => { const state = get(dice.state); - if (!state || state.busy) return; + if (state.busy) return; const s = get(settings); - if (!s) return; - const frame = get(); const eligible = outcomes.filter(o => { diff --git a/src/game/plugins/dice/cleanUp.ts b/src/game/plugins/dice/cleanUp.ts index 84dc55d..50f19a1 100644 --- a/src/game/plugins/dice/cleanUp.ts +++ b/src/game/plugins/dice/cleanUp.ts @@ -15,7 +15,7 @@ const seq = Sequence.for(PLUGIN_ID, 'cleanUp'); export const cleanUpOutcome: DiceOutcome = { id: DiceEvent.cleanUp, check: frame => - (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 75, + Composer.get(intensityState)(frame).intensity * 100 >= 75, update: Composer.pipe( seq.on(() => Composer.bind(settings, s => diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts index 93eeded..6f8bc8f 100644 --- a/src/game/plugins/dice/climax.ts +++ b/src/game/plugins/dice/climax.ts @@ -21,11 +21,11 @@ const seq = Sequence.for(PLUGIN_ID, 'climax'); export const climaxOutcome: DiceOutcome = { id: DiceEvent.climax, check: frame => { - const i = (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100; + const i = Composer.get(intensityState)(frame).intensity * 100; const s = Composer.get(settings)(frame); return ( i >= 100 && - (!s?.events.includes(DiceEvent.edge) || !!Composer.get(edged)(frame)) + (!s.events.includes(DiceEvent.edge) || !!Composer.get(edged)(frame)) ); }, update: Composer.pipe( diff --git a/src/game/plugins/dice/doublePace.ts b/src/game/plugins/dice/doublePace.ts index ebc3a42..19f0610 100644 --- a/src/game/plugins/dice/doublePace.ts +++ b/src/game/plugins/dice/doublePace.ts @@ -18,12 +18,12 @@ const seq = Sequence.for(PLUGIN_ID, 'doublePace'); export const doublePaceOutcome: DiceOutcome = { id: DiceEvent.doublePace, check: frame => - (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 20, + Composer.get(intensityState)(frame).intensity * 100 >= 20, update: Composer.pipe( seq.on(() => Composer.bind(paceState, pace => Composer.bind(settings, s => { - const newPace = Math.min(round((pace?.pace ?? 1) * 2), s.maxPace); + const newPace = Math.min(round(pace.pace * 2), s.maxPace); return Composer.pipe( Pace.setPace(newPace), seq.message({ title: 'Double pace!', description: '3...' }), diff --git a/src/game/plugins/dice/edge.ts b/src/game/plugins/dice/edge.ts index e010bd1..332554c 100644 --- a/src/game/plugins/dice/edge.ts +++ b/src/game/plugins/dice/edge.ts @@ -18,7 +18,7 @@ const seq = Sequence.for(PLUGIN_ID, 'edge'); export const edgeOutcome: DiceOutcome = { id: DiceEvent.edge, check: frame => { - const i = (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100; + const i = Composer.get(intensityState)(frame).intensity * 100; return i >= 90 && !Composer.get(edged)(frame); }, update: Composer.pipe( diff --git a/src/game/plugins/dice/halfPace.ts b/src/game/plugins/dice/halfPace.ts index 8289349..1865b9d 100644 --- a/src/game/plugins/dice/halfPace.ts +++ b/src/game/plugins/dice/halfPace.ts @@ -21,7 +21,7 @@ const seq = Sequence.for(PLUGIN_ID, 'halfPace'); export const halfPaceOutcome: DiceOutcome = { id: DiceEvent.halfPace, check: frame => { - const i = (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100; + const i = Composer.get(intensityState)(frame).intensity * 100; return i >= 10 && i <= 50; }, update: Composer.pipe( @@ -29,7 +29,7 @@ export const halfPaceOutcome: DiceOutcome = { Composer.do(({ get, pipe }) => { const pace = get(paceState); const s = get(settings); - const newPace = Math.max(round((pace?.pace ?? 1) / 2), s.minPace); + const newPace = Math.max(round(pace.pace / 2), s.minPace); pipe( Rand.next(v => { const duration = Math.ceil(v * 20000) + 12000; diff --git a/src/game/plugins/dice/pause.ts b/src/game/plugins/dice/pause.ts index e021a30..0a22dff 100644 --- a/src/game/plugins/dice/pause.ts +++ b/src/game/plugins/dice/pause.ts @@ -9,11 +9,11 @@ const seq = Sequence.for(PLUGIN_ID, 'pause'); export const pauseOutcome: DiceOutcome = { id: DiceEvent.pause, check: frame => - (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 15, + Composer.get(intensityState)(frame).intensity * 100 >= 15, update: Composer.pipe( seq.on(() => Composer.bind(intensityState, ist => { - const i = (ist?.intensity ?? 0) * 100; + const i = ist.intensity * 100; return Composer.pipe( seq.message({ title: 'Stop stroking!' }), Phase.setPhase(GamePhase.break), diff --git a/src/game/plugins/dice/randomPace.ts b/src/game/plugins/dice/randomPace.ts index 2e71743..0e2d132 100644 --- a/src/game/plugins/dice/randomPace.ts +++ b/src/game/plugins/dice/randomPace.ts @@ -17,7 +17,7 @@ const seq = Sequence.for(PLUGIN_ID, 'randomPace'); export const doRandomPace = (): Pipe => Composer.do(({ get, pipe }) => { - const i = get(intensityState)?.intensity ?? 0; + const i = get(intensityState).intensity; const s = get(settings); const { min, max } = intensityToPaceRange( i * 100, diff --git a/src/game/plugins/dice/risingPace.ts b/src/game/plugins/dice/risingPace.ts index 7b1b6b6..850dc07 100644 --- a/src/game/plugins/dice/risingPace.ts +++ b/src/game/plugins/dice/risingPace.ts @@ -23,12 +23,12 @@ const seq = Sequence.for(PLUGIN_ID, 'risingPace'); export const risingPaceOutcome: DiceOutcome = { id: DiceEvent.risingPace, check: frame => - (Composer.get(intensityState)(frame)?.intensity ?? 0) * 100 >= 30, + Composer.get(intensityState)(frame).intensity * 100 >= 30, update: Composer.pipe( seq.on(() => Composer.bind(intensityState, ist => Composer.bind(settings, s => { - const i = (ist?.intensity ?? 0) * 100; + const i = ist.intensity * 100; const acceleration = Math.round(100 / Math.min(i, 35)); const { max } = intensityToPaceRange(i, s.steepness, s.timeshift, { min: s.minPace, diff --git a/src/game/plugins/emergencyStop.ts b/src/game/plugins/emergencyStop.ts index 8e48591..73cbb70 100644 --- a/src/game/plugins/emergencyStop.ts +++ b/src/game/plugins/emergencyStop.ts @@ -35,7 +35,7 @@ export default class EmergencyStop { seq.on(() => Composer.bind(intensityState, ist => Composer.bind(settings, s => { - const i = (ist?.intensity ?? 0) * 100; + const i = ist.intensity * 100; const timeToCalmDown = Math.ceil((i * 500 + 10000) / 1000); return Composer.pipe( Phase.setPhase(GamePhase.break), diff --git a/src/game/plugins/hypno.ts b/src/game/plugins/hypno.ts index c35fa2c..e135583 100644 --- a/src/game/plugins/hypno.ts +++ b/src/game/plugins/hypno.ts @@ -37,20 +37,18 @@ export default class Hypno { update: Pause.whenPlaying( Composer.do(({ get, set, pipe }) => { - const phase = get(phaseState)?.current; + const phase = get(phaseState).current; if (phase !== GamePhase.active) return; const s = get(settings); - if (!s || s.hypno === GameHypnoType.off) return; + if (s.hypno === GameHypnoType.off) return; - const i = (get(intensityState)?.intensity ?? 0) * 100; + const i = get(intensityState).intensity * 100; const delay = 3000 - i * 29; if (delay <= 0) return; const delta = get(gameContext.step); const state = get(hypno.state); - if (!state) return; - const elapsed = state.timer + delta; if (elapsed < delay) { set(hypno.state.timer, elapsed); diff --git a/src/game/plugins/messages.ts b/src/game/plugins/messages.ts index 0352769..c514800 100644 --- a/src/game/plugins/messages.ts +++ b/src/game/plugins/messages.ts @@ -55,7 +55,7 @@ export default class Messages { update: Composer.pipe( Events.handle(eventType.sendMessage, event => Composer.pipe( - Composer.over(paths.state, ({ messages = [] }) => { + Composer.over(paths.state, ({ messages }) => { const patch = event.payload; const index = messages.findIndex(m => m.id === patch.id); const existing = messages[index]; @@ -72,7 +72,7 @@ export default class Messages { }), Composer.do(({ get, pipe }) => { - const { messages = [] } = get(paths.state); + const { messages } = get(paths.state); const messageId = event.payload.id; const updated = messages.find(m => m.id === messageId); const scheduleId = Scheduler.getKey( @@ -99,7 +99,7 @@ export default class Messages { ), Events.handle(eventType.expireMessage, event => - Composer.over(paths.state, ({ messages = [] }) => ({ + Composer.over(paths.state, ({ messages }) => ({ messages: messages.filter(m => m.id !== event.payload), })) ) diff --git a/src/game/plugins/randomImages.ts b/src/game/plugins/randomImages.ts index 36b0f62..0785fb9 100644 --- a/src/game/plugins/randomImages.ts +++ b/src/game/plugins/randomImages.ts @@ -55,8 +55,8 @@ export default class RandomImages { const imgs = get(images); if (!imgs || imgs.length === 0) return; - const { seenImages = [] } = get(imageState) ?? {}; - const { intensity = 0 } = get(intensityState) ?? {}; + const { seenImages = [] } = get(imageState); + const { intensity = 0 } = get(intensityState); const imagesWithDistance = imgs.map(image => { const seenIndex = seenImages.indexOf(image); diff --git a/src/game/plugins/warmup.ts b/src/game/plugins/warmup.ts index b35d9b3..f6e9ddc 100644 --- a/src/game/plugins/warmup.ts +++ b/src/game/plugins/warmup.ts @@ -44,7 +44,6 @@ export default class Warmup { GamePhase.warmup, Composer.do(({ get, set, pipe }) => { const s = get(settings); - if (!s) return; if (s.warmupDuration === 0) { pipe(Phase.setPhase(GamePhase.active)); @@ -52,7 +51,7 @@ export default class Warmup { } const state = get(warmup.state); - if (state?.initialized) return; + if (state.initialized) return; set(warmup.state.initialized, true); pipe( diff --git a/tests/engine/Lens.test.ts b/tests/engine/Lens.test.ts index e94ced5..497f382 100644 --- a/tests/engine/Lens.test.ts +++ b/tests/engine/Lens.test.ts @@ -140,7 +140,7 @@ describe('Lens', () => { expect(obj.a.b).toBe(5); }); - it('should provide empty object when value is undefined', () => { + it('should default to {} when value is undefined', () => { const lens = lensFromPath< { a?: { b?: { value: number } } }, { value: number } @@ -152,6 +152,15 @@ describe('Lens', () => { expect(result.a?.b?.value).toBe(1); }); + it('should accept a custom fallback for missing values', () => { + const lens = lensFromPath<{ a?: { b?: number } }, number>('a.b'); + const obj = {}; + + const result = lens.over(x => x + 1, 0)(obj); + + expect(result.a?.b).toBe(1); + }); + it('should not mutate the original object', () => { const lens = lensFromPath<{ a: { b: number } }, number>('a.b'); const obj = { a: { b: 1 } }; From 094920a9556e2cce8a7450aafd31a127367d8719 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 15 Feb 2026 17:53:53 +0100 Subject: [PATCH 81/90] fixed automatic unpausing --- src/game/GamePage.tsx | 6 ++-- src/game/plugins/pause.ts | 60 +++++++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index a864d7c..faf01d4 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -74,12 +74,14 @@ const StyledBottomBar = styled.div` `; export const GamePage = () => { - const { injectImpulse } = useGameEngine(); + const { state, injectImpulse } = useGameEngine(); + const ready = !!state; useEffect(() => { + if (!ready) return; injectImpulse(Pause.setPaused(false)); return () => injectImpulse(Pause.setPaused(true)); - }, [injectImpulse]); + }, [ready, injectImpulse]); return ( diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index 5882a94..a5ef5a3 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -12,6 +12,7 @@ export type PauseState = { paused: boolean; prev: boolean; countdown: number | null; + gen: number; }; const paths = pluginPaths(PLUGIN_ID); @@ -19,7 +20,7 @@ const paths = pluginPaths(PLUGIN_ID); const eventType = Events.getKeys(PLUGIN_ID, 'on', 'off'); type SetPayload = { paused: boolean }; -type CountdownPayload = { remaining: number }; +type CountdownPayload = { remaining: number; gen: number }; const seq = Sequence.for(PLUGIN_ID, 'set'); @@ -62,6 +63,7 @@ export default class Pause { paused: true, prev: true, countdown: null, + gen: 0, }), update: Composer.pipe( @@ -73,32 +75,42 @@ export default class Pause { }), seq.on(event => - Composer.when( - event.payload.paused, - Composer.pipe( - seq.cancel(), - Composer.set(paths.state.paused, true), - Composer.set(paths.state.countdown, null) - ), - Composer.pipe( - Composer.set(paths.state.countdown, 3), - seq.after(1000, 'countdown', { remaining: 2 }) - ) - ) + Composer.bind(paths.state.gen, (gen = 0) => { + const next = gen + 1; + return Composer.when( + event.payload.paused, + Composer.pipe( + Composer.set(paths.state.gen, next), + Composer.set(paths.state.paused, true), + Composer.set(paths.state.countdown, null) + ), + Composer.pipe( + Composer.set(paths.state.gen, next), + Composer.set(paths.state.countdown, 3), + seq.after(1000, 'countdown', { remaining: 2, gen: next }) + ) + ); + }) ), seq.on('countdown', event => - Composer.when( - event.payload.remaining <= 0, - Composer.pipe( - Composer.set(paths.state.countdown, null), - Composer.set(paths.state.paused, false) - ), - Composer.pipe( - Composer.set(paths.state.countdown, event.payload.remaining), - seq.after(1000, 'countdown', { - remaining: event.payload.remaining - 1, - }) + Composer.bind(paths.state.gen, gen => + Composer.when( + event.payload.gen === gen, + Composer.when( + event.payload.remaining <= 0, + Composer.pipe( + Composer.set(paths.state.countdown, null), + Composer.set(paths.state.paused, false) + ), + Composer.pipe( + Composer.set(paths.state.countdown, event.payload.remaining), + seq.after(1000, 'countdown', { + remaining: event.payload.remaining - 1, + gen, + }) + ) + ) ) ) ) From f8dc41f1085278256682cae7beb457102724a962 Mon Sep 17 00:00:00 2001 From: clragon Date: Sun, 15 Feb 2026 21:45:07 +0100 Subject: [PATCH 82/90] fixed fps display --- src/game/plugins/fps.ts | 63 ++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts index bfa7a8f..9ffb1bd 100644 --- a/src/game/plugins/fps.ts +++ b/src/game/plugins/fps.ts @@ -1,4 +1,4 @@ -import { Composer, GameContext, pluginPaths, typedPath } from '../../engine'; +import { Composer, pluginPaths } from '../../engine'; import type { Plugin } from '../../engine/plugins/Plugins'; import Debug from './debug'; @@ -9,13 +9,26 @@ const HISTORY_SIZE = 30; type FpsContext = { el: HTMLElement; - fpsHistory: number[]; - tpsHistory: number[]; - lastWallTime: number; + tickTimestamps: number[]; }; const fps = pluginPaths(PLUGIN_ID); -const gameContext = typedPath(['context']); + +let rafId = 0; +let lastFrameTime: number | null = null; +let fpsHistory: number[] = []; + +function rafLoop() { + const now = performance.now(); + if (lastFrameTime !== null) { + const delta = now - lastFrameTime; + if (delta > 0) { + fpsHistory = [...fpsHistory, 1000 / delta].slice(-HISTORY_SIZE); + } + } + lastFrameTime = now; + rafId = requestAnimationFrame(rafLoop); +} export default class Fps { static plugin: Plugin = { @@ -56,12 +69,12 @@ export default class Fps { el.style.display = visible ? '' : 'none'; document.body.appendChild(el); - set(fps.context, { - el, - fpsHistory: [], - tpsHistory: [], - lastWallTime: performance.now(), - }); + + lastFrameTime = null; + fpsHistory = []; + rafId = requestAnimationFrame(rafLoop); + + set(fps.context, { el, tickTimestamps: [] }); }), update: Composer.do(({ get, set }) => { @@ -72,31 +85,29 @@ export default class Fps { if (ctx.el) ctx.el.style.display = visible ? '' : 'none'; if (!visible) return; - const now = performance.now(); - const wallDelta = now - ctx.lastWallTime; - - const currentFps = wallDelta > 0 ? 1000 / wallDelta : 0; - const fpsHistory = [...ctx.fpsHistory, currentFps].slice(-HISTORY_SIZE); const avgFps = fpsHistory.length > 0 ? fpsHistory.reduce((sum, v) => sum + v, 0) / fpsHistory.length - : currentFps; + : 0; - const step = get(gameContext.step); - const currentTps = step > 0 ? 1000 / step : 0; - const tpsHistory = [...ctx.tpsHistory, currentTps].slice(-HISTORY_SIZE); - const avgTps = - tpsHistory.length > 0 - ? tpsHistory.reduce((sum, v) => sum + v, 0) / tpsHistory.length - : currentTps; + const now = performance.now(); + const cutoff = now - 1000; + const tickTimestamps = [...ctx.tickTimestamps, now].filter( + t => t >= cutoff + ); if (ctx.el) - ctx.el.textContent = `${Math.round(avgFps)} FPS / ${Math.round(avgTps)} TPS`; + ctx.el.textContent = `${Math.round(avgFps)} FPS / ${tickTimestamps.length} TPS`; - set(fps.context, { ...ctx, fpsHistory, tpsHistory, lastWallTime: now }); + set(fps.context, { ...ctx, tickTimestamps }); }), deactivate: Composer.do(({ get, set }) => { + cancelAnimationFrame(rafId); + rafId = 0; + lastFrameTime = null; + fpsHistory = []; + const el = get(fps.context)?.el; if (el) el.remove(); set(fps.context, undefined); From 5ffb3259a7ddfa7575674aeed9330ad4a6e0e55e Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 16 Feb 2026 18:52:20 +0100 Subject: [PATCH 83/90] added game over screen --- src/app/App.tsx | 2 + src/end/ClimaxResult.tsx | 44 +++++++ src/end/EndPage.tsx | 151 ++++++++++++++++++++++++ src/end/GameTimeline.tsx | 159 ++++++++++++++++++++++++++ src/end/index.ts | 1 + src/game/GamePage.tsx | 12 ++ src/game/components/GamePauseMenu.tsx | 2 +- src/game/plugins/clock.ts | 64 +++++++++++ src/game/plugins/dealer.ts | 50 ++++---- src/game/plugins/dice/climax.ts | 18 ++- src/game/plugins/dice/types.ts | 3 + src/game/plugins/index.ts | 2 + src/game/plugins/pace.ts | 34 +++++- src/utils/index.ts | 1 + src/utils/time.ts | 6 + 15 files changed, 514 insertions(+), 35 deletions(-) create mode 100644 src/end/ClimaxResult.tsx create mode 100644 src/end/EndPage.tsx create mode 100644 src/end/GameTimeline.tsx create mode 100644 src/end/index.ts create mode 100644 src/game/plugins/clock.ts create mode 100644 src/utils/time.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index caf1c2d..2382221 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { HomePage } from '../home'; import { GamePage } from '../game'; +import { EndPage } from '../end'; import { GameShell } from '../game/GameShell'; import '@awesome.me/webawesome/dist/styles/webawesome.css'; @@ -14,6 +15,7 @@ export const App = () => { } /> } /> + } /> diff --git a/src/end/ClimaxResult.tsx b/src/end/ClimaxResult.tsx new file mode 100644 index 0000000..c008958 --- /dev/null +++ b/src/end/ClimaxResult.tsx @@ -0,0 +1,44 @@ +import styled from 'styled-components'; +import { useGameState } from '../game/hooks'; +import { climax, type ClimaxResultType } from '../game/plugins/dice/climax'; + +type OutcomeDisplay = { label: string; description: string }; + +const outcomes: Record, OutcomeDisplay> = { + climax: { label: 'Climax', description: 'You came' }, + denied: { label: 'Denied', description: 'Better luck next time' }, + ruined: { label: 'Ruined', description: 'How unfortunate' }, +}; + +const earlyEnd: OutcomeDisplay = { label: 'Ended early', description: '' }; + +const StyledResult = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +`; + +const StyledLabel = styled.span` + font-size: 1.1rem; + font-weight: bold; + color: var(--text-color); +`; + +const StyledDescription = styled.span` + font-size: 0.85rem; + opacity: 0.6; +`; + +export const ClimaxResult = () => { + const result = useGameState(climax.result) as ClimaxResultType; + const display = (result && outcomes[result]) || earlyEnd; + + return ( + + {display.label} + {display.description && ( + {display.description} + )} + + ); +}; diff --git a/src/end/EndPage.tsx b/src/end/EndPage.tsx new file mode 100644 index 0000000..aea66c4 --- /dev/null +++ b/src/end/EndPage.tsx @@ -0,0 +1,151 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled, { keyframes } from 'styled-components'; +import { WaButton } from '@awesome.me/webawesome/dist/react'; +import { ContentSection } from '../common'; +import { useGameEngine } from '../game/hooks/UseGameEngine'; +import { useGameState } from '../game/hooks'; +import Clock from '../game/plugins/clock'; +import Rand from '../game/plugins/rand'; +import { formatTime } from '../utils'; +import { ClimaxResult } from './ClimaxResult'; +import { GameTimeline } from './GameTimeline'; + +const fadeInUp = keyframes` + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const StyledEndPage = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--wa-space-l); + + min-height: 100%; + width: 100%; + padding: 16px; + + & > * { + max-width: 480px; + width: 100%; + animation: ${fadeInUp} 500ms cubic-bezier(0.23, 1, 0.32, 1) both; + } + + & > :nth-child(1) { animation-delay: 100ms; } + & > :nth-child(2) { animation-delay: 250ms; } + & > :nth-child(3) { animation-delay: 400ms; } +`; + +const StyledTitle = styled.h1` + text-align: center; + font-size: clamp(2rem, 8vw, 3.5rem); + font-weight: bold; + line-height: 1; +`; + +const StyledCard = styled(ContentSection)` + display: flex; + flex-direction: column; + gap: var(--wa-space-m); + padding: 20px; +`; + +const StyledStatsRow = styled.div` + display: flex; + justify-content: space-around; +`; + +const StyledStat = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +`; + +const StyledStatValue = styled.span` + font-size: 1.25rem; + font-weight: bold; + color: var(--text-color); +`; + +const StyledStatLabel = styled.span` + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.4; +`; + +const StyledDivider = styled.hr` + border: none; + border-top: 1px solid currentColor; + width: 100%; + margin: 0; +`; + +const StyledActions = styled.div` + display: flex; + justify-content: center; +`; + +const StyledFinishButton = styled(WaButton)` + &::part(base) { + height: fit-content; + padding: 12px 40px; + } + + &::part(label) { + font-size: 1.25rem; + text-transform: uppercase; + } +`; + +export const EndPage = () => { + const { state } = useGameEngine(); + const navigate = useNavigate(); + const clockState = useGameState(Clock.paths.state) as { elapsed?: number } | undefined; + const randState = useGameState(Rand.paths.state) as { seed?: string } | undefined; + + useEffect(() => { + if (!state) navigate('/'); + }, [state, navigate]); + + if (!state) return null; + + const displayTime = typeof clockState?.elapsed === 'number' ? clockState.elapsed : 0; + const seed = randState?.seed ?? ''; + + return ( + + Game Over + + + + + + {formatTime(displayTime)} + Play time + + + {seed} + Seed + + + + + + + navigate('/')}> + Finish + + + + ); +}; diff --git a/src/end/GameTimeline.tsx b/src/end/GameTimeline.tsx new file mode 100644 index 0000000..ada844b --- /dev/null +++ b/src/end/GameTimeline.tsx @@ -0,0 +1,159 @@ +import { useMemo } from 'react'; +import { + ResponsiveContainer, + ComposedChart, + Line, + XAxis, + YAxis, + ReferenceLine, + Tooltip, + TooltipProps, +} from 'recharts'; +import { useGameState } from '../game/hooks'; +import Pace, { type PaceEntry } from '../game/plugins/pace'; +import Dealer from '../game/plugins/dealer'; +import Clock from '../game/plugins/clock'; +import { DiceEventLabels, type DiceEvent } from '../types'; +import type { DiceLogEntry } from '../game/plugins/dice/types'; +import { formatTime } from '../utils'; + +type DataPoint = { + time: number; + pace: number; + event?: DiceEvent; +}; + +const GraphTooltip = ({ active, payload }: TooltipProps) => { + if (!active || !payload?.length) return null; + const point: DataPoint = payload[0].payload; + + return ( +
+
+ {formatTime(point.time)} +
+
{point.pace.toFixed(1)} b/s
+ {point.event && ( +
+ {DiceEventLabels[point.event]} +
+ )} +
+ ); +}; + +export const GameTimeline = () => { + const paceState = useGameState(Pace.paths.state) as + | { history?: PaceEntry[] } + | undefined; + const diceState = useGameState(Dealer.paths.state) as + | { log?: DiceLogEntry[] } + | undefined; + const clockState = useGameState(Clock.paths.state) as + | { elapsed?: number } + | undefined; + + const totalTime = + typeof clockState?.elapsed === 'number' ? clockState.elapsed : 0; + + const history = paceState?.history; + const log = diceState?.log; + + const data = useMemo(() => { + if (!history?.length) return []; + + const eventsByTime = new Map(); + if (log) { + for (const entry of log) { + eventsByTime.set(entry.time, entry.event); + } + } + + const points: DataPoint[] = history.map(e => ({ + time: e.time, + pace: e.pace, + event: eventsByTime.get(e.time), + })); + + if (log) { + for (const entry of log) { + if (!points.some(p => p.time === entry.time)) { + const pace = findPaceAt(history, entry.time); + points.push({ time: entry.time, pace, event: entry.event }); + } + } + } + + points.sort((a, b) => a.time - b.time); + + const last = points[points.length - 1]; + if (totalTime > 0 && last.time < totalTime) { + points.push({ time: totalTime, pace: last.pace }); + } + + return points; + }, [history, log, totalTime]); + + if (data.length === 0 && !log?.length) return null; + + return ( + + + + + } /> + {log?.map((entry, i) => ( + + ))} + + + + ); +}; + +function findPaceAt(history: PaceEntry[], time: number): number { + let pace = 0; + for (const entry of history) { + if (entry.time > time) break; + pace = entry.pace; + } + return pace; +} diff --git a/src/end/index.ts b/src/end/index.ts new file mode 100644 index 0000000..0847c68 --- /dev/null +++ b/src/end/index.ts @@ -0,0 +1 @@ +export * from './EndPage'; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index faf01d4..29080ac 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { GameMessages } from './components/GameMessages'; import { GameImages } from './components/GameImages'; @@ -11,7 +12,9 @@ import { GameEmergencyStop } from './components/GameEmergencyStop'; import { GamePauseMenu } from './components/GamePauseMenu'; import { GameResume } from './components/GameResume'; import { useGameEngine } from './hooks/UseGameEngine'; +import { useGameState } from './hooks'; import Pause from './plugins/pause'; +import { climax } from './plugins/dice/climax'; const StyledGamePage = styled.div` position: relative; @@ -74,15 +77,24 @@ const StyledBottomBar = styled.div` `; export const GamePage = () => { + // TODO: none of this logic should live inside this component. const { state, injectImpulse } = useGameEngine(); const ready = !!state; + const done = useGameState(climax.done); + const navigate = useNavigate(); useEffect(() => { + // Replace this with on Scene transition to play, unpause if (!ready) return; injectImpulse(Pause.setPaused(false)); return () => injectImpulse(Pause.setPaused(true)); }, [ready, injectImpulse]); + useEffect(() => { + // Replace this with Scene transition to end + if (done === true) navigate('/end'); + }, [done, navigate]); + return ( diff --git a/src/game/components/GamePauseMenu.tsx b/src/game/components/GamePauseMenu.tsx index 84f091e..7dd48ac 100644 --- a/src/game/components/GamePauseMenu.tsx +++ b/src/game/components/GamePauseMenu.tsx @@ -78,7 +78,7 @@ export const GamePauseMenu = () => { }, [inject]); const onEndGame = useCallback(() => { - navigate('/'); + navigate('/end'); }, [navigate]); const onSettings = useCallback(() => { diff --git a/src/game/plugins/clock.ts b/src/game/plugins/clock.ts new file mode 100644 index 0000000..fecb7ad --- /dev/null +++ b/src/game/plugins/clock.ts @@ -0,0 +1,64 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine/Composer'; +import { pluginPaths } from '../../engine/plugins/Plugins'; +import Pause from './pause'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Clock: typeof Clock; + } +} + +const PLUGIN_ID = 'core.clock'; + +export type ClockState = { + elapsed: number; +}; + +type ClockContext = { + lastWall: number | null; +}; + +const clock = pluginPaths(PLUGIN_ID); + +export default class Clock { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Clock', + }, + + activate: Composer.pipe( + Composer.set(clock.state, { elapsed: 0 }), + Composer.set(clock.context, { lastWall: null }) + ), + + update: Composer.pipe( + Pause.whenPlaying( + Composer.do(({ get, over, set }) => { + const now = performance.now(); + const ctx = get(clock.context); + if (ctx?.lastWall !== null && ctx?.lastWall !== undefined) { + const delta = now - ctx.lastWall; + over(clock.state, ({ elapsed = 0 }) => ({ + elapsed: elapsed + delta, + })); + } + set(clock.context, { lastWall: now }); + }) + ), + Pause.onPause(() => + Composer.set(clock.context, { lastWall: null }) + ) + ), + + deactivate: Composer.pipe( + Composer.set(clock.state, undefined), + Composer.set(clock.context, undefined) + ), + }; + + static get paths() { + return clock; + } +} diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index 1900948..4bad772 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -1,11 +1,13 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine/Composer'; +import { Pipe } from '../../engine/State'; import { Events } from '../../engine/pipes/Events'; import { Scheduler } from '../../engine/pipes/Scheduler'; import { Sequence } from '../Sequence'; import Phase, { GamePhase } from './phase'; import Pause from './pause'; import Rand from './rand'; +import Clock from './clock'; import { DiceEvent } from '../../types'; import { PLUGIN_ID, @@ -13,6 +15,7 @@ import { settings, OUTCOME_DONE, DiceOutcome, + DiceLogEntry, } from './dice/types'; import { edgeOutcome } from './dice/edge'; import { pauseOutcome } from './dice/pause'; @@ -54,9 +57,6 @@ const rollChances: Record = { [DiceEvent.risingPace]: 30, }; -const eventKeyForOutcome = (id: DiceEvent): string => - Events.getKey(PLUGIN_ID, id); - const roll = Sequence.for(PLUGIN_ID, 'roll'); export default class Dealer { @@ -67,14 +67,8 @@ export default class Dealer { }, activate: Composer.pipe( - Composer.set(dice.state, { busy: false }), - Composer.bind(settings, s => - Composer.pipe( - ...outcomes.flatMap(o => - o.activate && s.events.includes(o.id) ? [o.activate] : [] - ) - ) - ), + Composer.set(dice.state, { busy: false, log: [] }), + ...outcomes.flatMap(o => o.activate ? [o.activate] : []), roll.after(1000, 'check') ), @@ -99,25 +93,17 @@ export default class Dealer { const guaranteed = eligible.find(o => rollChances[o.id] === 1); if (guaranteed) { - pipe( - Composer.set(dice.state.busy, true), - Events.dispatch({ type: eventKeyForOutcome(guaranteed.id) }) - ); + pipe(roll.dispatch('trigger', guaranteed.id)); return; } pipe( - Rand.next(roll => { + Rand.next(value => { let cumulative = 0; for (const outcome of eligible) { cumulative += 1 / rollChances[outcome.id]; - if (roll < cumulative) { - return Composer.pipe( - Composer.set(dice.state.busy, true), - Events.dispatch({ - type: eventKeyForOutcome(outcome.id), - }) - ); + if (value < cumulative) { + return roll.dispatch('trigger', outcome.id); } } return Composer.pipe(); @@ -129,6 +115,20 @@ export default class Dealer { ) ), + roll.on('trigger', event => + Composer.do(({ get, set, over, pipe }) => { + set(dice.state.busy, true); + const elapsed = get(Clock.paths.state)?.elapsed ?? 0; + over(dice.state.log, (log: DiceLogEntry[]) => [ + ...log, + { time: elapsed, event: event.payload }, + ]); + pipe( + Events.dispatch({ type: Events.getKey(PLUGIN_ID, event.payload) }) + ); + }) + ), + ...outcomes.map(o => o.update), Events.handle(OUTCOME_DONE, () => Composer.set(dice.state.busy, false)), @@ -144,6 +144,10 @@ export default class Dealer { deactivate: Composer.set(dice.state, undefined), }; + static triggerOutcome(id: DiceEvent): Pipe { + return roll.dispatch('trigger', id); + } + static get paths() { return dice; } diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts index 6f8bc8f..21b27b0 100644 --- a/src/game/plugins/dice/climax.ts +++ b/src/game/plugins/dice/climax.ts @@ -1,4 +1,5 @@ import { Composer } from '../../../engine/Composer'; +import { typedPath } from '../../../engine/Lens'; import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; import Pace from '../pace'; @@ -14,6 +15,15 @@ import { } from './types'; import { edged } from './edge'; +export type ClimaxResultType = 'climax' | 'denied' | 'ruined' | null; + +type ClimaxState = { + result: ClimaxResultType; + done: boolean; +}; + +export const climax = typedPath(['state', PLUGIN_ID, 'climax']); + type ClimaxEndPayload = { countdown: number; denied?: boolean; ruin?: boolean }; const seq = Sequence.for(PLUGIN_ID, 'climax'); @@ -81,6 +91,7 @@ export const climaxOutcome: DiceOutcome = { Rand.next(roll => { if (roll * 100 > s.climaxChance) { return Composer.pipe( + Composer.set(climax.result, 'denied'), Phase.setPhase(GamePhase.break), seq.message({ title: '$HANDS OFF! Do not cum!', @@ -92,6 +103,7 @@ export const climaxOutcome: DiceOutcome = { return Rand.next(ruinRoll => { const ruin = ruinRoll * 100 <= s.ruinChance; return Composer.pipe( + Composer.set(climax.result, ruin ? 'ruined' : 'climax'), Phase.setPhase(ruin ? GamePhase.break : GamePhase.climax), seq.message({ title: ruin ? '$HANDS OFF! Ruin your orgasm!' : 'Cum!', @@ -161,10 +173,6 @@ export const climaxOutcome: DiceOutcome = { ) ), - seq.on('leave', () => - Composer.do(() => { - window.location.href = '/'; - }) - ) + seq.on('leave', () => Composer.set(climax.done, true)) ), }; diff --git a/src/game/plugins/dice/types.ts b/src/game/plugins/dice/types.ts index d1c4d4e..ac8ab40 100644 --- a/src/game/plugins/dice/types.ts +++ b/src/game/plugins/dice/types.ts @@ -9,8 +9,11 @@ import { DiceEvent } from '../../../types'; export const PLUGIN_ID = 'core.dice'; +export type DiceLogEntry = { time: number; event: DiceEvent }; + export type DiceState = { busy: boolean; + log: DiceLogEntry[]; }; export const dice = pluginPaths(PLUGIN_ID); diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index 4713881..d43ced9 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -17,6 +17,7 @@ import Dealer from './dealer'; import EmergencyStop from './emergencyStop'; import Hypno from './hypno'; import Messages from './messages'; +import Clock from './clock'; const plugins = [ Messages, @@ -24,6 +25,7 @@ const plugins = [ Phase, Pace, Intensity, + Clock, Stroke, Rand, Dealer, diff --git a/src/game/plugins/pace.ts b/src/game/plugins/pace.ts index 756aa82..e3a6289 100644 --- a/src/game/plugins/pace.ts +++ b/src/game/plugins/pace.ts @@ -3,6 +3,7 @@ import { Pipe } from '../../engine/State'; import { typedPath } from '../../engine/Lens'; import { Settings } from '../../settings'; import { Composer, pluginPaths } from '../../engine'; +import Clock, { ClockState } from './clock'; declare module '../../engine/sdk' { interface PluginSDK { @@ -12,13 +13,18 @@ declare module '../../engine/sdk' { const PLUGIN_ID = 'core.pace'; +export type PaceEntry = { time: number; pace: number }; + export type PaceState = { pace: number; prevMinPace: number; + prevPace: number; + history: PaceEntry[]; }; const pace = pluginPaths(PLUGIN_ID); const settings = typedPath(['context', 'settings']); +const clockState = typedPath(Clock.paths.state); export default class Pace { static setPace(val: number): Pipe { @@ -38,15 +44,31 @@ export default class Pace { }, activate: Composer.bind(settings, s => - Composer.set(pace.state, { pace: s.minPace, prevMinPace: s.minPace }) + Composer.set(pace.state, { + pace: s.minPace, + prevMinPace: s.minPace, + prevPace: s.minPace, + history: [{ time: 0, pace: s.minPace }], + }) ), - update: Composer.do(({ get, set }) => { - const { prevMinPace } = get(pace.state); + update: Composer.do(({ get, set, over }) => { + const state = get(pace.state); const { minPace } = get(settings); - if (minPace === prevMinPace) return; - set(pace.state.prevMinPace, minPace); - set(pace.state.pace, minPace); + + if (minPace !== state.prevMinPace) { + set(pace.state.prevMinPace, minPace); + set(pace.state.pace, minPace); + } + + if (state.pace !== state.prevPace) { + const { elapsed } = get(clockState) ?? { elapsed: 0 }; + set(pace.state.prevPace, state.pace); + over(pace.state.history, (h: PaceEntry[]) => [ + ...h, + { time: elapsed, pace: state.pace }, + ]); + } }), deactivate: Composer.set(pace.state, undefined), diff --git a/src/utils/index.ts b/src/utils/index.ts index 9d16b50..4009f4a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -13,4 +13,5 @@ export * from './sound'; export * from './state'; export * from './translate'; export * from './vibrator'; +export * from './time'; export * from './wait'; diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000..3cad6ce --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,6 @@ +export const formatTime = (ms: number) => { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; From 086b07ee9bc310890a465ff75827893d5a157472 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 16 Feb 2026 19:21:51 +0100 Subject: [PATCH 84/90] added scene plugin --- src/app/App.tsx | 2 + src/end/EndPage.tsx | 14 ++----- src/game/GamePage.tsx | 24 ----------- src/game/SceneBridge.tsx | 48 ++++++++++++++++++++++ src/game/plugins/dice/climax.ts | 4 +- src/game/plugins/index.ts | 2 + src/game/plugins/pause.ts | 4 ++ src/game/plugins/phase.ts | 4 +- src/game/plugins/scene.ts | 73 +++++++++++++++++++++++++++++++++ 9 files changed, 136 insertions(+), 39 deletions(-) create mode 100644 src/game/SceneBridge.tsx create mode 100644 src/game/plugins/scene.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 2382221..42a3104 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,6 +3,7 @@ import { HomePage } from '../home'; import { GamePage } from '../game'; import { EndPage } from '../end'; import { GameShell } from '../game/GameShell'; +import { SceneBridge } from '../game/SceneBridge'; import '@awesome.me/webawesome/dist/styles/webawesome.css'; @@ -12,6 +13,7 @@ export const App = () => { + } /> } /> diff --git a/src/end/EndPage.tsx b/src/end/EndPage.tsx index aea66c4..99b6cec 100644 --- a/src/end/EndPage.tsx +++ b/src/end/EndPage.tsx @@ -1,5 +1,3 @@ -import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; import styled, { keyframes } from 'styled-components'; import { WaButton } from '@awesome.me/webawesome/dist/react'; import { ContentSection } from '../common'; @@ -7,6 +5,7 @@ import { useGameEngine } from '../game/hooks/UseGameEngine'; import { useGameState } from '../game/hooks'; import Clock from '../game/plugins/clock'; import Rand from '../game/plugins/rand'; +import Scene from '../game/plugins/scene'; import { formatTime } from '../utils'; import { ClimaxResult } from './ClimaxResult'; import { GameTimeline } from './GameTimeline'; @@ -108,17 +107,10 @@ const StyledFinishButton = styled(WaButton)` `; export const EndPage = () => { - const { state } = useGameEngine(); - const navigate = useNavigate(); + const { injectImpulse } = useGameEngine(); const clockState = useGameState(Clock.paths.state) as { elapsed?: number } | undefined; const randState = useGameState(Rand.paths.state) as { seed?: string } | undefined; - useEffect(() => { - if (!state) navigate('/'); - }, [state, navigate]); - - if (!state) return null; - const displayTime = typeof clockState?.elapsed === 'number' ? clockState.elapsed : 0; const seed = randState?.seed ?? ''; @@ -142,7 +134,7 @@ export const EndPage = () => { - navigate('/')}> + injectImpulse(Scene.setScene('home'))}> Finish diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 29080ac..938d26a 100644 --- a/src/game/GamePage.tsx +++ b/src/game/GamePage.tsx @@ -1,5 +1,3 @@ -import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { GameMessages } from './components/GameMessages'; import { GameImages } from './components/GameImages'; @@ -11,10 +9,6 @@ import { GameInstructions } from './components/GameInstructions'; import { GameEmergencyStop } from './components/GameEmergencyStop'; import { GamePauseMenu } from './components/GamePauseMenu'; import { GameResume } from './components/GameResume'; -import { useGameEngine } from './hooks/UseGameEngine'; -import { useGameState } from './hooks'; -import Pause from './plugins/pause'; -import { climax } from './plugins/dice/climax'; const StyledGamePage = styled.div` position: relative; @@ -77,24 +71,6 @@ const StyledBottomBar = styled.div` `; export const GamePage = () => { - // TODO: none of this logic should live inside this component. - const { state, injectImpulse } = useGameEngine(); - const ready = !!state; - const done = useGameState(climax.done); - const navigate = useNavigate(); - - useEffect(() => { - // Replace this with on Scene transition to play, unpause - if (!ready) return; - injectImpulse(Pause.setPaused(false)); - return () => injectImpulse(Pause.setPaused(true)); - }, [ready, injectImpulse]); - - useEffect(() => { - // Replace this with Scene transition to end - if (done === true) navigate('/end'); - }, [done, navigate]); - return ( diff --git a/src/game/SceneBridge.tsx b/src/game/SceneBridge.tsx new file mode 100644 index 0000000..a5e1bd7 --- /dev/null +++ b/src/game/SceneBridge.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useGameEngine } from './hooks/UseGameEngine'; +import { useGameState } from './hooks'; +import Scene from './plugins/scene'; + +const routeToScene: Record = { + '/': 'home', + '/play': 'game', + '/end': 'end', +}; + +const sceneToRoute: Record = { + home: '/', + game: '/play', + end: '/end', +}; + +export const SceneBridge = () => { + const { injectImpulse } = useGameEngine(); + const location = useLocation(); + const navigate = useNavigate(); + const sceneState = useGameState(Scene.paths.state) as + | { current?: string } + | undefined; + + const lastRouteSceneRef = useRef(null); + + useEffect(() => { + const scene = routeToScene[location.pathname] ?? 'unknown'; + lastRouteSceneRef.current = scene; + injectImpulse(Scene.setScene(scene)); + }, [location.pathname, injectImpulse]); + + useEffect(() => { + const current = sceneState?.current; + if (!current || current === 'unknown') return; + if (current === lastRouteSceneRef.current) return; + + const route = sceneToRoute[current]; + if (!route) return; + + lastRouteSceneRef.current = current; + navigate(route); + }, [navigate, sceneState]); + + return null; +}; diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts index 21b27b0..93cf954 100644 --- a/src/game/plugins/dice/climax.ts +++ b/src/game/plugins/dice/climax.ts @@ -2,6 +2,7 @@ import { Composer } from '../../../engine/Composer'; import { typedPath } from '../../../engine/Lens'; import { Sequence } from '../../Sequence'; import Phase, { GamePhase } from '../phase'; +import Scene from '../scene'; import Pace from '../pace'; import Rand from '../rand'; import { DiceEvent } from '../../../types'; @@ -19,7 +20,6 @@ export type ClimaxResultType = 'climax' | 'denied' | 'ruined' | null; type ClimaxState = { result: ClimaxResultType; - done: boolean; }; export const climax = typedPath(['state', PLUGIN_ID, 'climax']); @@ -173,6 +173,6 @@ export const climaxOutcome: DiceOutcome = { ) ), - seq.on('leave', () => Composer.set(climax.done, true)) + seq.on('leave', () => Scene.setScene('end')) ), }; diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index d43ced9..6a15d30 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -1,6 +1,7 @@ import { PluginManager } from '../../engine/plugins/PluginManager'; import { Composer } from '../../engine/Composer'; import { Pipe } from '../../engine/State'; +import Scene from './scene'; import Debug from './debug'; import Fps from './fps'; import Intensity from './intensity'; @@ -20,6 +21,7 @@ import Messages from './messages'; import Clock from './clock'; const plugins = [ + Scene, Messages, Pause, Phase, diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index a5ef5a3..cbdf9e4 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -5,6 +5,7 @@ import { Events } from '../../engine/pipes/Events'; import { pluginPaths } from '../../engine/plugins/Plugins'; import { Sequence } from '../Sequence'; import { sdk } from '../../engine/sdk'; +import Scene from './scene'; const PLUGIN_ID = 'core.pause'; @@ -67,6 +68,9 @@ export default class Pause { }), update: Composer.pipe( + Scene.onEnter('game', () => Pause.setPaused(false)), + Scene.onLeave('game', () => Pause.setPaused(true)), + Composer.do(({ get, set, pipe }) => { const { paused, prev } = get(paths.state); if (paused === prev) return; diff --git a/src/game/plugins/phase.ts b/src/game/plugins/phase.ts index 75a013d..431c59c 100644 --- a/src/game/plugins/phase.ts +++ b/src/game/plugins/phase.ts @@ -27,8 +27,8 @@ export type PhaseState = { const phase = pluginPaths(PLUGIN_ID); const eventType = { - enter: (p: string) => Events.getKey(PLUGIN_ID, `enter.${p}`), - leave: (p: string) => Events.getKey(PLUGIN_ID, `leave.${p}`), + enter: (p: string) => Events.getKey(PLUGIN_ID, `enter/${p}`), + leave: (p: string) => Events.getKey(PLUGIN_ID, `leave/${p}`), }; export default class Phase { diff --git a/src/game/plugins/scene.ts b/src/game/plugins/scene.ts new file mode 100644 index 0000000..78b413a --- /dev/null +++ b/src/game/plugins/scene.ts @@ -0,0 +1,73 @@ +import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; +import { Pipe } from '../../engine/State'; +import { Events } from '../../engine/pipes/Events'; +import { Composer } from '../../engine'; +import { sdk } from '../../engine/sdk'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Scene: typeof Scene; + } +} + +const PLUGIN_ID = 'core.scene'; + +export type SceneState = { + current: string; + prev: string; +}; + +const scene = pluginPaths(PLUGIN_ID); + +const eventType = { + enter: (s: string) => Events.getKey(PLUGIN_ID, `enter/${s}`), + leave: (s: string) => Events.getKey(PLUGIN_ID, `leave/${s}`), +}; + +export default class Scene { + static setScene(s: string): Pipe { + return Composer.set(scene.state.current, s); + } + + static whenScene(s: string, pipe: Pipe): Pipe { + return Composer.bind(scene.state, state => + Composer.when(state?.current === s, pipe) + ); + } + + static onEnter(s: string, fn: () => Pipe): Pipe { + return Events.handle(eventType.enter(s), fn); + } + + static onLeave(s: string, fn: () => Pipe): Pipe { + return Events.handle(eventType.leave(s), fn); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Scene', + }, + + activate: Composer.set(scene.state, { + current: 'unknown', + prev: 'unknown', + }), + + update: Composer.do(({ get, set, pipe }) => { + const { current, prev } = get(scene.state); + if (current === prev) return; + set(scene.state.prev, current); + pipe(Events.dispatch({ type: eventType.leave(prev) })); + pipe(Events.dispatch({ type: eventType.enter(current) })); + }), + + deactivate: Composer.set(scene.state, undefined), + }; + + static get paths() { + return scene; + } +} + +sdk.Scene = Scene; From 4fd7f530764257352fd30aff65d0f40e67d105f7 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 16 Feb 2026 19:30:25 +0100 Subject: [PATCH 85/90] reordered plugins --- src/game/plugins/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts index 6a15d30..d7bbf3d 100644 --- a/src/game/plugins/index.ts +++ b/src/game/plugins/index.ts @@ -22,21 +22,21 @@ import Clock from './clock'; const plugins = [ Scene, + Phase, + Rand, Messages, + Image, + Debug, Pause, - Phase, - Pace, - Intensity, Clock, + Intensity, + Pace, Stroke, - Rand, Dealer, - EmergencyStop, + Warmup, Hypno, - Image, RandomImages, - Warmup, - Debug, + EmergencyStop, Fps, PerfOverlay, ]; From 2c7524836960317d531e822e51cf98ee0ebc2985 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 16 Feb 2026 19:30:55 +0100 Subject: [PATCH 86/90] formatted files --- src/end/EndPage.tsx | 36 +++++++++++++++++++++-------- src/game/plugins/clock.ts | 4 +--- src/game/plugins/dealer.ts | 2 +- src/game/plugins/dice/cleanUp.ts | 3 +-- src/game/plugins/dice/doublePace.ts | 3 +-- src/game/plugins/dice/pause.ts | 3 +-- src/game/plugins/dice/risingPace.ts | 3 +-- 7 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/end/EndPage.tsx b/src/end/EndPage.tsx index 99b6cec..383ff50 100644 --- a/src/end/EndPage.tsx +++ b/src/end/EndPage.tsx @@ -38,9 +38,15 @@ const StyledEndPage = styled.div` animation: ${fadeInUp} 500ms cubic-bezier(0.23, 1, 0.32, 1) both; } - & > :nth-child(1) { animation-delay: 100ms; } - & > :nth-child(2) { animation-delay: 250ms; } - & > :nth-child(3) { animation-delay: 400ms; } + & > :nth-child(1) { + animation-delay: 100ms; + } + & > :nth-child(2) { + animation-delay: 250ms; + } + & > :nth-child(3) { + animation-delay: 400ms; + } `; const StyledTitle = styled.h1` @@ -108,10 +114,15 @@ const StyledFinishButton = styled(WaButton)` export const EndPage = () => { const { injectImpulse } = useGameEngine(); - const clockState = useGameState(Clock.paths.state) as { elapsed?: number } | undefined; - const randState = useGameState(Rand.paths.state) as { seed?: string } | undefined; - - const displayTime = typeof clockState?.elapsed === 'number' ? clockState.elapsed : 0; + const clockState = useGameState(Clock.paths.state) as + | { elapsed?: number } + | undefined; + const randState = useGameState(Rand.paths.state) as + | { seed?: string } + | undefined; + + const displayTime = + typeof clockState?.elapsed === 'number' ? clockState.elapsed : 0; const seed = randState?.seed ?? ''; return ( @@ -126,7 +137,11 @@ export const EndPage = () => { Play time - {seed} + + {seed} + Seed @@ -134,7 +149,10 @@ export const EndPage = () => { - injectImpulse(Scene.setScene('home'))}> + injectImpulse(Scene.setScene('home'))} + > Finish diff --git a/src/game/plugins/clock.ts b/src/game/plugins/clock.ts index fecb7ad..2c4964b 100644 --- a/src/game/plugins/clock.ts +++ b/src/game/plugins/clock.ts @@ -47,9 +47,7 @@ export default class Clock { set(clock.context, { lastWall: now }); }) ), - Pause.onPause(() => - Composer.set(clock.context, { lastWall: null }) - ) + Pause.onPause(() => Composer.set(clock.context, { lastWall: null })) ), deactivate: Composer.pipe( diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index 4bad772..fee90d4 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -68,7 +68,7 @@ export default class Dealer { activate: Composer.pipe( Composer.set(dice.state, { busy: false, log: [] }), - ...outcomes.flatMap(o => o.activate ? [o.activate] : []), + ...outcomes.flatMap(o => (o.activate ? [o.activate] : [])), roll.after(1000, 'check') ), diff --git a/src/game/plugins/dice/cleanUp.ts b/src/game/plugins/dice/cleanUp.ts index 50f19a1..94d12f1 100644 --- a/src/game/plugins/dice/cleanUp.ts +++ b/src/game/plugins/dice/cleanUp.ts @@ -14,8 +14,7 @@ const seq = Sequence.for(PLUGIN_ID, 'cleanUp'); export const cleanUpOutcome: DiceOutcome = { id: DiceEvent.cleanUp, - check: frame => - Composer.get(intensityState)(frame).intensity * 100 >= 75, + check: frame => Composer.get(intensityState)(frame).intensity * 100 >= 75, update: Composer.pipe( seq.on(() => Composer.bind(settings, s => diff --git a/src/game/plugins/dice/doublePace.ts b/src/game/plugins/dice/doublePace.ts index 19f0610..73aa3b3 100644 --- a/src/game/plugins/dice/doublePace.ts +++ b/src/game/plugins/dice/doublePace.ts @@ -17,8 +17,7 @@ const seq = Sequence.for(PLUGIN_ID, 'doublePace'); export const doublePaceOutcome: DiceOutcome = { id: DiceEvent.doublePace, - check: frame => - Composer.get(intensityState)(frame).intensity * 100 >= 20, + check: frame => Composer.get(intensityState)(frame).intensity * 100 >= 20, update: Composer.pipe( seq.on(() => Composer.bind(paceState, pace => diff --git a/src/game/plugins/dice/pause.ts b/src/game/plugins/dice/pause.ts index 0a22dff..7ca86ff 100644 --- a/src/game/plugins/dice/pause.ts +++ b/src/game/plugins/dice/pause.ts @@ -8,8 +8,7 @@ const seq = Sequence.for(PLUGIN_ID, 'pause'); export const pauseOutcome: DiceOutcome = { id: DiceEvent.pause, - check: frame => - Composer.get(intensityState)(frame).intensity * 100 >= 15, + check: frame => Composer.get(intensityState)(frame).intensity * 100 >= 15, update: Composer.pipe( seq.on(() => Composer.bind(intensityState, ist => { diff --git a/src/game/plugins/dice/risingPace.ts b/src/game/plugins/dice/risingPace.ts index 850dc07..563116e 100644 --- a/src/game/plugins/dice/risingPace.ts +++ b/src/game/plugins/dice/risingPace.ts @@ -22,8 +22,7 @@ const seq = Sequence.for(PLUGIN_ID, 'risingPace'); export const risingPaceOutcome: DiceOutcome = { id: DiceEvent.risingPace, - check: frame => - Composer.get(intensityState)(frame).intensity * 100 >= 30, + check: frame => Composer.get(intensityState)(frame).intensity * 100 >= 30, update: Composer.pipe( seq.on(() => Composer.bind(intensityState, ist => From 2b329275b29e5f8b1ef63e13b8b64b657dd76b11 Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 16 Feb 2026 21:18:25 +0100 Subject: [PATCH 87/90] added errors pipe primitive --- src/engine/pipes/Errors.ts | 59 +++++++ src/engine/pipes/index.ts | 1 + src/engine/plugins/PluginManager.ts | 13 +- src/game/GameProvider.tsx | 3 +- tests/engine/pipes/Errors.test.ts | 239 ++++++++++++++++++++++++++++ 5 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 src/engine/pipes/Errors.ts create mode 100644 tests/engine/pipes/Errors.test.ts diff --git a/src/engine/pipes/Errors.ts b/src/engine/pipes/Errors.ts new file mode 100644 index 0000000..54a3594 --- /dev/null +++ b/src/engine/pipes/Errors.ts @@ -0,0 +1,59 @@ +import { Composer } from '../Composer'; +import { Pipe } from '../State'; +import { pluginPaths, PluginId } from '../plugins/Plugins'; +import { sdk } from '../sdk'; + +declare module '../sdk' { + interface SDK { + Errors: typeof Errors; + } +} + +export type ErrorEntry = { + phase: string; + message: string; + stack?: string; + timestamp: number; + count: number; +}; + +export type ErrorsContext = { + plugins: Record>; +}; + +const PLUGIN_NAMESPACE = 'core.errors'; + +const errors = pluginPaths(PLUGIN_NAMESPACE); + +export class Errors { + static withCatch(id: PluginId, phase: string, pluginPipe: Pipe): Pipe { + return Composer.do(({ get, set, pipe }) => { + try { + pipe(pluginPipe); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + const timestamp = Date.now(); + + const entryPath = errors.context.plugins[id][phase]; + const existing = get(entryPath); + const count = existing ? existing.count + 1 : 1; + const isNew = !existing || existing.message !== message; + + set(entryPath, { phase, message, stack, timestamp, count }); + + if (sdk.debug && isNew) { + console.error(`[errors] ${id} ${phase}:`, err); + } + } + }); + } + + static pipe: Pipe = frame => frame; + + static get paths() { + return errors; + } +} + +sdk.Errors = Errors; diff --git a/src/engine/pipes/index.ts b/src/engine/pipes/index.ts index 23aba7f..3bf0322 100644 --- a/src/engine/pipes/index.ts +++ b/src/engine/pipes/index.ts @@ -5,3 +5,4 @@ export * from '../plugins/Plugins'; export * from './Scheduler'; export * from './Storage'; export * from './Perf'; +export * from './Errors'; diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index 6f93506..1e90ddd 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -15,6 +15,7 @@ import { type EnabledMap, } from './Plugins'; import { Perf } from '../pipes/Perf'; +import { Errors } from '../pipes/Errors'; import { sdk } from '../sdk'; const PLUGIN_NAMESPACE = 'core.plugin_manager'; @@ -201,14 +202,18 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const deactivates = toUnload .map(id => { const p = (loadedRefs[id] ?? registry[id])?.plugin.deactivate; - return p ? Perf.withTiming(id, 'deactivate', p) : undefined; + return p + ? Perf.withTiming(id, 'deactivate', Errors.withCatch(id, 'deactivate', p)) + : undefined; }) .filter(Boolean) as Pipe[]; const activates = toLoad .map(id => { const p = registry[id]?.plugin.activate; - return p ? Perf.withTiming(id, 'activate', p) : undefined; + return p + ? Perf.withTiming(id, 'activate', Errors.withCatch(id, 'activate', p)) + : undefined; }) .filter(Boolean) as Pipe[]; @@ -220,7 +225,9 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const updates = activeIds .map(id => { const p = (loadedRefs[id] ?? registry[id])?.plugin.update; - return p ? Perf.withTiming(id, 'update', p) : undefined; + return p + ? Perf.withTiming(id, 'update', Errors.withCatch(id, 'update', p)) + : undefined; }) .filter(Boolean) as Pipe[]; diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 82bb40b..6fd1820 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -4,6 +4,7 @@ import { GameEngine, GameState, Pipe, GameContext } from '../engine'; import { Events } from '../engine/pipes/Events'; import { Scheduler } from '../engine/pipes/Scheduler'; import { Perf } from '../engine/pipes/Perf'; +import { Errors } from '../engine/pipes/Errors'; import { Piper } from '../engine/Piper'; import { Composer } from '../engine/Composer'; @@ -50,7 +51,7 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { engineRef.current = new GameEngine( {}, - Piper([impulsePipe, Events.pipe, Scheduler.pipe, Perf.pipe, ...pipes]) + Piper([impulsePipe, Events.pipe, Scheduler.pipe, Perf.pipe, Errors.pipe, ...pipes]) ); const STEP = 16; diff --git a/tests/engine/pipes/Errors.test.ts b/tests/engine/pipes/Errors.test.ts new file mode 100644 index 0000000..a4aa727 --- /dev/null +++ b/tests/engine/pipes/Errors.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Composer } from '../../../src/engine/Composer'; +import { GameFrame, Pipe } from '../../../src/engine/State'; +import { Events } from '../../../src/engine/pipes/Events'; +import { Errors, type ErrorEntry } from '../../../src/engine/pipes/Errors'; +import { lensFromPath } from '../../../src/engine/Lens'; +import { sdk } from '../../../src/engine/sdk'; +import { makeFrame, tick } from '../../utils'; + +const basePipe: Pipe = Events.pipe; + +const getEntry = ( + frame: GameFrame, + pluginId: string, + phase: string +): ErrorEntry | undefined => + lensFromPath(['context', 'core.errors', 'plugins', pluginId, phase]).get( + frame + ); + +describe('Errors', () => { + beforeEach(() => { + sdk.debug = true; + }); + afterEach(() => { + sdk.debug = false; + }); + + describe('withCatch', () => { + it('should not affect a pipe that does not throw', () => { + const noop: Pipe = frame => frame; + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'update', noop) + ); + + const result = pipe(makeFrame()); + const entry = getEntry(result, 'test.plugin', 'update'); + + expect(entry).toBeUndefined(); + }); + + it('should catch errors and store them in context', () => { + const failing: Pipe = () => { + throw new Error('boom'); + }; + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'update', failing) + ); + + const result = pipe(makeFrame()); + const entry = getEntry(result, 'test.plugin', 'update'); + + expect(entry).toBeDefined(); + expect(entry!.message).toBe('boom'); + expect(entry!.phase).toBe('update'); + expect(entry!.count).toBe(1); + expect(entry!.stack).toBeDefined(); + expect(entry!.timestamp).toBeGreaterThan(0); + }); + + it('should let the tick continue after an error', () => { + const failing: Pipe = () => { + throw new Error('boom'); + }; + let ranAfter = false; + const after: Pipe = frame => { + ranAfter = true; + return frame; + }; + + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'update', failing), + after + ); + + pipe(makeFrame()); + expect(ranAfter).toBe(true); + }); + + it('should increment count on repeated errors', () => { + const failing: Pipe = () => { + throw new Error('boom'); + }; + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'update', failing) + ); + + let frame = makeFrame(); + for (let i = 0; i < 5; i++) { + frame = pipe(tick(frame)); + } + + const entry = getEntry(frame, 'test.plugin', 'update'); + expect(entry!.count).toBe(5); + }); + + it('should track separate plugins independently', () => { + const failA: Pipe = () => { + throw new Error('fail-a'); + }; + const failB: Pipe = () => { + throw new Error('fail-b'); + }; + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('plugin.a', 'update', failA), + Errors.withCatch('plugin.b', 'update', failB) + ); + + const result = pipe(makeFrame()); + + expect(getEntry(result, 'plugin.a', 'update')!.message).toBe('fail-a'); + expect(getEntry(result, 'plugin.b', 'update')!.message).toBe('fail-b'); + }); + + it('should track separate phases independently', () => { + const fail: Pipe = () => { + throw new Error('boom'); + }; + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'activate', fail), + Errors.withCatch('test.plugin', 'update', fail) + ); + + const result = pipe(makeFrame()); + + expect(getEntry(result, 'test.plugin', 'activate')).toBeDefined(); + expect(getEntry(result, 'test.plugin', 'update')).toBeDefined(); + }); + + it('should handle non-Error throws', () => { + const failing: Pipe = () => { + throw 'string error'; + }; + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'update', failing) + ); + + const result = pipe(makeFrame()); + const entry = getEntry(result, 'test.plugin', 'update'); + + expect(entry!.message).toBe('string error'); + expect(entry!.stack).toBeUndefined(); + }); + + it('should deduplicate console.error for repeated identical errors', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failing: Pipe = () => { + throw new Error('boom'); + }; + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'update', failing) + ); + + let frame = makeFrame(); + for (let i = 0; i < 5; i++) { + frame = pipe(tick(frame)); + } + + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + + it('should log again when error message changes', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let msg = 'first'; + const failing: Pipe = () => { + throw new Error(msg); + }; + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'update', failing) + ); + + let frame = pipe(makeFrame()); + msg = 'second'; + frame = pipe(tick(frame)); + + expect(spy).toHaveBeenCalledTimes(2); + spy.mockRestore(); + }); + + it('should not log when sdk.debug is false', () => { + sdk.debug = false; + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failing: Pipe = () => { + throw new Error('boom'); + }; + const pipe = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'update', failing) + ); + + pipe(makeFrame()); + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('should store errors without any setup pipe', () => { + const failing: Pipe = () => { + throw new Error('boom'); + }; + const pipe = Composer.pipe( + Events.pipe, + Errors.withCatch('test.plugin', 'update', failing) + ); + + const result = pipe(makeFrame()); + const entry = getEntry(result, 'test.plugin', 'update'); + + expect(entry).toBeDefined(); + expect(entry!.message).toBe('boom'); + }); + + it('should preserve error entries across frames', () => { + const failing: Pipe = () => { + throw new Error('boom'); + }; + + const frame0 = Composer.pipe( + basePipe, + Errors.withCatch('test.plugin', 'update', failing) + )(makeFrame()); + + expect(getEntry(frame0, 'test.plugin', 'update')).toBeDefined(); + + const frame1 = basePipe(tick(frame0)); + expect(getEntry(frame1, 'test.plugin', 'update')).toBeDefined(); + }); + }); +}); From 8a24eefb43c93f4cb179df51f7d582d38fcbd40b Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 16 Feb 2026 21:48:57 +0100 Subject: [PATCH 88/90] fixed perf plugin path access --- src/engine/pipes/Perf.ts | 129 +++++++++++++++----------------- tests/engine/pipes/Perf.test.ts | 15 ++-- 2 files changed, 68 insertions(+), 76 deletions(-) diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index cbe8a38..ca6265b 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -50,6 +50,39 @@ const eventType = Events.getKeys(PLUGIN_NAMESPACE, 'over_budget', 'configure'); const perf = pluginPaths(PLUGIN_NAMESPACE); const gameContext = typedPath(['context']); +function isEntry(value: unknown): value is PluginPerfEntry { + return value != null && typeof value === 'object' && 'lastTick' in value; +} + +function pruneExpired( + node: Record, + tick: number +): [Record | undefined, boolean] { + let dirty = false; + const result: Record = {}; + + for (const [key, value] of Object.entries(node)) { + if (isEntry(value)) { + if (tick - value.lastTick <= EXPIRY_TICKS) { + result[key] = value; + } else { + dirty = true; + } + } else if (value && typeof value === 'object') { + const [pruned, changed] = pruneExpired( + value as Record, + tick + ); + if (pruned) result[key] = pruned; + else dirty = true; + if (changed) dirty = true; + } + } + + const empty = Object.keys(result).length === 0; + return [empty ? undefined : result, dirty]; +} + export class Perf { static withTiming(id: PluginId, phase: string, pluginPipe: Pipe): Pipe { return Composer.do(({ get, set, pipe }) => { @@ -64,47 +97,26 @@ export class Perf { const duration = after - before; const tick = get(gameContext.tick) ?? 0; - const ctx = get(perf.context) ?? { plugins: {}, config: DEFAULT_CONFIG }; - const pluginMetrics = ctx.plugins[id] ?? {}; - const entry = pluginMetrics[phase]; + const entryPath = perf.context.plugins[id][phase]; + const entry = get(entryPath); const samples = entry ? [...entry.samples, duration].slice(-SAMPLE_SIZE) : [duration]; - const avg = - samples.length > 0 - ? samples.reduce((sum, v) => sum + v, 0) / samples.length - : duration; - + const avg = samples.reduce((sum, v) => sum + v, 0) / samples.length; const max = entry ? Math.max(entry.max, duration) : duration; - const newEntry: PluginPerfEntry = { - last: duration, - avg, - max, - samples, - lastTick: tick, - }; - - set(perf.context, { - ...ctx, - plugins: { - ...ctx.plugins, - [id]: { - ...pluginMetrics, - [phase]: newEntry, - }, - }, - }); - - const budget = ctx.config.pluginBudget; + set(entryPath, { last: duration, avg, max, samples, lastTick: tick }); + + const budget = + get(perf.context.config.pluginBudget) ?? + DEFAULT_CONFIG.pluginBudget; + if (duration > budget) { - if (sdk.debug) { - console.warn( - `[perf] ${id} ${phase} took ${duration.toFixed(2)}ms (budget: ${budget}ms)` - ); - } + console.warn( + `[perf] ${id} ${phase} took ${duration.toFixed(2)}ms (budget: ${budget}ms)` + ); pipe( Events.dispatch({ type: eventType.overBudget, @@ -127,52 +139,31 @@ export class Perf { } static pipe: Pipe = Composer.pipe( - Composer.over( - perf.context, - (ctx = { plugins: {}, config: DEFAULT_CONFIG }) => ({ - ...ctx, - plugins: ctx.plugins ?? {}, - config: ctx.config ?? DEFAULT_CONFIG, - }) - ), + Composer.do(({ get, set }) => { + if (!get(perf.context.config)) { + set(perf.context.config, DEFAULT_CONFIG); + } + }), Composer.do(({ get, set }) => { const tick = get(gameContext.tick) ?? 0; - const ctx = get(perf.context); - if (!ctx) return; - - let dirty = false; - const pruned: PerfMetrics = {}; - - for (const [id, phases] of Object.entries(ctx.plugins)) { - const kept: Record = {}; - for (const [phase, entry] of Object.entries(phases)) { - if (entry && tick - entry.lastTick <= EXPIRY_TICKS) { - kept[phase] = entry; - } else { - dirty = true; - } - } - if (Object.keys(kept).length > 0) { - pruned[id] = kept; - } else { - dirty = true; - } - } + const plugins = get>(perf.context.plugins); + if (!plugins) return; + const [pruned, dirty] = pruneExpired(plugins, tick); if (dirty) { - set(perf.context, { ...ctx, plugins: pruned }); + set(perf.context.plugins, pruned ?? {}); } }), Events.handle>(eventType.configure, event => - Composer.over(perf.context, ctx => ({ - ...ctx, - config: { - ...ctx.config, + Composer.over( + perf.context.config, + (config = DEFAULT_CONFIG) => ({ + ...config, ...event.payload, - }, - })) + }) + ) ) ); diff --git a/tests/engine/pipes/Perf.test.ts b/tests/engine/pipes/Perf.test.ts index f6f27be..007bae6 100644 --- a/tests/engine/pipes/Perf.test.ts +++ b/tests/engine/pipes/Perf.test.ts @@ -4,23 +4,24 @@ import { GameFrame, Pipe } from '../../../src/engine/State'; import { Events } from '../../../src/engine/pipes/Events'; import { Perf, - type PerfContext, + type PerfConfig, type PluginPerfEntry, } from '../../../src/engine/pipes/Perf'; +import { lensFromPath } from '../../../src/engine/Lens'; import { sdk } from '../../../src/engine/sdk'; import { makeFrame, tick } from '../../utils'; const basePipe: Pipe = Composer.pipe(Events.pipe, Perf.pipe); -const getPerfCtx = (frame: GameFrame): PerfContext | undefined => - (frame.context as any)?.core?.perf; - const getEntry = ( frame: GameFrame, pluginId: string, phase: string ): PluginPerfEntry | undefined => - (getPerfCtx(frame)?.plugins as any)?.[pluginId]?.[phase]; + lensFromPath(['context', 'core.perf', 'plugins', pluginId, phase]).get(frame); + +const getConfig = (frame: GameFrame): PerfConfig | undefined => + lensFromPath(['context', 'core.perf', 'config']).get(frame); describe('Perf', () => { beforeEach(() => { @@ -211,8 +212,8 @@ describe('Perf', () => { const frame1 = Perf.configure({ pluginBudget: 2 })(frame0); const frame2 = basePipe(tick(frame1)); - const ctx = getPerfCtx(frame2); - expect(ctx!.config.pluginBudget).toBe(2); + const config = getConfig(frame2); + expect(config!.pluginBudget).toBe(2); }); }); }); From 270af9148a2708e2014a822eb728b1674bca338e Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 16 Feb 2026 21:49:41 +0100 Subject: [PATCH 89/90] formatted files --- src/engine/pipes/Perf.ts | 11 ++++------- src/engine/plugins/PluginManager.ts | 6 +++++- src/game/GameProvider.tsx | 9 ++++++++- tests/engine/pipes/Errors.test.ts | 4 ++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index ca6265b..a6d8dea 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -157,13 +157,10 @@ export class Perf { }), Events.handle>(eventType.configure, event => - Composer.over( - perf.context.config, - (config = DEFAULT_CONFIG) => ({ - ...config, - ...event.payload, - }) - ) + Composer.over(perf.context.config, (config = DEFAULT_CONFIG) => ({ + ...config, + ...event.payload, + })) ) ); diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index 1e90ddd..13919d9 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -203,7 +203,11 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { .map(id => { const p = (loadedRefs[id] ?? registry[id])?.plugin.deactivate; return p - ? Perf.withTiming(id, 'deactivate', Errors.withCatch(id, 'deactivate', p)) + ? Perf.withTiming( + id, + 'deactivate', + Errors.withCatch(id, 'deactivate', p) + ) : undefined; }) .filter(Boolean) as Pipe[]; diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index 6fd1820..da19899 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -51,7 +51,14 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { engineRef.current = new GameEngine( {}, - Piper([impulsePipe, Events.pipe, Scheduler.pipe, Perf.pipe, Errors.pipe, ...pipes]) + Piper([ + impulsePipe, + Events.pipe, + Scheduler.pipe, + Perf.pipe, + Errors.pipe, + ...pipes, + ]) ); const STEP = 16; diff --git a/tests/engine/pipes/Errors.test.ts b/tests/engine/pipes/Errors.test.ts index a4aa727..4380642 100644 --- a/tests/engine/pipes/Errors.test.ts +++ b/tests/engine/pipes/Errors.test.ts @@ -179,9 +179,9 @@ describe('Errors', () => { Errors.withCatch('test.plugin', 'update', failing) ); - let frame = pipe(makeFrame()); + const frame = pipe(makeFrame()); msg = 'second'; - frame = pipe(tick(frame)); + pipe(tick(frame)); expect(spy).toHaveBeenCalledTimes(2); spy.mockRestore(); From bae6b40b09ae2f9a01f130683a06020a0302ad29 Mon Sep 17 00:00:00 2001 From: clragon Date: Thu, 19 Feb 2026 21:59:09 +0100 Subject: [PATCH 90/90] killed game context --- src/end/ClimaxResult.tsx | 4 +- src/end/EndPage.tsx | 8 +- src/end/GameTimeline.tsx | 8 +- src/engine/Engine.ts | 54 +++++-------- src/engine/State.ts | 13 +-- src/engine/pipes/Errors.ts | 6 +- src/engine/pipes/Events.ts | 6 +- src/engine/pipes/Perf.ts | 27 +++---- src/engine/pipes/Scheduler.ts | 23 +++--- src/engine/pipes/Storage.ts | 21 ++--- src/engine/plugins/PluginInstaller.ts | 29 +++---- src/engine/plugins/PluginManager.ts | 79 ++++++++----------- src/engine/plugins/Plugins.ts | 9 +-- src/game/GameProvider.tsx | 18 ++--- src/game/SceneBridge.tsx | 4 +- src/game/components/GameEmergencyStop.tsx | 4 +- src/game/components/GameHypno.tsx | 6 +- src/game/components/GameImages.tsx | 6 +- src/game/components/GameInstructions.tsx | 8 +- src/game/components/GameIntensity.tsx | 4 +- src/game/components/GameMessages.tsx | 4 +- src/game/components/GameMeter.tsx | 8 +- src/game/components/GamePauseMenu.tsx | 4 +- src/game/components/GameResume.tsx | 4 +- src/game/components/GameSound.tsx | 6 +- src/game/components/GameVibrator.tsx | 10 +-- src/game/hooks/UseGameContext.tsx | 12 --- .../{UseGameValue.tsx => UseGameFrame.tsx} | 7 +- src/game/hooks/index.ts | 3 +- src/game/pipes/Settings.ts | 4 +- src/game/plugins/clock.ts | 28 +++---- src/game/plugins/dealer.ts | 18 ++--- src/game/plugins/debug.ts | 12 ++- src/game/plugins/dice/climax.ts | 2 +- src/game/plugins/dice/edge.ts | 2 +- src/game/plugins/dice/randomGrip.ts | 2 +- src/game/plugins/dice/types.ts | 9 +-- src/game/plugins/emergencyStop.ts | 4 +- src/game/plugins/fps.ts | 16 ++-- src/game/plugins/hypno.ts | 22 +++--- src/game/plugins/image.ts | 10 +-- src/game/plugins/intensity.ts | 14 ++-- src/game/plugins/messages.ts | 10 +-- src/game/plugins/pace.ts | 27 +++---- src/game/plugins/pause.ts | 38 ++++----- src/game/plugins/perf.ts | 40 ++++++---- src/game/plugins/phase.ts | 12 +-- src/game/plugins/rand.ts | 12 +-- src/game/plugins/randomImages.ts | 6 +- src/game/plugins/scene.ts | 12 +-- src/game/plugins/stroke.ts | 20 ++--- src/game/plugins/warmup.ts | 14 ++-- tests/engine/Engine.test.ts | 50 ++++++------ tests/engine/pipes/Errors.test.ts | 4 +- tests/engine/pipes/Perf.test.ts | 8 +- tests/engine/pipes/Storage.test.ts | 61 ++++---------- tests/engine/plugins/PluginInstaller.test.ts | 14 ++-- tests/engine/plugins/PluginManager.test.ts | 4 +- tests/game/plugins/pause.test.ts | 4 +- tests/utils.ts | 22 +++--- 60 files changed, 387 insertions(+), 509 deletions(-) delete mode 100644 src/game/hooks/UseGameContext.tsx rename src/game/hooks/{UseGameValue.tsx => UseGameFrame.tsx} (53%) diff --git a/src/end/ClimaxResult.tsx b/src/end/ClimaxResult.tsx index c008958..636f45c 100644 --- a/src/end/ClimaxResult.tsx +++ b/src/end/ClimaxResult.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { useGameState } from '../game/hooks'; +import { useGameFrame } from '../game/hooks'; import { climax, type ClimaxResultType } from '../game/plugins/dice/climax'; type OutcomeDisplay = { label: string; description: string }; @@ -30,7 +30,7 @@ const StyledDescription = styled.span` `; export const ClimaxResult = () => { - const result = useGameState(climax.result) as ClimaxResultType; + const result = useGameFrame(climax.result) as ClimaxResultType; const display = (result && outcomes[result]) || earlyEnd; return ( diff --git a/src/end/EndPage.tsx b/src/end/EndPage.tsx index 383ff50..0dcd45a 100644 --- a/src/end/EndPage.tsx +++ b/src/end/EndPage.tsx @@ -2,7 +2,7 @@ import styled, { keyframes } from 'styled-components'; import { WaButton } from '@awesome.me/webawesome/dist/react'; import { ContentSection } from '../common'; import { useGameEngine } from '../game/hooks/UseGameEngine'; -import { useGameState } from '../game/hooks'; +import { useGameFrame } from '../game/hooks'; import Clock from '../game/plugins/clock'; import Rand from '../game/plugins/rand'; import Scene from '../game/plugins/scene'; @@ -114,12 +114,10 @@ const StyledFinishButton = styled(WaButton)` export const EndPage = () => { const { injectImpulse } = useGameEngine(); - const clockState = useGameState(Clock.paths.state) as + const clockState = useGameFrame(Clock.paths) as | { elapsed?: number } | undefined; - const randState = useGameState(Rand.paths.state) as - | { seed?: string } - | undefined; + const randState = useGameFrame(Rand.paths) as { seed?: string } | undefined; const displayTime = typeof clockState?.elapsed === 'number' ? clockState.elapsed : 0; diff --git a/src/end/GameTimeline.tsx b/src/end/GameTimeline.tsx index ada844b..01c9a53 100644 --- a/src/end/GameTimeline.tsx +++ b/src/end/GameTimeline.tsx @@ -9,7 +9,7 @@ import { Tooltip, TooltipProps, } from 'recharts'; -import { useGameState } from '../game/hooks'; +import { useGameFrame } from '../game/hooks'; import Pace, { type PaceEntry } from '../game/plugins/pace'; import Dealer from '../game/plugins/dealer'; import Clock from '../game/plugins/clock'; @@ -51,13 +51,13 @@ const GraphTooltip = ({ active, payload }: TooltipProps) => { }; export const GameTimeline = () => { - const paceState = useGameState(Pace.paths.state) as + const paceState = useGameFrame(Pace.paths) as | { history?: PaceEntry[] } | undefined; - const diceState = useGameState(Dealer.paths.state) as + const diceState = useGameFrame(Dealer.paths) as | { log?: DiceLogEntry[] } | undefined; - const clockState = useGameState(Clock.paths.state) as + const clockState = useGameFrame(Clock.paths) as | { elapsed?: number } | undefined; diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 37df1e3..5ad2eea 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -1,4 +1,4 @@ -import { GameState, GameContext, Pipe, GameTiming, GameFrame } from './State'; +import { Pipe, GameTiming, GameFrame } from './State'; import { deepFreeze } from './freeze'; export type GameEngineOptions = { @@ -6,8 +6,12 @@ export type GameEngineOptions = { }; export class GameEngine { - constructor(initial: GameState, pipe: Pipe, options?: GameEngineOptions) { - this.state = { ...initial }; + constructor( + initial: Record, + pipe: Pipe, + options?: GameEngineOptions + ) { + this.frame = { ...initial } as GameFrame; this.pipe = pipe; this.step = options?.step ?? 16; this.timing = { @@ -15,14 +19,12 @@ export class GameEngine { step: this.step, time: 0, }; - this.context = this.timing; } /** - * The state of the engine. This object should contain all information to run the game from a cold start. - * Pipes may add any additional fields. Must be serializable to JSON. + * The frame contains all plugin data. Each plugin's data lives at frame[pluginId]. */ - private state: GameState; + private frame: GameFrame; /** * The pipe is a function that produces a new game frame based on the current game frame. @@ -34,31 +36,17 @@ export class GameEngine { */ private step: number; - /** - * The context of the engine. May contain any ephemeral information of any plugin, however it is to be noted; - * Context may be discarded at any time, so it may not contain information necessary to restore the game state. - * It is not required to be serializable. As such, this object can contain inter-pipe communication, utility functions, or debugging information. - */ - private context: GameContext; - /** * Contains the timing information of the game engine. This may not be modified by either the outside nor by pipes. */ private timing: GameTiming; /** - * Returns the current game state. - */ - public getState(): GameState { - return this.state; - } - - /** - * Returns the current game context. + * Returns the current game frame. */ - public getContext(): GameContext { + public getFrame(): GameFrame { return { - ...this.context, + ...this.frame, ...this.timing, }; } @@ -66,28 +54,24 @@ export class GameEngine { /** * Runs the game engine for a single fixed-step tick. */ - public tick(): GameState { + public tick(): GameFrame { this.timing.tick += 1; this.timing.time += this.step; const frame: GameFrame = { - state: this.state, - context: { - ...this.context, - ...this.timing, - }, + ...this.frame, + ...this.timing, }; const result = this.pipe(frame); - this.state = result.state; - this.context = { - ...result.context, + this.frame = { + ...result, ...this.timing, }; - if (import.meta.env.DEV) deepFreeze(this.state); + if (import.meta.env.DEV) deepFreeze(this.frame); - return this.state; + return this.frame; } } diff --git a/src/engine/State.ts b/src/engine/State.ts index c53643c..3af20b7 100644 --- a/src/engine/State.ts +++ b/src/engine/State.ts @@ -1,11 +1,3 @@ -export type GameState = { - [key: string]: any; -}; - -export type GameContext = { - [key: string]: any; -} & GameTiming; - export type GameTiming = { tick: number; step: number; @@ -13,9 +5,8 @@ export type GameTiming = { }; export type GameFrame = { - state: GameState; - context: GameContext; -}; + [key: string]: any; +} & GameTiming; export type Pipe = (value: GameFrame) => GameFrame; diff --git a/src/engine/pipes/Errors.ts b/src/engine/pipes/Errors.ts index 54a3594..507001b 100644 --- a/src/engine/pipes/Errors.ts +++ b/src/engine/pipes/Errors.ts @@ -23,7 +23,7 @@ export type ErrorsContext = { const PLUGIN_NAMESPACE = 'core.errors'; -const errors = pluginPaths(PLUGIN_NAMESPACE); +const errors = pluginPaths(PLUGIN_NAMESPACE); export class Errors { static withCatch(id: PluginId, phase: string, pluginPipe: Pipe): Pipe { @@ -35,8 +35,8 @@ export class Errors { const stack = err instanceof Error ? err.stack : undefined; const timestamp = Date.now(); - const entryPath = errors.context.plugins[id][phase]; - const existing = get(entryPath); + const entryPath = errors.plugins[id][phase]; + const existing = get(entryPath); const count = existing ? existing.count + 1 : 1; const isNew = !existing || existing.message !== message; diff --git a/src/engine/pipes/Events.ts b/src/engine/pipes/Events.ts index 21b1019..cd1b3de 100644 --- a/src/engine/pipes/Events.ts +++ b/src/engine/pipes/Events.ts @@ -16,8 +16,8 @@ export type EventState = { const PLUGIN_NAMESPACE = 'core.events'; const events = pluginPaths(PLUGIN_NAMESPACE); -const eventStateLens = lensFromPath(events.state); -const pendingLens = lensFromPath(events.state.pending); +const eventStateLens = lensFromPath(events); +const pendingLens = lensFromPath(events.pending); export class Events { static getKey(namespace: string, key: string): string { @@ -75,7 +75,7 @@ export class Events { * This prevents events from being processed during the same frame they are created. * This is important because pipes later in the pipeline may add new events. */ - static pipe: Pipe = Composer.over(events.state, ({ pending = [] }) => ({ + static pipe: Pipe = Composer.over(events, ({ pending = [] }) => ({ pending: [], current: pending, })); diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts index a6d8dea..ea04942 100644 --- a/src/engine/pipes/Perf.ts +++ b/src/engine/pipes/Perf.ts @@ -1,5 +1,5 @@ import { Composer } from '../Composer'; -import { GameContext, Pipe } from '../State'; +import { GameFrame, Pipe } from '../State'; import { Events, type GameEvent } from './Events'; import { pluginPaths, PluginId } from '../plugins/Plugins'; import { typedPath } from '../Lens'; @@ -47,8 +47,8 @@ type OverBudgetPayload = { const eventType = Events.getKeys(PLUGIN_NAMESPACE, 'over_budget', 'configure'); -const perf = pluginPaths(PLUGIN_NAMESPACE); -const gameContext = typedPath(['context']); +const perf = pluginPaths(PLUGIN_NAMESPACE); +const frameTiming = typedPath([]); function isEntry(value: unknown): value is PluginPerfEntry { return value != null && typeof value === 'object' && 'lastTick' in value; @@ -96,9 +96,9 @@ export class Perf { const after = performance.now(); const duration = after - before; - const tick = get(gameContext.tick) ?? 0; - const entryPath = perf.context.plugins[id][phase]; - const entry = get(entryPath); + const tick = get(frameTiming.tick) ?? 0; + const entryPath = perf.plugins[id][phase]; + const entry = get(entryPath); const samples = entry ? [...entry.samples, duration].slice(-SAMPLE_SIZE) @@ -110,8 +110,7 @@ export class Perf { set(entryPath, { last: duration, avg, max, samples, lastTick: tick }); const budget = - get(perf.context.config.pluginBudget) ?? - DEFAULT_CONFIG.pluginBudget; + get(perf.config.pluginBudget) ?? DEFAULT_CONFIG.pluginBudget; if (duration > budget) { console.warn( @@ -140,24 +139,24 @@ export class Perf { static pipe: Pipe = Composer.pipe( Composer.do(({ get, set }) => { - if (!get(perf.context.config)) { - set(perf.context.config, DEFAULT_CONFIG); + if (!get(perf.config)) { + set(perf.config, DEFAULT_CONFIG); } }), Composer.do(({ get, set }) => { - const tick = get(gameContext.tick) ?? 0; - const plugins = get>(perf.context.plugins); + const tick = get(frameTiming.tick) ?? 0; + const plugins = get(perf.plugins); if (!plugins) return; const [pruned, dirty] = pruneExpired(plugins, tick); if (dirty) { - set(perf.context.plugins, pruned ?? {}); + set(perf.plugins, pruned ?? {}); } }), Events.handle>(eventType.configure, event => - Composer.over(perf.context.config, (config = DEFAULT_CONFIG) => ({ + Composer.over(perf.config, (config = DEFAULT_CONFIG) => ({ ...config, ...event.payload, })) diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts index 3666277..61dab9c 100644 --- a/src/engine/pipes/Scheduler.ts +++ b/src/engine/pipes/Scheduler.ts @@ -19,7 +19,7 @@ type SchedulerState = { }; const scheduler = pluginPaths(PLUGIN_NAMESPACE); -const timing = typedPath(['context']); +const timing = typedPath([]); const eventType = Events.getKeys( PLUGIN_NAMESPACE, @@ -37,6 +37,9 @@ export class Scheduler { return `${namespace}/schedule/${key}`; } + // These API methods are events to ensure they dont have weird ordering problems. + // TODO: re-evaluate this decision + static schedule(event: ScheduledEvent): Pipe { return Events.dispatch({ type: eventType.schedule, payload: event }); } @@ -70,7 +73,7 @@ export class Scheduler { static pipe: Pipe = Composer.pipe( Composer.bind(timing.step, delta => - Composer.over(scheduler.state, ({ scheduled = [] }) => { + Composer.over(scheduler, ({ scheduled = [] }) => { const remaining: ScheduledEvent[] = []; const current: GameEvent[] = []; @@ -91,37 +94,37 @@ export class Scheduler { }) ), - Composer.bind(scheduler.state.current, events => + Composer.bind(scheduler.current, events => Composer.pipe(...events.map(Events.dispatch)) ), Events.handle(eventType.schedule, event => - Composer.over(scheduler.state.scheduled, list => [ + Composer.over(scheduler.scheduled, list => [ ...list.filter(e => e.id !== event.payload.id), event.payload, ]) ), Events.handle(eventType.cancel, event => - Composer.over(scheduler.state.scheduled, list => + Composer.over(scheduler.scheduled, list => list.filter(s => s.id !== event.payload) ) ), Events.handle(eventType.hold, event => - Composer.over(scheduler.state.scheduled, list => + Composer.over(scheduler.scheduled, list => list.map(s => (s.id === event.payload ? { ...s, held: true } : s)) ) ), Events.handle(eventType.release, event => - Composer.over(scheduler.state.scheduled, list => + Composer.over(scheduler.scheduled, list => list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) ) ), Events.handle(eventType.holdByPrefix, event => - Composer.over(scheduler.state.scheduled, list => + Composer.over(scheduler.scheduled, list => list.map(s => s.id?.startsWith(event.payload) ? { ...s, held: true } : s ) @@ -129,7 +132,7 @@ export class Scheduler { ), Events.handle(eventType.releaseByPrefix, event => - Composer.over(scheduler.state.scheduled, list => + Composer.over(scheduler.scheduled, list => list.map(s => s.id?.startsWith(event.payload) ? { ...s, held: false } : s ) @@ -137,7 +140,7 @@ export class Scheduler { ), Events.handle(eventType.cancelByPrefix, event => - Composer.over(scheduler.state.scheduled, list => + Composer.over(scheduler.scheduled, list => list.filter(s => !s.id?.startsWith(event.payload)) ) ) diff --git a/src/engine/pipes/Storage.ts b/src/engine/pipes/Storage.ts index d564cc6..12c7566 100644 --- a/src/engine/pipes/Storage.ts +++ b/src/engine/pipes/Storage.ts @@ -28,8 +28,8 @@ export type StorageContext = { cache: { [key: string]: CacheEntry }; }; -const storage = pluginPaths(STORAGE_NAMESPACE); -const timing = typedPath(['context']); +const storage = pluginPaths(STORAGE_NAMESPACE); +const timing = typedPath([]); /** * Storage API for reading and writing to localStorage @@ -54,22 +54,19 @@ export class Storage { * Gets a value, using cache or loading from localStorage */ static bind(key: string, fn: (value: T | undefined) => Pipe): Pipe { - return Composer.bind(storage.context, ctx => { + return Composer.bind(storage, ctx => { const cache = ctx?.cache || {}; const cached = cache[key]; - // Return cached value if available and not expired if (cached) { return fn(cached.value as T | undefined); } - // Load from localStorage const value = Storage.load(key); - // Cache it (will be set with expiry in the next pipe) return Composer.pipe( Composer.bind(timing.time, elapsedTime => - Composer.over(storage.context, ctx => ({ + Composer.over(storage, ctx => ({ cache: { ...(ctx?.cache || {}), [key]: { @@ -89,16 +86,14 @@ export class Storage { */ static set(key: string, value: T): Pipe { return frame => { - // Write to localStorage try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.error('Failed to write to localStorage:', e); } - // Update cache return Composer.bind(timing.time, elapsedTime => - Composer.over(storage.context, ctx => ({ + Composer.over(storage, ctx => ({ cache: { ...(ctx?.cache || {}), [key]: { @@ -116,15 +111,13 @@ export class Storage { */ static remove(key: string): Pipe { return frame => { - // Remove from localStorage try { localStorage.removeItem(key); } catch (e) { console.error('Failed to remove from localStorage:', e); } - // Remove from cache - return Composer.over(storage.context, ctx => { + return Composer.over(storage, ctx => { const newCache = { ...(ctx?.cache || {}) }; delete newCache[key]; return { cache: newCache }; @@ -137,7 +130,7 @@ export class Storage { */ static pipe: Pipe = Composer.pipe( Composer.bind(timing.time, elapsedTime => - Composer.over(storage.context, ctx => { + Composer.over(storage, ctx => { const newCache: { [key: string]: CacheEntry } = {}; for (const [key, entry] of Object.entries(ctx?.cache || {})) { diff --git a/src/engine/plugins/PluginInstaller.ts b/src/engine/plugins/PluginInstaller.ts index 41ab41e..9c498f0 100644 --- a/src/engine/plugins/PluginInstaller.ts +++ b/src/engine/plugins/PluginInstaller.ts @@ -1,5 +1,5 @@ import { Composer } from '../Composer'; -import { Pipe, GameFrame } from '../State'; +import { Pipe } from '../State'; import { Storage } from '../pipes/Storage'; import { PluginManager } from './PluginManager'; import { pluginPaths, type PluginId, type PluginClass } from './Plugins'; @@ -15,13 +15,10 @@ type PluginLoad = { type InstallerState = { installed: PluginId[]; failed: PluginId[]; -}; - -type InstallerContext = { pending: Map; }; -const ins = pluginPaths(PLUGIN_NAMESPACE); +const ins = pluginPaths(PLUGIN_NAMESPACE); const storageKey = { user: `${PLUGIN_NAMESPACE}.user`, @@ -54,10 +51,10 @@ const importPipe: Pipe = Storage.bind( Composer.pipe( ...userPluginIds.map(id => Storage.bind(storageKey.code(id), code => - Composer.do(({ get, over }) => { - const installed = get(ins.state.installed) ?? []; - const failed = get(ins.state.failed) ?? []; - const pending = get(ins.context.pending); + Composer.do(({ get, over }) => { + const installed = get(ins.installed) ?? []; + const failed = get(ins.failed) ?? []; + const pending = get(ins.pending); if ( installed.includes(id) || @@ -70,14 +67,14 @@ const importPipe: Pipe = Storage.bind( console.error( `[PluginInstaller] plugin "${id}" has no code in storage` ); - over(ins.state.failed, (ids = []) => [ + over(ins.failed, (ids = []) => [ ...(Array.isArray(ids) ? ids : []), id, ]); return; } - over(ins.context.pending, pending => { + over(ins.pending, pending => { if (!(pending instanceof Map)) pending = new Map(); // TODO: generic async resolver pipe? const pluginLoad: PluginLoad = { @@ -99,8 +96,8 @@ const importPipe: Pipe = Storage.bind( ) ); -const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { - const pending = get(ins.context.pending); +const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { + const pending = get(ins.pending); if (!pending?.size) return; const resolved: PluginClass[] = []; @@ -123,21 +120,21 @@ const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => { if (resolved.length > 0) { pipe(...resolved.map(PluginManager.register)); - over(ins.state.installed, (ids = []) => [ + over(ins.installed, (ids = []) => [ ...(Array.isArray(ids) ? ids : []), ...resolved.map(cls => cls.plugin.id), ]); } if (failed.length > 0) { - over(ins.state.failed, (ids = []) => [ + over(ins.failed, (ids = []) => [ ...(Array.isArray(ids) ? ids : []), ...failed, ]); } if (remaining.size !== pending.size) { - set(ins.context.pending, remaining); + set(ins.pending, remaining); } }); diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts index 13919d9..cfdfe94 100644 --- a/src/engine/plugins/PluginManager.ts +++ b/src/engine/plugins/PluginManager.ts @@ -1,5 +1,5 @@ import { Composer } from '../Composer'; -import { Pipe, PipeTransformer, GameFrame } from '../State'; +import { Pipe, PipeTransformer } from '../State'; import { startDOMBatching, stopDOMBatching, @@ -32,10 +32,6 @@ const storageKey = { enabled: `${PLUGIN_NAMESPACE}.enabled`, }; -type PluginManagerState = { - loaded: PluginId[]; -}; - export type PluginManagerAPI = { register: PipeTransformer<[PluginClass]>; unregister: PipeTransformer<[PluginId]>; @@ -43,44 +39,35 @@ export type PluginManagerAPI = { disable: PipeTransformer<[PluginId]>; }; -type PluginManagerContext = PluginManagerAPI & { +type PluginManagerState = PluginManagerAPI & { + loaded: PluginId[]; registry: PluginRegistry; loadedRefs: Record; toLoad: PluginId[]; toUnload: PluginId[]; }; -const pm = pluginPaths( - PLUGIN_NAMESPACE -); +const pm = pluginPaths(PLUGIN_NAMESPACE); export class PluginManager { static register(pluginClass: PluginClass): Pipe { - return Composer.bind(pm.context, ({ register }) => - register(pluginClass) - ); + return Composer.bind(pm, ({ register }) => register(pluginClass)); } static unregister(id: PluginId): Pipe { - return Composer.bind(pm.context, ({ unregister }) => - unregister(id) - ); + return Composer.bind(pm, ({ unregister }) => unregister(id)); } static enable(id: PluginId): Pipe { - return Composer.bind(pm.context, ({ enable }) => - enable(id) - ); + return Composer.bind(pm, ({ enable }) => enable(id)); } static disable(id: PluginId): Pipe { - return Composer.bind(pm.context, ({ disable }) => - disable(id) - ); + return Composer.bind(pm, ({ disable }) => disable(id)); } } -const apiPipe: Pipe = Composer.over(pm.context, ctx => ({ +const apiPipe: Pipe = Composer.over(pm, ctx => ({ ...ctx, register: plugin => @@ -130,25 +117,25 @@ const enableDisablePipe: Pipe = Composer.pipe( const reconcilePipe: Pipe = Composer.pipe( Events.handle(eventType.register, event => - Composer.do(({ over }) => { - over(pm.context.registry, registry => ({ + Composer.do(({ over }) => { + over(pm.registry, registry => ({ ...registry, [event.payload.plugin.id]: event.payload, })); }) ), Events.handle(eventType.unregister, event => - Composer.do(({ over }) => { - over(pm.context.toUnload, (ids = []) => + Composer.do(({ over }) => { + over(pm.toUnload, (ids = []) => Array.isArray(ids) ? [...ids, event.payload] : [event.payload] ); }) ), Storage.bind(storageKey.enabled, (stored = {}) => - Composer.do(({ get, set, pipe }) => { - const registry = get(pm.context.registry) ?? {}; - const loaded = get(pm.state.loaded) ?? []; - const forcedUnload = get(pm.context.toUnload) ?? []; + Composer.do(({ get, set, pipe }) => { + const registry = get(pm.registry) ?? {}; + const loaded = get(pm.loaded) ?? []; + const forcedUnload = get(pm.toUnload) ?? []; const map = { ...stored }; let dirty = false; @@ -177,17 +164,17 @@ const reconcilePipe: Pipe = Composer.pipe( if (!dirty && toLoad.length === 0 && toUnload.length === 0) return; if (dirty) pipe(Storage.set(storageKey.enabled, map)); - if (toLoad.length > 0) set(pm.context.toLoad, toLoad); - if (toUnload.length > 0) set(pm.context.toUnload, toUnload); + if (toLoad.length > 0) set(pm.toLoad, toLoad); + if (toUnload.length > 0) set(pm.toUnload, toUnload); }) ) ); -const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { - const toUnload = get(pm.context.toUnload) ?? []; - const toLoad = get(pm.context.toLoad) ?? []; - const loadedRefs = get(pm.context.loadedRefs) ?? {}; - const registry = get(pm.context.registry) ?? {}; +const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { + const toUnload = get(pm.toUnload) ?? []; + const toLoad = get(pm.toLoad) ?? []; + const loadedRefs = get(pm.loadedRefs) ?? {}; + const registry = get(pm.registry) ?? {}; for (const id of toUnload) { const cls = loadedRefs[id] ?? registry[id]; @@ -246,22 +233,22 @@ const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => { const finalizePipe: Pipe = Composer.pipe( Events.handle(eventType.unregister, event => - Composer.do(({ over }) => { - over(pm.context.registry, registry => { + Composer.do(({ over }) => { + over(pm.registry, registry => { const next = { ...registry }; delete next[event.payload]; return next; }); }) ), - Composer.do(({ get, set, over }) => { - const toUnload = get(pm.context.toUnload) ?? []; - const toLoad = get(pm.context.toLoad) ?? []; + Composer.do(({ get, set, over }) => { + const toUnload = get(pm.toUnload) ?? []; + const toLoad = get(pm.toLoad) ?? []; if (toLoad.length === 0 && toUnload.length === 0) return; - const loadedRefs = get(pm.context.loadedRefs) ?? {}; - const registry = get(pm.context.registry) ?? {}; + const loadedRefs = get(pm.loadedRefs) ?? {}; + const registry = get(pm.registry) ?? {}; const newRefs = { ...loadedRefs }; for (const id of toUnload) delete newRefs[id]; @@ -269,8 +256,8 @@ const finalizePipe: Pipe = Composer.pipe( if (registry[id]) newRefs[id] = registry[id]; } - set(pm.state.loaded, Object.keys(newRefs)); - over(pm.context, ctx => ({ + set(pm.loaded, Object.keys(newRefs)); + over(pm, ctx => ({ ...ctx, loadedRefs: newRefs, toLoad: [], diff --git a/src/engine/plugins/Plugins.ts b/src/engine/plugins/Plugins.ts index 00a8b5d..58b4a11 100644 --- a/src/engine/plugins/Plugins.ts +++ b/src/engine/plugins/Plugins.ts @@ -1,13 +1,8 @@ import { typedPath, TypedPath } from '../Lens'; import { Pipe } from '../State'; -export function pluginPaths>( - namespace: string -): { state: TypedPath; context: TypedPath } { - return { - state: typedPath(['state', namespace]), - context: typedPath(['context', namespace]), - }; +export function pluginPaths(namespace: string): TypedPath { + return typedPath([namespace]); } export type PluginId = string; diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index da19899..58c848d 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, ReactNode, useCallback } from 'react'; import { createContext } from 'use-context-selector'; -import { GameEngine, GameState, Pipe, GameContext } from '../engine'; +import { GameEngine, Pipe, GameFrame } from '../engine'; import { Events } from '../engine/pipes/Events'; import { Scheduler } from '../engine/pipes/Scheduler'; import { Perf } from '../engine/pipes/Perf'; @@ -10,13 +10,9 @@ import { Composer } from '../engine/Composer'; type GameEngineContextValue = { /** - * The current state of the game engine. + * The current game frame containing all plugin data and timing. */ - state: GameState | null; - /** - * The current game context which contains inter-pipe data and debugging information. - */ - context: GameContext | null; + frame: GameFrame | null; /** * Queue a one-shot pipe to run in the next tick only. */ @@ -36,8 +32,7 @@ type Props = { export function GameEngineProvider({ children, pipes = [] }: Props) { const engineRef = useRef(null); - const [state, setState] = useState(null); - const [context, setContext] = useState(null); + const [frame, setFrame] = useState(null); const pendingImpulseRef = useRef([]); const activeImpulseRef = useRef([]); @@ -101,8 +96,7 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { } if (ticked) { - setState(engineRef.current.getState()); - setContext(engineRef.current.getContext()); + setFrame(engineRef.current.getFrame()); } frameId = requestAnimationFrame(loop); @@ -123,7 +117,7 @@ export function GameEngineProvider({ children, pipes = [] }: Props) { }, []); return ( - + {children} ); diff --git a/src/game/SceneBridge.tsx b/src/game/SceneBridge.tsx index a5e1bd7..906fb86 100644 --- a/src/game/SceneBridge.tsx +++ b/src/game/SceneBridge.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useGameEngine } from './hooks/UseGameEngine'; -import { useGameState } from './hooks'; +import { useGameFrame } from './hooks'; import Scene from './plugins/scene'; const routeToScene: Record = { @@ -20,7 +20,7 @@ export const SceneBridge = () => { const { injectImpulse } = useGameEngine(); const location = useLocation(); const navigate = useNavigate(); - const sceneState = useGameState(Scene.paths.state) as + const sceneState = useGameFrame(Scene.paths) as | { current?: string } | undefined; diff --git a/src/game/components/GameEmergencyStop.tsx b/src/game/components/GameEmergencyStop.tsx index 838696f..defd8f1 100644 --- a/src/game/components/GameEmergencyStop.tsx +++ b/src/game/components/GameEmergencyStop.tsx @@ -1,12 +1,12 @@ import { useCallback } from 'react'; import { WaButton, WaIcon } from '@awesome.me/webawesome/dist/react'; -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import { useDispatchEvent } from '../hooks/UseDispatchEvent'; import Phase, { GamePhase } from '../plugins/phase'; import { Events } from '../../engine/pipes/Events'; export const GameEmergencyStop = () => { - const phase = useGameState(Phase.paths.state.current) ?? ''; + const phase = useGameFrame(Phase.paths.current) ?? ''; const { dispatchEvent } = useDispatchEvent(); const onStop = useCallback(() => { diff --git a/src/game/components/GameHypno.tsx b/src/game/components/GameHypno.tsx index 6045968..423e5f3 100644 --- a/src/game/components/GameHypno.tsx +++ b/src/game/components/GameHypno.tsx @@ -3,7 +3,7 @@ import { useSetting, useTranslate } from '../../settings'; import { GameHypnoType, HypnoPhrases } from '../../types'; import { useMemo } from 'react'; import { motion } from 'framer-motion'; -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import Intensity from '../plugins/intensity'; import Hypno from '../plugins/hypno'; @@ -16,8 +16,8 @@ const StyledGameHypno = motion.create(styled.div` export const GameHypno = () => { const [hypno] = useSetting('hypno'); - const { currentPhrase = 0 } = useGameState(Hypno.paths.state) ?? {}; - const { intensity = 0 } = useGameState(Intensity.paths.state) ?? {}; + const { currentPhrase = 0 } = useGameFrame(Hypno.paths) ?? {}; + const { intensity = 0 } = useGameFrame(Intensity.paths) ?? {}; const translate = useTranslate(); const phrase = useMemo(() => { diff --git a/src/game/components/GameImages.tsx b/src/game/components/GameImages.tsx index 190bc02..24a2cdf 100644 --- a/src/game/components/GameImages.tsx +++ b/src/game/components/GameImages.tsx @@ -4,7 +4,7 @@ import { motion } from 'framer-motion'; import { JoiImage } from '../../common'; import { useImagePreloader } from '../../utils'; import { ImageSize, ImageType } from '../../types'; -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import Image from '../plugins/image'; import Intensity from '../plugins/intensity'; @@ -45,8 +45,8 @@ const StyledBackgroundImage = motion.create(styled.div` `); export const GameImages = () => { - const { currentImage, nextImages = [] } = useGameState(Image.paths.state); - const { intensity } = useGameState(Intensity.paths.state); + const { currentImage, nextImages = [] } = useGameFrame(Image.paths); + const { intensity } = useGameFrame(Intensity.paths); const [videoSound] = useSetting('videoSound'); const [highRes] = useSetting('highRes'); diff --git a/src/game/components/GameInstructions.tsx b/src/game/components/GameInstructions.tsx index 599bf21..6b8c9ef 100644 --- a/src/game/components/GameInstructions.tsx +++ b/src/game/components/GameInstructions.tsx @@ -12,7 +12,7 @@ import { useMemo } from 'react'; import { DiceEvent } from '../../types'; import { ProgressBar } from '../../common'; import { WaDivider } from '@awesome.me/webawesome/dist/react'; -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import Pace from '../plugins/pace'; import Intensity from '../plugins/intensity'; import { Paws, PawLabels, pawsPath } from '../plugins/dealer'; @@ -64,7 +64,7 @@ const StyledIntensityMeter = styled.div` `; const PaceDisplay = () => { - const { pace = 0 } = useGameState(Pace.paths.state) ?? {}; + const { pace = 0 } = useGameFrame(Pace.paths) ?? {}; const [maxPace] = useSetting('maxPace'); const paceSection = useMemo(() => maxPace / 3, [maxPace]); @@ -87,7 +87,7 @@ const PaceDisplay = () => { }; const GripDisplay = () => { - const paws = useGameState(pawsPath) ?? Paws.both; + const paws = useGameFrame(pawsPath) ?? Paws.both; return ( @@ -105,7 +105,7 @@ const GripDisplay = () => { }; const IntensityDisplay = () => { - const { intensity = 0 } = useGameState(Intensity.paths.state) ?? {}; + const { intensity = 0 } = useGameFrame(Intensity.paths) ?? {}; const intensityPct = Math.round(intensity * 100); return ( diff --git a/src/game/components/GameIntensity.tsx b/src/game/components/GameIntensity.tsx index d0c6e17..056ab9a 100644 --- a/src/game/components/GameIntensity.tsx +++ b/src/game/components/GameIntensity.tsx @@ -1,4 +1,4 @@ -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import Intensity from '../plugins/intensity'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faFire } from '@fortawesome/free-solid-svg-icons'; @@ -14,7 +14,7 @@ const StyledIntensityMeter = styled.div` `; export const GameIntensity = () => { - const { intensity } = useGameState(Intensity.paths.state); + const { intensity } = useGameFrame(Intensity.paths); return ( diff --git a/src/game/components/GameMessages.tsx b/src/game/components/GameMessages.tsx index dcd7f2d..0129dfa 100644 --- a/src/game/components/GameMessages.tsx +++ b/src/game/components/GameMessages.tsx @@ -6,7 +6,7 @@ import { useTranslate } from '../../settings'; import { defaultTransition, playTone } from '../../utils'; import { GameMessage } from '../plugins/messages'; import Messages from '../plugins/messages'; -import { useGameState } from '../hooks/UseGameValue'; +import { useGameFrame } from '../hooks/UseGameFrame'; import _ from 'lodash'; import { useDispatchEvent } from '../hooks/UseDispatchEvent'; @@ -60,7 +60,7 @@ const StyledGameMessageButton = motion.create(styled.button` `); export const GameMessages = () => { - const { messages } = useGameState(Messages.paths.state); + const { messages } = useGameFrame(Messages.paths); const { dispatchEvent } = useDispatchEvent(); const translate = useTranslate(); diff --git a/src/game/components/GameMeter.tsx b/src/game/components/GameMeter.tsx index 24b929a..635bf50 100644 --- a/src/game/components/GameMeter.tsx +++ b/src/game/components/GameMeter.tsx @@ -2,7 +2,7 @@ import styled from 'styled-components'; import { useMemo } from 'react'; import { motion } from 'framer-motion'; import { defaultTransition } from '../../utils'; -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import Phase, { GamePhase } from '../plugins/phase'; import Stroke, { StrokeDirection } from '../plugins/stroke'; import Pace from '../plugins/pace'; @@ -25,9 +25,9 @@ enum MeterColor { } export const GameMeter = () => { - const { stroke } = useGameState(Stroke.paths.state) ?? {}; - const { current: phase } = useGameState(Phase.paths.state) ?? {}; - const { pace } = useGameState(Pace.paths.state) ?? {}; + const { stroke } = useGameFrame(Stroke.paths) ?? {}; + const { current: phase } = useGameFrame(Phase.paths) ?? {}; + const { pace } = useGameFrame(Pace.paths) ?? {}; const switchDuration = useMemo(() => { if (!pace || pace === 0) return 0; diff --git a/src/game/components/GamePauseMenu.tsx b/src/game/components/GamePauseMenu.tsx index 7dd48ac..5f4d869 100644 --- a/src/game/components/GamePauseMenu.tsx +++ b/src/game/components/GamePauseMenu.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { WaButton, WaDialog, WaIcon } from '@awesome.me/webawesome/dist/react'; import { nestedDialogProps, useFullscreen } from '../../utils'; -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import { useDispatchEvent } from '../hooks/UseDispatchEvent'; import Pause from '../plugins/pause'; @@ -61,7 +61,7 @@ const StyledMenuButton = styled(WaButton)` `; export const GamePauseMenu = () => { - const { paused, countdown } = useGameState(Pause.paths.state) ?? {}; + const { paused, countdown } = useGameFrame(Pause.paths) ?? {}; const [fullscreen, setFullscreen] = useFullscreen(); const { inject } = useDispatchEvent(); const navigate = useNavigate(); diff --git a/src/game/components/GameResume.tsx b/src/game/components/GameResume.tsx index e79fa59..bd34bd5 100644 --- a/src/game/components/GameResume.tsx +++ b/src/game/components/GameResume.tsx @@ -1,6 +1,6 @@ import styled from 'styled-components'; import { AnimatePresence, motion } from 'framer-motion'; -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import Pause from '../plugins/pause'; const StyledOverlay = styled(motion.div)` @@ -23,7 +23,7 @@ const display = (countdown: number) => countdown === 3 ? 'Ready?' : `${countdown}`; export const GameResume = () => { - const { countdown } = useGameState(Pause.paths.state) ?? {}; + const { countdown } = useGameFrame(Pause.paths) ?? {}; return ( diff --git a/src/game/components/GameSound.tsx b/src/game/components/GameSound.tsx index 4da6266..195dfdb 100644 --- a/src/game/components/GameSound.tsx +++ b/src/game/components/GameSound.tsx @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react'; import { playTone } from '../../utils/sound'; import { wait } from '../../utils'; -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import Phase, { GamePhase } from '../plugins/phase'; import Stroke, { StrokeDirection } from '../plugins/stroke'; export const GameSound = () => { - const { stroke } = useGameState(Stroke.paths.state) ?? {}; - const { current: phase } = useGameState(Phase.paths.state) ?? {}; + const { stroke } = useGameFrame(Stroke.paths) ?? {}; + const { current: phase } = useGameFrame(Phase.paths) ?? {}; const [currentPhase, setCurrentPhase] = useState(phase); diff --git a/src/game/components/GameVibrator.tsx b/src/game/components/GameVibrator.tsx index 3729ddf..63c7db4 100644 --- a/src/game/components/GameVibrator.tsx +++ b/src/game/components/GameVibrator.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useAutoRef, useVibratorValue, VibrationMode, wait } from '../../utils'; import { useSetting } from '../../settings'; -import { useGameState } from '../hooks'; +import { useGameFrame } from '../hooks'; import { GamePhase } from '../plugins/phase'; import Phase from '../plugins/phase'; import { StrokeDirection } from '../plugins/stroke'; @@ -10,10 +10,10 @@ import Pace from '../plugins/pace'; import Intensity from '../plugins/intensity'; export const GameVibrator = () => { - const { stroke } = useGameState(Stroke.paths.state) ?? {}; - const { intensity } = useGameState(Intensity.paths.state) ?? {}; - const { pace } = useGameState(Pace.paths.state) ?? {}; - const { current: phase } = useGameState(Phase.paths.state) ?? {}; + const { stroke } = useGameFrame(Stroke.paths) ?? {}; + const { intensity } = useGameFrame(Intensity.paths) ?? {}; + const { pace } = useGameFrame(Pace.paths) ?? {}; + const { current: phase } = useGameFrame(Phase.paths) ?? {}; const [mode] = useSetting('vibrations'); const [devices] = useVibratorValue('devices'); diff --git a/src/game/hooks/UseGameContext.tsx b/src/game/hooks/UseGameContext.tsx deleted file mode 100644 index c06cdfc..0000000 --- a/src/game/hooks/UseGameContext.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useContextSelector } from 'use-context-selector'; -import { lensFromPath, normalizePath, Path } from '../../engine/Lens'; -import { GameEngineContext } from '../GameProvider'; - -export const useGameContext = (path: Path): T => { - return useContextSelector(GameEngineContext, ctx => { - if (!ctx?.context) return {} as T; - const segments = normalizePath(path); - const effective = segments[0] === 'context' ? segments.slice(1) : segments; - return lensFromPath(effective).get(ctx.context) ?? ({} as T); - }); -}; diff --git a/src/game/hooks/UseGameValue.tsx b/src/game/hooks/UseGameFrame.tsx similarity index 53% rename from src/game/hooks/UseGameValue.tsx rename to src/game/hooks/UseGameFrame.tsx index 4b77f42..e9f211a 100644 --- a/src/game/hooks/UseGameValue.tsx +++ b/src/game/hooks/UseGameFrame.tsx @@ -2,11 +2,10 @@ import { useContextSelector } from 'use-context-selector'; import { lensFromPath, normalizePath, Path } from '../../engine/Lens'; import { GameEngineContext } from '../GameProvider'; -export const useGameState = (path: Path): T => { +export const useGameFrame = (path: Path): T => { return useContextSelector(GameEngineContext, ctx => { - if (!ctx?.state) return {} as T; + if (!ctx?.frame) return {} as T; const segments = normalizePath(path); - const effective = segments[0] === 'state' ? segments.slice(1) : segments; - return lensFromPath(effective).get(ctx.state) ?? ({} as T); + return lensFromPath(segments).get(ctx.frame) ?? ({} as T); }); }; diff --git a/src/game/hooks/index.ts b/src/game/hooks/index.ts index 1da3915..719c8bf 100644 --- a/src/game/hooks/index.ts +++ b/src/game/hooks/index.ts @@ -1,4 +1,3 @@ export * from './UseDispatchEvent'; -export * from './UseGameContext'; export * from './UseGameEngine'; -export * from './UseGameValue'; +export * from './UseGameFrame'; diff --git a/src/game/pipes/Settings.ts b/src/game/pipes/Settings.ts index 4bef91d..085f7e3 100644 --- a/src/game/pipes/Settings.ts +++ b/src/game/pipes/Settings.ts @@ -14,8 +14,8 @@ export const useSettingsPipe = (): Pipe => { return useCallback( frame => Composer.pipe( - Composer.set(['context', 'settings'], settingsRef.current), - Composer.set(['context', 'images'], imagesRef.current) + Composer.set(['settings'], settingsRef.current), + Composer.set(['images'], imagesRef.current) )(frame), [] ); diff --git a/src/game/plugins/clock.ts b/src/game/plugins/clock.ts index 2c4964b..e428803 100644 --- a/src/game/plugins/clock.ts +++ b/src/game/plugins/clock.ts @@ -13,13 +13,10 @@ const PLUGIN_ID = 'core.clock'; export type ClockState = { elapsed: number; -}; - -type ClockContext = { lastWall: number | null; }; -const clock = pluginPaths(PLUGIN_ID); +const clock = pluginPaths(PLUGIN_ID); export default class Clock { static plugin: Plugin = { @@ -28,32 +25,27 @@ export default class Clock { name: 'Clock', }, - activate: Composer.pipe( - Composer.set(clock.state, { elapsed: 0 }), - Composer.set(clock.context, { lastWall: null }) - ), + activate: Composer.set(clock, { elapsed: 0, lastWall: null }), update: Composer.pipe( Pause.whenPlaying( Composer.do(({ get, over, set }) => { const now = performance.now(); - const ctx = get(clock.context); - if (ctx?.lastWall !== null && ctx?.lastWall !== undefined) { - const delta = now - ctx.lastWall; - over(clock.state, ({ elapsed = 0 }) => ({ + const state = get(clock); + if (state?.lastWall !== null && state?.lastWall !== undefined) { + const delta = now - state.lastWall; + over(clock, ({ elapsed = 0, ...rest }) => ({ + ...rest, elapsed: elapsed + delta, })); } - set(clock.context, { lastWall: now }); + set(clock.lastWall, now); }) ), - Pause.onPause(() => Composer.set(clock.context, { lastWall: null })) + Pause.onPause(() => Composer.set(clock.lastWall, null)) ), - deactivate: Composer.pipe( - Composer.set(clock.state, undefined), - Composer.set(clock.context, undefined) - ), + deactivate: Composer.set(clock, undefined), }; static get paths() { diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts index fee90d4..e20e81d 100644 --- a/src/game/plugins/dealer.ts +++ b/src/game/plugins/dealer.ts @@ -67,7 +67,7 @@ export default class Dealer { }, activate: Composer.pipe( - Composer.set(dice.state, { busy: false, log: [] }), + Composer.set(dice, { busy: false, log: [] }), ...outcomes.flatMap(o => (o.activate ? [o.activate] : [])), roll.after(1000, 'check') ), @@ -78,7 +78,7 @@ export default class Dealer { Phase.whenPhase( GamePhase.active, Composer.do(({ get, pipe }) => { - const state = get(dice.state); + const state = get(dice); if (state.busy) return; const s = get(settings); @@ -117,9 +117,9 @@ export default class Dealer { roll.on('trigger', event => Composer.do(({ get, set, over, pipe }) => { - set(dice.state.busy, true); - const elapsed = get(Clock.paths.state)?.elapsed ?? 0; - over(dice.state.log, (log: DiceLogEntry[]) => [ + set(dice.busy, true); + const elapsed = get(Clock.paths)?.elapsed ?? 0; + over(dice.log, (log: DiceLogEntry[]) => [ ...log, { time: elapsed, event: event.payload }, ]); @@ -131,17 +131,15 @@ export default class Dealer { ...outcomes.map(o => o.update), - Events.handle(OUTCOME_DONE, () => Composer.set(dice.state.busy, false)), + Events.handle(OUTCOME_DONE, () => Composer.set(dice.busy, false)), - Phase.onLeave(GamePhase.active, () => - Composer.set(dice.state.busy, false) - ), + Phase.onLeave(GamePhase.active, () => Composer.set(dice.busy, false)), Pause.onPause(() => Scheduler.holdByPrefix(PLUGIN_ID)), Pause.onResume(() => Scheduler.releaseByPrefix(PLUGIN_ID)) ), - deactivate: Composer.set(dice.state, undefined), + deactivate: Composer.set(dice, undefined), }; static triggerOutcome(id: DiceEvent): Pipe { diff --git a/src/game/plugins/debug.ts b/src/game/plugins/debug.ts index 5718c7f..5a3bec8 100644 --- a/src/game/plugins/debug.ts +++ b/src/game/plugins/debug.ts @@ -22,9 +22,7 @@ export default class Debug { } static whenVisible(pipe: Pipe): Pipe { - return Composer.bind(debug.state, state => - Composer.when(!!state?.visible, pipe) - ); + return Composer.bind(debug, state => Composer.when(!!state?.visible, pipe)); } static plugin: Plugin = { @@ -42,15 +40,15 @@ export default class Debug { }; window.addEventListener('keydown', handler); sdk.debug = false; - set(debug.state.visible, false); + set(debug.visible, false); }), update: Composer.do(({ get, set }) => { if (!pendingToggle) return; pendingToggle = false; - const current = get(debug.state.visible); + const current = get(debug.visible); sdk.debug = !current; - set(debug.state.visible, !current); + set(debug.visible, !current); }), deactivate: Composer.do(({ set }) => { @@ -60,7 +58,7 @@ export default class Debug { } pendingToggle = false; sdk.debug = false; - set(debug.state, undefined); + set(debug, undefined); }), }; } diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts index 93cf954..4b841f3 100644 --- a/src/game/plugins/dice/climax.ts +++ b/src/game/plugins/dice/climax.ts @@ -22,7 +22,7 @@ type ClimaxState = { result: ClimaxResultType; }; -export const climax = typedPath(['state', PLUGIN_ID, 'climax']); +export const climax = typedPath([PLUGIN_ID, 'climax']); type ClimaxEndPayload = { countdown: number; denied?: boolean; ruin?: boolean }; diff --git a/src/game/plugins/dice/edge.ts b/src/game/plugins/dice/edge.ts index 332554c..b18c3ba 100644 --- a/src/game/plugins/dice/edge.ts +++ b/src/game/plugins/dice/edge.ts @@ -11,7 +11,7 @@ import { DiceOutcome, } from './types'; -export const edged = typedPath(['state', PLUGIN_ID, 'edged']); +export const edged = typedPath([PLUGIN_ID, 'edged']); const seq = Sequence.for(PLUGIN_ID, 'edge'); diff --git a/src/game/plugins/dice/randomGrip.ts b/src/game/plugins/dice/randomGrip.ts index 565fe43..bf379cb 100644 --- a/src/game/plugins/dice/randomGrip.ts +++ b/src/game/plugins/dice/randomGrip.ts @@ -17,7 +17,7 @@ export const PawLabels: Record = { both: 'Both', }; -export const pawsPath = typedPath(['state', PLUGIN_ID, 'paws']); +export const pawsPath = typedPath([PLUGIN_ID, 'paws']); const allPaws = Object.values(Paws); diff --git a/src/game/plugins/dice/types.ts b/src/game/plugins/dice/types.ts index ac8ab40..49ca81d 100644 --- a/src/game/plugins/dice/types.ts +++ b/src/game/plugins/dice/types.ts @@ -17,12 +17,9 @@ export type DiceState = { }; export const dice = pluginPaths(PLUGIN_ID); -export const paceState = typedPath(['state', 'core.pace']); -export const intensityState = typedPath([ - 'state', - 'core.intensity', -]); -export const settings = typedPath(['context', 'settings']); +export const paceState = typedPath(['core.pace']); +export const intensityState = typedPath(['core.intensity']); +export const settings = typedPath(['settings']); export const OUTCOME_DONE = Events.getKey(PLUGIN_ID, 'outcome.done'); export const outcomeDone = (): Pipe => Events.dispatch({ type: OUTCOME_DONE }); diff --git a/src/game/plugins/emergencyStop.ts b/src/game/plugins/emergencyStop.ts index 73cbb70..8734894 100644 --- a/src/game/plugins/emergencyStop.ts +++ b/src/game/plugins/emergencyStop.ts @@ -11,8 +11,8 @@ import { Settings } from '../../settings'; const PLUGIN_ID = 'core.emergencyStop'; -const intensityState = typedPath(['state', 'core.intensity']); -const settings = typedPath(['context', 'settings']); +const intensityState = typedPath(['core.intensity']); +const settings = typedPath(['settings']); type CountdownPayload = { remaining: number }; diff --git a/src/game/plugins/fps.ts b/src/game/plugins/fps.ts index 9ffb1bd..8a38667 100644 --- a/src/game/plugins/fps.ts +++ b/src/game/plugins/fps.ts @@ -12,7 +12,7 @@ type FpsContext = { tickTimestamps: number[]; }; -const fps = pluginPaths(PLUGIN_ID); +const fps = pluginPaths(PLUGIN_ID); let rafId = 0; let lastFrameTime: number | null = null; @@ -65,7 +65,7 @@ export default class Fps { const el = document.createElement('div'); el.setAttribute(ELEMENT_ATTR, PLUGIN_ID); - const visible = get(Debug.paths.state.visible); + const visible = get(Debug.paths.visible); el.style.display = visible ? '' : 'none'; document.body.appendChild(el); @@ -74,14 +74,14 @@ export default class Fps { fpsHistory = []; rafId = requestAnimationFrame(rafLoop); - set(fps.context, { el, tickTimestamps: [] }); + set(fps, { el, tickTimestamps: [] }); }), update: Composer.do(({ get, set }) => { - const ctx = get(fps.context); + const ctx = get(fps); if (!ctx) return; - const visible = get(Debug.paths.state.visible); + const visible = get(Debug.paths.visible); if (ctx.el) ctx.el.style.display = visible ? '' : 'none'; if (!visible) return; @@ -99,7 +99,7 @@ export default class Fps { if (ctx.el) ctx.el.textContent = `${Math.round(avgFps)} FPS / ${tickTimestamps.length} TPS`; - set(fps.context, { ...ctx, tickTimestamps }); + set(fps, { ...ctx, tickTimestamps }); }), deactivate: Composer.do(({ get, set }) => { @@ -108,9 +108,9 @@ export default class Fps { lastFrameTime = null; fpsHistory = []; - const el = get(fps.context)?.el; + const el = get(fps)?.el; if (el) el.remove(); - set(fps.context, undefined); + set(fps, undefined); }), }; } diff --git a/src/game/plugins/hypno.ts b/src/game/plugins/hypno.ts index e135583..640f1f7 100644 --- a/src/game/plugins/hypno.ts +++ b/src/game/plugins/hypno.ts @@ -2,7 +2,7 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine/Composer'; import { pluginPaths } from '../../engine/plugins/Plugins'; import { typedPath } from '../../engine/Lens'; -import { GameContext } from '../../engine'; +import { GameTiming } from '../../engine/State'; import { GamePhase, PhaseState } from './phase'; import Pause from './pause'; import { IntensityState } from './intensity'; @@ -18,10 +18,10 @@ export type HypnoState = { }; const hypno = pluginPaths(PLUGIN_ID); -const gameContext = typedPath(['context']); -const phaseState = typedPath(['state', 'core.phase']); -const intensityState = typedPath(['state', 'core.intensity']); -const settings = typedPath(['context', 'settings']); +const timing = typedPath([]); +const phaseState = typedPath(['core.phase']); +const intensityState = typedPath(['core.intensity']); +const settings = typedPath(['settings']); export default class Hypno { static plugin: Plugin = { @@ -30,7 +30,7 @@ export default class Hypno { name: 'Hypno', }, - activate: Composer.set(hypno.state, { + activate: Composer.set(hypno, { currentPhrase: 0, timer: 0, }), @@ -47,11 +47,11 @@ export default class Hypno { const delay = 3000 - i * 29; if (delay <= 0) return; - const delta = get(gameContext.step); - const state = get(hypno.state); + const delta = get(timing.step); + const state = get(hypno); const elapsed = state.timer + delta; if (elapsed < delay) { - set(hypno.state.timer, elapsed); + set(hypno.timer, elapsed); return; } @@ -60,7 +60,7 @@ export default class Hypno { pipe( Rand.nextInt(phrases.length, idx => - Composer.set(hypno.state, { + Composer.set(hypno, { currentPhrase: idx, timer: 0, }) @@ -69,7 +69,7 @@ export default class Hypno { }) ), - deactivate: Composer.set(hypno.state, undefined), + deactivate: Composer.set(hypno, undefined), }; static get paths() { diff --git a/src/game/plugins/image.ts b/src/game/plugins/image.ts index fdb05c8..a758ba4 100644 --- a/src/game/plugins/image.ts +++ b/src/game/plugins/image.ts @@ -46,7 +46,7 @@ export default class Image { name: 'Image', }, - activate: Composer.set(image.state, { + activate: Composer.set(image, { currentImage: undefined, seenImages: [], nextImages: [], @@ -55,7 +55,7 @@ export default class Image { update: Composer.pipe( Events.handle(eventType.pushNext, event => Composer.over( - image.state, + image, ({ currentImage, seenImages = [], nextImages = [] }) => { const newImage = event.payload; const next = [...nextImages]; @@ -86,21 +86,21 @@ export default class Image { ), Events.handle(eventType.setNextImages, event => - Composer.over(image.state, state => ({ + Composer.over(image, state => ({ ...state, nextImages: event.payload, })) ), Events.handle(eventType.setImage, event => - Composer.over(image.state, state => ({ + Composer.over(image, state => ({ ...state, currentImage: event.payload, })) ) ), - deactivate: Composer.set(image.state, undefined), + deactivate: Composer.set(image, undefined), }; static get paths() { diff --git a/src/game/plugins/intensity.ts b/src/game/plugins/intensity.ts index 124909c..012f151 100644 --- a/src/game/plugins/intensity.ts +++ b/src/game/plugins/intensity.ts @@ -3,9 +3,9 @@ import { Composer } from '../../engine/Composer'; import { pluginPaths } from '../../engine/plugins/Plugins'; import { typedPath } from '../../engine/Lens'; import { Settings } from '../../settings'; +import { GameTiming } from '../../engine/State'; import Phase, { GamePhase } from './phase'; import Pause from './pause'; -import { GameContext } from '../../engine'; declare module '../../engine/sdk' { interface PluginSDK { @@ -20,8 +20,8 @@ export type IntensityState = { }; const intensity = pluginPaths(PLUGIN_ID); -const gameContext = typedPath(['context']); -const settings = typedPath(gameContext.settings); +const timing = typedPath([]); +const settings = typedPath(['settings']); export default class Intensity { static plugin: Plugin = { @@ -30,22 +30,22 @@ export default class Intensity { name: 'Intensity', }, - activate: Composer.set(intensity.state, { intensity: 0 }), + activate: Composer.set(intensity, { intensity: 0 }), update: Pause.whenPlaying( Phase.whenPhase( GamePhase.active, Composer.do(({ get, over }) => { - const delta = get(gameContext.step); + const delta = get(timing.step); const s = get(settings); - over(intensity.state, ({ intensity: i = 0 }) => ({ + over(intensity, ({ intensity: i = 0 }) => ({ intensity: Math.min(1, i + delta / (s.gameDuration * 1000)), })); }) ) ), - deactivate: Composer.set(intensity.state, undefined), + deactivate: Composer.set(intensity, undefined), }; static get paths() { diff --git a/src/game/plugins/messages.ts b/src/game/plugins/messages.ts index c514800..6869305 100644 --- a/src/game/plugins/messages.ts +++ b/src/game/plugins/messages.ts @@ -50,12 +50,12 @@ export default class Messages { name: 'Messages', }, - activate: Composer.set(paths.state, { messages: [] }), + activate: Composer.set(paths, { messages: [] }), update: Composer.pipe( Events.handle(eventType.sendMessage, event => Composer.pipe( - Composer.over(paths.state, ({ messages }) => { + Composer.over(paths, ({ messages }) => { const patch = event.payload; const index = messages.findIndex(m => m.id === patch.id); const existing = messages[index]; @@ -72,7 +72,7 @@ export default class Messages { }), Composer.do(({ get, pipe }) => { - const { messages } = get(paths.state); + const { messages } = get(paths); const messageId = event.payload.id; const updated = messages.find(m => m.id === messageId); const scheduleId = Scheduler.getKey( @@ -99,13 +99,13 @@ export default class Messages { ), Events.handle(eventType.expireMessage, event => - Composer.over(paths.state, ({ messages }) => ({ + Composer.over(paths, ({ messages }) => ({ messages: messages.filter(m => m.id !== event.payload), })) ) ), - deactivate: Composer.set(paths.state, undefined), + deactivate: Composer.set(paths, undefined), }; static get paths() { diff --git a/src/game/plugins/pace.ts b/src/game/plugins/pace.ts index e3a6289..2161b80 100644 --- a/src/game/plugins/pace.ts +++ b/src/game/plugins/pace.ts @@ -3,7 +3,7 @@ import { Pipe } from '../../engine/State'; import { typedPath } from '../../engine/Lens'; import { Settings } from '../../settings'; import { Composer, pluginPaths } from '../../engine'; -import Clock, { ClockState } from './clock'; +import Clock from './clock'; declare module '../../engine/sdk' { interface PluginSDK { @@ -23,18 +23,15 @@ export type PaceState = { }; const pace = pluginPaths(PLUGIN_ID); -const settings = typedPath(['context', 'settings']); -const clockState = typedPath(Clock.paths.state); +const settings = typedPath(['settings']); export default class Pace { static setPace(val: number): Pipe { - return Composer.set(pace.state.pace, val); + return Composer.set(pace.pace, val); } static resetPace(): Pipe { - return Composer.bind(settings, s => - Composer.set(pace.state.pace, s.minPace) - ); + return Composer.bind(settings, s => Composer.set(pace.pace, s.minPace)); } static plugin: Plugin = { @@ -44,7 +41,7 @@ export default class Pace { }, activate: Composer.bind(settings, s => - Composer.set(pace.state, { + Composer.set(pace, { pace: s.minPace, prevMinPace: s.minPace, prevPace: s.minPace, @@ -53,25 +50,25 @@ export default class Pace { ), update: Composer.do(({ get, set, over }) => { - const state = get(pace.state); + const state = get(pace); const { minPace } = get(settings); if (minPace !== state.prevMinPace) { - set(pace.state.prevMinPace, minPace); - set(pace.state.pace, minPace); + set(pace.prevMinPace, minPace); + set(pace.pace, minPace); } if (state.pace !== state.prevPace) { - const { elapsed } = get(clockState) ?? { elapsed: 0 }; - set(pace.state.prevPace, state.pace); - over(pace.state.history, (h: PaceEntry[]) => [ + const { elapsed } = get(Clock.paths) ?? { elapsed: 0 }; + set(pace.prevPace, state.pace); + over(pace.history, (h: PaceEntry[]) => [ ...h, { time: elapsed, pace: state.pace }, ]); } }), - deactivate: Composer.set(pace.state, undefined), + deactivate: Composer.set(pace, undefined), }; static get paths() { diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts index cbdf9e4..c71d797 100644 --- a/src/game/plugins/pause.ts +++ b/src/game/plugins/pause.ts @@ -31,19 +31,15 @@ export default class Pause { } static get togglePause(): Pipe { - return Composer.bind(paths.state, state => Pause.setPaused(!state?.paused)); + return Composer.bind(paths, state => Pause.setPaused(!state?.paused)); } static whenPaused(pipe: Pipe): Pipe { - return Composer.bind(paths.state, state => - Composer.when(!!state?.paused, pipe) - ); + return Composer.bind(paths, state => Composer.when(!!state?.paused, pipe)); } static whenPlaying(pipe: Pipe): Pipe { - return Composer.bind(paths.state, state => - Composer.when(!state?.paused, pipe) - ); + return Composer.bind(paths, state => Composer.when(!state?.paused, pipe)); } static onPause(fn: () => Pipe): Pipe { @@ -60,7 +56,7 @@ export default class Pause { name: 'Pause', }, - activate: Composer.set(paths.state, { + activate: Composer.set(paths, { paused: true, prev: true, countdown: null, @@ -72,25 +68,25 @@ export default class Pause { Scene.onLeave('game', () => Pause.setPaused(true)), Composer.do(({ get, set, pipe }) => { - const { paused, prev } = get(paths.state); + const { paused, prev } = get(paths); if (paused === prev) return; - set(paths.state.prev, paused); + set(paths.prev, paused); pipe(Events.dispatch({ type: paused ? eventType.on : eventType.off })); }), seq.on(event => - Composer.bind(paths.state.gen, (gen = 0) => { + Composer.bind(paths.gen, (gen = 0) => { const next = gen + 1; return Composer.when( event.payload.paused, Composer.pipe( - Composer.set(paths.state.gen, next), - Composer.set(paths.state.paused, true), - Composer.set(paths.state.countdown, null) + Composer.set(paths.gen, next), + Composer.set(paths.paused, true), + Composer.set(paths.countdown, null) ), Composer.pipe( - Composer.set(paths.state.gen, next), - Composer.set(paths.state.countdown, 3), + Composer.set(paths.gen, next), + Composer.set(paths.countdown, 3), seq.after(1000, 'countdown', { remaining: 2, gen: next }) ) ); @@ -98,17 +94,17 @@ export default class Pause { ), seq.on('countdown', event => - Composer.bind(paths.state.gen, gen => + Composer.bind(paths.gen, gen => Composer.when( event.payload.gen === gen, Composer.when( event.payload.remaining <= 0, Composer.pipe( - Composer.set(paths.state.countdown, null), - Composer.set(paths.state.paused, false) + Composer.set(paths.countdown, null), + Composer.set(paths.paused, false) ), Composer.pipe( - Composer.set(paths.state.countdown, event.payload.remaining), + Composer.set(paths.countdown, event.payload.remaining), seq.after(1000, 'countdown', { remaining: event.payload.remaining - 1, gen, @@ -120,7 +116,7 @@ export default class Pause { ) ), - deactivate: Composer.set(paths.state, undefined), + deactivate: Composer.set(paths, undefined), }; static get paths() { diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts index 0c41b46..d50e256 100644 --- a/src/game/plugins/perf.ts +++ b/src/game/plugins/perf.ts @@ -1,5 +1,5 @@ import type { Plugin } from '../../engine/plugins/Plugins'; -import type { PerfMetrics } from '../../engine/pipes/Perf'; +import type { PluginPerfEntry } from '../../engine/pipes/Perf'; import { Composer } from '../../engine/Composer'; import { Perf } from '../../engine/pipes/Perf'; import { pluginPaths } from '../../engine/plugins/Plugins'; @@ -13,7 +13,7 @@ type PerfOverlayContext = { el: HTMLElement; }; -const po = pluginPaths(PLUGIN_ID); +const po = pluginPaths(PLUGIN_ID); const COLOR_OK = [0x4a, 0xde, 0x80] as const; const COLOR_WARN = [0xfa, 0xcc, 0x15] as const; @@ -88,22 +88,22 @@ export default class PerfOverlay { const el = document.createElement('div'); el.setAttribute(ELEMENT_ATTR, PLUGIN_ID); - const visible = get(Debug.paths.state.visible); + const visible = get(Debug.paths.visible); el.style.display = visible ? '' : 'none'; document.body.appendChild(el); - set(po.context, { el }); + set(po, { el }); }), update: Composer.do(({ get }) => { - const el = get(po.context)?.el; + const el = get(po)?.el; if (!el) return; - const visible = get(Debug.paths.state.visible); + const visible = get(Debug.paths.visible); el.style.display = visible ? '' : 'none'; if (!visible) return; - const ctx = get(Perf.paths.context); + const ctx = get(Perf.paths); if (!ctx) return; const { plugins, config } = ctx; @@ -111,14 +111,22 @@ export default class PerfOverlay { let totalAvg = 0; - for (const [id, phases] of Object.entries(plugins as PerfMetrics)) { - if (id === PLUGIN_ID) continue; - for (const [phase, entry] of Object.entries(phases)) { - if (!entry) continue; - totalAvg += entry.avg; - lines.push(formatLine(id, phase, entry.avg, config.pluginBudget)); + const walk = (node: Record, prefix: string[]) => { + for (const [key, value] of Object.entries(node)) { + if (!value || typeof value !== 'object') continue; + if ('lastTick' in value) { + const id = prefix.join('.'); + if (id === PLUGIN_ID) continue; + const entry = value as PluginPerfEntry; + totalAvg += entry.avg; + lines.push(formatLine(id, key, entry.avg, config.pluginBudget)); + } else { + walk(value, [...prefix, key]); + } } - } + }; + + walk(plugins as Record, []); if (lines.length > 0) { const totalColor = budgetColor( @@ -138,9 +146,9 @@ export default class PerfOverlay { }), deactivate: Composer.do(({ get, set }) => { - const el = get(po.context)?.el; + const el = get(po)?.el; if (el) el.remove(); - set(po.context, undefined); + set(po, undefined); }), }; } diff --git a/src/game/plugins/phase.ts b/src/game/plugins/phase.ts index 431c59c..843d3e6 100644 --- a/src/game/plugins/phase.ts +++ b/src/game/plugins/phase.ts @@ -33,11 +33,11 @@ const eventType = { export default class Phase { static setPhase(p: string): Pipe { - return Composer.set(phase.state.current, p); + return Composer.set(phase.current, p); } static whenPhase(p: string, pipe: Pipe): Pipe { - return Composer.bind(phase.state, state => + return Composer.bind(phase, state => Composer.when(state?.current === p, pipe) ); } @@ -56,20 +56,20 @@ export default class Phase { name: 'Phase', }, - activate: Composer.set(phase.state, { + activate: Composer.set(phase, { current: GamePhase.warmup, prev: GamePhase.warmup, }), update: Composer.do(({ get, set, pipe }) => { - const { current, prev } = get(phase.state); + const { current, prev } = get(phase); if (current === prev) return; - set(phase.state.prev, current); + set(phase.prev, current); pipe(Events.dispatch({ type: eventType.leave(prev) })); pipe(Events.dispatch({ type: eventType.enter(current) })); }), - deactivate: Composer.set(phase.state, undefined), + deactivate: Composer.set(phase, undefined), }; static get paths() { diff --git a/src/game/plugins/rand.ts b/src/game/plugins/rand.ts index ae7a8ac..a2b3144 100644 --- a/src/game/plugins/rand.ts +++ b/src/game/plugins/rand.ts @@ -40,16 +40,16 @@ export default class Rand { activate: Composer.do(({ set }) => { const seed = Date.now().toString(); - set(paths.state, { seed, cursor: stringToSeed(seed) }); + set(paths, { seed, cursor: stringToSeed(seed) }); }), - deactivate: Composer.set(paths.state, undefined), + deactivate: Composer.set(paths, undefined), }; static next(fn: (value: number) => Pipe): Pipe { return Composer.do(({ get, set, pipe }) => { - const [cursor, value] = advance(get(paths.state.cursor)); - set(paths.state.cursor, cursor); + const [cursor, value] = advance(get(paths.cursor)); + set(paths.cursor, cursor); pipe(fn(value)); }); } @@ -72,7 +72,7 @@ export default class Rand { static shuffle(arr: T[], fn: (shuffled: T[]) => Pipe): Pipe { return Composer.do(({ get, set, pipe }) => { - let cursor = get(paths.state.cursor); + let cursor = get(paths.cursor); const shuffled = [...arr]; for (let i = shuffled.length - 1; i > 0; i--) { const [c, v] = advance(cursor); @@ -80,7 +80,7 @@ export default class Rand { const j = Math.floor(v * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } - set(paths.state.cursor, cursor); + set(paths.cursor, cursor); pipe(fn(shuffled)); }); } diff --git a/src/game/plugins/randomImages.ts b/src/game/plugins/randomImages.ts index 0785fb9..8d8b1ad 100644 --- a/src/game/plugins/randomImages.ts +++ b/src/game/plugins/randomImages.ts @@ -16,9 +16,9 @@ declare module '../../engine/sdk' { const PLUGIN_ID = 'core.random_images'; -const images = typedPath(['context', 'images']); -const intensityState = typedPath(['state', 'core.intensity']); -const imageState = typedPath(['state', 'core.images']); +const images = typedPath(['images']); +const intensityState = typedPath(['core.intensity']); +const imageState = typedPath(['core.images']); const eventType = Events.getKeys(PLUGIN_ID, 'schedule_next'); diff --git a/src/game/plugins/scene.ts b/src/game/plugins/scene.ts index 78b413a..4b54c3a 100644 --- a/src/game/plugins/scene.ts +++ b/src/game/plugins/scene.ts @@ -26,11 +26,11 @@ const eventType = { export default class Scene { static setScene(s: string): Pipe { - return Composer.set(scene.state.current, s); + return Composer.set(scene.current, s); } static whenScene(s: string, pipe: Pipe): Pipe { - return Composer.bind(scene.state, state => + return Composer.bind(scene, state => Composer.when(state?.current === s, pipe) ); } @@ -49,20 +49,20 @@ export default class Scene { name: 'Scene', }, - activate: Composer.set(scene.state, { + activate: Composer.set(scene, { current: 'unknown', prev: 'unknown', }), update: Composer.do(({ get, set, pipe }) => { - const { current, prev } = get(scene.state); + const { current, prev } = get(scene); if (current === prev) return; - set(scene.state.prev, current); + set(scene.prev, current); pipe(Events.dispatch({ type: eventType.leave(prev) })); pipe(Events.dispatch({ type: eventType.enter(current) })); }), - deactivate: Composer.set(scene.state, undefined), + deactivate: Composer.set(scene, undefined), }; static get paths() { diff --git a/src/game/plugins/stroke.ts b/src/game/plugins/stroke.ts index accbf26..0a3a179 100644 --- a/src/game/plugins/stroke.ts +++ b/src/game/plugins/stroke.ts @@ -2,7 +2,7 @@ import type { Plugin } from '../../engine/plugins/Plugins'; import { Composer } from '../../engine/Composer'; import { pluginPaths } from '../../engine/plugins/Plugins'; import { typedPath } from '../../engine/Lens'; -import { GameContext } from '../../engine'; +import { GameTiming } from '../../engine/State'; import { PhaseState, GamePhase } from './phase'; import Pause from './pause'; import { PaceState } from './pace'; @@ -26,9 +26,9 @@ export type StrokeState = { }; const stroke = pluginPaths(PLUGIN_ID); -const gameContext = typedPath(['context']); -const phaseState = typedPath(['state', 'core.phase']); -const paceState = typedPath(['state', 'core.pace']); +const timing = typedPath([]); +const phaseState = typedPath(['core.phase']); +const paceState = typedPath(['core.pace']); export default class Stroke { static plugin: Plugin = { @@ -37,7 +37,7 @@ export default class Stroke { name: 'Stroke', }, - activate: Composer.set(stroke.state, { + activate: Composer.set(stroke, { stroke: StrokeDirection.up, timer: 0, }), @@ -50,15 +50,15 @@ export default class Stroke { const pace = get(paceState)?.pace; if (!pace || pace <= 0) return; - const delta = get(gameContext.step); - const state = get(stroke.state); + const delta = get(timing.step); + const state = get(stroke); if (!state) return; const interval = (1 / pace) * 1000; const elapsed = state.timer + delta; if (elapsed >= interval) { - set(stroke.state, { + set(stroke, { stroke: state.stroke === StrokeDirection.up ? StrokeDirection.down @@ -66,12 +66,12 @@ export default class Stroke { timer: elapsed - interval, }); } else { - set(stroke.state.timer, elapsed); + set(stroke.timer, elapsed); } }) ), - deactivate: Composer.set(stroke.state, undefined), + deactivate: Composer.set(stroke, undefined), }; static get paths() { diff --git a/src/game/plugins/warmup.ts b/src/game/plugins/warmup.ts index f6e9ddc..9896b35 100644 --- a/src/game/plugins/warmup.ts +++ b/src/game/plugins/warmup.ts @@ -5,7 +5,6 @@ import Messages from './messages'; import { Scheduler } from '../../engine/pipes/Scheduler'; import { typedPath } from '../../engine/Lens'; import { Settings } from '../../settings'; -import { GameContext } from '../../engine'; import Phase, { GamePhase } from './phase'; import Pause from './pause'; @@ -22,8 +21,7 @@ type WarmupState = { }; const warmup = pluginPaths(PLUGIN_ID); -const gameContext = typedPath(['context']); -const settings = typedPath(gameContext.settings); +const settings = typedPath(['settings']); const AUTOSTART_KEY = Scheduler.getKey(PLUGIN_ID, 'autoStart'); @@ -36,7 +34,7 @@ export default class Warmup { name: 'Warmup', }, - activate: Composer.set(warmup.state, { initialized: false }), + activate: Composer.set(warmup, { initialized: false }), update: Composer.pipe( Pause.whenPlaying( @@ -50,10 +48,10 @@ export default class Warmup { return; } - const state = get(warmup.state); + const state = get(warmup); if (state.initialized) return; - set(warmup.state.initialized, true); + set(warmup.initialized, true); pipe( Messages.send({ id: GamePhase.warmup, @@ -80,7 +78,7 @@ export default class Warmup { Events.handle(eventType.startGame, () => Composer.pipe( Scheduler.cancel(AUTOSTART_KEY), - Composer.set(warmup.state, { initialized: false }), + Composer.set(warmup, { initialized: false }), Messages.send({ id: GamePhase.warmup, title: 'Now follow what I say, $player!', @@ -95,6 +93,6 @@ export default class Warmup { Pause.onResume(() => Scheduler.release(AUTOSTART_KEY)) ), - deactivate: Composer.set(warmup.state, undefined), + deactivate: Composer.set(warmup, undefined), }; } diff --git a/tests/engine/Engine.test.ts b/tests/engine/Engine.test.ts index b692050..d03080e 100644 --- a/tests/engine/Engine.test.ts +++ b/tests/engine/Engine.test.ts @@ -4,93 +4,93 @@ import { GameFrame, Pipe } from '../../src/engine/State'; describe('GameEngine', () => { it('should initialize with given state', () => { - const initialState = { foo: 'bar' }; + const initial = { foo: 'bar' }; const pipe: Pipe = frame => frame; - const engine = new GameEngine(initialState, pipe); + const engine = new GameEngine(initial, pipe); - expect(engine.getState()).toEqual({ foo: 'bar' }); + expect(engine.getFrame().foo).toBe('bar'); }); it('should increment tick on each tick', () => { const engine = new GameEngine({}, frame => frame); engine.tick(); - expect(engine.getContext().tick).toBe(1); + expect(engine.getFrame().tick).toBe(1); engine.tick(); - expect(engine.getContext().tick).toBe(2); + expect(engine.getFrame().tick).toBe(2); }); it('should accumulate time by fixed step', () => { const engine = new GameEngine({}, frame => frame); engine.tick(); - expect(engine.getContext().time).toBe(16); + expect(engine.getFrame().time).toBe(16); engine.tick(); - expect(engine.getContext().time).toBe(32); + expect(engine.getFrame().time).toBe(32); }); it('should use default step of 16ms', () => { const engine = new GameEngine({}, frame => frame); engine.tick(); - expect(engine.getContext().step).toBe(16); + expect(engine.getFrame().step).toBe(16); }); it('should accept custom step size', () => { const engine = new GameEngine({}, frame => frame, { step: 8 }); engine.tick(); - expect(engine.getContext().step).toBe(8); - expect(engine.getContext().time).toBe(8); + expect(engine.getFrame().step).toBe(8); + expect(engine.getFrame().time).toBe(8); engine.tick(); - expect(engine.getContext().time).toBe(16); + expect(engine.getFrame().time).toBe(16); }); - it('should pass state through pipe', () => { + it('should pass data through pipe', () => { const pipe: Pipe = (frame: GameFrame) => ({ ...frame, - state: { ...frame.state, modified: true }, + modified: true, }); const engine = new GameEngine({}, pipe); engine.tick(); - expect(engine.getState()).toEqual({ modified: true }); + expect(engine.getFrame().modified).toBe(true); }); - it('should produce new state references per tick', () => { + it('should produce new references per tick', () => { const pipe: Pipe = (frame: GameFrame) => ({ ...frame, - state: { ...frame.state, nested: { value: 42 } }, + nested: { value: 42 }, }); const engine = new GameEngine({}, pipe); engine.tick(); - const state1 = engine.getState(); + const frame1 = engine.getFrame(); engine.tick(); - const state2 = engine.getState(); + const frame2 = engine.getFrame(); - expect(state1.nested).not.toBe(state2.nested); - expect(state1.nested).toEqual(state2.nested); + expect(frame1.nested).not.toBe(frame2.nested); + expect(frame1.nested).toEqual(frame2.nested); }); - it('should freeze state in dev mode', () => { + it('should freeze frame in dev mode', () => { const pipe: Pipe = (frame: GameFrame) => ({ ...frame, - state: { ...frame.state, nested: { value: 42 } }, + nested: { value: 42 }, }); const engine = new GameEngine({}, pipe); engine.tick(); - const state = engine.getState(); + const frame = engine.getFrame(); expect(() => { - state.nested.value = 99; + frame.nested.value = 99; }).toThrow(); - expect(state.nested.value).toBe(42); + expect(frame.nested.value).toBe(42); }); }); diff --git a/tests/engine/pipes/Errors.test.ts b/tests/engine/pipes/Errors.test.ts index 4380642..eb476f9 100644 --- a/tests/engine/pipes/Errors.test.ts +++ b/tests/engine/pipes/Errors.test.ts @@ -14,9 +14,7 @@ const getEntry = ( pluginId: string, phase: string ): ErrorEntry | undefined => - lensFromPath(['context', 'core.errors', 'plugins', pluginId, phase]).get( - frame - ); + lensFromPath(['core.errors', 'plugins', pluginId, phase]).get(frame); describe('Errors', () => { beforeEach(() => { diff --git a/tests/engine/pipes/Perf.test.ts b/tests/engine/pipes/Perf.test.ts index 007bae6..392784b 100644 --- a/tests/engine/pipes/Perf.test.ts +++ b/tests/engine/pipes/Perf.test.ts @@ -18,10 +18,10 @@ const getEntry = ( pluginId: string, phase: string ): PluginPerfEntry | undefined => - lensFromPath(['context', 'core.perf', 'plugins', pluginId, phase]).get(frame); + lensFromPath(['core.perf', 'plugins', pluginId, phase]).get(frame); const getConfig = (frame: GameFrame): PerfConfig | undefined => - lensFromPath(['context', 'core.perf', 'config']).get(frame); + lensFromPath(['core.perf', 'config']).get(frame); describe('Perf', () => { beforeEach(() => { @@ -177,7 +177,7 @@ describe('Perf', () => { ); const frame1 = pipe(makeFrame()); - const pending = (frame1.state as any)?.core?.events?.pending ?? []; + const pending = frame1?.core?.events?.pending ?? []; const overBudgetEvents = pending.filter( (e: any) => e.type === 'core.perf/over_budget' ); @@ -196,7 +196,7 @@ describe('Perf', () => { ); const frame1 = pipe(makeFrame()); - const pending = (frame1.state as any)?.core?.events?.pending ?? []; + const pending = frame1?.core?.events?.pending ?? []; const overBudgetEvents = pending.filter( (e: any) => e.type === 'core.perf/over_budget' ); diff --git a/tests/engine/pipes/Storage.test.ts b/tests/engine/pipes/Storage.test.ts index 6ba7363..f852994 100644 --- a/tests/engine/pipes/Storage.test.ts +++ b/tests/engine/pipes/Storage.test.ts @@ -23,10 +23,7 @@ describe('Storage', () => { return (frame: GameFrame) => frame; }); - const frame: GameFrame = { - state: {}, - context: { tick: 0, step: 0, time: 0 }, - }; + const frame: GameFrame = { tick: 0, step: 0, time: 0 }; pipe(frame); expect(result).toEqual({ value: 42 }); @@ -39,10 +36,7 @@ describe('Storage', () => { return (frame: GameFrame) => frame; }); - const frame: GameFrame = { - state: {}, - context: { tick: 0, step: 0, time: 0 }, - }; + const frame: GameFrame = { tick: 0, step: 0, time: 0 }; pipe(frame); expect(result).toBeUndefined(); @@ -55,13 +49,10 @@ describe('Storage', () => { return (frame: GameFrame) => frame; }); - let frame1: GameFrame = { - state: {}, - context: { tick: 0, step: 0, time: 1000 }, - }; + let frame1: GameFrame = { tick: 0, step: 0, time: 1000 }; frame1 = Composer.over>( - ['context', STORAGE_NAMESPACE], + [STORAGE_NAMESPACE], (storage = {}) => ({ cache: {}, ...storage, @@ -71,13 +62,10 @@ describe('Storage', () => { pipe(frame1); expect(callCount).toBe(1); - let frame2: GameFrame = { - state: {}, - context: { tick: 1, step: 16, time: 1016 }, - }; + let frame2: GameFrame = { tick: 1, step: 16, time: 1016 }; frame2 = Composer.over>( - ['context', STORAGE_NAMESPACE], + [STORAGE_NAMESPACE], (storage = {}) => ({ cache: { 'test.key': { @@ -98,10 +86,7 @@ describe('Storage', () => { it('should write to localStorage', () => { const pipe = Storage.set('test.key', { foo: 'bar' }); - const frame: GameFrame = { - state: {}, - context: { tick: 0, step: 0, time: 0 }, - }; + const frame: GameFrame = { tick: 0, step: 0, time: 0 }; pipe(frame); @@ -112,13 +97,10 @@ describe('Storage', () => { it('should update cache', () => { const pipe = Storage.set('test.key', 'value'); - let frame: GameFrame = { - state: {}, - context: { tick: 0, step: 0, time: 1000 }, - }; + let frame: GameFrame = { tick: 0, step: 0, time: 1000 }; frame = Composer.over>( - ['context', STORAGE_NAMESPACE], + [STORAGE_NAMESPACE], (storage = {}) => ({ cache: {}, ...storage, @@ -127,7 +109,7 @@ describe('Storage', () => { const result = pipe(frame); - const storage = result.context.how.joi.storage; + const storage = result.how.joi.storage; expect(storage.cache['test.key'].value).toBe('value'); expect(storage.cache['test.key'].expiry).toBe(31000); }); @@ -139,10 +121,7 @@ describe('Storage', () => { const pipe = Storage.remove('test.key'); - const frame: GameFrame = { - state: {}, - context: { tick: 0, step: 0, time: 0 }, - }; + const frame: GameFrame = { tick: 0, step: 0, time: 0 }; pipe(frame); @@ -152,13 +131,10 @@ describe('Storage', () => { it('should remove from cache', () => { const pipe = Storage.remove('test.key'); - let frame: GameFrame = { - state: {}, - context: { tick: 0, step: 0, time: 0 }, - }; + let frame: GameFrame = { tick: 0, step: 0, time: 0 }; frame = Composer.over>( - ['context', STORAGE_NAMESPACE], + [STORAGE_NAMESPACE], (storage = {}) => ({ cache: { 'test.key': { value: 'foo', expiry: 1000 }, @@ -169,20 +145,17 @@ describe('Storage', () => { const result = pipe(frame); - const storage = result.context.how.joi.storage; + const storage = result.how.joi.storage; expect(storage.cache['test.key']).toBeUndefined(); }); }); describe('Storage.pipe', () => { it('should clean up expired cache entries', () => { - let frame: GameFrame = { - state: {}, - context: { tick: 0, step: 0, time: 20000 }, - }; + let frame: GameFrame = { tick: 0, step: 0, time: 20000 }; frame = Composer.over>( - ['context', STORAGE_NAMESPACE], + [STORAGE_NAMESPACE], (storage = {}) => ({ cache: { 'expired.key': { value: 'old', expiry: 10000 }, @@ -194,7 +167,7 @@ describe('Storage', () => { const result = Storage.pipe(frame); - const storage = result.context.how.joi.storage; + const storage = result.how.joi.storage; expect(storage.cache['expired.key']).toBeUndefined(); expect(storage.cache['valid.key']).toBeDefined(); expect(storage.cache['valid.key'].value).toBe('new'); diff --git a/tests/engine/plugins/PluginInstaller.test.ts b/tests/engine/plugins/PluginInstaller.test.ts index cef8226..4c99453 100644 --- a/tests/engine/plugins/PluginInstaller.test.ts +++ b/tests/engine/plugins/PluginInstaller.test.ts @@ -17,13 +17,13 @@ const fullPipe: Pipe = Composer.pipe( ); const getPending = (frame: GameFrame): Map | undefined => - (frame.context as any)?.core?.plugin_installer?.pending; + frame?.core?.plugin_installer?.pending; const getInstalledIds = (frame: GameFrame): string[] => - (frame.state as any)?.core?.plugin_installer?.installed ?? []; + frame?.core?.plugin_installer?.installed ?? []; const getLoadedIds = (frame: GameFrame): string[] => - (frame.state as any)?.core?.plugin_manager?.loaded ?? []; + frame?.core?.plugin_manager?.loaded ?? []; function makeLoadResult(plugin: Plugin, name: string) { const cls = { plugin, name } as PluginClass; @@ -84,7 +84,7 @@ describe('Plugin Installer', () => { const result = pluginInstallerPipe( makeFrame({ - state: { core: { plugin_installer: { installed: ['user.test'] } } }, + core: { plugin_installer: { installed: ['user.test'] } }, }) ); @@ -104,7 +104,7 @@ describe('Plugin Installer', () => { const frame0 = fullPipe(makeFrame()); const frame1 = Composer.set( - ['context', 'core', 'plugin_installer', 'pending'], + ['core', 'plugin_installer', 'pending'], new Map([['user.resolved', makeLoadResult(testPlugin, 'TestPlugin')]]) )(frame0); @@ -124,7 +124,7 @@ describe('Plugin Installer', () => { const frame0 = fullPipe(makeFrame()); const frame1 = Composer.set( - ['context', 'core', 'plugin_installer', 'pending'], + ['core', 'plugin_installer', 'pending'], new Map([['user.sdk', makeLoadResult(testPlugin, 'TestPlugin')]]) )(frame0); @@ -141,7 +141,7 @@ describe('Plugin Installer', () => { const frame0 = fullPipe(makeFrame()); const frame1 = Composer.set( - ['context', 'core', 'plugin_installer', 'pending'], + ['core', 'plugin_installer', 'pending'], new Map([ [ 'user.broken', diff --git a/tests/engine/plugins/PluginManager.test.ts b/tests/engine/plugins/PluginManager.test.ts index 3e415fb..a5e5b24 100644 --- a/tests/engine/plugins/PluginManager.test.ts +++ b/tests/engine/plugins/PluginManager.test.ts @@ -18,10 +18,10 @@ const PLUGIN_NAMESPACE = 'core.plugin_manager'; const gamePipe: Pipe = Composer.pipe(Events.pipe, pluginManagerPipe); const getLoadedIds = (frame: GameFrame): string[] => - (frame.state as any)?.core?.plugin_manager?.loaded ?? []; + frame?.core?.plugin_manager?.loaded ?? []; const getLoadedRefs = (frame: GameFrame): Record => - (frame.context as any)?.core?.plugin_manager?.loadedRefs ?? {}; + frame?.core?.plugin_manager?.loadedRefs ?? {}; const makePluginClass = (plugin: Plugin): PluginClass => ({ plugin, diff --git a/tests/game/plugins/pause.test.ts b/tests/game/plugins/pause.test.ts index 681ca97..c281fce 100644 --- a/tests/game/plugins/pause.test.ts +++ b/tests/game/plugins/pause.test.ts @@ -46,11 +46,11 @@ function bootstrap(): GameFrame { } function getScheduled(frame: GameFrame): ScheduledEvent[] { - return (frame.state as any)?.core?.scheduler?.scheduled ?? []; + return frame?.core?.scheduler?.scheduled ?? []; } function getPauseState(frame: GameFrame): PauseState | undefined { - return (frame.state as any)?.core?.pause; + return frame?.core?.pause; } function getDealerScheduled(frame: GameFrame): ScheduledEvent[] { diff --git a/tests/utils.ts b/tests/utils.ts index 9bc8498..2042ba6 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,19 +1,15 @@ -import type { GameFrame, GameContext, GameState } from '../src/engine/State'; +import type { GameFrame } from '../src/engine/State'; -export const makeFrame = (overrides?: { - state?: GameState; - context?: Partial; -}): GameFrame => ({ - state: overrides?.state ?? {}, - context: { tick: 0, step: 16, time: 0, ...overrides?.context }, +export const makeFrame = (overrides?: Partial): GameFrame => ({ + tick: 0, + step: 16, + time: 0, + ...overrides, }); export const tick = (frame: GameFrame, step = 16): GameFrame => ({ ...frame, - context: { - ...frame.context, - tick: frame.context.tick + 1, - step, - time: frame.context.time + step, - }, + tick: frame.tick + 1, + step, + time: frame.time + step, });