Initial commit

This commit is contained in:
Maxim
2025-12-16 22:16:45 +03:00
commit bf03750e76
64 changed files with 7619 additions and 0 deletions

View File

View File

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

View File

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

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

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

@@ -0,0 +1,25 @@
# Generated by Django 6.0 on 2025-12-16 18:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0003_aspect'),
]
operations = [
migrations.CreateModel(
name='BuildOfDay',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(unique=True)),
('skill_build', models.JSONField(default=dict)),
('aspect', models.CharField(blank=True, max_length=200, null=True)),
('hero', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.hero')),
('items', models.ManyToManyField(to='api.item')),
],
),
]

View File

@@ -0,0 +1,48 @@
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}"
class BuildOfDay(models.Model):
date = models.DateField(unique=True)
hero = models.ForeignKey(Hero, on_delete=models.CASCADE)
items = models.ManyToManyField(Item)
skill_build = models.JSONField(default=dict)
aspect = models.CharField(max_length=200, blank=True, null=True)
def __str__(self) -> str: # pragma: no cover - convenience only
return f"Build of {self.date} - {self.hero.name}"

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)

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)

View File

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

View File

@@ -0,0 +1,164 @@
from datetime import date
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from api.models import Aspect, BuildOfDay, 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."
class BuildOfDayView(APIView):
"""
GET: Return the build of the day. Generates one if it doesn't exist for today.
"""
ITEMS_COUNT = 6
def get(self, request):
today = date.today()
build = BuildOfDay.objects.filter(date=today).first()
if not build:
build = self._generate_build_of_day(today)
if build is None:
return Response(
{"message": "Unable to generate build. Load data first."},
status=status.HTTP_400_BAD_REQUEST,
)
response_data = {
"date": build.date.isoformat(),
"hero": HeroSerializer(build.hero).data,
"items": ItemSerializer(build.items.all(), many=True).data,
"skillBuild": build.skill_build,
}
if build.aspect:
response_data["aspect"] = build.aspect
return Response(response_data, status=status.HTTP_200_OK)
def _generate_build_of_day(self, today: date) -> BuildOfDay | None:
"""Generate and save a new build of the day."""
hero_count = Hero.objects.count()
item_count = Item.objects.count()
if hero_count == 0 or item_count < self.ITEMS_COUNT:
return None
hero_obj = Hero.objects.order_by("?").first()
item_objs = list(Item.objects.order_by("?")[: self.ITEMS_COUNT])
aspect_name = None
hero_aspects = Aspect.objects.filter(hero=hero_obj)
if hero_aspects.exists():
aspect_obj = hero_aspects.order_by("?").first()
aspect_name = aspect_obj.name
build = BuildOfDay.objects.create(
date=today,
hero=hero_obj,
skill_build=generate_skill_build(),
aspect=aspect_name,
)
build.items.set(item_objs)
return build