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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions frontend/src/admin/components/NodeDetailDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type DrawerTab = 'basics' | 'location' | 'game' | 'messages' | 'advanced'

type NodeDetailDrawerProps = {
stage: AdminReactOverviewStage
stages?: AdminReactOverviewStage[]
onClose: () => void
onApplyLocal: (stage: AdminReactOverviewStage) => void
onDeleteLocal: (stage: AdminReactOverviewStage) => void
Expand All @@ -31,8 +32,33 @@ function numberOrNull(value: string) {
return Number.isFinite(parsed) ? parsed : null
}

function getPhysicalRequirementOption(stage: AdminReactOverviewStage) {
const record = stage as AdminReactOverviewStage & {
physical_node_kind?: string
physical_item_kind?: string
physical_item_id?: string
physical_item_label?: string
physical_qr?: { item_id?: string; label?: string; kind?: string }
}

const kind = record.physical_node_kind || record.physical_item_kind || record.physical_qr?.kind
const itemId = record.physical_item_id || record.physical_qr?.item_id
const label = record.physical_item_label || record.physical_qr?.label || stage.title || itemId

if (!itemId) return null
if (kind !== 'collectible' && kind !== 'requirement' && kind !== 'clue' && kind !== 'bonus') return null

return {
itemId,
label: label || itemId,
title: stage.title || label || itemId,
kind,
}
}

export default function NodeDetailDrawer({
stage,
stages = [],
onClose,
onApplyLocal,
onDeleteLocal,
Expand Down Expand Up @@ -60,6 +86,10 @@ export default function NodeDetailDrawer({
? (((draft as EditableAdminStage).config || {}) as Record<string, unknown>)
: {}

const physicalRequirementOptions = stages
.map(getPhysicalRequirementOption)
.filter((item): item is NonNullable<ReturnType<typeof getPhysicalRequirementOption>> => Boolean(item))

function getDraftConfigText(key: string, fallback = '') {
const value = draftConfig[key]
if (Array.isArray(value)) return value.join(', ')
Expand Down Expand Up @@ -448,6 +478,32 @@ export default function NodeDetailDrawer({
<span>{t('editor.gameAuthoring.completionHelp')}</span>
</div>

<label className="admin-edit-field">
Physical QR item
<select
value={getDraftConfigText('required_item_id')}
onChange={(event) => {
const selected = physicalRequirementOptions.find((item) => item.itemId === event.target.value)

if (!selected) {
updateDraftConfigText('required_item_id', '')
updateDraftConfigText('required_item_label', '')
return
}

updateDraftConfigText('required_item_id', selected.itemId)
updateDraftConfigText('required_item_label', selected.label)
}}
>
<option value="">No physical item required</option>
{physicalRequirementOptions.map((item) => (
<option key={item.itemId} value={item.itemId}>
{item.kind === 'collectible' ? '⭐' : item.kind === 'requirement' ? '🔒' : item.kind === 'clue' ? '🧩' : '🎁'} {item.label}
</option>
))}
</select>
</label>

<div className="admin-edit-grid">
<label className="admin-edit-field">
{t('editor.gameAuthoring.requiredItemId')}
Expand Down
86 changes: 83 additions & 3 deletions frontend/src/player/PlayerApp.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { advancePlayer, fetchPlayerGame, fetchPublicConfig, fetchTeamStatus, sendHeartbeat } from '../shared/api'
import type { PlayerGamePayload, PlayerGpsStatus, TeamProfileLiveStatus } from '../types/player'
import type { PlayerGamePayload, PlayerGpsStatus, PlayerStage, TeamProfileLiveStatus } from '../types/player'
import { PlayerShell } from './components/PlayerShell'
import { PlayerHud } from './components/PlayerHud'
import { QuickProofPanel } from './components/QuickProofPanel'
Expand All @@ -16,6 +16,7 @@ import { cachePlayerShell, registerPlayerServiceWorker } from './offline/pwaShel
import { cacheTeamProfiles, getCachedTeamProfiles } from './offline/teamPresence'
import { countVisibleTeamMarkers, teamProfilesToMapMarkers } from './offline/teamMapPresence'
import { queueManualCode } from './offline/physicalEvents'
import { loadInventorySnapshot } from './offline/inventory'
import { getDistanceMeters } from './utils/geo'
import { readStoredGpsPosition, rememberGpsPosition, rememberGpsReady, hasRememberedGpsReady } from './utils/gpsStorage'
import { getCurrentStage, getPlayerPosition, getStagePosition, getStageRadius, normalizeGpsStatus } from './utils/stagePosition'
Expand Down Expand Up @@ -46,6 +47,48 @@ function getUserFromUrl(): string {
return params.get('user') || 'PLAYER 1'
}

type ActiveItemRequirement = {
itemId: string
label: string
quantity: number
consume: boolean
}

function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}

function readActiveItemRequirement(stage: PlayerStage | null): ActiveItemRequirement | null {
if (!stage) return null

const raw = asRecord(stage)
const requirements = asRecord(raw.requirements)
const items = Array.isArray(requirements.items) ? requirements.items : []
const first = asRecord(items[0])
const config = {
...asRecord(raw.config),
...asRecord(asRecord(raw.minigame).config),
}

const itemId = String(first.item_id || first.required_item_id || config.required_item_id || '').trim()
const label = String(first.label || first.required_item_label || config.required_item_label || itemId).trim()
const quantityRaw = first.quantity || first.required_item_quantity || config.required_item_quantity || 1
const quantity = Number.isFinite(Number(quantityRaw)) ? Math.max(1, Math.floor(Number(quantityRaw))) : 1
const consumeRaw = first.consume ?? first.required_item_consume ?? config.required_item_consume
const consume = consumeRaw === true || String(consumeRaw || '').toLowerCase() === 'true'

if (!itemId) return null
return { itemId, label: label || itemId, quantity, consume }
}

function countOwnedItems(user: string, itemId: string): number {
return loadInventorySnapshot(user).items
.filter((item) => item.item_id === itemId && item.state !== 'used')
.reduce((total, item) => total + Math.max(0, item.quantity || 0), 0)
}

export default function PlayerApp() {
const [state, setState] = useState<LoadState>({ status: 'idle' })
const [activePanel, setActivePanel] = useState<PlayerPanel>(null)
Expand All @@ -67,6 +110,7 @@ export default function PlayerApp() {
const [offlineSummary, setOfflineSummary] = useState<OfflineMissionSummary | null>(null)
const [browserGpsPosition, setBrowserGpsPosition] = useState<{ lat: number; lon: number } | null>(null)
const [browserGpsStatus, setBrowserGpsStatus] = useState<PlayerGpsStatus>('unavailable')
const [inventoryRevision, setInventoryRevision] = useState(0)

const noticeTimerRef = useRef<number | null>(null)
const overlayTimerRef = useRef<number | null>(null)
Expand All @@ -78,6 +122,20 @@ export default function PlayerApp() {
const isPhone =
typeof window !== 'undefined' ? window.innerWidth <= 560 : false

useEffect(() => {
const handleInventoryUpdated = () => {
setInventoryRevision((current) => current + 1)
}

window.addEventListener('saga:inventory-updated', handleInventoryUpdated)
window.addEventListener('storage', handleInventoryUpdated)

return () => {
window.removeEventListener('saga:inventory-updated', handleInventoryUpdated)
window.removeEventListener('storage', handleInventoryUpdated)
}
}, [])

useEffect(() => {
const playerUrl = `/player/${encodeURIComponent(user)}`
void registerPlayerServiceWorker()
Expand Down Expand Up @@ -357,8 +415,19 @@ export default function PlayerApp() {
!browserGpsPosition &&
!localDebugPosition

const activeRequirement = readActiveItemRequirement(currentStage)
const ownedRequirementQuantity = activeRequirement
? countOwnedItems(payload.user, activeRequirement.itemId)
: 0
const missingRequiredItem = Boolean(
activeRequirement && ownedRequirementQuantity < activeRequirement.quantity
)
void inventoryRevision

const hudHelperText =
gpsActionRequired
missingRequiredItem && activeRequirement
? `Necesitas ${activeRequirement.quantity > 1 ? `${activeRequirement.quantity}× ` : ''}${activeRequirement.label}. Escanea su QR físico para guardarlo en Objetos.`
: gpsActionRequired
? 'Activa GPS para calcular distancia, centrarte en el mapa y entrar en el nodo cuando estés dentro del radio.'
: runtime.helperText

Expand All @@ -379,7 +448,7 @@ export default function PlayerApp() {
const hasOfflineMission = offlinePrepState === 'saved' || Boolean(offlineSummary?.hasPack)
const hasBrowserGps = Boolean(browserGpsPosition)
const primaryLabel = gpsActionRequired ? 'Activar GPS' : runtime.primaryLabel
const primaryDisabled = gpsActionRequired ? false : !runtime.canEnter
const primaryDisabled = gpsActionRequired ? false : missingRequiredItem || !runtime.canEnter

async function refreshPayload() {
const nextPayload = await fetchPlayerGame(user)
Expand Down Expand Up @@ -657,6 +726,12 @@ return
return
}

if (missingRequiredItem && activeRequirement) {
showNotice(`Necesitas ${activeRequirement.label}. Escanea su QR físico primero.`, 'warn')
vibrate([10, 16, 10])
return
}

if (!runtime.canEnter) return
setFocusRequest({ target: 'node', token: Date.now() })
vibrate([10, 16, 10])
Expand All @@ -676,6 +751,11 @@ return

setFocusRequest({ target: 'node', token: Date.now() })

if (missingRequiredItem && activeRequirement) {
showNotice(`Necesitas ${activeRequirement.label}. Escanea su QR físico primero.`, 'warn')
return
}

if (runtime.canEnter) {
showNotice('Target in range. Use Open Interaction.', 'info')
return
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/player/components/QuickProofPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ export function QuickProofPanel({ user, mobile, hidden }: QuickProofPanelProps)
setMessage(`Guardado en Objetos. Tienes ${snapshot.items.length} tipo${snapshot.items.length === 1 ? '' : 's'} de objeto.`)
setMode('idle')
stopCamera()
window.dispatchEvent(new CustomEvent('saga:inventory-updated', {
detail: {
user,
item_id: parsed.item_id,
label: parsed.label,
source: 'qr',
},
}))
} catch {
setMessage('No se pudo guardar en este dispositivo. Usa Mochila > Respaldo.')
}
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/types/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ export interface StageMessages {
[key: string]: string | undefined
}

export interface StageItemRequirement {
item_id?: string
required_item_id?: string
label?: string
required_item_label?: string
quantity?: number
required_item_quantity?: number
consume?: boolean
required_item_consume?: boolean
}

export interface StageRequirements {
items?: StageItemRequirement[]
}

export interface StageConfig {
[key: string]: unknown
}
Expand Down Expand Up @@ -61,6 +76,7 @@ export interface PlayerStage {
config?: StageConfig
minigame?: StageMinigameRuntime
entry?: StageEntryRules
requirements?: StageRequirements
messages?: StageMessages
}

Expand Down
Loading