From 066d9fab299054be7057f0c4ebff23e516e9007a Mon Sep 17 00:00:00 2001 From: odegaard12 <153871346+odegaard12@users.noreply.github.com> Date: Wed, 27 May 2026 17:42:23 +0200 Subject: [PATCH 1/8] admin: add mission builder game templates --- frontend/src/admin/AdminApp.tsx | 137 +++++- .../components/AdminMissionControlShell.tsx | 17 +- .../src/admin/components/FamiliesPanel.tsx | 19 +- .../admin/components/MissionBuilderPanel.tsx | 53 +++ .../src/admin/components/NodeDetailDrawer.tsx | 76 +++- frontend/src/admin/lib/gameCatalog.ts | 415 ++++++++++++++++++ .../src/admin/styles/admin-modern-shell.css | 127 ++++++ 7 files changed, 830 insertions(+), 14 deletions(-) create mode 100644 frontend/src/admin/components/MissionBuilderPanel.tsx create mode 100644 frontend/src/admin/lib/gameCatalog.ts diff --git a/frontend/src/admin/AdminApp.tsx b/frontend/src/admin/AdminApp.tsx index c46b176..e6f54fb 100644 --- a/frontend/src/admin/AdminApp.tsx +++ b/frontend/src/admin/AdminApp.tsx @@ -25,6 +25,11 @@ import { type EditableAdminStage, type FamilyId, } from './lib/familyConfigs' +import { + getDefaultAdminStagePatchForGame, + getMissionTemplateById, + type MissionTemplateId, +} from './lib/gameCatalog' import { buildPlayerDrafts, normalizePlayerId, @@ -40,8 +45,37 @@ import { getStablePlayerColor, getPlayerInitials } from '../shared/playerIdentit type LoadState = 'loading' | 'ready' | 'error' type OverviewState = 'locked' | 'loading' | 'ready' | 'error' -type CmsPanel = 'none' | 'players' | 'mission' | 'labels' - +type CmsPanel = 'none' | 'players' | 'mission' | 'labels' | 'builder' + + +function slugifyMissionItemId(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80) +} + +function buildTemplatePhysicalFields(kind: 'collectible' | 'requirement' | 'clue' | 'bonus', label: string) { + const itemId = slugifyMissionItemId(label) || 'objeto_qr' + const payload = `saga:item:${itemId}` + + return { + physical_node_kind: kind, + physical_item_kind: kind, + physical_item_id: itemId, + physical_item_label: label, + physical_qr: { + kind, + item_id: itemId, + label, + payload, + }, + qr_payload: payload, + } +} function preservePhysicalStageFields>(previous: T, next: T): T { const keys = [ @@ -614,6 +648,104 @@ export default function AdminApp() { setLocalNotice('Local preview updated. Save changes to persist.') } + function applyMissionTemplate(templateId: MissionTemplateId) { + if (!overview) return + + const template = getMissionTemplateById(templateId) + const shouldReplace = + stages.length === 0 || + window.confirm(`Reemplazar la ruta local actual por la plantilla "${template.title}"? Guarda después para persistir.`) + + if (!shouldReplace) return + + const mapCenter = + overview?.config?.map_center || + config?.map_center || + ([40.4168, -3.7038] as [number, number]) + + const centerLat = Number(mapCenter[0] || 40.4168) + const centerLon = Number(mapCenter[1] || -3.7038) + let lastPhysicalItem: { id: string; label: string } | null = null + + const nextStages = template.stages.map((item, index) => { + const patch = getDefaultAdminStagePatchForGame(item.gameId) + const lat = centerLat + item.offsetLat + const lon = centerLon + item.offsetLon + const physicalFields = item.physicalKind + ? buildTemplatePhysicalFields(item.physicalKind, item.itemLabel || item.title) + : {} + + if (item.physicalKind) { + const record = physicalFields as { physical_item_id?: string; physical_item_label?: string } + lastPhysicalItem = { + id: record.physical_item_id || slugifyMissionItemId(item.itemLabel || item.title), + label: record.physical_item_label || item.itemLabel || item.title, + } + } + + const requirementConfig = + item.requiresPreviousItem && lastPhysicalItem + ? { + required_item_id: lastPhysicalItem.id, + required_item_label: lastPhysicalItem.label, + required_item_quantity: 1, + required_item_consume: false, + } + : {} + + return { + id: `local-template-${Date.now()}-${index}`, + index, + title: item.title, + type: patch.type, + label: patch.label, + icon: patch.icon, + lat, + lon, + radius: item.radius || 50, + entry_mode: 'gps', + require_proximity: true, + has_hint: false, + has_manual_fallback: false, + content: item.content || patch.content, + objective: patch.objective, + config: { + ...patch.config, + ...requirementConfig, + }, + config_summary: Array.from(new Set([...patch.config_summary, ...Object.keys(requirementConfig)])), + messages: patch.messages, + ...physicalFields, + } as EditableAdminStage + }) + + const familyCounts = nextStages.reduce>((acc, stage) => { + const family = stage.type || 'signal_hunt' + acc[family] = (acc[family] || 0) + 1 + return acc + }, {}) + + setOverview((current) => current + ? { + ...current, + stages: nextStages, + counts: current.counts + ? { + ...current.counts, + stages: nextStages.length, + family_counts: familyCounts, + } + : current.counts, + } + : current + ) + + setSelectedStage(nextStages[0] || null) + setCmsPanel('none') + setSaveState('idle') + setLocalNotice(`Plantilla "${template.title}" creada en local. Revisa los nodos y pulsa Guardar.`) + } + function createLocalNodeAt(lat?: number, lon?: number) { const mapCenter = overview?.config?.map_center || @@ -774,6 +906,7 @@ export default function AdminApp() { onSavePlayers={savePlayerProfiles} onUpdateMissionDraft={updateMissionDraft} onSaveSettings={saveMissionSettings} + onApplyMissionTemplate={applyMissionTemplate} /> ) diff --git a/frontend/src/admin/components/AdminMissionControlShell.tsx b/frontend/src/admin/components/AdminMissionControlShell.tsx index bdc5c38..6729148 100644 --- a/frontend/src/admin/components/AdminMissionControlShell.tsx +++ b/frontend/src/admin/components/AdminMissionControlShell.tsx @@ -5,13 +5,15 @@ import NodeDetailDrawer from './NodeDetailDrawer' import NodePhysicalTypePanel from './NodePhysicalTypePanel' import PlayersPanel from './PlayersPanel' import SettingsPanel from './SettingsPanel' +import MissionBuilderPanel from './MissionBuilderPanel' import type { AdminReactOverviewProfile, AdminReactOverviewStage } from '../lib/adminApi' import { familyCards } from '../lib/familyConfigs' +import type { MissionTemplateId } from '../lib/gameCatalog' import type { PlayerDraft } from '../lib/playerDrafts' import { getPhysicalNodeVisual } from '../lib/physicalNodeVisuals' import '../styles/admin-modern-shell.css' -type CmsPanel = 'none' | 'players' | 'mission' | 'labels' +type CmsPanel = 'none' | 'players' | 'mission' | 'labels' | 'builder' type SaveState = 'idle' | 'saving' | 'saved' | 'error' type AdminMissionControlShellProps = { @@ -47,6 +49,7 @@ type AdminMissionControlShellProps = { onSavePlayers: () => void onUpdateMissionDraft: (key: string, value: string) => void onSaveSettings: () => void + onApplyMissionTemplate: (templateId: MissionTemplateId) => void } function selectedStageKey(stage: AdminReactOverviewStage | null) { @@ -92,6 +95,7 @@ export default function AdminMissionControlShell({ onSavePlayers, onUpdateMissionDraft, onSaveSettings, + onApplyMissionTemplate, }: AdminMissionControlShellProps) { const [typeChooserStageKey, setTypeChooserStageKey] = useState(null) @@ -189,6 +193,14 @@ export default function AdminMissionControlShell({
+ +
@@ -415,6 +427,7 @@ export default function AdminMissionControlShell({ diff --git a/frontend/src/admin/components/FamiliesPanel.tsx b/frontend/src/admin/components/FamiliesPanel.tsx index 58a527f..9caa82d 100644 --- a/frontend/src/admin/components/FamiliesPanel.tsx +++ b/frontend/src/admin/components/FamiliesPanel.tsx @@ -1,16 +1,29 @@ import { familyCards } from '../lib/familyConfigs' +import { adminGameCatalog } from '../lib/gameCatalog' export default function FamiliesPanel() { return (
- Families / labels - Available family-native runtime labels. + Juegos disponibles + 13 plantillas editables. Internamente usan 3 motores estables: GPS, brújula y lógica. + +
+ {adminGameCatalog.map((game) => ( +
+ {game.icon} {game.title} + {game.difficulty} · {game.duration} · {game.summary} +
+ ))} +
+ + Motores internos + Estos son los runtimes que ejecuta el player actualmente.
{familyCards.map((family) => (
{family.icon} {family.title} - {family.id} + {family.id} · {family.detail}
))}
diff --git a/frontend/src/admin/components/MissionBuilderPanel.tsx b/frontend/src/admin/components/MissionBuilderPanel.tsx new file mode 100644 index 0000000..1d6b5d2 --- /dev/null +++ b/frontend/src/admin/components/MissionBuilderPanel.tsx @@ -0,0 +1,53 @@ +import { missionTemplates, type MissionTemplateId } from '../lib/gameCatalog' +import type { AdminReactOverviewStage } from '../lib/adminApi' + +type MissionBuilderPanelProps = { + stages: AdminReactOverviewStage[] + onApplyTemplate: (templateId: MissionTemplateId) => void +} + +export default function MissionBuilderPanel({ + stages, + onApplyTemplate, +}: MissionBuilderPanelProps) { + return ( +
+ Mission Builder + Elige una plantilla clara. Se crea una ruta editable en local; después revisa nodos y pulsa Guardar. + + {stages.length > 0 ? ( +
+ Esta acción reemplaza la ruta local actual. No toca datos persistidos hasta que pulses Guardar. +
+ ) : null} + +
+ {missionTemplates.map((template) => ( +
+
+ {template.icon} +
+ {template.title} + {template.goodFor} +
+
+ +

{template.summary}

+ +
    + {template.stages.map((stage) => ( +
  1. + {stage.title} +
  2. + ))} +
+ + +
+ ))} +
+
+ ) +} diff --git a/frontend/src/admin/components/NodeDetailDrawer.tsx b/frontend/src/admin/components/NodeDetailDrawer.tsx index c338af3..96b171c 100644 --- a/frontend/src/admin/components/NodeDetailDrawer.tsx +++ b/frontend/src/admin/components/NodeDetailDrawer.tsx @@ -8,6 +8,12 @@ import { type EditableAdminStage, type FamilyId, } from '../lib/familyConfigs' +import { + adminGameCatalog, + getAdminGameForStage, + getDefaultAdminStagePatchForGame, + type AdminGameId, +} from '../lib/gameCatalog' type DrawerTab = 'basics' | 'location' | 'game' | 'requirement' | 'messages' | 'advanced' @@ -132,6 +138,7 @@ export default function NodeDetailDrawer({ const selectedRequirement = physicalRequirementOptions.find( (item) => item.itemId === getDraftConfigText('required_item_id') ) + const selectedGame = getAdminGameForStage(draft.type, draftConfig) function getDraftConfigText(key: string, fallback = '') { const value = draftConfig[key] @@ -203,6 +210,30 @@ export default function NodeDetailDrawer({ updateDraftConfig('sequence', parts.length > 0 ? parts : value) } + function handleDraftGameChange(nextGameId: AdminGameId) { + const patch = getDefaultAdminStagePatchForGame(nextGameId) + + updateDraftLocal((current) => ({ + ...(current as EditableAdminStage), + type: patch.type, + label: patch.label, + icon: patch.icon, + objective: patch.objective, + config: { + ...patch.config, + ...(((current as EditableAdminStage).config || {}) as Record), + game_id: nextGameId, + game_title: patch.label, + }, + config_summary: Object.keys(patch.config), + content: current.content || patch.content, + messages: { + ...(patch.messages || {}), + ...(current.messages || {}), + }, + })) + } + function handleDraftFamilyChange(nextType: FamilyId) { const nextConfig = getDefaultAdminConfigForFamily(nextType) @@ -324,17 +355,28 @@ export default function NodeDetailDrawer({ +
+ {selectedGame.icon} +
+ {selectedGame.title} +

{selectedGame.summary}

+ {selectedGame.playerGoal} +
+
+