Merge Workstream F: unlocks/inventory UI + sigil designer + listening tool
This commit is contained in:
@@ -8,6 +8,7 @@ import { GhostLog } from './components/GhostLog'
|
|||||||
import { CodexEntityPage } from './pages/CodexEntityPage'
|
import { CodexEntityPage } from './pages/CodexEntityPage'
|
||||||
import { CodexPage } from './pages/CodexPage'
|
import { CodexPage } from './pages/CodexPage'
|
||||||
import { EnterPage } from './pages/EnterPage'
|
import { EnterPage } from './pages/EnterPage'
|
||||||
|
import { InventoryPage } from './pages/InventoryPage'
|
||||||
import { LandingPage } from './pages/LandingPage'
|
import { LandingPage } from './pages/LandingPage'
|
||||||
import { SeancePage } from './pages/SeancePage'
|
import { SeancePage } from './pages/SeancePage'
|
||||||
import { ShopPage } from './pages/ShopPage'
|
import { ShopPage } from './pages/ShopPage'
|
||||||
@@ -29,6 +30,7 @@ function App() {
|
|||||||
<Route path="/codex" element={<CodexPage />} />
|
<Route path="/codex" element={<CodexPage />} />
|
||||||
<Route path="/codex/:id" element={<CodexEntityPage />} />
|
<Route path="/codex/:id" element={<CodexEntityPage />} />
|
||||||
<Route path="/shop" element={<ShopPage />} />
|
<Route path="/shop" element={<ShopPage />} />
|
||||||
|
<Route path="/inventory" element={<InventoryPage />} />
|
||||||
<Route path="*" element={<LandingPage />} />
|
<Route path="*" element={<LandingPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
|
/** Unlock keys the user has earned (e.g. 'listening_tool'). Contract: GET /auth/me. */
|
||||||
|
unlocks: string[]
|
||||||
|
/** Current spendable currency balance. Contract: GET /auth/me. */
|
||||||
|
essence: number
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
|||||||
231
frontend/src/components/InventoryPanel.css
Normal file
231
frontend/src/components/InventoryPanel.css
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/* InventoryPanel — terminal item listing meets occult rarity-glow cards.
|
||||||
|
Reuses the app-wide palette (App.css): bg #07070d, panel rgba(16,16,26,.85),
|
||||||
|
phosphor #7cffb2, violet #b26bff, blood #ff3b5c, border rgba(124,255,178,.18).
|
||||||
|
Rarity glow borders mirror CodexPage.css's `.codex-rarity[data-rarity]`. */
|
||||||
|
|
||||||
|
.inv-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.6rem;
|
||||||
|
font-family: var(--qm-font-ui);
|
||||||
|
color: var(--qm-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- essence readout: big terminal stat, front and center ---- */
|
||||||
|
|
||||||
|
.inv-essence {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.9rem 1.2rem;
|
||||||
|
background: var(--qm-panel);
|
||||||
|
border: 1px solid var(--qm-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 22px rgba(124, 255, 178, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-essence-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.32em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--qm-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-essence-value {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--qm-green);
|
||||||
|
text-shadow: 0 0 14px rgba(124, 255, 178, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- sections ---- */
|
||||||
|
|
||||||
|
.inv-section-title {
|
||||||
|
font-family: var(--qm-font-display);
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #eef7f0;
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-state {
|
||||||
|
color: var(--qm-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-state-error {
|
||||||
|
color: var(--qm-blood);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- terminal-style item/unlock listing ---- */
|
||||||
|
|
||||||
|
.inv-term-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-term-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: rgba(16, 16, 26, 0.6);
|
||||||
|
border: 1px solid var(--qm-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-term-empty {
|
||||||
|
color: var(--qm-dim);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-key {
|
||||||
|
font-family: var(--qm-font-ui);
|
||||||
|
color: #eef7f0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-tag {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
padding: 0.12rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-tag-owned {
|
||||||
|
color: var(--qm-green);
|
||||||
|
border-color: rgba(124, 255, 178, 0.5);
|
||||||
|
text-shadow: 0 0 8px rgba(124, 255, 178, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-tag-locked {
|
||||||
|
color: var(--qm-dim);
|
||||||
|
border-color: rgba(138, 143, 152, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- catalog offer cards ---- */
|
||||||
|
|
||||||
|
.inv-offer-grid {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-offer-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
background: var(--qm-panel);
|
||||||
|
border: 1px solid var(--qm-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-offer-card.is-affordable {
|
||||||
|
border-color: rgba(178, 107, 255, 0.4);
|
||||||
|
box-shadow: 0 0 18px rgba(178, 107, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-offer-card.is-locked {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-price {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--qm-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-price-est {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-buy-btn {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
font-family: var(--qm-font-ui);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #0a0a12;
|
||||||
|
background: var(--qm-violet);
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 150ms ease, box-shadow 150ms ease, opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-buy-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 16px rgba(178, 107, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-buy-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
background: #3a3a46;
|
||||||
|
color: rgba(216, 230, 220, 0.5);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-offer-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--qm-blood);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- item grid: rarity-glow borders, same convention as CodexPage ---- */
|
||||||
|
|
||||||
|
.inv-item-grid {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-item-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
background: rgba(16, 16, 26, 0.7);
|
||||||
|
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-item-type {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--qm-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-item-card[data-rarity='common'] {
|
||||||
|
border-color: rgba(138, 143, 152, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-item-card[data-rarity='uncommon'] {
|
||||||
|
border-color: rgba(124, 255, 178, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-item-card[data-rarity='rare'] {
|
||||||
|
border-color: rgba(178, 107, 255, 0.5);
|
||||||
|
box-shadow: 0 0 14px rgba(178, 107, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-item-card[data-rarity='mythic'] {
|
||||||
|
border-color: rgba(255, 157, 74, 0.55);
|
||||||
|
box-shadow: 0 0 16px rgba(255, 157, 74, 0.3), inset 0 0 8px rgba(255, 157, 74, 0.12);
|
||||||
|
}
|
||||||
136
frontend/src/components/InventoryPanel.test.tsx
Normal file
136
frontend/src/components/InventoryPanel.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import '../i18n'
|
||||||
|
import { InventoryPanel } from './InventoryPanel'
|
||||||
|
|
||||||
|
function mockFetch(handlers: {
|
||||||
|
items?: unknown
|
||||||
|
itemsStatus?: number
|
||||||
|
catalog?: unknown
|
||||||
|
catalogStatus?: number
|
||||||
|
purchaseStatus?: number
|
||||||
|
}) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url.includes('/api/inventory/items')) {
|
||||||
|
return new Response(JSON.stringify(handlers.items ?? { items: [] }), {
|
||||||
|
status: handlers.itemsStatus ?? 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.includes('/api/inventory/catalog')) {
|
||||||
|
if (handlers.catalogStatus && handlers.catalogStatus !== 200) {
|
||||||
|
return new Response('not found', { status: handlers.catalogStatus })
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(handlers.catalog ?? { unlocks: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.includes('/api/inventory/unlocks/') && init?.method === 'POST') {
|
||||||
|
return new Response('{}', { status: handlers.purchaseStatus ?? 200 })
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InventoryPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the essence balance prominently', async () => {
|
||||||
|
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
|
||||||
|
render(<InventoryPanel unlocks={[]} essence={128} />)
|
||||||
|
expect(screen.getByTestId('essence-value').textContent).toBe('128')
|
||||||
|
await screen.findByRole('button', { name: /buy/i }) // let pending fetches settle
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists owned unlocks with an [OWNED] tag and hides them from the buy catalog', async () => {
|
||||||
|
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
|
||||||
|
render(<InventoryPanel unlocks={['listening_tool']} essence={5} />)
|
||||||
|
expect(await screen.findByText('listening_tool')).toBeTruthy()
|
||||||
|
expect(screen.getByText('[OWNED]')).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: /buy/i })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the buy button when the user cannot afford the unlock', async () => {
|
||||||
|
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
|
||||||
|
render(<InventoryPanel unlocks={[]} essence={10} />)
|
||||||
|
const btn = await screen.findByRole('button', { name: /buy/i })
|
||||||
|
await waitFor(() => expect(btn).toHaveProperty('disabled', true))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables the buy button when the user can afford the unlock', async () => {
|
||||||
|
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
|
||||||
|
render(<InventoryPanel unlocks={[]} essence={100} />)
|
||||||
|
const btn = await screen.findByRole('button', { name: /buy/i })
|
||||||
|
await waitFor(() => expect(btn).toHaveProperty('disabled', false))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a flagged estimated price when the catalog endpoint is unavailable', async () => {
|
||||||
|
mockFetch({ catalogStatus: 404 })
|
||||||
|
render(<InventoryPanel unlocks={[]} essence={100} />)
|
||||||
|
await waitFor(() => expect(screen.getByText(/\(est\.\)/)).toBeTruthy())
|
||||||
|
const btn = screen.getByRole('button', { name: /buy/i })
|
||||||
|
expect(btn).toHaveProperty('disabled', false) // 100 essence affords the 40 fallback price
|
||||||
|
})
|
||||||
|
|
||||||
|
it('purchasing successfully moves the unlock into the owned list and deducts essence', async () => {
|
||||||
|
mockFetch({
|
||||||
|
catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] },
|
||||||
|
purchaseStatus: 200,
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<InventoryPanel unlocks={[]} essence={100} />)
|
||||||
|
const btn = await screen.findByRole('button', { name: /buy/i })
|
||||||
|
await waitFor(() => expect(btn).toHaveProperty('disabled', false))
|
||||||
|
await user.click(btn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('essence-value').textContent).toBe('60'))
|
||||||
|
expect(screen.getByText('[OWNED]')).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: /buy/i })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an insufficient-funds message on a 402 without changing essence', async () => {
|
||||||
|
mockFetch({
|
||||||
|
catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] },
|
||||||
|
purchaseStatus: 402,
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<InventoryPanel unlocks={[]} essence={100} />)
|
||||||
|
const btn = await screen.findByRole('button', { name: /buy/i })
|
||||||
|
await waitFor(() => expect(btn).toHaveProperty('disabled', false))
|
||||||
|
await user.click(btn)
|
||||||
|
|
||||||
|
await screen.findByRole('alert')
|
||||||
|
expect(screen.getByTestId('essence-value').textContent).toBe('100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders items with a rarity data attribute when payload carries a known rarity', async () => {
|
||||||
|
mockFetch({
|
||||||
|
items: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item_type: 'trinket',
|
||||||
|
item_key: 'silver-locket',
|
||||||
|
payload: { rarity: 'rare' },
|
||||||
|
obtained_at: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<InventoryPanel unlocks={[]} essence={0} />)
|
||||||
|
const key = await screen.findByText('silver-locket')
|
||||||
|
const card = key.closest('[data-rarity]')
|
||||||
|
expect(card?.getAttribute('data-rarity')).toBe('rare')
|
||||||
|
})
|
||||||
|
})
|
||||||
261
frontend/src/components/InventoryPanel.tsx
Normal file
261
frontend/src/components/InventoryPanel.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
// InventoryPanel — "terminal item listing" crossed with occult rarity-glow
|
||||||
|
// cards (see CodexPage.css's `.codex-rarity[data-rarity]` convention, reused
|
||||||
|
// here). Shows the seeker's essence balance, their owned unlocks/items, and
|
||||||
|
// a small fixed catalog of purchasable unlocks with afford/can't-afford
|
||||||
|
// buy buttons.
|
||||||
|
//
|
||||||
|
// Backend-shape note (see report): the contract only names
|
||||||
|
// GET /auth/me { unlocks: string[], essence: number } and
|
||||||
|
// POST /api/inventory/unlocks/{unlock_key} explicitly. It does not define a
|
||||||
|
// REST shape for *listing* a user's items, nor for exposing per-unlock
|
||||||
|
// prices. This component:
|
||||||
|
// - fetches GET /api/inventory/items for the item list (reasonable REST
|
||||||
|
// extension of "list a user's ... items" per Workstream C's brief),
|
||||||
|
// - optimistically fetches GET /api/inventory/catalog for prices, and
|
||||||
|
// falls back to FALLBACK_PRICES (sourced directly from the spec's own
|
||||||
|
// worked example, `{"listening_tool": 40}`) — flagged "(est.)" in the
|
||||||
|
// UI — when that endpoint isn't available yet.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { InventoryItem, Rarity } from '../lib/types'
|
||||||
|
import './InventoryPanel.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The small fixed catalog of unlocks this panel offers. "listening_tool" is
|
||||||
|
* the only unlock key named anywhere in the contract; more can be appended
|
||||||
|
* here once other workstreams define further unlock keys.
|
||||||
|
*/
|
||||||
|
const OFFERED_UNLOCK_KEYS: readonly string[] = ['listening_tool']
|
||||||
|
|
||||||
|
/** Fallback prices, used only if /api/inventory/catalog is unavailable. */
|
||||||
|
const FALLBACK_PRICES: Record<string, number> = { listening_tool: 40 }
|
||||||
|
|
||||||
|
type CatalogState = 'loading' | 'live' | 'fallback'
|
||||||
|
|
||||||
|
export type InventoryPanelProps = {
|
||||||
|
/** Current unlock keys owned by the user (from GET /auth/me). */
|
||||||
|
unlocks: string[]
|
||||||
|
/** Current spendable essence balance (from GET /auth/me). */
|
||||||
|
essence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRarity(x: unknown): x is Rarity {
|
||||||
|
return x === 'common' || x === 'uncommon' || x === 'rare' || x === 'mythic'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryPanel({ unlocks, essence: initialEssence }: InventoryPanelProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [ownedUnlocks, setOwnedUnlocks] = useState<string[]>(unlocks)
|
||||||
|
const [essence, setEssence] = useState(initialEssence)
|
||||||
|
const [items, setItems] = useState<InventoryItem[]>([])
|
||||||
|
const [itemsState, setItemsState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||||
|
const [prices, setPrices] = useState<Record<string, number>>(FALLBACK_PRICES)
|
||||||
|
const [catalogState, setCatalogState] = useState<CatalogState>('loading')
|
||||||
|
const [purchasing, setPurchasing] = useState<string | null>(null)
|
||||||
|
const [purchaseError, setPurchaseError] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
// Keep local state in sync if the parent's auth snapshot changes (e.g.
|
||||||
|
// after a full /auth/me refresh elsewhere in the app).
|
||||||
|
useEffect(() => setOwnedUnlocks(unlocks), [unlocks])
|
||||||
|
useEffect(() => setEssence(initialEssence), [initialEssence])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
fetch('/api/inventory/items', { credentials: 'include', signal: controller.signal })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`items fetch failed: ${res.status}`)
|
||||||
|
return (await res.json()) as { items: InventoryItem[] }
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setItems(data.items ?? [])
|
||||||
|
setItemsState('ready')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!controller.signal.aborted) setItemsState('error')
|
||||||
|
})
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
fetch('/api/inventory/catalog', { credentials: 'include', signal: controller.signal })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`catalog fetch failed: ${res.status}`)
|
||||||
|
return (await res.json()) as { unlocks: { unlock_key: string; price: number }[] }
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
const live: Record<string, number> = { ...FALLBACK_PRICES }
|
||||||
|
for (const entry of data.unlocks ?? []) live[entry.unlock_key] = entry.price
|
||||||
|
setPrices(live)
|
||||||
|
setCatalogState('live')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setPrices(FALLBACK_PRICES)
|
||||||
|
setCatalogState('fallback')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const buy = useCallback(
|
||||||
|
async (unlockKey: string) => {
|
||||||
|
if (purchasing) return
|
||||||
|
setPurchasing(unlockKey)
|
||||||
|
setPurchaseError((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[unlockKey]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/inventory/unlocks/${encodeURIComponent(unlockKey)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (res.status === 402) {
|
||||||
|
setPurchaseError((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[unlockKey]: t('inventory.buyInsufficient', {
|
||||||
|
defaultValue: 'not enough essence',
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
setPurchaseError((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[unlockKey]: t('inventory.buyError', { defaultValue: 'the purchase failed' }),
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const price = prices[unlockKey] ?? 0
|
||||||
|
setOwnedUnlocks((prev) => (prev.includes(unlockKey) ? prev : [...prev, unlockKey]))
|
||||||
|
setEssence((prev) => Math.max(0, prev - price))
|
||||||
|
} catch {
|
||||||
|
setPurchaseError((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[unlockKey]: t('inventory.buyError', { defaultValue: 'the purchase failed' }),
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
setPurchasing(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[prices, purchasing, t],
|
||||||
|
)
|
||||||
|
|
||||||
|
const offers = OFFERED_UNLOCK_KEYS.filter((key) => !ownedUnlocks.includes(key))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="inv-panel" aria-label={t('inventory.title', { defaultValue: 'inventory' })}>
|
||||||
|
<div className="inv-essence" role="status">
|
||||||
|
<span className="inv-essence-label">
|
||||||
|
{t('inventory.essenceLabel', { defaultValue: 'essence' })}
|
||||||
|
</span>
|
||||||
|
<span className="inv-essence-value" data-testid="essence-value">
|
||||||
|
{essence}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inv-section">
|
||||||
|
<h3 className="inv-section-title">
|
||||||
|
{t('inventory.unlocksTitle', { defaultValue: 'unlocks' })}
|
||||||
|
</h3>
|
||||||
|
<ul className="inv-term-list">
|
||||||
|
{ownedUnlocks.length === 0 && (
|
||||||
|
<li className="inv-term-empty">
|
||||||
|
{t('inventory.noUnlocks', { defaultValue: 'nothing unlocked yet' })}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{ownedUnlocks.map((key) => (
|
||||||
|
<li className="inv-term-row" key={key}>
|
||||||
|
<span className="inv-tag inv-tag-owned">
|
||||||
|
{t('inventory.ownedTag', { defaultValue: '[OWNED]' })}
|
||||||
|
</span>
|
||||||
|
<span className="inv-key">{key}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{offers.length > 0 && (
|
||||||
|
<ul className="inv-offer-grid">
|
||||||
|
{offers.map((key) => {
|
||||||
|
const price = prices[key]
|
||||||
|
const affordable = typeof price === 'number' && essence >= price
|
||||||
|
const disabled = purchasing === key || typeof price !== 'number' || !affordable
|
||||||
|
return (
|
||||||
|
<li className={`inv-offer-card${affordable ? ' is-affordable' : ' is-locked'}`} key={key}>
|
||||||
|
<span className="inv-tag inv-tag-locked">
|
||||||
|
{t('inventory.lockedTag', { defaultValue: '[LOCKED]' })}
|
||||||
|
</span>
|
||||||
|
<span className="inv-key">{key}</span>
|
||||||
|
<span className="inv-price">
|
||||||
|
{typeof price === 'number'
|
||||||
|
? t('inventory.priceValue', { price, defaultValue: '{{price}} essence' })
|
||||||
|
: t('inventory.priceUnknown', { defaultValue: 'price unknown' })}
|
||||||
|
{catalogState === 'fallback' && typeof price === 'number' && (
|
||||||
|
<span className="inv-price-est">
|
||||||
|
{' '}
|
||||||
|
{t('inventory.priceEstimate', { defaultValue: '(est.)' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inv-buy-btn"
|
||||||
|
disabled={disabled}
|
||||||
|
data-affordable={affordable}
|
||||||
|
onClick={() => void buy(key)}
|
||||||
|
>
|
||||||
|
{purchasing === key
|
||||||
|
? t('inventory.buying', { defaultValue: 'buying…' })
|
||||||
|
: t('inventory.buy', { defaultValue: 'buy' })}
|
||||||
|
</button>
|
||||||
|
{purchaseError[key] && (
|
||||||
|
<p className="inv-offer-error" role="alert">
|
||||||
|
{purchaseError[key]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inv-section">
|
||||||
|
<h3 className="inv-section-title">
|
||||||
|
{t('inventory.itemsTitle', { defaultValue: 'items' })}
|
||||||
|
</h3>
|
||||||
|
{itemsState === 'loading' && (
|
||||||
|
<p className="inv-state">{t('inventory.itemsLoading', { defaultValue: 'reading the shelf…' })}</p>
|
||||||
|
)}
|
||||||
|
{itemsState === 'error' && (
|
||||||
|
<p className="inv-state inv-state-error" role="alert">
|
||||||
|
{t('common.error')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{itemsState === 'ready' && items.length === 0 && (
|
||||||
|
<p className="inv-state">{t('inventory.itemsEmpty', { defaultValue: 'nothing collected yet' })}</p>
|
||||||
|
)}
|
||||||
|
{itemsState === 'ready' && items.length > 0 && (
|
||||||
|
<ul className="inv-item-grid">
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const rarity = isRarity(item.payload?.rarity) ? item.payload.rarity : null
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className="inv-item-card"
|
||||||
|
data-rarity={rarity ?? undefined}
|
||||||
|
key={`${item.item_type}-${item.item_key}-${i}`}
|
||||||
|
>
|
||||||
|
<span className="inv-item-type">{item.item_type}</span>
|
||||||
|
<span className="inv-key">{item.item_key}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
242
frontend/src/components/SigilDesigner.css
Normal file
242
frontend/src/components/SigilDesigner.css
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/* SigilDesigner — "hacker witch" fusion: the builder reads like a plotting/
|
||||||
|
debug tool (crosshair cursor, monospace coordinate HUD, phosphor grid),
|
||||||
|
while the constructed result (lines + rune) renders as a soft violet-
|
||||||
|
bloom occult sigil. Palette matches App.css: bg #07070d, panel
|
||||||
|
rgba(16,16,26,.85), phosphor #7cffb2, violet #b26bff, border
|
||||||
|
rgba(124,255,178,.18). */
|
||||||
|
|
||||||
|
.sigil-designer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 340px) minmax(200px, 1fr);
|
||||||
|
gap: 1.6rem;
|
||||||
|
font-family: var(--qm-font-ui);
|
||||||
|
color: var(--qm-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.sigil-designer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- canvas column ---- */
|
||||||
|
|
||||||
|
.sigil-canvas-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-canvas {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: radial-gradient(ellipse at 50% 40%, rgba(178, 107, 255, 0.07), transparent 65%), var(--qm-bg);
|
||||||
|
border: 1px solid var(--qm-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-guide-circle {
|
||||||
|
stroke: rgba(124, 255, 178, 0.28);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 2 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-guide-axis {
|
||||||
|
stroke: rgba(124, 255, 178, 0.12);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mystical result layer: violet bloom */
|
||||||
|
.sigil-lines line {
|
||||||
|
stroke: var(--qm-violet);
|
||||||
|
stroke-width: 1.4;
|
||||||
|
stroke-linecap: round;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-rune {
|
||||||
|
color: var(--qm-violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-rune path,
|
||||||
|
.sigil-rune circle {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* clickable slots: technical/precise plotting-tool feel */
|
||||||
|
.sigil-slot-hit {
|
||||||
|
fill: transparent;
|
||||||
|
stroke: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-slot-dot {
|
||||||
|
fill: rgba(124, 255, 178, 0.55);
|
||||||
|
stroke: none;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: r 120ms ease, fill 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-slot:hover .sigil-slot-dot {
|
||||||
|
fill: #eef7f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-slot.is-active .sigil-slot-dot {
|
||||||
|
fill: var(--qm-violet);
|
||||||
|
filter: drop-shadow(0 0 4px rgba(178, 107, 255, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- debug-tool coordinate HUD ---- */
|
||||||
|
|
||||||
|
.sigil-hud {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(16, 16, 26, 0.7);
|
||||||
|
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-hud-label {
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--qm-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-hud-value {
|
||||||
|
color: var(--qm-green);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-hud-value.dim {
|
||||||
|
color: var(--qm-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- controls column ---- */
|
||||||
|
|
||||||
|
.sigil-controls-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-rune-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-rune-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.6rem 0.4rem;
|
||||||
|
background: var(--qm-panel);
|
||||||
|
border: 1px solid var(--qm-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--qm-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-rune-btn:hover {
|
||||||
|
border-color: rgba(178, 107, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-rune-btn.is-active {
|
||||||
|
color: var(--qm-violet);
|
||||||
|
border-color: rgba(178, 107, 255, 0.6);
|
||||||
|
box-shadow: 0 0 14px rgba(178, 107, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-rune-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-rune-icon path,
|
||||||
|
.sigil-rune-icon circle {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-rune-name {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-name-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--qm-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-name-input {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
background: rgba(16, 16, 26, 0.7);
|
||||||
|
border: 1px solid var(--qm-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--qm-text);
|
||||||
|
font-family: var(--qm-font-ui);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-name-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(178, 107, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-save-btn {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-family: var(--qm-font-ui);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #0a0a12;
|
||||||
|
background: var(--qm-violet);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 150ms ease, box-shadow 150ms ease, opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-save-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 18px rgba(178, 107, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-save-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
background: #3a3a46;
|
||||||
|
color: rgba(216, 230, 220, 0.5);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-status.is-saved {
|
||||||
|
color: var(--qm-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-status.is-error {
|
||||||
|
color: var(--qm-blood);
|
||||||
|
}
|
||||||
134
frontend/src/components/SigilDesigner.test.tsx
Normal file
134
frontend/src/components/SigilDesigner.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import '../i18n'
|
||||||
|
import { SigilDesigner } from './SigilDesigner'
|
||||||
|
import { RUNES, SIGIL_MAX_POINTS, SIGIL_SLOT_COUNT } from '../lib/sigil'
|
||||||
|
|
||||||
|
function anchorButtons() {
|
||||||
|
return screen.getAllByRole('button', { name: /anchor point/i })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SigilDesigner', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all 24 clock-face anchor slots and starts with zero points', () => {
|
||||||
|
render(<SigilDesigner />)
|
||||||
|
expect(anchorButtons()).toHaveLength(SIGIL_SLOT_COUNT)
|
||||||
|
expect(screen.getByTestId('sigil-point-count').textContent).toBe(`0/${SIGIL_MAX_POINTS}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a point on click, up to the 12-point cap, and refuses a 13th', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<SigilDesigner />)
|
||||||
|
const anchors = anchorButtons()
|
||||||
|
|
||||||
|
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
|
||||||
|
await user.click(anchors[i])
|
||||||
|
}
|
||||||
|
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
|
||||||
|
`${SIGIL_MAX_POINTS}/${SIGIL_MAX_POINTS}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// clicking a 13th, distinct, previously-unselected slot must be a no-op.
|
||||||
|
await user.click(anchors[SIGIL_MAX_POINTS])
|
||||||
|
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
|
||||||
|
`${SIGIL_MAX_POINTS}/${SIGIL_MAX_POINTS}`,
|
||||||
|
)
|
||||||
|
expect(anchors[SIGIL_MAX_POINTS]).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggling a placed point removes it, freeing a slot even while capped', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<SigilDesigner />)
|
||||||
|
const anchors = anchorButtons()
|
||||||
|
|
||||||
|
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
|
||||||
|
await user.click(anchors[i])
|
||||||
|
}
|
||||||
|
await user.click(anchors[0]) // remove the first point
|
||||||
|
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
|
||||||
|
`${SIGIL_MAX_POINTS - 1}/${SIGIL_MAX_POINTS}`,
|
||||||
|
)
|
||||||
|
expect(anchors[0]).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
|
||||||
|
await user.click(anchors[SIGIL_MAX_POINTS]) // now there's room
|
||||||
|
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
|
||||||
|
`${SIGIL_MAX_POINTS}/${SIGIL_MAX_POINTS}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the fixed rune set and lets the seeker pick one', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<SigilDesigner />)
|
||||||
|
|
||||||
|
// First rune defaults active.
|
||||||
|
const firstBtn = screen.getByRole('button', { name: RUNES[0].label })
|
||||||
|
expect(firstBtn).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
|
||||||
|
const secondBtn = screen.getByRole('button', { name: RUNES[1].label })
|
||||||
|
expect(secondBtn).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
|
||||||
|
await user.click(secondBtn)
|
||||||
|
expect(secondBtn).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
expect(firstBtn).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save is disabled with zero points, and posts the {points, rune} payload shape', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 's1',
|
||||||
|
name: 'untitled sigil',
|
||||||
|
design: { points: [], rune: 'eye' },
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<SigilDesigner />)
|
||||||
|
|
||||||
|
const saveBtn = screen.getByRole('button', { name: /inscribe sigil/i })
|
||||||
|
expect(saveBtn).toHaveProperty('disabled', true)
|
||||||
|
|
||||||
|
const anchors = anchorButtons()
|
||||||
|
await user.click(anchors[0])
|
||||||
|
await user.click(anchors[6])
|
||||||
|
expect(saveBtn).toHaveProperty('disabled', false)
|
||||||
|
|
||||||
|
await user.click(saveBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||||
|
const [url, init] = fetchMock.mock.calls[0]
|
||||||
|
expect(String(url)).toBe('/api/inventory/sigils')
|
||||||
|
expect(init?.method).toBe('POST')
|
||||||
|
const body = JSON.parse(String(init?.body))
|
||||||
|
expect(body.design.rune).toBe(RUNES[0].id)
|
||||||
|
expect(Array.isArray(body.design.points)).toBe(true)
|
||||||
|
expect(body.design.points).toHaveLength(2)
|
||||||
|
for (const p of body.design.points) {
|
||||||
|
expect(p).toHaveLength(2)
|
||||||
|
expect(typeof p[0]).toBe('number')
|
||||||
|
expect(typeof p[1]).toBe('number')
|
||||||
|
}
|
||||||
|
|
||||||
|
await screen.findByText(/bound/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error state when saving fails', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => new Response('nope', { status: 500 })),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<SigilDesigner />)
|
||||||
|
await user.click(anchorButtons()[0])
|
||||||
|
await user.click(screen.getByRole('button', { name: /inscribe sigil/i }))
|
||||||
|
await screen.findByRole('alert')
|
||||||
|
})
|
||||||
|
})
|
||||||
271
frontend/src/components/SigilDesigner.tsx
Normal file
271
frontend/src/components/SigilDesigner.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
// SigilDesigner — a constrained geometric builder, not freeform drawing.
|
||||||
|
// The seeker clicks fixed clock-face slots around a circle (cap 12 points,
|
||||||
|
// matching Workstream C's payload limit — see lib/sigil.ts), the tool
|
||||||
|
// connects them in placement order, and a rune overlay is chosen from a
|
||||||
|
// small fixed set.
|
||||||
|
//
|
||||||
|
// Art direction: the *process* reads like a debugging/plotting tool —
|
||||||
|
// crosshair cursor over the circle, a monospace coordinate readout HUD,
|
||||||
|
// slot numbers — while the *result* (lines + rune) renders as a soft
|
||||||
|
// violet-bloom occult sigil, consistent with GhostGlyph.tsx's glow
|
||||||
|
// convention (dark ground, radial glow, hsl violet).
|
||||||
|
//
|
||||||
|
// Saves via Workstream C's POST endpoint (path inferred — see report) with
|
||||||
|
// the contract payload shape `{"points": [[x,y],...], "rune": str}`.
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { ReactElement } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
RUNES,
|
||||||
|
SIGIL_MAX_POINTS,
|
||||||
|
SIGIL_SLOT_COUNT,
|
||||||
|
buildDesign,
|
||||||
|
slotDegrees,
|
||||||
|
slotPoint,
|
||||||
|
} from '../lib/sigil'
|
||||||
|
import type { RuneId } from '../lib/sigil'
|
||||||
|
import type { SigilRecord } from '../lib/types'
|
||||||
|
import './SigilDesigner.css'
|
||||||
|
|
||||||
|
const VIEWBOX = 240
|
||||||
|
const CENTER = VIEWBOX / 2
|
||||||
|
const RADIUS = 92
|
||||||
|
const SLOT_HIT_R = 10
|
||||||
|
|
||||||
|
/** Simple stroke-only rune glyphs, drawn in a -12..12 local box (see <g transform>). */
|
||||||
|
const RUNE_PATHS: Record<RuneId, ReactElement> = {
|
||||||
|
eye: (
|
||||||
|
<>
|
||||||
|
<path d="M-11 0 C-6 -8 6 -8 11 0 C6 8 -6 8 -11 0 Z" />
|
||||||
|
<circle cx="0" cy="0" r="2.6" fill="currentColor" stroke="none" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
crescent: <path d="M4 -11 A11 11 0 1 0 4 11 A8 8 0 1 1 4 -11 Z" />,
|
||||||
|
key: (
|
||||||
|
<>
|
||||||
|
<circle cx="-6" cy="0" r="5" />
|
||||||
|
<path d="M-1 0 H11 M6 0 V4 M9 0 V3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
spiral: <path d="M0 0 C0 -6 6 -6 6 0 C6 5 -3 6 -3 0 C-3 -3 1 -3.5 1 0" />,
|
||||||
|
thorn: <path d="M0 -12 V12 M0 -6 L7 -11 M0 -1 L-7 -6 M0 4 L7 -1 M0 9 L-7 4" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SigilDesignerProps = {
|
||||||
|
/** Called after a successful save. */
|
||||||
|
onSaved?: (sigil: SigilRecord) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SigilDesigner({ onSaved }: SigilDesignerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [selectedSlots, setSelectedSlots] = useState<number[]>([])
|
||||||
|
const [rune, setRune] = useState<RuneId>(RUNES[0].id)
|
||||||
|
const [hoverSlot, setHoverSlot] = useState<number | null>(null)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [status, setStatus] = useState<'idle' | 'pending' | 'saved' | 'error'>('idle')
|
||||||
|
|
||||||
|
const toSvg = (slot: number): [number, number] => {
|
||||||
|
const [ux, uy] = slotPoint(slot, SIGIL_SLOT_COUNT)
|
||||||
|
return [CENTER + ux * RADIUS, CENTER + uy * RADIUS]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (slot: number) => {
|
||||||
|
setSelectedSlots((prev) => {
|
||||||
|
if (prev.includes(slot)) return prev.filter((s) => s !== slot)
|
||||||
|
if (prev.length >= SIGIL_MAX_POINTS) return prev
|
||||||
|
return [...prev, slot]
|
||||||
|
})
|
||||||
|
setStatus('idle')
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (status === 'pending') return
|
||||||
|
setStatus('pending')
|
||||||
|
try {
|
||||||
|
const design = buildDesign(selectedSlots, rune)
|
||||||
|
const res = await fetch('/api/inventory/sigils', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name.trim() || 'untitled sigil', design }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`save failed: ${res.status}`)
|
||||||
|
const saved = (await res.json()) as SigilRecord
|
||||||
|
setStatus('saved')
|
||||||
|
onSaved?.(saved)
|
||||||
|
} catch {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hovered = hoverSlot !== null ? slotPoint(hoverSlot, SIGIL_SLOT_COUNT) : null
|
||||||
|
const lastSlot = selectedSlots.length > 0 ? selectedSlots[selectedSlots.length - 1] : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="sigil-designer" aria-label={t('inventory.sigil.title', { defaultValue: 'sigil designer' })}>
|
||||||
|
<div className="sigil-canvas-col">
|
||||||
|
<svg
|
||||||
|
className="sigil-canvas"
|
||||||
|
viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={t('inventory.sigil.canvasLabel', { defaultValue: 'circle of anchor points' })}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<filter id="sigil-glow" x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="3.2" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* ---- technical grid layer: plotting-tool feel ---- */}
|
||||||
|
<circle
|
||||||
|
className="sigil-guide-circle"
|
||||||
|
cx={CENTER}
|
||||||
|
cy={CENTER}
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<line className="sigil-guide-axis" x1={CENTER} y1={CENTER - RADIUS - 8} x2={CENTER} y2={CENTER + RADIUS + 8} />
|
||||||
|
<line className="sigil-guide-axis" x1={CENTER - RADIUS - 8} y1={CENTER} x2={CENTER + RADIUS + 8} y2={CENTER} />
|
||||||
|
|
||||||
|
{/* ---- mystical result layer: connecting lines + rune, violet glow ---- */}
|
||||||
|
<g className="sigil-lines" filter="url(#sigil-glow)">
|
||||||
|
{selectedSlots.slice(1).map((slot, i) => {
|
||||||
|
const [x1, y1] = toSvg(selectedSlots[i])
|
||||||
|
const [x2, y2] = toSvg(slot)
|
||||||
|
return <line key={`${selectedSlots[i]}-${slot}`} x1={x1} y1={y1} x2={x2} y2={y2} />
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g className="sigil-rune" filter="url(#sigil-glow)" transform={`translate(${CENTER} ${CENTER})`}>
|
||||||
|
{RUNE_PATHS[rune]}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* ---- clickable anchor slots ---- */}
|
||||||
|
{Array.from({ length: SIGIL_SLOT_COUNT }, (_, slot) => {
|
||||||
|
const [x, y] = toSvg(slot)
|
||||||
|
const active = selectedSlots.includes(slot)
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={slot}
|
||||||
|
className={`sigil-slot${active ? ' is-active' : ''}`}
|
||||||
|
onMouseEnter={() => setHoverSlot(slot)}
|
||||||
|
onMouseLeave={() => setHoverSlot((s) => (s === slot ? null : s))}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={SLOT_HIT_R}
|
||||||
|
className="sigil-slot-hit"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={t('inventory.sigil.slotLabel', {
|
||||||
|
slot,
|
||||||
|
defaultValue: 'anchor point {{slot}}',
|
||||||
|
})}
|
||||||
|
onClick={() => toggle(slot)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
toggle(slot)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<circle cx={x} cy={y} r={active ? 4.5 : 2.5} className="sigil-slot-dot" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* ---- debug-tool coordinate HUD ---- */}
|
||||||
|
<div className="sigil-hud" role="status">
|
||||||
|
<span className="sigil-hud-label">
|
||||||
|
{t('inventory.sigil.coordLabel', { defaultValue: 'coords' })}
|
||||||
|
</span>
|
||||||
|
{hovered ? (
|
||||||
|
<span className="sigil-hud-value">
|
||||||
|
SLOT {String(hoverSlot).padStart(2, '0')} · θ={slotDegrees(hoverSlot ?? 0, SIGIL_SLOT_COUNT)}°
|
||||||
|
· x={hovered[0].toFixed(3)} y={hovered[1].toFixed(3)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="sigil-hud-value dim">
|
||||||
|
{t('inventory.sigil.hoverHint', { defaultValue: 'hover an anchor point' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sigil-hud">
|
||||||
|
<span className="sigil-hud-label">
|
||||||
|
{t('inventory.sigil.pointsLabel', { defaultValue: 'points' })}
|
||||||
|
</span>
|
||||||
|
<span className="sigil-hud-value" data-testid="sigil-point-count">
|
||||||
|
{selectedSlots.length}/{SIGIL_MAX_POINTS}
|
||||||
|
</span>
|
||||||
|
{lastSlot !== null && (
|
||||||
|
<span className="sigil-hud-value dim">
|
||||||
|
{t('inventory.sigil.lastLabel', { defaultValue: 'last' })}: {lastSlot}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sigil-controls-col">
|
||||||
|
<div className="sigil-rune-picker" role="group" aria-label={t('inventory.sigil.runeLabel', { defaultValue: 'rune' })}>
|
||||||
|
{RUNES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
className={`sigil-rune-btn${rune === r.id ? ' is-active' : ''}`}
|
||||||
|
aria-pressed={rune === r.id}
|
||||||
|
onClick={() => setRune(r.id)}
|
||||||
|
title={r.label}
|
||||||
|
>
|
||||||
|
<svg viewBox="-12 -12 24 24" className="sigil-rune-icon" aria-hidden="true">
|
||||||
|
{RUNE_PATHS[r.id]}
|
||||||
|
</svg>
|
||||||
|
<span className="sigil-rune-name">{r.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="sigil-name-label" htmlFor="sigil-name-input">
|
||||||
|
{t('inventory.sigil.nameLabel', { defaultValue: 'name' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="sigil-name-input"
|
||||||
|
className="sigil-name-input"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
maxLength={60}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t('inventory.sigil.namePlaceholder', { defaultValue: 'untitled sigil' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sigil-save-btn"
|
||||||
|
disabled={status === 'pending' || selectedSlots.length === 0}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{status === 'pending'
|
||||||
|
? t('inventory.sigil.saving', { defaultValue: 'inscribing…' })
|
||||||
|
: t('inventory.sigil.save', { defaultValue: 'inscribe sigil' })}
|
||||||
|
</button>
|
||||||
|
{status === 'saved' && (
|
||||||
|
<p className="sigil-status is-saved" role="status">
|
||||||
|
{t('inventory.sigil.saved', { defaultValue: 'the sigil is bound' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<p className="sigil-status is-error" role="alert">
|
||||||
|
{t('inventory.sigil.error', { defaultValue: 'the sigil would not hold' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"home": "the threshold",
|
"home": "the threshold",
|
||||||
"codex": "codex",
|
"codex": "codex",
|
||||||
"shop": "shop",
|
"shop": "shop",
|
||||||
|
"inventory": "inventory",
|
||||||
"seance": "séance",
|
"seance": "séance",
|
||||||
"enter": "enter",
|
"enter": "enter",
|
||||||
"logout": "depart"
|
"logout": "depart"
|
||||||
@@ -405,6 +406,43 @@
|
|||||||
},
|
},
|
||||||
"honesty": "the Ultimate Quantum Box is in active development — this is a reservation manifest, no payment is taken. the website works fully without it; hardware only knocks louder."
|
"honesty": "the Ultimate Quantum Box is in active development — this is a reservation manifest, no payment is taken. the website works fully without it; hardware only knocks louder."
|
||||||
},
|
},
|
||||||
|
"inventory": {
|
||||||
|
"back": "← back to the séance",
|
||||||
|
"pageTitle": "THE SHELF",
|
||||||
|
"pageSubtitle": "what the veil has left you, and what it will still trade for",
|
||||||
|
"title": "inventory",
|
||||||
|
"essenceLabel": "essence",
|
||||||
|
"unlocksTitle": "unlocks",
|
||||||
|
"noUnlocks": "nothing unlocked yet",
|
||||||
|
"ownedTag": "[OWNED]",
|
||||||
|
"lockedTag": "[LOCKED]",
|
||||||
|
"priceValue": "{{price}} essence",
|
||||||
|
"priceUnknown": "price unknown",
|
||||||
|
"priceEstimate": "(est.)",
|
||||||
|
"buy": "buy",
|
||||||
|
"buying": "buying…",
|
||||||
|
"buyInsufficient": "not enough essence",
|
||||||
|
"buyError": "the purchase failed",
|
||||||
|
"itemsTitle": "items",
|
||||||
|
"itemsLoading": "reading the shelf…",
|
||||||
|
"itemsEmpty": "nothing collected yet",
|
||||||
|
"sigil": {
|
||||||
|
"title": "sigil designer",
|
||||||
|
"canvasLabel": "circle of anchor points",
|
||||||
|
"slotLabel": "anchor point {{slot}}",
|
||||||
|
"coordLabel": "coords",
|
||||||
|
"hoverHint": "hover an anchor point",
|
||||||
|
"pointsLabel": "points",
|
||||||
|
"lastLabel": "last",
|
||||||
|
"runeLabel": "rune",
|
||||||
|
"nameLabel": "name",
|
||||||
|
"namePlaceholder": "untitled sigil",
|
||||||
|
"save": "inscribe sigil",
|
||||||
|
"saving": "inscribing…",
|
||||||
|
"saved": "the sigil is bound",
|
||||||
|
"error": "the sigil would not hold"
|
||||||
|
}
|
||||||
|
},
|
||||||
"haunting": {
|
"haunting": {
|
||||||
"whisper": {
|
"whisper": {
|
||||||
"behindYou": "behind you",
|
"behindYou": "behind you",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"home": "el umbral",
|
"home": "el umbral",
|
||||||
"codex": "códice",
|
"codex": "códice",
|
||||||
"shop": "relicario",
|
"shop": "relicario",
|
||||||
|
"inventory": "inventario",
|
||||||
"seance": "sesión",
|
"seance": "sesión",
|
||||||
"enter": "entrar",
|
"enter": "entrar",
|
||||||
"logout": "partir"
|
"logout": "partir"
|
||||||
@@ -405,6 +406,43 @@
|
|||||||
},
|
},
|
||||||
"honesty": "la Ultimate Quantum Box está en desarrollo activo — esto es un manifiesto de reserva, no se cobra nada. el sitio funciona por completo sin ella; el hardware solo golpea más fuerte."
|
"honesty": "la Ultimate Quantum Box está en desarrollo activo — esto es un manifiesto de reserva, no se cobra nada. el sitio funciona por completo sin ella; el hardware solo golpea más fuerte."
|
||||||
},
|
},
|
||||||
|
"inventory": {
|
||||||
|
"back": "← volver a la sesión",
|
||||||
|
"pageTitle": "EL ESTANTE",
|
||||||
|
"pageSubtitle": "lo que el velo te ha dejado, y lo que aún cambiará por más",
|
||||||
|
"title": "inventario",
|
||||||
|
"essenceLabel": "esencia",
|
||||||
|
"unlocksTitle": "desbloqueos",
|
||||||
|
"noUnlocks": "nada desbloqueado todavía",
|
||||||
|
"ownedTag": "[POSEÍDO]",
|
||||||
|
"lockedTag": "[BLOQUEADO]",
|
||||||
|
"priceValue": "{{price}} esencia",
|
||||||
|
"priceUnknown": "precio desconocido",
|
||||||
|
"priceEstimate": "(est.)",
|
||||||
|
"buy": "comprar",
|
||||||
|
"buying": "comprando…",
|
||||||
|
"buyInsufficient": "no hay esencia suficiente",
|
||||||
|
"buyError": "la compra falló",
|
||||||
|
"itemsTitle": "objetos",
|
||||||
|
"itemsLoading": "leyendo el estante…",
|
||||||
|
"itemsEmpty": "nada recolectado todavía",
|
||||||
|
"sigil": {
|
||||||
|
"title": "diseñador de sigilos",
|
||||||
|
"canvasLabel": "círculo de puntos de anclaje",
|
||||||
|
"slotLabel": "punto de anclaje {{slot}}",
|
||||||
|
"coordLabel": "coords",
|
||||||
|
"hoverHint": "pasa el cursor sobre un punto de anclaje",
|
||||||
|
"pointsLabel": "puntos",
|
||||||
|
"lastLabel": "último",
|
||||||
|
"runeLabel": "runa",
|
||||||
|
"nameLabel": "nombre",
|
||||||
|
"namePlaceholder": "sigilo sin título",
|
||||||
|
"save": "inscribir sigilo",
|
||||||
|
"saving": "inscribiendo…",
|
||||||
|
"saved": "el sigilo quedó ligado",
|
||||||
|
"error": "el sigilo no se sostuvo"
|
||||||
|
}
|
||||||
|
},
|
||||||
"haunting": {
|
"haunting": {
|
||||||
"whisper": {
|
"whisper": {
|
||||||
"behindYou": "detrás de ti",
|
"behindYou": "detrás de ti",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { EvpDetectorCore } from './evp'
|
import {
|
||||||
|
EVP_BASE_THRESHOLD_DB,
|
||||||
|
EVP_LISTENING_TOOL_THRESHOLD_DB,
|
||||||
|
EvpDetectorCore,
|
||||||
|
evpThresholdDb,
|
||||||
|
} from './evp'
|
||||||
import type { EvpAnomaly } from './evp'
|
import type { EvpAnomaly } from './evp'
|
||||||
|
|
||||||
const SAMPLE_RATE = 48000
|
const SAMPLE_RATE = 48000
|
||||||
@@ -156,3 +161,46 @@ describe('EvpDetectorCore', () => {
|
|||||||
expect(core.getFloor()![50]).toBeCloseTo(-100, 9)
|
expect(core.getFloor()![50]).toBeCloseTo(-100, 9)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('evpThresholdDb ("listening_tool" unlock)', () => {
|
||||||
|
it('returns the base threshold when the unlock is absent', () => {
|
||||||
|
expect(evpThresholdDb(false)).toBe(EVP_BASE_THRESHOLD_DB)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a lower (more sensitive) threshold when the unlock is present', () => {
|
||||||
|
expect(evpThresholdDb(true)).toBe(EVP_LISTENING_TOOL_THRESHOLD_DB)
|
||||||
|
expect(EVP_LISTENING_TOOL_THRESHOLD_DB).toBeLessThan(EVP_BASE_THRESHOLD_DB)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listening_tool threshold changes real detector behavior', () => {
|
||||||
|
// Test the pure detector logic directly (EvpDetectorCore constructed with
|
||||||
|
// the resolved threshold), not through any UI — a real gameplay effect,
|
||||||
|
// not cosmetic: a faint spike that never registers at the base threshold
|
||||||
|
// must register once the unlock's lower threshold is wired in.
|
||||||
|
|
||||||
|
it('a faint spike is missed at the base threshold but caught with the unlock', () => {
|
||||||
|
const bin = 50
|
||||||
|
const faintSpikeDb = -94 // 6 dB above the -100 dB floor: below base (8), above unlocked (4)
|
||||||
|
|
||||||
|
const withoutUnlock = new EvpDetectorCore({
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
fftSize: FFT_SIZE,
|
||||||
|
thresholdDb: evpThresholdDb(false),
|
||||||
|
})
|
||||||
|
withoutUnlock.process(quietFrame(), 0)
|
||||||
|
for (let t = 1; t <= 20; t++) withoutUnlock.process(quietFrame(), t * 100)
|
||||||
|
expect(withoutUnlock.process(spikedFrame(bin, faintSpikeDb), 5000)).toBeNull()
|
||||||
|
|
||||||
|
const withUnlock = new EvpDetectorCore({
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
fftSize: FFT_SIZE,
|
||||||
|
thresholdDb: evpThresholdDb(true),
|
||||||
|
})
|
||||||
|
withUnlock.process(quietFrame(), 0)
|
||||||
|
for (let t = 1; t <= 20; t++) withUnlock.process(quietFrame(), t * 100)
|
||||||
|
const anomaly = withUnlock.process(spikedFrame(bin, faintSpikeDb), 5000)
|
||||||
|
expect(anomaly).not.toBeNull()
|
||||||
|
expect(anomaly!.magnitude).toBeCloseTo(6, 6)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ export type EvpAnomaly = {
|
|||||||
magnitude: number
|
magnitude: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- "listening_tool" unlock: lowers the detection threshold ----
|
||||||
|
//
|
||||||
|
// Purchased via POST /api/inventory/unlocks/listening_tool (see
|
||||||
|
// InventoryPanel.tsx) and reported back on the `unlocks` list in
|
||||||
|
// GET /auth/me. Once owned, fainter voice-band deviations register as
|
||||||
|
// anomalies — a real gameplay effect, not cosmetic.
|
||||||
|
|
||||||
|
/** Default deviation threshold (dB) required to register an anomaly. */
|
||||||
|
export const EVP_BASE_THRESHOLD_DB = 8
|
||||||
|
/** Threshold once the seeker owns the "listening_tool" unlock. */
|
||||||
|
export const EVP_LISTENING_TOOL_THRESHOLD_DB = 4
|
||||||
|
|
||||||
|
/** Pure: resolve the detection threshold from unlock ownership. */
|
||||||
|
export function evpThresholdDb(hasListeningTool: boolean): number {
|
||||||
|
return hasListeningTool ? EVP_LISTENING_TOOL_THRESHOLD_DB : EVP_BASE_THRESHOLD_DB
|
||||||
|
}
|
||||||
|
|
||||||
export type EvpDetectorOptions = {
|
export type EvpDetectorOptions = {
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
fftSize: number
|
fftSize: number
|
||||||
@@ -51,7 +68,7 @@ export class EvpDetectorCore {
|
|||||||
const bandHigh = opts.bandHighHz ?? 3400
|
const bandHigh = opts.bandHighHz ?? 3400
|
||||||
this.loBin = Math.max(0, Math.floor(bandLow / this.binHz))
|
this.loBin = Math.max(0, Math.floor(bandLow / this.binHz))
|
||||||
this.hiBin = Math.min(bins - 1, Math.ceil(bandHigh / this.binHz))
|
this.hiBin = Math.min(bins - 1, Math.ceil(bandHigh / this.binHz))
|
||||||
this.thresholdDb = opts.thresholdDb ?? 8
|
this.thresholdDb = opts.thresholdDb ?? EVP_BASE_THRESHOLD_DB
|
||||||
this.throttleMs = opts.throttleMs ?? 2000
|
this.throttleMs = opts.throttleMs ?? 2000
|
||||||
this.floorAlpha = opts.floorAlpha ?? 0.05
|
this.floorAlpha = opts.floorAlpha ?? 0.05
|
||||||
this.quietBandDb = opts.quietBandDb ?? 3
|
this.quietBandDb = opts.quietBandDb ?? 3
|
||||||
@@ -144,6 +161,11 @@ export type EvpListenerCallbacks = {
|
|||||||
onError?: (err: Error) => void
|
onError?: (err: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EvpListenerStartOptions = {
|
||||||
|
/** Whether the seeker owns the "listening_tool" unlock (from /auth/me's `unlocks`). */
|
||||||
|
hasListeningTool?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export class EvpListener {
|
export class EvpListener {
|
||||||
private ctx: AudioContext | null = null
|
private ctx: AudioContext | null = null
|
||||||
private stream: MediaStream | null = null
|
private stream: MediaStream | null = null
|
||||||
@@ -156,7 +178,7 @@ export class EvpListener {
|
|||||||
return this.running
|
return this.running
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(cb: EvpListenerCallbacks): Promise<void> {
|
async start(cb: EvpListenerCallbacks, opts: EvpListenerStartOptions = {}): Promise<void> {
|
||||||
if (this.running) return
|
if (this.running) return
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
const ctx = new AudioContext()
|
const ctx = new AudioContext()
|
||||||
@@ -172,6 +194,7 @@ export class EvpListener {
|
|||||||
this.core = new EvpDetectorCore({
|
this.core = new EvpDetectorCore({
|
||||||
sampleRate: ctx.sampleRate,
|
sampleRate: ctx.sampleRate,
|
||||||
fftSize: analyser.fftSize,
|
fftSize: analyser.fftSize,
|
||||||
|
thresholdDb: evpThresholdDb(opts.hasListeningTool ?? false),
|
||||||
})
|
})
|
||||||
this.running = true
|
this.running = true
|
||||||
|
|
||||||
|
|||||||
119
frontend/src/lib/sigil.test.ts
Normal file
119
frontend/src/lib/sigil.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
RUNES,
|
||||||
|
SIGIL_MAX_POINTS,
|
||||||
|
buildDesign,
|
||||||
|
isValidRune,
|
||||||
|
slotDegrees,
|
||||||
|
slotPoint,
|
||||||
|
toggleSlot,
|
||||||
|
} from './sigil'
|
||||||
|
|
||||||
|
describe('sigil geometry + selection', () => {
|
||||||
|
it('places slot 0 at the top of the circle and proceeds clockwise', () => {
|
||||||
|
const [x0, y0] = slotPoint(0, 24)
|
||||||
|
expect(x0).toBeCloseTo(0, 3)
|
||||||
|
expect(y0).toBeCloseTo(-1, 3)
|
||||||
|
|
||||||
|
// quarter turn clockwise (slot 6 of 24) lands on the right.
|
||||||
|
const [x6, y6] = slotPoint(6, 24)
|
||||||
|
expect(x6).toBeCloseTo(1, 3)
|
||||||
|
expect(y6).toBeCloseTo(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('slotPoint always lies on the unit circle', () => {
|
||||||
|
for (let s = 0; s < 24; s++) {
|
||||||
|
const [x, y] = slotPoint(s, 24)
|
||||||
|
expect(x * x + y * y).toBeCloseTo(1, 3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('slotDegrees reports clockwise degrees from the top', () => {
|
||||||
|
expect(slotDegrees(0, 24)).toBe(0)
|
||||||
|
expect(slotDegrees(6, 24)).toBe(90)
|
||||||
|
expect(slotDegrees(12, 24)).toBe(180)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggleSlot appends new slots up to the cap', () => {
|
||||||
|
let selected: number[] = []
|
||||||
|
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
|
||||||
|
selected = toggleSlot(selected, i)
|
||||||
|
}
|
||||||
|
expect(selected).toHaveLength(SIGIL_MAX_POINTS)
|
||||||
|
expect(selected).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to add a 13th point once at the cap', () => {
|
||||||
|
let selected: number[] = []
|
||||||
|
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
|
||||||
|
selected = toggleSlot(selected, i)
|
||||||
|
}
|
||||||
|
expect(selected).toHaveLength(SIGIL_MAX_POINTS)
|
||||||
|
|
||||||
|
const withThirteenth = toggleSlot(selected, 20)
|
||||||
|
expect(withThirteenth).toHaveLength(SIGIL_MAX_POINTS)
|
||||||
|
expect(withThirteenth).not.toContain(20)
|
||||||
|
expect(withThirteenth).toEqual(selected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggling an already-selected slot removes it, even while at the cap', () => {
|
||||||
|
let selected: number[] = []
|
||||||
|
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
|
||||||
|
selected = toggleSlot(selected, i)
|
||||||
|
}
|
||||||
|
const removed = toggleSlot(selected, 5)
|
||||||
|
expect(removed).toHaveLength(SIGIL_MAX_POINTS - 1)
|
||||||
|
expect(removed).not.toContain(5)
|
||||||
|
|
||||||
|
// and now there's room again for a fresh point.
|
||||||
|
const readded = toggleSlot(removed, 20)
|
||||||
|
expect(readded).toHaveLength(SIGIL_MAX_POINTS)
|
||||||
|
expect(readded).toContain(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors a custom max for edge-case testing', () => {
|
||||||
|
let selected: number[] = []
|
||||||
|
selected = toggleSlot(selected, 0, 2)
|
||||||
|
selected = toggleSlot(selected, 1, 2)
|
||||||
|
selected = toggleSlot(selected, 2, 2) // refused: cap of 2
|
||||||
|
expect(selected).toEqual([0, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rune selection', () => {
|
||||||
|
it('exposes a small fixed set of runes', () => {
|
||||||
|
expect(RUNES.length).toBeGreaterThanOrEqual(3)
|
||||||
|
expect(RUNES.length).toBeLessThanOrEqual(8)
|
||||||
|
const ids = RUNES.map((r) => r.id)
|
||||||
|
expect(new Set(ids).size).toBe(ids.length) // all unique
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates known rune ids and rejects unknown ones', () => {
|
||||||
|
for (const r of RUNES) {
|
||||||
|
expect(isValidRune(r.id)).toBe(true)
|
||||||
|
}
|
||||||
|
expect(isValidRune('not-a-rune')).toBe(false)
|
||||||
|
expect(isValidRune('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildDesign', () => {
|
||||||
|
it('produces the exact backend payload shape: {points, rune}', () => {
|
||||||
|
const design = buildDesign([0, 6, 12], 'eye', 24)
|
||||||
|
expect(design.rune).toBe('eye')
|
||||||
|
expect(design.points).toEqual([slotPoint(0, 24), slotPoint(6, 24), slotPoint(12, 24)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves point placement order (not slot-index order)', () => {
|
||||||
|
const design = buildDesign([12, 0, 6], 'thorn', 24)
|
||||||
|
expect(design.points).toEqual([slotPoint(12, 24), slotPoint(0, 24), slotPoint(6, 24)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never exceeds the backend payload cap when fed a fully-capped selection', () => {
|
||||||
|
let selected: number[] = []
|
||||||
|
for (let i = 0; i < 24; i++) selected = toggleSlot(selected, i)
|
||||||
|
expect(selected).toHaveLength(SIGIL_MAX_POINTS)
|
||||||
|
const design = buildDesign(selected, 'spiral')
|
||||||
|
expect(design.points.length).toBeLessThanOrEqual(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
82
frontend/src/lib/sigil.ts
Normal file
82
frontend/src/lib/sigil.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Sigil design: pure geometry + rune-selection logic for SigilDesigner.tsx.
|
||||||
|
//
|
||||||
|
// A sigil is a constrained geometric construction, not freeform drawing: the
|
||||||
|
// seeker toggles points on/off at fixed slots evenly spaced around a unit
|
||||||
|
// circle (clock-face style), capped at SIGIL_MAX_POINTS to match the
|
||||||
|
// backend's payload limit (Workstream C caps `points` length at 12). The
|
||||||
|
// component connects the chosen points in placement order and overlays a
|
||||||
|
// rune chosen from a small fixed set.
|
||||||
|
//
|
||||||
|
// Kept UI-free and pure so point-cap enforcement and rune selection are
|
||||||
|
// directly unit-testable, mirroring evp.ts's EvpDetectorCore split.
|
||||||
|
|
||||||
|
/** Number of clock-face anchor slots placed evenly around the circle. */
|
||||||
|
export const SIGIL_SLOT_COUNT = 24
|
||||||
|
|
||||||
|
/** Hard cap on active points — mirrors the backend's `points` payload cap. */
|
||||||
|
export const SIGIL_MAX_POINTS = 12
|
||||||
|
|
||||||
|
export type RuneId = 'eye' | 'crescent' | 'key' | 'spiral' | 'thorn'
|
||||||
|
|
||||||
|
/** Fixed small set of rune overlays a seeker can choose from. */
|
||||||
|
export const RUNES: readonly { id: RuneId; label: string }[] = [
|
||||||
|
{ id: 'eye', label: 'watching eye' },
|
||||||
|
{ id: 'crescent', label: 'waning crescent' },
|
||||||
|
{ id: 'key', label: 'binding key' },
|
||||||
|
{ id: 'spiral', label: 'drawing spiral' },
|
||||||
|
{ id: 'thorn', label: 'thorn ward' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function isValidRune(id: string): id is RuneId {
|
||||||
|
return RUNES.some((r) => r.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates of a clock-face slot on the unit circle, slot 0 at the top,
|
||||||
|
* proceeding clockwise. Rounded to 3 decimals to keep the saved payload
|
||||||
|
* compact.
|
||||||
|
*/
|
||||||
|
export function slotPoint(slot: number, slotCount: number = SIGIL_SLOT_COUNT): [number, number] {
|
||||||
|
const angle = (slot / slotCount) * Math.PI * 2 - Math.PI / 2
|
||||||
|
const round = (v: number) => Math.round(v * 1000) / 1000
|
||||||
|
return [round(Math.cos(angle)), round(Math.sin(angle))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slot angle in whole degrees, clockwise from the top (0..360). */
|
||||||
|
export function slotDegrees(slot: number, slotCount: number = SIGIL_SLOT_COUNT): number {
|
||||||
|
return Math.round((slot / slotCount) * 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure reducer for clicking a slot: toggles it out if already selected,
|
||||||
|
* otherwise appends it — but only while under SIGIL_MAX_POINTS. Once at the
|
||||||
|
* cap, clicking an unselected slot is a no-op (the 13th point never gets
|
||||||
|
* added); toggling an already-selected slot off still works at the cap.
|
||||||
|
*/
|
||||||
|
export function toggleSlot(
|
||||||
|
selected: readonly number[],
|
||||||
|
slot: number,
|
||||||
|
max: number = SIGIL_MAX_POINTS,
|
||||||
|
): number[] {
|
||||||
|
if (selected.includes(slot)) {
|
||||||
|
return selected.filter((s) => s !== slot)
|
||||||
|
}
|
||||||
|
if (selected.length >= max) {
|
||||||
|
return [...selected]
|
||||||
|
}
|
||||||
|
return [...selected, slot]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SigilDesign = { points: [number, number][]; rune: RuneId }
|
||||||
|
|
||||||
|
/** Build the exact payload shape Workstream C's save endpoint expects. */
|
||||||
|
export function buildDesign(
|
||||||
|
selectedSlots: readonly number[],
|
||||||
|
rune: RuneId,
|
||||||
|
slotCount: number = SIGIL_SLOT_COUNT,
|
||||||
|
): SigilDesign {
|
||||||
|
return {
|
||||||
|
points: selectedSlots.map((s) => slotPoint(s, slotCount)),
|
||||||
|
rune,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,3 +136,27 @@ export type ServerFrame =
|
|||||||
|
|
||||||
// REST helpers
|
// REST helpers
|
||||||
export type SortOrder = 'recent' | 'contacted'
|
export type SortOrder = 'recent' | 'contacted'
|
||||||
|
|
||||||
|
// ---- Workstream C inventory/unlocks (consumed by InventoryPanel/SigilDesigner) ----
|
||||||
|
|
||||||
|
/** One row from the `unlocks` table, as listed by Workstream C's inventory routes. */
|
||||||
|
export type UnlockRecord = {
|
||||||
|
unlock_key: string
|
||||||
|
unlocked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row from the `inventory_items` table. */
|
||||||
|
export type InventoryItem = {
|
||||||
|
item_type: string
|
||||||
|
item_key: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
obtained_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row from the `sigils` table. */
|
||||||
|
export type SigilRecord = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
design: { points: [number, number][]; rune: string }
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|||||||
80
frontend/src/pages/InventoryPage.css
Normal file
80
frontend/src/pages/InventoryPage.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/* InventoryPage — thin shell around InventoryPanel + SigilDesigner,
|
||||||
|
matching CodexPage.css's page-chrome conventions. */
|
||||||
|
|
||||||
|
.inventory-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||||
|
var(--qm-bg);
|
||||||
|
color: var(--qm-text);
|
||||||
|
font-family: var(--qm-font-ui);
|
||||||
|
padding: 2rem clamp(1rem, 4vw, 3.5rem) 5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(255, 255, 255, 0.022) 0px,
|
||||||
|
rgba(255, 255, 255, 0.022) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 3px
|
||||||
|
),
|
||||||
|
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-loading {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--qm-green);
|
||||||
|
font-family: var(--qm-font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-header {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 auto 2.2rem;
|
||||||
|
max-width: 760px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-back {
|
||||||
|
color: var(--qm-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-back:hover {
|
||||||
|
color: var(--qm-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-title {
|
||||||
|
font-family: var(--qm-font-display);
|
||||||
|
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
margin: 0.6rem 0 0.4rem;
|
||||||
|
color: #eef7f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-subtitle {
|
||||||
|
color: var(--qm-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-main {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2.4rem;
|
||||||
|
}
|
||||||
47
frontend/src/pages/InventoryPage.tsx
Normal file
47
frontend/src/pages/InventoryPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// The Reliquary Shelf — the seeker's unlocks/items/essence and the sigil
|
||||||
|
// designer, gated behind auth like SeancePage.
|
||||||
|
|
||||||
|
import { Link, Navigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAuth } from '../state/auth'
|
||||||
|
import { InventoryPanel } from '../components/InventoryPanel'
|
||||||
|
import { SigilDesigner } from '../components/SigilDesigner'
|
||||||
|
import './InventoryPage.css'
|
||||||
|
|
||||||
|
export function InventoryPage() {
|
||||||
|
const { user, checking } = useAuth()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (checking) {
|
||||||
|
return (
|
||||||
|
<div className="inventory-loading" role="status">
|
||||||
|
{t('common.loading')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!user) return <Navigate to="/enter" replace />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inventory-page">
|
||||||
|
<div className="inventory-overlay" aria-hidden="true" />
|
||||||
|
<header className="inventory-header">
|
||||||
|
<Link to="/seance" className="inventory-back">
|
||||||
|
{t('inventory.back', { defaultValue: '← back to the séance' })}
|
||||||
|
</Link>
|
||||||
|
<h1 className="inventory-title">{t('inventory.pageTitle', { defaultValue: 'THE SHELF' })}</h1>
|
||||||
|
<p className="inventory-subtitle">
|
||||||
|
{t('inventory.pageSubtitle', {
|
||||||
|
defaultValue: 'what the veil has left you, and what it will still trade for',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="inventory-main">
|
||||||
|
<InventoryPanel unlocks={user.unlocks} essence={user.essence} />
|
||||||
|
<SigilDesigner />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InventoryPage
|
||||||
@@ -256,6 +256,10 @@ function SeanceSession({ username }: { username: string }) {
|
|||||||
{t('nav.codex')}
|
{t('nav.codex')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link to="/inventory" className="seance-codex-link">
|
||||||
|
{t('nav.inventory', { defaultValue: 'inventory' })}
|
||||||
|
</Link>
|
||||||
|
|
||||||
<span className="seance-user" title={username}>
|
<span className="seance-user" title={username}>
|
||||||
{username}
|
{username}
|
||||||
</span>
|
</span>
|
||||||
@@ -469,7 +473,11 @@ function WirePanel() {
|
|||||||
|
|
||||||
function EvpPanel() {
|
function EvpPanel() {
|
||||||
const { sendAnomaly } = useSeance()
|
const { sendAnomaly } = useSeance()
|
||||||
|
const { user } = useAuth()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
// "listening_tool" unlock (GET /auth/me's `unlocks`) lowers the EVP
|
||||||
|
// detector's threshold so fainter deviations register — see lib/evp.ts.
|
||||||
|
const hasListeningTool = user?.unlocks?.includes('listening_tool') ?? false
|
||||||
const [listening, setListening] = useState(false)
|
const [listening, setListening] = useState(false)
|
||||||
const [pending, setPending] = useState(false)
|
const [pending, setPending] = useState(false)
|
||||||
const [denied, setDenied] = useState(false)
|
const [denied, setDenied] = useState(false)
|
||||||
@@ -531,7 +539,7 @@ function EvpPanel() {
|
|||||||
setBandDb(Number.isFinite(peak) ? peak : null)
|
setBandDb(Number.isFinite(peak) ? peak : null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
}, { hasListeningTool })
|
||||||
if (listenerRef.current === listener) setListening(true)
|
if (listenerRef.current === listener) setListening(true)
|
||||||
} catch {
|
} catch {
|
||||||
if (listenerRef.current === listener) listenerRef.current = null
|
if (listenerRef.current === listener) listenerRef.current = null
|
||||||
|
|||||||
Reference in New Issue
Block a user