Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 0 additions & 97 deletions apps/_lib/client-machine.test.js

This file was deleted.

207 changes: 26 additions & 181 deletions apps/_lib/game-fsm.js
Original file line number Diff line number Diff line change
@@ -1,207 +1,52 @@
// game-fsm.js — declarative, xstate-backed finite-state-machine builder for
// orchestrating game state setups (match phases, rounds, countdowns, etc).
//
// Apps hand-roll game flow in update()/onMessage() with ad-hoc Maps and timers;
// this builder lets a game declare its phases once and drive them through the
// app lifecycle. A spec compiles to a real xstate 5 machine, so guards, timed
// transitions, and entry/exit actions are the battle-tested xstate semantics —
// the builder only adds the per-state enter/exit/tick game hooks and a thin
// runtime wrapper that mirrors createAppMachine (apps/_lib/lifecycle.js).
//
// Spec shape:
// defineGameFSM({
// initial: 'waiting',
// context: { round: 0, scores: {} }, // optional seed data
// states: {
// waiting: { on: { START: 'countdown' } },
// countdown:{ after: { 3000: 'active' }, enter(ctx, fsm){...} },
// active: { tick(ctx, dt, fsm){...}, on: { ROUND_OVER: { target: 'roundEnd', guard: (ctx,fsm)=>fsm.context.round<5 } } },
// roundEnd: { enter(ctx,fsm){ fsm.context.round++ }, after: { 2000: 'active' } },
// done: { final: true }
// }
// }, appCtx)
//
// Hooks receive the app ctx (so they can touch ctx.players/state/network) and
// the fsm runtime. Pass appCtx as the 2nd arg, or omit it and the hooks get
// undefined for ctx (still usable for pure-logic machines / tests).

const _isNode = typeof process !== 'undefined' && !!process.versions?.node
const _xstate = await import(_isNode ? 'xstate' : '/node_modules/xstate/dist/xstate.esm.js')
const { setup, createActor, assign } = _xstate

const HOOK_KEYS = ['enter', 'exit', 'tick']
const { setup, createActor } = _xstate

// Validate the spec up front with clear, actionable errors — a bad target or a
// missing initial state should fail loudly at build time, not silently route
// nowhere at runtime.
function validateSpec(spec) {
if (!spec || typeof spec !== 'object') throw new Error('[game-fsm] spec must be an object')
const states = spec.states
if (!states || typeof states !== 'object' || !Object.keys(states).length) {
throw new Error('[game-fsm] spec.states must be a non-empty object')
}
const names = Object.keys(states)
if (!spec.initial) throw new Error('[game-fsm] spec.initial is required')
if (!names.includes(spec.initial)) {
throw new Error(`[game-fsm] spec.initial "${spec.initial}" is not a declared state (have: ${names.join(', ')})`)
}
if (!states || typeof states !== 'object' || !Object.keys(states).length) throw new Error('[game-fsm] spec.states must be a non-empty object')
const names = Object.keys(states); if (!spec.initial) throw new Error('[game-fsm] spec.initial is required')
if (!names.includes(spec.initial)) throw new Error(`[game-fsm] spec.initial "${spec.initial}" is not a declared state (have: ${names.join(', ')})`)
const targetsOf = (def) => (Array.isArray(def) ? def : [def]).map(d => typeof d === 'string' ? d : d?.target).filter(Boolean)
for (const name of names) {
const st = states[name] || {}
for (const [evt, def] of Object.entries(st.on || {})) {
for (const target of targetsOf(def)) {
if (!names.includes(target)) throw new Error(`[game-fsm] state "${name}" event "${evt}" targets unknown state "${target}"`)
}
}
for (const [ms, def] of Object.entries(st.after || {})) {
for (const target of targetsOf(def)) {
if (!names.includes(target)) throw new Error(`[game-fsm] state "${name}" after(${ms}) targets unknown state "${target}"`)
}
}
}
}

