+
{item.emoji} {getTranslatedText(item.name)}
+ {item.description &&
{getTranslatedText(item.description)}
}
- {/* Base Stats Display for Crafting */}
- {workbenchTab === 'craft' && (item.base_stats || item.stats) && (
-
-
- {Object.entries(item.base_stats || item.stats).map(([key, value]) => {
+ {/* Base Stats Display for Crafting */}
+ {workbenchTab === 'craft' && (item.base_stats || item.stats) && (
+
+
+ {Object.entries(item.base_stats || item.stats).map(([key, value]) => {
+ const icons: Record
= {
+ weight_capacity: '⚖️ Weight',
+ volume_capacity: '📦 Volume',
+ armor: '🛡️ Armor',
+ hp_max: '❤️ Max HP',
+ stamina_max: '⚡ Max Stamina',
+ damage_min: '⚔️ Damage Min',
+ damage_max: '⚔️ Damage Max'
+ }
+ const label = icons[key] || key.replace('_', ' ')
+ const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
+ return (
+
+ {label}: +{Math.round(Number(value))}{unit}
+
+ )
+ })}
+
+
+ * Potential base stats. Actual stats may vary.
+
+
+ )}
+
+ {/* Stats Display for Repair/Salvage */}
+ {workbenchTab !== 'craft' && (item.unique_item_data?.unique_stats || item.unique_item_data || item.base_stats || item.stats) && (
+
+ {Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => {
const icons: Record = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
@@ -166,257 +200,230 @@ function Workbench({
)
})}
-
- * Potential base stats. Actual stats may vary.
-
-
+ )}
+
+
+ {workbenchTab === 'craft' && (
+ <>
+
+
📊 Requirements
+
+ {item.craft_level && item.craft_level > 1 && (
+
+ Level {item.craft_level} Required
+ {item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}
+
+ )}
+
+ {item.tools && item.tools.length > 0 && (
+ <>
+
Tools
+ {item.tools.map((tool: any, i: number) => (
+
+ {tool.emoji} {getTranslatedText(tool.name)}
+
+ {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
+
+
+ ))}
+ >
+ )}
+
+
Materials
+ {item.materials && item.materials.length > 0 ? (
+ item.materials.map((mat: any, i: number) => (
+
+ {mat.emoji} {getTranslatedText(mat.name)}
+ {mat.available} / {mat.required}
+
+ ))
+ ) : (
+
+ No materials required
+
+ )}
+
+
+
+
+
+ >
)}
- {/* Stats Display for Repair/Salvage */}
- {workbenchTab !== 'craft' && (item.unique_item_data?.unique_stats || item.unique_item_data || item.base_stats || item.stats) && (
-
- {Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => {
- const icons: Record
= {
- weight_capacity: '⚖️ Weight',
- volume_capacity: '📦 Volume',
- armor: '🛡️ Armor',
- hp_max: '❤️ Max HP',
- stamina_max: '⚡ Max Stamina',
- damage_min: '⚔️ Damage Min',
- damage_max: '⚔️ Damage Max'
- }
- const label = icons[key] || key.replace('_', ' ')
- const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
- return (
-
-
{label}: +{Math.round(Number(value))}{unit}
+ {workbenchTab === 'repair' && (
+ <>
+
+
🔧 Repair Status
+
+ {!item.needs_repair ? (
+
✅ Item is in perfect condition
+ ) : (
+ <>
+
+ Current: {item.durability_percent}%
+ After Repair: {Math.min(100, item.durability_percent + (item.repair_percentage || 0))}%
+
+
+
+ {item.current_durability}/{item.max_durability}
+ +{Math.round((item.max_durability || 0) * ((item.repair_percentage || 0) / 100))} durability
+
+ >
+ )}
+
+ {item.needs_repair && (
+ <>
+ {item.tools && item.tools.length > 0 && (
+ <>
+
Tools
+ {item.tools.map((tool: any, i: number) => (
+
+ {tool.emoji} {getTranslatedText(tool.name)}
+
+ {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
+
+
+ ))}
+ >
+ )}
+
+
Materials
+ {item.materials.map((mat: any, i: number) => (
+
+ {mat.emoji} {getTranslatedText(mat.name)}
+ {mat.available} / {mat.quantity}
+
+ ))}
+ >
+ )}
+
+
+
+
+
+ >
+ )}
+
+ {workbenchTab === 'uncraft' && (
+ <>
+
+
♻️ Salvage Preview
+
+ {/* Show durability bar if we have durability data */}
+ {(item.unique_item_data || item.durability_percent !== undefined) && (
+
+
+
+ Condition: {item.unique_item_data?.durability_percent || item.durability_percent || 0}%
+
- )
- })}
-
+ )}
+
+
+ {(() => {
+ const durabilityRatio = item.unique_item_data?.durability_percent !== undefined
+ ? (item.unique_item_data.durability_percent || 0) / 100
+ : item.durability_percent !== undefined
+ ? (item.durability_percent || 0) / 100
+ : 1.0
+ const adjustedYield = (item.uncraft_yield || item.base_yield || []).map((mat: any) => ({
+ ...mat,
+ adjusted_quantity: Math.round((mat.quantity || 0) * durabilityRatio)
+ }))
+
+ return (
+ <>
+ {durabilityRatio < 1.0 && (
+
+ ⚠️ Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage
+
+ )}
+
+ {item.loss_chance && (
+
+ ⚠️ {Math.round(item.loss_chance * 100)}% chance to lose each material
+
+ )}
+
+ {adjustedYield.map((mat: any, i: number) => (
+
+ {mat.emoji} {getTranslatedText(mat.name)}
+ x{mat.adjusted_quantity}
+
+ ))}
+ >
+ )
+ })()}
+
+
+
+
+
+
+ >
)}
-
- {workbenchTab === 'craft' && (
- <>
-
-
📊 Requirements
-
- {item.craft_level && item.craft_level > 1 && (
-
- Level {item.craft_level} Required
- {item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}
-
- )}
-
- {item.tools && item.tools.length > 0 && (
- <>
-
Tools
- {item.tools.map((tool: any, i: number) => (
-
- {tool.emoji} {tool.name}
-
- {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
-
-
- ))}
- >
- )}
-
-
Materials
- {item.materials && item.materials.length > 0 ? (
- item.materials.map((mat: any, i: number) => (
-
- {mat.emoji} {mat.name}
- {mat.available} / {mat.required}
-
- ))
- ) : (
-
- No materials required
-
- )}
-
-
-
-
-
- >
- )}
-
- {workbenchTab === 'repair' && (
- <>
-
-
🔧 Repair Status
-
- {!item.needs_repair ? (
-
✅ Item is in perfect condition
- ) : (
- <>
-
- Current: {item.durability_percent}%
- After Repair: {Math.min(100, item.durability_percent + (item.repair_percentage || 0))}%
-
-
-
- {item.current_durability}/{item.max_durability}
- +{Math.round((item.max_durability || 0) * ((item.repair_percentage || 0) / 100))} durability
-
- >
- )}
-
- {item.needs_repair && (
- <>
- {item.tools && item.tools.length > 0 && (
- <>
-
Tools
- {item.tools.map((tool: any, i: number) => (
-
- {tool.emoji} {tool.name}
-
- {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
-
-
- ))}
- >
- )}
-
-
Materials
- {item.materials.map((mat: any, i: number) => (
-
- {mat.emoji} {mat.name}
- {mat.available} / {mat.quantity}
-
- ))}
- >
- )}
-
-
-
-
-
- >
- )}
-
- {workbenchTab === 'uncraft' && (
- <>
-
-
♻️ Salvage Preview
-
- {/* Show durability bar if we have durability data */}
- {(item.unique_item_data || item.durability_percent !== undefined) && (
-
-
-
- Condition: {item.unique_item_data?.durability_percent || item.durability_percent || 0}%
-
-
- )}
-
-
- {(() => {
- const durabilityRatio = item.unique_item_data?.durability_percent !== undefined
- ? (item.unique_item_data.durability_percent || 0) / 100
- : item.durability_percent !== undefined
- ? (item.durability_percent || 0) / 100
- : 1.0
- const adjustedYield = (item.uncraft_yield || item.base_yield || []).map((mat: any) => ({
- ...mat,
- adjusted_quantity: Math.round((mat.quantity || 0) * durabilityRatio)
- }))
-
- return (
- <>
- {durabilityRatio < 1.0 && (
-
- ⚠️ Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage
-
- )}
-
- {item.loss_chance && (
-
- ⚠️ {Math.round(item.loss_chance * 100)}% chance to lose each material
-
- )}
-
- {adjustedYield.map((mat: any, i: number) => (
-
- {mat.emoji} {mat.name}
- x{mat.adjusted_quantity}
-
- ))}
- >
- )
- })()}
-
-
-
-
-
-
- >
- )}
>
)
}
@@ -500,7 +507,7 @@ function Workbench({
{items.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
- const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase())
+ const matchesSearch = !searchFilter || getTranslatedText(item.name).toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
@@ -521,7 +528,7 @@ function Workbench({
.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
- const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase())
+ const matchesSearch = !searchFilter || getTranslatedText(item.name).toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
@@ -533,7 +540,7 @@ function Workbench({
return matchesSearch && matchesCategory
})
.map((item: any, idx: number) => {
- const imagePath = item.image_path || `/images/items/${item.item_id || item.id}.webp`
+ const imagePath = getAssetPath(item.image_path || `images/items/${item.item_id || item.id}.webp`)
return (
{
(e.target as HTMLImageElement).style.display = 'none';
@@ -564,7 +571,7 @@ function Workbench({
- {item.name}
+ {getTranslatedText(item.name)}
{item.location === 'equipped' && Equipped}
diff --git a/pwa/src/components/game/types.ts b/pwa/src/components/game/types.ts
index d09a67a..a1de803 100644
--- a/pwa/src/components/game/types.ts
+++ b/pwa/src/components/game/types.ts
@@ -16,13 +16,13 @@ export interface DirectionDetail {
stamina_cost: number
distance: number
destination: string
- destination_name?: string
+ destination_name?: string | { [key: string]: string }
}
export interface Location {
id: string
- name: string
- description: string
+ name: string | { [key: string]: string }
+ description: string | { [key: string]: string }
directions: string[]
directions_detailed?: DirectionDetail[]
danger_level?: number
diff --git a/pwa/src/i18n/index.ts b/pwa/src/i18n/index.ts
new file mode 100644
index 0000000..266d514
--- /dev/null
+++ b/pwa/src/i18n/index.ts
@@ -0,0 +1,29 @@
+import i18n from 'i18next'
+import { initReactI18next } from 'react-i18next'
+import LanguageDetector from 'i18next-browser-languagedetector'
+
+import en from './locales/en.json'
+import es from './locales/es.json'
+
+const resources = {
+ en: { translation: en },
+ es: { translation: es }
+}
+
+i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ fallbackLng: 'en',
+ supportedLngs: ['en', 'es'],
+ interpolation: {
+ escapeValue: false // React already escapes
+ },
+ detection: {
+ order: ['localStorage', 'navigator'],
+ caches: ['localStorage']
+ }
+ })
+
+export default i18n
diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json
new file mode 100644
index 0000000..905b264
--- /dev/null
+++ b/pwa/src/i18n/locales/en.json
@@ -0,0 +1,136 @@
+{
+ "common": {
+ "loading": "Loading...",
+ "error": "Error",
+ "save": "Save",
+ "cancel": "Cancel",
+ "confirm": "Confirm",
+ "close": "Close",
+ "yes": "Yes",
+ "no": "No",
+ "game": "Game",
+ "leaderboards": "Leaderboards",
+ "account": "Account"
+ },
+ "auth": {
+ "login": "Login",
+ "logout": "Logout",
+ "register": "Register",
+ "username": "Username",
+ "password": "Password",
+ "email": "Email",
+ "forgotPassword": "Forgot Password?",
+ "createAccount": "Create Account",
+ "alreadyHaveAccount": "Already have an account?",
+ "dontHaveAccount": "Don't have an account?"
+ },
+ "game": {
+ "travel": "🧭 Travel",
+ "surroundings": "🌿 Surroundings",
+ "character": "👤 Character",
+ "equipment": "⚔️ Equipment",
+ "inventory": "🎒 Open Inventory",
+ "workbench": "🔧 Workbench",
+ "craft": "🔨 Craft",
+ "repair": "🛠️ Repair",
+ "salvage": "♻️ Salvage",
+ "pickUp": "Pick Up",
+ "drop": "Drop",
+ "dropAll": "All",
+ "use": "Use",
+ "equip": "Equip",
+ "unequip": "Unequip",
+ "attack": "Attack",
+ "flee": "Flee",
+ "rest": "Rest",
+ "onlineCount": "{{count}} Online"
+ },
+ "stats": {
+ "hp": "❤️ HP",
+ "maxHp": "❤️ Max HP",
+ "stamina": "⚡ Stamina",
+ "maxStamina": "⚡ Max Stamina",
+ "xp": "⭐ XP",
+ "level": "Level",
+ "unspentPoints": "⭐ Unspent",
+ "weight": "⚖️ Weight",
+ "volume": "📦 Volume",
+ "strength": "💪 STR",
+ "strengthFull": "Strength",
+ "strengthDesc": "Increases melee damage and carry capacity",
+ "agility": "🏃 AGI",
+ "agilityFull": "Agility",
+ "agilityDesc": "Improves dodge chance and critical hits",
+ "endurance": "🛡️ END",
+ "enduranceFull": "Endurance",
+ "enduranceDesc": "Increases HP and stamina",
+ "intellect": "🧠 INT",
+ "intellectFull": "Intellect",
+ "intellectDesc": "Enhances crafting and resource gathering",
+ "armor": "🛡️ Armor",
+ "damage": "⚔️ Damage",
+ "durability": "Durability"
+ },
+ "combat": {
+ "inCombat": "In Combat",
+ "yourTurn": "Your Turn",
+ "enemyTurn": "Enemy's Turn",
+ "victory": "Victory!",
+ "defeat": "Defeat",
+ "youDied": "You Died",
+ "respawn": "Respawn",
+ "fleeSuccess": "You escaped!",
+ "fleeFailed": "Failed to escape!"
+ },
+ "equipment": {
+ "head": "Head",
+ "torso": "Torso",
+ "legs": "Legs",
+ "feet": "Feet",
+ "weapon": "Weapon",
+ "backpack": "Backpack",
+ "noBackpack": "No Backpack Equipped",
+ "equipped": "Equipped"
+ },
+ "crafting": {
+ "requirements": "📊 Requirements",
+ "materials": "Materials",
+ "tools": "Tools",
+ "levelRequired": "Level {{level}} Required",
+ "missingRequirements": "Missing Requirements",
+ "craftItem": "🔨 Craft Item",
+ "repairItem": "🛠️ Repair Item",
+ "salvageItem": "♻️ Salvage Item",
+ "staminaCost": "⚡ {{cost}} Stamina",
+ "alreadyFull": "Already Full",
+ "perfectCondition": "✅ Item is in perfect condition",
+ "yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage"
+ },
+ "categories": {
+ "all": "All Items",
+ "weapon": "Weapons",
+ "armor": "Armor",
+ "clothing": "Clothing",
+ "backpack": "Backpacks",
+ "tool": "Tools",
+ "consumable": "Consumables",
+ "resource": "Resources",
+ "quest": "Quest",
+ "misc": "Misc"
+ },
+ "messages": {
+ "notEnoughStamina": "Not enough stamina",
+ "inventoryFull": "Inventory full",
+ "itemDropped": "Item dropped",
+ "itemPickedUp": "Item picked up",
+ "waitBeforeMoving": "Wait {{seconds}}s before moving",
+ "cannotTravelInCombat": "Cannot travel during combat",
+ "cannotInteractInCombat": "Cannot interact during combat"
+ },
+ "landing": {
+ "heroTitle": "Echoes of the Ash",
+ "heroSubtitle": "A post-apocalyptic survival RPG",
+ "playNow": "Play Now",
+ "features": "Features"
+ }
+}
\ No newline at end of file
diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json
new file mode 100644
index 0000000..e94e764
--- /dev/null
+++ b/pwa/src/i18n/locales/es.json
@@ -0,0 +1,136 @@
+{
+ "common": {
+ "loading": "Cargando...",
+ "error": "Error",
+ "save": "Guardar",
+ "cancel": "Cancelar",
+ "confirm": "Confirmar",
+ "close": "Cerrar",
+ "yes": "Sí",
+ "no": "No",
+ "game": "Juego",
+ "leaderboards": "Clasificación",
+ "account": "Cuenta"
+ },
+ "auth": {
+ "login": "Iniciar Sesión",
+ "logout": "Cerrar Sesión",
+ "register": "Registrarse",
+ "username": "Usuario",
+ "password": "Contraseña",
+ "email": "Correo",
+ "forgotPassword": "¿Olvidaste tu contraseña?",
+ "createAccount": "Crear Cuenta",
+ "alreadyHaveAccount": "¿Ya tienes una cuenta?",
+ "dontHaveAccount": "¿No tienes cuenta?"
+ },
+ "game": {
+ "travel": "🧭 Viajar",
+ "surroundings": "🌿 Alrededores",
+ "character": "👤 Personaje",
+ "equipment": "⚔️ Equipamiento",
+ "inventory": "🎒 Abrir Inventario",
+ "workbench": "🔧 Banco de Trabajo",
+ "craft": "🔨 Fabricar",
+ "repair": "🛠️ Reparar",
+ "salvage": "♻️ Desmontar",
+ "pickUp": "Recoger",
+ "drop": "Soltar",
+ "dropAll": "Todo",
+ "use": "Usar",
+ "equip": "Equipar",
+ "unequip": "Desequipar",
+ "attack": "Atacar",
+ "flee": "Huir",
+ "rest": "Descansar",
+ "onlineCount": "{{count}} En línea"
+ },
+ "stats": {
+ "hp": "❤️ Vida",
+ "maxHp": "❤️ Vida Máx.",
+ "stamina": "⚡ Aguante",
+ "maxStamina": "⚡ Aguante Máx.",
+ "xp": "⭐ XP",
+ "level": "Nivel",
+ "unspentPoints": "⭐ Sin gastar",
+ "weight": "⚖️ Peso",
+ "volume": "📦 Volumen",
+ "strength": "💪 FUE",
+ "strengthFull": "Fuerza",
+ "strengthDesc": "Aumenta el daño cuerpo a cuerpo y capacidad de carga",
+ "agility": "🏃 AGI",
+ "agilityFull": "Agilidad",
+ "agilityDesc": "Mejora la esquiva y golpes críticos",
+ "endurance": "🛡️ RES",
+ "enduranceFull": "Resistencia",
+ "enduranceDesc": "Aumenta la vida y energía",
+ "intellect": "🧠 INT",
+ "intellectFull": "Intelecto",
+ "intellectDesc": "Mejora la fabricación y recolección",
+ "armor": "🛡️ Armadura",
+ "damage": "⚔️ Daño",
+ "durability": "Durabilidad"
+ },
+ "combat": {
+ "inCombat": "En Combate",
+ "yourTurn": "Tu Turno",
+ "enemyTurn": "Turno del Enemigo",
+ "victory": "¡Victoria!",
+ "defeat": "Derrota",
+ "youDied": "Has Muerto",
+ "respawn": "Revivir",
+ "fleeSuccess": "¡Escapaste!",
+ "fleeFailed": "¡No pudiste escapar!"
+ },
+ "equipment": {
+ "head": "Cabeza",
+ "torso": "Torso",
+ "legs": "Piernas",
+ "feet": "Pies",
+ "weapon": "Arma",
+ "backpack": "Mochila",
+ "noBackpack": "Sin Mochila Equipada",
+ "equipped": "Equipado"
+ },
+ "crafting": {
+ "requirements": "📊 Requisitos",
+ "materials": "Materiales",
+ "tools": "Herramientas",
+ "levelRequired": "Nivel {{level}} Requerido",
+ "missingRequirements": "Faltan Requisitos",
+ "craftItem": "🔨 Fabricar",
+ "repairItem": "🛠️ Reparar",
+ "salvageItem": "♻️ Desmontar",
+ "staminaCost": "⚡ {{cost}} Energía",
+ "alreadyFull": "Ya está Completo",
+ "perfectCondition": "✅ El objeto está en perfecto estado",
+ "yieldReduced": "⚠️ Rendimiento reducido {{percent}}% por daño"
+ },
+ "categories": {
+ "all": "Todos",
+ "weapon": "Armas",
+ "armor": "Armadura",
+ "clothing": "Ropa",
+ "backpack": "Mochilas",
+ "tool": "Herramientas",
+ "consumable": "Consumibles",
+ "resource": "Recursos",
+ "quest": "Misión",
+ "misc": "Varios"
+ },
+ "messages": {
+ "notEnoughStamina": "No tienes suficiente energía",
+ "inventoryFull": "Inventario lleno",
+ "itemDropped": "Objeto soltado",
+ "itemPickedUp": "Objeto recogido",
+ "waitBeforeMoving": "Espera {{seconds}}s antes de moverte",
+ "cannotTravelInCombat": "No puedes viajar en combate",
+ "cannotInteractInCombat": "No puedes interactuar en combate"
+ },
+ "landing": {
+ "heroTitle": "Ecos de la Ceniza",
+ "heroSubtitle": "Un RPG de supervivencia post-apocalíptico",
+ "playNow": "Jugar Ahora",
+ "features": "Características"
+ }
+}
\ No newline at end of file
diff --git a/pwa/src/main.tsx b/pwa/src/main.tsx
index e40fd47..70050a5 100644
--- a/pwa/src/main.tsx
+++ b/pwa/src/main.tsx
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
+import './i18n' // Initialize i18n
import { registerSW } from 'virtual:pwa-register'
import twemoji from 'twemoji'
diff --git a/pwa/src/utils/assetPath.ts b/pwa/src/utils/assetPath.ts
new file mode 100644
index 0000000..f3ef615
--- /dev/null
+++ b/pwa/src/utils/assetPath.ts
@@ -0,0 +1,41 @@
+/**
+ * Asset Path Utility
+ *
+ * Resolves asset paths based on runtime environment:
+ * - Electron: Returns local path (assets bundled with app)
+ * - Browser: Returns full server URL
+ */
+
+// Detect if running in Electron
+const isElectron = !!(window as any).electronAPI?.isElectron
+
+// Base URL for remote assets (browser mode)
+const ASSET_BASE_URL = import.meta.env.VITE_ASSET_URL ||
+ (import.meta.env.PROD ? 'https://api-staging.echoesoftheash.com' : '')
+
+/**
+ * Resolves an asset path for the current environment
+ * @param path - The asset path (e.g., "images/items/knife.webp" or "/images/items/knife.webp")
+ * @returns The resolved path for the current environment
+ */
+export function getAssetPath(path: string): string {
+ if (!path) return ''
+
+ // Normalize path (ensure leading slash)
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
+
+ if (isElectron) {
+ // In Electron, assets are served relative to the app
+ return normalizedPath
+ }
+
+ // In browser, prepend the server URL
+ return `${ASSET_BASE_URL}${normalizedPath}`
+}
+
+/**
+ * Check if we're running in Electron
+ */
+export function isElectronApp(): boolean {
+ return isElectron
+}
diff --git a/pwa/src/utils/i18nUtils.ts b/pwa/src/utils/i18nUtils.ts
new file mode 100644
index 0000000..5c1f631
--- /dev/null
+++ b/pwa/src/utils/i18nUtils.ts
@@ -0,0 +1,31 @@
+import i18n from '../i18n'
+
+export type I18nString = string | { [key: string]: string }
+
+/**
+ * Safely extracts the translated string from a value that could be a string or an I18nString object.
+ * @param value The value to translate (string or object with language keys)
+ * @returns The translated string for the current language, or fallback to English/first available
+ */
+export const getTranslatedText = (value: I18nString | undefined | null): string => {
+ if (!value) return ''
+
+ // If it's already a string, return it
+ if (typeof value === 'string') return value
+
+ // If it's an object, try to get the current language
+ const currentLang = i18n.language || 'en'
+
+ // 1. Try current language
+ if (value[currentLang]) return value[currentLang]
+
+ // 2. Try English fallback
+ if (value['en']) return value['en']
+
+ // 3. Return the first available key
+ const firstKey = Object.keys(value)[0]
+ if (firstKey) return value[firstKey]
+
+ // 4. Fallback empty
+ return ''
+}
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..592392f
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,36 @@
+# Release Scripts
+
+## release.sh
+
+Automated release script for Echoes of the Ash desktop builds.
+
+### Usage
+
+```bash
+./scripts/release.sh
+```
+
+### What it does
+
+1. Generates `package-lock.json` using Docker (no local npm installation required)
+2. Suggests next version automatically (increments patch version)
+3. Commits changes and pushes to main
+4. Creates and pushes git tag to trigger CI/CD pipeline
+
+### Example
+
+```
+Current version: v0.2.6
+Suggested next version: v0.2.7
+
+Enter version (or press Enter for v0.2.7): v0.3.0
+
+Using version: v0.3.0
+✓ Release v0.3.0 completed!
+```
+
+The CI/CD pipeline will automatically build:
+- Linux AppImage and .deb (compressed in `linux-builds.zip`)
+- Windows .exe installer (compressed in `windows-builds.zip`)
+
+Download artifacts from: http://gitlab.kingstudio.es/patacuack/echoes-of-the-ash/-/pipelines
diff --git a/scripts/convert_to_i18n.py b/scripts/convert_to_i18n.py
new file mode 100644
index 0000000..9ae0900
--- /dev/null
+++ b/scripts/convert_to_i18n.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+"""
+Convert game data JSON files to i18n-ready format.
+Transforms plain text fields to inline translation objects.
+
+Usage: python convert_to_i18n.py
+"""
+
+import json
+from pathlib import Path
+
+GAMEDATA_DIR = Path(__file__).parent.parent / 'gamedata'
+
+# Fields that should be translated
+TRANSLATABLE_FIELDS = ['name', 'description']
+
+# Nested text fields in outcomes
+OUTCOME_TEXT_FIELDS = ['success', 'failure', 'crit_success', 'crit_failure']
+
+
+def convert_text_to_i18n(value: str) -> dict:
+ """Convert a plain string to i18n format"""
+ if not value or not isinstance(value, str):
+ return value
+ return {
+ "en": value,
+ "es": "" # Empty - to be translated
+ }
+
+
+def convert_item(item_id: str, item: dict) -> dict:
+ """Convert an item to i18n format"""
+ result = {}
+ for key, value in item.items():
+ if key in TRANSLATABLE_FIELDS and isinstance(value, str):
+ result[key] = convert_text_to_i18n(value)
+ else:
+ result[key] = value
+ return result
+
+
+def convert_location(location: dict) -> dict:
+ """Convert a location to i18n format with nested interactable outcomes"""
+ result = {}
+ for key, value in location.items():
+ if key in TRANSLATABLE_FIELDS and isinstance(value, str):
+ result[key] = convert_text_to_i18n(value)
+ elif key == 'interactables' and isinstance(value, dict):
+ # Convert interactable outcome texts
+ result[key] = convert_interactables(value)
+ else:
+ result[key] = value
+ return result
+
+
+def convert_interactables(interactables: dict) -> dict:
+ """Convert interactable templates and outcomes to i18n format"""
+ result = {}
+ for inter_id, inter_data in interactables.items():
+ result[inter_id] = {}
+ for key, value in inter_data.items():
+ if key in TRANSLATABLE_FIELDS and isinstance(value, str):
+ result[inter_id][key] = convert_text_to_i18n(value)
+ elif key == 'actions' and isinstance(value, dict):
+ result[inter_id][key] = convert_actions(value)
+ elif key == 'outcomes' and isinstance(value, dict):
+ result[inter_id][key] = convert_outcomes(value)
+ else:
+ result[inter_id][key] = value
+ return result
+
+
+def convert_actions(actions: dict) -> dict:
+ """Convert action labels to i18n format"""
+ result = {}
+ for action_id, action_data in actions.items():
+ result[action_id] = {}
+ for key, value in action_data.items():
+ if key == 'label' and isinstance(value, str):
+ result[action_id][key] = convert_text_to_i18n(value)
+ else:
+ result[action_id][key] = value
+ return result
+
+
+def convert_outcomes(outcomes: dict) -> dict:
+ """Convert outcome text fields to i18n format"""
+ result = {}
+ for outcome_id, outcome_data in outcomes.items():
+ result[outcome_id] = {}
+ for key, value in outcome_data.items():
+ if key == 'text' and isinstance(value, dict):
+ result[outcome_id][key] = {}
+ for text_key, text_value in value.items():
+ if text_key in OUTCOME_TEXT_FIELDS and isinstance(text_value, str) and text_value:
+ result[outcome_id][key][text_key] = convert_text_to_i18n(text_value)
+ else:
+ result[outcome_id][key][text_key] = text_value
+ else:
+ result[outcome_id][key] = value
+ return result
+
+
+def convert_npc(npc: dict) -> dict:
+ """Convert an NPC to i18n format"""
+ result = {}
+ for key, value in npc.items():
+ if key in TRANSLATABLE_FIELDS and isinstance(value, str):
+ result[key] = convert_text_to_i18n(value)
+ else:
+ result[key] = value
+ return result
+
+
+
+def convert_interactable_template(interactable: dict) -> dict:
+ """Convert an interactable template to i18n format"""
+ result = {}
+ for key, value in interactable.items():
+ if key in TRANSLATABLE_FIELDS and isinstance(value, str):
+ result[key] = convert_text_to_i18n(value)
+ elif key == 'actions' and isinstance(value, dict):
+ result[key] = convert_actions(value)
+ else:
+ result[key] = value
+ return result
+
+
+def convert_file(filename: str, converter, key_name: str):
+ """Convert a JSON file to i18n format"""
+ filepath = GAMEDATA_DIR / filename
+
+ print(f"Converting {filename}...")
+
+ with open(filepath, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+
+ if key_name in data:
+ if isinstance(data[key_name], dict):
+ # items.json format: {"items": {"item_id": {...}, ...}}
+ data[key_name] = {
+ item_id: converter(item_id, item) if key_name == 'items' else converter(item)
+ for item_id, item in data[key_name].items()
+ }
+ elif isinstance(data[key_name], list):
+ # locations.json format: {"locations": [{...}, ...]}
+ data[key_name] = [converter(item) for item in data[key_name]]
+
+ # Write back
+ output_file = GAMEDATA_DIR / f'{filename.replace(".json", "_i18n.json")}'
+ with open(output_file, 'w', encoding='utf-8') as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+
+ print(f" -> Saved to {output_file.name}")
+
+
+def main():
+ print("Converting game data to i18n format...\n")
+
+ convert_file('items.json', convert_item, 'items')
+ convert_file('locations.json', convert_location, 'locations')
+ convert_file('npcs.json', convert_npc, 'npcs')
+ convert_file('interactables.json', convert_interactable_template, 'interactables')
+
+ print("\nDone! Review *_i18n.json files, then rename to replace originals.")
+ print("Spanish translations are empty - add them in the web-map editor.")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100755
index 0000000..59e1012
--- /dev/null
+++ b/scripts/release.sh
@@ -0,0 +1,118 @@
+#!/bin/bash
+
+# Echoes of the Ash - Release Script
+# Generates package-lock.json, commits changes, and creates a new release tag
+
+set -e # Exit on error
+
+# Colors for output
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}========================================${NC}"
+echo -e "${BLUE} Echoes of the Ash - Release Script${NC}"
+echo -e "${BLUE}========================================${NC}"
+echo ""
+
+# Get the project root directory
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+# Step 1: Generate package-lock.json using Docker
+echo -e "${YELLOW}[1/5]${NC} Generating package-lock.json with Docker..."
+docker run --rm -v "$PROJECT_ROOT/pwa:/app" -w /app node:20-alpine npm install
+
+# Optional: Remove node_modules to keep local directory clean
+echo -e "${YELLOW}[2/5]${NC} Cleaning up node_modules..."
+#rm -rf "$PROJECT_ROOT/pwa/node_modules"
+
+echo -e "${GREEN}✓${NC} package-lock.json generated successfully"
+echo ""
+
+# Step 2: Get current version from git tags
+echo -e "${YELLOW}[3/5]${NC} Determining version..."
+CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
+echo -e "Current version: ${GREEN}$CURRENT_VERSION${NC}"
+
+# Parse version and suggest next version
+if [[ $CURRENT_VERSION =~ v([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
+ MAJOR="${BASH_REMATCH[1]}"
+ MINOR="${BASH_REMATCH[2]}"
+ PATCH="${BASH_REMATCH[3]}"
+ NEXT_PATCH=$((PATCH + 1))
+ SUGGESTED_VERSION="v${MAJOR}.${MINOR}.${NEXT_PATCH}"
+else
+ SUGGESTED_VERSION="v0.1.0"
+fi
+
+echo -e "Suggested next version: ${BLUE}$SUGGESTED_VERSION${NC}"
+echo ""
+read -p "Enter version (or press Enter for $SUGGESTED_VERSION): " NEW_VERSION
+
+# Use suggested version if user didn't provide one
+if [ -z "$NEW_VERSION" ]; then
+ NEW_VERSION="$SUGGESTED_VERSION"
+fi
+
+# Ensure version starts with 'v'
+if [[ ! $NEW_VERSION =~ ^v ]]; then
+ NEW_VERSION="v$NEW_VERSION"
+fi
+
+echo -e "Using version: ${GREEN}$NEW_VERSION${NC}"
+echo ""
+
+# Step 3: Git add and commit
+echo -e "${YELLOW}[4/5]${NC} Committing changes..."
+git add -A
+
+# Check if there are changes to commit
+if git diff --cached --quiet; then
+ echo -e "${YELLOW}No changes to commit${NC}"
+else
+ git commit -m "Release $NEW_VERSION: Update package-lock.json and CI config"
+ echo -e "${GREEN}✓${NC} Changes committed"
+fi
+
+# Push to main
+echo -e "${YELLOW}Pushing to main branch...${NC}"
+git push origin main
+echo -e "${GREEN}✓${NC} Pushed to main"
+echo ""
+
+# Step 4: Create and push tag
+echo -e "${YELLOW}[5/5]${NC} Creating release tag..."
+
+# Delete tag if it already exists (locally and remotely)
+if git tag -l | grep -q "^$NEW_VERSION$"; then
+ echo -e "${YELLOW}Tag $NEW_VERSION already exists, deleting...${NC}"
+ git tag -d "$NEW_VERSION"
+ git push origin ":refs/tags/$NEW_VERSION" 2>/dev/null || true
+fi
+
+# Create new tag
+git tag "$NEW_VERSION"
+echo -e "${GREEN}✓${NC} Tag $NEW_VERSION created"
+
+# Push tag
+git push origin "$NEW_VERSION"
+echo -e "${GREEN}✓${NC} Tag pushed to remote"
+echo ""
+
+# Step 5: Summary
+echo -e "${BLUE}========================================${NC}"
+echo -e "${GREEN}✓ Release $NEW_VERSION completed!${NC}"
+echo -e "${BLUE}========================================${NC}"
+echo ""
+echo -e "Pipeline URL:"
+echo -e "${BLUE}http://gitlab.kingstudio.es/patacuack/echoes-of-the-ash/-/pipelines${NC}"
+echo ""
+echo -e "The CI/CD pipeline will now build:"
+echo -e " • Linux AppImage and .deb (compressed)"
+echo -e " • Windows .exe installer (compressed)"
+echo ""
+echo -e "Build time: ~10-20 minutes"
+echo ""
diff --git a/web-map/editor.html b/web-map/editor.html
index 7a64dff..04fd87f 100644
--- a/web-map/editor.html
+++ b/web-map/editor.html
@@ -922,13 +922,17 @@