diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 86794dae..1929e3d6 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', }, }; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..408c048e --- /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/.gitignore b/.gitignore index a547bf36..de9ee66e 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 803cd039..7cff39ee 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,11 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "format": "prettier --write .", + "format": "prettier --write src/ tests/", "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", @@ -54,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", @@ -61,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/app/App.tsx b/src/app/App.tsx index ce5230cd..42a3104f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,18 +1,25 @@ 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 { SceneBridge } from '../game/SceneBridge'; import '@awesome.me/webawesome/dist/styles/webawesome.css'; export const App = () => { return ( - - - } /> - } /> - - + + + + + } /> + } /> + } /> + + + ); }; diff --git a/src/common/ToggleTile.tsx b/src/common/ToggleTile.tsx index 514ad22e..46c564d3 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); diff --git a/src/end/ClimaxResult.tsx b/src/end/ClimaxResult.tsx new file mode 100644 index 00000000..636f45cc --- /dev/null +++ b/src/end/ClimaxResult.tsx @@ -0,0 +1,44 @@ +import styled from 'styled-components'; +import { useGameFrame } 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 = useGameFrame(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 00000000..0dcd45a6 --- /dev/null +++ b/src/end/EndPage.tsx @@ -0,0 +1,159 @@ +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 { useGameFrame } 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'; + +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 { injectImpulse } = useGameEngine(); + const clockState = useGameFrame(Clock.paths) as + | { elapsed?: number } + | undefined; + const randState = useGameFrame(Rand.paths) as { seed?: string } | undefined; + + const displayTime = + typeof clockState?.elapsed === 'number' ? clockState.elapsed : 0; + const seed = randState?.seed ?? ''; + + return ( + + Game Over + + + + + + {formatTime(displayTime)} + Play time + + + + {seed} + + Seed + + + + + + + injectImpulse(Scene.setScene('home'))} + > + Finish + + + + ); +}; diff --git a/src/end/GameTimeline.tsx b/src/end/GameTimeline.tsx new file mode 100644 index 00000000..01c9a536 --- /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 { useGameFrame } 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 = useGameFrame(Pace.paths) as + | { history?: PaceEntry[] } + | undefined; + const diceState = useGameFrame(Dealer.paths) as + | { log?: DiceLogEntry[] } + | undefined; + const clockState = useGameFrame(Clock.paths) 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 00000000..0847c681 --- /dev/null +++ b/src/end/index.ts @@ -0,0 +1 @@ +export * from './EndPage'; diff --git a/src/engine/Composer.ts b/src/engine/Composer.ts new file mode 100644 index 00000000..020079c9 --- /dev/null +++ b/src/engine/Composer.ts @@ -0,0 +1,284 @@ +import { lensFromPath, Path } from './Lens'; +import { deepFreeze } from './freeze'; + +/** + * 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; + +export type Compositor = ( + composer: Composer +) => Composer; + +export type ComposerScope = { + [K in keyof Composer as Composer[K] extends (...args: any[]) => any + ? K + : 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, fallback?: A): T { + return lensFromPath(path).over(fn, fallback)(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. + */ +export class Composer { + private obj: T; + + constructor(initial: T) { + this.obj = initial; + } + + /** + * Runs a composer function. + */ + chain(fn: (composer: this) => this): this { + return fn(this); + } + + /** + * Shorthand for building a composer that runs a function. + */ + static chain(fn: Compositor): (obj: T) => T { + return (obj: T) => fn(new Composer(obj)).get(); + } + + /** + * Applies a series of mapping functions to the current object. + */ + pipe(...pipes: ((t: T) => T)[]): this { + this.obj = _pipe(this.obj, pipes); + return this; + } + + /** + * 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 (obj: T): T => _pipe(obj, pipes); + } + + /** + * Extracts the current object from the composer. + */ + 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; + 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; + } + + /** + * 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. + */ + 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?: unknown): this { + if (maybeValue === undefined) { + this.obj = pathOrValue as T; + } else { + this.obj = _set(this.obj, pathOrValue as Path, maybeValue); + } + return this; + } + + /** + * Shorthand for building a composer that sets a path. + */ + static set(path: Path, value: A) { + return (obj: T): T => _set(obj, path, value); + } + + /** + * Runs a composer on a sub-object at the specified path, + * then updates the original composer and returns it. + */ + zoom(path: Path, fn: Compositor): this { + 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; + } + + /** + * Shorthand for building a composer that zooms into a path + */ + static zoom(path: Path, fn: (a: A) => A) { + 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, 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, fallback?: A) { + return (obj: T): T => _over(obj, path, fn, fallback); + } + + /** + * Runs a composer function with the value at the specified path. + */ + bind(path: Path, fn: Transformer<[A], T>): this { + this.obj = _bind(this.obj, path, fn); + 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 => _bind(obj, path, fn); + } + + call (obj: any) => any>( + path: Path, + ...args: Parameters + ): this { + this.obj = _bind(this.obj, path, (fn: A) => fn(...args)); + return this; + } + + static call (obj: any) => any>( + path: Path, + ...args: Parameters + ) { + return (obj: T): T => + _bind(obj, path, (fn: A) => fn(...args)); + } + + /** + * Runs a composer function when the condition is true. + */ + when( + condition: boolean, + fn: (c: this) => this, + elseFn?: (c: this) => this + ): this { + if (condition) return fn(this); + return elseFn ? elseFn(this) : this; + } + + /** + * Shorthand for building a composer that runs a function when the condition is true. + */ + static when( + condition: boolean, + fn: (obj: T) => T, + elseFn?: (obj: T) => T + ): (obj: T) => T { + if (condition) return fn; + return elseFn ?? ((obj: T) => 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: (obj: T) => T + ): (obj: T) => T { + 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 (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') + continue; + (scope as any)[key] = (c as any)[key].bind(c); + } + + 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); + }; + } + + 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.get(); + }; + } +} diff --git a/src/engine/DOMBatcher.ts b/src/engine/DOMBatcher.ts new file mode 100644 index 00000000..2057298c --- /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/Engine.ts b/src/engine/Engine.ts new file mode 100644 index 00000000..5ad2eeab --- /dev/null +++ b/src/engine/Engine.ts @@ -0,0 +1,77 @@ +import { Pipe, GameTiming, GameFrame } from './State'; +import { deepFreeze } from './freeze'; + +export type GameEngineOptions = { + step?: number; +}; + +export class GameEngine { + constructor( + initial: Record, + pipe: Pipe, + options?: GameEngineOptions + ) { + this.frame = { ...initial } as GameFrame; + this.pipe = pipe; + this.step = options?.step ?? 16; + this.timing = { + tick: 0, + step: this.step, + time: 0, + }; + } + + /** + * The frame contains all plugin data. Each plugin's data lives at frame[pluginId]. + */ + private frame: GameFrame; + + /** + * The pipe is a function that produces a new game frame based on the current game frame. + */ + private pipe: Pipe; + + /** + * The fixed time step per tick in milliseconds. + */ + private step: number; + + /** + * 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 frame. + */ + public getFrame(): GameFrame { + return { + ...this.frame, + ...this.timing, + }; + } + + /** + * Runs the game engine for a single fixed-step tick. + */ + public tick(): GameFrame { + this.timing.tick += 1; + this.timing.time += this.step; + + const frame: GameFrame = { + ...this.frame, + ...this.timing, + }; + + const result = this.pipe(frame); + + this.frame = { + ...result, + ...this.timing, + }; + + if (import.meta.env.DEV) deepFreeze(this.frame); + + return this.frame; + } +} diff --git a/src/engine/Lens.ts b/src/engine/Lens.ts new file mode 100644 index 00000000..d5c1f7ef --- /dev/null +++ b/src/engine/Lens.ts @@ -0,0 +1,91 @@ +export type Lens = { + get: (source: S) => A; + set: (value: A) => (source: S) => S; + over: (fn: (a: A) => A, fallback?: A) => (source: S) => S; +}; + +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)) { + 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, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set: (value: A) => (_source: S) => value 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, + }; + } + + const lens: Lens = { + 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 = { ...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, fallback = {} as A) => + (source: S): S => { + const current = lens.get(source) ?? fallback; + return lens.set(fn(current))(source); + }, + }; + + return lens; +} diff --git a/src/engine/Piper.ts b/src/engine/Piper.ts new file mode 100644 index 00000000..0dbb42b8 --- /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.chain(c => c.pipe(...pipes)); diff --git a/src/engine/State.ts b/src/engine/State.ts new file mode 100644 index 00000000..3af20b78 --- /dev/null +++ b/src/engine/State.ts @@ -0,0 +1,13 @@ +export type GameTiming = { + tick: number; + step: number; + time: number; +}; + +export type GameFrame = { + [key: string]: any; +} & GameTiming; + +export type Pipe = (value: GameFrame) => GameFrame; + +export type PipeTransformer = (...args: TArgs) => Pipe; diff --git a/src/engine/freeze.ts b/src/engine/freeze.ts new file mode 100644 index 00000000..5452accf --- /dev/null +++ b/src/engine/freeze.ts @@ -0,0 +1,7 @@ +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/index.ts b/src/engine/index.ts new file mode 100644 index 00000000..1db5210d --- /dev/null +++ b/src/engine/index.ts @@ -0,0 +1,7 @@ +export * from './pipes'; +export * from './Composer'; +export * from './DOMBatcher'; +export * from './Engine'; +export * from './Lens'; +export * from './Piper'; +export * from './State'; diff --git a/src/engine/pipes/Errors.ts b/src/engine/pipes/Errors.ts new file mode 100644 index 00000000..507001b0 --- /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.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/Events.ts b/src/engine/pipes/Events.ts new file mode 100644 index 00000000..cd1b3de5 --- /dev/null +++ b/src/engine/pipes/Events.ts @@ -0,0 +1,82 @@ +import { lensFromPath } from '../Lens'; +import { Composer } from '../Composer'; +import { pluginPaths } from '../plugins/Plugins'; +import { GameFrame, Pipe } from '../State'; +import { CamelCase, toCamel } from '../../utils/case'; + +export type GameEvent = { type: string } & (unknown extends T + ? { payload?: T } + : { payload: T }); + +export type EventState = { + pending: GameEvent[]; + current: GameEvent[]; +}; + +const PLUGIN_NAMESPACE = 'core.events'; + +const events = pluginPaths(PLUGIN_NAMESPACE); +const eventStateLens = lensFromPath(events); +const pendingLens = lensFromPath(events.pending); + +export class Events { + static getKey(namespace: string, key: string): string { + return `${namespace}/${key}`; + } + + 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 }; + } + + static parseKey(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), + }; + } + + static dispatch(event: GameEvent): Pipe { + return pendingLens.over(pending => [...pending, event], []); + } + + static handle( + type: string, + fn: (event: GameEvent) => Pipe + ): Pipe { + 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 as GameEvent)(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. + */ + static pipe: Pipe = Composer.over(events, ({ pending = [] }) => ({ + pending: [], + current: pending, + })); +} diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts new file mode 100644 index 00000000..ea049425 --- /dev/null +++ b/src/engine/pipes/Perf.ts @@ -0,0 +1,171 @@ +import { Composer } from '../Composer'; +import { GameFrame, Pipe } from '../State'; +import { Events, type GameEvent } from './Events'; +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; + max: number; + samples: number[]; + lastTick: number; +}; + +export type PerfMetrics = Record>; + +export type PerfConfig = { + pluginBudget: number; +}; + +export type PerfContext = { + plugins: PerfMetrics; + config: PerfConfig; +}; + +const PLUGIN_NAMESPACE = 'core.perf'; +const SAMPLE_SIZE = 60; +const EXPIRY_TICKS = 900; + +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); +const frameTiming = typedPath([]); + +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 }) => { + if (!sdk.debug) { + pipe(pluginPipe); + return; + } + + const before = performance.now(); + pipe(pluginPipe); + const after = performance.now(); + const duration = after - before; + + 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) + : [duration]; + + const avg = samples.reduce((sum, v) => sum + v, 0) / samples.length; + const max = entry ? Math.max(entry.max, duration) : duration; + + set(entryPath, { last: duration, avg, max, samples, lastTick: tick }); + + const budget = + get(perf.config.pluginBudget) ?? DEFAULT_CONFIG.pluginBudget; + + if (duration > budget) { + console.warn( + `[perf] ${id} ${phase} took ${duration.toFixed(2)}ms (budget: ${budget}ms)` + ); + pipe( + Events.dispatch({ + type: eventType.overBudget, + payload: { id, phase, duration, budget }, + }) + ); + } + }); + } + + 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); + } + + static pipe: Pipe = Composer.pipe( + Composer.do(({ get, set }) => { + if (!get(perf.config)) { + set(perf.config, DEFAULT_CONFIG); + } + }), + + Composer.do(({ get, set }) => { + const tick = get(frameTiming.tick) ?? 0; + const plugins = get(perf.plugins); + if (!plugins) return; + + const [pruned, dirty] = pruneExpired(plugins, tick); + if (dirty) { + set(perf.plugins, pruned ?? {}); + } + }), + + Events.handle>(eventType.configure, event => + Composer.over(perf.config, (config = DEFAULT_CONFIG) => ({ + ...config, + ...event.payload, + })) + ) + ); + + static get paths() { + return perf; + } +} + +sdk.Perf = Perf; diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts new file mode 100644 index 00000000..61dab9c3 --- /dev/null +++ b/src/engine/pipes/Scheduler.ts @@ -0,0 +1,148 @@ +import { Composer } from '../Composer'; +import { typedPath } from '../Lens'; +import { pluginPaths } from '../plugins/Plugins'; +import { GameTiming, Pipe } from '../State'; +import { Events, GameEvent } from './Events'; + +const PLUGIN_NAMESPACE = 'core.scheduler'; + +export type ScheduledEvent = { + id?: string; + duration: number; + event: GameEvent; + held?: boolean; +}; + +type SchedulerState = { + scheduled: ScheduledEvent[]; + current: GameEvent[]; +}; + +const scheduler = pluginPaths(PLUGIN_NAMESPACE); +const timing = typedPath([]); + +const eventType = Events.getKeys( + PLUGIN_NAMESPACE, + 'schedule', + 'cancel', + 'hold', + 'release', + 'hold_by_prefix', + 'release_by_prefix', + 'cancel_by_prefix' +); + +export class Scheduler { + static getKey(namespace: string, key: string): string { + 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 }); + } + + static cancel(id: string): Pipe { + return Events.dispatch({ type: eventType.cancel, payload: id }); + } + + static hold(id: string): Pipe { + return Events.dispatch({ type: eventType.hold, payload: id }); + } + + static release(id: string): Pipe { + return Events.dispatch({ type: eventType.release, payload: id }); + } + + static holdByPrefix(prefix: string): Pipe { + return Events.dispatch({ type: eventType.holdByPrefix, payload: prefix }); + } + + static releaseByPrefix(prefix: string): Pipe { + return Events.dispatch({ + type: eventType.releaseByPrefix, + payload: prefix, + }); + } + + static cancelByPrefix(prefix: string): Pipe { + return Events.dispatch({ type: eventType.cancelByPrefix, payload: prefix }); + } + + static pipe: Pipe = Composer.pipe( + Composer.bind(timing.step, delta => + Composer.over(scheduler, ({ 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 }; + }) + ), + + Composer.bind(scheduler.current, events => + Composer.pipe(...events.map(Events.dispatch)) + ), + + Events.handle(eventType.schedule, event => + Composer.over(scheduler.scheduled, list => [ + ...list.filter(e => e.id !== event.payload.id), + event.payload, + ]) + ), + + Events.handle(eventType.cancel, event => + Composer.over(scheduler.scheduled, list => + list.filter(s => s.id !== event.payload) + ) + ), + + Events.handle(eventType.hold, event => + Composer.over(scheduler.scheduled, list => + list.map(s => (s.id === event.payload ? { ...s, held: true } : s)) + ) + ), + + Events.handle(eventType.release, event => + Composer.over(scheduler.scheduled, list => + list.map(s => (s.id === event.payload ? { ...s, held: false } : s)) + ) + ), + + Events.handle(eventType.holdByPrefix, event => + Composer.over(scheduler.scheduled, list => + list.map(s => + s.id?.startsWith(event.payload) ? { ...s, held: true } : s + ) + ) + ), + + Events.handle(eventType.releaseByPrefix, event => + Composer.over(scheduler.scheduled, list => + list.map(s => + s.id?.startsWith(event.payload) ? { ...s, held: false } : s + ) + ) + ), + + Events.handle(eventType.cancelByPrefix, event => + 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 new file mode 100644 index 00000000..12c75668 --- /dev/null +++ b/src/engine/pipes/Storage.ts @@ -0,0 +1,146 @@ +/** + * 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 game time) + * - Hot cache expires after inactivity to save memory + * - Writes are immediate to localStorage and update cache + */ + +import { Composer } from '../Composer'; +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 + +export type CacheEntry = { + value: any; + expiry: number; // game time when this expires +}; + +export type StorageContext = { + cache: { [key: string]: CacheEntry }; +}; + +const storage = pluginPaths(STORAGE_NAMESPACE); +const timing = typedPath([]); + +/** + * 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(storage, ctx => { + const cache = ctx?.cache || {}; + const cached = cache[key]; + + if (cached) { + return fn(cached.value as T | undefined); + } + + const value = Storage.load(key); + + return Composer.pipe( + Composer.bind(timing.time, elapsedTime => + Composer.over(storage, 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 => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error('Failed to write to localStorage:', e); + } + + return Composer.bind(timing.time, elapsedTime => + Composer.over(storage, 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 => { + try { + localStorage.removeItem(key); + } catch (e) { + console.error('Failed to remove from localStorage:', e); + } + + return Composer.over(storage, ctx => { + const newCache = { ...(ctx?.cache || {}) }; + delete newCache[key]; + return { cache: newCache }; + })(frame); + }; + } + + /** + * Storage pipe - evicts expired cache entries + */ + static pipe: Pipe = Composer.pipe( + Composer.bind(timing.time, elapsedTime => + Composer.over(storage, 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/pipes/index.ts b/src/engine/pipes/index.ts new file mode 100644 index 00000000..3bf0322e --- /dev/null +++ b/src/engine/pipes/index.ts @@ -0,0 +1,8 @@ +export * from './Events'; +export * from '../plugins/PluginInstaller'; +export * from '../plugins/PluginManager'; +export * from '../plugins/Plugins'; +export * from './Scheduler'; +export * from './Storage'; +export * from './Perf'; +export * from './Errors'; diff --git a/src/engine/plugins/PluginInstaller.ts b/src/engine/plugins/PluginInstaller.ts new file mode 100644 index 00000000..9c498f07 --- /dev/null +++ b/src/engine/plugins/PluginInstaller.ts @@ -0,0 +1,141 @@ +import { Composer } from '../Composer'; +import { Pipe } from '../State'; +import { Storage } from '../pipes/Storage'; +import { PluginManager } from './PluginManager'; +import { pluginPaths, type PluginId, type PluginClass } from './Plugins'; + +const PLUGIN_NAMESPACE = 'core.plugin_installer'; + +type PluginLoad = { + promise: Promise; + result?: PluginClass; + error?: Error; +}; + +type InstallerState = { + installed: PluginId[]; + failed: PluginId[]; + 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 cls = module.default; + + if (!cls?.plugin?.id) { + throw new Error( + 'Plugin must export a default class with a static plugin field' + ); + } + + return cls; + } 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.installed) ?? []; + const failed = get(ins.failed) ?? []; + const pending = get(ins.pending); + + 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.failed, (ids = []) => [ + ...(Array.isArray(ids) ? ids : []), + id, + ]); + return; + } + + over(ins.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.pending); + 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) { + console.error( + `[PluginInstaller] failed to load plugin "${id}":`, + entry.error + ); + failed.push(id); + } else { + remaining.set(id, entry); + } + } + + if (resolved.length > 0) { + pipe(...resolved.map(PluginManager.register)); + over(ins.installed, (ids = []) => [ + ...(Array.isArray(ids) ? ids : []), + ...resolved.map(cls => cls.plugin.id), + ]); + } + + if (failed.length > 0) { + over(ins.failed, (ids = []) => [ + ...(Array.isArray(ids) ? ids : []), + ...failed, + ]); + } + + if (remaining.size !== pending.size) { + set(ins.pending, remaining); + } +}); + +export const pluginInstallerPipe: Pipe = Composer.pipe(importPipe, resolvePipe); diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts new file mode 100644 index 00000000..cfdfe94b --- /dev/null +++ b/src/engine/plugins/PluginManager.ts @@ -0,0 +1,283 @@ +import { Composer } from '../Composer'; +import { Pipe, PipeTransformer } from '../State'; +import { + startDOMBatching, + stopDOMBatching, + flushDOMOperations, +} from '../DOMBatcher'; +import { Storage } from '../pipes/Storage'; +import { Events } from '../pipes/Events'; +import { + pluginPaths, + type PluginId, + type PluginClass, + type PluginRegistry, + type EnabledMap, +} from './Plugins'; +import { Perf } from '../pipes/Perf'; +import { Errors } from '../pipes/Errors'; +import { sdk } from '../sdk'; + +const PLUGIN_NAMESPACE = 'core.plugin_manager'; + +const eventType = Events.getKeys( + PLUGIN_NAMESPACE, + 'register', + 'unregister', + 'enable', + 'disable' +); + +const storageKey = { + enabled: `${PLUGIN_NAMESPACE}.enabled`, +}; + +export type PluginManagerAPI = { + register: PipeTransformer<[PluginClass]>; + unregister: PipeTransformer<[PluginId]>; + enable: PipeTransformer<[PluginId]>; + disable: PipeTransformer<[PluginId]>; +}; + +type PluginManagerState = PluginManagerAPI & { + loaded: PluginId[]; + registry: PluginRegistry; + loadedRefs: Record; + toLoad: PluginId[]; + toUnload: PluginId[]; +}; + +const pm = pluginPaths(PLUGIN_NAMESPACE); + +export class PluginManager { + static register(pluginClass: PluginClass): Pipe { + return Composer.bind(pm, ({ register }) => register(pluginClass)); + } + + static unregister(id: PluginId): Pipe { + return Composer.bind(pm, ({ unregister }) => unregister(id)); + } + + static enable(id: PluginId): Pipe { + return Composer.bind(pm, ({ enable }) => enable(id)); + } + + static disable(id: PluginId): Pipe { + return Composer.bind(pm, ({ disable }) => disable(id)); + } +} + +const apiPipe: Pipe = Composer.over(pm, 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]: true, + }) + ) + ), + Events.handle(eventType.disable, event => + Storage.bind(storageKey.enabled, (map = {}) => + Storage.set(storageKey.enabled, { + ...map, + [event.payload]: false, + }) + ) + ) +); + +const reconcilePipe: Pipe = Composer.pipe( + Events.handle(eventType.register, event => + Composer.do(({ over }) => { + over(pm.registry, registry => ({ + ...registry, + [event.payload.plugin.id]: event.payload, + })); + }) + ), + Events.handle(eventType.unregister, event => + 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.registry) ?? {}; + const loaded = get(pm.loaded) ?? []; + const forcedUnload = get(pm.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.toLoad, toLoad); + if (toUnload.length > 0) set(pm.toUnload, toUnload); + }) + ) +); + +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]; + 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])?.plugin.deactivate; + 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', Errors.withCatch(id, 'activate', p)) + : undefined; + }) + .filter(Boolean) as Pipe[]; + + const activeIds = [ + ...Object.keys(loadedRefs).filter(id => !toUnload.includes(id)), + ...toLoad, + ]; + + const updates = activeIds + .map(id => { + const p = (loadedRefs[id] ?? registry[id])?.plugin.update; + return p + ? Perf.withTiming(id, 'update', Errors.withCatch(id, 'update', p)) + : undefined; + }) + .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 }) => { + over(pm.registry, registry => { + const next = { ...registry }; + delete next[event.payload]; + return next; + }); + }) + ), + 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.loadedRefs) ?? {}; + const registry = get(pm.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.loaded, Object.keys(newRefs)); + over(pm, ctx => ({ + ...ctx, + loadedRefs: newRefs, + toLoad: [], + toUnload: [], + })); + }) +); + +declare module '../sdk' { + interface SDK { + PluginManager: typeof PluginManager; + } +} + +sdk.PluginManager = PluginManager; + +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 00000000..58b4a11b --- /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): TypedPath { + return typedPath([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 PluginClass = { + plugin: Plugin; + name: string; +}; + +export type PluginRegistry = Record; +export type EnabledMap = Record; diff --git a/src/engine/sdk.ts b/src/engine/sdk.ts new file mode 100644 index 00000000..e11db92e --- /dev/null +++ b/src/engine/sdk.ts @@ -0,0 +1,28 @@ +import { Composer } from './Composer'; +import { Events } from './pipes/Events'; +import { Scheduler } from './pipes/Scheduler'; +import { Storage } from './pipes/Storage'; +import { pluginPaths } from './plugins/Plugins'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface PluginSDK {} + +export interface SDK extends PluginSDK { + debug: boolean; + Composer: typeof Composer; + Events: typeof Events; + Scheduler: typeof Scheduler; + Storage: typeof Storage; + pluginPaths: typeof pluginPaths; +} + +export const sdk: SDK = { + debug: false, + Composer, + Events, + Scheduler, + Storage, + pluginPaths, +} as SDK; + +(globalThis as any).sdk = sdk; diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx index 3a245977..938d26ab 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 { GameMessages } from './components/GameMessages'; +import { GameImages } from './components/GameImages'; +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 { GamePauseMenu } from './components/GamePauseMenu'; +import { GameResume } from './components/GameResume'; 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; @@ -82,30 +72,23 @@ const StyledBottomBar = styled.div` export const GamePage = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + ); }; diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx index dedd60db..58c848d5 100644 --- a/src/game/GameProvider.tsx +++ b/src/game/GameProvider.tsx @@ -1,100 +1,124 @@ -import { useCallback } from 'react'; -import { createStateProvider } from '../utils'; -import { ImageItem } from '../types'; - -export enum Paws { - left = 'left', - right = 'right', - both = 'both', - none = 'none', -} +import { useEffect, useRef, useState, ReactNode, useCallback } from 'react'; +import { createContext } from 'use-context-selector'; +import { GameEngine, Pipe, GameFrame } 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'; -export const PawLabels: Record = { - [Paws.left]: 'Left', - [Paws.right]: 'Right', - [Paws.both]: 'Both', - [Paws.none]: 'Off', +type GameEngineContextValue = { + /** + * The current game frame containing all plugin data and timing. + */ + frame: GameFrame | null; + /** + * Queue a one-shot pipe to run in the next tick only. + */ + injectImpulse: (pipe: Pipe) => void; }; -export enum Stroke { - up = 'up', - down = 'down', -} +// eslint-disable-next-line react-refresh/only-export-components +export const GameEngineContext = createContext< + GameEngineContextValue | undefined +>(undefined); -export enum GamePhase { - pause = 'pause', - warmup = 'warmup', - active = 'active', - break = 'break', - finale = 'finale', - climax = 'climax', -} +type Props = { + children: ReactNode; + pipes?: Pipe[]; +}; -export interface GameMessagePrompt { - title: string; - onClick: () => void | Promise; -} +export function GameEngineProvider({ children, pipes = [] }: Props) { + const engineRef = useRef(null); -export interface GameMessage { - id: string; - title: string; - description?: string; - prompts?: GameMessagePrompt[]; - duration?: number; -} + const [frame, setFrame] = useState(null); -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 pendingImpulseRef = useRef([]); + const activeImpulseRef = useRef([]); -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: [], -}; + 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.chain(c => + c.pipe(...activeImpulseRef.current) + ); + + engineRef.current = new GameEngine( + {}, + Piper([ + impulsePipe, + Events.pipe, + Scheduler.pipe, + Perf.pipe, + Errors.pipe, + ...pipes, + ]) + ); + + const STEP = 16; + const MAX_TICKS_PER_FRAME = 4; + let accumulator = 0; + let lastWallTime: number | null = null; + let frameId: number; + + const loop = () => { + if (!engineRef.current) return; + + const now = performance.now(); + + if (document.hidden || lastWallTime === null) { + lastWallTime = now; + frameId = requestAnimationFrame(loop); + return; + } -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] + accumulator += now - lastWallTime; + lastWallTime = now; + + let ticked = false; + let ticks = 0; + + while (accumulator >= STEP && ticks < MAX_TICKS_PER_FRAME) { + if (!ticked) { + activeImpulseRef.current = pendingImpulseRef.current; + pendingImpulseRef.current = []; + ticked = true; + } + + engineRef.current.tick(); + accumulator -= STEP; + ticks++; + } + + if (ticks >= MAX_TICKS_PER_FRAME) { + accumulator = 0; + } + + if (ticked) { + setFrame(engineRef.current.getFrame()); + } + + frameId = requestAnimationFrame(loop); + }; + + frameId = requestAnimationFrame(loop); + + return () => { + cancelAnimationFrame(frameId); + engineRef.current = null; + pendingImpulseRef.current = []; + activeImpulseRef.current = []; + }; + }, [pipes]); + + const injectImpulse = useCallback((pipe: Pipe) => { + pendingImpulseRef.current.push(pipe); + }, []); + + return ( + + {children} + ); -}; +} diff --git a/src/game/GameShell.tsx b/src/game/GameShell.tsx new file mode 100644 index 00000000..5e03d621 --- /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/SceneBridge.tsx b/src/game/SceneBridge.tsx new file mode 100644 index 00000000..906fb865 --- /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 { useGameFrame } 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 = useGameFrame(Scene.paths) 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/Sequence.ts b/src/game/Sequence.ts new file mode 100644 index 00000000..e4a68d81 --- /dev/null +++ b/src/game/Sequence.ts @@ -0,0 +1,76 @@ +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'>; +type MessagePrompt = NonNullable[number]; + +export type SequenceScope = { + messageId: string; + on(handler: (event: GameEvent) => Pipe): Pipe; + on(name: string, handler: (event: GameEvent) => Pipe): Pipe; + message(msg: MessageInput): 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?: T): Pipe; + eventKey(target: string): string; + scheduleKey(target: string): string; +}; + +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) => Scheduler.getKey(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 }), + }, + }), + start: payload => + 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, + }; + } +} + +declare module '../engine/sdk' { + interface SDK { + Sequence: typeof Sequence; + } +} + +sdk.Sequence = Sequence; diff --git a/src/game/components/GameEmergencyStop.tsx b/src/game/components/GameEmergencyStop.tsx index fa0d9747..defd8f10 100644 --- a/src/game/components/GameEmergencyStop.tsx +++ b/src/game/components/GameEmergencyStop.tsx @@ -1,52 +1,17 @@ -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 { 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, setPhase] = useGameValue('phase'); - const [intensity, setIntensity] = useGameValue('intensity'); - const [, setPace] = useGameValue('pace'); - const [minPace] = useSetting('minPace'); - const sendMessage = useSendMessage(); - const messageId = 'emergency-stop'; + const phase = useGameFrame(Phase.paths.current) ?? ''; + const { dispatchEvent } = useDispatchEvent(); - 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 onStop = useCallback(() => { + dispatchEvent({ type: Events.getKey('core.emergencyStop', 'stop') }); + }, [dispatchEvent]); return ( <> diff --git a/src/game/components/GameEvents.tsx b/src/game/components/GameEvents.tsx deleted file mode 100644 index 4150cb54..00000000 --- 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 238e1080..423e5f3c 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 { useGameFrame } from '../hooks'; +import Intensity from '../plugins/intensity'; +import Hypno from '../plugins/hypno'; const StyledGameHypno = motion.create(styled.div` pointer-events: none; @@ -15,33 +16,24 @@ 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 } = useGameFrame(Hypno.paths) ?? {}; + const { intensity = 0 } = useGameFrame(Intensity.paths) ?? {}; 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 [images] = useImages(); - const [currentImage, setCurrentImage] = useGameValue('currentImage'); - const [seenImages, setSeenImages] = useGameValue('seenImages'); - const [nextImages, setNextImages] = useGameValue('nextImages'); - const [intensity] = useGameValue('intensity'); + const { currentImage, nextImages = [] } = useGameFrame(Image.paths); + const { intensity } = useGameFrame(Intensity.paths); 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 +70,6 @@ export const GameImages = () => { @@ -128,9 +78,11 @@ export const GameImages = () => { { - const [pace] = useGameValue('pace'); - const [intensity] = useGameValue('intensity'); +const PaceDisplay = () => { + const { pace = 0 } = useGameFrame(Pace.paths) ?? {}; const [maxPace] = useSetting('maxPace'); const paceSection = useMemo(() => maxPace / 3, [maxPace]); - const [paws] = useGameValue('paws'); + return ( + + + + + paceSection && pace <= paceSection * 2}> + + + paceSection * 2}> + + + + {pace} b/s + + + ); +}; + +const GripDisplay = () => { + const paws = useGameFrame(pawsPath) ?? Paws.both; + + return ( + + + + + + + + + {PawLabels[paws]} + + + ); +}; + +const IntensityDisplay = () => { + const { intensity = 0 } = useGameFrame(Intensity.paths) ?? {}; + const intensityPct = Math.round(intensity * 100); + + return ( + + + + + ); +}; + +export const GameInstructions = () => { const [events] = useSetting('events'); const useRandomGrip = useMemo( - () => events.includes(GameEvent.randomGrip), + () => events.includes(DiceEvent.randomGrip), [events] ); 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 7df0bd08..056ab9a5 100644 --- a/src/game/components/GameIntensity.tsx +++ b/src/game/components/GameIntensity.tsx @@ -1,19 +1,33 @@ -import { useSetting } from '../../settings'; -import { useLooping } from '../../utils'; -import { GamePhase, useGameValue } from '../GameProvider'; +import { useGameFrame } 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'; +import styled from 'styled-components'; + +const StyledIntensityMeter = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin: 8px; +`; export const GameIntensity = () => { - const [, setIntensity] = useGameValue('intensity'); - const [phase] = useGameValue('phase'); - const [duration] = useSetting('gameDuration'); + const { intensity } = useGameFrame(Intensity.paths); - useLooping( - () => { - setIntensity(prev => Math.min(prev + 1, 100)); - }, - duration * 10, - phase === GamePhase.active + return ( + + + + ); - - return null; }; diff --git a/src/game/components/GameMessages.tsx b/src/game/components/GameMessages.tsx index 4449217e..0129dfab 100644 --- a/src/game/components/GameMessages.tsx +++ b/src/game/components/GameMessages.tsx @@ -1,10 +1,16 @@ -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 } from '../plugins/messages'; +import Messages from '../plugins/messages'; +import { useGameFrame } from '../hooks/UseGameFrame'; + +import _ from 'lodash'; +import { useDispatchEvent } from '../hooks/UseDispatchEvent'; + const StyledGameMessages = styled.div` display: flex; flex-direction: column; @@ -54,83 +60,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 } = useGameFrame(Messages.paths); + const { dispatchEvent } = useDispatchEvent(); 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 +119,7 @@ export const GameMessages = () => { ...defaultTransition, ease: 'circInOut', }} - onClick={() => onMessageClick(message, prompt)} + onClick={() => dispatchEvent(prompt.event)} > {translate(prompt.title)} diff --git a/src/game/components/GameMeter.tsx b/src/game/components/GameMeter.tsx index ad12661e..635bf501 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 { useGameFrame } from '../hooks'; +import Phase, { GamePhase } from '../plugins/phase'; +import Stroke, { StrokeDirection } from '../plugins/stroke'; +import Pace 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 } = useGameFrame(Stroke.paths) ?? {}; + const { current: phase } = useGameFrame(Phase.paths) ?? {}; + const { pace } = useGameFrame(Pace.paths) ?? {}; 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/GamePace.tsx b/src/game/components/GamePace.tsx deleted file mode 100644 index 56dec88a..00000000 --- a/src/game/components/GamePace.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from 'react'; -import { useGameValue } from '../GameProvider'; -import { useSetting } from '../../settings'; - -export const GamePace = () => { - const [minPace] = useSetting('minPace'); - const [, setPace] = useGameValue('pace'); - - useEffect(() => { - setPace(minPace); - }, [minPace, setPace]); - - return null; -}; diff --git a/src/game/components/GamePauseMenu.tsx b/src/game/components/GamePauseMenu.tsx new file mode 100644 index 00000000..5f4d869b --- /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 { useGameFrame } 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 } = useGameFrame(Pause.paths) ?? {}; + 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('/end'); + }, [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 00000000..bd34bd53 --- /dev/null +++ b/src/game/components/GameResume.tsx @@ -0,0 +1,50 @@ +import styled from 'styled-components'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useGameFrame } 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 } = useGameFrame(Pause.paths) ?? {}; + + return ( + + {countdown != null && ( + + + {display(countdown)} + + + )} + + ); +}; diff --git a/src/game/components/GameSettings.tsx b/src/game/components/GameSettings.tsx deleted file mode 100644 index 0352ab1c..00000000 --- a/src/game/components/GameSettings.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import styled from 'styled-components'; -import { memo, useCallback, useState } from 'react'; -import { - BoardSettings, - ClimaxSettings, - DurationSettings, - EventSettings, - HypnoSettings, - ImageSettings, - PaceSettings, - PlayerSettings, - VibratorSettings, -} from '../../settings'; -import { GamePhase, useGameValue, useSendMessage } from '../GameProvider'; -import { useFullscreen, useLooping } from '../../utils'; -import { - WaButton, - WaDialog, - WaDivider, - WaIcon, -} from '@awesome.me/webawesome/dist/react'; - -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 [phase, setPhase] = useGameValue('phase'); - const [timer, setTimer] = useState(undefined); - const [fullscreen, setFullscreen] = useFullscreen(); - const sendMessage = useSendMessage(); - const messageId = 'game-settings'; - - const onOpen = useCallback( - (open: boolean) => { - if (open) { - setTimer(undefined); - setPhase(phase => { - if (phase === GamePhase.active) { - return GamePhase.pause; - } - return phase; - }); - } else { - setTimer(3000); - } - setOpen(open); - }, - [setPhase] - ); - - 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); - } - }, - 1000, - !open && phase === GamePhase.pause && timer !== undefined - ); - - return ( - - onOpen(true)}> - - - - setFullscreen(fullscreen => !fullscreen)} - > - - - onOpen(false)} - label={'Game Settings'} - style={{ - '--width': '920px', - }} - > - {open && } - - - ); -}; diff --git a/src/game/components/GameSound.tsx b/src/game/components/GameSound.tsx index b43d958d..195dfdb4 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 { useGameFrame } from '../hooks'; +import Phase, { GamePhase } from '../plugins/phase'; +import Stroke, { StrokeDirection } from '../plugins/stroke'; export const GameSound = () => { - const [stroke] = useGameValue('stroke'); - const [phase] = useGameValue('phase'); + const { stroke } = useGameFrame(Stroke.paths) ?? {}; + const { current: phase } = useGameFrame(Phase.paths) ?? {}; 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 7ed42337..63c7db4e 100644 --- a/src/game/components/GameVibrator.tsx +++ b/src/game/components/GameVibrator.tsx @@ -1,19 +1,25 @@ import { useEffect, useState } from 'react'; -import { GamePhase, Stroke, useGameValue } from '../GameProvider'; import { useAutoRef, useVibratorValue, VibrationMode, wait } from '../../utils'; import { useSetting } from '../../settings'; +import { useGameFrame } from '../hooks'; +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] = useGameValue('stroke'); - const [intensity] = useGameValue('intensity'); - const [pace] = useGameValue('pace'); - const [phase] = useGameValue('phase'); + 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'); const data = useAutoRef({ - intensity, - pace, + intensity: (intensity ?? 0) * 100, + pace: pace ?? 1, devices, mode, }); @@ -23,7 +29,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 +44,7 @@ export const GameVibrator = () => { } } break; - case Stroke.down: + case StrokeDirection.down: break; } }, [data, stroke]); @@ -46,7 +52,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 65f7bc09..00000000 --- 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 d44687e8..00000000 --- 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 8daaa668..00000000 --- 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 3d71ea6e..00000000 --- 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 d91a97f4..00000000 --- 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 8c095fdd..00000000 --- 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 3fa73b31..00000000 --- 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 8229edc1..00000000 --- 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 14c65046..00000000 --- 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 d1212cee..00000000 --- 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 9241865a..00000000 --- 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 4cb0ba1e..0c28243c 100644 --- a/src/game/components/index.ts +++ b/src/game/components/index.ts @@ -1,14 +1,11 @@ -export * from './events'; export * from './GameEmergencyStop'; -export * from './GameEvents'; export * from './GameHypno'; export * from './GameImages'; export * from './GameInstructions'; export * from './GameIntensity'; export * from './GameMessages'; export * from './GameMeter'; -export * from './GamePace'; -export * from './GameSettings'; +export * from './GameResume'; +export * from './GamePauseMenu'; export * from './GameSound'; export * from './GameVibrator'; -export * from './GameWarmup'; diff --git a/src/game/hooks/UseDispatchEvent.tsx b/src/game/hooks/UseDispatchEvent.tsx new file mode 100644 index 00000000..6a5ef4e5 --- /dev/null +++ b/src/game/hooks/UseDispatchEvent.tsx @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; +import { useContextSelector } from 'use-context-selector'; +import { Events, GameEvent } from '../../engine/pipes/Events'; +import { Pipe } from '../../engine/State'; +import { GameEngineContext } from '../GameProvider'; + +export function useDispatchEvent() { + const injectImpulse = useContextSelector( + GameEngineContext, + ctx => ctx?.injectImpulse + ); + + return useMemo( + () => ({ + inject: (pipe: Pipe) => { + injectImpulse?.(pipe); + }, + dispatchEvent: (event: GameEvent) => { + injectImpulse?.(Events.dispatch(event)); + }, + }), + [injectImpulse] + ); +} diff --git a/src/game/hooks/UseGameEngine.tsx b/src/game/hooks/UseGameEngine.tsx new file mode 100644 index 00000000..be226ac4 --- /dev/null +++ b/src/game/hooks/UseGameEngine.tsx @@ -0,0 +1,9 @@ +import { useContext } from 'use-context-selector'; +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/UseGameFrame.tsx b/src/game/hooks/UseGameFrame.tsx new file mode 100644 index 00000000..e9f211ad --- /dev/null +++ b/src/game/hooks/UseGameFrame.tsx @@ -0,0 +1,11 @@ +import { useContextSelector } from 'use-context-selector'; +import { lensFromPath, normalizePath, Path } from '../../engine/Lens'; +import { GameEngineContext } from '../GameProvider'; + +export const useGameFrame = (path: Path): T => { + return useContextSelector(GameEngineContext, ctx => { + if (!ctx?.frame) return {} as T; + const segments = normalizePath(path); + return lensFromPath(segments).get(ctx.frame) ?? ({} as T); + }); +}; diff --git a/src/game/hooks/index.ts b/src/game/hooks/index.ts new file mode 100644 index 00000000..719c8bf0 --- /dev/null +++ b/src/game/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './UseDispatchEvent'; +export * from './UseGameEngine'; +export * from './UseGameFrame'; diff --git a/src/game/pipes/Settings.ts b/src/game/pipes/Settings.ts new file mode 100644 index 00000000..085f7e3b --- /dev/null +++ b/src/game/pipes/Settings.ts @@ -0,0 +1,22 @@ +import { useCallback, useRef } from 'react'; +import { useSettings, useImages } from '../../settings'; +import { Composer, Pipe } from '../../engine'; + +export const useSettingsPipe = (): Pipe => { + const [settings] = useSettings(); + const [images] = useImages(); + const settingsRef = useRef(settings); + const imagesRef = useRef(images); + + settingsRef.current = settings; + imagesRef.current = images; + + return useCallback( + frame => + Composer.pipe( + Composer.set(['settings'], settingsRef.current), + Composer.set(['images'], imagesRef.current) + )(frame), + [] + ); +}; diff --git a/src/game/pipes/index.ts b/src/game/pipes/index.ts new file mode 100644 index 00000000..90e26973 --- /dev/null +++ b/src/game/pipes/index.ts @@ -0,0 +1 @@ +export * from './Settings'; diff --git a/src/game/plugins/clock.ts b/src/game/plugins/clock.ts new file mode 100644 index 00000000..e4288032 --- /dev/null +++ b/src/game/plugins/clock.ts @@ -0,0 +1,54 @@ +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; + lastWall: number | null; +}; + +const clock = pluginPaths(PLUGIN_ID); + +export default class Clock { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Clock', + }, + + activate: Composer.set(clock, { elapsed: 0, lastWall: null }), + + update: Composer.pipe( + Pause.whenPlaying( + Composer.do(({ get, over, set }) => { + const now = performance.now(); + 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.lastWall, now); + }) + ), + Pause.onPause(() => Composer.set(clock.lastWall, null)) + ), + + deactivate: Composer.set(clock, undefined), + }; + + static get paths() { + return clock; + } +} diff --git a/src/game/plugins/dealer.ts b/src/game/plugins/dealer.ts new file mode 100644 index 00000000..e20e81da --- /dev/null +++ b/src/game/plugins/dealer.ts @@ -0,0 +1,155 @@ +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, + dice, + settings, + OUTCOME_DONE, + DiceOutcome, + DiceLogEntry, +} 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'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Dealer: typeof Dealer; + } +} + +const outcomes: DiceOutcome[] = [ + climaxOutcome, + edgeOutcome, + randomPaceOutcome, + cleanUpOutcome, + randomGripOutcome, + doublePaceOutcome, + halfPaceOutcome, + pauseOutcome, + risingPaceOutcome, +]; + +const rollChances: Record = { + [DiceEvent.climax]: 1, + [DiceEvent.edge]: 1, + [DiceEvent.randomPace]: 10, + [DiceEvent.cleanUp]: 25, + [DiceEvent.randomGrip]: 50, + [DiceEvent.doublePace]: 50, + [DiceEvent.halfPace]: 50, + [DiceEvent.pause]: 50, + [DiceEvent.risingPace]: 30, +}; + +const roll = Sequence.for(PLUGIN_ID, 'roll'); + +export default class Dealer { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Dealer', + }, + + activate: Composer.pipe( + Composer.set(dice, { busy: false, log: [] }), + ...outcomes.flatMap(o => (o.activate ? [o.activate] : [])), + roll.after(1000, 'check') + ), + + update: Composer.pipe( + roll.on('check', () => + Composer.pipe( + Phase.whenPhase( + GamePhase.active, + Composer.do(({ get, pipe }) => { + const state = get(dice); + if (state.busy) return; + + const s = get(settings); + const frame = get(); + + 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(roll.dispatch('trigger', guaranteed.id)); + return; + } + + pipe( + Rand.next(value => { + let cumulative = 0; + for (const outcome of eligible) { + cumulative += 1 / rollChances[outcome.id]; + if (value < cumulative) { + return roll.dispatch('trigger', outcome.id); + } + } + return Composer.pipe(); + }) + ); + }) + ), + roll.after(1000, 'check') + ) + ), + + roll.on('trigger', event => + Composer.do(({ get, set, over, pipe }) => { + set(dice.busy, true); + const elapsed = get(Clock.paths)?.elapsed ?? 0; + over(dice.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.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, undefined), + }; + + static triggerOutcome(id: DiceEvent): Pipe { + return roll.dispatch('trigger', id); + } + + static get paths() { + return dice; + } +} + +export { Paws, PawLabels, pawsPath } from './dice/randomGrip'; +export { type DiceState } from './dice/types'; diff --git a/src/game/plugins/debug.ts b/src/game/plugins/debug.ts new file mode 100644 index 00000000..5a3bec86 --- /dev/null +++ b/src/game/plugins/debug.ts @@ -0,0 +1,64 @@ +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 => 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.visible, false); + }), + + update: Composer.do(({ get, set }) => { + if (!pendingToggle) return; + pendingToggle = false; + const current = get(debug.visible); + sdk.debug = !current; + set(debug.visible, !current); + }), + + deactivate: Composer.do(({ set }) => { + if (handler) { + window.removeEventListener('keydown', handler); + handler = null; + } + pendingToggle = false; + sdk.debug = false; + set(debug, undefined); + }), + }; +} diff --git a/src/game/plugins/dice/cleanUp.ts b/src/game/plugins/dice/cleanUp.ts new file mode 100644 index 00000000..94d12f12 --- /dev/null +++ b/src/game/plugins/dice/cleanUp.ts @@ -0,0 +1,43 @@ +import { Composer } from '../../../engine/Composer'; +import { Sequence } from '../../Sequence'; +import Phase, { GamePhase } from '../phase'; +import { DiceEvent, CleanUpDescriptions } from '../../../types'; +import { + PLUGIN_ID, + intensityState, + settings, + outcomeDone, + DiceOutcome, +} from './types'; + +const seq = Sequence.for(PLUGIN_ID, 'cleanUp'); + +export const cleanUpOutcome: DiceOutcome = { + id: DiceEvent.cleanUp, + check: frame => Composer.get(intensityState)(frame).intensity * 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: [seq.prompt(`I'm done, $master`, 'done')], + }) + ) + ) + ), + seq.on('done', () => + Composer.pipe( + seq.message({ + title: 'Good $player', + duration: 5000, + prompts: undefined, + }), + Phase.setPhase(GamePhase.active), + outcomeDone() + ) + ) + ), +}; diff --git a/src/game/plugins/dice/climax.ts b/src/game/plugins/dice/climax.ts new file mode 100644 index 00000000..4b841f3e --- /dev/null +++ b/src/game/plugins/dice/climax.ts @@ -0,0 +1,178 @@ +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'; +import { IntensityState } from '../intensity'; +import { + PLUGIN_ID, + intensityState, + settings, + outcomeDone, + DiceOutcome, +} from './types'; +import { edged } from './edge'; + +export type ClimaxResultType = 'climax' | 'denied' | 'ruined' | null; + +type ClimaxState = { + result: ClimaxResultType; +}; + +export const climax = typedPath([PLUGIN_ID, 'climax']); + +type ClimaxEndPayload = { countdown: number; denied?: boolean; ruin?: boolean }; + +const seq = Sequence.for(PLUGIN_ID, 'climax'); + +export const climaxOutcome: DiceOutcome = { + id: DiceEvent.climax, + check: frame => { + 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)) + ); + }, + update: Composer.pipe( + seq.on(() => + Composer.pipe( + Phase.setPhase(GamePhase.finale), + seq.message({ + title: 'Are you edging?', + prompts: [ + seq.prompt("I'm edging, $master", 'edging'), + seq.prompt("I can't", 'cant'), + ], + }) + ) + ), + + seq.on('edging', () => + Composer.bind(settings, s => + Composer.pipe( + seq.message({ + title: 'Stay on the edge, $player', + prompts: undefined, + }), + Pace.setPace(s.minPace), + seq.after(3000, 'countdown3') + ) + ) + ), + + seq.on('countdown3', () => + Composer.pipe( + seq.message({ description: '3...' }), + seq.after(5000, 'countdown2') + ) + ), + + seq.on('countdown2', () => + Composer.pipe( + seq.message({ description: '2...' }), + seq.after(5000, 'countdown1') + ) + ), + + seq.on('countdown1', () => + Composer.pipe( + seq.message({ description: '1...' }), + seq.after(5000, 'resolve') + ) + ), + + seq.on('resolve', () => + Composer.bind(settings, s => + 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!', + description: undefined, + }), + seq.after(1000, 'end', { countdown: 5, denied: true }) + ); + } + 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!', + description: undefined, + }), + seq.after(3000, 'end', { countdown: 10, 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') + ); + } + 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: [seq.prompt('Leave', 'leave')], + }) + ), + + 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') + ) + ), + + 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), + outcomeDone() + ) + ) + ), + + seq.on('leave', () => Scene.setScene('end')) + ), +}; diff --git a/src/game/plugins/dice/doublePace.ts b/src/game/plugins/dice/doublePace.ts new file mode 100644 index 00000000..73aa3b31 --- /dev/null +++ b/src/game/plugins/dice/doublePace.ts @@ -0,0 +1,58 @@ +import { Composer } from '../../../engine/Composer'; +import { Sequence } from '../../Sequence'; +import Pace from '../pace'; +import { DiceEvent } from '../../../types'; +import { round } from '../../../utils'; +import { + PLUGIN_ID, + paceState, + intensityState, + settings, + outcomeDone, + DiceOutcome, +} from './types'; +import { doRandomPace } from './randomPace'; + +const seq = Sequence.for(PLUGIN_ID, 'doublePace'); + +export const doublePaceOutcome: DiceOutcome = { + id: DiceEvent.doublePace, + check: frame => 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 * 2), s.maxPace); + return Composer.pipe( + Pace.setPace(newPace), + seq.message({ title: 'Double pace!', description: '3...' }), + seq.after(3000, 'step2') + ); + }) + ) + ), + seq.on('step2', () => + Composer.pipe( + seq.message({ description: '2...' }), + seq.after(3000, 'step3') + ) + ), + seq.on('step3', () => + Composer.pipe( + seq.message({ description: '1...' }), + seq.after(3000, 'done') + ) + ), + seq.on('done', () => + Composer.pipe( + seq.message({ + title: 'Done! Back to normal pace', + description: undefined, + duration: 5000, + }), + doRandomPace(), + outcomeDone() + ) + ) + ), +}; diff --git a/src/game/plugins/dice/edge.ts b/src/game/plugins/dice/edge.ts new file mode 100644 index 00000000..b18c3bac --- /dev/null +++ b/src/game/plugins/dice/edge.ts @@ -0,0 +1,40 @@ +import { Composer } from '../../../engine/Composer'; +import { typedPath } from '../../../engine/Lens'; +import { Sequence } from '../../Sequence'; +import Pace from '../pace'; +import { DiceEvent } from '../../../types'; +import { + PLUGIN_ID, + intensityState, + settings, + outcomeDone, + DiceOutcome, +} from './types'; + +export const edged = typedPath([PLUGIN_ID, 'edged']); + +const seq = Sequence.for(PLUGIN_ID, 'edge'); + +export const edgeOutcome: DiceOutcome = { + id: DiceEvent.edge, + check: frame => { + const i = Composer.get(intensityState)(frame).intensity * 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, + }), + seq.after(10000, 'done') + ) + ) + ), + seq.on('done', () => outcomeDone()) + ), +}; diff --git a/src/game/plugins/dice/halfPace.ts b/src/game/plugins/dice/halfPace.ts new file mode 100644 index 00000000..1865b9da --- /dev/null +++ b/src/game/plugins/dice/halfPace.ts @@ -0,0 +1,70 @@ +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 { + PLUGIN_ID, + paceState, + intensityState, + settings, + outcomeDone, + DiceOutcome, +} from './types'; +import { doRandomPace } from './randomPace'; + +type HalfPacePayload = { portion: number }; + +const seq = Sequence.for(PLUGIN_ID, 'halfPace'); + +export const halfPaceOutcome: DiceOutcome = { + id: DiceEvent.halfPace, + check: frame => { + const i = Composer.get(intensityState)(frame).intensity * 100; + return i >= 10 && i <= 50; + }, + update: Composer.pipe( + seq.on(() => + Composer.do(({ get, pipe }) => { + const pace = get(paceState); + const s = get(settings); + const newPace = Math.max(round(pace.pace / 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( + seq.message({ description: '2...' }), + seq.after(event.payload.portion, 'step3', event.payload) + ) + ), + seq.on('step3', event => + Composer.pipe( + seq.message({ description: '1...' }), + seq.after(event.payload.portion, 'done') + ) + ), + seq.on('done', () => + Composer.pipe( + seq.message({ + title: 'Done! Back to normal pace', + description: undefined, + duration: 5000, + }), + doRandomPace(), + outcomeDone() + ) + ) + ), +}; diff --git a/src/game/plugins/dice/pause.ts b/src/game/plugins/dice/pause.ts new file mode 100644 index 00000000..7ca86ff5 --- /dev/null +++ b/src/game/plugins/dice/pause.ts @@ -0,0 +1,31 @@ +import { Composer } from '../../../engine/Composer'; +import { Sequence } from '../../Sequence'; +import Phase, { GamePhase } from '../phase'; +import { DiceEvent } from '../../../types'; +import { PLUGIN_ID, intensityState, outcomeDone, DiceOutcome } from './types'; + +const seq = Sequence.for(PLUGIN_ID, 'pause'); + +export const pauseOutcome: DiceOutcome = { + id: DiceEvent.pause, + check: frame => Composer.get(intensityState)(frame).intensity * 100 >= 15, + update: Composer.pipe( + seq.on(() => + Composer.bind(intensityState, ist => { + const i = ist.intensity * 100; + return Composer.pipe( + seq.message({ title: 'Stop stroking!' }), + Phase.setPhase(GamePhase.break), + seq.after(Math.ceil(-100 * i + 12000), 'resume') + ); + }) + ), + seq.on('resume', () => + Composer.pipe( + seq.message({ title: 'Start stroking again!', duration: 5000 }), + Phase.setPhase(GamePhase.active), + outcomeDone() + ) + ) + ), +}; diff --git a/src/game/plugins/dice/randomGrip.ts b/src/game/plugins/dice/randomGrip.ts new file mode 100644 index 00000000..bf379cba --- /dev/null +++ b/src/game/plugins/dice/randomGrip.ts @@ -0,0 +1,48 @@ +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 { + left = 'left', + right = 'right', + both = 'both', +} + +export const PawLabels: Record = { + left: 'Left', + right: 'Right', + both: 'Both', +}; + +export const pawsPath = typedPath([PLUGIN_ID, 'paws']); + +const allPaws = Object.values(Paws); + +const seq = Sequence.for(PLUGIN_ID, 'randomGrip'); + +export const randomGripOutcome: DiceOutcome = { + id: DiceEvent.randomGrip, + activate: Rand.pick(allPaws, paw => Composer.set(pawsPath, paw)), + update: Composer.pipe( + seq.on(() => + 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 new file mode 100644 index 00000000..0e2d1325 --- /dev/null +++ b/src/game/plugins/dice/randomPace.ts @@ -0,0 +1,48 @@ +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 { + PLUGIN_ID, + intensityState, + settings, + outcomeDone, + DiceOutcome, +} from './types'; + +const seq = Sequence.for(PLUGIN_ID, 'randomPace'); + +export const doRandomPace = (): Pipe => + Composer.do(({ get, pipe }) => { + const i = get(intensityState).intensity; + 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, + 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 new file mode 100644 index 00000000..563116e8 --- /dev/null +++ b/src/game/plugins/dice/risingPace.ts @@ -0,0 +1,72 @@ +import { Composer } from '../../../engine/Composer'; +import { Sequence } from '../../Sequence'; +import Pace from '../pace'; +import { DiceEvent } from '../../../types'; +import { intensityToPaceRange, round } from '../../../utils'; +import { + PLUGIN_ID, + intensityState, + settings, + outcomeDone, + DiceOutcome, +} 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: DiceEvent.risingPace, + check: frame => Composer.get(intensityState)(frame).intensity * 100 >= 30, + update: Composer.pipe( + seq.on(() => + Composer.bind(intensityState, ist => + Composer.bind(settings, s => { + 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, + 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, + }) + ); + }) + ) + ), + 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( + seq.message({ title: 'Stay at this pace for a bit', duration: 5000 }), + seq.after(15000, 'done') + ) + ), + seq.on('done', () => Composer.pipe(doRandomPace(), outcomeDone())) + ), +}; diff --git a/src/game/plugins/dice/types.ts b/src/game/plugins/dice/types.ts new file mode 100644 index 00000000..49ca81de --- /dev/null +++ b/src/game/plugins/dice/types.ts @@ -0,0 +1,32 @@ +import { Pipe, GameFrame } from '../../../engine/State'; +import { Events } from '../../../engine/pipes/Events'; +import { pluginPaths } from '../../../engine/plugins/Plugins'; +import { typedPath } from '../../../engine/Lens'; +import { IntensityState } from '../intensity'; +import { PaceState } from '../pace'; +import { Settings } from '../../../settings'; +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); +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 }); + +export type DiceOutcome = { + id: DiceEvent; + 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 00000000..87348943 --- /dev/null +++ b/src/game/plugins/emergencyStop.ts @@ -0,0 +1,77 @@ +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(['core.intensity']); +const settings = typedPath(['settings']); + +type CountdownPayload = { remaining: number }; + +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 * 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/fps.ts b/src/game/plugins/fps.ts new file mode 100644 index 00000000..8a386673 --- /dev/null +++ b/src/game/plugins/fps.ts @@ -0,0 +1,116 @@ +import { Composer, pluginPaths } from '../../engine'; +import type { Plugin } from '../../engine/plugins/Plugins'; +import Debug from './debug'; + +const PLUGIN_ID = 'core.fps'; +const ELEMENT_ATTR = 'data-plugin-id'; +const STYLE_ID = `${PLUGIN_ID}-styles`; +const HISTORY_SIZE = 30; + +type FpsContext = { + el: HTMLElement; + tickTimestamps: number[]; +}; + +const fps = pluginPaths(PLUGIN_ID); + +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 = { + id: PLUGIN_ID, + meta: { + name: 'FPS Counter', + }, + + activate: Composer.do(({ get, set }) => { + const style = + document.getElementById(STYLE_ID) ?? document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + [${ELEMENT_ATTR}="${PLUGIN_ID}"] { + position: fixed; + 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}"]` + ); + if (existing) existing.remove(); + + const el = document.createElement('div'); + el.setAttribute(ELEMENT_ATTR, PLUGIN_ID); + + const visible = get(Debug.paths.visible); + el.style.display = visible ? '' : 'none'; + + document.body.appendChild(el); + + lastFrameTime = null; + fpsHistory = []; + rafId = requestAnimationFrame(rafLoop); + + set(fps, { el, tickTimestamps: [] }); + }), + + update: Composer.do(({ get, set }) => { + const ctx = get(fps); + if (!ctx) return; + + const visible = get(Debug.paths.visible); + if (ctx.el) ctx.el.style.display = visible ? '' : 'none'; + if (!visible) return; + + const avgFps = + fpsHistory.length > 0 + ? fpsHistory.reduce((sum, v) => sum + v, 0) / fpsHistory.length + : 0; + + 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 / ${tickTimestamps.length} TPS`; + + set(fps, { ...ctx, tickTimestamps }); + }), + + deactivate: Composer.do(({ get, set }) => { + cancelAnimationFrame(rafId); + rafId = 0; + lastFrameTime = null; + fpsHistory = []; + + const el = get(fps)?.el; + if (el) el.remove(); + set(fps, undefined); + }), + }; +} diff --git a/src/game/plugins/hypno.ts b/src/game/plugins/hypno.ts new file mode 100644 index 00000000..640f1f74 --- /dev/null +++ b/src/game/plugins/hypno.ts @@ -0,0 +1,78 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine/Composer'; +import { pluginPaths } from '../../engine/plugins/Plugins'; +import { typedPath } from '../../engine/Lens'; +import { GameTiming } from '../../engine/State'; +import { GamePhase, PhaseState } from './phase'; +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'; + +export type HypnoState = { + currentPhrase: number; + timer: number; +}; + +const hypno = pluginPaths(PLUGIN_ID); +const timing = typedPath([]); +const phaseState = typedPath(['core.phase']); +const intensityState = typedPath(['core.intensity']); +const settings = typedPath(['settings']); + +export default class Hypno { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Hypno', + }, + + activate: Composer.set(hypno, { + currentPhrase: 0, + timer: 0, + }), + + update: Pause.whenPlaying( + Composer.do(({ get, set, pipe }) => { + const phase = get(phaseState).current; + if (phase !== GamePhase.active) return; + + const s = get(settings); + if (s.hypno === GameHypnoType.off) return; + + const i = get(intensityState).intensity * 100; + const delay = 3000 - i * 29; + if (delay <= 0) return; + + const delta = get(timing.step); + const state = get(hypno); + const elapsed = state.timer + delta; + if (elapsed < delay) { + set(hypno.timer, elapsed); + return; + } + + const phrases = HypnoPhrases[s.hypno]; + if (phrases.length <= 0) return; + + pipe( + Rand.nextInt(phrases.length, idx => + Composer.set(hypno, { + currentPhrase: idx, + timer: 0, + }) + ) + ); + }) + ), + + deactivate: Composer.set(hypno, undefined), + }; + + static get paths() { + return hypno; + } +} diff --git a/src/game/plugins/image.ts b/src/game/plugins/image.ts new file mode 100644 index 00000000..a758ba47 --- /dev/null +++ b/src/game/plugins/image.ts @@ -0,0 +1,109 @@ +import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; +import { Pipe } from '../../engine/State'; +import { Composer } from '../../engine'; +import { Events } 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[]; +}; + +const image = pluginPaths(PLUGIN_ID); + +const eventType = Events.getKeys( + PLUGIN_ID, + 'push_next', + 'set_image', + 'set_next_images' +); + +export default class Image { + static pushNextImage(img: ImageItem): Pipe { + return Events.dispatch({ type: eventType.pushNext, payload: img }); + } + + static setCurrentImage(img: ImageItem | undefined): Pipe { + return Events.dispatch({ type: eventType.setImage, payload: img }); + } + + static setNextImages(imgs: ImageItem[]): Pipe { + return Events.dispatch({ type: eventType.setNextImages, payload: imgs }); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Image', + }, + + activate: Composer.set(image, { + currentImage: undefined, + seenImages: [], + nextImages: [], + }), + + update: Composer.pipe( + Events.handle(eventType.pushNext, event => + Composer.over( + image, + ({ 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, + nextImages: event.payload, + })) + ), + + Events.handle(eventType.setImage, event => + Composer.over(image, state => ({ + ...state, + currentImage: event.payload, + })) + ) + ), + + deactivate: Composer.set(image, undefined), + }; + + static get paths() { + return image; + } +} diff --git a/src/game/plugins/index.ts b/src/game/plugins/index.ts new file mode 100644 index 00000000..d7bbf3d6 --- /dev/null +++ b/src/game/plugins/index.ts @@ -0,0 +1,46 @@ +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'; +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'; +import Stroke from './stroke'; +import Rand from './rand'; +import Dealer from './dealer'; +import EmergencyStop from './emergencyStop'; +import Hypno from './hypno'; +import Messages from './messages'; +import Clock from './clock'; + +const plugins = [ + Scene, + Phase, + Rand, + Messages, + Image, + Debug, + Pause, + Clock, + Intensity, + Pace, + Stroke, + Dealer, + Warmup, + Hypno, + RandomImages, + EmergencyStop, + Fps, + PerfOverlay, +]; + +export const registerPlugins: Pipe = Composer.pipe( + ...plugins.map(PluginManager.register) +); diff --git a/src/game/plugins/intensity.ts b/src/game/plugins/intensity.ts new file mode 100644 index 00000000..012f1512 --- /dev/null +++ b/src/game/plugins/intensity.ts @@ -0,0 +1,54 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +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'; + +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 timing = typedPath([]); +const settings = typedPath(['settings']); + +export default class Intensity { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Intensity', + }, + + activate: Composer.set(intensity, { intensity: 0 }), + + update: Pause.whenPlaying( + Phase.whenPhase( + GamePhase.active, + Composer.do(({ get, over }) => { + const delta = get(timing.step); + const s = get(settings); + over(intensity, ({ intensity: i = 0 }) => ({ + intensity: Math.min(1, i + delta / (s.gameDuration * 1000)), + })); + }) + ) + ), + + deactivate: Composer.set(intensity, undefined), + }; + + static get paths() { + return intensity; + } +} diff --git a/src/game/plugins/messages.ts b/src/game/plugins/messages.ts new file mode 100644 index 00000000..68693053 --- /dev/null +++ b/src/game/plugins/messages.ts @@ -0,0 +1,114 @@ +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 } from '../../engine/pipes/Events'; +import { 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 MessageState = { + messages: GameMessage[]; +}; + +const PLUGIN_ID = 'core.messages'; + +const paths = pluginPaths(PLUGIN_ID); + +const eventType = Events.getKeys(PLUGIN_ID, 'send_message', 'expire_message'); + +export default class Messages { + static send(message: PartialGameMessage): Pipe { + return Events.dispatch({ + type: eventType.sendMessage, + payload: message, + }); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Messages', + }, + + activate: Composer.set(paths, { messages: [] }), + + update: Composer.pipe( + Events.handle(eventType.sendMessage, event => + Composer.pipe( + Composer.over(paths, ({ messages }) => { + const patch = event.payload; + 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); + const messageId = event.payload.id; + const updated = messages.find(m => m.id === messageId); + const scheduleId = Scheduler.getKey( + PLUGIN_ID, + `message/${messageId}` + ); + + if (updated?.duration !== undefined) { + return pipe( + Scheduler.schedule({ + id: scheduleId, + duration: updated.duration, + event: { + type: eventType.expireMessage, + payload: updated.id, + }, + }) + ); + } else { + return pipe(Scheduler.cancel(scheduleId)); + } + }) + ) + ), + + Events.handle(eventType.expireMessage, event => + Composer.over(paths, ({ messages }) => ({ + messages: messages.filter(m => m.id !== event.payload), + })) + ) + ), + + deactivate: Composer.set(paths, undefined), + }; + + static get paths() { + return paths; + } +} diff --git a/src/game/plugins/pace.ts b/src/game/plugins/pace.ts new file mode 100644 index 00000000..2161b80e --- /dev/null +++ b/src/game/plugins/pace.ts @@ -0,0 +1,77 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Pipe } from '../../engine/State'; +import { typedPath } from '../../engine/Lens'; +import { Settings } from '../../settings'; +import { Composer, pluginPaths } from '../../engine'; +import Clock from './clock'; + +declare module '../../engine/sdk' { + interface PluginSDK { + Pace: typeof Pace; + } +} + +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(['settings']); + +export default class Pace { + static setPace(val: number): Pipe { + return Composer.set(pace.pace, val); + } + + static resetPace(): Pipe { + return Composer.bind(settings, s => Composer.set(pace.pace, s.minPace)); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Pace', + }, + + activate: Composer.bind(settings, s => + Composer.set(pace, { + pace: s.minPace, + prevMinPace: s.minPace, + prevPace: s.minPace, + history: [{ time: 0, pace: s.minPace }], + }) + ), + + update: Composer.do(({ get, set, over }) => { + const state = get(pace); + const { minPace } = get(settings); + + if (minPace !== state.prevMinPace) { + set(pace.prevMinPace, minPace); + set(pace.pace, minPace); + } + + if (state.pace !== state.prevPace) { + 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, undefined), + }; + + static get paths() { + return pace; + } +} diff --git a/src/game/plugins/pause.ts b/src/game/plugins/pause.ts new file mode 100644 index 00000000..c71d7979 --- /dev/null +++ b/src/game/plugins/pause.ts @@ -0,0 +1,133 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Pipe } from '../../engine/State'; +import { Composer } from '../../engine/Composer'; +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'; + +export type PauseState = { + paused: boolean; + prev: boolean; + countdown: number | null; + gen: number; +}; + +const paths = pluginPaths(PLUGIN_ID); + +const eventType = Events.getKeys(PLUGIN_ID, 'on', 'off'); + +type SetPayload = { paused: boolean }; +type CountdownPayload = { remaining: number; gen: number }; + +const seq = Sequence.for(PLUGIN_ID, 'set'); + +export default class Pause { + static setPaused(val: boolean): Pipe { + return seq.start({ paused: val }); + } + + static get togglePause(): Pipe { + return Composer.bind(paths, state => Pause.setPaused(!state?.paused)); + } + + static whenPaused(pipe: Pipe): Pipe { + return Composer.bind(paths, state => Composer.when(!!state?.paused, pipe)); + } + + static whenPlaying(pipe: Pipe): Pipe { + return Composer.bind(paths, state => Composer.when(!state?.paused, pipe)); + } + + static onPause(fn: () => Pipe): Pipe { + return Events.handle(eventType.on, fn); + } + + static onResume(fn: () => Pipe): Pipe { + return Events.handle(eventType.off, fn); + } + + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Pause', + }, + + activate: Composer.set(paths, { + paused: true, + prev: true, + countdown: null, + gen: 0, + }), + + 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); + if (paused === prev) return; + set(paths.prev, paused); + pipe(Events.dispatch({ type: paused ? eventType.on : eventType.off })); + }), + + seq.on(event => + Composer.bind(paths.gen, (gen = 0) => { + const next = gen + 1; + return Composer.when( + event.payload.paused, + Composer.pipe( + Composer.set(paths.gen, next), + Composer.set(paths.paused, true), + Composer.set(paths.countdown, null) + ), + Composer.pipe( + Composer.set(paths.gen, next), + Composer.set(paths.countdown, 3), + seq.after(1000, 'countdown', { remaining: 2, gen: next }) + ) + ); + }) + ), + + seq.on('countdown', event => + Composer.bind(paths.gen, gen => + Composer.when( + event.payload.gen === gen, + Composer.when( + event.payload.remaining <= 0, + Composer.pipe( + Composer.set(paths.countdown, null), + Composer.set(paths.paused, false) + ), + Composer.pipe( + Composer.set(paths.countdown, event.payload.remaining), + seq.after(1000, 'countdown', { + remaining: event.payload.remaining - 1, + gen, + }) + ) + ) + ) + ) + ) + ), + + deactivate: Composer.set(paths, undefined), + }; + + static get paths() { + return paths; + } +} + +declare module '../../engine/sdk' { + interface PluginSDK { + Pause: typeof Pause; + } +} + +sdk.Pause = Pause; diff --git a/src/game/plugins/perf.ts b/src/game/plugins/perf.ts new file mode 100644 index 00000000..d50e2565 --- /dev/null +++ b/src/game/plugins/perf.ts @@ -0,0 +1,154 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import type { PluginPerfEntry } 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'; +const STYLE_ID = `${PLUGIN_ID}-styles`; + +type PerfOverlayContext = { + el: HTMLElement; +}; + +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) 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: string, + avg: number, + budget: number +): string { + const name = id.padEnd(24); + const ph = phase.padEnd(11); + const a = `${avg.toFixed(2)}ms`.padStart(8); + const color = budgetColor(avg, budget); + return `${name}${ph}${a}`; +} + +export default class PerfOverlay { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Performance Overlay', + }, + + activate: Composer.do(({ get, set }) => { + const style = + document.getElementById(STYLE_ID) ?? document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + [${ELEMENT_ATTR}="${PLUGIN_ID}"] { + position: fixed; + 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); + + const visible = get(Debug.paths.visible); + el.style.display = visible ? '' : 'none'; + + document.body.appendChild(el); + set(po, { el }); + }), + + update: Composer.do(({ get }) => { + const el = get(po)?.el; + if (!el) return; + + const visible = get(Debug.paths.visible); + el.style.display = visible ? '' : 'none'; + if (!visible) return; + + const ctx = get(Perf.paths); + if (!ctx) return; + + const { plugins, config } = ctx; + const lines: string[] = []; + + let totalAvg = 0; + + 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( + 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') + : 'no plugin data'; + }), + + deactivate: Composer.do(({ get, set }) => { + const el = get(po)?.el; + if (el) el.remove(); + set(po, undefined); + }), + }; +} diff --git a/src/game/plugins/phase.ts b/src/game/plugins/phase.ts new file mode 100644 index 00000000..843d3e60 --- /dev/null +++ b/src/game/plugins/phase.ts @@ -0,0 +1,78 @@ +import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; +import { Pipe } from '../../engine/State'; +import { Events } 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; +}; + +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}`), +}; + +export default class Phase { + static setPhase(p: string): Pipe { + return Composer.set(phase.current, p); + } + + static whenPhase(p: string, pipe: Pipe): Pipe { + return Composer.bind(phase, 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.set(phase, { + current: GamePhase.warmup, + prev: GamePhase.warmup, + }), + + update: Composer.do(({ get, set, pipe }) => { + const { current, prev } = get(phase); + if (current === prev) return; + set(phase.prev, current); + pipe(Events.dispatch({ type: eventType.leave(prev) })); + pipe(Events.dispatch({ type: eventType.enter(current) })); + }), + + deactivate: Composer.set(phase, undefined), + }; + + static get paths() { + return phase; + } +} diff --git a/src/game/plugins/rand.ts b/src/game/plugins/rand.ts new file mode 100644 index 00000000..a2b31447 --- /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, { seed, cursor: stringToSeed(seed) }); + }), + + deactivate: Composer.set(paths, undefined), + }; + + static next(fn: (value: number) => Pipe): Pipe { + return Composer.do(({ get, set, pipe }) => { + const [cursor, value] = advance(get(paths.cursor)); + set(paths.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.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.cursor, cursor); + pipe(fn(shuffled)); + }); + } + + static get paths() { + return paths; + } +} diff --git a/src/game/plugins/randomImages.ts b/src/game/plugins/randomImages.ts new file mode 100644 index 00000000..8d8b1ad4 --- /dev/null +++ b/src/game/plugins/randomImages.ts @@ -0,0 +1,101 @@ +import type { Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine'; +import { Events } from '../../engine/pipes/Events'; +import { Scheduler } from '../../engine/pipes/Scheduler'; +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 { + RandomImages: typeof RandomImages; + } +} + +const PLUGIN_ID = 'core.random_images'; + +const images = typedPath(['images']); +const intensityState = typedPath(['core.intensity']); +const imageState = typedPath(['core.images']); + +const eventType = Events.getKeys(PLUGIN_ID, 'schedule_next'); + +const scheduleId = Scheduler.getKey(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; + + 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, () => + 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); + + pipe( + 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 }, + }) + ); + }) + ); + }) + ), + }; +} diff --git a/src/game/plugins/scene.ts b/src/game/plugins/scene.ts new file mode 100644 index 00000000..4b54c3ac --- /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.current, s); + } + + static whenScene(s: string, pipe: Pipe): Pipe { + return Composer.bind(scene, 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, { + current: 'unknown', + prev: 'unknown', + }), + + update: Composer.do(({ get, set, pipe }) => { + const { current, prev } = get(scene); + if (current === prev) return; + set(scene.prev, current); + pipe(Events.dispatch({ type: eventType.leave(prev) })); + pipe(Events.dispatch({ type: eventType.enter(current) })); + }), + + deactivate: Composer.set(scene, undefined), + }; + + static get paths() { + return scene; + } +} + +sdk.Scene = Scene; diff --git a/src/game/plugins/stroke.ts b/src/game/plugins/stroke.ts new file mode 100644 index 00000000..0a3a1795 --- /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 { GameTiming } from '../../engine/State'; +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 timing = typedPath([]); +const phaseState = typedPath(['core.phase']); +const paceState = typedPath(['core.pace']); + +export default class Stroke { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Stroke', + }, + + activate: Composer.set(stroke, { + 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(timing.step); + const state = get(stroke); + if (!state) return; + + const interval = (1 / pace) * 1000; + const elapsed = state.timer + delta; + + if (elapsed >= interval) { + set(stroke, { + stroke: + state.stroke === StrokeDirection.up + ? StrokeDirection.down + : StrokeDirection.up, + timer: elapsed - interval, + }); + } else { + set(stroke.timer, elapsed); + } + }) + ), + + deactivate: Composer.set(stroke, undefined), + }; + + static get paths() { + return stroke; + } +} diff --git a/src/game/plugins/warmup.ts b/src/game/plugins/warmup.ts new file mode 100644 index 00000000..9896b352 --- /dev/null +++ b/src/game/plugins/warmup.ts @@ -0,0 +1,98 @@ +import { pluginPaths, type Plugin } from '../../engine/plugins/Plugins'; +import { Composer } from '../../engine'; +import { Events } from '../../engine/pipes/Events'; +import Messages from './messages'; +import { Scheduler } from '../../engine/pipes/Scheduler'; +import { typedPath } from '../../engine/Lens'; +import { Settings } from '../../settings'; +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 settings = typedPath(['settings']); + +const AUTOSTART_KEY = Scheduler.getKey(PLUGIN_ID, 'autoStart'); + +const eventType = Events.getKeys(PLUGIN_ID, 'start_game'); + +export default class Warmup { + static plugin: Plugin = { + id: PLUGIN_ID, + meta: { + name: 'Warmup', + }, + + activate: Composer.set(warmup, { initialized: false }), + + update: Composer.pipe( + Pause.whenPlaying( + Phase.whenPhase( + GamePhase.warmup, + Composer.do(({ get, set, pipe }) => { + const s = get(settings); + + if (s.warmupDuration === 0) { + pipe(Phase.setPhase(GamePhase.active)); + return; + } + + const state = get(warmup); + if (state.initialized) return; + + set(warmup.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, { 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, undefined), + }; +} diff --git a/src/settings/SettingsProvider.tsx b/src/settings/SettingsProvider.tsx index 390d7144..aa447592 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 e9e4e8bb..0308d1f7 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 39e171c3..0a04bec5 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', diff --git a/src/utils/case.ts b/src/utils/case.ts new file mode 100644 index 00000000..e9cd8755 --- /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()); diff --git a/src/utils/dialogNesting.ts b/src/utils/dialogNesting.ts new file mode 100644 index 00000000..ca2afc54 --- /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 c0c1952b..4009f4ad 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'; @@ -12,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 00000000..3cad6ce2 --- /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')}`; +}; diff --git a/tests/engine/Composer.test.ts b/tests/engine/Composer.test.ts new file mode 100644 index 00000000..b0b067c9 --- /dev/null +++ b/tests/engine/Composer.test.ts @@ -0,0 +1,458 @@ +import { describe, it, expect } from 'vitest'; +import { Composer } from '../../src/engine/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); + }); + + 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', () => { + 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.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 => ({ + ...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); + }); + + 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', () => { + 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); + }); + }); + + 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); + }); + + 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/tests/engine/Engine.test.ts b/tests/engine/Engine.test.ts new file mode 100644 index 00000000..d03080e8 --- /dev/null +++ b/tests/engine/Engine.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { GameEngine } from '../../src/engine/Engine'; +import { GameFrame, Pipe } from '../../src/engine/State'; + +describe('GameEngine', () => { + it('should initialize with given state', () => { + const initial = { foo: 'bar' }; + const pipe: Pipe = frame => frame; + const engine = new GameEngine(initial, pipe); + + expect(engine.getFrame().foo).toBe('bar'); + }); + + it('should increment tick on each tick', () => { + const engine = new GameEngine({}, frame => frame); + + engine.tick(); + expect(engine.getFrame().tick).toBe(1); + + engine.tick(); + expect(engine.getFrame().tick).toBe(2); + }); + + it('should accumulate time by fixed step', () => { + const engine = new GameEngine({}, frame => frame); + + engine.tick(); + expect(engine.getFrame().time).toBe(16); + + engine.tick(); + expect(engine.getFrame().time).toBe(32); + }); + + it('should use default step of 16ms', () => { + const engine = new GameEngine({}, frame => frame); + + engine.tick(); + expect(engine.getFrame().step).toBe(16); + }); + + it('should accept custom step size', () => { + const engine = new GameEngine({}, frame => frame, { step: 8 }); + + engine.tick(); + expect(engine.getFrame().step).toBe(8); + expect(engine.getFrame().time).toBe(8); + + engine.tick(); + expect(engine.getFrame().time).toBe(16); + }); + + it('should pass data through pipe', () => { + const pipe: Pipe = (frame: GameFrame) => ({ + ...frame, + modified: true, + }); + + const engine = new GameEngine({}, pipe); + engine.tick(); + + expect(engine.getFrame().modified).toBe(true); + }); + + it('should produce new references per tick', () => { + const pipe: Pipe = (frame: GameFrame) => ({ + ...frame, + nested: { value: 42 }, + }); + + const engine = new GameEngine({}, pipe); + engine.tick(); + + const frame1 = engine.getFrame(); + engine.tick(); + const frame2 = engine.getFrame(); + + expect(frame1.nested).not.toBe(frame2.nested); + expect(frame1.nested).toEqual(frame2.nested); + }); + + it('should freeze frame in dev mode', () => { + const pipe: Pipe = (frame: GameFrame) => ({ + ...frame, + nested: { value: 42 }, + }); + + const engine = new GameEngine({}, pipe); + engine.tick(); + + const frame = engine.getFrame(); + expect(() => { + frame.nested.value = 99; + }).toThrow(); + expect(frame.nested.value).toBe(42); + }); +}); diff --git a/tests/engine/Lens.test.ts b/tests/engine/Lens.test.ts new file mode 100644 index 00000000..497f3824 --- /dev/null +++ b/tests/engine/Lens.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect } from 'vitest'; +import { lensFromPath, normalizePath } from '../../src/engine/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 default to {} 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 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 } }; + + 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/tests/engine/pipes/Errors.test.ts b/tests/engine/pipes/Errors.test.ts new file mode 100644 index 00000000..eb476f9b --- /dev/null +++ b/tests/engine/pipes/Errors.test.ts @@ -0,0 +1,237 @@ +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(['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) + ); + + const frame = pipe(makeFrame()); + msg = 'second'; + 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(); + }); + }); +}); diff --git a/tests/engine/pipes/Perf.test.ts b/tests/engine/pipes/Perf.test.ts new file mode 100644 index 00000000..392784bc --- /dev/null +++ b/tests/engine/pipes/Perf.test.ts @@ -0,0 +1,219 @@ +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 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 getEntry = ( + frame: GameFrame, + pluginId: string, + phase: string +): PluginPerfEntry | undefined => + lensFromPath(['core.perf', 'plugins', pluginId, phase]).get(frame); + +const getConfig = (frame: GameFrame): PerfConfig | undefined => + lensFromPath(['core.perf', 'config']).get(frame); + +describe('Perf', () => { + beforeEach(() => { + sdk.debug = true; + }); + afterEach(() => { + sdk.debug = false; + }); + describe('Perf.pipe', () => { + it('should preserve existing metrics across frames', () => { + const frame0 = basePipe(makeFrame()); + + const noop: Pipe = frame => frame; + const timed = Perf.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, + Perf.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, + Perf.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, + Perf.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, + Perf.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, + Perf.withTiming('test.plugin', 'activate', noop), + Perf.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, + Perf.withTiming('plugin.a', 'update', noop), + Perf.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 Perf.pipe (graceful fallback)', () => { + const noop: Pipe = frame => frame; + const pipe = Composer.pipe( + Events.pipe, + Perf.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, + Perf.withTiming('test.plugin', 'update', slow) + ); + const frame1 = pipe(makeFrame()); + + const pending = frame1?.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, + Perf.withTiming('test.plugin', 'update', noop) + ); + const frame1 = pipe(makeFrame()); + + const pending = frame1?.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 config = getConfig(frame2); + expect(config!.pluginBudget).toBe(2); + }); + }); +}); diff --git a/tests/engine/pipes/Storage.test.ts b/tests/engine/pipes/Storage.test.ts new file mode 100644 index 00000000..f852994e --- /dev/null +++ b/tests/engine/pipes/Storage.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +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(() => { + 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 = { tick: 0, step: 0, time: 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 = { tick: 0, step: 0, time: 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 = { tick: 0, step: 0, time: 1000 }; + + frame1 = Composer.over>( + [STORAGE_NAMESPACE], + (storage = {}) => ({ + cache: {}, + ...storage, + }) + )(frame1); + + pipe(frame1); + expect(callCount).toBe(1); + + let frame2: GameFrame = { tick: 1, step: 16, time: 1016 }; + + frame2 = Composer.over>( + [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 = { tick: 0, step: 0, time: 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 = { tick: 0, step: 0, time: 1000 }; + + frame = Composer.over>( + [STORAGE_NAMESPACE], + (storage = {}) => ({ + cache: {}, + ...storage, + }) + )(frame); + + const result = pipe(frame); + + const storage = result.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 = { tick: 0, step: 0, time: 0 }; + + pipe(frame); + + expect(localStorage.getItem('test.key')).toBeNull(); + }); + + it('should remove from cache', () => { + const pipe = Storage.remove('test.key'); + + let frame: GameFrame = { tick: 0, step: 0, time: 0 }; + + frame = Composer.over>( + [STORAGE_NAMESPACE], + (storage = {}) => ({ + cache: { + 'test.key': { value: 'foo', expiry: 1000 }, + }, + ...storage, + }) + )(frame); + + const result = pipe(frame); + + 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 = { tick: 0, step: 0, time: 20000 }; + + frame = Composer.over>( + [STORAGE_NAMESPACE], + (storage = {}) => ({ + cache: { + 'expired.key': { value: 'old', expiry: 10000 }, + 'valid.key': { value: 'new', expiry: 50000 }, + }, + ...storage, + }) + )(frame); + + const result = Storage.pipe(frame); + + 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 new file mode 100644 index 00000000..4c994532 --- /dev/null +++ b/tests/engine/plugins/PluginInstaller.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +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'; +import { makeFrame, tick } from '../../utils'; + +const PLUGIN_NAMESPACE = 'core.plugin_installer'; + +const fullPipe: Pipe = Composer.pipe( + Events.pipe, + pluginManagerPipe, + pluginInstallerPipe +); + +const getPending = (frame: GameFrame): Map | undefined => + frame?.core?.plugin_installer?.pending; + +const getInstalledIds = (frame: GameFrame): string[] => + frame?.core?.plugin_installer?.installed ?? []; + +const getLoadedIds = (frame: GameFrame): string[] => + frame?.core?.plugin_manager?.loaded ?? []; + +function makeLoadResult(plugin: Plugin, name: string) { + const cls = { plugin, name } as PluginClass; + return { + promise: Promise.resolve(cls), + result: cls, + }; +} + +describe('Plugin Installer', () => { + beforeEach(() => { + localStorage.clear(); + }); + + 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({ + 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( + ['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( + ['core', 'plugin_installer', 'pending'], + new Map([['user.sdk', makeLoadResult(testPlugin, 'TestPlugin')]]) + )(frame0); + + const frame2 = fullPipe(tick(frame1)); + fullPipe(tick(frame2)); + + 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()); + + const frame1 = Composer.set( + ['core', 'plugin_installer', 'pending'], + new Map([ + [ + 'user.broken', + { + promise: Promise.reject(error).catch(() => {}), + error, + }, + ], + ]) + )(frame0); + + const frame2 = fullPipe(tick(frame1)); + + expect(getPending(frame2)?.has('user.broken')).toBeFalsy(); + expect(getInstalledIds(frame2)).not.toContain('user.broken'); + }); +}); diff --git a/tests/engine/plugins/PluginManager.test.ts b/tests/engine/plugins/PluginManager.test.ts new file mode 100644 index 00000000..a5e5b242 --- /dev/null +++ b/tests/engine/plugins/PluginManager.test.ts @@ -0,0 +1,221 @@ +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 { 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 gamePipe: Pipe = Composer.pipe(Events.pipe, pluginManagerPipe); + +const getLoadedIds = (frame: GameFrame): string[] => + frame?.core?.plugin_manager?.loaded ?? []; + +const getLoadedRefs = (frame: GameFrame): Record => + frame?.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(makePluginClass(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(tick(frame4))); + + 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(tick(frame3))); + 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/tests/game/plugins/pause.test.ts b/tests/game/plugins/pause.test.ts new file mode 100644 index 00000000..c281fce8 --- /dev/null +++ b/tests/game/plugins/pause.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +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 '../../../src/engine/plugins/PluginManager'; +import { GameFrame, Pipe } from '../../../src/engine/State'; +import { PluginClass } from '../../../src/engine/plugins/Plugins'; +import Pause, { PauseState } from '../../../src/game/plugins/pause'; +import { makeFrame, tick } from '../../utils'; + +const gamePipe: Pipe = Composer.pipe( + Events.pipe, + Scheduler.pipe, + 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?.core?.scheduler?.scheduled ?? []; +} + +function getPauseState(frame: GameFrame): PauseState | undefined { + return frame?.core?.pause; +} + +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(); + }); + + 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 = unpauseAndWait(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 = unpauseAndWait(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 count down through pause state 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 seenCountdowns: number[] = []; + + for (let i = 0; i < 300; i++) { + frame = gamePipe(tick(frame, 100)); + const countdown = getPauseState(frame)?.countdown; + if (countdown != null && !seenCountdowns.includes(countdown)) { + seenCountdowns.push(countdown); + } + } + + 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', () => { + 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/tests/utils.ts b/tests/utils.ts new file mode 100644 index 00000000..2042ba6c --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,15 @@ +import type { GameFrame } from '../src/engine/State'; + +export const makeFrame = (overrides?: Partial): GameFrame => ({ + tick: 0, + step: 16, + time: 0, + ...overrides, +}); + +export const tick = (frame: GameFrame, step = 16): GameFrame => ({ + ...frame, + tick: frame.tick + 1, + step, + time: frame.time + step, +}); diff --git a/tsconfig.json b/tsconfig.json index 278e77c6..7a75965b 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 58163673..ed3c4401 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/**/*.d.ts', 'tests/**/*.test.ts'], + }, + }, }); diff --git a/yarn.lock b/yarn.lock index ea64a256..98c84fee 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"