// Normalize a transition def into the xstate transition shape, registering any
// guard/action into the shared maps so machine setup() can reference them by
// name. A def may be:
// 'target' — bare target
// {target, guard, action} — single guarded/actioned transition
// [def, def, ...] — ordered candidates; xstate picks the first
// whose guard passes (last one usually bare).
function normalizeTransition(stateName, key, def, guards, actions, getRuntime, getAppCtx) {
if (Array.isArray(def)) {
return def.map((d, i) => normalizeOne(stateName, `${key}_${i}`, d, guards, actions, getRuntime, getAppCtx))
for (const [evt, def] of Object.entries(st.on || {})) { for (const target of targetsOf(def)) if (!names.includes(target)) throw new Error(`[game-fsm] state "${name}" event "${evt}" targets unknown state "${target}"`) }
for (const [ms, def] of Object.entries(st.after || {})) { for (const target of targetsOf(def)) if (!names.includes(target)) throw new Error(`[game-fsm] state "${name}" after(${ms}) targets unknown state "${target}"`) }
}
return normalizeOne(stateName, key, def, guards, actions, getRuntime, getAppCtx)
}

function normalizeOne(stateName, key, def, guards, actions, getRuntime, getAppCtx) {
const target = typeof def === 'string' ? def : def.target
const out = { target }
const target = typeof def === 'string' ? def : def.target, out = { target }
if (def && typeof def === 'object') {
if (typeof def.guard === 'function') {
const gname = `g_${stateName}_${key}`
guards[gname] = () => !!def.guard(getAppCtx(), getRuntime())
out.guard = gname
}
if (typeof def.action === 'function') {
const aname = `a_${stateName}_${key}`
actions[aname] = () => def.action(getAppCtx(), getRuntime())
out.actions = aname
}
if (typeof def.guard === 'function') { const gname = `g_${stateName}_${key}`; guards[gname] = () => !!def.guard(getAppCtx(), getRuntime()); out.guard = gname }
if (typeof def.action === 'function') { const aname = `a_${stateName}_${key}`; actions[aname] = () => def.action(getAppCtx(), getRuntime()); out.actions = aname }
}
return out
}

