stuff
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { useHumanChallenge } from '../useHumanChallenge'
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
function makeChallengeData(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: 'bitcoin_trivia',
|
||||
label: 'Bitcoin Trivia',
|
||||
prompt: 'Who is Satoshi Nakamoto?',
|
||||
roundNumber: 1,
|
||||
timeoutMs: 10000,
|
||||
scoring: 'factual',
|
||||
choices: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useHumanChallenge', () => {
|
||||
let fightId: Ref<string>
|
||||
let myBotId: Ref<string | null>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
fightId = ref('fight-1') as Ref<string>
|
||||
myBotId = ref<string | null>('bot-1')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('applyChallenge sets challenge state correctly', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
const data = makeChallengeData({ roundNumber: 3, choices: ['A', 'B', 'C'] })
|
||||
|
||||
hc.applyChallenge(data)
|
||||
|
||||
expect(hc.humanChallenge.value).not.toBeNull()
|
||||
expect(hc.humanChallenge.value!.type).toBe('bitcoin_trivia')
|
||||
expect(hc.humanChallenge.value!.prompt).toBe('Who is Satoshi Nakamoto?')
|
||||
expect(hc.humanChallenge.value!.roundNumber).toBe(3)
|
||||
expect(hc.humanChallenge.value!.scoring).toBe('factual')
|
||||
expect(hc.humanChoices.value).toEqual(['A', 'B', 'C'])
|
||||
expect(hc.humanAnswer.value).toBe('')
|
||||
expect(hc.humanSubmitted.value).toBe(false)
|
||||
expect(hc.humanTimer.value).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('applyChallenge deduplicates same round', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
const data = makeChallengeData({ roundNumber: 1 })
|
||||
|
||||
hc.applyChallenge(data)
|
||||
const firstChallenge = hc.humanChallenge.value
|
||||
|
||||
// Apply same round again — should be no-op
|
||||
hc.applyChallenge(data)
|
||||
expect(hc.humanChallenge.value).toBe(firstChallenge)
|
||||
})
|
||||
|
||||
it('applyChallenge allows different round numbers', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
|
||||
hc.applyChallenge(makeChallengeData({ roundNumber: 1 }))
|
||||
expect(hc.humanChallenge.value!.roundNumber).toBe(1)
|
||||
|
||||
hc.applyChallenge(makeChallengeData({ roundNumber: 2, prompt: 'New prompt' }))
|
||||
expect(hc.humanChallenge.value!.roundNumber).toBe(2)
|
||||
expect(hc.humanChallenge.value!.prompt).toBe('New prompt')
|
||||
})
|
||||
|
||||
it('timer counts down each second', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData({ timeoutMs: 8000 }))
|
||||
|
||||
const initialTimer = hc.humanTimer.value
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(hc.humanTimer.value).toBe(initialTimer - 1)
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(hc.humanTimer.value).toBe(initialTimer - 2)
|
||||
})
|
||||
|
||||
it('timeout clears challenge state and submits timeout', async () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData({ timeoutMs: 6000 }))
|
||||
|
||||
// Advance past all timer ticks until timer reaches 0
|
||||
const timerVal = hc.humanTimer.value
|
||||
vi.advanceTimersByTime(timerVal * 1000)
|
||||
|
||||
expect(hc.humanSubmitted.value).toBe(true)
|
||||
|
||||
// submitTimeout should have been called — verify the fetch
|
||||
await vi.runAllTimersAsync()
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/fights/fight-1/respond/bot-1',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answer: '', timeout: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('submitHumanAnswer sends answer to API', async () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData())
|
||||
hc.humanAnswer.value = 'A cypherpunk legend'
|
||||
|
||||
await hc.submitHumanAnswer()
|
||||
|
||||
expect(hc.humanSubmitted.value).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/fights/fight-1/respond/bot-1',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answer: 'A cypherpunk legend' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('submitHumanAnswer does not submit empty answer', async () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData())
|
||||
hc.humanAnswer.value = ' '
|
||||
|
||||
await hc.submitHumanAnswer()
|
||||
|
||||
expect(hc.humanSubmitted.value).toBe(false)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submitHumanAnswer does not submit without botId', async () => {
|
||||
myBotId.value = null
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData())
|
||||
hc.humanAnswer.value = 'test'
|
||||
|
||||
await hc.submitHumanAnswer()
|
||||
|
||||
expect(hc.humanSubmitted.value).toBe(false)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submitChoice sets answer and submits', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData({ choices: ['21M', '42M', '100M'] }))
|
||||
|
||||
hc.submitChoice('21M')
|
||||
|
||||
expect(hc.humanAnswer.value).toBe('21M')
|
||||
expect(hc.humanSubmitted.value).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/fights/fight-1/respond/bot-1',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answer: '21M' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('submitChoice prevents double-tap', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData({ choices: ['A', 'B'] }))
|
||||
|
||||
hc.submitChoice('A')
|
||||
mockFetch.mockClear()
|
||||
|
||||
// Second tap should be ignored
|
||||
hc.submitChoice('B')
|
||||
expect(hc.humanAnswer.value).toBe('A')
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cooldown prevents immediate resubmission', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.startCooldown(3)
|
||||
|
||||
expect(hc.roundCooldown.value).toBe(3)
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(hc.roundCooldown.value).toBe(2)
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(hc.roundCooldown.value).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(hc.roundCooldown.value).toBe(0)
|
||||
})
|
||||
|
||||
it('cooldown applies pending challenge when finished', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
const pendingData = makeChallengeData({ roundNumber: 5 })
|
||||
|
||||
// Queue a pending challenge during cooldown
|
||||
hc.startCooldown(2)
|
||||
hc.pendingChallengeData.value = { data: pendingData, receivedAt: Date.now() }
|
||||
|
||||
// Advance past cooldown
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
expect(hc.roundCooldown.value).toBe(0)
|
||||
expect(hc.humanChallenge.value).not.toBeNull()
|
||||
expect(hc.humanChallenge.value!.roundNumber).toBe(5)
|
||||
expect(hc.pendingChallengeData.value).toBeNull()
|
||||
})
|
||||
|
||||
it('resetState clears all state', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData({ choices: ['X'] }))
|
||||
hc.humanAnswer.value = 'test answer'
|
||||
|
||||
hc.resetState()
|
||||
|
||||
expect(hc.humanChallenge.value).toBeNull()
|
||||
expect(hc.humanAnswer.value).toBe('')
|
||||
expect(hc.humanSubmitted.value).toBe(false)
|
||||
expect(hc.humanChoices.value).toEqual([])
|
||||
expect(hc.roundCooldown.value).toBe(0)
|
||||
expect(hc.pendingChallengeData.value).toBeNull()
|
||||
expect(hc.entrancePlaying.value).toBe(false)
|
||||
expect(hc.animatingRound.value).toBe(false)
|
||||
})
|
||||
|
||||
it('handleSSEChallenge queues when entrance is playing', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.entrancePlaying.value = true
|
||||
|
||||
const data = makeChallengeData({ roundNumber: 1 })
|
||||
hc.handleSSEChallenge(data)
|
||||
|
||||
// Should be queued, not applied
|
||||
expect(hc.humanChallenge.value).toBeNull()
|
||||
expect(hc.pendingChallengeData.value).not.toBeNull()
|
||||
expect(hc.pendingChallengeData.value!.data).toEqual(data)
|
||||
})
|
||||
|
||||
it('handleSSEChallenge applies immediately when not blocked', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
|
||||
const data = makeChallengeData({ roundNumber: 1 })
|
||||
hc.handleSSEChallenge(data)
|
||||
|
||||
expect(hc.humanChallenge.value).not.toBeNull()
|
||||
expect(hc.humanChallenge.value!.roundNumber).toBe(1)
|
||||
})
|
||||
|
||||
it('setEntrancePlaying applies pending challenge when entrance ends', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.entrancePlaying.value = true
|
||||
|
||||
const data = makeChallengeData({ roundNumber: 2 })
|
||||
hc.pendingChallengeData.value = { data, receivedAt: Date.now() }
|
||||
|
||||
hc.setEntrancePlaying(false)
|
||||
|
||||
expect(hc.entrancePlaying.value).toBe(false)
|
||||
expect(hc.humanChallenge.value).not.toBeNull()
|
||||
expect(hc.humanChallenge.value!.roundNumber).toBe(2)
|
||||
expect(hc.pendingChallengeData.value).toBeNull()
|
||||
})
|
||||
|
||||
it('applyChallenge guarantees minimum display time', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
// remainingMs of 2000 is below MIN_DISPLAY_MS (5000), so it should clamp up
|
||||
hc.applyChallenge(makeChallengeData({ remainingMs: 2000 }))
|
||||
|
||||
// Timer should be at least 5 seconds (MIN_DISPLAY_MS / 1000)
|
||||
expect(hc.humanTimer.value).toBeGreaterThanOrEqual(5)
|
||||
expect(hc.humanChallenge.value!.remainingMs).toBeGreaterThanOrEqual(5000)
|
||||
})
|
||||
|
||||
it('stopHumanPolling clears all interval handles', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData())
|
||||
hc.startCooldown(5)
|
||||
|
||||
// This should not throw and should clean up intervals
|
||||
hc.stopHumanPolling()
|
||||
|
||||
// Advancing timers should not change state
|
||||
const timerVal = hc.humanTimer.value
|
||||
const cooldownVal = hc.roundCooldown.value
|
||||
vi.advanceTimersByTime(5000)
|
||||
expect(hc.humanTimer.value).toBe(timerVal)
|
||||
expect(hc.roundCooldown.value).toBe(cooldownVal)
|
||||
})
|
||||
|
||||
it('clearChallenge removes challenge but preserves other state', () => {
|
||||
const hc = useHumanChallenge(fightId, myBotId)
|
||||
hc.applyChallenge(makeChallengeData({ choices: ['A', 'B'] }))
|
||||
hc.startCooldown(3)
|
||||
|
||||
hc.clearChallenge()
|
||||
|
||||
expect(hc.humanChallenge.value).toBeNull()
|
||||
expect(hc.humanSubmitted.value).toBe(false)
|
||||
expect(hc.pendingChallengeData.value).toBeNull()
|
||||
// Cooldown should still be running (clearChallenge doesn't touch it)
|
||||
expect(hc.roundCooldown.value).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -31,10 +31,12 @@ describe('useNostr', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('isLoggedIn is false when no pubkey or bot stored', async () => {
|
||||
@@ -50,7 +52,7 @@ describe('useNostr', () => {
|
||||
localStorage.setItem('bf_pubkey', JSON.stringify('testpub'))
|
||||
localStorage.setItem('bf_bot', JSON.stringify({ id: 'b1', name: 'Bot' }))
|
||||
localStorage.setItem('bf_pic', JSON.stringify('https://example.com/pic.jpg'))
|
||||
localStorage.setItem('bf_nsec', 'secretkey')
|
||||
sessionStorage.setItem('bf_nsec', 'secretkey')
|
||||
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
@@ -61,7 +63,7 @@ describe('useNostr', () => {
|
||||
expect(localStorage.getItem('bf_pubkey')).toBeNull()
|
||||
expect(localStorage.getItem('bf_bot')).toBeNull()
|
||||
expect(localStorage.getItem('bf_pic')).toBeNull()
|
||||
expect(localStorage.getItem('bf_nsec')).toBeNull()
|
||||
expect(sessionStorage.getItem('bf_nsec')).toBeNull()
|
||||
expect(mockSetToken).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
@@ -80,8 +82,8 @@ describe('useNostr', () => {
|
||||
expect(hasStoredKey.value).toBe(false)
|
||||
})
|
||||
|
||||
it('hasStoredKey is true when nsec pre-set in localStorage', async () => {
|
||||
localStorage.setItem('bf_nsec', 'test-nsec')
|
||||
it('hasStoredKey is true when nsec pre-set in sessionStorage', async () => {
|
||||
sessionStorage.setItem('bf_nsec', 'test-nsec')
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { hasStoredKey } = useNostr()
|
||||
@@ -95,13 +97,13 @@ describe('useNostr', () => {
|
||||
expect(getStoredNsec()).toBeNull()
|
||||
})
|
||||
|
||||
it('persistKey saves session nsec to localStorage', async () => {
|
||||
it('persistKey saves session nsec to sessionStorage', async () => {
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { persistKey } = useNostr()
|
||||
// Without a session key, persist should be a no-op
|
||||
persistKey()
|
||||
expect(localStorage.getItem('bf_nsec')).toBeNull()
|
||||
expect(sessionStorage.getItem('bf_nsec')).toBeNull()
|
||||
})
|
||||
|
||||
it('pubkey and bot are readonly refs', async () => {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { reactive, onMounted, onUnmounted } from 'vue'
|
||||
import type { PlayerInput } from '../game/arcade/types'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Keyboard Mappings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const P1_KEYS: Record<string, keyof PlayerInput> = {
|
||||
w: 'up', W: 'up',
|
||||
s: 'down', S: 'down',
|
||||
a: 'left', A: 'left',
|
||||
d: 'right', D: 'right',
|
||||
g: 'punch', G: 'punch',
|
||||
h: 'kick', H: 'kick',
|
||||
}
|
||||
|
||||
const P2_KEYS: Record<string, keyof PlayerInput> = {
|
||||
ArrowUp: 'up',
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
k: 'punch', K: 'punch',
|
||||
l: 'kick', L: 'kick',
|
||||
}
|
||||
|
||||
// Standard Gamepad button indices
|
||||
const GAMEPAD_DPAD_UP = 12
|
||||
const GAMEPAD_DPAD_DOWN = 13
|
||||
const GAMEPAD_DPAD_LEFT = 14
|
||||
const GAMEPAD_DPAD_RIGHT = 15
|
||||
const GAMEPAD_BUTTON_A = 0 // face bottom (A on Xbox, X on PS)
|
||||
const GAMEPAD_BUTTON_B = 2 // face left (X on Xbox, Square on PS)
|
||||
const GAMEPAD_STICK_DEADZONE = 0.4
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Input layer: each source writes its own state, merged with OR
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function emptyInput(): PlayerInput {
|
||||
return { up: false, down: false, left: false, right: false, punch: false, kick: false }
|
||||
}
|
||||
|
||||
function mergeInputs(...sources: PlayerInput[]): PlayerInput {
|
||||
const out = emptyInput()
|
||||
for (const s of sources) {
|
||||
if (s.up) out.up = true
|
||||
if (s.down) out.down = true
|
||||
if (s.left) out.left = true
|
||||
if (s.right) out.right = true
|
||||
if (s.punch) out.punch = true
|
||||
if (s.kick) out.kick = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Composable
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export function useArcadeInput() {
|
||||
// Final merged output (read by the game engine)
|
||||
const p1Input = reactive<PlayerInput>(emptyInput())
|
||||
const p2Input = reactive<PlayerInput>(emptyInput())
|
||||
|
||||
// Per-source state for each player
|
||||
const p1Keyboard = emptyInput()
|
||||
const p2Keyboard = emptyInput()
|
||||
const p1Gamepad = emptyInput()
|
||||
const p2Gamepad = emptyInput()
|
||||
const p1Relay = emptyInput()
|
||||
const p2Relay = emptyInput()
|
||||
|
||||
const keyboardState: Record<string, boolean> = {}
|
||||
let gamepadPollId: number | null = null
|
||||
|
||||
// --- Merge all sources into final output ---
|
||||
function syncOutputs(): void {
|
||||
const m1 = mergeInputs(p1Keyboard, p1Gamepad, p1Relay)
|
||||
const m2 = mergeInputs(p2Keyboard, p2Gamepad, p2Relay)
|
||||
Object.assign(p1Input, m1)
|
||||
Object.assign(p2Input, m2)
|
||||
}
|
||||
|
||||
// --- Keyboard handlers ---
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
if (keyboardState[e.key]) return
|
||||
keyboardState[e.key] = true
|
||||
|
||||
const p1Action = P1_KEYS[e.key]
|
||||
if (p1Action) {
|
||||
p1Keyboard[p1Action] = true
|
||||
syncOutputs()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const p2Action = P2_KEYS[e.key]
|
||||
if (p2Action) {
|
||||
p2Keyboard[p2Action] = true
|
||||
syncOutputs()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent): void {
|
||||
keyboardState[e.key] = false
|
||||
|
||||
const p1Action = P1_KEYS[e.key]
|
||||
if (p1Action) {
|
||||
p1Keyboard[p1Action] = false
|
||||
syncOutputs()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const p2Action = P2_KEYS[e.key]
|
||||
if (p2Action) {
|
||||
p2Keyboard[p2Action] = false
|
||||
syncOutputs()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gamepad polling ---
|
||||
function pollGamepads(): void {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
if (gamepads) {
|
||||
for (let i = 0; i < Math.min(2, gamepads.length); i++) {
|
||||
const gp = gamepads[i]
|
||||
if (!gp || !gp.connected) continue
|
||||
|
||||
const gpState = i === 0 ? p1Gamepad : p2Gamepad
|
||||
|
||||
// D-pad buttons
|
||||
gpState.up = gp.buttons[GAMEPAD_DPAD_UP]?.pressed ?? false
|
||||
gpState.down = gp.buttons[GAMEPAD_DPAD_DOWN]?.pressed ?? false
|
||||
gpState.left = gp.buttons[GAMEPAD_DPAD_LEFT]?.pressed ?? false
|
||||
gpState.right = gp.buttons[GAMEPAD_DPAD_RIGHT]?.pressed ?? false
|
||||
|
||||
// Left stick as fallback for d-pad
|
||||
if (gp.axes.length >= 2) {
|
||||
const [lx, ly] = gp.axes
|
||||
if (!gpState.left && !gpState.right) {
|
||||
gpState.left = lx < -GAMEPAD_STICK_DEADZONE
|
||||
gpState.right = lx > GAMEPAD_STICK_DEADZONE
|
||||
}
|
||||
if (!gpState.up && !gpState.down) {
|
||||
gpState.up = ly < -GAMEPAD_STICK_DEADZONE
|
||||
gpState.down = ly > GAMEPAD_STICK_DEADZONE
|
||||
}
|
||||
}
|
||||
|
||||
// Face buttons
|
||||
gpState.punch = gp.buttons[GAMEPAD_BUTTON_A]?.pressed ?? false
|
||||
gpState.kick = gp.buttons[GAMEPAD_BUTTON_B]?.pressed ?? false
|
||||
}
|
||||
}
|
||||
|
||||
syncOutputs()
|
||||
gamepadPollId = requestAnimationFrame(pollGamepads)
|
||||
}
|
||||
|
||||
// --- Archy relay handler ---
|
||||
function applyRelayInput(key: string, player: number, pressed: boolean): void {
|
||||
const relay = player === 2 ? p2Relay : p1Relay
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowUp': relay.up = pressed; break
|
||||
case 'ArrowDown': relay.down = pressed; break
|
||||
case 'ArrowLeft': relay.left = pressed; break
|
||||
case 'ArrowRight': relay.right = pressed; break
|
||||
case 'a': case 'A': case 'x': case 'X': relay.punch = pressed; break
|
||||
case 'b': case 'B': case 'y': case 'Y': relay.kick = pressed; break
|
||||
default: return
|
||||
}
|
||||
syncOutputs()
|
||||
}
|
||||
|
||||
function onArcadeInput(e: Event): void {
|
||||
const detail = (e as CustomEvent).detail
|
||||
if (!detail?.key) return
|
||||
applyRelayInput(detail.key, detail.player || 1, detail.type !== 'up')
|
||||
}
|
||||
|
||||
function onPostMessage(e: MessageEvent): void {
|
||||
const data = e.data
|
||||
if (!data || data.type !== 'arcade-input' || !data.key) return
|
||||
applyRelayInput(data.key, data.player || 1, data.action !== 'up')
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
function setup(): void {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('message', onPostMessage)
|
||||
document.addEventListener('arcade-input', onArcadeInput)
|
||||
gamepadPollId = requestAnimationFrame(pollGamepads)
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('message', onPostMessage)
|
||||
document.removeEventListener('arcade-input', onArcadeInput)
|
||||
if (gamepadPollId !== null) cancelAnimationFrame(gamepadPollId)
|
||||
Object.assign(p1Input, emptyInput())
|
||||
Object.assign(p2Input, emptyInput())
|
||||
}
|
||||
|
||||
onMounted(setup)
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return { p1Input, p2Input, cleanup }
|
||||
}
|
||||
@@ -87,7 +87,7 @@ function clearAllState() {
|
||||
store('bf_pic', null)
|
||||
setToken(null)
|
||||
sessionNsec = null
|
||||
localStorage.removeItem('bf_nsec')
|
||||
sessionStorage.removeItem('bf_nsec')
|
||||
}
|
||||
|
||||
const pubkey = ref<string | null>(loadStored('bf_pubkey'))
|
||||
@@ -202,7 +202,7 @@ export function useNostr() {
|
||||
const found = await waitForSigner(3000)
|
||||
if (!found) {
|
||||
// Fall back to session or persisted nsec if available
|
||||
const storedNsec = sessionNsec || localStorage.getItem('bf_nsec')
|
||||
const storedNsec = sessionNsec || sessionStorage.getItem('bf_nsec')
|
||||
if (storedNsec) {
|
||||
return loginWithNsec(storedNsec)
|
||||
}
|
||||
@@ -277,7 +277,7 @@ export function useNostr() {
|
||||
store('bf_pic', null)
|
||||
|
||||
sessionNsec = nsecHex
|
||||
if (persist) localStorage.setItem('bf_nsec', nsecHex)
|
||||
if (persist) sessionStorage.setItem('bf_nsec', nsecHex)
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -461,16 +461,16 @@ export function useNostr() {
|
||||
}
|
||||
|
||||
/** Check if user has a locally stored key (no extension needed) */
|
||||
const hasStoredKey = computed(() => !!localStorage.getItem('bf_nsec'))
|
||||
const hasStoredKey = computed(() => !!sessionStorage.getItem('bf_nsec'))
|
||||
|
||||
/** Get the current nsec hex (session memory first, then localStorage) */
|
||||
function getStoredNsec(): string | null {
|
||||
return sessionNsec || localStorage.getItem('bf_nsec')
|
||||
return sessionNsec || sessionStorage.getItem('bf_nsec')
|
||||
}
|
||||
|
||||
/** Persist the current session key to localStorage (opt-in) */
|
||||
function persistKey(): void {
|
||||
if (sessionNsec) localStorage.setItem('bf_nsec', sessionNsec)
|
||||
if (sessionNsec) sessionStorage.setItem('bf_nsec', sessionNsec)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,7 +76,7 @@ export function useWallet() {
|
||||
}
|
||||
|
||||
// Store NWC string locally for client-side payment sending
|
||||
try { localStorage.setItem('bf_nwc_url', connectionString) } catch { /* quota */ }
|
||||
try { sessionStorage.setItem('bf_nwc_url', connectionString) } catch { /* quota */ }
|
||||
walletMethod.value = 'nwc'
|
||||
isWalletConnected.value = true
|
||||
store('bf_wallet_method', 'nwc')
|
||||
@@ -123,7 +123,7 @@ export function useWallet() {
|
||||
paymentStatus.value = 'idle'
|
||||
pendingPayment.value = null
|
||||
store('bf_wallet_method', null)
|
||||
try { localStorage.removeItem('bf_nwc_url') } catch { /* quota */ }
|
||||
try { sessionStorage.removeItem('bf_nwc_url') } catch { /* quota */ }
|
||||
}
|
||||
|
||||
async function checkWalletStatus(): Promise<void> {
|
||||
@@ -166,7 +166,7 @@ export function useWallet() {
|
||||
}
|
||||
|
||||
// If NWC connected, auto-pay via NWC and confirm directly
|
||||
const nwcUrl = localStorage.getItem('bf_nwc_url')
|
||||
const nwcUrl = sessionStorage.getItem('bf_nwc_url')
|
||||
let nwcValid = false
|
||||
if (nwcUrl) {
|
||||
try { parseNwcUrl(nwcUrl); nwcValid = true } catch { /* bad stored URL — fall through to poll */ }
|
||||
|
||||
Reference in New Issue
Block a user