import { BUTTONS, BUTTON_BITS, type Button } from './protocol'; const RATE_GROUP: Record = { up: 'command_0', down: 'command_1', left: 'command_2', right: 'command_3', a: 'command_4', b: 'command_5', start: 'command_6', select: 'command_7', }; /** Fixed readout: plasticity belongs to the neural network, not button gains. */ export class MotorDecoder { private readonly baseline: Record = {}; private readonly heldUntil = Object.fromEntries(BUTTONS.map(button => [button, 0])) as Record; private readonly nextAllowed = Object.fromEntries(BUTTONS.map(button => [button, 0])) as Record; private calibrated = false; private nextDirectionDecision = 0; private direction: Button | null = null; private fatigue: Record = { up: 0, down: 0, left: 0, right: 0 }; clearHolds(nowMs: number): void { for (const button of BUTTONS) { this.heldUntil[button] = 0; this.nextAllowed[button] = nowMs + 480; } this.direction = null; for (const key of Object.keys(this.fatigue)) this.fatigue[key] = 0; this.nextDirectionDecision = nowMs; } calibrate(rates: Record): void { for (const name of Object.values(RATE_GROUP)) this.baseline[name] = rates[name] ?? 0; this.calibrated = true; } decode(rates: Record, nowMs: number, boot = true): number { if (!this.calibrated) return 0; const scores = Object.fromEntries(BUTTONS.map(button => { const name = RATE_GROUP[button]; return [button, ((rates[name] ?? 0) + 1) / ((this.baseline[name] ?? 0) + 1)]; })) as Record; const directions = ['up', 'down', 'left', 'right'] as const; if (nowMs >= this.nextDirectionDecision) { // Bounded motor habituation prevents a small persistent rate bias from // holding one command forever. No game coordinates enter this readout. for (const direction of directions) scores[direction] /= 1 + this.fatigue[direction]; let best = directions.reduce((a, b) => scores[b] > scores[a] ? b : a); if (this.direction && scores[best] < scores[this.direction] * 1.15) best = this.direction as typeof best; for (const direction of directions) this.heldUntil[direction] = 0; this.direction = best; for (const direction of directions) this.fatigue[direction] = direction === best ? Math.min(1, this.fatigue[direction] + 0.08) : this.fatigue[direction] * 0.8; this.heldUntil[best] = nowMs + 400; this.nextDirectionDecision = nowMs + 400; } for (const button of ['a', 'b', 'start', 'select'] as const) { const system = button === 'start' || button === 'select'; if (scores[button] > (system && !boot ? 1.35 : 1) && nowMs >= this.nextAllowed[button]) { this.heldUntil[button] = nowMs + (system ? 55 : 85); this.nextAllowed[button] = nowMs + (system ? (boot ? 2500 : 30000) : 480); if (system) this.nextAllowed[button === 'start' ? 'select' : 'start'] = this.nextAllowed[button]; } } let mask = 0; for (const button of BUTTONS) if (nowMs < this.heldUntil[button]) mask |= BUTTON_BITS[button]; return mask; } exportState() { return { version: 3, fatigue: { ...this.fatigue }, direction: this.direction, baseline: { ...this.baseline }, heldUntil: { ...this.heldUntil }, nextAllowed: { ...this.nextAllowed }, calibrated: this.calibrated, nextDirectionDecision: this.nextDirectionDecision }; } importState(state: ReturnType): void { if (!state || typeof state.calibrated !== 'boolean' || !Number.isFinite(state.nextDirectionDecision) || [state.baseline, state.heldUntil, state.nextAllowed].some(record => !record || Object.values(record).some(value => !Number.isFinite(value))) || BUTTONS.some(button => !Number.isFinite(state.heldUntil[button]) || !Number.isFinite(state.nextAllowed[button]))) throw new Error('Invalid decoder checkpoint'); Object.assign(this.baseline, state.baseline); Object.assign(this.heldUntil, state.heldUntil); Object.assign(this.nextAllowed, state.nextAllowed); this.calibrated = state.calibrated; this.nextDirectionDecision = state.nextDirectionDecision; if (state.version !== undefined && state.version !== 2 && state.version !== 3) throw new Error('Invalid decoder version'); if (state.version === 3) { if (!state.fatigue || Object.keys(this.fatigue).some(key => !Number.isFinite(state.fatigue[key]) || state.fatigue[key] < 0 || state.fatigue[key] > 1)) throw new Error('Invalid motor habituation'); this.fatigue = { ...state.fatigue }; } if (state.direction != null && !['up', 'down', 'left', 'right'].includes(state.direction)) throw new Error('Invalid decoder direction'); this.direction = state.direction ?? null; } }