first commit

This commit is contained in:
Maxim
2025-12-11 18:15:56 +03:00
commit d451ca7d3a
6071 changed files with 786794 additions and 0 deletions

0
api/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

3
api/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
api/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = 'api'

51
api/data.py Normal file
View File

@@ -0,0 +1,51 @@
import random
from typing import Dict
def generate_skill_build() -> Dict[int, str]:
"""
Generate a random skill build for levels 1-25 following Dota 2 rules:
- Ultimate (r) can only be skilled at levels 6, 12, 18
- Talents can only be skilled at levels 10, 15, 20, 25
- Basic abilities (q, w, e) can be skilled at other levels
- Each basic ability maxes at 4 points
- Ultimate maxes at 3 points
- One talent per tier (left OR right)
"""
skill_build: Dict[int, str] = {}
# Track ability points spent
ability_points = {"q": 0, "w": 0, "e": 0, "r": 0}
max_points = {"q": 4, "w": 4, "e": 4, "r": 3}
# Talent levels and which side to pick
talent_levels = [10, 15, 20, 25]
# Ultimate levels
ult_levels = [6, 12, 18]
for level in range(1, 26):
if level in talent_levels:
# Pick random talent side
skill_build[level] = random.choice(["left_talent", "right_talent"])
elif level in ult_levels:
# Must skill ultimate if not maxed
if ability_points["r"] < max_points["r"]:
skill_build[level] = "r"
ability_points["r"] += 1
else:
# Ultimate maxed, pick a basic ability
available = [a for a in ["q", "w", "e"] if ability_points[a] < max_points[a]]
if available:
choice = random.choice(available)
skill_build[level] = choice
ability_points[choice] += 1
else:
# Regular level - pick a basic ability
available = [a for a in ["q", "w", "e"] if ability_points[a] < max_points[a]]
if available:
choice = random.choice(available)
skill_build[level] = choice
ability_points[choice] += 1
return skill_build

View File

@@ -0,0 +1 @@
"" # Package marker

Binary file not shown.

View File

@@ -0,0 +1 @@
"" # Package marker

View File

@@ -0,0 +1,104 @@
import re
import sys
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from api.models import Aspect, Hero, Item
# Add data directory to path for imports
sys.path.insert(0, str(Path(settings.BASE_DIR) / "data"))
from facets import HERO_FACETS
def _strip_comments(text: str) -> str:
"""Remove single-line and block comments from TypeScript data."""
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
lines = []
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("//"):
continue
lines.append(line)
return "\n".join(lines)
def _extract_objects(text: str, require_primary: bool = False):
cleaned = _strip_comments(text)
objects = []
for match in re.finditer(r"\{[^}]*\}", cleaned):
chunk = match.group(0)
entry = {}
id_match = re.search(r"['\"]?id['\"]?\s*:\s*(['\"])(.*?)\1", chunk)
name_match = re.search(r"['\"]?name['\"]?\s*:\s*(['\"])(.*?)\1", chunk)
if not (id_match and name_match):
continue
entry["slug"] = id_match.group(2)
entry["name"] = name_match.group(2)
if require_primary:
primary_match = re.search(r"['\"]?primary['\"]?\s*:\s*(['\"])(.*?)\1", chunk)
if not primary_match:
continue
entry["primary"] = primary_match.group(2)
if len(entry) == (3 if require_primary else 2):
objects.append(entry)
return objects
class Command(BaseCommand):
help = "Load heroes, items and facets from data files into the database."
def handle(self, *args, **options):
base_dir = Path(settings.BASE_DIR)
heroes_path = base_dir / "data" / "heroes.ts"
items_path = base_dir / "data" / "items.ts"
if not heroes_path.exists() or not items_path.exists():
raise CommandError("Missing data files in the ./data directory.")
heroes = _extract_objects(heroes_path.read_text(encoding="utf-8"), require_primary=True)
items = _extract_objects(items_path.read_text(encoding="utf-8"), require_primary=False)
if not heroes:
raise CommandError("Failed to parse heroes.ts")
if not items:
raise CommandError("Failed to parse items.ts")
hero_created = item_created = aspect_created = 0
for hero in heroes:
hero_obj, created = Hero.objects.update_or_create(
name=hero["name"],
defaults={
"primary": hero["primary"],
},
)
hero_created += int(created)
# Load facets for this hero
facets = HERO_FACETS.get(hero["name"], [])
for facet_name in facets:
_, facet_created = Aspect.objects.update_or_create(
hero=hero_obj,
name=facet_name,
)
aspect_created += int(facet_created)
for item in items:
_, created = Item.objects.update_or_create(
name=item["name"],
)
item_created += int(created)
self.stdout.write(
self.style.SUCCESS(
f"Loaded {len(heroes)} heroes ({hero_created} new), "
f"{len(items)} items ({item_created} new), "
f"and {aspect_created} new facets."
)
)

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0 on 2025-12-09 03:20
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Hero',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_id', models.CharField(max_length=100, unique=True)),
('name', models.CharField(max_length=200)),
('primary', models.CharField(choices=[('Strength', 'Strength'), ('Agility', 'Agility'), ('Intelligence', 'Intelligence')], max_length=20)),
],
),
migrations.CreateModel(
name='Item',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_id', models.CharField(max_length=100, unique=True)),
('name', models.CharField(max_length=200)),
],
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 6.0 on 2025-12-09 03:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('api', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='hero',
name='external_id',
),
migrations.RemoveField(
model_name='item',
name='external_id',
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0 on 2025-12-09 18:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0002_remove_hero_external_id_remove_item_external_id'),
]
operations = [
migrations.CreateModel(
name='Aspect',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('hero', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aspects', to='api.hero')),
],
options={
'unique_together': {('hero', 'name')},
},
),
]

