Pre-menu-integration snapshot: combat, crafting, status effects, gamedata updates

This commit is contained in:
Joan
2026-03-11 12:43:23 +01:00
parent d5afd28eb9
commit a8dc8211d5
36 changed files with 1724 additions and 404 deletions

View File

@@ -424,6 +424,9 @@ function Game() {
try {
const response = await api.post('/api/game/pvp/action', { action })
actions.setMessage(response.data.message || 'Action performed!')
if (response.data.equipment) {
actions.updateEquipment(response.data.equipment)
}
// We don't need to fetchGameData here because the websocket update will handle it?
// The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
// So we should probably update state from response if possible, OR fetch.
@@ -504,6 +507,8 @@ function Game() {
onUncraft={(uniqueItemId: string, inventoryId: number, quantity?: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId, quantity)}
failedActionItemId={state.failedActionItemId}
quests={state.quests}
craftedItemResult={state.craftedItemResult}
onCloseCraftedItemResult={() => actions.setCraftedItemResult(null)}
/>
)}
</div>

View File

@@ -82,14 +82,14 @@ export const ItemStatBadges = ({ item }: ItemStatBadgesProps) => {
🏋 +{stats.endurance_bonus} {t('stats.end')}
</span>
)}
{(stats.hp_bonus) && (
{(stats.max_hp) && (
<span className="stat-badge health">
+{stats.hp_bonus} {t('stats.hpMax')}
+{stats.max_hp} {t('stats.hpMax')}
</span>
)}
{(stats.stamina_bonus) && (
{(stats.max_stamina) && (
<span className="stat-badge stamina">
+{stats.stamina_bonus} {t('stats.stmMax')}
+{stats.max_stamina} {t('stats.stmMax')}
</span>
)}

View File

@@ -5,6 +5,8 @@ import api from '../../services/api';
import { GameModal } from './GameModal';
import { GameProgressBar } from '../common/GameProgressBar';
import { GameButton } from '../common/GameButton';
import { GameTooltip } from '../common/GameTooltip';
import { EffectBadge } from './EffectBadge';
import './CharacterSheet.css';
interface CharacterSheetProps {
@@ -75,6 +77,7 @@ interface CharacterSheetData {
used_points: number;
all_perks: PerkData[];
};
status_effects: any[];
character: {
name: string;
level: number;
@@ -149,7 +152,7 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
);
}
const { base_stats, derived_stats, skills, perks, character } = data;
const { base_stats, derived_stats, skills, perks, character, status_effects } = data;
const renderStatsTab = () => (
<div className="cs-stats-tab">
@@ -186,6 +189,27 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
/>
</div>
{status_effects && status_effects.length > 0 && (
<div className="cs-status-effects" style={{ marginBottom: '1.5rem' }}>
<h5 style={{ margin: '0 0 0.5rem 0', color: '#ffb94a' }}>{t('characterSheet.activeEffects', 'Active Effects')}</h5>
<div style={{ display: 'flex', gap: '5px', flexWrap: 'wrap' }}>
{status_effects.map((e: any) => (
<GameTooltip key={e.effect_name || e.id} content={`${getTranslatedText(e.description, { interval: t('stats.interval_minute'), intervals_plural: t('stats.intervals_minute') })} (${e.ticks_remaining} ${t('game.ticksRemaining', 'ticks left')})`}>
<div style={{ display: 'inline-block' }}>
<EffectBadge effect={{
name: e.name || e.effect_name,
icon: e.icon,
type: e.type || (e.damage_per_tick > 0 ? 'damage' : 'buff'),
damage_per_tick: e.damage_per_tick,
ticks: e.ticks_remaining
}} />
</div>
</GameTooltip>
))}
</div>
</div>
)}
{base_stats.unspent_points > 0 && (
<div className="cs-unspent-badge">
<span></span> {base_stats.unspent_points} {t('characterSheet.pointsAvailable', 'points available')}
@@ -273,7 +297,7 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
<span className="cs-skill-badge locked"><span>🔒</span></span>
)}
</div>
<p className="cs-skill-desc">{getTranslatedText(skill.description)}</p>
<p className="cs-skill-desc">{getTranslatedText(skill.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</p>
<div className="cs-skill-meta">
<span className="cs-skill-tag"><span></span> {skill.stamina_cost}</span>
<span className="cs-skill-tag"><span>🔄</span> {skill.cooldown}t</span>
@@ -313,7 +337,7 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
<span className="cs-perk-icon">{perk.icon}</span>
<div className="cs-perk-title-block">
<span className="cs-perk-name">{getTranslatedText(perk.name)}</span>
<p className="cs-perk-desc">{getTranslatedText(perk.description)}</p>
<p className="cs-perk-desc">{getTranslatedText(perk.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</p>
</div>
{perk.owned ? (
<span className="cs-perk-status owned"><span></span> {t('characterSheet.owned', 'Owned')}</span>

View File

@@ -132,7 +132,10 @@ export const Combat: React.FC<CombatProps> = ({
opponentName: isPvP
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
: undefined,
turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining
turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining,
npcEffects: initialCombatData?.combat?.npc_effects || [],
playerEffects: initialCombatData?.player_effects || [],
npcIntent: initialCombatData?.combat?.npc_intent
});
const [animState, setAnimState] = useState<AnimationState>({
@@ -158,6 +161,8 @@ export const Combat: React.FC<CombatProps> = ({
const pendingPlayerHpRef = useRef<{ hp: number; max_hp: number } | null>(null);
// Store server player XP to apply when XP floating text appears
const pendingPlayerXpRef = useRef<{ xp: number; level: number } | null>(null);
// Store server equipment to apply when attack/hit animations occur
const pendingEquipmentRef = useRef<any>(null);
// Update queueRef
useEffect(() => {
@@ -284,6 +289,7 @@ export const Combat: React.FC<CombatProps> = ({
yourTurn: newYourTurn !== undefined ? newYourTurn : prev.yourTurn,
round: initialCombatData?.combat?.round ?? prev.round,
turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining,
npcIntent: initialCombatData?.combat?.npc_intent ?? prev.npcIntent,
// Sync HP for PVP from WebSocket updates
...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}),
...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {})
@@ -411,14 +417,17 @@ export const Combat: React.FC<CombatProps> = ({
// Apply server player HP when floating text appears
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({
...prev,
playerHp: hp,
playerMaxHp: max_hp
}));
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
// Apply pending equipment update (durability loss from being hit)
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
}
break;
@@ -436,6 +445,18 @@ export const Combat: React.FC<CombatProps> = ({
triggerAnim('shaking', 500);
if (data.damage) {
addFloatingText(`-${data.damage}!`, 'crit', 'player');
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
}
break;
@@ -502,13 +523,28 @@ export const Combat: React.FC<CombatProps> = ({
// ── Skill messages ──
case 'skill_attack':
triggerAnim('playerAttacking');
triggerAnim('npcHit', 300);
const target_origin = origin === 'enemy' ? 'player' : 'enemy';
triggerAnim(origin === 'enemy' ? 'enemyAttacking' : 'playerAttacking');
triggerAnim(origin === 'enemy' ? 'playerHit' : 'npcHit', 300);
if (data.damage) {
const label = data.hits > 1
? `${data.skill_icon || '⚔️'} -${data.damage} (x${data.hits})`
: `${data.skill_icon || '⚔️'} -${data.damage}`;
addFloatingText(label, 'damage', 'enemy');
addFloatingText(label, 'damage', target_origin);
if (target_origin === 'player') {
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
}
}
break;
@@ -572,6 +608,12 @@ export const Combat: React.FC<CombatProps> = ({
} else if (messageQueue.length === 0 && isProcessingQueue) {
// Queue just finished processing
setIsProcessingQueue(false);
// Apply pending equipment updates (durability loss etc.) after ALL animations finish
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
}
}, [messageQueue, processQueue, isProcessingQueue]);
@@ -596,13 +638,23 @@ export const Combat: React.FC<CombatProps> = ({
npcMaxHp: data.combat.npc_max_hp,
turn: data.combat.turn,
round: data.combat.round,
npcName: resolveName(data.combat.npc_name) || prev.npcName
npcName: resolveName(data.combat.npc_name) || prev.npcName,
npcEffects: data.combat.npc_effects || [],
playerEffects: (data as any).player_effects || [],
npcIntent: data.combat.npc_intent
}));
} else if (data.combat_over && data.player_won) {
} else if (data.combat_over && data.player_won === true && action !== 'flee') {
// Apply any remaining pending data on victory
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
// Combat ended with victory but data.combat is null - set enemy HP to 0
setLocalCombatState(prev => ({
...prev,
npcHp: 0
npcHp: 0,
npcEffects: [],
playerEffects: []
}));
}
@@ -611,8 +663,13 @@ export const Combat: React.FC<CombatProps> = ({
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
// Store player XP to apply when xp_gain message is processed
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
refreshCharacters();
}
if (data.equipment) {
pendingEquipmentRef.current = data.equipment;
}
refreshCharacters();
}
} catch (err) {
console.error(err);
@@ -753,12 +810,17 @@ export const Combat: React.FC<CombatProps> = ({
npcMaxHp: data.combat.npc_max_hp,
turn: data.combat.turn,
round: data.combat.round,
npcName: resolveName(data.combat.npc_name) || prev.npcName
npcName: resolveName(data.combat.npc_name) || prev.npcName,
npcEffects: data.combat.npc_effects || [],
playerEffects: (data as any).player_effects || [],
npcIntent: data.combat.npc_intent
}));
} else if (data.combat_over && data.player_won) {
} else if (data.combat_over && data.player_won === true) {
setLocalCombatState(prev => ({
...prev,
npcHp: 0
npcHp: 0,
npcEffects: [],
playerEffects: []
}));
}

View File

@@ -33,7 +33,7 @@
clip-path: var(--game-clip-path);
border: 1px solid rgba(255, 107, 107, 0.3);
flex-shrink: 0;
}
}
.combat-location-bg {
width: 100%;
@@ -557,4 +557,36 @@
.progress-fill {
height: 100%;
transition: width 0.3s ease-out;
}
/* Combat Status Effect Badges */
.combat-effects-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.combat-effect-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 6px;
font-size: 0.7rem;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
clip-path: var(--game-clip-path-sm);
}
.combat-effect-badge.effect-buff {
background: rgba(76, 175, 80, 0.3);
border: 1px solid rgba(76, 175, 80, 0.5);
color: #81c784;
}
.combat-effect-badge.effect-debuff {
background: rgba(220, 53, 69, 0.3);
border: 1px solid rgba(220, 53, 69, 0.5);
color: #ef9a9a;
}

View File

@@ -15,6 +15,14 @@ export interface FloatingText {
timestamp: number;
}
export interface CombatEffect {
name: string | Record<string, string>;
icon: string;
ticks_remaining: number;
type?: string; // 'buff', 'debuff', 'damage'
description?: string | Record<string, string>;
}
export interface CombatState {
inCombat: boolean;
turn: 'player' | 'enemy' | 'attacker' | 'defender';
@@ -31,6 +39,9 @@ export interface CombatState {
round: number;
isPvP?: boolean;
opponentName?: string;
npcEffects?: CombatEffect[];
playerEffects?: CombatEffect[];
npcIntent?: string;
}
export interface CombatActionResponse {
@@ -47,6 +58,7 @@ export interface CombatActionResponse {
level: number;
};
winner_id?: string;
equipment?: any;
}
export interface AnimationState {

View File

@@ -8,6 +8,7 @@ import './CombatEffects.css';
import { GameProgressBar } from '../common/GameProgressBar';
import { GameButton } from '../common/GameButton';
import { GameDropdown } from '../common/GameDropdown';
import { GameTooltip } from '../common/GameTooltip';
import api from '../../services/api';
interface CombatViewProps {
@@ -122,6 +123,20 @@ export const CombatView: React.FC<CombatViewProps> = ({
}
}, [state.messages]);
const getIntentDisplay = (intent: string) => {
switch (intent) {
case 'defend': return { icon: '🛡️', text: t('combat.intents.defend', 'Defending') };
case 'flee': return { icon: '🏃', text: t('combat.intents.flee', 'Fleeing') };
case 'buff': return { icon: '✨', text: t('combat.intents.buff', 'Buffing') };
case 'attack': return { icon: '⚔️', text: t('combat.intents.attack', 'Attacking') };
case 'charging_attack': return { icon: '⚠️', text: t('combat.intents.charging', 'Charging Attack!') };
default:
// For skills like bandage_self etc.
const skillName = intent.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return { icon: '🌀', text: t(`combat.intents.${intent}`, skillName) };
}
};
return (
<div className="combat-container">
@@ -231,6 +246,31 @@ export const CombatView: React.FC<CombatViewProps> = ({
height="10px"
labelAlignment="right"
/>
{/* Enemy Intent */}
{!state.isPvP && state.npcIntent && !combatResult && (
<div style={{ marginTop: '4px', fontSize: '0.85rem', color: '#ffcc00', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: '4px', fontStyle: 'italic' }}>
<span>{getIntentDisplay(state.npcIntent).icon}</span>
<span>{t('combat.intents.label', 'Next move:')} {getIntentDisplay(state.npcIntent).text}</span>
</div>
)}
{/* Enemy Status Effects */}
{state.npcEffects && state.npcEffects.length > 0 && (
<div className="combat-effects-row">
{state.npcEffects.map((eff, i) => (
<GameTooltip key={i} content={
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{eff.icon} {getTranslatedText(eff.name)}</div>
<div style={{ fontSize: '0.8rem', color: '#aaa' }}>{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
<div style={{ fontSize: '0.75rem', color: '#888', marginTop: '2px' }}>{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}</div>
</div>
}>
<span className="combat-effect-badge effect-debuff">
{eff.icon} {eff.ticks_remaining}
</span>
</GameTooltip>
))}
</div>
)}
</div>
{/* Player HP (Right) */}
@@ -245,6 +285,24 @@ export const CombatView: React.FC<CombatViewProps> = ({
align="right"
labelAlignment="left"
/>
{/* Player Active Buffs/Effects */}
{state.playerEffects && state.playerEffects.length > 0 && (
<div className="combat-effects-row" style={{ justifyContent: 'flex-end' }}>
{state.playerEffects.map((eff, i) => (
<GameTooltip key={i} content={
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{eff.icon} {getTranslatedText(eff.name)}</div>
<div style={{ fontSize: '0.8rem', color: '#aaa' }}>{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
<div style={{ fontSize: '0.75rem', color: '#888', marginTop: '2px' }}>{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}</div>
</div>
}>
<span className={`combat-effect-badge ${eff.type === 'damage' ? 'effect-debuff' : 'effect-buff'}`}>
{eff.icon} {eff.ticks_remaining}
</span>
</GameTooltip>
))}
</div>
)}
</div>
</div>
@@ -261,17 +319,27 @@ export const CombatView: React.FC<CombatViewProps> = ({
)}
{!combatResult && (
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', width: '100%', maxWidth: '500px', margin: '0 auto' }}>
<GameButton
variant="danger"
onClick={() => onAction('attack')}
disabled={isProcessing || !state.yourTurn}
>
👊 {t('combat.actions.attack')}
</GameButton>
<GameButton
variant="secondary"
onClick={() => onAction('defend')}
disabled={isProcessing || !state.yourTurn}
>
🛡 {t('combat.actions.defend')}
</GameButton>
<AbilitiesDropdown
onAction={onAction}
disabled={isProcessing || !state.yourTurn}
playerStamina={playerStamina}
/>
@@ -325,10 +393,13 @@ export const CombatView: React.FC<CombatViewProps> = ({
case 'enemy_miss': text = t('combat.log.enemy_miss'); break;
case 'victory': text = t('combat.victory'); className += " text-success bold"; break;
case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
case 'flee_success': text = t('combat.flee.success'); break;
case 'flee_fail': text = t('combat.flee.fail'); break;
case 'item_broken': text = t('combat.item_broken', { item: getTranslatedText(msg.data?.item_name) }); break;
case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break;
case 'flee_success': text = t('combat.log.flee_success'); break;
case 'flee_fail':
text = t('combat.log.flee_fail');
className += " text-danger";
break;
case 'item_broken': text = t('combat.log.item_broken', { item: getTranslatedText(msg.data?.item_name), emoji: msg.data?.emoji || '' }); break;
case 'xp_gain': text = t('combat.log.xp_gain', { amount: msg.data?.amount }); className += " text-warning"; break;
case 'damage':
if (msg.origin === 'enemy') {
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
@@ -340,7 +411,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
case 'text': text = getTranslatedText(msg.data?.text) || ""; break;
case 'item_used':
text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' });
if (msg.data?.effects) text += getTranslatedText(msg.data.effects); // Append effects string if backend still sends it
if (msg.data?.effects) text += getTranslatedText(msg.data.effects);
className += " text-info";
break;
case 'effect_applied':
@@ -350,7 +421,121 @@ export const CombatView: React.FC<CombatViewProps> = ({
});
className += " text-warning";
break;
default: text = msg.type;
// ── Skill messages ──
case 'skill_attack': {
const hitsText = msg.data?.hits > 1 ? ` (x${msg.data.hits})` : '';
text = t('combat.log.skill_attack', {
skill_icon: msg.data?.skill_icon || '⚔️',
skill_name: getTranslatedText(msg.data?.skill_name) || '',
damage: msg.data?.damage || 0,
hits_text: hitsText
});
break;
}
case 'skill_heal':
text = t('combat.log.skill_heal', {
skill_icon: msg.data?.skill_icon || '💚',
skill_name: getTranslatedText(msg.data?.skill_name) || '',
heal: msg.data?.heal || 0
});
className += " text-success";
break;
case 'skill_buff':
text = t('combat.log.skill_buff', {
skill_icon: msg.data?.skill_icon || '🛡️',
skill_name: getTranslatedText(msg.data?.skill_name) || ''
});
className += " text-info";
break;
case 'skill_effect':
text = msg.data?.message || '';
className += " text-info";
break;
case 'skill_analyze':
text = t('combat.log.skill_analyze', { skill_icon: msg.data?.skill_icon || '🔍' });
className += " text-info";
break;
// ── Combat reactions ──
case 'combat_crit':
text = t('combat.log.combat_crit');
className += " text-warning bold";
break;
case 'combat_dodge':
text = t('combat.log.combat_dodge');
className += " text-success";
break;
case 'combat_block':
text = t('combat.log.combat_block');
className += " text-success";
break;
case 'damage_reduced':
text = t('combat.log.damage_reduced', { reduction: msg.data?.reduction || 0 });
className += " text-info";
break;
case 'player_defend':
text = t('combat.log.defend');
className += " text-info bold";
break;
// ── Enemy actions ──
case 'enemy_enraged':
text = t('combat.log.enemy_enraged', { npc_name: getTranslatedText(msg.data?.npc_name) || t('common.enemy') });
className += " text-danger bold";
break;
case 'enemy_defend':
text = t('combat.log.enemy_defend', { heal: msg.data?.heal || 0 });
className += " text-danger";
break;
case 'enemy_special':
text = t('combat.log.enemy_special', { damage: msg.data?.damage || 0 });
className += " text-danger bold";
break;
// ── Status effects ──
case 'effect_damage':
if (msg.origin === 'enemy') {
text = t('combat.log.effect_damage_npc', { damage: msg.data?.damage || 0 });
} else {
text = t('combat.log.effect_damage', { damage: msg.data?.damage || 0 });
}
className += " text-danger";
break;
case 'effect_bleeding':
text = t('combat.log.effect_bleeding', { damage: msg.data?.damage || 0 });
className += " text-danger";
break;
case 'effect_heal':
text = t('combat.log.effect_heal', { heal: msg.data?.heal || 0 });
className += " text-success";
break;
// ── Items ──
case 'weapon_broke':
text = t('combat.log.weapon_broke', { item_name: getTranslatedText(msg.data?.item_name) || '' });
className += " text-danger";
break;
case 'item_heal':
text = t('combat.log.item_heal', { heal: msg.data?.heal || 0 });
className += " text-success";
break;
case 'item_restore':
text = t('combat.log.item_restore', { amount: msg.data?.amount || 0, stat: msg.data?.stat || '' });
className += " text-info";
break;
case 'item_damage':
text = t('combat.log.item_damage', { item: getTranslatedText(msg.data?.item_name) || '', damage: msg.data?.damage || 0 });
break;
// ── Outcomes ──
case 'level_up':
text = t('combat.log.level_up', { new_level: msg.data?.new_level || 0 });
className += " text-warning bold";
break;
case 'died':
text = t('combat.log.died');
className += " text-danger bold";
break;
case 'quest_update':
text = msg.data?.message || '';
className += " text-info";
break;
default: text = msg.data?.message || msg.type;
}
}
const time = msg.timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -385,6 +570,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
interface SkillInfo {
id: string;
name: any;
description: any;
icon: string;
stamina_cost: number;
cooldown: number;
@@ -429,7 +615,7 @@ const AbilitiesDropdown: React.FC<{
disabled={disabled}
style={{ width: '100%' }}
>
{t('combat.actions.abilities', 'Abilities')}
{t('combat.actions.abilities')}
</GameButton>
{open && skills.length > 0 && (
<GameDropdown
@@ -443,24 +629,34 @@ const AbilitiesDropdown: React.FC<{
const isSkillDisabled = disabled || onCooldown || notEnoughStamina;
return (
<GameButton
key={s.id}
variant="secondary"
size="sm"
onClick={() => handleUse(s.id)}
disabled={isSkillDisabled}
style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '4px' }}
>
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: '0.4rem', filter: isSkillDisabled ? 'grayscale(100%)' : 'none' }}>
<span style={{ fontSize: '1rem' }}>{s.icon}</span>
<span style={{ flex: 1, textAlign: 'left', color: isSkillDisabled ? '#808090' : '#d0d0e0' }}>{getTranslatedText(s.name)}</span>
{onCooldown ? (
<span style={{ color: '#e53e3e', fontSize: '0.65rem', fontWeight: 'bold' }}> {s.current_cooldown}T</span>
) : (
<span style={{ color: notEnoughStamina ? '#e53e3e' : '#a0a0b0', fontSize: '0.65rem' }}>{s.stamina_cost}</span>
)}
<GameTooltip key={s.id} content={
<div>
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{s.icon} {getTranslatedText(s.name)}</div>
<div style={{ fontSize: '0.8rem', color: '#ccc', marginBottom: '4px' }}>{getTranslatedText(s.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
<div style={{ fontSize: '0.75rem', color: '#f0c040', display: 'flex', gap: '8px' }}>
<span> {s.stamina_cost} {t('combat.stamina', 'Stamina')}</span>
<span> {t('combat.cooldown_turns', { turns: s.cooldown })}</span>
</div>
</div>
</GameButton>
}>
<GameButton
variant="secondary"
size="sm"
onClick={() => handleUse(s.id)}
disabled={isSkillDisabled}
style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '4px' }}
>
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: '0.4rem', filter: isSkillDisabled ? 'grayscale(100%)' : 'none' }}>
<span style={{ fontSize: '1rem' }}>{s.icon}</span>
<span style={{ flex: 1, textAlign: 'left', color: isSkillDisabled ? '#808090' : '#d0d0e0' }}>{getTranslatedText(s.name)}</span>
{onCooldown ? (
<span style={{ color: '#ff9f43', fontSize: '0.7rem', fontWeight: 'bold' }}> {s.current_cooldown}</span>
) : (
<span style={{ color: notEnoughStamina ? '#e53e3e' : '#f0c040', fontSize: '0.7rem', fontWeight: 600 }}>{s.stamina_cost}</span>
)}
</div>
</GameButton>
</GameTooltip>
);
})}
</GameDropdown>

View File

@@ -24,7 +24,7 @@ export const EffectBadge: React.FC<EffectBadgeProps> = ({ effect }) => {
: getTranslatedText(effect.name);
return (
<span className={`stat-badge ${badgeClass}`}>
<span className={`stat-badge ${badgeClass}`} style={{ padding: '2px 6px', fontSize: '0.75rem', lineHeight: '1' }}>
{effect.icon}
{effect.damage_per_tick ? (
<>

View File

@@ -68,6 +68,8 @@ interface LocationViewProps {
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
failedActionItemId: string | number | null
quests: { active: any[], available: any[] }
craftedItemResult: any | null
onCloseCraftedItemResult: () => void
}
function LocationView({
@@ -90,6 +92,8 @@ function LocationView({
craftCategoryFilter,
profile,
quests,
craftedItemResult,
onCloseCraftedItemResult,
onInitiateCombat,
onInitiatePvP,
@@ -810,6 +814,8 @@ function LocationView({
onCraft={onCraft}
onRepair={onRepair}
onUncraft={onUncraft}
craftedItemResult={craftedItemResult}
onCloseCraftedItemResult={onCloseCraftedItemResult}
/>
)
}

View File

@@ -11,6 +11,7 @@ import { GameButton } from '../common/GameButton'
import { GameItemCard } from '../common/GameItemCard'
import { GameDropdown } from '../common/GameDropdown'
import { useAudio } from '../../contexts/AudioContext'
import { EffectBadge } from './EffectBadge'
interface PlayerSidebarProps {
playerState: PlayerState
@@ -140,14 +141,18 @@ function PlayerSidebar({
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{t('stats.hp')}
<div className="status-indicators" style={{ display: 'flex', gap: '5px' }}>
{playerState.status_effects?.filter((e: any) => e.damage_per_tick !== 0).map((e: any) => (
<span key={e.id} className={`stat-indicator ${e.damage_per_tick > 0 ? 'negative' : 'positive'}`} style={{
color: e.damage_per_tick > 0 ? '#ff6b6b' : '#4caf50',
fontSize: '0.85rem',
fontWeight: 'bold'
}}>
{e.damage_per_tick > 0 ? `-${e.damage_per_tick}` : `+${Math.abs(e.damage_per_tick)}`}/t ({e.ticks_remaining})
</span>
{playerState.status_effects?.map((e: any) => (
<GameTooltip key={e.effect_name || e.id} content={`${getTranslatedText(e.description, { interval: state?.combatState?.inCombat ? t('stats.interval_turn') : t('stats.interval_minute'), intervals_plural: state?.combatState?.inCombat ? t('stats.intervals_turn') : t('stats.intervals_minute') })} (${e.ticks_remaining} ${t('game.ticksRemaining', 'ticks left')})`}>
<div style={{ display: 'inline-block' }}>
<EffectBadge effect={{
name: e.name || e.effect_name,
icon: e.icon,
type: e.type || (e.damage_per_tick > 0 ? 'damage' : 'buff'),
damage_per_tick: e.damage_per_tick,
ticks: e.ticks_remaining
}} />
</div>
</GameTooltip>
))}
</div>
</div>

View File

@@ -28,6 +28,8 @@ interface WorkbenchProps {
onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
craftedItemResult: any | null
onCloseCraftedItemResult: () => void
}
function Workbench({
@@ -50,12 +52,15 @@ function Workbench({
onSetCraftCategoryFilter,
onCraft,
onRepair,
onUncraft
onUncraft,
craftedItemResult,
onCloseCraftedItemResult
}: WorkbenchProps) {
const { t } = useTranslation()
const [selectedItem, setSelectedItem] = useState<any>(null)
const [salvageQuantity, setSalvageQuantity] = useState<number>(1)
const [showSalvageModal, setShowSalvageModal] = useState<boolean>(false)
// Reset selection when tab changes
useEffect(() => {
@@ -448,10 +453,7 @@ function Workbench({
variant="danger"
disabled={(profile?.stamina || 0) < ((item.stamina_cost || 1) * salvageQuantity)}
onClick={() => {
const confirmMsg = t('crafting.confirmSalvage', { name: getTranslatedText(item.name) })
if (window.confirm(`${confirmMsg} (x${salvageQuantity})`)) {
onUncraft(item.unique_item_id, item.inventory_id, salvageQuantity)
}
setShowSalvageModal(true)
}}
style={{ width: '100%' }}
>
@@ -677,6 +679,99 @@ function Workbench({
</div>
</div>
</div>
{showSalvageModal && selectedItem && (
<GameModal
title={`♻️ ${t('game.salvage')}`}
onClose={() => setShowSalvageModal(false)}
className="salvage-confirm-modal"
>
<div style={{ padding: '1rem', textAlign: 'center' }}>
<p>{t('crafting.confirmSalvage', { name: getTranslatedText(selectedItem.name) })} (x{salvageQuantity})</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem', justifyContent: 'center' }}>
<GameButton variant="secondary" onClick={() => setShowSalvageModal(false)}>
{t('common.cancel', 'Cancel')}
</GameButton>
<GameButton variant="danger" onClick={() => {
onUncraft(selectedItem.unique_item_id, selectedItem.inventory_id, salvageQuantity)
setShowSalvageModal(false)
}}>
{t('common.confirm', 'Confirm')}
</GameButton>
</div>
</div>
</GameModal>
)}
{/* Crafted Item Feedback Modal */}
{craftedItemResult && (
<GameModal
title={`${t('crafting.successTitle', 'Crafting Successful!')}`}
onClose={onCloseCraftedItemResult}
className="crafted-item-modal"
>
<div style={{ padding: '1rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div className="item-image-thumb" style={{ width: '80px', height: '80px', marginBottom: '1rem' }}>
{craftedItemResult.image_path ? (
<img
src={getAssetPath(craftedItemResult.image_path)}
alt={getTranslatedText(craftedItemResult.name)}
className="item-thumb-img"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<div className={`item-thumb-emoji ${craftedItemResult.image_path ? 'hidden' : ''}`} style={{ fontSize: '3rem' }}>
{craftedItemResult.emoji || '📦'}
</div>
</div>
<h3 style={{ margin: '0 0 0.5rem 0', color: '#ecc94b' }}>
{getTranslatedText(craftedItemResult.name)}
</h3>
{craftedItemResult.tier && (
<span className={`text-tier-${craftedItemResult.tier}`} style={{ marginBottom: '1rem', fontWeight: 'bold' }}>
Tier {craftedItemResult.tier}
</span>
)}
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap', width: '100%' }}>
{Object.entries(craftedItemResult.unique_item_data?.unique_stats ?? craftedItemResult.unique_item_data ?? craftedItemResult.base_stats ?? craftedItemResult.stats ?? {})
.filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k))
.map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: `⚖️ ${t('game.weight')}`,
volume_capacity: `📦 ${t('game.volume')}`,
armor: `🛡️ ${t('stats.armor')}`,
hp_max: `❤️ ${t('stats.maxHp')}`,
stamina_max: `${t('stats.maxStamina')}`,
damage_min: `⚔️ ${t('stats.damage')} Min`,
damage_max: `⚔️ ${t('stats.damage')} Max`
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
return (
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.5rem 1rem', borderRadius: '4px', fontSize: '1rem', color: '#ccc' }}>
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
</div>
)
})}
</div>
<div style={{ marginTop: '2rem' }}>
<GameButton variant="primary" onClick={onCloseCraftedItemResult}>
{t('common.continue', 'Continue')}
</GameButton>
</div>
</div>
</GameModal>
)}
</GameModal>
)
}

View File

@@ -54,6 +54,7 @@ export interface GameEngineState {
uncraftFilter: string
inventoryFilter: string
inventoryCategoryFilter: string
craftedItemResult: any | null
// PvP state
lastSeenPvPAction: string | null
@@ -130,6 +131,7 @@ export interface GameEngineActions {
setInventoryFilter: (filter: string) => void
setInventoryCategoryFilter: (filter: string) => void
toggleCategoryCollapse: (category: string) => void
setCraftedItemResult: (result: any) => void
// WebSocket helpers
refreshLocation: () => Promise<void>
@@ -142,6 +144,7 @@ export interface GameEngineActions {
addNPCToLocation: (npc: any) => void
removeNPCFromLocation: (enemyId: string) => void
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
updateEquipment: (equipmentData: any) => void
// Quests
updateQuests: (active: any[], available: any[]) => void
@@ -186,6 +189,7 @@ export function useGameEngine(
const [uncraftableItems, setUncraftableItems] = useState<any[]>([])
const [inventoryFilter, setInventoryFilter] = useState<string>('')
const [inventoryCategoryFilter, setInventoryCategoryFilter] = useState<string>('all')
const [craftedItemResult, setCraftedItemResult] = useState<any | null>(null)
const [lastSeenPvPAction, setLastSeenPvPAction] = useState<string | null>(null)
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
@@ -483,7 +487,8 @@ export function useGameEngine(
in_combat: true,
combat_over: false,
player_won: false,
combat: encounter.combat
combat: encounter.combat,
player_effects: encounter.player_effects || []
})
setCombatLog([])
@@ -663,7 +668,8 @@ export function useGameEngine(
mobileHeaderOpen,
locationMessages,
interactableCooldowns,
forceUpdate: _forceUpdate
forceUpdate: _forceUpdate,
craftedItemResult
}
const handleUseItem = async (itemId: string) => {
@@ -779,6 +785,9 @@ export function useGameEngine(
// setMessage('Crafting...') // Loading state ok to keep specific or remove? Let's remove to avoid spam
const response = await api.post('/api/game/craft_item', { item_id: itemId })
addLocationMessage(response.data.message || 'Item crafted!')
if (response.data.item) {
setCraftedItemResult(response.data.item)
}
await refreshWorkbenchData()
} catch (error: any) {
addLocationMessage(error.response?.data?.detail || 'Failed to craft item')
@@ -870,7 +879,8 @@ export function useGameEngine(
in_combat: true,
combat_over: false,
player_won: false,
combat: response.data.combat
combat: response.data.combat,
player_effects: response.data.player_effects || []
})
setEnemyName(response.data.combat.npc_name)
@@ -908,6 +918,10 @@ export function useGameEngine(
response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q))
}
// if (response.data.equipment) {
// setEquipment(response.data.equipment)
// }
return response.data
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Combat action failed')
@@ -941,7 +955,9 @@ export function useGameEngine(
const handlePvPAction = async (action: string, _targetId: number) => {
try {
let payload: any = { action }
if (action.includes(':')) {
if (action.startsWith('skill:')) {
payload = { action: 'skill', skill_id: action.substring(6) }
} else if (action.includes(':')) {
const [act, itemId] = action.split(':')
payload = { action: act, item_id: itemId }
}
@@ -1081,7 +1097,8 @@ export function useGameEngine(
setCombatState({
in_combat: true,
combat_over: false,
combat: combatRes.data.combat
combat: combatRes.data.combat,
player_effects: combatRes.data.player_effects || []
})
// Update enemy name/image state
@@ -1118,6 +1135,12 @@ export function useGameEngine(
if (playerData.max_stamina !== undefined) {
mappedData.max_stamina = playerData.max_stamina
}
if (playerData.status_effects !== undefined) {
mappedData.status_effects = playerData.status_effects
}
if (playerData.equipment !== undefined) {
setEquipment(playerData.equipment)
}
// Update playerState with mapped fields
if (Object.keys(mappedData).length > 0) {
@@ -1259,6 +1282,8 @@ export function useGameEngine(
setUncraftFilter,
setInventoryFilter,
setInventoryCategoryFilter,
setCraftedItemResult,
updateEquipment: (data: any) => setEquipment(data),
// WebSocket helper functions
refreshLocation,
refreshCombat,

View File

@@ -257,7 +257,11 @@
"agi": "AGI",
"end": "END",
"hpMax": "HP max",
"stmMax": "Stm max"
"stmMax": "Stm max",
"interval_turn": "turn",
"intervals_turn": "turns",
"interval_minute": "minute",
"intervals_minute": "minutes"
},
"combat": {
"title": "Combat",
@@ -285,6 +289,14 @@
"yourTurnTimer": "Your Turn ({{time}})",
"enemyTurnTimer": "Enemy Turn",
"waiting": "Waiting for opponent...",
"intents": {
"label": "Next move:",
"defend": "Defending",
"flee": "Fleeing",
"buff": "Buffing",
"attack": "Attacking",
"charging": "Charging Attack!"
},
"messages": {
"combat_start": "Combat started with {{enemy}}!",
"player_attack": "You attack for {{damage}} damage!",
@@ -298,8 +310,11 @@
"defend": "Defend",
"flee": "Flee",
"supplies": "Supplies",
"useItem": "Use Item"
"useItem": "Use Item",
"abilities": "Abilities"
},
"stamina": "Stamina",
"cooldown_turns": "{{turns}} turn cooldown",
"status": {
"attacking": "Attacking...",
"defending": "Bracing for impact...",
@@ -332,15 +347,33 @@
"weapon_broke": "Your {{item_name}} broke!",
"item_broken": "Your {{emoji}} {{item_name}} broke!",
"combat_crit": "CRITICAL HIT!",
"combat_dodge": "You Dodged the attack!",
"combat_block": "You Blocked the attack!",
"combat_dodge": "You dodged the attack!",
"combat_block": "You blocked the attack!",
"xp_gain": "Gained {{amount}} XP",
"flee_success": "You managed to escape!",
"flee_fail": "Failed to escape!",
"defend": "You brace for impact!",
"item_used": "Used {{item}}",
"effect_applied": "Applied {{effect}} to {{target}}",
"item_damage": "{{item}} deals {{damage}} damage!",
"damage_reduced": "Damage reduced by {{reduction}}%"
"damage_reduced": "Damage reduced by {{reduction}}%",
"skill_attack": "{{skill_icon}} {{skill_name}} hits for {{damage}} damage{{hits_text}}",
"skill_heal": "{{skill_icon}} {{skill_name}} heals for {{heal}} HP",
"skill_buff": "{{skill_icon}} {{skill_name}} activated",
"skill_effect": "{{message}}",
"skill_analyze": "{{skill_icon}} Target analyzed!",
"enemy_enraged": "{{npc_name}} is enraged!",
"enemy_defend": "Enemy recovers {{heal}} HP",
"enemy_special": "Enemy uses a special attack for {{damage}} damage!",
"effect_bleeding": "Bleeding for {{damage}} damage",
"effect_heal": "Recovered {{heal}} HP",
"effect_damage": "Took {{damage}} damage from status effects",
"effect_damage_npc": "The enemy took {{damage}} damage from status effects",
"level_up": "Level up! You are now level {{new_level}}!",
"item_heal": "Healed for {{heal}} HP",
"item_restore": "Restored {{amount}} {{stat}}",
"died": "You have been defeated!",
"turns_remaining": "{{turns}} turns remaining"
},
"modal": {
"supplies_title": "Combat Supplies",

View File

@@ -254,8 +254,12 @@
"str": "FUE",
"agi": "AGI",
"end": "RES",
"hpMax": "Vida máx",
"stmMax": "Agua. máx"
"hpMax": "PS máx",
"stmMax": "Ag máx",
"interval_turn": "turno",
"intervals_turn": "turnos",
"interval_minute": "minuto",
"intervals_minute": "minutos"
},
"combat": {
"title": "Combate",
@@ -284,6 +288,14 @@
"yourTurnTimer": "Tu Turno ({{time}})",
"enemyTurnTimer": "Turno del Enemigo",
"waiting": "Esperando al oponente...",
"intents": {
"label": "Próximo movimiento:",
"defend": "Defendiendo",
"flee": "Huyendo",
"buff": "Potenciándose",
"attack": "Atacando",
"charging": "¡Ataque Cargado!"
},
"messages": {
"combat_start": "¡Combate iniciado con {{enemy}}!",
"player_attack": "¡Atacas por {{damage}} de daño!",
@@ -296,8 +308,11 @@
"defend": "Defender",
"flee": "Huir",
"supplies": "Suministros",
"useItem": "Usar Objeto"
"useItem": "Usar Objeto",
"abilities": "Habilidades"
},
"stamina": "Aguante",
"cooldown_turns": "{{turns}} turnos de espera",
"status": {
"attacking": "Atacando...",
"defending": "Preparándose...",
@@ -339,7 +354,24 @@
"item_used": "Usaste {{item}}",
"effect_applied": "Aplicado {{effect}} a {{target}}",
"item_damage": "{{item}} inflige {{damage}} de daño!",
"damage_reduced": "Daño reducido en {{reduction}}%"
"damage_reduced": "Daño reducido en {{reduction}}%",
"skill_attack": "{{skill_icon}} {{skill_name}} golpea por {{damage}} de daño{{hits_text}}",
"skill_heal": "{{skill_icon}} {{skill_name}} cura {{heal}} PS",
"skill_buff": "{{skill_icon}} {{skill_name}} activado",
"skill_effect": "{{message}}",
"skill_analyze": "{{skill_icon}} ¡Objetivo analizado!",
"enemy_enraged": "¡{{npc_name}} está enfurecido!",
"enemy_defend": "El enemigo recupera {{heal}} PS",
"enemy_special": "¡El enemigo usa un ataque especial por {{damage}} de daño!",
"effect_bleeding": "Sangrado por {{damage}} de daño",
"effect_heal": "Recuperaste {{heal}} PS",
"effect_damage": "Recibiste {{damage}} de daño por efectos de estado",
"effect_damage_npc": "El enemigo recibió {{damage}} de daño por efectos de estado",
"level_up": "¡Subiste de nivel! ¡Ahora eres nivel {{new_level}}!",
"item_heal": "Curaste {{heal}} PS",
"item_restore": "Restauraste {{amount}} de {{stat}}",
"died": "¡Has sido derrotado!",
"turns_remaining": "{{turns}} turnos restantes"
},
"modal": {
"supplies_title": "Suministros de Combate",

View File

@@ -7,25 +7,37 @@ export type I18nString = string | { [key: string]: string }
* @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 => {
export const getTranslatedText = (value: I18nString | undefined | null, vars?: Record<string, string | number>): string => {
if (!value) return ''
// If it's already a string, return it
if (typeof value === 'string') return value
let text = typeof value === 'string' ? value : '';
// If it's an object, try to get the current language
const currentLang = i18n.language || 'en'
if (!text && typeof value === 'object') {
const objValue = value as Record<string, string>;
const currentLang = i18n.language || 'en';
// 1. Try current language
if (value[currentLang]) return value[currentLang]
// 1. Try current language
if (objValue[currentLang]) {
text = objValue[currentLang];
}
// 2. Try English fallback
else if (objValue['en']) {
text = objValue['en'];
}
// 3. Return the first available key
else {
const firstKey = Object.keys(objValue)[0];
if (firstKey) text = objValue[firstKey];
}
}
// 2. Try English fallback
if (value['en']) return value['en']
if (!text) return '';
// 3. Return the first available key
const firstKey = Object.keys(value)[0]
if (firstKey) return value[firstKey]
if (vars) {
Object.entries(vars).forEach(([k, v]) => {
text = text.replace(new RegExp(`{{${k}}}`, 'g'), String(v));
});
}
// 4. Fallback empty
return ''
return text;
}