first commit
This commit is contained in:
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
BIN
api/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
api/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/admin.cpython-312.pyc
Normal file
BIN
api/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/apps.cpython-312.pyc
Normal file
BIN
api/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/data.cpython-312.pyc
Normal file
BIN
api/__pycache__/data.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/models.cpython-312.pyc
Normal file
BIN
api/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/serializers.cpython-312.pyc
Normal file
BIN
api/__pycache__/serializers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/tests.cpython-312.pyc
Normal file
BIN
api/__pycache__/tests.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/urls.cpython-312.pyc
Normal file
BIN
api/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/views.cpython-312.pyc
Normal file
BIN
api/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
3
api/admin.py
Normal file
3
api/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
api/apps.py
Normal file
5
api/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = 'api'
|
||||
51
api/data.py
Normal file
51
api/data.py
Normal 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
|
||||
1
api/management/__init__.py
Normal file
1
api/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"" # Package marker
|
||||
BIN
api/management/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
api/management/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
1
api/management/commands/__init__.py
Normal file
1
api/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"" # Package marker
|
||||
BIN
api/management/commands/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
api/management/commands/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
104
api/management/commands/load_static_data.py
Normal file
104
api/management/commands/load_static_data.py
Normal 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."
|
||||
)
|
||||
)
|
||||
31
api/migrations/0001_initial.py
Normal file
31
api/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
25
api/migrations/0003_aspect.py
Normal file
25
api/migrations/0003_aspect.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
api/migrations/__init__.py
Normal file
0
api/migrations/__init__.py
Normal file
BIN
api/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
BIN
api/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
api/migrations/__pycache__/0003_aspect.cpython-312.pyc
Normal file
BIN
api/migrations/__pycache__/0003_aspect.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
api/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
37
api/models.py
Normal file
37
api/models.py
Normal 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
38
api/serializers.py
Normal 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
69
api/tests.py
Normal 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
8
api/urls.py
Normal 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
103
api/views.py
Normal 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."
|
||||
Reference in New Issue
Block a user