Pre-combat-rewrite: Backup current state before comprehensive combat frontend rewrite
This commit is contained in:
@@ -135,18 +135,24 @@ async def spawn_manager_loop(manager=None):
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
npc_def = NPCS.get(npc_id)
|
||||
npc_name = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
|
||||
npc_name_obj = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
|
||||
# Handle localized name for the fallback message
|
||||
if isinstance(npc_name_obj, dict):
|
||||
npc_name_en = npc_name_obj.get('en', str(npc_name_obj))
|
||||
else:
|
||||
npc_name_en = str(npc_name_obj)
|
||||
|
||||
await manager.send_to_location(
|
||||
location_id=location_id,
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"A {npc_name} appeared!",
|
||||
"message": f"A {npc_name_en} appeared!",
|
||||
"action": "enemy_spawned",
|
||||
"npc_data": {
|
||||
"id": enemy_data['id'],
|
||||
"npc_id": npc_id,
|
||||
"name": npc_name,
|
||||
"name": npc_name_obj,
|
||||
"type": "enemy",
|
||||
"is_wandering": True,
|
||||
"image_path": npc_def.image_path if npc_def else None
|
||||
@@ -209,7 +215,8 @@ async def decay_dropped_items(manager=None):
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{count} dropped item(s) decayed",
|
||||
"action": "items_decayed"
|
||||
"action": "items_decayed",
|
||||
"count": count
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -472,7 +479,8 @@ async def decay_corpses(manager=None):
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{total} {corpse_type} decayed",
|
||||
"action": "corpses_decayed"
|
||||
"action": "corpses_decayed",
|
||||
"count": total
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
|
||||
detail="No character selected. Please select a character first."
|
||||
)
|
||||
|
||||
player = await db.get_player_by_id(character_id)
|
||||
player = await db.get_character_by_id(character_id)
|
||||
if player is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -358,15 +358,7 @@ async def init_db():
|
||||
await conn.execute(text(index_sql))
|
||||
|
||||
|
||||
# Player operations
|
||||
async def get_player_by_id(player_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player by internal ID"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(players).where(players.c.id == player_id)
|
||||
)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
|
||||
async def get_player_by_username(username: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -421,13 +413,7 @@ async def create_player(
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def update_player(player_id: int, **kwargs) -> bool:
|
||||
"""Update player fields - OLD FUNCTION, use update_character instead"""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = update(characters).where(characters.c.id == player_id).values(**kwargs)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
|
||||
async def update_player_location(player_id: int, location_id: str) -> bool:
|
||||
|
||||
@@ -6,14 +6,15 @@ import random
|
||||
import time
|
||||
from typing import Dict, Any, Tuple, Optional, List
|
||||
from . import database as db
|
||||
from .services.helpers import get_locale_string, translate_travel_message, create_combat_message
|
||||
|
||||
|
||||
async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[bool, str, Optional[str], int, int]:
|
||||
async def move_player(player_id: int, direction: str, locations: Dict, locale: str = 'en') -> Tuple[bool, str, Optional[str], int, int]:
|
||||
"""
|
||||
Move player in a direction.
|
||||
Returns: (success, message, new_location_id, stamina_cost, distance_meters)
|
||||
"""
|
||||
player = await db.get_player_by_id(player_id)
|
||||
player = await db.get_character_by_id(player_id)
|
||||
if not player:
|
||||
return False, "Player not found", None, 0, 0
|
||||
|
||||
@@ -69,13 +70,15 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[
|
||||
return False, "You're too exhausted to move. Wait for your stamina to regenerate.", None, 0, 0
|
||||
|
||||
# Update player location and stamina
|
||||
await db.update_player(
|
||||
await db.update_character(
|
||||
player_id,
|
||||
location_id=new_location_id,
|
||||
stamina=max(0, player['stamina'] - stamina_cost)
|
||||
)
|
||||
|
||||
return True, f"You travel {direction} to {new_location.name}.", new_location_id, stamina_cost, distance
|
||||
translated_location = get_locale_string(new_location.name, locale)
|
||||
travel_message = translate_travel_message(direction, translated_location, locale)
|
||||
return True, travel_message, new_location_id, stamina_cost, distance
|
||||
|
||||
|
||||
async def inspect_area(player_id: int, location, interactables_data: Dict) -> str:
|
||||
@@ -216,7 +219,7 @@ async def interact_with_object(
|
||||
if not item:
|
||||
continue
|
||||
|
||||
item_name = item.name if item else item_id
|
||||
item_name = get_locale_string(item.name) if item else item_id
|
||||
emoji = item.emoji if item and hasattr(item, 'emoji') else ''
|
||||
|
||||
# Check if item has durability (unique item)
|
||||
@@ -237,7 +240,7 @@ async def interact_with_object(
|
||||
max_durability=item.durability,
|
||||
tier=getattr(item, 'tier', None)
|
||||
)
|
||||
items_found.append(f"{emoji} {item_name}")
|
||||
items_found.append(f"{emoji} {get_locale_string(item_name)}")
|
||||
current_weight += item.weight
|
||||
current_volume += item.volume
|
||||
else:
|
||||
@@ -252,7 +255,7 @@ async def interact_with_object(
|
||||
unique_stats=base_stats
|
||||
)
|
||||
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
|
||||
items_dropped.append(f"{emoji} {item_name}")
|
||||
items_dropped.append(f"{emoji} {get_locale_string(item_name)}")
|
||||
else:
|
||||
# Stackable items - process as before
|
||||
item_weight = item.weight * quantity
|
||||
@@ -262,13 +265,13 @@ async def interact_with_object(
|
||||
current_volume + item_volume <= max_volume):
|
||||
# Add to inventory
|
||||
await db.add_item_to_inventory(player_id, item_id, quantity)
|
||||
items_found.append(f"{emoji} {item_name} x{quantity}")
|
||||
items_found.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
|
||||
current_weight += item_weight
|
||||
current_volume += item_volume
|
||||
else:
|
||||
# Drop to ground
|
||||
await db.drop_item_to_world(item_id, quantity, player['location_id'])
|
||||
items_dropped.append(f"{emoji} {item_name} x{quantity}")
|
||||
items_dropped.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
|
||||
|
||||
# Apply damage
|
||||
if damage_taken > 0:
|
||||
@@ -283,7 +286,7 @@ async def interact_with_object(
|
||||
await db.set_interactable_cooldown(interactable_id, action_id, 60)
|
||||
|
||||
# Build message
|
||||
final_message = outcome.text
|
||||
final_message = get_locale_string(outcome.text)
|
||||
if items_dropped:
|
||||
final_message += f"\n⚠️ Inventory full! Dropped to ground: {', '.join(items_dropped)}"
|
||||
|
||||
@@ -565,7 +568,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
message = f"{npc_def.name} defends and recovers {heal_amount} HP!"
|
||||
message = f"{get_locale_string(npc_def.name)} defends and recovers {heal_amount} HP!"
|
||||
|
||||
elif intent_type == 'special':
|
||||
# Strong attack (1.5x damage)
|
||||
@@ -574,7 +577,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
message = f"{npc_def.name} uses a SPECIAL ATTACK for {npc_damage} damage!"
|
||||
message = f"{get_locale_string(npc_def.name)} uses a SPECIAL ATTACK for {npc_damage} damage!"
|
||||
if armor_absorbed > 0:
|
||||
message += f" (Armor absorbed {armor_absorbed})"
|
||||
|
||||
@@ -589,7 +592,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
||||
# Enrage bonus if NPC is below 30% HP
|
||||
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
message = f"{npc_def.name} is ENRAGED! "
|
||||
message = f"{get_locale_string(npc_def.name)} is ENRAGED! "
|
||||
else:
|
||||
message = ""
|
||||
|
||||
@@ -597,7 +600,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
message += f"{npc_def.name} attacks for {npc_damage} damage!"
|
||||
message += create_combat_message("enemy_attack", npc_name=npc_def.name, damage=npc_damage, armor_absorbed=armor_absorbed)
|
||||
if armor_absorbed > 0:
|
||||
message += f" (Armor absorbed {armor_absorbed})"
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
@@ -147,7 +147,7 @@ async def initiate_combat(
|
||||
await manager.send_personal_message(current_user['id'], {
|
||||
"type": "combat_started",
|
||||
"data": {
|
||||
"message": f"Combat started with {npc_def.name}!",
|
||||
"message": create_combat_message("combat_start", npc_name=npc_def.name),
|
||||
"combat": {
|
||||
"npc_id": enemy.npc_id,
|
||||
"npc_name": npc_def.name,
|
||||
@@ -167,7 +167,7 @@ async def initiate_combat(
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} entered combat with {npc_def.name}",
|
||||
"message": f"{player['name']} entered combat with {get_locale_string(npc_def.name)}",
|
||||
"action": "combat_started",
|
||||
"player_id": player['id']
|
||||
},
|
||||
@@ -178,7 +178,7 @@ async def initiate_combat(
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Combat started with {npc_def.name}!",
|
||||
"message": create_combat_message("combat_start", npc_name=npc_def.name),
|
||||
"combat": {
|
||||
"npc_id": enemy.npc_id,
|
||||
"npc_name": npc_def.name,
|
||||
@@ -304,7 +304,7 @@ async def combat_action(
|
||||
|
||||
if new_npc_hp <= 0:
|
||||
# NPC defeated
|
||||
result_message += f"{npc_def.name} has been defeated!"
|
||||
result_message += create_combat_message("victory", npc_name=npc_def.name)
|
||||
combat_over = True
|
||||
player_won = True
|
||||
|
||||
@@ -435,7 +435,7 @@ async def combat_action(
|
||||
# Failed to flee, NPC attacks
|
||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
new_player_hp = max(0, player['hp'] - npc_damage)
|
||||
result_message = f"Failed to flee! {npc_def.name} attacks for {npc_damage} damage!"
|
||||
result_message = create_combat_message("flee_fail", npc_name=npc_def.name, damage=npc_damage)
|
||||
|
||||
if new_player_hp <= 0:
|
||||
result_message += "\nYou have been defeated!"
|
||||
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost, get_locale_string
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
@@ -156,7 +156,7 @@ async def get_craftable_items(current_user: dict = Depends(get_current_user)):
|
||||
})
|
||||
|
||||
# Sort: craftable items first, then by tier, then by name
|
||||
craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name']))
|
||||
craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], get_locale_string(x['name'])))
|
||||
|
||||
return {'craftable_items': craftable_items}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Game Routes router.
|
||||
Auto-generated from main.py migration.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -391,8 +391,11 @@ async def spend_stat_point(
|
||||
|
||||
|
||||
@router.get("/api/game/location")
|
||||
async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
async def get_current_location(request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Get current location information"""
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
location_id = current_user['location_id']
|
||||
location = LOCATIONS.get(location_id)
|
||||
|
||||
@@ -682,7 +685,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
corpses_data.append({
|
||||
"id": f"npc_{corpse['id']}",
|
||||
"type": "npc",
|
||||
"name": f"{npc_def.name if npc_def else corpse['npc_id']} Corpse",
|
||||
"name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse",
|
||||
"emoji": "💀",
|
||||
"loot_count": len(loot),
|
||||
"timestamp": corpse['death_timestamp']
|
||||
@@ -719,6 +722,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
@router.post("/api/game/move")
|
||||
async def move(
|
||||
move_req: MoveRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Move player in a direction"""
|
||||
@@ -756,10 +760,14 @@ async def move(
|
||||
detail=f"You must wait {int(cooldown_remaining)} seconds before moving again."
|
||||
)
|
||||
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
success, message, new_location_id, stamina_cost, distance = await game_logic.move_player(
|
||||
current_user['id'],
|
||||
move_req.direction,
|
||||
LOCATIONS
|
||||
LOCATIONS,
|
||||
locale
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -951,9 +959,13 @@ async def inspect(current_user: dict = Depends(get_current_user)):
|
||||
@router.post("/api/game/interact")
|
||||
async def interact(
|
||||
interact_req: InteractRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Interact with an object"""
|
||||
"""Interact with an object in the game world"""
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Check if player is in combat
|
||||
combat = await db.get_active_combat(current_user['id'])
|
||||
if combat:
|
||||
@@ -1026,7 +1038,7 @@ async def interact(
|
||||
"instance_id": interact_req.interactable_id,
|
||||
"action_id": interact_req.action_id,
|
||||
"cooldown_remaining": cooldown_remaining,
|
||||
"message": f"{current_user['name']} used {action_display} on {interactable_name}"
|
||||
"message": f"{current_user['name']} used {get_locale_string(action_display, locale)} on {get_locale_string(interactable_name, locale)}"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -1035,6 +1047,8 @@ async def interact(
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/game/use_item")
|
||||
async def use_item(
|
||||
use_req: UseItemRequest,
|
||||
@@ -1159,15 +1173,19 @@ async def use_item(
|
||||
@router.post("/api/game/pickup")
|
||||
async def pickup(
|
||||
pickup_req: PickupItemRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Pick up an item from the ground"""
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Get item details for broadcast BEFORE picking it up (it will be removed from DB)
|
||||
# pickup_req.item_id is the dropped_item database ID, not the item_id string
|
||||
dropped_item = await db.get_dropped_item(pickup_req.item_id)
|
||||
if dropped_item:
|
||||
item_def = ITEMS_MANAGER.get_item(dropped_item['item_id'])
|
||||
item_name = item_def.name if item_def else dropped_item['item_id']
|
||||
item_name = get_locale_string(item_def.name, locale) if item_def else dropped_item['item_id']
|
||||
else:
|
||||
item_name = "item"
|
||||
|
||||
@@ -1392,5 +1410,5 @@ async def drop_item(
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Dropped {item_def.emoji} {item_def.name} x{quantity}"
|
||||
"message": f"Dropped {item_def.emoji} {get_locale_string(item_def.name)} x{quantity}"
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
@@ -310,13 +310,13 @@ async def loot_corpse(
|
||||
message_parts = []
|
||||
for item in looted_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
|
||||
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||
|
||||
dropped_parts = []
|
||||
for item in dropped_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
|
||||
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||
|
||||
message = ""
|
||||
@@ -438,13 +438,13 @@ async def loot_corpse(
|
||||
message_parts = []
|
||||
for item in looted_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
|
||||
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||
|
||||
dropped_parts = []
|
||||
for item in dropped_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
|
||||
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||
|
||||
message = ""
|
||||
|
||||
@@ -15,6 +15,45 @@ def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> st
|
||||
return str(value)
|
||||
|
||||
|
||||
# Translation maps for backend messages
|
||||
DIRECTION_TRANSLATIONS = {
|
||||
'north': {'en': 'north', 'es': 'norte'},
|
||||
'south': {'en': 'south', 'es': 'sur'},
|
||||
'east': {'en': 'east', 'es': 'este'},
|
||||
'west': {'en': 'west', 'es': 'oeste'},
|
||||
'northeast': {'en': 'northeast', 'es': 'noreste'},
|
||||
'northwest': {'en': 'northwest', 'es': 'noroeste'},
|
||||
'southeast': {'en': 'southeast', 'es': 'sureste'},
|
||||
'southwest': {'en': 'southwest', 'es': 'suroeste'},
|
||||
}
|
||||
|
||||
def translate_travel_message(direction: str, location_name: str, lang: str = 'en') -> str:
|
||||
"""Translate a travel message to the user's language."""
|
||||
dir_translated = DIRECTION_TRANSLATIONS.get(direction, {}).get(lang, direction)
|
||||
|
||||
if lang == 'es':
|
||||
return f"Viajas al {dir_translated} hacia {location_name}."
|
||||
else:
|
||||
return f"You travel {dir_translated} to {location_name}."
|
||||
|
||||
|
||||
import json
|
||||
|
||||
def create_combat_message(message_type: str, **data) -> str:
|
||||
"""Create a structured combat message with type and data.
|
||||
|
||||
Args:
|
||||
message_type: Type of combat message (combat_start, player_attack, etc.)
|
||||
**data: Dynamic data for the message (damage, npc_name, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary with 'type' and 'data' fields
|
||||
"""
|
||||
return json.dumps({
|
||||
"type": message_type,
|
||||
"data": data
|
||||
})
|
||||
|
||||
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
|
||||
"""
|
||||
Calculate distance between two points using Euclidean distance.
|
||||
|
||||
Reference in New Issue
Block a user