export function defineGameFSM(spec, appCtx) {
validateSpec(spec)

let runtime = null
const getRuntime = () => runtime
const getAppCtx = () => appCtx

const guards = {}
const actions = {}
const xstates = {}

validateSpec(spec); let runtime = null; const getRuntime = () => runtime, getAppCtx = () => appCtx
const guards = {}, actions = {}, xstates = {}
for (const [name, raw] of Object.entries(spec.states)) {
const st = raw || {}
const xs = {}
if (st.final === true || st.type === 'final') xs.type = 'final'

// enter/exit -> xstate entry/exit actions calling the game hooks.
if (typeof st.enter === 'function') {
const an = `enter_${name}`
actions[an] = () => st.enter(getAppCtx(), getRuntime())
xs.entry = an
}
if (typeof st.exit === 'function') {
const an = `exit_${name}`
actions[an] = () => st.exit(getAppCtx(), getRuntime())
xs.exit = an
}

// Event transitions.
if (st.on) {
xs.on = {}
for (const [evt, def] of Object.entries(st.on)) {
xs.on[evt] = normalizeTransition(name, evt, def, guards, actions, getRuntime, getAppCtx)
}
}
// Timed (after) transitions.
if (st.after) {
xs.after = {}
for (const [ms, def] of Object.entries(st.after)) {
xs.after[ms] = normalizeTransition(name, `after${ms}`, def, guards, actions, getRuntime, getAppCtx)
}
}
const st = raw || {}, xs = {}; if (st.final === true || st.type === 'final') xs.type = 'final'
if (typeof st.enter === 'function') { const an = `enter_${name}`; actions[an] = () => st.enter(getAppCtx(), getRuntime()); xs.entry = an }
if (typeof st.exit === 'function') { const an = `exit_${name}`; actions[an] = () => st.exit(getAppCtx(), getRuntime()); xs.exit = an }
if (st.on) { xs.on = {}; for (const [evt, def] of Object.entries(st.on)) xs.on[evt] = Array.isArray(def) ? def.map((d, i) => normalizeOne(name, `${evt}_${i}`, d, guards, actions, getRuntime, getAppCtx)) : normalizeOne(name, evt, def, guards, actions, getRuntime, getAppCtx) }
if (st.after) { xs.after = {}; for (const [ms, def] of Object.entries(st.after)) xs.after[ms] = Array.isArray(def) ? def.map((d, i) => normalizeOne(name, `after${ms}_${i}`, d, guards, actions, getRuntime, getAppCtx)) : normalizeOne(name, `after${ms}`, def, guards, actions, getRuntime, getAppCtx) }
xstates[name] = xs
}

const machine = setup({ guards, actions }).createMachine({
id: spec.id || 'game-fsm',
initial: spec.initial,
context: { ...(spec.context || {}) },
states: xstates
})

const actor = createActor(machine)
let _stopped = false
const _subs = new Set()

const machine = setup({ guards, actions }).createMachine({ id: spec.id || 'game-fsm', initial: spec.initial, context: { ...(spec.context || {}) }, states: xstates })
const actor = createActor(machine); let _stopped = false; const _subs = new Set(); let _stateEnteredAt = Date.now(), _lastValue = null
runtime = {
// The shared, mutable game-data bag. Seeded from spec.context; hooks read
// and write it directly (fsm.context.round++). xstate context holds the
// same reference so timed/guarded logic sees the live values.
get context() { return actor.getSnapshot().context },
get state() { return actor.getSnapshot().value },
get timeInState() { return _stopped ? 0 : (_now() - _stateEnteredAt) },
is(s) { return actor.getSnapshot().matches(s) },
matches(s) { return actor.getSnapshot().matches(s) },
can(evt) { return actor.getSnapshot().can({ type: evt }) },
send(type, payload) {
if (_stopped) return
actor.send(payload != null ? { type, ...payload } : { type })
},
onTransition(fn) {
if (_stopped) return () => {}
_subs.add(fn)
return () => _subs.delete(fn)
},
// Drive the active state's tick hook. The app calls this from its
// update(ctx, dt). Final states do not tick.
tick(dt) {
if (_stopped) return
const snap = actor.getSnapshot()
if (snap.status === 'done') return
const name = snap.value
const st = spec.states[name]
if (st && typeof st.tick === 'function') st.tick(getAppCtx(), dt, runtime)
},
stop() {
if (_stopped) return
_stopped = true
_subs.clear()
try { actor.stop() } catch (_) {}
}
get context() { return actor.getSnapshot().context }, get state() { return actor.getSnapshot().value }, get timeInState() { return _stopped ? 0 : (Date.now() - _stateEnteredAt) },
is(s) { return actor.getSnapshot().matches(s) }, matches(s) { return actor.getSnapshot().matches(s) }, can(evt) { return actor.getSnapshot().can({ type: evt }) },
send(type, payload) { if (!_stopped) actor.send(payload != null ? { type, ...payload } : { type }) },
onTransition(fn) { if (_stopped) return () => {}; _subs.add(fn); return () => _subs.delete(fn) },
tick(dt) { if (_stopped) return; const snap = actor.getSnapshot(); if (snap.status === 'done') return; const st = spec.states[snap.value]; if (st && typeof st.tick === 'function') st.tick(getAppCtx(), dt, runtime) },
stop() { if (_stopped) return; _stopped = true; _subs.clear(); try { actor.stop() } catch (_) {} }
}

let _stateEnteredAt = _now()
let _lastValue = null
actor.subscribe((snap) => {
if (snap.value !== _lastValue) {
_lastValue = snap.value
_stateEnteredAt = _now()
for (const fn of _subs) { try { fn(snap.value, runtime) } catch (_) {} }
}
})
actor.start()

return runtime
actor.subscribe((snap) => { if (snap.value !== _lastValue) { _lastValue = snap.value; _stateEnteredAt = Date.now(); for (const fn of _subs) { try { fn(snap.value, runtime) } catch (_) {} } } })
actor.start(); return runtime
}

// Monotonic-ish time. Date.now is fine here (these are wall-clock game timers,
// not the gm-plugkit determinism constraint).
function _now() { return Date.now() }

export default defineGameFSM
Loading
Loading