View File

Binary file not shown.

Binary file not shown.

37
api/models.py Normal file
View File

@@ -0,0 +1,37 @@
from django.db import models
class Hero(models.Model):
PRIMARY_ATTR_STRENGTH = "Strength"
PRIMARY_ATTR_AGILITY = "Agility"
PRIMARY_ATTR_INTELLIGENCE = "Intelligence"
PRIMARY_CHOICES = (
(PRIMARY_ATTR_STRENGTH, "Strength"),
(PRIMARY_ATTR_AGILITY, "Agility"),
(PRIMARY_ATTR_INTELLIGENCE, "Intelligence"),
)
name = models.CharField(max_length=200)
primary = models.CharField(max_length=20, choices=PRIMARY_CHOICES)
def __str__(self) -> str: # pragma: no cover - convenience only
return f"{self.name} ({self.primary})"
class Item(models.Model):
name = models.CharField(max_length=200)
def __str__(self) -> str: # pragma: no cover - convenience only
return self.name
class Aspect(models.Model):
hero = models.ForeignKey(Hero, on_delete=models.CASCADE, related_name="aspects")
name = models.CharField(max_length=200)
class Meta:
unique_together = ["hero", "name"]
def __str__(self) -> str: # pragma: no cover - convenience only
return f"{self.hero.name} - {self.name}"

38
api/serializers.py Normal file
View File

@@ -0,0 +1,38 @@
from rest_framework import serializers
from api.models import Hero, Item
class HeroSerializer(serializers.ModelSerializer):
primary = serializers.SerializerMethodField()
class Meta:
model = Hero
fields = ["id", "name", "primary"]
def get_primary(self, obj):
return obj.primary.lower()
class ItemSerializer(serializers.ModelSerializer):
class Meta:
model = Item
fields = ["id", "name"]
class RandomizeBuildRequestSerializer(serializers.Serializer):
includeSkills = serializers.BooleanField()
includeAspect = serializers.BooleanField()
itemsCount = serializers.IntegerField(min_value=1)
heroId = serializers.IntegerField(required=False, allow_null=True)
class RandomizeBuildResponseSerializer(serializers.Serializer):
hero = HeroSerializer()
items = ItemSerializer(many=True)
skillBuild = serializers.DictField(
child=serializers.CharField(),
required=False,
help_text="Map of level (1-25) to skill (q/w/e/r/left_talent/right_talent)"
)
aspect = serializers.CharField(required=False)

69
api/tests.py Normal file
View File

@@ -0,0 +1,69 @@
from django.core.management import call_command
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APIClient
from api.models import Hero, Item
class RandomizeBuildAPITest(TestCase):
def setUp(self):
self.client = APIClient()
self.hero = Hero.objects.create(name="Axe", primary="Strength")
Item.objects.bulk_create(
[
Item(name="Blink Dagger"),
Item(name="Black King Bar"),
Item(name="Boots of Travel"),
Item(name="Force Staff"),
]
)
def test_randomize_with_skills(self):
response = self.client.post(
"/api/randomize",
{"includeSkills": True, "includeAspect": False, "itemsCount": 3},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIn("hero", data)
self.assertEqual(len(data.get("items", [])), 3)
self.assertIn("skillBuild", data)
self.assertTrue(data.get("skillBuilds"))
self.assertNotIn("aspect", data)
def test_randomize_with_aspect_only(self):
response = self.client.post(
"/api/randomize",
{"includeSkills": False, "includeAspect": True, "itemsCount": 2},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIn("aspect", data)
self.assertTrue(data.get("aspects"))
self.assertNotIn("skillBuild", data)
self.assertEqual(len(data.get("items", [])), 2)
def test_rejects_invalid_payload(self):
response = self.client.post(
"/api/randomize",
{"includeSkills": "yes", "includeAspect": True, "itemsCount": 0},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("message", response.json())
class LoadStaticDataCommandTest(TestCase):
def test_loads_heroes_and_items(self):
call_command("load_static_data")
self.assertGreater(Hero.objects.count(), 0)
self.assertGreater(Item.objects.count(), 0)

8
api/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from .views import HeroesListView, RandomizeBuildView
urlpatterns = [
path("randomize", RandomizeBuildView.as_view(), name="randomize-build"),
path("heroes", HeroesListView.as_view(), name="heroes-list"),
]

103
api/views.py Normal file
View File

@@ -0,0 +1,103 @@
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from api.models import Aspect, Hero, Item
from api.serializers import (
HeroSerializer,
ItemSerializer,
RandomizeBuildRequestSerializer,
)
from .data import generate_skill_build
class HeroesListView(APIView):
"""
GET: Return all available heroes for selection.
"""
def get(self, request):
heroes = Hero.objects.all().order_by("name")
return Response(HeroSerializer(heroes, many=True).data, status=status.HTTP_200_OK)
class RandomizeBuildView(APIView):
"""
POST: Generate a random Dota 2 build with hero, items, and optionally skills/aspects.
"""
def get_serializer(self, *args, **kwargs):
return RandomizeBuildRequestSerializer(*args, **kwargs)
def post(self, request):
serializer = RandomizeBuildRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"message": self._format_errors(serializer.errors)},
status=status.HTTP_400_BAD_REQUEST,
)
validated_data = serializer.validated_data
include_skills = validated_data["includeSkills"]
include_aspect = validated_data["includeAspect"]
items_count = validated_data["itemsCount"]
hero_id = validated_data.get("heroId")
hero_count = Hero.objects.count()
item_count = Item.objects.count()
if hero_count == 0:
return Response(
{"message": "No heroes available. Load data first."},
status=status.HTTP_400_BAD_REQUEST,
)
if item_count < items_count:
return Response(
{"message": f"Not enough items available. Requested {items_count}, found {item_count}."},
status=status.HTTP_400_BAD_REQUEST,
)
if hero_id:
hero_obj = Hero.objects.filter(id=hero_id).first()
if not hero_obj:
return Response(
{"message": f"Hero with id {hero_id} not found."},
status=status.HTTP_400_BAD_REQUEST,
)
else:
hero_obj = Hero.objects.order_by("?").first()
item_objs = Item.objects.order_by("?")[:items_count]
response_data = {
"hero": HeroSerializer(hero_obj).data,
"items": ItemSerializer(item_objs, many=True).data,
}
if include_skills:
response_data["skillBuild"] = generate_skill_build()
if include_aspect:
hero_aspects = Aspect.objects.filter(hero=hero_obj)
if hero_aspects.exists():
aspect_obj = hero_aspects.order_by("?").first()
response_data["aspect"] = aspect_obj.name
return Response(response_data, status=status.HTTP_200_OK)
def _format_errors(self, errors: dict) -> str:
"""Format serializer errors into a readable message."""
messages = []
for field, field_errors in errors.items():
for error in field_errors:
if field == "itemsCount" and "greater than or equal to" in str(error):
messages.append("itemsCount must be at least 1.")
elif "valid" in str(error).lower() or "required" in str(error).lower():
if field in ("includeSkills", "includeAspect"):
messages.append("includeSkills and includeAspect must be boolean.")
elif field == "itemsCount":
messages.append("itemsCount must be a number.")
else:
messages.append(f"{field}: {error}")
return messages[0] if messages else "Invalid request data."